[i18n][system upgrade] Upgrade i18n tooling (#186519)

Update i18n tools after the main packages upgrade. This upgrade makes
use of formatJS tooling instead of fully implementing the parsers
ourselves. It also changes our custom AST parsing from babel to the
typescript compiler.
- [x] i18n exrtract
- [x] i18n check
- [x] i18n integrate
- [x] add test cases for formatjs runner
- [x] Make sure all CLI flags are handled properly
- [x] Update tooling readme

Closes https://github.com/elastic/kibana/issues/180616
Closes https://github.com/elastic/kibana/issues/187703

### Note to reviewers

Teams outside operations and core are probably requested to review
because the `i18n_check` fixed malformed i18n messages in your plugins.
Please check and approve :elasticheart:
This commit is contained in:
Ahmad Bamieh 2024-07-16 21:47:54 +01:00 committed by GitHub
parent 026f68858c
commit 7c6aa3fc8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 24144 additions and 24484 deletions

View file

@ -5,4 +5,4 @@ set -euo pipefail
source .buildkite/scripts/common/util.sh
echo --- Check i18n
node scripts/i18n_check --ignore-missing
node scripts/i18n_check

2
.github/CODEOWNERS vendored
View file

@ -1293,7 +1293,7 @@ x-pack/test_serverless/api_integration/test_suites/common/security_response_head
x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kibana-core @elastic/kibana-telemetry @shahinakmal
# Kibana Localization
/src/dev/i18n/ @elastic/kibana-localization @elastic/kibana-core
/src/dev/i18n_tools/ @elastic/kibana-localization @elastic/kibana-core
/src/core/public/i18n/ @elastic/kibana-localization @elastic/kibana-core
#CC# /x-pack/plugins/translations/ @elastic/kibana-localization @elastic/kibana-core

View file

@ -41,7 +41,7 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
);
},
getDisplayName: () =>
i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
i18n.translate('embeddableExamples.euiMarkdownEditor.displayNameAriaLabel', {
defaultMessage: 'EUI Markdown',
}),
});

View file

@ -87,7 +87,7 @@ export const markdownEmbeddableFactory: ReactEmbeddableFactory<
`}
value={content ?? ''}
onChange={(value) => content$.next(value)}
aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.embeddableAriaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
height="full"

View file

@ -131,6 +131,7 @@
"@formatjs/intl-pluralrules": "^5.2.12",
"@formatjs/intl-relativetimeformat": "^11.2.12",
"@formatjs/intl-utils": "^3.8.4",
"@formatjs/ts-transformer": "^3.13.14",
"@grpc/grpc-js": "^1.8.22",
"@hapi/accept": "^5.0.2",
"@hapi/boom": "^9.1.4",

View file

@ -190,7 +190,7 @@ export function Header({
button={
<HeaderMenuButton
data-test-subj="toggleNavButton"
aria-label={i18n.translate('core.ui.primaryNav.toggleNavAriaLabel', {
aria-label={i18n.translate('core.ui.primaryNav.header.toggleNavAriaLabel', {
defaultMessage: 'Toggle primary navigation',
})}
onClick={() => setIsNavOpen(!isNavOpen)}

View file

@ -104,7 +104,7 @@ const headerStrings = {
}),
},
nav: {
closeNavAriaLabel: i18n.translate('core.ui.primaryNav.toggleNavAriaLabel', {
closeNavAriaLabel: i18n.translate('core.ui.primaryNav.project.toggleNavAriaLabel', {
defaultMessage: 'Toggle primary navigation',
}),
},

View file

@ -221,7 +221,6 @@ export class RenderingService {
strictCsp: http.csp.strict,
uiPublicUrl: `${staticAssetsHrefBase}/ui`,
bootstrapScriptUrl: `${basePath}/${bootstrapScript}`,
i18n: i18nLib.translate,
locale,
themeVersion,
darkMode,

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { ThemeVersion } from '@kbn/ui-shared-deps-npm';
import type { InjectedMetadata } from '@kbn/core-injected-metadata-common-internal';
import type { KibanaRequest, ICspConfig } from '@kbn/core-http-server';
@ -30,7 +29,6 @@ export interface RenderingMetadata {
strictCsp: ICspConfig['strict'];
uiPublicUrl: string;
bootstrapScriptUrl: string;
i18n: typeof i18n.translate;
locale: string;
themeVersion: ThemeVersion;
darkMode: DarkModeValue;

View file

@ -8,6 +8,7 @@
import React, { FunctionComponent, createElement } from 'react';
import { EUI_STYLES_GLOBAL, EUI_STYLES_UTILS } from '@kbn/core-base-common';
import { i18n } from '@kbn/i18n';
import { RenderingMetadata } from '../types';
import { Fonts } from './fonts';
import { Logo } from './logo';
@ -25,7 +26,6 @@ export const Template: FunctionComponent<Props> = ({
stylesheetPaths,
scriptPaths,
injectedMetadata,
i18n,
bootstrapScriptUrl,
strictCsp,
customBranding,
@ -80,18 +80,18 @@ export const Template: FunctionComponent<Props> = ({
{logo}
<div
className="kbnWelcomeText"
data-error-message-title={i18n('core.ui.welcomeErrorMessageTitle', {
data-error-message-title={i18n.translate('core.ui.welcomeErrorMessageTitle', {
defaultMessage: 'Elastic did not load properly',
})}
data-error-message-text={i18n('core.ui.welcomeErrorMessageText', {
data-error-message-text={i18n.translate('core.ui.welcomeErrorMessageText', {
defaultMessage:
'Please reload this page. If the issue persists, check the browser console and server logs.',
})}
data-error-message-reload={i18n('core.ui.welcomeErrorReloadButton', {
data-error-message-reload={i18n.translate('core.ui.welcomeErrorReloadButton', {
defaultMessage: 'Reload',
})}
>
{i18n('core.ui.welcomeMessage', {
{i18n.translate('core.ui.welcomeMessage', {
defaultMessage: 'Loading Elastic',
})}
</div>
@ -103,12 +103,12 @@ export const Template: FunctionComponent<Props> = ({
{logo}
<h2 className="kbnWelcomeTitle">
{i18n('core.ui.legacyBrowserTitle', {
{i18n.translate('core.ui.legacyBrowserTitle', {
defaultMessage: 'Please upgrade your browser',
})}
</h2>
<div className="kbnWelcomeText">
{i18n('core.ui.legacyBrowserMessage', {
{i18n.translate('core.ui.legacyBrowserMessage', {
defaultMessage:
'This Elastic installation has strict security requirements enabled that your current browser does not meet.',
})}

View file

@ -15,7 +15,6 @@ import {
handleIntlError,
getIsInitialized,
} from './src/core';
import { polyfillLocale } from './src/polyfills';
import {
registerTranslationFile,
@ -43,7 +42,6 @@ const i18nLoader = {
getAllTranslations,
getAllTranslationsFromPaths,
getRegisteredLocales: getRegisteredLocalesForLoader,
polyfillLocale,
};
export type { Translation, TranslationInput } from './src/translation';

View file

@ -9,12 +9,17 @@
import { OnErrorFn, IntlErrorCode } from '@formatjs/intl';
export const handleIntlError: OnErrorFn = (error) => {
// Dont throw on missing translations.
/**
* log any intl error except missing translations
* we do not log on missing translations because our process
* of translations happens after developers have commited their
* strings in english.
*
* Previously we used to throw an error on malformed strings but
* restricting to logging is less risky in cases of errors
* for better UX (ie seeing `InvalidDate` is better than an empty screen)
*/
if (error.code !== IntlErrorCode.MISSING_TRANSLATION) {
// eslint-disable-next-line no-console
console.error(
'Error Parsing translation string. This will start throwing an error once the i18n package tooling is upgraded.'
);
// eslint-disable-next-line no-console
console.error(error);
}

View file

@ -1,44 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { shouldPolyfill as shouldPolyfillPluralRules } from '@formatjs/intl-pluralrules/should-polyfill';
import { shouldPolyfill as shouldPolyfillRelativetimeFormat } from '@formatjs/intl-relativetimeformat/should-polyfill';
// formatJS polyfills docs: https://formatjs.io/docs/polyfills/intl-pluralrules/
export async function polyfillLocale(locale: string) {
await Promise.all([
maybePolyfillPluralRules(locale),
maybePolyfillRelativetimeformatRules(locale),
]);
}
async function maybePolyfillPluralRules(locale: string) {
const unsupportedLocale = shouldPolyfillPluralRules(locale);
// This locale is supported
if (!unsupportedLocale) {
return;
}
// Load the polyfill 1st BEFORE loading data
await import('@formatjs/intl-pluralrules/polyfill-force');
await import(`@formatjs/intl-pluralrules/locale-data/${unsupportedLocale}`);
}
async function maybePolyfillRelativetimeformatRules(locale: string) {
const unsupportedLocale = shouldPolyfillRelativetimeFormat(locale);
// This locale is supported
if (!unsupportedLocale) {
return;
}
// Load the polyfill 1st BEFORE loading data
await import('@formatjs/intl-relativetimeformat/polyfill-force');
await import(`@formatjs/intl-relativetimeformat/locale-data/${unsupportedLocale}`);
}

View file

@ -4577,6 +4577,7 @@ ROW ver = CONCAT(("0"::INT + 1)::STRING, ".2.3")::VERSION
`,
description:
'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)',
ignoreTag: true,
}
)}
/>

View file

@ -7,4 +7,4 @@
*/
require('../src/setup_node_env');
require('../src/dev/run_i18n_check');
require('../src/dev/i18n_tools/bin/run_i18n_check');

View file

@ -7,4 +7,4 @@
*/
require('../src/setup_node_env');
require('../src/dev/run_i18n_extract');
require('../src/dev/i18n_tools/bin/run_i18n_extract');

View file

@ -7,4 +7,4 @@
*/
require('../src/setup_node_env');
require('../src/dev/run_i18n_integrate');
require('../src/dev/i18n_tools/bin/run_i18n_integrate');

View file

@ -1,22 +0,0 @@
/* eslint-disable */
// @kbn/i18n
i18n.translate('plugin_1.id_1', {
defaultMessage: 'Message 1',
description: 'Message description',
});
// React component. FormattedMessage, Intl.formatMessage()
class Component extends PureComponent {
render() {
return (
<div>
<FormattedMessage
id="plugin_1.id_2"
defaultMessage="Message 2"
/>
{intl.formatMessage({ id: 'plugin_1.id_3', defaultMessage: 'Message 3' })}
</div>
);
}
}

View file

@ -1,8 +0,0 @@
/* eslint-disable */
i18n.translate('plugin_2.duplicate_id', { defaultMessage: 'Message 1' });
i18n.translate('plugin_2.duplicate_id', {
defaultMessage: 'Message 2',
description: 'Message description',
});

View file

@ -1,8 +0,0 @@
/* eslint-disable */
i18n('plugin_3.duplicate_id', { defaultMessage: 'Message 1' });
i18n.translate('plugin_3.duplicate_id', {
defaultMessage: 'Message 2',
description: 'Message description',
});

View file

@ -1,63 +0,0 @@
{
"formats": {
"number": {
"currency": {
"style": "currency"
},
"percent": {
"style": "percent"
}
},
"date": {
"short": {
"month": "numeric",
"day": "numeric",
"year": "2-digit"
},
"medium": {
"month": "short",
"day": "numeric",
"year": "numeric"
},
"long": {
"month": "long",
"day": "numeric",
"year": "numeric"
},
"full": {
"weekday": "long",
"month": "long",
"day": "numeric",
"year": "numeric"
}
},
"time": {
"short": {
"hour": "numeric",
"minute": "numeric"
},
"medium": {
"hour": "numeric",
"minute": "numeric",
"second": "numeric"
},
"long": {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short"
},
"full": {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short"
}
}
},
"messages": {
"plugin-1.message-id-1": "Translated text 1",
"plugin-1.message-id-2": "Translated text 2",
"plugin-2.message-id": "Translated text"
}
}

View file

@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/extract_default_translations extracts messages from path to map 1`] = `
Array [
Array [
"plugin_1.id_1",
Object {
"description": "Message description",
"message": "Message 1",
},
],
Array [
"plugin_1.id_2",
Object {
"description": undefined,
"message": "Message 2",
},
],
Array [
"plugin_1.id_3",
Object {
"description": undefined,
"message": "Message 3",
},
],
]
`;
exports[`dev/i18n/extract_default_translations throws on id collision 1`] = `
Array [
" I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx
Error: There is more than one default message for the same id \\"plugin_2.duplicate_id\\":
\\"Message 1\\" and \\"Message 2\\"",
]
`;
exports[`dev/i18n/extract_default_translations throws on wrong message namespace 1`] = `
Array [
Array [
[Error: Expected "wrong_plugin_namespace.message-id" id to have "plugin_2" namespace. See .i18nrc.json for the list of supported namespaces.],
],
]
`;

View file

@ -1,171 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/integrate_locale_files integrateLocaleFiles splits locale file by plugins and writes them into the right folders 1`] = `
Array [
"src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_1/translations/fr.json",
"{
\\"formats\\": {
\\"number\\": {
\\"currency\\": {
\\"style\\": \\"currency\\"
},
\\"percent\\": {
\\"style\\": \\"percent\\"
}
},
\\"date\\": {
\\"short\\": {
\\"month\\": \\"numeric\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"2-digit\\"
},
\\"medium\\": {
\\"month\\": \\"short\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
},
\\"long\\": {
\\"month\\": \\"long\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
},
\\"full\\": {
\\"weekday\\": \\"long\\",
\\"month\\": \\"long\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
}
},
\\"time\\": {
\\"short\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\"
},
\\"medium\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\"
},
\\"long\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\",
\\"timeZoneName\\": \\"short\\"
},
\\"full\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\",
\\"timeZoneName\\": \\"short\\"
}
}
},
\\"messages\\": {
\\"plugin-1.message-id-1\\": \\"Translated text 1\\",
\\"plugin-1.message-id-2\\": \\"Translated text 2\\"
}
}
",
]
`;
exports[`dev/i18n/integrate_locale_files integrateLocaleFiles splits locale file by plugins and writes them into the right folders 2`] = `
Array [
"src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_2/translations/fr.json",
"{
\\"formats\\": {
\\"number\\": {
\\"currency\\": {
\\"style\\": \\"currency\\"
},
\\"percent\\": {
\\"style\\": \\"percent\\"
}
},
\\"date\\": {
\\"short\\": {
\\"month\\": \\"numeric\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"2-digit\\"
},
\\"medium\\": {
\\"month\\": \\"short\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
},
\\"long\\": {
\\"month\\": \\"long\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
},
\\"full\\": {
\\"weekday\\": \\"long\\",
\\"month\\": \\"long\\",
\\"day\\": \\"numeric\\",
\\"year\\": \\"numeric\\"
}
},
\\"time\\": {
\\"short\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\"
},
\\"medium\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\"
},
\\"long\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\",
\\"timeZoneName\\": \\"short\\"
},
\\"full\\": {
\\"hour\\": \\"numeric\\",
\\"minute\\": \\"numeric\\",
\\"second\\": \\"numeric\\",
\\"timeZoneName\\": \\"short\\"
}
}
},
\\"messages\\": {
\\"plugin-2.message-id\\": \\"Translated text\\"
}
}
",
]
`;
exports[`dev/i18n/integrate_locale_files integrateLocaleFiles splits locale file by plugins and writes them into the right folders 3`] = `
Array [
"src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_1/translations",
"src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_2/translations",
]
`;
exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 1`] = `
"
1 missing translation(s):
plugin-1.message-id-2"
`;
exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 2`] = `
"
1 unused translation(s):
plugin-1.message-id-3"
`;
exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 3`] = `
"
1 unused translation(s):
plugin-2.message
1 missing translation(s):
plugin-2.message-id"
`;
exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 4`] = `
"
Incompatible translation: some properties are missing in \\"values\\" object (\\"plugin-1.message-id-2\\"): [value].
"
`;

View file

@ -1,107 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import path from 'path';
import globby from 'globby';
import { extractCodeMessages } from './extractors';
import { readFileAsync, normalizePath } from './utils';
import { createFailError, isFailError } from '@kbn/dev-cli-errors';
function addMessageToMap(targetMap, key, value, reporter) {
const existingValue = targetMap.get(key);
if (targetMap.has(key) && existingValue.message !== value.message) {
reporter.report(
createFailError(`There is more than one default message for the same id "${key}":
"${existingValue.message}" and "${value.message}"`)
);
} else {
targetMap.set(key, value);
}
}
function filterEntries(entries, exclude) {
return entries.filter((entry) =>
exclude.every((excludedPath) => !normalizePath(entry).startsWith(excludedPath))
);
}
export function validateMessageNamespace(id, filePath, allowedPaths, reporter) {
const normalizedPath = normalizePath(filePath);
const [expectedNamespace] = Object.entries(allowedPaths).find(([, pluginPaths]) =>
pluginPaths.some((pluginPath) => normalizedPath.startsWith(`${pluginPath}/`))
);
if (!id.startsWith(`${expectedNamespace}.`)) {
reporter.report(
createFailError(`Expected "${id}" id to have "${expectedNamespace}" namespace. \
See .i18nrc.json for the list of supported namespaces.`)
);
}
}
export async function matchEntriesWithExctractors(inputPath, options = {}) {
const { additionalIgnore = [], mark = false, absolute = false } = options;
const ignore = [
'**/node_modules/**',
'**/__tests__/**',
'**/dist/**',
'**/target/**',
'**/vendor/**',
'**/build/**',
'**/*.test.{js,jsx,ts,tsx}',
'**/*.d.ts',
]
.concat(additionalIgnore)
.map((i) => `!${i}`);
const entries = await globby(['*.{js,jsx,ts,tsx}', ...ignore], {
cwd: inputPath,
baseNameMatch: true,
markDirectories: mark,
absolute,
});
return {
entries: entries.map((entry) => path.resolve(inputPath, entry)),
extractFunction: extractCodeMessages,
};
}
export async function extractMessagesFromPathToMap(inputPath, targetMap, config, reporter) {
const { entries, extractFunction } = await matchEntriesWithExctractors(inputPath);
const files = await Promise.all(
filterEntries(entries, config.exclude).map(async (entry) => {
return {
name: entry,
content: await readFileAsync(entry),
};
})
);
for (const { name, content } of files) {
const reporterWithContext = reporter.withContext({ name });
try {
for (const [id, value] of extractFunction(content, reporterWithContext)) {
validateMessageNamespace(id, name, config.paths, reporterWithContext);
addMessageToMap(targetMap, id, value, reporterWithContext);
}
} catch (error) {
if (!isFailError(error)) {
throw error;
}
reporterWithContext.report(error);
}
}
}

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import path from 'path';
import {
extractMessagesFromPathToMap,
validateMessageNamespace,
} from './extract_default_translations';
import { ErrorReporter } from './utils';
const fixturesPath = path.resolve(__dirname, '__fixtures__', 'extract_default_translations');
const pluginsPaths = [
path.join(fixturesPath, 'test_plugin_1'),
path.join(fixturesPath, 'test_plugin_2'),
path.join(fixturesPath, 'test_plugin_2_additional_path'),
];
const config = {
paths: {
plugin_1: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1'],
plugin_2: [
'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2',
'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path',
],
},
exclude: [],
};
describe('dev/i18n/extract_default_translations', () => {
test('extracts messages from path to map', async () => {
const [pluginPath] = pluginsPaths;
const resultMap = new Map();
await extractMessagesFromPathToMap(pluginPath, resultMap, config, new ErrorReporter());
expect([...resultMap].sort()).toMatchSnapshot();
});
test('throws on id collision', async () => {
const [, pluginPath] = pluginsPaths;
const reporter = new ErrorReporter();
await expect(
extractMessagesFromPathToMap(pluginPath, new Map(), config, reporter)
).resolves.not.toThrow();
expect(reporter.errors).toMatchSnapshot();
});
test('validates message namespace', () => {
const id = 'plugin_2.message-id';
const filePath = path.resolve(
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx'
);
expect(() => validateMessageNamespace(id, filePath, config.paths)).not.toThrow();
});
test('validates message namespace with multiple paths', () => {
const id = 'plugin_2.message-id';
const filePath1 = path.resolve(
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx'
);
const filePath2 = path.resolve(
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx'
);
expect(() => validateMessageNamespace(id, filePath1, config.paths)).not.toThrow();
expect(() => validateMessageNamespace(id, filePath2, config.paths)).not.toThrow();
});
test('throws on wrong message namespace', () => {
const report = jest.fn();
const id = 'wrong_plugin_namespace.message-id';
const filePath = path.resolve(
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx'
);
expect(() => validateMessageNamespace(id, filePath, config.paths, { report })).not.toThrow();
expect(report.mock.calls).toMatchSnapshot();
});
});

View file

@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/extractors/code extracts React, server-side and angular service default messages 1`] = `
Array [
Array [
"kbn.mgmt.id-1",
Object {
"description": undefined,
"message": "Message text 1",
},
],
Array [
"kbn.mgmt.id-2",
Object {
"description": "Message description",
"message": "Message text 2",
},
],
Array [
"kbn.mgmt.id-3",
Object {
"description": undefined,
"message": "Message text 3",
},
],
]
`;
exports[`dev/i18n/extractors/code throws on empty id 1`] = `
Array [
Array [
[Error: Empty "id" value in i18n() or i18n.translate() is not allowed.],
],
]
`;
exports[`dev/i18n/extractors/code throws on missing defaultMessage 1`] = `
Array [
Array [
[Error: Empty defaultMessage in intl.formatMessage() is not allowed ("message-id").],
],
]
`;

View file

@ -1,41 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/extractors/i18n_call extracts "i18n" and "i18n.translate" functions call message 1`] = `
Array [
"message-id-1",
Object {
"description": "Message description 1",
"message": "Default message 1",
},
]
`;
exports[`dev/i18n/extractors/i18n_call extracts "i18n" and "i18n.translate" functions call message 2`] = `
Array [
"message-id-2",
Object {
"description": "Message description 2",
"message": "Default message 2",
},
]
`;
exports[`dev/i18n/extractors/i18n_call extracts "i18n" and "i18n.translate" functions call message 3`] = `
Array [
"message-id-3",
Object {
"description": "Message
description 3",
"message": "Default
message 3",
},
]
`;
exports[`dev/i18n/extractors/i18n_call throws if defaultMessage is not a string literal 1`] = `"defaultMessage value should be a string or template literal (\\"message-id\\")."`;
exports[`dev/i18n/extractors/i18n_call throws if message id value is not a string literal 1`] = `"Message id should be a string literal."`;
exports[`dev/i18n/extractors/i18n_call throws if properties object is not provided 1`] = `"Object with defaultMessage property is not passed to i18n() or i18n.translate() function call (\\"message-id\\")."`;
exports[`dev/i18n/extractors/i18n_call throws on empty defaultMessage 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`;

View file

@ -1,27 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/extractors/react extractFormattedMessages extracts messages from "<FormattedMessage>" element 1`] = `
Array [
"message-id-2",
Object {
"description": "Message description 2",
"message": "Default message 2",
},
]
`;
exports[`dev/i18n/extractors/react extractIntlMessages extracts messages from "intl.formatMessage" function call 1`] = `
Array [
"message-id-1",
Object {
"description": "Message description 1",
"message": "Default message 1",
},
]
`;
exports[`dev/i18n/extractors/react extractIntlMessages throws if description value is not a string literal 1`] = `"description value should be a string or template literal (\\"message-id\\")."`;
exports[`dev/i18n/extractors/react extractIntlMessages throws if defaultMessage value is not a string literal 1`] = `"defaultMessage value should be a string or template literal (\\"message-id\\")."`;
exports[`dev/i18n/extractors/react extractIntlMessages throws if message id is not a string literal 1`] = `"Message id should be a string literal."`;

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse } from '@babel/parser';
import {
isCallExpression,
isIdentifier,
isJSXIdentifier,
isJSXOpeningElement,
isMemberExpression,
} from '@babel/types';
import { extractI18nCallMessages } from './i18n_call';
import { createParserErrorMessage, isI18nTranslateFunction, traverseNodes } from '../utils';
import { extractIntlMessages, extractFormattedMessages } from './react';
import { createFailError, isFailError } from '@kbn/dev-cli-errors';
/**
* Detect Intl.formatMessage() function call (React).
* @param {Object} node
* @returns {boolean}
* @example
* formatMessage({ id: 'message-id', defaultMessage: 'Message text' });
* intl.formatMessage({ id: 'message-id', defaultMessage: 'Message text' });
* props.intl.formatMessage({ id: 'message-id', defaultMessage: 'Message text' });
* this.props.intl.formatMessage({ id: 'message-id', defaultMessage: 'Message text' });
*/
export function isIntlFormatMessageFunction(node) {
return (
isCallExpression(node) &&
(isIdentifier(node.callee, { name: 'formatMessage' }) ||
(isMemberExpression(node.callee) &&
(isIdentifier(node.callee.object, { name: 'intl' }) ||
isIdentifier(node.callee.object.property, { name: 'intl' })) &&
isIdentifier(node.callee.property, { name: 'formatMessage' })))
);
}
/**
* Detect <FormattedMessage> elements in JSX.
*
* Example: `<FormattedMessage id="message-id" defaultMessage="Message text"/>`
*/
export function isFormattedMessageElement(node) {
return isJSXOpeningElement(node) && isJSXIdentifier(node.name, { name: 'FormattedMessage' });
}
export function* extractCodeMessages(buffer, reporter) {
let ast;
try {
ast = parse(buffer.toString(), {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'objectRestSpread',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'asyncGenerators',
'dynamicImport',
'nullishCoalescingOperator',
'optionalChaining',
'exportNamespaceFrom',
],
});
} catch (error) {
if (error instanceof SyntaxError) {
const errorWithContext = createParserErrorMessage(buffer.toString(), error);
reporter.report(createFailError(errorWithContext));
return;
}
}
for (const node of traverseNodes(ast.program.body)) {
try {
if (isI18nTranslateFunction(node)) {
yield extractI18nCallMessages(node);
} else if (isIntlFormatMessageFunction(node)) {
yield extractIntlMessages(node);
} else if (isFormattedMessageElement(node)) {
yield extractFormattedMessages(node);
}
} catch (error) {
if (!isFailError(error)) {
throw error;
}
reporter.report(error);
}
}
}

View file

@ -1,106 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse } from '@babel/parser';
import { isCallExpression, isJSXOpeningElement } from '@babel/types';
import {
extractCodeMessages,
isFormattedMessageElement,
isIntlFormatMessageFunction,
} from './code';
import { traverseNodes } from '../utils';
const extractCodeMessagesSource = Buffer.from(`
i18n('kbn.mgmt.id-1', { defaultMessage: 'Message text 1' });
class Component extends PureComponent {
render() {
return (
<div>
<FormattedMessage
id="kbn.mgmt.id-2"
defaultMessage="Message text 2"
description="Message description"
/>
{intl.formatMessage({ id: 'kbn.mgmt.id-3', defaultMessage: 'Message text 3' })}
</div>
);
}
}
`);
const intlFormatMessageSource = `
formatMessage({ id: 'kbn.mgmt.id-1', defaultMessage: 'Message text 1', description: 'Message description' });
intl.formatMessage({ id: 'kbn.mgmt.id-2', defaultMessage: 'Message text 2', description: 'Message description' });
props.intl.formatMessage({ id: 'kbn.mgmt.id-5', defaultMessage: 'Message text 5', description: 'Message description' });
this.props.intl.formatMessage({ id: 'kbn.mgmt.id-6', defaultMessage: 'Message text 6', description: 'Message description' });
`;
const formattedMessageSource = `
function f() {
return (
<FormattedMessage
id="kbn.mgmt.id-1"
defaultMessage="Message text 1"
description="Message description"
/>
);
}
`;
const report = jest.fn();
describe('dev/i18n/extractors/code', () => {
beforeEach(() => {
report.mockClear();
});
test('extracts React, server-side and angular service default messages', () => {
const actual = Array.from(extractCodeMessages(extractCodeMessagesSource));
expect(actual.sort()).toMatchSnapshot();
});
test('throws on empty id', () => {
const source = Buffer.from(`i18n.translate('', { defaultMessage: 'Default message' });`);
expect(() => extractCodeMessages(source, { report }).next()).not.toThrow();
expect(report.mock.calls).toMatchSnapshot();
});
test('throws on missing defaultMessage', () => {
const source = Buffer.from(`intl.formatMessage({ id: 'message-id' });`);
expect(() => extractCodeMessages(source, { report }).next()).not.toThrow();
expect(report.mock.calls).toMatchSnapshot();
});
});
describe('isIntlFormatMessageFunction', () => {
test('detects intl.formatMessage call expression', () => {
const callExpressionNodes = [
...traverseNodes(parse(intlFormatMessageSource).program.body),
].filter((node) => isCallExpression(node));
expect(callExpressionNodes).toHaveLength(4);
expect(
callExpressionNodes.every((callExpressionNode) =>
isIntlFormatMessageFunction(callExpressionNode)
)
).toBe(true);
});
});
describe('isFormattedMessageElement', () => {
test('detects FormattedMessage jsx element', () => {
const AST = parse(formattedMessageSource, { plugins: ['jsx'] });
const jsxOpeningElementNode = [...traverseNodes(AST.program.body)].find((node) =>
isJSXOpeningElement(node)
);
expect(isFormattedMessageElement(jsxOpeningElementNode)).toBe(true);
});
});

View file

@ -1,67 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isObjectExpression } from '@babel/types';
import {
isPropertyWithKey,
formatJSString,
checkValuesProperty,
extractMessageIdFromNode,
extractMessageValueFromNode,
extractDescriptionValueFromNode,
extractValuesKeysFromNode,
} from '../utils';
import { DEFAULT_MESSAGE_KEY, DESCRIPTION_KEY, VALUES_KEY } from '../constants';
import { createFailError } from '@kbn/dev-cli-errors';
/**
* Extract messages from `funcName('id', { defaultMessage: 'Message text' })` call expression AST
*/
export function extractI18nCallMessages(node) {
const [idSubTree, optionsSubTree] = node.arguments;
const messageId = extractMessageIdFromNode(idSubTree);
if (!messageId) {
throw createFailError(`Empty "id" value in i18n() or i18n.translate() is not allowed.`);
}
if (!isObjectExpression(optionsSubTree)) {
throw createFailError(
`Object with defaultMessage property is not passed to i18n() or i18n.translate() function call ("${messageId}").`
);
}
const [messageProperty, descriptionProperty, valuesProperty] = [
DEFAULT_MESSAGE_KEY,
DESCRIPTION_KEY,
VALUES_KEY,
].map((key) => optionsSubTree.properties.find((property) => isPropertyWithKey(property, key)));
const message = messageProperty
? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId))
: undefined;
const description = descriptionProperty
? formatJSString(extractDescriptionValueFromNode(descriptionProperty.value, messageId))
: undefined;
if (!message) {
throw createFailError(
`Empty defaultMessage in i18n() or i18n.translate() is not allowed ("${messageId}").`
);
}
const valuesKeys = valuesProperty
? extractValuesKeysFromNode(valuesProperty.value, messageId)
: [];
checkValuesProperty(valuesKeys, message, messageId);
return [messageId, { message, description }];
}

View file

@ -1,90 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse } from '@babel/parser';
import { isCallExpression } from '@babel/types';
import { extractI18nCallMessages } from './i18n_call';
import { traverseNodes } from '../utils';
const i18nCallMessageSource = `
i18n('message-id-1', { defaultMessage: 'Default message 1', description: 'Message description 1' });
`;
const translateCallMessageSource = `
i18n.translate('message-id-2', { defaultMessage: 'Default message 2', description: 'Message description 2' });
`;
const i18nCallMessageWithTemplateLiteralSource = `
i18n('message-id-3', { defaultMessage: \`Default
message 3\`, description: \`Message
description 3\` });
`;
describe('dev/i18n/extractors/i18n_call', () => {
test('extracts "i18n" and "i18n.translate" functions call message', () => {
let callExpressionNode = [...traverseNodes(parse(i18nCallMessageSource).program.body)].find(
(node) => isCallExpression(node)
);
expect(extractI18nCallMessages(callExpressionNode)).toMatchSnapshot();
callExpressionNode = [...traverseNodes(parse(translateCallMessageSource).program.body)].find(
(node) => isCallExpression(node)
);
expect(extractI18nCallMessages(callExpressionNode)).toMatchSnapshot();
callExpressionNode = [
...traverseNodes(parse(i18nCallMessageWithTemplateLiteralSource).program.body),
].find((node) => isCallExpression(node));
expect(extractI18nCallMessages(callExpressionNode)).toMatchSnapshot();
});
test('throws if message id value is not a string literal', () => {
const source = `
i18n(messageIdIdentifier, { defaultMessage: 'Default message', description: 'Message description' });
`;
const callExpressionNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractI18nCallMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
test('throws if properties object is not provided', () => {
const source = `i18n('message-id');`;
const callExpressionNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractI18nCallMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
test('throws if defaultMessage is not a string literal', () => {
const source = `
const message = 'Default message';
i18n('message-id', { defaultMessage: message });
`;
const callExpressionNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractI18nCallMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
test('throws on empty defaultMessage', () => {
const source = `i18n('message-id', { defaultMessage: '' });`;
const callExpressionNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractI18nCallMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
});

View file

@ -1,127 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isJSXIdentifier, isObjectExpression, isJSXExpressionContainer } from '@babel/types';
import {
isPropertyWithKey,
formatJSString,
formatHTMLString,
extractMessageIdFromNode,
extractMessageValueFromNode,
extractDescriptionValueFromNode,
extractValuesKeysFromNode,
checkValuesProperty,
} from '../utils';
import { DEFAULT_MESSAGE_KEY, VALUES_KEY, DESCRIPTION_KEY } from '../constants';
import { createFailError } from '@kbn/dev-cli-errors';
/**
* Extract default messages from ReactJS intl.formatMessage(...) AST
* @param node Babel parser AST node
* @returns {[string, string][]} Array of id-message tuples
*/
export function extractIntlMessages(node) {
const [options, valuesNode] = node.arguments;
if (!isObjectExpression(options)) {
throw createFailError(
`Object with defaultMessage property is not passed to intl.formatMessage().`
);
}
const [messageIdProperty, messageProperty, descriptionProperty] = [
'id',
DEFAULT_MESSAGE_KEY,
DESCRIPTION_KEY,
].map((key) => options.properties.find((property) => isPropertyWithKey(property, key)));
const messageId = messageIdProperty
? formatJSString(extractMessageIdFromNode(messageIdProperty.value))
: undefined;
if (!messageId) {
throw createFailError(`Empty "id" value in intl.formatMessage() is not allowed.`);
}
const message = messageProperty
? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId))
: undefined;
const description = descriptionProperty
? formatJSString(extractDescriptionValueFromNode(descriptionProperty.value, messageId))
: undefined;
if (!message) {
throw createFailError(
`Empty defaultMessage in intl.formatMessage() is not allowed ("${messageId}").`
);
}
const valuesKeys = valuesNode ? extractValuesKeysFromNode(valuesNode, messageId) : [];
checkValuesProperty(valuesKeys, message, messageId);
return [messageId, { message, description }];
}
/**
* Extract default messages from ReactJS <FormattedMessage> element
* @param node Babel parser AST node
* @returns {[string, string][]} Array of id-message tuples
*/
export function extractFormattedMessages(node) {
const [messageIdAttribute, messageAttribute, descriptionAttribute, valuesAttribute] = [
'id',
DEFAULT_MESSAGE_KEY,
DESCRIPTION_KEY,
VALUES_KEY,
].map((key) =>
node.attributes.find((attribute) => isJSXIdentifier(attribute.name, { name: key }))
);
const messageId = messageIdAttribute
? formatHTMLString(extractMessageIdFromNode(messageIdAttribute.value))
: undefined;
if (!messageId) {
throw createFailError(`Empty "id" value in <FormattedMessage> is not allowed.`);
}
const message = messageAttribute
? formatHTMLString(extractMessageValueFromNode(messageAttribute.value, messageId))
: undefined;
const description = descriptionAttribute
? formatHTMLString(extractDescriptionValueFromNode(descriptionAttribute.value, messageId))
: undefined;
if (!message) {
throw createFailError(
`Empty default message in <FormattedMessage> is not allowed ("${messageId}").`
);
}
if (
valuesAttribute &&
(!isJSXExpressionContainer(valuesAttribute.value) ||
!isObjectExpression(valuesAttribute.value.expression))
) {
throw createFailError(
`"values" value in <FormattedMessage> should be an object ("${messageId}").`
);
}
const valuesKeys = valuesAttribute
? extractValuesKeysFromNode(valuesAttribute.value.expression, messageId)
: [];
checkValuesProperty(valuesKeys, message, messageId);
return [messageId, { message, description }];
}

View file

@ -1,124 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse } from '@babel/parser';
import { isCallExpression, isJSXOpeningElement, isJSXIdentifier } from '@babel/types';
import { extractIntlMessages, extractFormattedMessages } from './react';
import { traverseNodes } from '../utils';
const intlFormatMessageCallSource = `
const MyComponentContent = ({ intl }) => (
<input
type="text"
placeholder={intl.formatMessage({
id: 'message-id-1',
defaultMessage: 'Default message 1',
description: 'Message description 1'
})}
/>
);
`;
const formattedMessageElementSource = `
class Component extends PureComponent {
render() {
return (
<p>
<FormattedMessage
id="message-id-2"
defaultMessage="Default message 2"
description="Message description 2"
/>
</p>
);
}
}
`;
const intlFormatMessageCallErrorSources = [
`
const messageId = 'message-id'
intl.formatMessage({
id: messageId,
defaultMessage: 'Default message',
description: 'Message description'
});
`,
`
const message = 'Default message'
intl.formatMessage({
id: 'message-id',
defaultMessage: message,
description: 'Message description'
});
`,
`
const description = 'Message description'
intl.formatMessage({
id: 'message-id',
defaultMessage: 'Default message',
description: 1
});
`,
];
describe('dev/i18n/extractors/react', () => {
describe('extractIntlMessages', () => {
test('extracts messages from "intl.formatMessage" function call', () => {
const ast = parse(intlFormatMessageCallSource, { plugins: ['jsx'] });
const expressionNode = [...traverseNodes(ast.program.body)].find((node) =>
isCallExpression(node)
);
expect(extractIntlMessages(expressionNode)).toMatchSnapshot();
});
test('throws if message id is not a string literal', () => {
const source = intlFormatMessageCallErrorSources[0];
const ast = parse(source, { plugins: ['jsx'] });
const callExpressionNode = [...traverseNodes(ast.program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractIntlMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
test('throws if defaultMessage value is not a string literal', () => {
const source = intlFormatMessageCallErrorSources[1];
const ast = parse(source, { plugins: ['jsx'] });
const callExpressionNode = [...traverseNodes(ast.program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractIntlMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
test('throws if description value is not a string literal', () => {
const source = intlFormatMessageCallErrorSources[2];
const ast = parse(source, { plugins: ['jsx'] });
const callExpressionNode = [...traverseNodes(ast.program.body)].find((node) =>
isCallExpression(node)
);
expect(() => extractIntlMessages(callExpressionNode)).toThrowErrorMatchingSnapshot();
});
});
describe('extractFormattedMessages', () => {
test('extracts messages from "<FormattedMessage>" element', () => {
const ast = parse(formattedMessageElementSource, { plugins: ['jsx'] });
const jsxOpeningElementNode = [...traverseNodes(ast.program.body)].find(
(node) =>
isJSXOpeningElement(node) && isJSXIdentifier(node.name, { name: 'FormattedMessage' })
);
expect(extractFormattedMessages(jsxOpeningElementNode)).toMatchSnapshot();
});
});
});

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// @ts-ignore
export { extractMessagesFromPathToMap } from './extract_default_translations';
// @ts-ignore
export { matchEntriesWithExctractors } from './extract_default_translations';
export { arrayify, writeFileAsync, readFileAsync, normalizePath, ErrorReporter } from './utils';
export { serializeToJson, serializeToJson5 } from './serializers';
export type { I18nConfig } from './config';
export { filterConfigPaths, assignConfigFromPath, checkConfigNamespacePrefix } from './config';
export { integrateLocaleFiles } from './integrate_locale_files';

View file

@ -1,16 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const mockWriteFileAsync = jest.fn();
export const mockMakeDirAsync = jest.fn();
jest.mock('./utils', () => ({
// Jest typings don't define `requireActual` for some reason.
...(jest as any).requireActual('./utils'),
writeFileAsync: mockWriteFileAsync,
makeDirAsync: mockMakeDirAsync,
}));

View file

@ -1,168 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { mockMakeDirAsync, mockWriteFileAsync } from './integrate_locale_files.test.mocks';
import path from 'path';
import { integrateLocaleFiles, verifyMessages } from './integrate_locale_files';
import { normalizePath } from './utils';
const localePath = path.resolve(__dirname, '__fixtures__', 'integrate_locale_files', 'fr.json');
const mockDefaultMessagesMap = new Map([
['plugin-1.message-id-1', { message: 'Message text 1' }],
['plugin-1.message-id-2', { message: 'Message text 2' }],
['plugin-2.message-id', { message: 'Message text' }],
]);
const defaultIntegrateOptions = {
sourceFileName: localePath,
dryRun: false,
ignoreIncompatible: false,
ignoreMalformed: false,
ignoreMissing: false,
ignoreUnused: false,
config: {
paths: {
'plugin-1': ['src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_1'],
'plugin-2': ['src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_2'],
},
exclude: [],
translations: [],
},
log: { success: jest.fn(), warning: jest.fn() } as any,
};
describe('dev/i18n/integrate_locale_files', () => {
describe('verifyMessages', () => {
test('validates localized messages', () => {
const localizedMessagesMap = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-1.message-id-2', 'Translated text 2'],
['plugin-2.message-id', 'Translated text'],
]);
expect(() =>
verifyMessages(localizedMessagesMap, mockDefaultMessagesMap, defaultIntegrateOptions)
).not.toThrow();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('throws an error for unused id, missing id or the incompatible ones', () => {
const localizedMessagesMapWithMissingMessage = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-2.message-id', 'Translated text'],
]);
const localizedMessagesMapWithUnusedMessage = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-1.message-id-2', 'Translated text 2'],
['plugin-1.message-id-3', 'Translated text 3'],
['plugin-2.message-id', 'Translated text'],
]);
const localizedMessagesMapWithIdTypo = new Map([
['plugin-1.message-id-1', 'Message text 1'],
['plugin-1.message-id-2', 'Message text 2'],
['plugin-2.message', 'Message text'],
]);
const localizedMessagesMapWithUnknownValues = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-1.message-id-2', 'Translated text 2 with some unknown {value}'],
['plugin-2.message-id', 'Translated text'],
]);
expect(() =>
verifyMessages(
localizedMessagesMapWithMissingMessage,
mockDefaultMessagesMap,
defaultIntegrateOptions
)
).toThrowErrorMatchingSnapshot();
expect(() =>
verifyMessages(
localizedMessagesMapWithUnusedMessage,
mockDefaultMessagesMap,
defaultIntegrateOptions
)
).toThrowErrorMatchingSnapshot();
expect(() =>
verifyMessages(
localizedMessagesMapWithIdTypo,
mockDefaultMessagesMap,
defaultIntegrateOptions
)
).toThrowErrorMatchingSnapshot();
expect(() =>
verifyMessages(
localizedMessagesMapWithUnknownValues,
mockDefaultMessagesMap,
defaultIntegrateOptions
)
).toThrowErrorMatchingSnapshot();
});
test('removes unused ids if `ignoreUnused` is set', () => {
const localizedMessagesMapWithUnusedMessage = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-1.message-id-2', 'Translated text 2'],
['plugin-1.message-id-3', 'Some old translated text 3'],
['plugin-2.message-id', 'Translated text'],
['plugin-2.message', 'Some old translated text'],
]);
verifyMessages(localizedMessagesMapWithUnusedMessage, mockDefaultMessagesMap, {
...defaultIntegrateOptions,
ignoreUnused: true,
});
expect(localizedMessagesMapWithUnusedMessage).toMatchInlineSnapshot(`
Map {
"plugin-1.message-id-1" => "Translated text 1",
"plugin-1.message-id-2" => "Translated text 2",
"plugin-2.message-id" => "Translated text",
}
`);
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('removes ids with incompatible ICU structure if `ignoreIncompatible` is set', () => {
const localizedMessagesMapWithIncompatibleMessage = new Map([
['plugin-1.message-id-1', 'Translated text 1'],
['plugin-1.message-id-2', 'Translated text 2 with some unknown {value}'],
['plugin-2.message-id', 'Translated text'],
]);
verifyMessages(localizedMessagesMapWithIncompatibleMessage, mockDefaultMessagesMap, {
...defaultIntegrateOptions,
ignoreIncompatible: true,
});
expect(localizedMessagesMapWithIncompatibleMessage).toMatchInlineSnapshot(`
Map {
"plugin-1.message-id-1" => "Translated text 1",
"plugin-2.message-id" => "Translated text",
}
`);
});
});
describe('integrateLocaleFiles', () => {
test('splits locale file by plugins and writes them into the right folders', async () => {
await integrateLocaleFiles(mockDefaultMessagesMap, defaultIntegrateOptions);
const [[path1, json1], [path2, json2]] = mockWriteFileAsync.mock.calls;
const [[dirPath1], [dirPath2]] = mockMakeDirAsync.mock.calls;
expect([normalizePath(path1), json1]).toMatchSnapshot();
expect([normalizePath(path2), json2]).toMatchSnapshot();
expect([normalizePath(dirPath1), normalizePath(dirPath2)]).toMatchSnapshot();
});
});
});

View file

@ -1,209 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ToolingLog } from '@kbn/tooling-log';
import { Formats } from '@kbn/i18n';
import path from 'path';
import { createFailError } from '@kbn/dev-cli-errors';
import {
accessAsync,
checkValuesProperty,
difference,
extractValueReferencesFromMessage,
makeDirAsync,
normalizePath,
readFileAsync,
writeFileAsync,
verifyICUMessage,
} from './utils';
import { I18nConfig } from './config';
import { serializeToJson } from './serializers';
export interface IntegrateOptions {
sourceFileName: string;
targetFileName?: string;
dryRun: boolean;
ignoreMalformed: boolean;
ignoreIncompatible: boolean;
ignoreUnused: boolean;
ignoreMissing: boolean;
config: I18nConfig;
log: ToolingLog;
}
type MessageMap = Map<string, { message: string }>;
type GroupedMessageMap = Map<string, Array<[string, { message: string }]>>;
type LocalizedMessageMap = Map<string, string | { text: string }>;
export function verifyMessages(
localizedMessagesMap: LocalizedMessageMap,
defaultMessagesMap: MessageMap,
options: IntegrateOptions
) {
let errorMessage = '';
const defaultMessagesIds = [...defaultMessagesMap.keys()];
const localizedMessagesIds = [...localizedMessagesMap.keys()];
const unusedTranslations = difference(localizedMessagesIds, defaultMessagesIds);
if (unusedTranslations.length > 0) {
if (!options.ignoreUnused) {
errorMessage += `\n${
unusedTranslations.length
} unused translation(s):\n${unusedTranslations.join(', ')}`;
} else {
for (const unusedTranslationId of unusedTranslations) {
localizedMessagesMap.delete(unusedTranslationId);
}
}
}
if (!options.ignoreMissing) {
const missingTranslations = difference(defaultMessagesIds, localizedMessagesIds);
if (missingTranslations.length > 0) {
errorMessage += `\n${
missingTranslations.length
} missing translation(s):\n${missingTranslations.join(', ')}`;
}
}
for (const messageId of localizedMessagesIds) {
const defaultMessage = defaultMessagesMap.get(messageId);
if (defaultMessage) {
try {
const message = localizedMessagesMap.get(messageId)!;
checkValuesProperty(
extractValueReferencesFromMessage(defaultMessage.message, messageId),
typeof message === 'string' ? message : message.text,
messageId
);
} catch (err) {
if (options.ignoreIncompatible) {
localizedMessagesMap.delete(messageId);
options.log.warning(`Incompatible translation ignored: ${err.message}`);
} else {
errorMessage += `\nIncompatible translation: ${err.message}\n`;
}
}
}
}
for (const messageId of localizedMessagesIds) {
const defaultMessage = defaultMessagesMap.get(messageId);
if (defaultMessage) {
try {
const message = localizedMessagesMap.get(messageId)!;
verifyICUMessage(typeof message === 'string' ? message : message?.text);
} catch (err) {
if (options.ignoreMalformed) {
localizedMessagesMap.delete(messageId);
options.log.warning(`Malformed translation ignored (${messageId}): ${err}`);
} else {
errorMessage += `\nMalformed translation (${messageId}): ${err}\n`;
}
}
}
}
if (errorMessage) {
throw createFailError(errorMessage);
}
}
function groupMessagesByNamespace(
localizedMessagesMap: LocalizedMessageMap,
knownNamespaces: string[]
) {
const localizedMessagesByNamespace = new Map();
for (const [messageId, messageValue] of localizedMessagesMap) {
const namespace = knownNamespaces.find((key) => messageId.startsWith(`${key}.`));
if (!namespace) {
throw createFailError(`Unknown namespace in id ${messageId}.`);
}
if (!localizedMessagesByNamespace.has(namespace)) {
localizedMessagesByNamespace.set(namespace, []);
}
localizedMessagesByNamespace
.get(namespace)
.push([
messageId,
{ message: typeof messageValue === 'string' ? messageValue : messageValue.text },
]);
}
return localizedMessagesByNamespace;
}
async function writeMessages(
localizedMessagesByNamespace: GroupedMessageMap,
formats: Formats,
options: IntegrateOptions
) {
// If target file name is specified we need to write all the translations into one file,
// irrespective to the namespace.
if (options.targetFileName) {
await writeFileAsync(
options.targetFileName,
serializeToJson(
[...localizedMessagesByNamespace.values()].reduce((acc, val) => acc.concat(val), []),
formats
)
);
return options.log.success(
`Translations have been integrated to ${normalizePath(options.targetFileName)}`
);
}
// Use basename of source file name to write the same locale name as the source file has.
const fileName = path.basename(options.sourceFileName);
for (const [namespace, messages] of localizedMessagesByNamespace) {
for (const namespacedPath of options.config.paths[namespace]) {
const destPath = path.resolve(namespacedPath, 'translations');
try {
await accessAsync(destPath);
} catch (_) {
await makeDirAsync(destPath);
}
const writePath = path.resolve(destPath, fileName);
await writeFileAsync(writePath, serializeToJson(messages, formats));
options.log.success(`Translations have been integrated to ${normalizePath(writePath)}`);
}
}
}
export async function integrateLocaleFiles(
defaultMessagesMap: MessageMap,
options: IntegrateOptions
) {
const localizedMessages = JSON.parse((await readFileAsync(options.sourceFileName)).toString());
if (!localizedMessages.formats) {
throw createFailError(`Locale file should contain formats object.`);
}
const localizedMessagesMap: LocalizedMessageMap = new Map(
Object.entries(localizedMessages.messages)
);
verifyMessages(localizedMessagesMap, defaultMessagesMap, options);
const knownNamespaces = Object.keys(options.config.paths);
const groupedLocalizedMessagesMap = groupMessagesByNamespace(
localizedMessagesMap,
knownNamespaces
);
if (!options.dryRun) {
await writeMessages(groupedLocalizedMessagesMap, localizedMessages.formats, options);
}
}

View file

@ -1,87 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/serializers/json5 should serialize default messages to JSON5 1`] = `
"{
formats: {
number: {
currency: {
style: 'currency',
},
percent: {
style: 'percent',
},
},
date: {
short: {
month: 'numeric',
day: 'numeric',
year: '2-digit',
},
medium: {
month: 'short',
day: 'numeric',
year: 'numeric',
},
long: {
month: 'long',
day: 'numeric',
year: 'numeric',
},
full: {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
},
},
time: {
short: {
hour: 'numeric',
minute: 'numeric',
},
medium: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
},
full: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
},
},
relative: {
years: {
units: 'year',
},
months: {
units: 'month',
},
days: {
units: 'day',
},
hours: {
units: 'hour',
},
minutes: {
units: 'minute',
},
seconds: {
units: 'second',
},
},
},
messages: {
'plugin1.message.id-1': 'Message text 1',
'plugin2.message.id-2': 'Message text 2', // Message description
},
}
"
`;

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { serializeToJson } from './json';
describe('dev/i18n/serializers/json', () => {
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('should serialize default messages to JSON', () => {
const messages: Array<[string, { message: string; description?: string }]> = [
['plugin1.message.id-1', { message: 'Message text 1 ' }],
[
'plugin2.message.id-2',
{
message: 'Message text 2',
description: 'Message description',
},
],
];
expect(serializeToJson(messages)).toMatchSnapshot();
});
});

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { Serializer } from '.';
export const serializeToJson: Serializer = (messages, formats = i18n.getTranslation().formats) => {
const resultJsonObject = {
formats,
messages: {} as Record<string, string | { text: string; comment: string }>,
};
for (const [mapKey, mapValue] of messages) {
if (mapValue.description) {
resultJsonObject.messages[mapKey] = { text: mapValue.message, comment: mapValue.description };
} else {
resultJsonObject.messages[mapKey] = mapValue.message;
}
}
return JSON.stringify(resultJsonObject, undefined, 2).concat('\n');
};

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { serializeToJson5 } from './json5';
describe('dev/i18n/serializers/json5', () => {
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('should serialize default messages to JSON5', () => {
const messages: Array<[string, { message: string; description?: string }]> = [
[
'plugin1.message.id-1',
{
message: 'Message text 1',
},
],
[
'plugin2.message.id-2',
{
message: 'Message text 2',
description: 'Message description',
},
],
];
expect(serializeToJson5(messages).toString()).toMatchSnapshot();
});
});

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import JSON5 from 'json5';
import { Serializer } from '.';
const ESCAPE_SINGLE_QUOTE_REGEX = /\\([\s\S])|(')/g;
export const serializeToJson5: Serializer = (messages, formats = i18n.getTranslation().formats) => {
// .slice(0, -4): remove closing curly braces from json to append messages
let jsonBuffer = Buffer.from(
JSON5.stringify({ formats, messages: {} }, { quote: `'`, space: 2 }).slice(0, -4).concat('\n')
);
for (const [mapKey, mapValue] of messages) {
const formattedMessage = mapValue.message.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2');
const formattedDescription = mapValue.description
? mapValue.description.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2')
: '';
jsonBuffer = Buffer.concat([
jsonBuffer,
Buffer.from(` '${mapKey}': '${formattedMessage}',`),
Buffer.from(formattedDescription ? ` // ${formattedDescription}\n` : '\n'),
]);
}
// append previously removed closing curly braces
jsonBuffer = Buffer.concat([jsonBuffer, Buffer.from(' },\n}\n')]);
return jsonBuffer.toString();
};

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ToolingLog } from '@kbn/tooling-log';
import { integrateLocaleFiles, I18nConfig } from '..';
import { I18nCheckTaskContext } from '../types';
export interface I18nFlags {
fix: boolean;
ignoreMalformed: boolean;
ignoreIncompatible: boolean;
ignoreUnused: boolean;
ignoreMissing: boolean;
}
export function checkCompatibility(config: I18nConfig, flags: I18nFlags, log: ToolingLog) {
const { fix, ignoreIncompatible, ignoreUnused, ignoreMalformed, ignoreMissing } = flags;
return config.translations.map((translationsPath) => ({
task: async ({ messages }: I18nCheckTaskContext) => {
// If `fix` is set we should try apply all possible fixes and override translations file.
await integrateLocaleFiles(messages, {
dryRun: !fix,
ignoreIncompatible: fix || ignoreIncompatible,
ignoreUnused: fix || ignoreUnused,
ignoreMissing: fix || ignoreMissing,
ignoreMalformed: fix || ignoreMalformed,
sourceFileName: translationsPath,
targetFileName: fix ? translationsPath : undefined,
config,
log,
});
},
title: `Compatibility check with ${translationsPath}`,
}));
}

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import chalk from 'chalk';
import { createFailError } from '@kbn/dev-cli-errors';
import { extractMessagesFromPathToMap, filterConfigPaths, I18nConfig } from '..';
import { I18nCheckTaskContext } from '../types';
export function extractDefaultMessages(config: I18nConfig, inputPaths: string[]) {
const filteredPaths = filterConfigPaths(inputPaths, config) as string[];
if (filteredPaths.length === 0) {
throw createFailError(
`${chalk.white.bgRed(
' I18N ERROR '
)} None of input paths is covered by the mappings in .i18nrc.json.`
);
}
return filteredPaths.map((filteredPath) => ({
task: async (context: I18nCheckTaskContext) => {
const { messages, reporter } = context;
const initialErrorsNumber = reporter.errors.length;
// Return result if no new errors were reported for this path.
const result = await extractMessagesFromPathToMap(filteredPath, messages, config, reporter);
if (reporter.errors.length === initialErrorsNumber) {
return result;
}
throw reporter;
},
title: filteredPath,
}));
}

View file

@ -1,95 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createFailError } from '@kbn/dev-cli-errors';
import { matchEntriesWithExctractors } from '../extract_default_translations';
import { I18nConfig } from '../config';
import { normalizePath, readFileAsync, ErrorReporter } from '../utils';
import { I18nCheckTaskContext } from '../types';
function filterEntries(entries: string[], exclude: string[]) {
return entries.filter((entry: string) =>
exclude.every((excludedPath: string) => !normalizePath(entry).startsWith(excludedPath))
);
}
export async function extractUntrackedMessagesTask({
path,
config,
reporter,
}: {
path?: string | string[];
config: I18nConfig;
reporter: ErrorReporter;
}) {
const inputPaths = Array.isArray(path) ? path : [path || './'];
const availablePaths = Object.values(config.paths).flat();
const ignore = availablePaths.concat([
'**/build/**',
'**/__fixtures__/**',
'**/packages/kbn-i18n/**',
'**/packages/kbn-i18n-react/**',
'**/packages/kbn-plugin-generator/template/**',
'**/test/**',
'**/scripts/**',
'**/src/dev/**',
'**/target/**',
'**/dist/**',
]);
for (const inputPath of inputPaths) {
const { entries, extractFunction } = await matchEntriesWithExctractors(inputPath, {
additionalIgnore: ignore,
mark: true,
absolute: true,
});
const files = await Promise.all(
filterEntries(entries, config.exclude)
.filter((entry) => {
const normalizedEntry = normalizePath(entry);
return !availablePaths.some(
(availablePath) =>
normalizedEntry.startsWith(`${normalizePath(availablePath)}/`) ||
normalizePath(availablePath) === normalizedEntry
);
})
.map(async (entry: any) => ({
name: entry,
content: await readFileAsync(entry),
}))
);
for (const { name, content } of files) {
const reporterWithContext = reporter.withContext({ name });
for (const [id] of extractFunction(content, reporterWithContext)) {
const errorMessage = `Untracked file contains i18n label (${id}).`;
reporterWithContext.report(createFailError(errorMessage));
}
}
}
}
export function extractUntrackedMessages(inputPaths: string[]) {
return inputPaths.map((inputPath) => ({
title: `Checking untracked messages in ${inputPath}`,
task: async (context: I18nCheckTaskContext) => {
const { reporter, config } = context;
const initialErrorsNumber = reporter.errors.length;
const result = await extractUntrackedMessagesTask({
path: inputPath,
config: config as I18nConfig,
reporter,
});
if (reporter.errors.length === initialErrorsNumber) {
return result;
}
throw reporter;
},
}));
}

View file

@ -1,31 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`i18n utils should create verbose parser error message 1`] = `
"Unexpected token, expected \\",\\" (4:19):
const object = {
object: 'with',
semicolon: '->';
};
"
`;
exports[`i18n utils should normalizePath 1`] = `"src/dev/i18n/utils"`;
exports[`i18n utils should not escape linebreaks 1`] = `
"Text
with
line-breaks
"
`;
exports[`i18n utils should parse string concatenation 1`] = `"Very long concatenated string"`;
exports[`i18n utils should throw if "values" has a value that is unused in the message 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [url]."`;
exports[`i18n utils should throw if "values" property is not provided and defaultMessage requires it 1`] = `"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): [username,password,url]."`;
exports[`i18n utils should throw if "values" property is provided and defaultMessage doesn't include any references 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [url,username]."`;
exports[`i18n utils should throw if some key is missing in "values" 1`] = `"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): [password]."`;
exports[`i18n utils should throw on wrong nested ICU message 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [third]."`;

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export {
// constants
readFileAsync,
writeFileAsync,
makeDirAsync,
accessAsync,
// functions
normalizePath,
difference,
isPropertyWithKey,
isI18nTranslateFunction,
formatJSString,
formatHTMLString,
traverseNodes,
createParserErrorMessage,
checkValuesProperty,
extractValueReferencesFromMessage,
extractMessageIdFromNode,
extractMessageValueFromNode,
extractDescriptionValueFromNode,
extractValuesKeysFromNode,
arrayify,
// classes
ErrorReporter,
} from './utils';
export { verifyICUMessage } from './verify_icu_message';

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface OptionalFormatPatternNode {
type: 'optionalFormatPattern';
selector: string;
value: any;
}
export interface LinePosition {
offset: number;
line: number;
column: number;
}
export interface LocationNode {
start: LinePosition;
end: LinePosition;
}
export interface SelectFormatNode {
type: 'selectFormat';
options: OptionalFormatPatternNode[];
location: LocationNode;
}

View file

@ -1,324 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
isCallExpression,
isIdentifier,
isMemberExpression,
isNode,
isObjectExpression,
isObjectProperty,
isStringLiteral,
isTemplateLiteral,
isBinaryExpression,
} from '@babel/types';
import fs from 'fs';
import { promisify } from 'util';
import normalize from 'normalize-path';
import path from 'path';
import chalk from 'chalk';
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
import { createFailError } from '@kbn/dev-cli-errors';
const ESCAPE_LINE_BREAK_REGEX = /(?<!\\)\\\n/g;
const HTML_LINE_BREAK_REGEX = /[\s]*\n[\s]*/g;
const HTML_KEY_PREFIX = 'html_';
export const readFileAsync = promisify(fs.readFile);
export const writeFileAsync = promisify(fs.writeFile);
export const makeDirAsync = promisify(fs.mkdir);
export const accessAsync = promisify(fs.access);
export function normalizePath(inputPath) {
return normalize(path.relative('.', inputPath));
}
export function difference(left = [], right = []) {
return left.filter((value) => !right.includes(value));
}
export function isPropertyWithKey(property, identifierName) {
return isObjectProperty(property) && isIdentifier(property.key, { name: identifierName });
}
/**
* Detect angular i18n service call or `@kbn/i18n` translate function call.
*
* Service call example: `i18n('message-id', { defaultMessage: 'Message text'})`
*
* `@kbn/i18n` example: `i18n.translate('message-id', { defaultMessage: 'Message text'})`
*/
export function isI18nTranslateFunction(node) {
return (
isCallExpression(node) &&
(isIdentifier(node.callee, { name: 'i18n' }) ||
(isMemberExpression(node.callee) &&
isIdentifier(node.callee.object, { name: 'i18n' }) &&
isIdentifier(node.callee.property, { name: 'translate' })))
);
}
export function formatJSString(string) {
return (string || '').replace(ESCAPE_LINE_BREAK_REGEX, '');
}
export function formatHTMLString(string) {
return (string || '').replace(HTML_LINE_BREAK_REGEX, ' ');
}
/**
* Traverse an array of nodes using default depth-first traversal algorithm.
* We don't use `@babel/traverse` because of its bug: https://github.com/babel/babel/issues/8262
*
* @generator
* @param {object[]} nodes array of nodes or objects with Node values
* @yields {Node} each node
*/
export function* traverseNodes(nodes) {
for (const node of nodes) {
if (isNode(node)) {
yield node;
}
// if node is an object / array, traverse all of its object values
if (node && typeof node === 'object') {
yield* traverseNodes(
Object.values(node).filter((value) => value && typeof value === 'object')
);
}
}
}
/**
* Forms an formatted error message for parser errors.
*
* This function returns a string which represents an error message and a place in the code where the error happened.
* In total five lines of the code are displayed: the line where the error occured, two lines before and two lines after.
*
* @param {string} content a code string where parsed error happened
* @param {{ loc: { line: number, column: number }, message: string }} error an object that contains an error message and
* the line number and the column number in the file that raised this error
* @returns {string} a formatted string representing parser error message
*/
export function createParserErrorMessage(content, error) {
const line = error.loc.line - 1;
const column = error.loc.column;
const contentLines = content.split(/\n/);
const firstLine = Math.max(line - 2, 0);
const lastLine = Math.min(line + 2, contentLines.length - 1);
contentLines[line] =
contentLines[line].substring(0, column) +
chalk.white.bgRed(contentLines[line][column] || ' ') +
contentLines[line].substring(column + 1);
const context = contentLines.slice(firstLine, lastLine + 1).join('\n');
return `${error.message}:\n${context}`;
}
/**
* Recursively extracts all references from ICU message ast.
*
* Example: `'Removed tag {tag} from {assignmentsLength, plural, one {beat {beatName}} other {# beats}}.'`
*
* @param {any} node
* @param {Set<string>} keys
*/
function extractValueReferencesFromIcuAst(node, keys = new Set()) {
if (Array.isArray(node)) {
for (const element of node) {
if (element.type === TYPE.literal) {
continue;
}
keys.add(element.value);
if (element.options) {
for (const option of Object.values(element.options)) {
extractValueReferencesFromIcuAst(option, keys);
}
}
}
} else if (node.value) {
extractValueReferencesFromIcuAst(node.value, keys);
}
return [...keys];
}
/**
* Checks whether values from "values" and "defaultMessage" correspond to each other.
*
* @param {string[]} prefixedValuesKeys array of "values" property keys
* @param {string} defaultMessage "defaultMessage" value
* @param {string} messageId message id for fail errors
* @throws if "values" and "defaultMessage" don't correspond to each other
*/
export function checkValuesProperty(prefixedValuesKeys, defaultMessage, messageId) {
// TODO: Skip values check until i18n tooling are upgraded.
return;
// Skip validation if `defaultMessage` doesn't include any ICU values and
// `values` prop has no keys.
const defaultMessageValueReferences = extractValueReferencesFromMessage(
defaultMessage,
messageId
);
if (!prefixedValuesKeys.length && defaultMessageValueReferences.length === 0) {
return;
}
const valuesKeys = prefixedValuesKeys.map((key) =>
key.startsWith(HTML_KEY_PREFIX) ? key.slice(HTML_KEY_PREFIX.length) : key
);
const missingValuesKeys = difference(defaultMessageValueReferences, valuesKeys);
if (missingValuesKeys.length) {
throw createFailError(
`some properties are missing in "values" object ("${messageId}"): [${missingValuesKeys}].`
);
}
const unusedValuesKeys = difference(valuesKeys, defaultMessageValueReferences);
if (unusedValuesKeys.length) {
throw createFailError(
`"values" object contains unused properties ("${messageId}"): [${unusedValuesKeys}].`
);
}
}
/**
* Extracts value references from the ICU message.
* @param message ICU message.
* @param messageId ICU message id
* @returns {string[]}
*/
export function extractValueReferencesFromMessage(message, messageId) {
try {
const messageAST = parse(message);
// Skip extraction if icu-messageformat-parser didn't return an AST with nonempty elements array.
if (!messageAST || !messageAST.length) {
return [];
}
// Skip validation if message doesn't use ICU.
if (messageAST.every((element) => element.type === TYPE.literal)) {
return [];
}
return extractValueReferencesFromIcuAst(messageAST);
} catch (error) {
if (error.name === 'SyntaxError') {
const errorWithContext = createParserErrorMessage(message, {
loc: {
line: error.location.start.line,
column: error.location.start.column - 1,
},
message: error.message,
});
throw createFailError(
`Couldn't parse default message ("${messageId}"):\n${errorWithContext}`
);
}
throw error;
}
}
export function extractMessageIdFromNode(node) {
if (!isStringLiteral(node)) {
throw createFailError(`Message id should be a string literal.`);
}
return node.value;
}
function parseTemplateLiteral(node, messageId) {
// TemplateLiteral consists of quasis (strings) and expressions.
// If we have at least one expression in template literal, then quasis length
// will be greater than 1
if (node.quasis.length > 1) {
throw createFailError(`expressions are not allowed in template literals ("${messageId}").`);
}
// Babel reads 'cooked' and 'raw' versions of a string.
// 'cooked' acts like a normal StringLiteral value and interprets backslashes
// 'raw' is primarily designed for TaggedTemplateLiteral and escapes backslashes
return node.quasis[0].value.cooked;
}
function extractStringFromNode(node, messageId, errorMessage) {
if (isStringLiteral(node)) {
return node.value;
}
if (isTemplateLiteral(node)) {
return parseTemplateLiteral(node, messageId);
}
if (isBinaryExpression(node, { operator: '+' })) {
return (
extractStringFromNode(node.left, messageId, errorMessage) +
extractStringFromNode(node.right, messageId, errorMessage)
);
}
throw createFailError(errorMessage);
}
export function extractMessageValueFromNode(node, messageId) {
return extractStringFromNode(
node,
messageId,
`defaultMessage value should be a string or template literal ("${messageId}").`
);
}
export function extractDescriptionValueFromNode(node, messageId) {
return extractStringFromNode(
node,
messageId,
`description value should be a string or template literal ("${messageId}").`
);
}
export function extractValuesKeysFromNode(node, messageId) {
if (!isObjectExpression(node)) {
throw createFailError(`"values" value should be an inline object literal ("${messageId}").`);
}
return node.properties.map((property) =>
isStringLiteral(property.key) ? property.key.value : property.key.name
);
}
export class ErrorReporter {
errors = [];
withContext(context) {
return { report: (error) => this.report(error, context) };
}
report(error, context) {
this.errors.push(
`${chalk.white.bgRed(' I18N ERROR ')} Error in ${normalizePath(context.name)}\n${error}`
);
}
}
// export function arrayify<Subj = any>(subj: Subj | Subj[]): Subj[] {
export function arrayify(subj) {
return Array.isArray(subj) ? subj : [subj];
}

View file

@ -1,196 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse } from '@babel/parser';
import { isExpressionStatement, isObjectExpression, isObjectProperty } from '@babel/types';
import {
isI18nTranslateFunction,
isPropertyWithKey,
traverseNodes,
formatJSString,
checkValuesProperty,
createParserErrorMessage,
normalizePath,
extractMessageValueFromNode,
extractValueReferencesFromMessage,
} from './utils';
const i18nTranslateSources = ['i18n', 'i18n.translate'].map(
(callee) => `
${callee}('plugin_1.id_1', {
values: {
key: 'value',
},
defaultMessage: 'Message text',
description: 'Message description'
});
`
);
const objectPropertySource = `
const object = {
id: 'value',
};
`;
describe('i18n utils', () => {
test('should remove escaped linebreak', () => {
expect(formatJSString('Test\\\n str\\\ning')).toEqual('Test string');
});
test('should not escape linebreaks', () => {
expect(
formatJSString(`Text\n with
line-breaks
`)
).toMatchSnapshot();
});
test('should detect i18n translate function call', () => {
let source = i18nTranslateSources[0];
let expressionStatementNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isExpressionStatement(node)
);
expect(isI18nTranslateFunction(expressionStatementNode.expression)).toBe(true);
source = i18nTranslateSources[1];
expressionStatementNode = [...traverseNodes(parse(source).program.body)].find((node) =>
isExpressionStatement(node)
);
expect(isI18nTranslateFunction(expressionStatementNode.expression)).toBe(true);
});
test('should detect object property with defined key', () => {
const objectExpresssionNode = [...traverseNodes(parse(objectPropertySource).program.body)].find(
(node) => isObjectExpression(node)
);
const [objectExpresssionProperty] = objectExpresssionNode.properties;
expect(isPropertyWithKey(objectExpresssionProperty, 'id')).toBe(true);
expect(isPropertyWithKey(objectExpresssionProperty, 'not_id')).toBe(false);
});
test('should create verbose parser error message', () => {
expect.assertions(1);
const content = `function testFunction() {
const object = {
object: 'with',
semicolon: '->';
};
return object;
}
`;
try {
parse(content);
} catch (error) {
expect(createParserErrorMessage(content, error)).toMatchSnapshot();
}
});
test('should normalizePath', () => {
expect(normalizePath(__dirname)).toMatchSnapshot();
});
test('should validate conformity of "values" and "defaultMessage"', () => {
const valuesKeys = ['url', 'username', 'password'];
const defaultMessage = 'Test message with {username}, {password} and [markdown link]({url}).';
const messageId = 'namespace.message.id';
expect(() => checkValuesProperty(valuesKeys, defaultMessage, messageId)).not.toThrow();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('should throw if "values" has a value that is unused in the message', () => {
const valuesKeys = ['username', 'url', 'password'];
const defaultMessage = 'Test message with {username} and {password}.';
const messageId = 'namespace.message.id';
expect(() =>
checkValuesProperty(valuesKeys, defaultMessage, messageId)
).toThrowErrorMatchingSnapshot();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('should throw if some key is missing in "values"', () => {
const valuesKeys = ['url', 'username'];
const defaultMessage = 'Test message with {username}, {password} and [markdown link]({url}).';
const messageId = 'namespace.message.id';
expect(() =>
checkValuesProperty(valuesKeys, defaultMessage, messageId)
).toThrowErrorMatchingSnapshot();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip('should throw if "values" property is not provided and defaultMessage requires it', () => {
const valuesKeys = [];
const defaultMessage = 'Test message with {username}, {password} and [markdown link]({url}).';
const messageId = 'namespace.message.id';
expect(() =>
checkValuesProperty(valuesKeys, defaultMessage, messageId)
).toThrowErrorMatchingSnapshot();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip(`should throw if "values" property is provided and defaultMessage doesn't include any references`, () => {
const valuesKeys = ['url', 'username'];
const defaultMessage = 'Test message';
const messageId = 'namespace.message.id';
expect(() =>
checkValuesProperty(valuesKeys, defaultMessage, messageId)
).toThrowErrorMatchingSnapshot();
});
test('should parse nested ICU message', () => {
const valuesKeys = ['first', 'second', 'third'];
const defaultMessage = 'Test message {first, plural, one {{second}} other {{third}}}';
const messageId = 'namespace.message.id';
expect(() => checkValuesProperty(valuesKeys, defaultMessage, messageId)).not.toThrow();
});
// TODO: fix in i18n tooling upgrade https://github.com/elastic/kibana/pull/180617
test.skip(`should throw on wrong nested ICU message`, () => {
const valuesKeys = ['first', 'second', 'third'];
const defaultMessage = 'Test message {first, plural, one {{second}} other {other}}';
const messageId = 'namespace.message.id';
expect(() =>
checkValuesProperty(valuesKeys, defaultMessage, messageId)
).toThrowErrorMatchingSnapshot();
});
test(`should parse string concatenation`, () => {
const source = `
i18n('namespace.id', {
defaultMessage: 'Very ' + 'long ' + 'concatenated ' + 'string',
});`;
const objectProperty = [...traverseNodes(parse(source).program.body)].find((node) =>
isObjectProperty(node)
);
expect(extractMessageValueFromNode(objectProperty.value)).toMatchSnapshot();
});
test(`should parse html required variables`, () => {
const valuesKeys = ['a'];
const defaultMessage = 'Click here to go to <a>homepage</a>';
const messageId = 'namespace.message.id';
const result = extractValueReferencesFromMessage(defaultMessage, messageId);
expect(result).toEqual(valuesKeys);
});
});

View file

@ -1,101 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { verifyICUMessage, checkEnglishOnly } from './verify_icu_message';
describe('verifyICUMessage', () => {
it('passes on plain text', () => {
const message = 'plain text here';
expect(() => verifyICUMessage(message)).not.toThrowError();
});
it('passes on empty string', () => {
const message = '';
expect(() => verifyICUMessage(message)).not.toThrowError();
});
it('passes on variable icu-syntax', () => {
const message = 'Your regular {foobar}';
expect(() => verifyICUMessage(message)).not.toThrowError();
});
it('passes on correct plural icu-syntax', () => {
const message = `You have {itemCount, plural,
=0 {no items}
one {1 item}
other {{itemCount} items}
}.`;
expect(() => verifyICUMessage(message)).not.toThrowError();
});
it('throws on malformed string', () => {
const message =
'CDATA[extended_bounds設定を使用すると、強制的にヒストグラムアグリゲーションを実行し、特定の最小値に対してバケットの作成を開始し、最大値までバケットを作成し続けます。 ]]></target>\n\t\t\t<note>Kibana-SW - String "data.search.aggs.buckets.dateHistogram.extendedBounds.help" in Json.Root "messages\\strings" ';
expect(() => verifyICUMessage(message)).toThrowErrorMatchingInlineSnapshot(`
"UNMATCHED_CLOSING_TAG:
CDATA[extended_bounds設定を使用すると ]]></target>
<note>Kibana-SW - String \\"data.search.aggs.buckets.dateHistogram.extendedBounds.help\\" in Json.Root \\"messages\\\\strings\\" "
`);
});
it('throws on missing curly brackets', () => {
const message = `A missing {curly`;
expect(() => verifyICUMessage(message)).toThrowErrorMatchingInlineSnapshot(`
"EXPECT_ARGUMENT_CLOSING_BRACE:
A missing {curly"
`);
});
it('throws on incorrect plural icu-syntax', () => {
// Notice that small/Medium/Large constants are swapped with the translation strings.
const message =
'{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }';
expect(() => verifyICUMessage(message)).toThrowErrorMatchingInlineSnapshot(`
"MISSING_OTHER_CLAUSE:
{textScale, select, small {} {Medium} {Large} {{textScale}} }"
`);
});
it('throws on non-english select icu-syntax', () => {
// Notice that small/Medium/Large constants are swapped with the translation strings.
const message =
'{textScale, select, small {小さい} 中くらい {Medium} other {Large} その他の {{textScale}} }';
expect(() => verifyICUMessage(message)).toThrowErrorMatchingInlineSnapshot(`
"English only selector required. selectFormat options must be in english, got :
{textScale, select, small {} {Medium} other {Large} {{textScale}} }"
`);
});
});
describe('checkEnglishOnly', () => {
it('returns true on english only message', () => {
const result = checkEnglishOnly('english');
expect(result).toEqual(true);
});
it('returns true on empty message', () => {
const result = checkEnglishOnly('');
expect(result).toEqual(true);
});
it('returns false on message containing numbers', () => {
const result = checkEnglishOnly('english 123');
expect(result).toEqual(false);
});
it('returns false on message containing non-english alphabets', () => {
const result = checkEnglishOnly('i am 大きい');
expect(result).toEqual(false);
});
});

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { parse, isSelectElement, SelectElement } from '@formatjs/icu-messageformat-parser';
import { ErrorKind } from '@formatjs/icu-messageformat-parser/error';
// @ts-ignore
import { createParserErrorMessage } from './utils';
export function checkEnglishOnly(message: string) {
return /^[a-z]*$/i.test(message);
}
export function verifySelectFormatElement(element: SelectElement) {
for (const optionKey of Object.keys(element.options)) {
if (!checkEnglishOnly(optionKey)) {
const error = new SyntaxError('EXPECT_SELECT_ARGUMENT_OPTIONS');
// @ts-expect-error Assign to error object
error.kind = ErrorKind.EXPECT_SELECT_ARGUMENT_OPTIONS;
// @ts-expect-error Assign to error object
error.location = element.location;
error.message = `English only selector required. selectFormat options must be in english, got ${optionKey}`;
throw error;
}
}
}
export function verifyICUMessage(message: string) {
try {
const elements = parse(message, { captureLocation: true });
for (const element of elements) {
if (isSelectElement(element)) {
verifySelectFormatElement(element);
}
}
} catch (error) {
if (error.name === 'SyntaxError') {
const errorWithContext = createParserErrorMessage(message, {
loc: {
line: error.location.start.line,
column: error.location.start.column - 1,
},
message: error.message,
});
throw new Error(errorWithContext);
}
throw error;
}
}

View file

@ -6,7 +6,7 @@
The tool is used to extract default messages from all `*.{js, ts, jsx, tsx, html }` files in provided plugins directories to a JSON file.
It uses Babel to parse code and build an AST for each file or a single JS expression if whole file parsing is impossible. The tool is able to validate, extract and match IDs, default messages and descriptions only if they are defined statically and together, otherwise it will fail with detailed explanation. That means one can't define ID in one place and default message in another, or use function call to dynamically create default message etc.
It uses Typescript compiler to parse code and build an AST for each file. The tool is able to validate, extract and match IDs, default messages and descriptions only if they are defined statically and together, otherwise it will fail with detailed explanation. That means one can't define ID in one place and default message in another, or use function call to dynamically create default message etc.
### Examples and restrictions
@ -91,13 +91,12 @@ The `description` is optional, `values` is optional too unless `defaultMessage`
### Usage
```bash
node scripts/i18n_extract --path path/to/plugin --path path/to/another/plugin --output-dir ./translations --output-format json5
node scripts/i18n_extract --path path/to/plugin --path path/to/another/plugin --output-dir ./translations
```
* `path/to/plugin` is an example of path to a directory(-es) where messages searching should start. By default `--path` is `.`, it means that messages from all paths in `.i18nrc.json` will be parsed. Each specified path should start with any path in `.i18nrc.json` or be a part of it.
* `--namespace` Filter which namespaces to extract from Kibana. All namespaces must be defined in the `.i18nrc.json` files.
* `--output-dir` specifies a path to a directory, where `en.json` will be created.\
In case of parsing issues, exception with the necessary information will be thrown to console and extraction will be aborted.
* `--output-format` specifies format of generated `en.json`. By default it is `json`. Use it only if you need a JSON5 file.
* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins)
### Output
@ -135,7 +134,7 @@ The tool throws an exception if `formats` object is missing in locale file.
### Usage
```bash
node scripts/i18n_integrate --source path/to/locale.json --target x-pack/legacy/plugins/translations/translations/locale.json
node scripts/i18n_integrate --source path/to/locale.json --target x-pack/plugins/translations/translations/locale.json
```
* `--source` path to the JSON file with translations that should be integrated.
@ -143,18 +142,8 @@ node scripts/i18n_integrate --source path/to/locale.json --target x-pack/legacy/
[.i18nrc.json](../../../.i18nrc.json) are ignored in this case. It's currently used for integrating of Kibana built-in
translations that are located in a single JSON file within `x-pack/translations` plugin.
* `--dry-run` tells the tool to exit after verification phase and not write translations to the disk.
* `--ignore-incompatible` specifies whether tool should ignore incompatible translations. It may be useful when the code base you're
integrating translations to has changed and some default messages switched to ICU structure that is incompatible with the one used in corresponding translation.
* `--ignore-missing` specifies whether tool should ignore missing translations. It may be useful when the code base you're
integrating translations to has moved forward since the revision translations were created for.
* `--ignore-unused` specifies whether tool should ignore unused translations. It may be useful when the code base you're
integrating translations to has changed and some translations are not needed anymore.
* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins)
### Output
Unless `--target` is specified, the tool generates locale files in plugin folders and few other special locations based on namespaces and corresponding mappings defined in [.i18nrc.json](../../../.i18nrc.json).
## Validation tool
@ -182,8 +171,9 @@ node scripts/i18n_check --fix
```
* `--fix` tells the tool to try to fix as much violations as possible. All errors that tool won't be able to fix will be reported.
* `--ignore-incompatible` specifies whether tool should ignore incompatible translations.
* `--ignore-missing` specifies whether tool should ignore missing translations.
* `--ignore-unused` specifies whether tool should ignore unused translations.
* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins)
* `--namespace` Filter which namespaces to run the i18n checker on. All namespaces must be defined in the `.i18nrc.json` files. This is useful when ran locally against a subset of namespaces on a branch.
* `--ignore-unused` Check against the translation files. Remove outdated message that are no longer inside codebase
* `--ignore-incompatible` Check against the translation files. Remove messages insied the code base that are no longer compatible with the defaultMessage inside the codebase (variables changed)
* `--ignore-untracked` Check against the codebase. Skip checking all files for namespace paths that are not defined inside `.i18nrc.json` files
* `--ignore-malformed` Check against the codebase. Ignore messages with malformed ICU syntax.
* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins)

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
i18n.translate('Multiple_Binary_strings_with_No_Substitution_Template_Literal', {
defaultMessage:
'{objectCount, plural, one {# object} other {# objects}} with unknown types {objectCount, plural, one {was} other {were}} found in Kibana system indices. ' +
'Upgrading with unknown savedObject types is no longer supported. ' +
`To ensure that upgrades will succeed in the future, either re-enable plugins or delete these documents from the Kibana indices`,
values: {
objectCount: 13,
},
});
i18n.translate('more_than_3_No_Substitution_Template_Literals', {
defaultMessage:
`The UI theme that the Kibana UI should use. ` +
`Set to 'enabled' or 'disabled' to enable or disable the dark theme. ` +
`Set to 'system' to have the Kibana UI theme follow the system theme. ` +
`A page refresh is required for the setting to be applied.`,
});

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
i18n.translate('basic', {
defaultMessage: 'i18n.translate default message',
});
i18n.translate('with_ICU_value', {
defaultMessage: 'I have a basic ICU {VALUE_HERE} message',
values: {
VALUE_HERE: 'variable value',
},
});
export const INTERVAL_MINIMUM_TEXT = (minimum: string) =>
i18n.translate('value_defined_as_variable', {
defaultMessage: 'Interval must be at least {minimum}.',
values: { minimum },
});
i18n.translate('with_nested_i18n', {
defaultMessage: 'The {formatLink} for pretty formatted dates.',
values: {
formatLink: i18n.translate('i_am_nested_inside', { defaultMessage: 'format' }),
},
});
i18n.translate('with_html_tags_nested_variables_inside', {
defaultMessage: `Allows you to set which shards handle your search requests.
<ul>
<li><strong>{sessionId}:</strong> restricts operations to execute all search requests on the same shards.
This has the benefit of reusing shard caches across requests.</li>
<li><strong>{custom}:</strong> allows you to define a your own preference.
Use <strong>courier:customRequestPreference</strong> to customize your preference value.</li>
<li><strong>{none}:</strong> means do not set a preference.
This might provide better performance because requests can be spread across all shard copies.
However, results might be inconsistent because different shards might be in different refresh states.</li>
</ul>`,
values: {
sessionId: '123',
custom: 'css',
none: 'noon',
ul: (chunks) => `<ul>${chunks}</ul>`,
li: (chunks) => `<li>${chunks}</li>`,
strong: (chunks) => `<strong>${chunks}</strong>`,
},
});
i18n.translate('with_ignored_tags', {
defaultMessage:
'Update {numberOfIndexPatternsWithScriptedFields} data views that have scripted fields to use runtime fields instead. In most cases, to migrate existing scripts, you will need to change "return <value>;" to "emit(<value>);". Data views with at least one scripted field: {allTitles}',
values: {
allTitles: 'all the titles',
numberOfIndexPatternsWithScriptedFields: '123',
},
ignoreTag: true,
});
const aggName = '123';
const agg = {
fixed_interval: 13,
delay: false,
time_zone: 'UTC',
};
i18n.translate('complext_nesting', {
defaultMessage: '{aggName} (interval: {interval}, {delay} {time_zone})',
values: {
aggName,
interval: agg.fixed_interval,
delay: agg.delay
? i18n.translate('i_am_optional_nested_inside', {
defaultMessage: 'delay: {delay},',
values: {
delay: agg.delay,
},
})
: '',
time_zone: agg.time_zone,
},
});
i18n.translate('double_tagged', {
defaultMessage:
'Returns the maximum value from multiple columns. This is similar to <<esql-mv_max>>\nexcept it is intended to run on multiple columns at once.',
ignoreTag: true,
});
i18n.translate('select_syntax', {
defaultMessage: `{rangeType, select,
between {Must be between {min} and {max}}
gt {Must be greater than {min}}
lt {Must be less than {max}}
other {Must be an integer}
}`,
values: {
min: 20,
max: 40,
rangeType: 'gt',
},
});
i18n.translate('plural_syntax_with_nested_variable', {
values: { totalCases: 1, severity: 'high', caseTitle: 'ok' },
defaultMessage:
'{totalCases, plural, =1 {Case "{caseTitle}" was} other {{totalCases} cases were}} set to {severity}',
});

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
export class SomeComponent extends React.Component {
someFn() {
const message = this.props.intl.formatMessage(
{
id: 'home.tutorial.unexpectedStatusCheckStateErrorDescription',
defaultMessage: 'Unexpected status check state {statusCheckState}',
},
{
statusCheckState: 123,
}
);
}
anotherFn() {
const { intl } = this.props;
const anotherMsg = intl.formatMessage({
id: 'message_with_no_values',
defaultMessage: 'Pipeline batch delay',
});
}
render() {
const { intl } = this.props;
return (
<div
aria-label={intl.formatMessage({
id: 'messsage_inside_component',
defaultMessage: 'Pipeline batch delay',
})}
>
Hello
</div>
);
}
}

View file

@ -6,11 +6,9 @@
* Side Public License, v 1.
*/
import { I18nConfig } from './config';
import { ErrorReporter } from './utils';
import { i18n } from '@kbn/i18n';
export interface I18nCheckTaskContext {
config?: I18nConfig;
reporter: ErrorReporter;
messages: Map<string, { message: string }>;
}
i18n.translate('wrong_select_icu_syntax', {
defaultMessage:
'This is a malformed select ICU {MISSING_VALUE, select, one {one} two {two}} no other',
});

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
export const DEFAULT_MESSAGE_KEY = 'defaultMessage';
export const DESCRIPTION_KEY = 'description';
export const VALUES_KEY = 'values';
export const I18N_RC = '.i18nrc.json';
import { i18n } from '@kbn/i18n';
i18n.translate('extra_value_test_ts_call', {
defaultMessage: 'hey! this value is missing {MISSING_VALUE} value here',
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
export const SomeComponent = () => {
const label = '123';
const regularI18n = i18n.translate('i_am_inside_a_react_component', {
defaultMessage: 'i18n.translate inside a react component',
});
return (
<legend>
<FormattedMessage
id="with_value"
defaultMessage="Set color for value {legendDataLabel}"
values={{ legendDataLabel: label }}
/>
<FormattedMessage id="standard_message" defaultMessage="Reset color" />
{regularI18n}
</legend>
);
};
const ignoreTagAsAFlag = (
<FormattedMessage
id="ignore_flag_without_equals_true"
defaultMessage="Replace <PROJECT_ID> in the following command with your project ID and copy the command"
ignoreTag
/>
);

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n as I18n } from '@kbn/i18n';
I18n.translate('renamed_i18n', {
defaultMessage: 'renamed I18n.translate is parsed!',
});

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
i18n.translate('extra_value_test_ts_call', {
defaultMessage: 'hey! there is an unused value here',
values: {
UNUSED_VALUE: '123',
},
});

View file

@ -6,22 +6,22 @@
* Side Public License, v 1.
*/
import chalk from 'chalk';
import { Listr } from 'listr2';
import { createFailError } from '@kbn/dev-cli-errors';
import { run } from '@kbn/dev-cli-runner';
import { ToolingLog } from '@kbn/tooling-log';
import { getTimeReporter } from '@kbn/ci-stats-reporter';
import { ErrorReporter } from './i18n';
import { isFailError } from '@kbn/dev-cli-errors';
import { I18nCheckTaskContext, MessageDescriptor } from '../types';
import {
extractDefaultMessages,
extractUntrackedMessages,
checkCompatibility,
checkConfigs,
mergeConfigs,
} from './i18n/tasks';
import { I18nCheckTaskContext } from './i18n/types';
checkUntrackedNamespacesTask,
validateTranslationsTask,
validateTranslationFiles,
} from '../tasks';
import { TaskReporter } from '../utils/task_reporter';
import { flagFailError, isDefined, undefinedOrBoolean } from '../utils/verify_bin_flags';
const toolingLog = new ToolingLog({
level: 'info',
@ -37,12 +37,16 @@ const skipOnNoTranslations = ({ config }: I18nCheckTaskContext) =>
run(
async ({
flags: {
// checks inside translation files
'ignore-incompatible': ignoreIncompatible,
'ignore-malformed': ignoreMalformed,
'ignore-missing': ignoreMissing,
'ignore-unused': ignoreUnused,
'include-config': includeConfig,
// checks against codebase
'ignore-malformed': ignoreMalformed,
'ignore-untracked': ignoreUntracked,
'include-config': includeConfig,
namespace: namespace,
fix = false,
path,
},
@ -50,30 +54,40 @@ run(
}) => {
if (
fix &&
(ignoreIncompatible !== undefined ||
ignoreUnused !== undefined ||
ignoreMalformed !== undefined ||
ignoreMissing !== undefined ||
ignoreUntracked !== undefined)
(isDefined(ignoreIncompatible) ||
isDefined(ignoreUnused) ||
isDefined(ignoreUntracked) ||
isDefined(ignoreMalformed))
) {
throw createFailError(
`${chalk.white.bgRed(
' I18N ERROR '
)} none of the --ignore-incompatible, --ignore-malformed, --ignore-unused or --ignore-missing, --ignore-untracked is allowed when --fix is set.`
throw flagFailError(
`none of the --ignore-incompatible, --namespace, --ignore-unused, --ignore-malformed, --ignore-untracked is allowed when --fix is set.`
);
}
if (typeof path === 'boolean' || typeof includeConfig === 'boolean') {
throw createFailError(
`${chalk.white.bgRed(' I18N ERROR ')} --path and --include-config require a value`
throw flagFailError(`--path and --include-config require a value`);
}
if (
!undefinedOrBoolean(fix) ||
!undefinedOrBoolean(ignoreIncompatible) ||
!undefinedOrBoolean(ignoreUnused) ||
!undefinedOrBoolean(ignoreMalformed) ||
!undefinedOrBoolean(ignoreUntracked)
) {
throw flagFailError(
`--fix, --ignore-incompatible, --ignore-malformed, --ignore-unused, and --ignore-untracked can't have a value`
);
}
if (typeof fix !== 'boolean') {
throw createFailError(`${chalk.white.bgRed(' I18N ERROR ')} --fix can't have a value`);
if (typeof namespace === 'boolean') {
throw flagFailError(`--namespace require a value`);
}
const srcPaths = Array().concat(path || ['./src', './packages', './x-pack']);
const filterNamespaces = typeof namespace === 'string' ? [namespace] : namespace;
const kibanaRootPaths = ['./src', './packages', './x-pack'];
const rootPaths = Array().concat(path || kibanaRootPaths);
const list = new Listr<I18nCheckTaskContext>(
[
@ -88,36 +102,29 @@ run(
task.newListr(mergeConfigs(includeConfig), { exitOnError: true }),
},
{
title: 'Checking For Untracked Messages based on .i18nrc.json',
enabled: (_) => !ignoreUntracked,
title: 'Validating i18n Messages',
skip: skipOnNoTranslations,
task: (context, task) =>
task.newListr(extractUntrackedMessages(srcPaths), { exitOnError: true }),
validateTranslationsTask(context, task, {
filterNamespaces,
ignoreMalformed,
}),
},
{
title: 'Validating Default Messages',
skip: skipOnNoTranslations,
task: (context, task) =>
task.newListr(extractDefaultMessages(context.config!, srcPaths), { exitOnError: true }),
title: 'Checking Untracked i18n Messages outside defined namespaces',
enabled: (_) => !ignoreUntracked || !!(filterNamespaces && filterNamespaces.length),
task: (context, task) => checkUntrackedNamespacesTask(context, task, { rootPaths }),
},
{
title: 'Compatibility Checks',
title: 'Validating translation files',
skip: skipOnNoTranslations,
task: (context, task) =>
task.newListr(
checkCompatibility(
context.config!,
{
ignoreMalformed: !!ignoreMalformed,
ignoreIncompatible: !!ignoreIncompatible,
ignoreUnused: !!ignoreUnused,
ignoreMissing: !!ignoreMissing,
fix,
},
log
),
{ exitOnError: true }
),
validateTranslationFiles(context, task, {
filterNamespaces,
fix,
ignoreIncompatible,
ignoreUnused,
}),
},
],
{
@ -128,17 +135,17 @@ run(
);
try {
const reporter = new ErrorReporter();
const messages: Map<string, { message: string }> = new Map();
await list.run({ messages, reporter });
const messages: Map<string, MessageDescriptor[]> = new Map();
const taskReporter = new TaskReporter({ toolingLog });
await list.run({ messages, taskReporter });
reportTime(runStartTime, 'total', {
success: true,
});
} catch (error) {
process.exitCode = 1;
if (error instanceof ErrorReporter) {
error.errors.forEach((e: string | Error) => log.error(e));
if (isFailError(error)) {
reportTime(runStartTime, 'error', {
success: false,
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Listr } from 'listr2';
import { run } from '@kbn/dev-cli-runner';
import { ToolingLog } from '@kbn/tooling-log';
import { getTimeReporter } from '@kbn/ci-stats-reporter';
import { ErrorReporter } from '../utils';
import { I18nCheckTaskContext, MessageDescriptor } from '../types';
import {
checkConfigs,
mergeConfigs,
extractDefaultMessagesTask,
writeExtractedMessagesToFile,
} from '../tasks';
import { flagFailError } from '../utils/verify_bin_flags';
import { TaskReporter } from '../utils/task_reporter';
const toolingLog = new ToolingLog({
level: 'info',
writeTo: process.stdout,
});
const runStartTime = Date.now();
const reportTime = getTimeReporter(toolingLog, 'scripts/i18n_check');
run(
async ({
flags: { path, 'output-dir': outputDir, 'include-config': includeConfig, namespace: namespace },
log,
}) => {
if (!outputDir || typeof outputDir !== 'string') {
throw flagFailError(`--output-dir option should be specified.`);
}
if (
typeof path === 'boolean' ||
typeof includeConfig === 'boolean' ||
typeof namespace === 'boolean'
) {
throw flagFailError(`--namespace --path and --include-config require a value`);
}
const filterNamespaces = typeof namespace === 'string' ? [namespace] : namespace;
const list = new Listr<I18nCheckTaskContext>(
[
{
title: 'Checking .i18nrc.json files',
task: (context, task) =>
task.newListr(checkConfigs(includeConfig), { exitOnError: true }),
},
{
title: 'Merging .i18nrc.json files',
task: (context, task) =>
task.newListr(mergeConfigs(includeConfig), { exitOnError: true }),
},
{
title: 'Extracting Default i18n Messages',
task: (context, task) => extractDefaultMessagesTask(context, task, { filterNamespaces }),
},
{
title: 'Writing Translations file',
enabled: (ctx) => Boolean(outputDir && ctx.messages.size > 0),
task: (context, task) => writeExtractedMessagesToFile(context, task, { outputDir }),
},
],
{
concurrent: false,
exitOnError: true,
renderer: process.env.CI ? 'verbose' : ('default' as any),
}
);
try {
const messages: Map<string, MessageDescriptor[]> = new Map();
const taskReporter = new TaskReporter({ toolingLog });
await list.run({ messages, taskReporter });
reportTime(runStartTime, 'total', {
success: true,
});
} catch (error) {
process.exitCode = 1;
if (error instanceof ErrorReporter) {
error.errors.forEach((e: string | Error) => log.error(e));
reportTime(runStartTime, 'error', {
success: false,
});
} else {
log.error('Unhandled exception!');
log.error(error);
reportTime(runStartTime, 'error', {
success: false,
error: error.message,
});
}
}
},
{
flags: {
allowUnexpected: true,
guessTypesForUnexpectedFlags: true,
},
}
);

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Listr } from 'listr2';
import { run } from '@kbn/dev-cli-runner';
import { ToolingLog } from '@kbn/tooling-log';
import { getTimeReporter } from '@kbn/ci-stats-reporter';
import { ErrorReporter } from '../utils';
import { I18nCheckTaskContext, MessageDescriptor } from '../types';
import {
checkConfigs,
mergeConfigs,
extractDefaultMessagesTask,
integrateTranslations,
validateTranslationFiles,
} from '../tasks';
import { flagFailError } from '../utils/verify_bin_flags';
import { TaskReporter } from '../utils/task_reporter';
const toolingLog = new ToolingLog({
level: 'info',
writeTo: process.stdout,
});
const runStartTime = Date.now();
const reportTime = getTimeReporter(toolingLog, 'scripts/i18n_check');
run(
async ({
flags: { source, target, 'include-config': includeConfig, 'dry-run': dryRun },
log,
}) => {
if (typeof source !== 'string' || typeof target !== 'string') {
throw flagFailError(`--target and --source options should be specified.`);
}
if (typeof includeConfig === 'boolean') {
throw flagFailError(`--include-config require a value`);
}
if (typeof dryRun !== 'boolean') {
throw flagFailError(`--dry-run can't have a value`);
}
const list = new Listr<I18nCheckTaskContext>(
[
{
title: 'Checking .i18nrc.json files',
task: (context, task) =>
task.newListr(checkConfigs(includeConfig), { exitOnError: true }),
},
{
title: 'Merging .i18nrc.json files',
task: (context, task) =>
task.newListr(mergeConfigs(includeConfig), { exitOnError: true }),
},
{
title: 'Extracting Default i18n Messages',
task: (context, task) => extractDefaultMessagesTask(context, task, {}),
},
{
title: 'Integrating Translation file',
task: (context, task) => integrateTranslations(context, task, { source, target }),
},
{
title: 'Validating translation files',
task: (context, task) =>
validateTranslationFiles(context, task, {
fix: !dryRun,
filterTranslationFiles: [target],
}),
},
],
{
concurrent: false,
exitOnError: true,
renderer: process.env.CI ? 'verbose' : ('default' as any),
}
);
try {
const messages: Map<string, MessageDescriptor[]> = new Map();
const taskReporter = new TaskReporter({ toolingLog });
await list.run({ messages, taskReporter });
reportTime(runStartTime, 'total', {
success: true,
});
} catch (error) {
process.exitCode = 1;
if (error instanceof ErrorReporter) {
error.errors.forEach((e: string | Error) => log.error(e));
reportTime(runStartTime, 'error', {
success: false,
});
} else {
log.error('Unhandled exception!');
log.error(error);
reportTime(runStartTime, 'error', {
success: false,
error: error.message,
});
}
}
},
{
flags: {
allowUnexpected: true,
guessTypesForUnexpectedFlags: true,
},
}
);

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { extractCodeMessages } from './code';
export const I18N_RC = '.i18nrc.json';

View file

@ -0,0 +1,462 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Opts } from '@formatjs/ts-transformer';
import ts from 'typescript';
type TypeScript = typeof ts;
const MESSAGE_DESC_KEYS: Array<keyof MessageDescriptor> = [
'id',
'defaultMessage',
'description',
'ignoreTag',
];
import type { MessageDescriptor } from '../types';
export interface ExtractorOpts extends Opts {
onMsgWithValuesExtracted: (filePath: string, msgs: MessageDescriptor[]) => void;
}
export type { MessageDescriptor };
/**
* Check if node is `i18n.translate` node
* @param node
* @param sf
*/
function isMemberMethodI18nTranslateCall(typescript: TypeScript, node: ts.CallExpression) {
const fnNames = new Set(['translate']);
const method = node.expression;
// Handle foo.formatMessage()
if (typescript.isPropertyAccessExpression(method)) {
return fnNames.has(method.name.text);
}
// Handle formatMessage()
return typescript.isIdentifier(method) && fnNames.has(method.text);
}
/**
* Check if node is `foo.bar.formatMessage` node
* @param node
* @param sf
*/
function isMemberMethodFormatMessageCall(typescript: TypeScript, node: ts.CallExpression) {
const fnNames = new Set(['formatMessage']);
const method = node.expression;
// Handle foo.formatMessage()
if (typescript.isPropertyAccessExpression(method)) {
return fnNames.has(method.name.text);
}
// Handle formatMessage()
return typescript.isIdentifier(method) && fnNames.has(method.text);
}
function updateDefaultMessageobject(
typescript: TypeScript,
factory: ts.NodeFactory,
defaultMessageProp: ts.PropertyAssignment
) {
if (typescript.isBinaryExpression(defaultMessageProp.initializer)) {
const [result, isStatic] = evaluateStringConcat(typescript, defaultMessageProp.initializer);
if (!isStatic) {
throw new Error('Unexpected defaultMessage with runtime evaluated variables found.');
}
const stringLiteral = factory.createStringLiteral(result);
return factory.createPropertyAssignment('defaultMessage', stringLiteral);
}
return defaultMessageProp;
}
function setAttributesInObject(
typescript: TypeScript,
factory: ts.NodeFactory,
node: ts.ObjectLiteralExpression,
msg: MessageDescriptor,
ast?: boolean
) {
const newProps = [
factory.createPropertyAssignment('id', factory.createStringLiteral(msg.id)),
factory.createPropertyAssignment('ignoreTag', factory.createTrue()),
];
for (const prop of node.properties) {
if (
typescript.isPropertyAssignment(prop) &&
typescript.isIdentifier(prop.name) &&
MESSAGE_DESC_KEYS.includes(prop.name.text as keyof MessageDescriptor)
) {
if (prop.name.escapedText === 'defaultMessage') {
newProps.push(updateDefaultMessageobject(typescript, factory, prop));
} else {
newProps.push(prop);
}
continue;
}
if (typescript.isPropertyAssignment(prop)) {
newProps.push(prop);
}
}
return factory.createObjectLiteralExpression(factory.createNodeArray(newProps));
}
export function extractMessagesFromCallExpression(
typescript: TypeScript,
factory: ts.NodeFactory,
node: ts.CallExpression,
opts: ExtractorOpts = { onMsgWithValuesExtracted: () => {} },
sf: ts.SourceFile
): ts.VisitResult<ts.CallExpression> {
if (isMemberMethodFormatMessageCall(typescript, node)) {
const [descriptorsObj, valuesObj, ...restArgs] = node.arguments;
if (!valuesObj) {
return node;
}
const msg = extractMessageDescriptor(typescript, descriptorsObj, opts, sf);
if (!msg) {
throw new Error('No message extracted from descriptor');
}
const valuesKeys = getObjectKeys(typescript, valuesObj);
const hasValuesObject = valuesKeys.length > 0;
if (restArgs.length) {
throw new Error(
'We do not support a 3rd argument for formatMessage, please use i18n.translate instead.'
);
}
if (hasValuesObject) {
opts.onMsgWithValuesExtracted(sf.fileName, [{ ...msg, valuesKeys, hasValuesObject }]);
}
} else if (isMemberMethodI18nTranslateCall(typescript, node)) {
const [idArgumentNode, descriptorsObj, ...restArgs] = node.arguments;
if (
typescript.isStringLiteral(idArgumentNode) &&
typescript.isObjectLiteralExpression(descriptorsObj)
) {
const msg = extractMessageDescriptor(typescript, descriptorsObj, opts, sf);
if (!msg) {
return node;
}
const messageId: string = literalToObj(typescript, idArgumentNode) as string;
if (msg.hasValuesObject || msg.ignoreTag) {
opts.onMsgWithValuesExtracted(sf.fileName, [{ ...msg, id: messageId }]);
}
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
setAttributesInObject(
ts,
factory,
descriptorsObj,
{
id: messageId,
},
opts.ast
),
setAttributesInObject(
ts,
factory,
descriptorsObj,
{
id: messageId,
},
opts.ast
),
...restArgs,
]);
}
}
return node;
}
function getObjectKeys(
typescript: TypeScript,
node: ts.ObjectLiteralExpression | ts.Expression
): string[] {
if (!typescript.isObjectLiteralExpression(node)) {
throw new Error(`Expecting object literal expression. Got ${node}`);
}
const valuesKeys = node.properties.map((prop) => {
if (typescript.isPropertyAssignment(prop) || typescript.isShorthandPropertyAssignment(prop)) {
if (typescript.isIdentifier(prop.name)) {
return prop.name.getText();
}
}
});
if (valuesKeys.some((valuesKey) => typeof valuesKey === 'undefined')) {
throw new Error('Unexpected undefined value inside values.');
}
return valuesKeys as string[];
}
export function extractMessageDescriptor(
typescript: TypeScript,
node:
| ts.ObjectLiteralExpression
| ts.JsxOpeningElement
| ts.JsxSelfClosingElement
| ts.Expression,
{ overrideIdFn, extractSourceLocation, preserveWhitespace }: any,
sf: ts.SourceFile
): MessageDescriptor | undefined {
let properties:
| ts.NodeArray<ts.ObjectLiteralElement>
| ts.NodeArray<ts.JsxAttributeLike>
| undefined;
if (typescript.isObjectLiteralExpression(node)) {
properties = node.properties;
} else if (typescript.isJsxOpeningElement(node) || typescript.isJsxSelfClosingElement(node)) {
properties = node.attributes.properties;
}
const msg: MessageDescriptor = { id: '' };
if (!properties) {
return;
}
properties.forEach((prop) => {
const { name } = prop;
const initializer: ts.Expression | ts.JsxExpression | undefined =
typescript.isPropertyAssignment(prop) || typescript.isJsxAttribute(prop)
? prop.initializer
: undefined;
if (name && typescript.isIdentifier(name) && initializer) {
// ignoreTag boolean
if (
initializer.kind === ts.SyntaxKind.TrueKeyword ||
initializer.kind === ts.SyntaxKind.FalseKeyword
) {
switch (name.text) {
case 'ignoreTag': {
msg.ignoreTag = initializer.kind === ts.SyntaxKind.TrueKeyword;
break;
}
}
}
// values object
else if (ts.isObjectLiteralExpression(initializer)) {
msg.hasValuesObject = true;
const valuesKeys = getObjectKeys(typescript, initializer);
msg.valuesKeys = valuesKeys as string[];
} else if (ts.isStringLiteral(initializer)) {
switch (name.text) {
case 'id':
msg.id = initializer.text;
break;
case 'defaultMessage':
msg.defaultMessage = initializer.text;
break;
case 'description':
msg.description = initializer.text;
break;
}
}
// message binary 'a' + `b` + ...
else if (typescript.isBinaryExpression(initializer)) {
const [result, isStatic] = evaluateStringConcat(typescript, initializer);
if (isStatic) {
switch (name.text) {
case 'id':
msg.id = result;
break;
case 'defaultMessage':
msg.defaultMessage = result;
break;
case 'description':
msg.description = result;
break;
}
}
}
// {id: `id`}
else if (typescript.isNoSubstitutionTemplateLiteral(initializer)) {
switch (name.text) {
case 'id':
msg.id = initializer.text;
break;
case 'defaultMessage':
msg.defaultMessage = initializer.text;
break;
case 'description':
msg.description = initializer.text;
break;
}
} else if (typescript.isJsxExpression(initializer) && initializer.expression) {
// <FormattedMessage foo={'barbaz'} />
if (typescript.isStringLiteral(initializer.expression)) {
switch (name.text) {
case 'id':
msg.id = initializer.expression.text;
break;
case 'defaultMessage':
msg.defaultMessage = initializer.expression.text;
break;
case 'description':
msg.description = initializer.expression.text;
break;
}
}
// description={{custom: 1}}
else if (
typescript.isObjectLiteralExpression(initializer.expression) &&
name.text === 'description'
) {
msg.description = objectLiteralExpressionToObj(typescript, initializer.expression);
} else if (
typescript.isObjectLiteralExpression(initializer.expression) &&
name.text === 'values'
) {
msg.hasValuesObject = true;
const valuesKeys = getObjectKeys(typescript, initializer.expression);
msg.valuesKeys = valuesKeys as string[];
}
// <FormattedMessage foo={`bar`} />
else if (typescript.isNoSubstitutionTemplateLiteral(initializer.expression)) {
const { expression } = initializer;
switch (name.text) {
case 'id':
msg.id = expression.text;
break;
case 'defaultMessage':
msg.defaultMessage = expression.text;
break;
case 'description':
msg.description = expression.text;
break;
}
}
// <FormattedMessage foo={'bar' + 'baz'} />
else if (typescript.isBinaryExpression(initializer.expression)) {
const { expression } = initializer;
const [result, isStatic] = evaluateStringConcat(typescript, expression);
if (isStatic) {
switch (name.text) {
case 'id':
msg.id = result;
break;
case 'defaultMessage':
msg.defaultMessage = result;
break;
case 'description':
msg.description = result;
break;
}
}
}
}
// {defaultMessage: 'asd' + bar'}
else if (typescript.isBinaryExpression(initializer)) {
const [result, isStatic] = evaluateStringConcat(typescript, initializer);
if (isStatic) {
switch (name.text) {
case 'id':
msg.id = result;
break;
case 'defaultMessage':
msg.defaultMessage = result;
break;
case 'description':
msg.description = result;
break;
}
}
}
// description: {custom: 1}
else if (typescript.isObjectLiteralExpression(initializer) && name.text === 'description') {
msg.description = objectLiteralExpressionToObj(typescript, initializer);
}
} else if (name && typescript.isIdentifier(name) && !initializer) {
// <FormattedMessage ignoreTag />
if (typescript.isJsxAttribute(prop)) {
switch (name.text) {
case 'ignoreTag': {
msg.ignoreTag = true;
break;
}
}
}
}
});
// We extracted nothing
if (!msg.defaultMessage && !msg.id) {
return;
}
if (extractSourceLocation) {
return {
...msg,
file: sf.fileName,
start: node.pos,
end: node.end,
};
}
return msg;
}
function objectLiteralExpressionToObj(
typescript: TypeScript,
obj: ts.ObjectLiteralExpression
): object {
return obj.properties.reduce((all: Record<string, any>, prop) => {
if (typescript.isPropertyAssignment(prop) && prop.name) {
if (typescript.isIdentifier(prop.name)) {
all[prop.name.escapedText.toString()] = literalToObj(ts, prop.initializer);
} else if (typescript.isStringLiteral(prop.name)) {
all[prop.name.text] = literalToObj(ts, prop.initializer);
}
}
return all;
}, {});
}
function literalToObj(typescript: TypeScript, n: ts.Node) {
if (typescript.isNumericLiteral(n)) {
return +n.text;
}
if (typescript.isStringLiteral(n)) {
return n.text;
}
if (n.kind === ts.SyntaxKind.TrueKeyword) {
return true;
}
if (n.kind === ts.SyntaxKind.FalseKeyword) {
return false;
}
}
function evaluateStringConcat(
typescript: TypeScript,
node: ts.BinaryExpression
): [result: string, isStaticallyEvaluatable: boolean] {
const { right, left } = node;
if (!typescript.isStringLiteral(right) && !typescript.isNoSubstitutionTemplateLiteral(right)) {
return ['', false];
}
if (typescript.isStringLiteral(left) || typescript.isNoSubstitutionTemplateLiteral(left)) {
return [left.text + right.text, true];
}
if (typescript.isBinaryExpression(left)) {
const [result, isStatic] = evaluateStringConcat(typescript, left);
return [result + right.text, isStatic];
}
return ['', false];
}

View file

@ -0,0 +1,339 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readFile } from 'fs/promises';
import * as path from 'path';
import { makeAbsolutePath } from '../utils';
import { extractI18nMessageDescriptors, verifyMessageDescriptor } from './formatjs';
const formatJsFixtureRunner = async (filePath: string) => {
const absolutePath = makeAbsolutePath(
path.join(__dirname, '..', '__fixtures__', 'extraction_signatures', filePath)
);
const source = await readFile(absolutePath, 'utf8');
const extractedMessages = await extractI18nMessageDescriptors(filePath, source);
extractedMessages.forEach((messageDescriptor) => {
try {
verifyMessageDescriptor(messageDescriptor.defaultMessage, messageDescriptor);
} catch (err) {
// @ts-expect-error
messageDescriptor.VerifyError = err.message;
}
});
return { extractedMessages };
};
describe('formatJS Runner', () => {
describe('extraction of `i18n.translate`', () => {
it('parses all expected cases for i18n translated as expected', async () => {
const { extractedMessages } = await formatJsFixtureRunner('i18n_translate.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"with_ICU_value" => Object {
"defaultMessage": "I have a basic ICU {VALUE_HERE} message",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "with_ICU_value",
"start": -1,
"valuesKeys": Array [
"VALUE_HERE",
],
},
"value_defined_as_variable" => Object {
"defaultMessage": "Interval must be at least {minimum}.",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "value_defined_as_variable",
"start": -1,
"valuesKeys": Array [
"minimum",
],
},
"with_nested_i18n" => Object {
"defaultMessage": "The {formatLink} for pretty formatted dates.",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "with_nested_i18n",
"start": -1,
"valuesKeys": Array [
"formatLink",
],
},
"with_html_tags_nested_variables_inside" => Object {
"defaultMessage": "Allows you to set which shards handle your search requests. <ul> <li><strong>{sessionId}:</strong> restricts operations to execute all search requests on the same shards. This has the benefit of reusing shard caches across requests.</li> <li><strong>{custom}:</strong> allows you to define a your own preference. Use <strong>courier:customRequestPreference</strong> to customize your preference value.</li> <li><strong>{none}:</strong> means do not set a preference. This might provide better performance because requests can be spread across all shard copies. However, results might be inconsistent because different shards might be in different refresh states.</li> </ul>",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "with_html_tags_nested_variables_inside",
"start": -1,
"valuesKeys": Array [
"sessionId",
"custom",
"none",
"ul",
"li",
"strong",
],
},
"with_ignored_tags" => Object {
"defaultMessage": "Update {numberOfIndexPatternsWithScriptedFields} data views that have scripted fields to use runtime fields instead. In most cases, to migrate existing scripts, you will need to change \\"return <value>;\\" to \\"emit(<value>);\\". Data views with at least one scripted field: {allTitles}",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "with_ignored_tags",
"ignoreTag": true,
"start": -1,
"valuesKeys": Array [
"allTitles",
"numberOfIndexPatternsWithScriptedFields",
],
},
"complext_nesting" => Object {
"defaultMessage": "{aggName} (interval: {interval}, {delay} {time_zone})",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "complext_nesting",
"start": -1,
"valuesKeys": Array [
"aggName",
"interval",
"delay",
"time_zone",
],
},
"i_am_optional_nested_inside" => Object {
"defaultMessage": "delay: {delay},",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "i_am_optional_nested_inside",
"start": -1,
"valuesKeys": Array [
"delay",
],
},
"double_tagged" => Object {
"defaultMessage": "Returns the maximum value from multiple columns. This is similar to <<esql-mv_max>> except it is intended to run on multiple columns at once.",
"end": -1,
"file": "i18n_translate.ts",
"id": "double_tagged",
"ignoreTag": true,
"start": -1,
},
"select_syntax" => Object {
"defaultMessage": "{rangeType, select, between {Must be between {min} and {max}} gt {Must be greater than {min}} lt {Must be less than {max}} other {Must be an integer} }",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "select_syntax",
"start": -1,
"valuesKeys": Array [
"min",
"max",
"rangeType",
],
},
"plural_syntax_with_nested_variable" => Object {
"defaultMessage": "{totalCases, plural, =1 {Case \\"{caseTitle}\\" was} other {{totalCases} cases were}} set to {severity}",
"end": -1,
"file": "i18n_translate.ts",
"hasValuesObject": true,
"id": "plural_syntax_with_nested_variable",
"start": -1,
"valuesKeys": Array [
"totalCases",
"severity",
"caseTitle",
],
},
"basic" => Object {
"defaultMessage": "i18n.translate default message",
"end": -1,
"file": "i18n_translate.ts",
"id": "basic",
"start": -1,
},
"i_am_nested_inside" => Object {
"defaultMessage": "format",
"end": -1,
"file": "i18n_translate.ts",
"id": "i_am_nested_inside",
"start": -1,
},
}
`);
});
it('parses complex cases for i18n translated as expected', async () => {
const { extractedMessages } = await formatJsFixtureRunner('complex_i18n_cases.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"Multiple_Binary_strings_with_No_Substitution_Template_Literal" => Object {
"defaultMessage": "{objectCount, plural, one {# object} other {# objects}} with unknown types {objectCount, plural, one {was} other {were}} found in Kibana system indices. Upgrading with unknown savedObject types is no longer supported. To ensure that upgrades will succeed in the future, either re-enable plugins or delete these documents from the Kibana indices",
"end": -1,
"file": "complex_i18n_cases.ts",
"hasValuesObject": true,
"id": "Multiple_Binary_strings_with_No_Substitution_Template_Literal",
"start": -1,
"valuesKeys": Array [
"objectCount",
],
},
"more_than_3_No_Substitution_Template_Literals" => Object {
"defaultMessage": "The UI theme that the Kibana UI should use. Set to 'enabled' or 'disabled' to enable or disable the dark theme. Set to 'system' to have the Kibana UI theme follow the system theme. A page refresh is required for the setting to be applied.",
"end": -1,
"file": "complex_i18n_cases.ts",
"id": "more_than_3_No_Substitution_Template_Literals",
"start": -1,
},
}
`);
});
it('parses renamed i18n imports as expected', async () => {
const { extractedMessages } = await formatJsFixtureRunner('renamed_i18n.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"renamed_i18n" => Object {
"defaultMessage": "renamed I18n.translate is parsed!",
"end": -1,
"file": "renamed_i18n.ts",
"id": "renamed_i18n",
"start": -1,
},
}
`);
});
it('throws when ICU message with values does not have defined { values }', async () => {
const { extractedMessages } = await formatJsFixtureRunner('not_defined_value.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"extra_value_test_ts_call" => Object {
"VerifyError": "Messsage with ID extra_value_test_ts_call in not_defined_value.ts requires the following values [MISSING_VALUE] to be defined.",
"defaultMessage": "hey! this value is missing {MISSING_VALUE} value here",
"end": -1,
"file": "not_defined_value.ts",
"id": "extra_value_test_ts_call",
"start": -1,
},
}
`);
});
it('throws when message has extra unused defined { values }', async () => {
const { extractedMessages } = await formatJsFixtureRunner('unused_value.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"extra_value_test_ts_call" => Object {
"VerifyError": "Messsage with ID extra_value_test_ts_call in unused_value.ts has defined values while the defaultMessage does not require any.",
"defaultMessage": "hey! there is an unused value here",
"end": -1,
"file": "unused_value.ts",
"hasValuesObject": true,
"id": "extra_value_test_ts_call",
"start": -1,
"valuesKeys": Array [
"UNUSED_VALUE",
],
},
}
`);
});
it('throws when message has malformed ICU syntx', async () => {
const { extractedMessages } = await formatJsFixtureRunner('malformed_icu.ts');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"wrong_select_icu_syntax" => Object {
"VerifyError": "MISSING_OTHER_CLAUSE",
"defaultMessage": "This is a malformed select ICU {MISSING_VALUE, select, one {one} two {two}} no other",
"end": -1,
"file": "malformed_icu.ts",
"id": "wrong_select_icu_syntax",
"start": -1,
},
}
`);
});
});
describe('extraction inside react components', () => {
it('parses intl prop correctly', async () => {
const { extractedMessages } = await formatJsFixtureRunner('intl_prop.tsx');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"home.tutorial.unexpectedStatusCheckStateErrorDescription" => Object {
"defaultMessage": "Unexpected status check state {statusCheckState}",
"end": 663,
"file": "intl_prop.tsx",
"hasValuesObject": true,
"id": "home.tutorial.unexpectedStatusCheckStateErrorDescription",
"start": 499,
"valuesKeys": Array [
"statusCheckState",
],
},
"message_with_no_values" => Object {
"defaultMessage": "Pipeline batch delay",
"end": 904,
"file": "intl_prop.tsx",
"id": "message_with_no_values",
"start": 815,
},
"messsage_inside_component" => Object {
"defaultMessage": "Pipeline batch delay",
"end": 1125,
"file": "intl_prop.tsx",
"id": "messsage_inside_component",
"start": 1021,
},
}
`);
});
it('parses FormattedMessage and i18n.translate inside react components correctly', async () => {
const { extractedMessages } = await formatJsFixtureRunner('intl_prop.tsx');
expect(extractedMessages).toMatchInlineSnapshot(`
Map {
"home.tutorial.unexpectedStatusCheckStateErrorDescription" => Object {
"defaultMessage": "Unexpected status check state {statusCheckState}",
"end": 663,
"file": "intl_prop.tsx",
"hasValuesObject": true,
"id": "home.tutorial.unexpectedStatusCheckStateErrorDescription",
"start": 499,
"valuesKeys": Array [
"statusCheckState",
],
},
"message_with_no_values" => Object {
"defaultMessage": "Pipeline batch delay",
"end": 904,
"file": "intl_prop.tsx",
"id": "message_with_no_values",
"start": 815,
},
"messsage_inside_component" => Object {
"defaultMessage": "Pipeline batch delay",
"end": 1125,
"file": "intl_prop.tsx",
"id": "messsage_inside_component",
"start": 1021,
},
}
`);
});
});
});

View file

@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { transformWithTs } from '@formatjs/ts-transformer';
import difference from 'lodash/difference';
import * as icuParser from '@formatjs/icu-messageformat-parser';
import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
import ts from 'typescript';
type TypeScript = typeof ts;
import { extractMessagesFromCallExpression, MessageDescriptor, ExtractorOpts } from './call_expt';
import { extractMessageFromJsxComponent } from './react';
function getVisitor(
typescript: TypeScript,
ctx: ts.TransformationContext,
sf: ts.SourceFile,
opts: ExtractorOpts
) {
const visitor: ts.Visitor = (node: ts.Node): ts.Node => {
let newNode: ts.Node = node;
if (typescript.isCallExpression(node)) {
newNode = extractMessagesFromCallExpression(ts, ctx.factory, node, opts, sf) as ts.Node;
} else if (typescript.isJsxOpeningElement(node) || typescript.isJsxSelfClosingElement(node)) {
newNode = extractMessageFromJsxComponent(
ts,
ctx.factory,
node as ts.JsxOpeningElement,
opts,
sf
) as ts.Node;
}
return typescript.visitEachChild(newNode, visitor, ctx);
};
return visitor;
}
export function normalizeI18nTranslateSignature(typescript: TypeScript, opts: ExtractorOpts) {
const transformFn: ts.TransformerFactory<ts.SourceFile> = (ctx) => {
return (sf) => {
return typescript.visitEachChild(sf, getVisitor(typescript, ctx, sf, opts), ctx);
};
};
return transformFn;
}
export const extractValues = (elements: MessageFormatElement[]) => {
return elements.reduce((acc, element) => {
if (icuParser.isTagElement(element)) {
acc.push(element.value);
const nested = extractValues(element.children);
nested.forEach((item) => acc.push(item));
} else if (icuParser.isSelectElement(element) || icuParser.isPluralElement(element)) {
acc.push(element.value);
const optionElements = Object.values(element.options).flatMap(({ value }) => value);
const nested = extractValues(optionElements);
nested.forEach((item) => acc.push(item));
} else if (!icuParser.isLiteralElement(element) && !icuParser.isPoundElement(element)) {
acc.push(element.value);
}
return acc;
}, [] as string[]);
};
export const verifyMessagesWithValues = (
messageDescriptor: MessageDescriptor,
elements: MessageFormatElement[]
) => {
if (elements.every(icuParser.isLiteralElement)) {
// All message elements are literals (plain text)
// no values must be defined in the message definition.
if (messageDescriptor.hasValuesObject) {
throw new Error(
`Messsage with ID ${messageDescriptor.id} in ${messageDescriptor.file} has defined values while the defaultMessage does not require any.`
);
}
return;
}
const valuesInsideMessage = [...new Set(extractValues(elements))];
const excessValues = difference(messageDescriptor.valuesKeys || [], valuesInsideMessage);
if (excessValues.length) {
throw new Error(
`Messsage with ID ${messageDescriptor.id} in ${
messageDescriptor.file
} has the following unnecessary values [${excessValues.join(', ')}]`
);
}
const nonDefinedValues = difference(valuesInsideMessage, messageDescriptor.valuesKeys || []);
if (nonDefinedValues.length) {
throw new Error(
`Messsage with ID ${messageDescriptor.id} in ${
messageDescriptor.file
} requires the following values [${nonDefinedValues.join(', ')}] to be defined.`
);
}
};
export const verifyMessageDescriptor = (
defaultMessage: string | undefined,
messageDescriptor: MessageDescriptor
) => {
if (typeof defaultMessage !== 'string') {
throw new Error('We require each i18n definition to include a `defaultMessage`.');
}
const elements = icuParser.parse(defaultMessage, {
requiresOtherClause: true,
shouldParseSkeletons: false,
ignoreTag: messageDescriptor.ignoreTag,
});
verifyMessagesWithValues(messageDescriptor, elements);
};
export const verifyMessageIdStartsWithNamespace = (
messageDescriptor: MessageDescriptor,
namespace: string
): boolean => {
/**
* Example:
* namespace: advancedSettings
* Valid messageId: advancedSettings.advancedSettingsLabel
* Invalid messageId: advancedSettings123.advancedSettingsLabel
* Invalid messageId: something_else.advancedSettingsLabel
*/
return messageDescriptor.id.startsWith(`${namespace}.`);
};
export async function extractI18nMessageDescriptors(fileName: string, source: string) {
const extractedMessages = new Map<string, MessageDescriptor>();
try {
ts.transpileModule(source, {
compilerOptions: {
allowJs: true,
target: ts.ScriptTarget.ESNext,
noEmit: true,
experimentalDecorators: true,
},
reportDiagnostics: true,
fileName,
transformers: {
before: [
normalizeI18nTranslateSignature(ts, {
extractSourceLocation: true,
ast: true,
onMsgWithValuesExtracted(_, msgs) {
msgs.map((msg) => {
const newDefinition = { ...msg };
const { id } = msg;
extractedMessages.set(id, newDefinition);
});
},
}),
transformWithTs(ts, {
additionalFunctionNames: ['translate'],
extractSourceLocation: true,
removeDefaultMessage: true,
ast: true,
onMsgExtracted(_, msgs) {
msgs.map((msg) => {
const { id } = msg;
if (extractedMessages.has(id)) {
const existingMsg = extractedMessages.get(id);
extractedMessages.set(id, {
...existingMsg,
...msg,
});
return;
}
extractedMessages.set(id, msg);
});
},
}),
],
},
});
} catch (err) {
throw new Error(`Error parsing file ${fileName}: ${err}`);
}
return extractedMessages;
}

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import ts from 'typescript';
type TypeScript = typeof ts;
import { extractMessageDescriptor } from './call_expt';
export function isSingularMessageDecl(
typescript: TypeScript,
node: ts.CallExpression | ts.JsxOpeningElement | ts.JsxSelfClosingElement,
additionalComponentNames: string[]
) {
const compNames = new Set(['FormattedMessage']);
let fnName = '';
if (typescript.isCallExpression(node) && typescript.isIdentifier(node.expression)) {
fnName = node.expression.text;
} else if (typescript.isJsxOpeningElement(node) && typescript.isIdentifier(node.tagName)) {
fnName = node.tagName.text;
} else if (typescript.isJsxSelfClosingElement(node) && typescript.isIdentifier(node.tagName)) {
fnName = node.tagName.text;
}
return compNames.has(fnName);
}
type Opts = any;
export function extractMessageFromJsxComponent(
typescript: TypeScript,
factory: ts.NodeFactory,
node: ts.JsxSelfClosingElement,
opts: Opts,
sf: ts.SourceFile
): ts.VisitResult<ts.JsxSelfClosingElement>;
export function extractMessageFromJsxComponent(
typescript: TypeScript,
factory: ts.NodeFactory,
node: ts.JsxOpeningElement,
opts: Opts,
sf: ts.SourceFile
): ts.VisitResult<ts.JsxOpeningElement>;
export function extractMessageFromJsxComponent(
typescript: TypeScript,
factory: ts.NodeFactory,
node: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
opts: Opts,
sf: ts.SourceFile
): ts.VisitResult<typeof node> {
const { onMsgWithValuesExtracted } = opts;
if (!isSingularMessageDecl(typescript, node, opts.additionalComponentNames || [])) {
return node;
}
const msg = extractMessageDescriptor(typescript, node, opts, sf);
if (!msg) {
return node;
}
if (msg.hasValuesObject || (msg.ignoreTag && typeof onMsgWithValuesExtracted === 'function')) {
onMsgWithValuesExtracted(sf.fileName, [msg]);
}
return node;
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dev/i18n/serializers/json should serialize default messages to JSON 1`] = `
exports[`i18n json serializer should serialize default messages to JSON 1`] = `
"{
\\"formats\\": {
\\"number\\": {
@ -59,22 +59,22 @@ exports[`dev/i18n/serializers/json should serialize default messages to JSON 1`]
},
\\"relative\\": {
\\"years\\": {
\\"units\\": \\"year\\"
\\"style\\": \\"long\\"
},
\\"months\\": {
\\"units\\": \\"month\\"
\\"style\\": \\"long\\"
},
\\"days\\": {
\\"units\\": \\"day\\"
\\"style\\": \\"long\\"
},
\\"hours\\": {
\\"units\\": \\"hour\\"
\\"style\\": \\"long\\"
},
\\"minutes\\": {
\\"units\\": \\"minute\\"
\\"style\\": \\"long\\"
},
\\"seconds\\": {
\\"units\\": \\"second\\"
\\"style\\": \\"long\\"
}
}
},

View file

@ -6,12 +6,5 @@
* Side Public License, v 1.
*/
import { Formats } from '@kbn/i18n';
export { serializeToJson } from './json';
export { serializeToJson5 } from './json5';
export type Serializer = (
messages: Array<[string, { message: string; description?: string }]>,
formats?: Formats
) => string;
export type { Serializer } from './types';

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { serializeToJson } from './json';
describe('i18n json serializer', () => {
test('should serialize default messages to JSON', () => {
expect(
serializeToJson([
{
id: 'plugin1.message.id-1',
defaultMessage: 'Message text 1 ',
},
{
id: 'plugin2.message.id-2',
defaultMessage: 'Message text 2',
description: 'Message description',
},
])
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { defaultEnFormats } from '@kbn/i18n/src/core';
import { Serializer, FileOutput } from './types';
export const serializeToJson: Serializer = (messageDescriptors, formats = defaultEnFormats) => {
const resultJsonObject: FileOutput = {
formats,
messages: {},
};
for (const messageDescriptor of messageDescriptors) {
const { id, defaultMessage, description } = messageDescriptor;
if (typeof id !== 'string' || typeof defaultMessage !== 'string') {
throw new Error(
`Unexpected message inputs, got: ${JSON.stringify(messageDescriptor, null, 2)}`
);
}
if (description && typeof description === 'string') {
resultJsonObject.messages[id] = { text: defaultMessage, comment: description };
} else {
resultJsonObject.messages[id] = defaultMessage;
}
}
return JSON.stringify(resultJsonObject, undefined, 2).concat('\n');
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { MessageDescriptor } from '@formatjs/intl';
import { TranslationInput } from '@kbn/i18n';
export interface FileOutput {
messages: Record<string, string | { text: string; comment: string }>;
formats: TranslationInput['formats'];
}
export type Serializer = (
messages: MessageDescriptor[],
formats?: TranslationInput['formats']
) => string;

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readFile as readFileAsync } from 'fs/promises';
import { globNamespacePaths, makeAbsolutePath } from '../../utils';
import { extractI18nMessageDescriptors } from '../../extractors/formatjs';
import { I18nConfig } from '../../types';
export interface Params {
rootPaths: string[];
config: I18nConfig;
}
export const getNamespacePathsForRoot = (rootPath: string, definedPaths: string[]) => {
const absoluteRootPath = makeAbsolutePath(rootPath, true);
return definedPaths
.map((definedPath) => makeAbsolutePath(definedPath))
.filter((definedPath) => definedPath.startsWith(absoluteRootPath));
};
export async function* extractUntrackedMessages(rootPath: string, definedPathsForRoot: string[]) {
const untrackedFiles = await globNamespacePaths(rootPath, {
additionalIgnore: [
...definedPathsForRoot,
'**/build/**',
'**/__fixtures__/**',
'**/packages/kbn-i18n/**',
'**/packages/kbn-i18n-react/**',
'**/packages/kbn-plugin-generator/template/**',
'**/test/**',
'**/scripts/**',
'**/src/dev/**',
'**/target/**',
'**/dist/**',
// MUST BE DEFINED IN A NAMESPACE IGNORING UNTIL FIXED
'**/x-pack/examples/**',
],
absolute: true,
});
let itter = 1;
for (const untrackedFilePath of untrackedFiles) {
const source = await readFileAsync(untrackedFilePath, 'utf8');
const extractedMessages = await extractI18nMessageDescriptors(untrackedFilePath, source);
yield {
untrackedFilePath,
extractedMessages,
totalChecked: itter,
totalToCheck: untrackedFiles.length,
};
itter++;
}
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { checkUntrackedNamespacesTask } from './task';

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PRESET_TIMER } from 'listr2';
import { TaskSignature } from '../../types';
import { ErrorReporter } from '../../utils/error_reporter';
import {
extractUntrackedMessages,
getNamespacePathsForRoot,
} from './extract_untracked_translations';
export interface TaskOptions {
rootPaths: string[];
}
export const checkUntrackedNamespacesTask: TaskSignature<TaskOptions> = (
context,
task,
options
) => {
const { config } = context;
const { rootPaths } = options;
const errorReporter = new ErrorReporter({ name: 'Untracked Translations' });
if (!config || !Object.keys(config.paths).length) {
throw errorReporter.reportFailure(
'None of input paths is covered by the mappings in .i18nrc.json'
);
}
return task.newListr(
(parent) => [
{
title: `Checking Untracked Messages not defined in .i18nrc namespaces`,
task: async () => {
const definedNamespacesPaths = Object.values(config.paths).flat();
for (const rootPath of rootPaths) {
parent.title = `Checking untracked messages inside "${rootPath}"`;
const definedPathsForRoot = getNamespacePathsForRoot(rootPath, definedNamespacesPaths);
for await (const untrackedMessageDetails of extractUntrackedMessages(
rootPath,
definedPathsForRoot
)) {
const { untrackedFilePath, extractedMessages, totalChecked, totalToCheck } =
untrackedMessageDetails;
task.output = `[${totalChecked}/${totalToCheck}] Found ${extractedMessages.size} untracked messages in file ${untrackedFilePath}`;
if (extractedMessages.size > 0) {
const error = new Error(
`The file ${untrackedFilePath} contains i18n messages but is not defined in the .i18nrc namespaces paths`
);
errorReporter.report(error);
}
}
}
if (errorReporter.hasErrors()) {
throw errorReporter.throwErrors();
}
const formattedRootPaths = rootPaths.map((rootPath) => `"${rootPath}"`).join(', ');
parent.title = `Check all untracked messages inside [${formattedRootPaths}]`;
},
},
],
{ exitOnError: true, rendererOptions: { timer: PRESET_TIMER }, collectErrors: 'full' }
);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readFile as readFileAsync } from 'fs/promises';
import { extractI18nMessageDescriptors } from '../../extractors/formatjs';
import { globNamespacePaths } from '../../utils';
const formatJsRunner = async (filePaths: string[]) => {
const allNamespaceMessages = new Map();
for (const filePath of filePaths) {
const source = await readFileAsync(filePath, 'utf8');
const extractedMessages = await extractI18nMessageDescriptors(filePath, source);
extractedMessages.forEach((extractedMessage) => {
allNamespaceMessages.set(extractedMessage.id, extractedMessage);
});
}
return allNamespaceMessages;
};
export const runForNamespacePath = async (namespaceRoots: string[]) => {
const namespacePaths = await globNamespacePaths(namespaceRoots);
const allNamespaceMessages = await formatJsRunner(namespacePaths);
return allNamespaceMessages;
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { extractDefaultMessagesTask } from './task';

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PRESET_TIMER } from 'listr2';
import { TaskSignature } from '../../types';
import { runForNamespacePath } from './extract_with_formatjs';
import { ErrorReporter } from '../../utils/error_reporter';
export interface TaskOptions {
filterNamespaces?: string[];
}
export const extractDefaultMessagesTask: TaskSignature<TaskOptions> = (
context,
task,
{ filterNamespaces }
) => {
const { config } = context;
const errorReporter = new ErrorReporter({ name: 'Extract Translations' });
if (!config || !Object.keys(config.paths).length) {
throw errorReporter.reportFailure(
'None of input paths is covered by the mappings in .i18nrc.json'
);
}
return task.newListr(
(parent) => [
{
title: `Extracting i18n messages inside namespaces`,
task: async () => {
const namespacesDetails = Object.entries(config.paths).filter(([namespace]) => {
if (filterNamespaces && filterNamespaces.length) {
return filterNamespaces.some((filterNamespace) => filterNamespace === namespace);
}
return true;
});
let iter = 0;
let totalMessagesCount = 0;
for (const [namespace, namespacePaths] of namespacesDetails) {
const countMessage = `Extracting i18n messages inside "${namespace}" namespace`;
task.output = countMessage;
const allNamespaceMessages = await runForNamespacePath(namespacePaths);
const messagesCount = allNamespaceMessages.size;
parent.title = `[${iter + 1}/${
namespacesDetails.length
}] Successfully extracted Namespace "${namespace}" with ${messagesCount} defined i18n messages.`;
iter++;
totalMessagesCount += messagesCount;
context.messages.set(namespace, [...allNamespaceMessages.values()]);
}
parent.title = `[${iter}/${namespacesDetails.length}] Successfully extracted all namespaces with ${totalMessagesCount} total defined i18n messages.`;
},
},
],
{ exitOnError: true, rendererOptions: { timer: PRESET_TIMER }, collectErrors: 'minimal' }
);
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { mergeConfigs, checkConfigs } from './verify_rc_files';
export { validateTranslationsTask } from './validate_translations';
export { checkUntrackedNamespacesTask } from './check_untracked_namespaces';
export { writeExtractedMessagesToFile } from './write_extraced_messages_to_file';
export { extractDefaultMessagesTask } from './extract_default_translations';
export { validateTranslationFiles } from './validate_translation_files';
export { integrateTranslations } from './integrate_translations';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { integrateTranslations } from './task';

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PRESET_TIMER } from 'listr2';
import { readFile as readFileAsync } from 'fs/promises';
import { TaskSignature } from '../../types';
import { ErrorReporter } from '../../utils/error_reporter';
import { makeAbsolutePath } from '../../utils';
import { updateTranslationFile } from '../validate_translation_files';
import { groupMessagesByNamespace } from '../validate_translation_files/group_messages_by_namespace';
export interface TaskOptions {
source: string;
target: string;
}
export const integrateTranslations: TaskSignature<TaskOptions> = (
context,
task,
{ source, target }
) => {
const { config } = context;
const errorReporter = new ErrorReporter({ name: 'Validate Translations' });
if (!config || !Object.keys(config.paths).length) {
throw errorReporter.reportFailure(
'None of input paths is covered by the mappings in .i18nrc.json'
);
}
return task.newListr(
(parent) => [
{
title: `Integrating ${source}`,
task: async () => {
const namespaces = Object.keys(config.paths);
const sourceFilePath = makeAbsolutePath(source);
const localizedMessages = JSON.parse((await readFileAsync(sourceFilePath)).toString());
const namespacedTranslatedMessages = groupMessagesByNamespace(
localizedMessages,
namespaces
);
await updateTranslationFile({
namespacedTranslatedMessages,
targetFilePath: target,
formats: localizedMessages.formats,
});
},
},
],
{ exitOnError: true, rendererOptions: { timer: PRESET_TIMER }, collectErrors: 'minimal' }
);
};

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import path from 'path';
export const getLocalesFromFiles = (filePaths: string[]) => {
const localesMap = new Map<string, string>();
for (const filePath of filePaths) {
const locale = getLocaleFromFile(filePath);
if (localesMap.has(locale)) {
throw new Error(`Locale file ${locale} already exists in ${filePath}`);
}
localesMap.set(locale, filePath);
}
return localesMap;
};
export const getLocaleFromFile = (filePath: string): string => {
const { name } = path.parse(filePath);
return name;
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { TranslationInput } from '@kbn/i18n';
// Map<namespace, [id, translatedMessage]>
export type GroupedMessagesByNamespace = Map<string, Array<[string, string | { message: string }]>>;
export function groupMessagesByNamespace(
translationInput: TranslationInput,
knownNamespaces: string[]
): GroupedMessagesByNamespace {
const localizedMessagesByNamespace: GroupedMessagesByNamespace = new Map();
for (const [messageId, messageValue] of Object.entries(translationInput.messages)) {
const namespace = knownNamespaces.find((key) => messageId.startsWith(`${key}.`));
if (!namespace) {
continue;
}
if (!localizedMessagesByNamespace.has(namespace)) {
localizedMessagesByNamespace.set(namespace, []);
}
localizedMessagesByNamespace
.get(namespace)!
.push([
messageId,
{ message: typeof messageValue === 'string' ? messageValue : messageValue.text },
]);
}
return localizedMessagesByNamespace;
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { validateTranslationFiles } from './task';
export { updateTranslationFile } from './update_translation_file';

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { TranslationInput } from '@kbn/i18n';
import { readFile as readFileAsync } from 'fs/promises';
export async function parseTranslationFile(translationFile: string): Promise<TranslationInput> {
const fileString = await readFileAsync(translationFile, 'utf-8');
const translationInput: TranslationInput = JSON.parse(fileString);
return translationInput;
}

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { I18nCheckTaskContext, MessageDescriptor } from '../../types';
import { verifyMessageDescriptor } from '../../extractors/formatjs';
import type { GroupedMessagesByNamespace } from './group_messages_by_namespace';
import { TaskReporter } from '../../utils/task_reporter';
import { ErrorReporter } from '../../utils';
export const removeOutdatedTranslations = ({
context,
namespacedTranslatedMessages,
filterNamespaces,
taskReporter,
errorReporter,
}: {
context: I18nCheckTaskContext;
namespacedTranslatedMessages: GroupedMessagesByNamespace;
filterNamespaces?: string[];
taskReporter: TaskReporter;
errorReporter: ErrorReporter;
}) => {
for (const [namespace, translatedMessages] of namespacedTranslatedMessages) {
if (filterNamespaces) {
const isInFilteredNamespace = filterNamespaces.find((n) => n === namespace);
if (!isInFilteredNamespace) {
// if not in the targeted namespace then just keep the messages as is.
namespacedTranslatedMessages.set(namespace, translatedMessages);
continue;
}
}
const extractedMessages = context.messages.get(namespace);
if (!extractedMessages) {
// the whole namespace is removed from the codebase. remove from file.
namespacedTranslatedMessages.delete(namespace);
taskReporter.log(`The whole namespace ${namespace} has been removed from the codebase.`);
} else {
const { updatedMessages, outdatedMessages } = removeOutdatedMessages(
extractedMessages,
translatedMessages
);
outdatedMessages.forEach((outdatedMessage) => {
const message = `Found incompatible message with id ${outdatedMessage[0]}.`;
taskReporter.log(message);
errorReporter.report(message);
});
namespacedTranslatedMessages.set(namespace, updatedMessages);
}
}
return namespacedTranslatedMessages;
};
const removeOutdatedMessages = (
extractedMessages: MessageDescriptor[],
translationMessages: Array<[string, string | { message: string }]>
): Record<
'outdatedMessages' | 'updatedMessages',
Array<[string, string | { message: string }]>
> => {
const outdatedMessages: Array<[string, string | { message: string }]> = [];
let updatedMessages = translationMessages;
updatedMessages = translationMessages.filter(([translatedId, translatedMessage]) => {
const messageDescriptor = extractedMessages.find(({ id }) => id === translatedId);
if (!messageDescriptor?.hasValuesObject) {
return true;
}
try {
verifyMessageDescriptor(
typeof translatedMessage === 'string' ? translatedMessage : translatedMessage.message,
messageDescriptor
);
return true;
} catch (err) {
outdatedMessages.push([translatedId, translatedMessage]);
// failed to verify message against latest descriptor. remove from file.
return false;
}
});
return { updatedMessages, outdatedMessages };
};

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { difference } from 'lodash';
import { I18nCheckTaskContext, MessageDescriptor } from '../../types';
import { ErrorReporter } from '../../utils';
import { TaskReporter } from '../../utils/task_reporter';
import type { GroupedMessagesByNamespace } from './group_messages_by_namespace';
export const removeUnusedTranslations = ({
context,
namespacedTranslatedMessages,
filterNamespaces,
taskReporter,
errorReporter,
}: {
context: I18nCheckTaskContext;
namespacedTranslatedMessages: GroupedMessagesByNamespace;
filterNamespaces?: string[];
taskReporter: TaskReporter;
errorReporter: ErrorReporter;
}) => {
for (const [namespace, translatedMessages] of namespacedTranslatedMessages) {
if (filterNamespaces) {
const isInFilteredNamespace = filterNamespaces.find((n) => n === namespace);
if (!isInFilteredNamespace) {
// if not in the targeted namespace then just keep the messages as is.
namespacedTranslatedMessages.set(namespace, translatedMessages);
continue;
}
}
const extractedMessages = context.messages.get(namespace);
if (!extractedMessages) {
// the whole namespace is removed from the codebase. remove from file.
taskReporter.log(`The whole namespace ${namespace} has been removed from the codebase.`);
namespacedTranslatedMessages.delete(namespace);
} else {
const { updatedMessages, unusedMessages } = removeUnusedMessages(
extractedMessages,
translatedMessages
);
unusedMessages.forEach((unusedMessage) => {
const message = `Found no longer used message with id ${unusedMessage[0]}.`;
taskReporter.log(message);
errorReporter.report(message);
});
namespacedTranslatedMessages.set(namespace, updatedMessages);
}
}
return namespacedTranslatedMessages;
};
const removeUnusedMessages = (
extractedMessages: MessageDescriptor[],
translationMessages: Array<[string, string | { message: string }]>
): Record<'unusedMessages' | 'updatedMessages', Array<[string, string | { message: string }]>> => {
const extractedMessagesIds = [...extractedMessages].map(({ id }) => id);
const translationMessagesIds = [...translationMessages.map(([id]) => id)];
const unusedTranslations = difference(translationMessagesIds, extractedMessagesIds);
const unusedMessages: Array<[string, string | { message: string }]> = [];
let updatedMessages = translationMessages;
if (unusedTranslations.length > 0) {
updatedMessages = translationMessages.filter(([id]) => {
const unusedMessage = unusedTranslations.find(
(unusedTranslationId) => unusedTranslationId === id
);
if (unusedMessage) {
unusedMessages.push([id, unusedMessage]);
}
return !unusedMessage;
});
}
return { updatedMessages, unusedMessages };
};

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PRESET_TIMER } from 'listr2';
import { parseTranslationFile } from './parse_translation_file';
import { groupMessagesByNamespace } from './group_messages_by_namespace';
import { removeUnusedTranslations } from './remove_unused_translations';
import { removeOutdatedTranslations } from './remove_outdated_translations';
import { updateTranslationFile } from './update_translation_file';
import { ErrorReporter } from '../../utils/error_reporter';
import { getLocalesFromFiles } from './get_locale_from_file';
import { TaskSignature } from '../../types';
import { makeAbsolutePath } from '../../utils';
export interface TaskOptions {
fix?: boolean;
filterNamespaces?: string[];
filterTranslationFiles?: string[];
ignoreUnused?: boolean;
ignoreIncompatible?: boolean;
}
export const validateTranslationFiles: TaskSignature<TaskOptions> = (context, task, options) => {
const { config, taskReporter } = context;
const {
filterNamespaces,
filterTranslationFiles,
fix = false,
ignoreUnused,
ignoreIncompatible,
} = options;
const errorReporter = new ErrorReporter({ name: 'Validate Translation Files' });
if (!config || !Object.keys(config.paths).length) {
throw errorReporter.reportFailure(
'None of input paths is covered by the mappings in .i18nrc.json'
);
}
const namespaces = Object.keys(config.paths);
return task.newListr(
(parent) => [
{
title: `Verifying messages inside translation files`,
task: async () => {
const translationFiles = getLocalesFromFiles(config.translations);
for (const filePath of translationFiles.values()) {
const translationInput = await parseTranslationFile(filePath);
if (filterTranslationFiles && filterTranslationFiles.length) {
const matchingFilteredFile = filterTranslationFiles.find((filterTranslationFile) => {
return makeAbsolutePath(filterTranslationFile) === makeAbsolutePath(filePath);
});
if (!matchingFilteredFile) {
continue;
}
}
parent.title = `Verifying transltion file ${filePath}`;
task.output = `Grouping by namespace`;
let namespacedTranslatedMessages = groupMessagesByNamespace(
translationInput,
namespaces
);
if (!ignoreUnused) {
parent.title = `Removing unused translations`;
namespacedTranslatedMessages = removeUnusedTranslations({
namespacedTranslatedMessages,
filterNamespaces,
context,
taskReporter,
errorReporter,
});
}
if (!ignoreIncompatible) {
parent.title = `Removing incompatible translations`;
namespacedTranslatedMessages = removeOutdatedTranslations({
namespacedTranslatedMessages,
filterNamespaces,
context,
taskReporter,
errorReporter,
});
}
parent.title = fix ? `Updating translation file` : `No fixes will be commited to file.`;
if (fix) {
await updateTranslationFile({
formats: translationInput.formats,
namespacedTranslatedMessages,
targetFilePath: filePath,
});
} else if (errorReporter.hasErrors()) {
// only throw if --fix is not set (or dry-run)
throw errorReporter.throwErrors();
}
}
},
},
],
{ exitOnError: true, rendererOptions: { timer: PRESET_TIMER }, collectErrors: 'full' }
);
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { writeFile as writeFileAsync } from 'fs/promises';
import { Formats } from '@kbn/i18n';
import { serializeToJson } from '../../serializers';
import type { GroupedMessagesByNamespace } from './group_messages_by_namespace';
import { makeAbsolutePath } from '../../utils';
interface TranslationFileDetails {
namespacedTranslatedMessages: GroupedMessagesByNamespace;
targetFilePath: string;
formats?: Formats;
}
export async function updateTranslationFile({
namespacedTranslatedMessages,
formats,
targetFilePath,
}: TranslationFileDetails) {
try {
const sortedMessages = [...namespacedTranslatedMessages.values()]
.flat()
.map(([id, details]) => {
return {
id,
defaultMessage: typeof details === 'string' ? details : details.message,
};
})
.sort(({ id: key1 }, { id: key2 }) => key1.localeCompare(key2));
const fileJsonContent = serializeToJson(sortedMessages, formats);
await writeFileAsync(makeAbsolutePath(targetFilePath), fileJsonContent);
} catch (err) {
throw err;
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { validateTranslationsTask } from './task';
export { validateMessages } from './per_namespace';

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { readFile as readFileAsync } from 'fs/promises';
import { diffStrings } from '@kbn/dev-utils';
import { MessageDescriptor } from '../../extractors/call_expt';
import {
extractI18nMessageDescriptors,
verifyMessageDescriptor,
verifyMessageIdStartsWithNamespace,
} from '../../extractors/formatjs';
import { globNamespacePaths, descriptorDetailsStack, ErrorReporter } from '../../utils';
export const validateMessages = ({
extractedMessages,
namespace,
}: {
extractedMessages: Map<string, MessageDescriptor>;
namespace: string;
}) => {
for (const [, messageDescriptor] of extractedMessages) {
const validId = verifyMessageIdStartsWithNamespace(messageDescriptor, namespace);
if (!validId) {
const errorDetailsStack = descriptorDetailsStack(messageDescriptor, namespace);
throw new Error(
`Error in file Message id ${messageDescriptor.id} must start with the namespace root (${namespace}) defined in the .i18nrc.json file. ${errorDetailsStack}`
);
}
try {
verifyMessageDescriptor(messageDescriptor.defaultMessage!, messageDescriptor);
} catch (err) {
// :${err.location?.start.column}:${err.location?.start.offset}
const errorDetailsStack = descriptorDetailsStack(messageDescriptor, namespace);
throw new Error(`Failed to verify message: ${errorDetailsStack}. Got ${err}`);
}
}
};
const formatJsRunner = async (
filePaths: string[],
namespace: string,
errorReporter: ErrorReporter,
ignoreFlags: { ignoreMalformed?: boolean }
) => {
const allNamespaceMessages = new Map();
const { ignoreMalformed } = ignoreFlags;
for (const filePath of filePaths) {
const source = await readFileAsync(filePath, 'utf8');
const extractedMessages = await extractI18nMessageDescriptors(filePath, source);
try {
validateMessages({
extractedMessages,
namespace,
});
} catch (err) {
if (!ignoreMalformed) {
throw err;
}
}
extractedMessages.forEach((extractedMessage) => {
if (allNamespaceMessages.has(extractedMessage.id)) {
const mismatchMessage =
allNamespaceMessages.get(extractedMessage.id).defaultMessage !==
extractedMessage.defaultMessage;
if (mismatchMessage) {
const excpectedDescriptor = allNamespaceMessages.get(extractedMessage.id);
const expectedMessage = excpectedDescriptor.defaultMessage;
const receivedMessage = `${extractedMessage.defaultMessage}`;
const diffOutput = diffStrings(expectedMessage, receivedMessage);
errorReporter.report(
`Found duplicate i18n message id with mismatching defaultMessages in files:\n- ${extractedMessage.file}\n- ${excpectedDescriptor.file}\nMessage id: ${extractedMessage.id}\nNamespace: ${namespace}\n${diffOutput}`
);
}
}
allNamespaceMessages.set(extractedMessage.id, extractedMessage);
});
if (errorReporter.hasErrors()) {
throw errorReporter.throwErrors();
}
}
return allNamespaceMessages;
};
export const runForNamespacePath = async (
namespace: string,
namespaceRoots: string[],
errorReporter: ErrorReporter,
ignoreFlags: { ignoreMalformed?: boolean }
) => {
const namespacePaths = await globNamespacePaths(namespaceRoots);
const allNamespaceMessages = await formatJsRunner(
namespacePaths,
namespace,
errorReporter,
ignoreFlags
);
return allNamespaceMessages;
};

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PRESET_TIMER } from 'listr2';
import { TaskSignature } from '../../types';
import { runForNamespacePath } from './per_namespace';
import { ErrorReporter } from '../../utils/error_reporter';
export interface TaskOptions {
filterNamespaces?: string[];
ignoreMalformed?: boolean;
}
export const validateTranslationsTask: TaskSignature<TaskOptions> = (
context,
task,
{ filterNamespaces, ignoreMalformed }
) => {
const { config } = context;
const errorReporter = new ErrorReporter({ name: 'Validate Translations' });
if (!config || !Object.keys(config.paths).length) {
throw errorReporter.reportFailure(
'None of input paths is covered by the mappings in .i18nrc.json'
);
}
return task.newListr(
(parent) => [
{
title: `Verifying i18n messages inside namespaces`,
task: async () => {
const namespacesDetails = Object.entries(config.paths).filter(([namespace]) => {
if (filterNamespaces && filterNamespaces.length) {
return filterNamespaces.some((filterNamespace) => filterNamespace === namespace);
}
return true;
});
let iter = 0;
let totalMessagesCount = 0;
for (const [namespace, namespacePaths] of namespacesDetails) {
const countMessage = `Verifying i18n messages inside "${namespace}" namespace`;
task.output = countMessage;
const allNamespaceMessages = await runForNamespacePath(
namespace,
namespacePaths,
errorReporter,
{ ignoreMalformed }
);
const messagesCount = allNamespaceMessages.size;
parent.title = `[${iter + 1}/${
namespacesDetails.length
}] Successfully verified Namespace "${namespace}" with ${messagesCount} defined i18n messages.`;
iter++;
totalMessagesCount += messagesCount;
context.messages.set(namespace, [...allNamespaceMessages.values()]);
}
parent.title = `[${iter}/${namespacesDetails.length}] Successfully verified all namespaces with ${totalMessagesCount} total defined i18n messages.`;
},
},
],
{ exitOnError: true, rendererOptions: { timer: PRESET_TIMER }, collectErrors: 'minimal' }
);
};

View file

@ -6,27 +6,26 @@
* Side Public License, v 1.
*/
import { resolve, join } from 'path';
import { I18N_RC } from '../constants';
import { checkConfigNamespacePrefix, arrayify } from '..';
import { I18nCheckTaskContext } from '../types';
import { join } from 'path';
import { I18N_RC } from '../../constants';
import { arrayify, ErrorReporter, makeAbsolutePath } from '../../utils';
import { checkConfigNamespacePrefix } from './i18n_config';
import { I18nCheckTaskContext } from '../../types';
export function checkConfigs(additionalConfigPaths: string | string[] = []) {
const root = join(__dirname, '../../../../');
const kibanaRC = resolve(root, I18N_RC);
const xpackRC = resolve(root, 'x-pack', I18N_RC);
const kibanaRC = makeAbsolutePath(I18N_RC);
const xpackRC = makeAbsolutePath(join('x-pack', I18N_RC));
const configPaths = [kibanaRC, xpackRC, ...arrayify(additionalConfigPaths)];
return configPaths.map((configPath) => ({
task: async (context: I18nCheckTaskContext) => {
const errorReporter = new ErrorReporter({ name: `Checking config path ${configPath}` });
try {
await checkConfigNamespacePrefix(configPath);
} catch (err) {
const { reporter } = context;
const reporterWithContext = reporter.withContext({ name: configPath });
reporterWithContext.report(err);
throw reporter;
throw errorReporter.reportFailure(err);
}
},
title: `Checking configs in ${configPath}`,

View file

@ -7,16 +7,9 @@
*/
import { resolve } from 'path';
// @ts-ignore
import { normalizePath, readFileAsync } from '.';
export interface I18nConfig {
paths: Record<string, string[]>;
exclude: string[];
translations: string[];
prefix?: string;
}
import { readFile as readFileAsync } from 'fs/promises';
import { normalizePath } from '../../utils';
import type { I18nConfig } from '../../types';
export async function checkConfigNamespacePrefix(configPath: string) {
const { prefix, paths } = JSON.parse(await readFileAsync(resolve(configPath), 'utf8'));

View file

@ -6,8 +6,5 @@
* Side Public License, v 1.
*/
export { extractDefaultMessages } from './extract_default_translations';
export { extractUntrackedMessages } from './extract_untracked_translations';
export { checkCompatibility } from './check_compatibility';
export { mergeConfigs } from './merge_configs';
export { checkConfigs } from './check_configs';

View file

@ -6,26 +6,25 @@
* Side Public License, v 1.
*/
import { resolve, join } from 'path';
import { assignConfigFromPath, arrayify } from '..';
import { I18nCheckTaskContext } from '../types';
import { join } from 'path';
import { arrayify, ErrorReporter, makeAbsolutePath } from '../../utils';
import { assignConfigFromPath } from './i18n_config';
import { I18nCheckTaskContext } from '../../types';
import { I18N_RC } from '../../constants';
export function mergeConfigs(additionalConfigPaths: string | string[] = []) {
const root = join(__dirname, '../../../../');
const kibanaRC = resolve(root, '.i18nrc.json');
const xpackRC = resolve(root, 'x-pack/.i18nrc.json');
const kibanaRC = makeAbsolutePath(I18N_RC);
const xpackRC = makeAbsolutePath(join('x-pack', I18N_RC));
const configPaths = [kibanaRC, xpackRC, ...arrayify(additionalConfigPaths)];
return configPaths.map((configPath) => ({
task: async (context: I18nCheckTaskContext) => {
const errorReporter = new ErrorReporter({ name: `Merging config path ${configPath}` });
try {
context.config = await assignConfigFromPath(context.config, configPath);
} catch (err) {
const { reporter } = context;
const reporterWithContext = reporter.withContext({ name: configPath });
reporterWithContext.report(err);
throw reporter;
throw errorReporter.reportFailure(err);
}
},
title: `Merging configs in ${configPath}`,

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { writeExtractedMessagesToFile } from './task';

Some files were not shown because too many files have changed in this diff Show more