[I18n] Add include option to i18n_check for 3rd party plugins (#26963)

* [I18n] Add include/exclude options to i18n_check tool for 3rd-party plugins

* Implement a better solution

* Update .i18nrc.json template

* Resolve comment

* Add conditional ejs expressions for i18n in plugin generator

* Hide package.json from Jest

* Complete template translation

* Resolve comments
This commit is contained in:
Leanid Shutau 2018-12-21 12:55:45 +03:00 committed by GitHub
parent 76ae4e6923
commit 973fad3b0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 197 additions and 28 deletions

View file

@ -72,6 +72,7 @@ module.exports = function({ name }) {
filters: {
'public/**/*': 'generateApp',
'translations/**/*': 'generateTranslations',
'.i18nrc.json': 'generateTranslations',
'public/hack.js': 'generateHack',
'server/**/*': 'generateApi',
'public/app.scss': 'generateScss',
@ -80,6 +81,7 @@ module.exports = function({ name }) {
move: {
gitignore: '.gitignore',
eslintrc: '.eslintrc',
'package_template.json': 'package.json',
},
data: answers =>
Object.assign(

View file

@ -0,0 +1,5 @@
{
"paths": {
"<%= camelCase(name) %>": "./"
}
}

View file

@ -17,6 +17,11 @@
"test:browser": "plugin-helpers test:browser",
"build": "plugin-helpers build"
},
<%_ if (generateTranslations) { _%>
"dependencies": {
"@kbn/i18n": "link:../../kibana/packages/kbn-i18n"
},
<%_ } _%>
"devDependencies": {
"@elastic/eslint-config-kibana": "link:../../kibana/packages/eslint-config-kibana",
"@elastic/eslint-import-resolver-kibana": "link:../../kibana/packages/kbn-eslint-import-resolver-kibana",

View file

@ -2,6 +2,9 @@ import React from 'react';
import { uiModules } from 'ui/modules';
import chrome from 'ui/chrome';
import { render, unmountComponentAtNode } from 'react-dom';
<%_ if (generateTranslations) { _%>
import { I18nProvider } from '@kbn/i18n/react';
<%_ } _%>
import 'ui/autoload/styles';
import './less/main.less';
@ -24,7 +27,16 @@ function RootController($scope, $element, $http) {
const domNode = $element[0];
// render react to DOM
<%_ if (generateTranslations) { _%>
render(
<I18nProvider>
<Main title="<%= name %>" httpClient={$http} />
</I18nProvider>,
domNode
);
<%_ } else { _%>
render(<Main title="<%= name %>" httpClient={$http} />, domNode);
<%_ } _%>
// unmount react on controller destroy
$scope.$on('$destroy', () => {

View file

@ -9,6 +9,9 @@ import {
EuiPageContentBody,
EuiText
} from '@elastic/eui';
<%_ if (generateTranslations) { _%>
import { FormattedMessage } from '@kbn/i18n/react';
<%_ } _%>
export class Main extends React.Component {
constructor(props) {
@ -33,19 +36,57 @@ export class Main extends React.Component {
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>{title} Hello World!</h1>
<h1>
<%_ if (generateTranslations) { _%>
<FormattedMessage
id="<%= camelCase(name) %>.helloWorldText"
defaultMessage="{title} Hello World!"
values={{ title }}
/>
<%_ } else { _%>
{title} Hello World!
<%_ } _%>
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiTitle>
<h2>Congratulations</h2>
<h2>
<%_ if (generateTranslations) { _%>
<FormattedMessage
id="<%= camelCase(name) %>.congratulationsTitle"
defaultMessage="Congratulations"
/>
<%_ } else { _%>
Congratulations
<%_ } _%>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiText>
<h3>You have successfully created your first Kibana Plugin!</h3>
<p>The server time (via API call) is {this.state.time || 'NO API CALL YET'}</p>
<h3>
<%_ if (generateTranslations) { _%>
<FormattedMessage
id="<%= camelCase(name) %>.congratulationsText"
defaultMessage="You have successfully created your first Kibana Plugin!"
/>
<%_ } else { _%>
You have successfully created your first Kibana Plugin!
<%_ } _%>
</h3>
<p>
<%_ if (generateTranslations) { _%>
<FormattedMessage
id="<%= camelCase(name) %>.serverTimeText"
defaultMessage="The server time (via API call) is {time}"
values={{ time: this.state.time || 'NO API CALL YET' }}
/>
<%_ } else { _%>
The server time (via API call) is {this.state.time || 'NO API CALL YET'}
<%_ } _%>
</p>
</EuiText>
</EuiPageContentBody>
</EuiPageContent>

View file

@ -0,0 +1,84 @@
{
"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": {
"<%= camelCase(name) %>.congratulationsText": "您已经成功创建第一个 Kibana 插件。",
"<%= camelCase(name) %>.congratulationsTitle": "恭喜!",
"<%= camelCase(name) %>.helloWorldText": "{title} 您好,世界!",
"<%= camelCase(name) %>.serverTimeText": "服务器时间(通过 API 调用)为 {time}"
}
}

View file

@ -18,7 +18,6 @@
*/
import path from 'path';
import normalize from 'normalize-path';
import chalk from 'chalk';
import {
@ -27,8 +26,7 @@ import {
extractPugMessages,
extractHandlebarsMessages,
} from './extractors';
import { globAsync, readFileAsync } from './utils';
import { paths, exclude } from '../../../.i18nrc.json';
import { globAsync, readFileAsync, normalizePath } from './utils';
import { createFailError, isFailError } from '../run';
function addMessageToMap(targetMap, key, value) {
@ -42,11 +40,7 @@ function addMessageToMap(targetMap, key, value) {
targetMap.set(key, value);
}
function normalizePath(inputPath) {
return normalize(path.relative('.', inputPath));
}
export function filterPaths(inputPaths) {
export function filterPaths(inputPaths, paths) {
const availablePaths = Object.values(paths);
const pathsForExtraction = new Set();
@ -70,16 +64,16 @@ export function filterPaths(inputPaths) {
return [...pathsForExtraction];
}
function filterEntries(entries) {
function filterEntries(entries, exclude) {
return entries.filter(entry =>
exclude.every(excludedPath => !normalizePath(entry).startsWith(excludedPath))
);
}
export function validateMessageNamespace(id, filePath) {
export function validateMessageNamespace(id, filePath, allowedPaths) {
const normalizedPath = normalizePath(filePath);
const [expectedNamespace] = Object.entries(paths).find(([, pluginPath]) =>
const [expectedNamespace] = Object.entries(allowedPaths).find(([, pluginPath]) =>
normalizedPath.startsWith(`${pluginPath}/`)
);
@ -89,7 +83,7 @@ See .i18nrc.json for the list of supported namespaces.`);
}
}
export async function extractMessagesFromPathToMap(inputPath, targetMap) {
export async function extractMessagesFromPathToMap(inputPath, targetMap, config) {
const entries = await globAsync('*.{js,jsx,pug,ts,tsx,html,hbs,handlebars}', {
cwd: inputPath,
matchBase: true,
@ -123,7 +117,7 @@ export async function extractMessagesFromPathToMap(inputPath, targetMap) {
[hbsEntries, extractHandlebarsMessages],
].map(async ([entries, extractFunction]) => {
const files = await Promise.all(
filterEntries(entries).map(async entry => {
filterEntries(entries, config.exclude).map(async entry => {
return {
name: entry,
content: await readFileAsync(entry),
@ -134,7 +128,7 @@ export async function extractMessagesFromPathToMap(inputPath, targetMap) {
for (const { name, content } of files) {
try {
for (const [id, value] of extractFunction(content)) {
validateMessageNamespace(id, name);
validateMessageNamespace(id, name, config.paths);
addMessageToMap(targetMap, id, value);
}
} catch (error) {

View file

@ -31,21 +31,21 @@ const pluginsPaths = [
path.join(fixturesPath, 'test_plugin_3'),
];
jest.mock('../../../.i18nrc.json', () => ({
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',
plugin_3: 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3',
},
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);
await extractMessagesFromPathToMap(pluginPath, resultMap, config);
expect([...resultMap].sort()).toMatchSnapshot();
});
@ -54,7 +54,7 @@ describe('dev/i18n/extract_default_translations', () => {
const [, , pluginPath] = pluginsPaths;
await expect(
extractMessagesFromPathToMap(pluginPath, new Map())
extractMessagesFromPathToMap(pluginPath, new Map(), config)
).rejects.toThrowErrorMatchingSnapshot();
});
@ -64,7 +64,7 @@ describe('dev/i18n/extract_default_translations', () => {
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2/test_file.html'
);
expect(() => validateMessageNamespace(id, filePath)).not.toThrow();
expect(() => validateMessageNamespace(id, filePath, config.paths)).not.toThrow();
});
test('throws on wrong message namespace', () => {
@ -73,6 +73,8 @@ describe('dev/i18n/extract_default_translations', () => {
__dirname,
'__fixtures__/extract_default_translations/test_plugin_2/test_file.html'
);
expect(() => validateMessageNamespace(id, filePath)).toThrowErrorMatchingSnapshot();
expect(() =>
validateMessageNamespace(id, filePath, config.paths)
).toThrowErrorMatchingSnapshot();
});
});

View file

@ -18,5 +18,5 @@
*/
export { filterPaths, extractMessagesFromPathToMap } from './extract_default_translations';
export { writeFileAsync } from './utils';
export { writeFileAsync, readFileAsync, normalizePath } from './utils';
export { serializeToJson, serializeToJson5 } from './serializers';

View file

@ -33,6 +33,8 @@ import glob from 'glob';
import { promisify } from 'util';
import chalk from 'chalk';
import parser from 'intl-messageformat-parser';
import normalize from 'normalize-path';
import path from 'path';
import { createFailError } from '../run';
@ -288,3 +290,7 @@ export function extractValuesKeysFromNode(node, messageId) {
property => (isStringLiteral(property.key) ? property.key.value : property.key.name)
);
}
export function normalizePath(inputPath) {
return normalize(path.relative('.', inputPath));
}

View file

@ -22,17 +22,35 @@ import Listr from 'listr';
import { resolve } from 'path';
import { run, createFailError } from './run';
import config from '../../.i18nrc.json';
import {
filterPaths,
extractMessagesFromPathToMap,
writeFileAsync,
readFileAsync,
serializeToJson,
serializeToJson5,
normalizePath,
} from './i18n/';
run(async ({ flags: { path, output, 'output-format': outputFormat } }) => {
run(async ({ flags: { path, output, 'output-format': outputFormat, include = [] } }) => {
const paths = Array.isArray(path) ? path : [path || './'];
const filteredPaths = filterPaths(paths);
const additionalI18nConfigPaths = Array.isArray(include) ? include : [include];
const mergedConfig = { exclude: [], ...config };
for (const configPath of additionalI18nConfigPaths) {
const additionalConfig = JSON.parse(await readFileAsync(resolve(configPath)));
for (const [pathNamespace, pathValue] of Object.entries(additionalConfig.paths)) {
mergedConfig.paths[pathNamespace] = normalizePath(resolve(configPath, '..', pathValue));
}
for (const exclude of additionalConfig.exclude || []) {
mergedConfig.exclude.push(normalizePath(resolve(configPath, '..', exclude)));
}
}
const filteredPaths = filterPaths(paths, mergedConfig.paths);
if (filteredPaths.length === 0) {
throw createFailError(
@ -43,7 +61,7 @@ None of input paths is available for extraction or validation. See .i18nrc.json.
const list = new Listr(
filteredPaths.map(filteredPath => ({
task: messages => extractMessagesFromPathToMap(filteredPath, messages),
task: messages => extractMessagesFromPathToMap(filteredPath, messages, mergedConfig),
title: filteredPath,
}))
);