mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Implement a build tool for default messages extraction (#19620)
* Implement a build tool for default messages extraction * Refactor extraction tool, resolve review comments, fix bugs * Resolve comments, refactor extraction tool, fix bugs * Add context to messages extraction tool * Resolve comments * Fix bugs * Add messages extraction from .jade files, refactor code * Add template literals parsing * Return defaultMessages.json to plain structure * Refactor utils * Fix bugs * Refactor code, resolve review comments, fix bugs * Fix minor bug * Get rid of '@babel/traverse' and add its native implementation * Add handlebars messages extraction * Fix mkdir on macOS * Fix bugs, inject default formats, finalize the tool * Fix filsystem permissions * Refactor nodes traversal * Update code style * Downgrade @babel/types to fix build issues * Resolve comments * Fix minor bugs
This commit is contained in:
parent
6939f5073c
commit
9c9159ce0c
13 changed files with 811 additions and 0 deletions
|
@ -224,6 +224,8 @@
|
|||
"yauzl": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.0.0-beta.52",
|
||||
"@babel/types": "7.0.0-beta.31",
|
||||
"@elastic/eslint-config-kibana": "link:packages/eslint-config-kibana",
|
||||
"@elastic/eslint-plugin-kibana-custom": "link:packages/eslint-plugin-kibana-custom",
|
||||
"@kbn/es": "link:packages/kbn-es",
|
||||
|
@ -307,6 +309,7 @@
|
|||
"jest-raw-loader": "^1.0.1",
|
||||
"jimp": "0.2.28",
|
||||
"jsdom": "9.9.1",
|
||||
"json5": "^1.0.1",
|
||||
"karma": "1.7.0",
|
||||
"karma-chrome-launcher": "2.1.1",
|
||||
"karma-coverage": "1.1.1",
|
||||
|
|
21
scripts/extract_default_translations.js
Normal file
21
scripts/extract_default_translations.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
require('../src/setup_node_env');
|
||||
require('../src/dev/run_extract_default_translations');
|
21
src/dev/i18n/constants.js
Normal file
21
src/dev/i18n/constants.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export const DEFAULT_MESSAGE_KEY = 'defaultMessage';
|
||||
export const CONTEXT_KEY = 'context';
|
71
src/dev/i18n/extract_code_messages.js
Normal file
71
src/dev/i18n/extract_code_messages.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { parse } from '@babel/parser';
|
||||
import {
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isJSXIdentifier,
|
||||
isJSXOpeningElement,
|
||||
isMemberExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
import { extractI18nCallMessages } from './extract_i18n_call_messages';
|
||||
import { isI18nTranslateFunction, traverseNodes } from './utils';
|
||||
import { extractIntlMessages, extractFormattedMessages } from './extract_react_messages';
|
||||
|
||||
/**
|
||||
* Detect Intl.formatMessage() function call (React).
|
||||
*
|
||||
* Example: `intl.formatMessage({ id: 'message-id', defaultMessage: 'Message text' });`
|
||||
*/
|
||||
function isIntlFormatMessageFunction(node) {
|
||||
return (
|
||||
isCallExpression(node) &&
|
||||
isMemberExpression(node.callee) &&
|
||||
isIdentifier(node.callee.object, { name: 'intl' }) &&
|
||||
isIdentifier(node.callee.property, { name: 'formatMessage' })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect <FormattedMessage> elements in JSX.
|
||||
*
|
||||
* Example: `<FormattedMessage id="message-id" defaultMessage="Message text"/>`
|
||||
*/
|
||||
function isFormattedMessageElement(node) {
|
||||
return isJSXOpeningElement(node) && isJSXIdentifier(node.name, { name: 'FormattedMessage' });
|
||||
}
|
||||
|
||||
export function* extractCodeMessages(buffer) {
|
||||
const content = parse(buffer.toString(), {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'objectRestSpread', 'classProperties', 'asyncGenerators'],
|
||||
});
|
||||
|
||||
for (const node of traverseNodes(content.program.body)) {
|
||||
if (isI18nTranslateFunction(node)) {
|
||||
yield extractI18nCallMessages(node);
|
||||
} else if (isIntlFormatMessageFunction(node)) {
|
||||
yield extractIntlMessages(node);
|
||||
} else if (isFormattedMessageElement(node)) {
|
||||
yield extractFormattedMessages(node);
|
||||
}
|
||||
}
|
||||
}
|
120
src/dev/i18n/extract_default_translations.js
Normal file
120
src/dev/i18n/extract_default_translations.js
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { formats } from '@kbn/i18n';
|
||||
import JSON5 from 'json5';
|
||||
|
||||
import { extractHtmlMessages } from './extract_html_messages';
|
||||
import { extractCodeMessages } from './extract_code_messages';
|
||||
import { extractJadeMessages } from './extract_jade_messages';
|
||||
import { extractHandlebarsMessages } from './extract_handlebars_messages';
|
||||
import { globAsync, makeDirAsync, accessAsync, readFileAsync, writeFileAsync } from './utils';
|
||||
|
||||
function addMessageToMap(targetMap, key, value) {
|
||||
const existingValue = targetMap.get(key);
|
||||
if (targetMap.has(key) && existingValue.message !== value.message) {
|
||||
throw new Error(
|
||||
`There is more than one default message for the same id "${key}": "${existingValue}" and "${value}"`
|
||||
);
|
||||
}
|
||||
targetMap.set(key, value);
|
||||
}
|
||||
|
||||
export async function extractDefaultTranslations(inputPath) {
|
||||
const entries = await globAsync('*.{js,jsx,jade,ts,tsx,html,hbs,handlebars}', {
|
||||
cwd: inputPath,
|
||||
matchBase: true,
|
||||
});
|
||||
|
||||
const { htmlEntries, codeEntries, jadeEntries, hbsEntries } = entries.reduce(
|
||||
(paths, entry) => {
|
||||
const resolvedPath = resolve(inputPath, entry);
|
||||
|
||||
if (resolvedPath.endsWith('.html')) {
|
||||
paths.htmlEntries.push(resolvedPath);
|
||||
} else if (resolvedPath.endsWith('.jade')) {
|
||||
paths.jadeEntries.push(resolvedPath);
|
||||
} else if (resolvedPath.endsWith('.hbs') || resolvedPath.endsWith('.handlebars')) {
|
||||
paths.hbsFiles.push(resolvedPath);
|
||||
} else {
|
||||
paths.codeEntries.push(resolvedPath);
|
||||
}
|
||||
|
||||
return paths;
|
||||
},
|
||||
{ htmlEntries: [], codeEntries: [], jadeEntries: [], hbsEntries: [] }
|
||||
);
|
||||
|
||||
const defaultMessagesMap = new Map();
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
[htmlEntries, extractHtmlMessages],
|
||||
[codeEntries, extractCodeMessages],
|
||||
[jadeEntries, extractJadeMessages],
|
||||
[hbsEntries, extractHandlebarsMessages],
|
||||
].map(async ([entries, extractFunction]) => {
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
return {
|
||||
name: entry,
|
||||
content: await readFileAsync(entry),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
for (const { name, content } of files) {
|
||||
try {
|
||||
for (const [id, value] of extractFunction(content)) {
|
||||
addMessageToMap(defaultMessagesMap, id, value);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error in ${name}\n${error.message || error}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// .slice(0, -1): remove closing curly brace from json to append messages
|
||||
let jsonBuffer = Buffer.from(JSON5.stringify({ formats }, { quote: `'`, space: 2 }).slice(0, -1));
|
||||
|
||||
const defaultMessages = [...defaultMessagesMap].sort(([key1], [key2]) => {
|
||||
return key1 < key2 ? -1 : 1;
|
||||
});
|
||||
|
||||
for (const [mapKey, mapValue] of defaultMessages) {
|
||||
jsonBuffer = Buffer.concat([
|
||||
jsonBuffer,
|
||||
Buffer.from(` '${mapKey}': '${mapValue.message}',`),
|
||||
Buffer.from(mapValue.context ? ` // ${mapValue.context}\n` : '\n'),
|
||||
]);
|
||||
}
|
||||
|
||||
// append previously removed closing curly brace
|
||||
jsonBuffer = Buffer.concat([jsonBuffer, Buffer.from('}\n')]);
|
||||
|
||||
try {
|
||||
await accessAsync(resolve(inputPath, 'translations'));
|
||||
} catch (_) {
|
||||
await makeDirAsync(resolve(inputPath, 'translations'));
|
||||
}
|
||||
|
||||
await writeFileAsync(resolve(inputPath, 'translations', 'en.json'), jsonBuffer);
|
||||
}
|
71
src/dev/i18n/extract_handlebars_messages.js
Normal file
71
src/dev/i18n/extract_handlebars_messages.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { formatJSString } from './utils';
|
||||
|
||||
const HBS_REGEX = /(?<=\{\{)([\s\S]*?)(?=\}\})/g;
|
||||
const TOKENS_REGEX = /[^'\s]+|(?:'([^'\\]|\\[\s\S])*')/g;
|
||||
|
||||
/**
|
||||
* Example: `'{{i18n 'message-id' '{"defaultMessage": "Message text"}'}}'`
|
||||
*/
|
||||
export function* extractHandlebarsMessages(buffer) {
|
||||
for (const expression of buffer.toString().match(HBS_REGEX) || []) {
|
||||
const tokens = expression.match(TOKENS_REGEX);
|
||||
|
||||
const [functionName, idString, propertiesString] = tokens;
|
||||
|
||||
if (functionName !== 'i18n') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokens.length !== 3) {
|
||||
throw new Error('Wrong arguments amount for handlebars i18n call.');
|
||||
}
|
||||
|
||||
if (!idString.startsWith(`'`) || !idString.endsWith(`'`)) {
|
||||
throw new Error('Message id should be a string literal.');
|
||||
}
|
||||
|
||||
const messageId = formatJSString(idString.slice(1, -1));
|
||||
|
||||
if (!propertiesString.startsWith(`'`) || !propertiesString.endsWith(`'`)) {
|
||||
throw new Error(
|
||||
`Cannot parse "${messageId}" message: properties string should be a string literal.`
|
||||
);
|
||||
}
|
||||
|
||||
const properties = JSON.parse(propertiesString.slice(1, -1));
|
||||
const message = formatJSString(properties.defaultMessage);
|
||||
|
||||
if (typeof message !== 'string') {
|
||||
throw new Error(
|
||||
`Cannot parse "${messageId}" message: defaultMessage value should be a string.`
|
||||
);
|
||||
}
|
||||
|
||||
const context = formatJSString(properties.context);
|
||||
|
||||
if (context != null && typeof context !== 'string') {
|
||||
throw new Error(`Cannot parse "${messageId}" message: context value should be a string.`);
|
||||
}
|
||||
|
||||
yield [messageId, { message, context }];
|
||||
}
|
||||
}
|
142
src/dev/i18n/extract_html_messages.js
Normal file
142
src/dev/i18n/extract_html_messages.js
Normal file
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { jsdom } from 'jsdom';
|
||||
import { parse } from '@babel/parser';
|
||||
import { isDirectiveLiteral, isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
|
||||
import { isPropertyWithKey, formatHTMLString, formatJSString, traverseNodes } from './utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Find all substrings of "{{ any text }}" pattern
|
||||
*/
|
||||
const ANGULAR_EXPRESSION_REGEX = /\{\{+([\s\S]*?)\}\}+/g;
|
||||
|
||||
const I18N_FILTER_MARKER = '| i18n: ';
|
||||
|
||||
/**
|
||||
* Extract default message from an angular filter expression argument
|
||||
* @param {string} expression JavaScript code containing a filter object
|
||||
* @returns {string} Default message
|
||||
*/
|
||||
function parseFilterObjectExpression(expression) {
|
||||
// parse an object expression instead of block statement
|
||||
const nodes = parse(`+${expression}`).program.body;
|
||||
|
||||
for (const node of traverseNodes(nodes)) {
|
||||
if (!isObjectExpression(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message;
|
||||
let context;
|
||||
|
||||
for (const property of node.properties) {
|
||||
if (isPropertyWithKey(property, DEFAULT_MESSAGE_KEY)) {
|
||||
if (!isStringLiteral(property.value)) {
|
||||
throw new Error('defaultMessage value should be a string literal.');
|
||||
}
|
||||
|
||||
message = formatJSString(property.value.value);
|
||||
} else if (isPropertyWithKey(property, CONTEXT_KEY)) {
|
||||
if (!isStringLiteral(property.value)) {
|
||||
throw new Error('context value should be a string literal.');
|
||||
}
|
||||
|
||||
context = formatJSString(property.value.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { message, context };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseIdExpression(expression) {
|
||||
for (const node of traverseNodes(parse(expression).program.directives)) {
|
||||
if (isDirectiveLiteral(node)) {
|
||||
return formatJSString(node.value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimCurlyBraces(string) {
|
||||
return string.slice(2, -2).trim();
|
||||
}
|
||||
|
||||
function* getFilterMessages(htmlContent) {
|
||||
const expressions = (htmlContent.match(ANGULAR_EXPRESSION_REGEX) || [])
|
||||
.filter(expression => expression.includes(I18N_FILTER_MARKER))
|
||||
.map(trimCurlyBraces);
|
||||
|
||||
for (const expression of expressions) {
|
||||
const filterStart = expression.indexOf(I18N_FILTER_MARKER);
|
||||
const idExpression = expression.slice(0, filterStart).trim();
|
||||
const filterObjectExpression = expression.slice(filterStart + I18N_FILTER_MARKER.length).trim();
|
||||
|
||||
if (!filterObjectExpression || !idExpression) {
|
||||
throw new Error(`Cannot parse i18n filter expression: {{ ${expression} }}`);
|
||||
}
|
||||
|
||||
const messageId = parseIdExpression(idExpression);
|
||||
|
||||
if (!messageId) {
|
||||
throw new Error('Empty "id" value in angular filter expression is not allowed.');
|
||||
}
|
||||
|
||||
const { message, context } = parseFilterObjectExpression(filterObjectExpression) || {};
|
||||
|
||||
if (!message) {
|
||||
throw new Error(`Cannot parse "${messageId}" message: default message is required`);
|
||||
}
|
||||
|
||||
yield [messageId, { message, context }];
|
||||
}
|
||||
}
|
||||
|
||||
function* getDirectiveMessages(htmlContent) {
|
||||
const document = jsdom(htmlContent, {
|
||||
features: { ProcessExternalResources: false },
|
||||
}).defaultView.document;
|
||||
|
||||
for (const element of document.querySelectorAll('[i18n-id]')) {
|
||||
const messageId = formatHTMLString(element.getAttribute('i18n-id'));
|
||||
if (!messageId) {
|
||||
throw new Error('Empty "i18n-id" value is not allowed.');
|
||||
}
|
||||
|
||||
const message = formatHTMLString(element.getAttribute('i18n-default-message'));
|
||||
if (!message) {
|
||||
throw new Error(`Cannot parse "${messageId}" message: default message is required.`);
|
||||
}
|
||||
|
||||
const context = formatHTMLString(element.getAttribute('i18n-context'));
|
||||
yield [messageId, { message, context }];
|
||||
}
|
||||
}
|
||||
|
||||
export function* extractHtmlMessages(buffer) {
|
||||
const content = buffer.toString();
|
||||
yield* getDirectiveMessages(content);
|
||||
yield* getFilterMessages(content);
|
||||
}
|
70
src/dev/i18n/extract_i18n_call_messages.js
Normal file
70
src/dev/i18n/extract_i18n_call_messages.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
|
||||
import { isPropertyWithKey, formatJSString } from './utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Extract messages from `funcName('id', { defaultMessage: 'Message text' })` call expression AST
|
||||
*/
|
||||
export function extractI18nCallMessages(node) {
|
||||
const [idSubTree, optionsSubTree] = node.arguments;
|
||||
|
||||
if (!isStringLiteral(idSubTree)) {
|
||||
throw new Error('Message id should be a string literal.');
|
||||
}
|
||||
|
||||
const messageId = idSubTree.value;
|
||||
let message;
|
||||
let context;
|
||||
|
||||
if (!isObjectExpression(optionsSubTree)) {
|
||||
throw new Error(
|
||||
`Cannot parse "${messageId}" message: object with defaultMessage property is not provided.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const prop of optionsSubTree.properties) {
|
||||
if (isPropertyWithKey(prop, DEFAULT_MESSAGE_KEY)) {
|
||||
if (!isStringLiteral(prop.value)) {
|
||||
throw new Error(
|
||||
`Cannot parse "${messageId}" message: defaultMessage value should be a string literal.`
|
||||
);
|
||||
}
|
||||
|
||||
message = formatJSString(prop.value.value);
|
||||
} else if (isPropertyWithKey(prop, CONTEXT_KEY)) {
|
||||
if (!isStringLiteral(prop.value)) {
|
||||
throw new Error(
|
||||
`Cannot parse "${messageId}" message: context value should be a string literal.`
|
||||
);
|
||||
}
|
||||
|
||||
context = formatJSString(prop.value.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error(`Cannot parse "${messageId}" message: defaultMessage is required`);
|
||||
}
|
||||
|
||||
return [messageId, { message, context }];
|
||||
}
|
44
src/dev/i18n/extract_jade_messages.js
Normal file
44
src/dev/i18n/extract_jade_messages.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { parse } from '@babel/parser';
|
||||
|
||||
import { extractI18nCallMessages } from './extract_i18n_call_messages';
|
||||
import { isI18nTranslateFunction, traverseNodes } from './utils';
|
||||
|
||||
/**
|
||||
* Matches `i18n(...)` in `#{i18n('id', { defaultMessage: 'Message text' })}`
|
||||
*/
|
||||
const JADE_I18N_REGEX = /(?<=\#\{)i18n\((([^)']|'([^'\\]|\\.)*')*\)(?=\}))/g;
|
||||
|
||||
/**
|
||||
* Example: `#{i18n('message-id', { defaultMessage: 'Message text' })}`
|
||||
*/
|
||||
export function* extractJadeMessages(buffer) {
|
||||
const expressions = buffer.toString().match(JADE_I18N_REGEX) || [];
|
||||
|
||||
for (const expression of expressions) {
|
||||
for (const node of traverseNodes(parse(expression).program.body)) {
|
||||
if (isI18nTranslateFunction(node)) {
|
||||
yield extractI18nCallMessages(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
123
src/dev/i18n/extract_react_messages.js
Normal file
123
src/dev/i18n/extract_react_messages.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { isJSXIdentifier, isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
|
||||
import { isPropertyWithKey, formatJSString, formatHTMLString } from './utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants';
|
||||
|
||||
function extractMessageId(value) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw new Error('Message id should be a string literal.');
|
||||
}
|
||||
|
||||
return value.value;
|
||||
}
|
||||
|
||||
function extractMessageValue(value, id) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw new Error(`defaultMessage value should be a string literal for id: ${id}.`);
|
||||
}
|
||||
|
||||
return value.value;
|
||||
}
|
||||
|
||||
function extractContextValue(value, id) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw new Error(`context value should be a string literal for id: ${id}.`);
|
||||
}
|
||||
|
||||
return value.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = node.arguments[0];
|
||||
|
||||
if (!isObjectExpression(options)) {
|
||||
throw new Error('Object with defaultMessage property is not passed to intl.formatMessage().');
|
||||
}
|
||||
|
||||
const [messageIdProperty, messageProperty, contextProperty] = [
|
||||
'id',
|
||||
DEFAULT_MESSAGE_KEY,
|
||||
CONTEXT_KEY,
|
||||
].map(key => options.properties.find(property => isPropertyWithKey(property, key)));
|
||||
|
||||
const messageId = messageIdProperty
|
||||
? formatJSString(extractMessageId(messageIdProperty.value))
|
||||
: undefined;
|
||||
|
||||
if (!messageId) {
|
||||
throw new Error('Empty "id" value in intl.formatMessage() is not allowed.');
|
||||
}
|
||||
|
||||
const message = messageProperty
|
||||
? formatJSString(extractMessageValue(messageProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
if (!message) {
|
||||
throw new Error(`Default message is required for id: ${messageId}.`);
|
||||
}
|
||||
|
||||
const context = contextProperty
|
||||
? formatJSString(extractContextValue(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
return [messageId, { message, context }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [messageIdProperty, messageProperty, contextProperty] = [
|
||||
'id',
|
||||
DEFAULT_MESSAGE_KEY,
|
||||
CONTEXT_KEY,
|
||||
].map(key => node.attributes.find(attribute => isJSXIdentifier(attribute.name, { name: key })));
|
||||
|
||||
const messageId = messageIdProperty
|
||||
? formatHTMLString(extractMessageId(messageIdProperty.value))
|
||||
: undefined;
|
||||
|
||||
if (!messageId) {
|
||||
throw new Error('Empty "id" value in <FormattedMessage> is not allowed.');
|
||||
}
|
||||
|
||||
const message = messageProperty
|
||||
? formatHTMLString(extractMessageValue(messageProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
if (!message) {
|
||||
throw new Error(`Default message is required for id: ${messageId}.`);
|
||||
}
|
||||
|
||||
const context = contextProperty
|
||||
? formatHTMLString(extractContextValue(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
return [messageId, { message, context }];
|
||||
}
|
94
src/dev/i18n/utils.js
Normal file
94
src/dev/i18n/utils.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectProperty,
|
||||
isMemberExpression,
|
||||
isNode,
|
||||
} from '@babel/types';
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const ESCAPE_LINE_BREAK_REGEX = /(?<!\\)\\\n/g;
|
||||
const ESCAPE_SINGLE_QUOTE_REGEX = /\\([\s\S])|(')/g;
|
||||
const HTML_LINE_BREAK_REGEX = /[\s]*\n[\s]*/g;
|
||||
|
||||
export const readFileAsync = promisify(fs.readFile);
|
||||
export const writeFileAsync = promisify(fs.writeFile);
|
||||
export const globAsync = promisify(glob);
|
||||
export const makeDirAsync = promisify(fs.mkdir);
|
||||
export const accessAsync = promisify(fs.access);
|
||||
|
||||
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, '')
|
||||
.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2')
|
||||
.replace('\n', '\\n');
|
||||
}
|
||||
|
||||
export function formatHTMLString(string) {
|
||||
return (string || '')
|
||||
.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2')
|
||||
.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'));
|
||||
}
|
||||
}
|
||||
}
|
27
src/dev/run_extract_default_translations.js
Normal file
27
src/dev/run_extract_default_translations.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { run } from './run';
|
||||
import { extractDefaultTranslations } from './i18n/extract_default_translations';
|
||||
|
||||
run(async () => {
|
||||
for (const inputPath of process.argv.slice(2)) {
|
||||
await extractDefaultTranslations(inputPath);
|
||||
}
|
||||
});
|
|
@ -39,6 +39,10 @@
|
|||
esutils "^2.0.2"
|
||||
js-tokens "^3.0.0"
|
||||
|
||||
"@babel/parser@7.0.0-beta.52":
|
||||
version "7.0.0-beta.52"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.0.0-beta.52.tgz#4e935b62cd9bf872bd37bcf1f63d82fe7b0237a2"
|
||||
|
||||
"@babel/template@7.0.0-beta.31":
|
||||
version "7.0.0-beta.31"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.31.tgz#577bb29389f6c497c3e7d014617e7d6713f68bda"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue