mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
026f68858c
commit
7c6aa3fc8a
125 changed files with 24144 additions and 24484 deletions
|
@ -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
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
|
|||
);
|
||||
},
|
||||
getDisplayName: () =>
|
||||
i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
|
||||
i18n.translate('embeddableExamples.euiMarkdownEditor.displayNameAriaLabel', {
|
||||
defaultMessage: 'EUI Markdown',
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -221,7 +221,6 @@ export class RenderingService {
|
|||
strictCsp: http.csp.strict,
|
||||
uiPublicUrl: `${staticAssetsHrefBase}/ui`,
|
||||
bootstrapScriptUrl: `${basePath}/${bootstrapScript}`,
|
||||
i18n: i18nLib.translate,
|
||||
locale,
|
||||
themeVersion,
|
||||
darkMode,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.',
|
||||
})}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
*/
|
||||
|
||||
require('../src/setup_node_env');
|
||||
require('../src/dev/run_i18n_check');
|
||||
require('../src/dev/i18n_tools/bin/run_i18n_check');
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
*/
|
||||
|
||||
require('../src/setup_node_env');
|
||||
require('../src/dev/run_i18n_extract');
|
||||
require('../src/dev/i18n_tools/bin/run_i18n_extract');
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
*/
|
||||
|
||||
require('../src/setup_node_env');
|
||||
require('../src/dev/run_i18n_integrate');
|
||||
require('../src/dev/i18n_tools/bin/run_i18n_integrate');
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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',
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 [
|
||||
"[37m[41m I18N ERROR [49m[39m 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.],
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -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].
|
||||
"
|
||||
`;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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").],
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -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\\")."`;
|
|
@ -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."`;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 }];
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
127
src/dev/i18n/extractors/react.js
vendored
127
src/dev/i18n/extractors/react.js
vendored
|
@ -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 }];
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
}));
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
},
|
||||
}
|
||||
"
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
};
|
|
@ -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}`,
|
||||
}));
|
||||
}
|
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
}));
|
||||
}
|
31
src/dev/i18n/utils/__snapshots__/utils.test.js.snap
generated
31
src/dev/i18n/utils/__snapshots__/utils.test.js.snap
generated
|
@ -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: '->'[37m[41m;[49m[39m
|
||||
};
|
||||
"
|
||||
`;
|
||||
|
||||
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]."`;
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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設定を使用すると、強制的にヒストグラムアグリゲーションを実行し、特定の最小値に対してバケットの作成を開始し、最大値までバケットを作成し続けます。 ]]>[37m[41m<[49m[39m/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 [37m[41m{[49m[39mcurly"
|
||||
`);
|
||||
});
|
||||
|
||||
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}} [37m[41m}[49m[39m"
|
||||
`);
|
||||
});
|
||||
|
||||
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 中くらい:
|
||||
[37m[41m{[49m[39mtextScale, 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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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.`,
|
||||
});
|
|
@ -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}',
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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',
|
||||
});
|
|
@ -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
|
||||
/>
|
||||
);
|
|
@ -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!',
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
111
src/dev/i18n_tools/bin/run_i18n_extract.ts
Normal file
111
src/dev/i18n_tools/bin/run_i18n_extract.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
117
src/dev/i18n_tools/bin/run_i18n_integrate.ts
Normal file
117
src/dev/i18n_tools/bin/run_i18n_integrate.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
|
@ -6,4 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { extractCodeMessages } from './code';
|
||||
export const I18N_RC = '.i18nrc.json';
|
462
src/dev/i18n_tools/extractors/call_expt.ts
Normal file
462
src/dev/i18n_tools/extractors/call_expt.ts
Normal 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];
|
||||
}
|
339
src/dev/i18n_tools/extractors/formatjs.test.ts
Normal file
339
src/dev/i18n_tools/extractors/formatjs.test.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
196
src/dev/i18n_tools/extractors/formatjs.ts
Normal file
196
src/dev/i18n_tools/extractors/formatjs.ts
Normal 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;
|
||||
}
|
68
src/dev/i18n_tools/extractors/react.ts
Normal file
68
src/dev/i18n_tools/extractors/react.ts
Normal 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;
|
||||
}
|
|
@ -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\\"
|
||||
}
|
||||
}
|
||||
},
|
|
@ -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';
|
27
src/dev/i18n_tools/serializers/json.test.ts
Normal file
27
src/dev/i18n_tools/serializers/json.test.ts
Normal 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();
|
||||
});
|
||||
});
|
35
src/dev/i18n_tools/serializers/json.ts
Normal file
35
src/dev/i18n_tools/serializers/json.ts
Normal 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');
|
||||
};
|
20
src/dev/i18n_tools/serializers/types.ts
Normal file
20
src/dev/i18n_tools/serializers/types.ts
Normal 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;
|
|
@ -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++;
|
||||
}
|
||||
}
|
|
@ -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';
|
73
src/dev/i18n_tools/tasks/check_untracked_namespaces/task.ts
Normal file
73
src/dev/i18n_tools/tasks/check_untracked_namespaces/task.ts
Normal 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' }
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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' }
|
||||
);
|
||||
};
|
17
src/dev/i18n_tools/tasks/index.ts
Normal file
17
src/dev/i18n_tools/tasks/index.ts
Normal 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';
|
9
src/dev/i18n_tools/tasks/integrate_translations/index.ts
Normal file
9
src/dev/i18n_tools/tasks/integrate_translations/index.ts
Normal 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';
|
59
src/dev/i18n_tools/tasks/integrate_translations/task.ts
Normal file
59
src/dev/i18n_tools/tasks/integrate_translations/task.ts
Normal 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' }
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
10
src/dev/i18n_tools/tasks/validate_translation_files/index.ts
Normal file
10
src/dev/i18n_tools/tasks/validate_translation_files/index.ts
Normal 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';
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
};
|
|
@ -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 };
|
||||
};
|
109
src/dev/i18n_tools/tasks/validate_translation_files/task.ts
Normal file
109
src/dev/i18n_tools/tasks/validate_translation_files/task.ts
Normal 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' }
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
10
src/dev/i18n_tools/tasks/validate_translations/index.ts
Normal file
10
src/dev/i18n_tools/tasks/validate_translations/index.ts
Normal 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';
|
113
src/dev/i18n_tools/tasks/validate_translations/per_namespace.ts
Normal file
113
src/dev/i18n_tools/tasks/validate_translations/per_namespace.ts
Normal 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;
|
||||
};
|
71
src/dev/i18n_tools/tasks/validate_translations/task.ts
Normal file
71
src/dev/i18n_tools/tasks/validate_translations/task.ts
Normal 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' }
|
||||
);
|
||||
};
|
|
@ -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}`,
|
|
@ -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'));
|
|
@ -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';
|
|
@ -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}`,
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue