mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Tools] Add "values" property validation (#22538)
* [Tools] Add "values" property validation * Fix values validation * Fix typo in values regex * Fix whitespaces handling * Fix curly braces in regex * Fix missing/unused values differentiation * Use intl-messageformat-parser for parsing values from defaultMessage
This commit is contained in:
parent
40c232b111
commit
71d284469b
14 changed files with 354 additions and 136 deletions
|
@ -308,6 +308,7 @@
|
|||
"has-ansi": "^3.0.0",
|
||||
"husky": "^0.14.3",
|
||||
"image-diff": "1.6.0",
|
||||
"intl-messageformat-parser": "^1.4.0",
|
||||
"istanbul-instrumenter-loader": "3.0.0",
|
||||
"jest": "^23.5.0",
|
||||
"jest-cli": "^23.5.0",
|
||||
|
|
|
@ -287,10 +287,14 @@ import { injectI18n, intlShape } from '@kbn/i18n/react';
|
|||
const MyComponentContent = ({ intl }) => (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'KIBANA-MANAGEMENT-OBJECTS-SEARCH_PLACEHOLDER',
|
||||
defaultMessage: 'Search',
|
||||
})}
|
||||
placeholder={intl.formatMessage(
|
||||
{
|
||||
id: 'welcome',
|
||||
defaultMessage: 'Hello {name}, you have {unreadCount, number}\
|
||||
{unreadCount, plural, one {message} other {messages}}',
|
||||
},
|
||||
{ name, unreadCount }
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -111,9 +111,9 @@ Click \'Confirm overwrite\' to import and overwrite existing objects. Any change
|
|||
const statusMsg = hasErrors
|
||||
? this.props.intl.formatMessage(
|
||||
{ id: 'kbn.home.tutorial.savedObject.unableToAddErrorMessage',
|
||||
defaultMessage: 'Unable to add {errorsLength} of {savedObjectsLength} kibana objects, Error: ${errors[0].error.message}'
|
||||
defaultMessage: 'Unable to add {errorsLength} of {savedObjectsLength} kibana objects, Error: {errorMessage}'
|
||||
},
|
||||
{ errorsLength: errors.length, savedObjectsLength: this.props.savedObjects.length })
|
||||
{ errorsLength: errors.length, savedObjectsLength: this.props.savedObjects.length, errorMessage: errors[0].error.message })
|
||||
: this.props.intl.formatMessage(
|
||||
{ id: 'kbn.home.tutorial.savedObject.addedLabel',
|
||||
defaultMessage: '{savedObjectsLength} saved objects successfully added'
|
||||
|
|
|
@ -10,8 +10,28 @@ exports[`i18n utils should create verbose parser error message 1`] = `
|
|||
`;
|
||||
|
||||
exports[`i18n utils should not escape linebreaks 1`] = `
|
||||
"Text
|
||||
"Text
|
||||
with
|
||||
line-breaks
|
||||
"
|
||||
`;
|
||||
|
||||
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]."
|
||||
`;
|
||||
|
|
|
@ -19,3 +19,4 @@
|
|||
|
||||
export const DEFAULT_MESSAGE_KEY = 'defaultMessage';
|
||||
export const CONTEXT_KEY = 'context';
|
||||
export const VALUES_KEY = 'values';
|
||||
|
|
|
@ -14,7 +14,7 @@ Array [
|
|||
|
||||
exports[`dev/i18n/extractors/handlebars throws on empty id 1`] = `"Empty id argument in Handlebars i18n is not allowed."`;
|
||||
|
||||
exports[`dev/i18n/extractors/handlebars throws on missing defaultMessage property 1`] = `"Empty defaultMessage in Handlebars i18n is not allowed (\\"message-id\\")."`;
|
||||
exports[`dev/i18n/extractors/handlebars throws on missing defaultMessage property 1`] = `"defaultMessage value in Handlebars i18n should be a string (\\"message-id\\")."`;
|
||||
|
||||
exports[`dev/i18n/extractors/handlebars throws on wrong number of arguments 1`] = `"Wrong number of arguments for handlebars i18n call."`;
|
||||
|
||||
|
|
|
@ -20,10 +20,10 @@ Array [
|
|||
]
|
||||
`;
|
||||
|
||||
exports[`dev/i18n/extractors/i18n_call throws if defaultMessage is not a string literal 1`] = `"defaultMessage value in i18n() or i18n.translate() should be a string literal (\\"message-id\\")."`;
|
||||
exports[`dev/i18n/extractors/i18n_call throws if defaultMessage is not a string literal 1`] = `"defaultMessage value should be a string literal (\\"message-id\\")."`;
|
||||
|
||||
exports[`dev/i18n/extractors/i18n_call throws if message id value is not a string literal 1`] = `"Message id in i18n() or i18n.translate() should be a string literal."`;
|
||||
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`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`;
|
||||
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\\")."`;
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { formatJSString } from '../utils';
|
||||
import { formatJSString, checkValuesProperty } from '../utils';
|
||||
import { createFailError } from '../../run';
|
||||
|
||||
const HBS_REGEX = /(?<=\{\{)([\s\S]*?)(?=\}\})/g;
|
||||
|
@ -57,28 +57,38 @@ export function* extractHandlebarsMessages(buffer) {
|
|||
}
|
||||
|
||||
const properties = JSON.parse(propertiesString.slice(1, -1));
|
||||
const message = formatJSString(properties.defaultMessage);
|
||||
|
||||
if (typeof message !== 'string') {
|
||||
if (typeof properties.defaultMessage !== 'string') {
|
||||
throw createFailError(
|
||||
`defaultMessage value in Handlebars i18n should be a string ("${messageId}").`
|
||||
);
|
||||
}
|
||||
|
||||
if (properties.context != null && typeof properties.context !== 'string') {
|
||||
throw createFailError(
|
||||
`Context value in Handlebars i18n should be a string ("${messageId}").`
|
||||
);
|
||||
}
|
||||
|
||||
const message = formatJSString(properties.defaultMessage);
|
||||
const context = formatJSString(properties.context);
|
||||
|
||||
if (!message) {
|
||||
throw createFailError(
|
||||
`Empty defaultMessage in Handlebars i18n is not allowed ("${messageId}").`
|
||||
);
|
||||
}
|
||||
|
||||
const context = formatJSString(properties.context);
|
||||
const valuesObject = properties.values;
|
||||
|
||||
if (context != null && typeof context !== 'string') {
|
||||
if (valuesObject != null && typeof valuesObject !== 'object') {
|
||||
throw createFailError(
|
||||
`Context value in Handlebars i18n should be a string ("${messageId}").`
|
||||
`"values" value should be an object in Handlebars i18n ("${messageId}").`
|
||||
);
|
||||
}
|
||||
|
||||
checkValuesProperty(Object.keys(valuesObject || {}), message, messageId);
|
||||
|
||||
yield [messageId, { message, context }];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,16 +19,20 @@
|
|||
|
||||
import cheerio from 'cheerio';
|
||||
import { parse } from '@babel/parser';
|
||||
import { isDirectiveLiteral, isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
import { isDirectiveLiteral, isObjectExpression } from '@babel/types';
|
||||
|
||||
import {
|
||||
isPropertyWithKey,
|
||||
formatHTMLString,
|
||||
formatJSString,
|
||||
traverseNodes,
|
||||
checkValuesProperty,
|
||||
createParserErrorMessage,
|
||||
extractMessageValueFromNode,
|
||||
extractValuesKeysFromNode,
|
||||
extractContextValueFromNode,
|
||||
} from '../utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY, VALUES_KEY } from '../constants';
|
||||
import { createFailError } from '../../run';
|
||||
|
||||
/**
|
||||
|
@ -41,9 +45,10 @@ 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
|
||||
* @param {string} messageId id of the message
|
||||
* @returns {{ message?: string, context?: string, valuesKeys: string[]] }}
|
||||
*/
|
||||
function parseFilterObjectExpression(expression) {
|
||||
function parseFilterObjectExpression(expression, messageId) {
|
||||
let ast;
|
||||
|
||||
try {
|
||||
|
@ -60,34 +65,33 @@ function parseFilterObjectExpression(expression) {
|
|||
throw error;
|
||||
}
|
||||
|
||||
for (const node of traverseNodes(ast.program.body)) {
|
||||
if (!isObjectExpression(node)) {
|
||||
continue;
|
||||
}
|
||||
const objectExpressionNode = [...traverseNodes(ast.program.body)].find(node =>
|
||||
isObjectExpression(node)
|
||||
);
|
||||
|
||||
let message;
|
||||
let context;
|
||||
|
||||
for (const property of node.properties) {
|
||||
if (isPropertyWithKey(property, DEFAULT_MESSAGE_KEY)) {
|
||||
if (!isStringLiteral(property.value)) {
|
||||
throw createFailError(`defaultMessage value should be a string literal.`);
|
||||
}
|
||||
|
||||
message = formatJSString(property.value.value);
|
||||
} else if (isPropertyWithKey(property, CONTEXT_KEY)) {
|
||||
if (!isStringLiteral(property.value)) {
|
||||
throw createFailError(`context value should be a string literal.`);
|
||||
}
|
||||
|
||||
context = formatJSString(property.value.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { message, context };
|
||||
if (!objectExpressionNode) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return null;
|
||||
const [messageProperty, contextProperty, valuesProperty] = [
|
||||
DEFAULT_MESSAGE_KEY,
|
||||
CONTEXT_KEY,
|
||||
VALUES_KEY,
|
||||
].map(key => objectExpressionNode.properties.find(property => isPropertyWithKey(property, key)));
|
||||
|
||||
const message = messageProperty
|
||||
? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
const context = contextProperty
|
||||
? formatJSString(extractContextValueFromNode(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
const valuesKeys = valuesProperty
|
||||
? extractValuesKeysFromNode(valuesProperty.value, messageId)
|
||||
: [];
|
||||
|
||||
return { message, context, valuesKeys };
|
||||
}
|
||||
|
||||
function parseIdExpression(expression) {
|
||||
|
@ -106,13 +110,10 @@ function parseIdExpression(expression) {
|
|||
throw error;
|
||||
}
|
||||
|
||||
for (const node of traverseNodes(ast.program.directives)) {
|
||||
if (isDirectiveLiteral(node)) {
|
||||
return formatJSString(node.value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const stringNode = [...traverseNodes(ast.program.directives)].find(node =>
|
||||
isDirectiveLiteral(node)
|
||||
);
|
||||
return stringNode ? formatJSString(stringNode.value) : null;
|
||||
}
|
||||
|
||||
function trimCurlyBraces(string) {
|
||||
|
@ -170,7 +171,10 @@ function* getFilterMessages(htmlContent) {
|
|||
throw createFailError(`Empty "id" value in angular filter expression is not allowed.`);
|
||||
}
|
||||
|
||||
const { message, context } = parseFilterObjectExpression(filterObjectExpression) || {};
|
||||
const { message, context, valuesKeys } = parseFilterObjectExpression(
|
||||
filterObjectExpression,
|
||||
messageId
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
throw createFailError(
|
||||
|
@ -178,6 +182,8 @@ function* getFilterMessages(htmlContent) {
|
|||
);
|
||||
}
|
||||
|
||||
checkValuesProperty(valuesKeys, message, messageId);
|
||||
|
||||
yield [messageId, { message, context }];
|
||||
}
|
||||
}
|
||||
|
@ -185,14 +191,17 @@ function* getFilterMessages(htmlContent) {
|
|||
function* getDirectiveMessages(htmlContent) {
|
||||
const $ = cheerio.load(htmlContent);
|
||||
|
||||
const elements = $('[i18n-id]').map(function (idx, el) {
|
||||
const $el = $(el);
|
||||
return {
|
||||
id: $el.attr('i18n-id'),
|
||||
defaultMessage: $el.attr('i18n-default-message'),
|
||||
context: $el.attr('i18n-context'),
|
||||
};
|
||||
}).toArray();
|
||||
const elements = $('[i18n-id]')
|
||||
.map(function (idx, el) {
|
||||
const $el = $(el);
|
||||
return {
|
||||
id: $el.attr('i18n-id'),
|
||||
defaultMessage: $el.attr('i18n-default-message'),
|
||||
context: $el.attr('i18n-context'),
|
||||
values: $el.attr('i18n-values'),
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
|
||||
for (const element of elements) {
|
||||
const messageId = formatHTMLString(element.id);
|
||||
|
@ -207,6 +216,16 @@ function* getDirectiveMessages(htmlContent) {
|
|||
);
|
||||
}
|
||||
|
||||
if (element.values) {
|
||||
const nodes = parse(`+${element.values}`).program.body;
|
||||
const valuesObjectNode = [...traverseNodes(nodes)].find(node => isObjectExpression(node));
|
||||
const valuesKeys = extractValuesKeysFromNode(valuesObjectNode);
|
||||
|
||||
checkValuesProperty(valuesKeys, message, messageId);
|
||||
} else {
|
||||
checkValuesProperty([], message, messageId);
|
||||
}
|
||||
|
||||
yield [messageId, { message, context: formatHTMLString(element.context) || undefined }];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,18 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
import { isObjectExpression } from '@babel/types';
|
||||
|
||||
import { isPropertyWithKey, formatJSString } from '../utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants';
|
||||
import {
|
||||
isPropertyWithKey,
|
||||
formatJSString,
|
||||
checkValuesProperty,
|
||||
extractMessageIdFromNode,
|
||||
extractMessageValueFromNode,
|
||||
extractContextValueFromNode,
|
||||
extractValuesKeysFromNode,
|
||||
} from '../utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY, VALUES_KEY } from '../constants';
|
||||
import { createFailError } from '../../run';
|
||||
|
||||
/**
|
||||
|
@ -28,45 +36,31 @@ import { createFailError } from '../../run';
|
|||
*/
|
||||
export function extractI18nCallMessages(node) {
|
||||
const [idSubTree, optionsSubTree] = node.arguments;
|
||||
|
||||
if (!isStringLiteral(idSubTree)) {
|
||||
throw createFailError(`Message id in i18n() or i18n.translate() should be a string literal.`);
|
||||
}
|
||||
|
||||
const messageId = idSubTree.value;
|
||||
const messageId = extractMessageIdFromNode(idSubTree);
|
||||
|
||||
if (!messageId) {
|
||||
throw createFailError(`Empty "id" value in i18n() or i18n.translate() is not allowed.`);
|
||||
}
|
||||
|
||||
let message;
|
||||
let context;
|
||||
|
||||
if (!isObjectExpression(optionsSubTree)) {
|
||||
throw createFailError(
|
||||
`Empty defaultMessage in i18n() or i18n.translate() is not allowed ("${messageId}").`
|
||||
`Object with defaultMessage property is not passed to i18n() or i18n.translate() function call ("${messageId}").`
|
||||
);
|
||||
}
|
||||
|
||||
for (const prop of optionsSubTree.properties) {
|
||||
if (isPropertyWithKey(prop, DEFAULT_MESSAGE_KEY)) {
|
||||
if (!isStringLiteral(prop.value)) {
|
||||
throw createFailError(
|
||||
`defaultMessage value in i18n() or i18n.translate() should be a string literal ("${messageId}").`
|
||||
);
|
||||
}
|
||||
const [messageProperty, contextProperty, valuesProperty] = [
|
||||
DEFAULT_MESSAGE_KEY,
|
||||
CONTEXT_KEY,
|
||||
VALUES_KEY,
|
||||
].map(key => optionsSubTree.properties.find(property => isPropertyWithKey(property, key)));
|
||||
|
||||
message = formatJSString(prop.value.value);
|
||||
} else if (isPropertyWithKey(prop, CONTEXT_KEY)) {
|
||||
if (!isStringLiteral(prop.value)) {
|
||||
throw createFailError(
|
||||
`context value in i18n() or i18n.translate() should be a string literal ("${messageId}").`
|
||||
);
|
||||
}
|
||||
const message = messageProperty
|
||||
? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
context = formatJSString(prop.value.value);
|
||||
}
|
||||
}
|
||||
const context = contextProperty
|
||||
? formatJSString(extractContextValueFromNode(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
if (!message) {
|
||||
throw createFailError(
|
||||
|
@ -74,5 +68,11 @@ export function extractI18nCallMessages(node) {
|
|||
);
|
||||
}
|
||||
|
||||
const valuesKeys = valuesProperty
|
||||
? extractValuesKeysFromNode(valuesProperty.value, messageId)
|
||||
: [];
|
||||
|
||||
checkValuesProperty(valuesKeys, message, messageId);
|
||||
|
||||
return [messageId, { message, context }];
|
||||
}
|
||||
|
|
88
src/dev/i18n/extractors/react.js
vendored
88
src/dev/i18n/extractors/react.js
vendored
|
@ -17,43 +17,28 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { isJSXIdentifier, isObjectExpression, isStringLiteral } from '@babel/types';
|
||||
import { isJSXIdentifier, isObjectExpression, isJSXExpressionContainer } from '@babel/types';
|
||||
|
||||
import { isPropertyWithKey, formatJSString, formatHTMLString } from '../utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants';
|
||||
import {
|
||||
isPropertyWithKey,
|
||||
formatJSString,
|
||||
formatHTMLString,
|
||||
extractMessageIdFromNode,
|
||||
extractMessageValueFromNode,
|
||||
extractContextValueFromNode,
|
||||
extractValuesKeysFromNode,
|
||||
checkValuesProperty,
|
||||
} from '../utils';
|
||||
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY, VALUES_KEY } from '../constants';
|
||||
import { createFailError } from '../../run';
|
||||
|
||||
function extractMessageId(value) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw createFailError(`Message id should be a string literal.`);
|
||||
}
|
||||
|
||||
return value.value;
|
||||
}
|
||||
|
||||
function extractMessageValue(value, id) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw createFailError(`defaultMessage value should be a string literal ("${id}").`);
|
||||
}
|
||||
|
||||
return value.value;
|
||||
}
|
||||
|
||||
function extractContextValue(value, id) {
|
||||
if (!isStringLiteral(value)) {
|
||||
throw createFailError(`context value should be a string literal ("${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];
|
||||
const [options, valuesNode] = node.arguments;
|
||||
|
||||
if (!isObjectExpression(options)) {
|
||||
throw createFailError(
|
||||
|
@ -68,7 +53,7 @@ export function extractIntlMessages(node) {
|
|||
].map(key => options.properties.find(property => isPropertyWithKey(property, key)));
|
||||
|
||||
const messageId = messageIdProperty
|
||||
? formatJSString(extractMessageId(messageIdProperty.value))
|
||||
? formatJSString(extractMessageIdFromNode(messageIdProperty.value))
|
||||
: undefined;
|
||||
|
||||
if (!messageId) {
|
||||
|
@ -76,7 +61,11 @@ export function extractIntlMessages(node) {
|
|||
}
|
||||
|
||||
const message = messageProperty
|
||||
? formatJSString(extractMessageValue(messageProperty.value, messageId))
|
||||
? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
const context = contextProperty
|
||||
? formatJSString(extractContextValueFromNode(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
|
||||
if (!message) {
|
||||
|
@ -85,9 +74,9 @@ export function extractIntlMessages(node) {
|
|||
);
|
||||
}
|
||||
|
||||
const context = contextProperty
|
||||
? formatJSString(extractContextValue(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
const valuesKeys = valuesNode ? extractValuesKeysFromNode(valuesNode, messageId) : [];
|
||||
|
||||
checkValuesProperty(valuesKeys, message, messageId);
|
||||
|
||||
return [messageId, { message, context }];
|
||||
}
|
||||
|
@ -98,22 +87,27 @@ export function extractIntlMessages(node) {
|
|||
* @returns {[string, string][]} Array of id-message tuples
|
||||
*/
|
||||
export function extractFormattedMessages(node) {
|
||||
const [messageIdProperty, messageProperty, contextProperty] = [
|
||||
const [messageIdAttribute, messageAttribute, contextAttribute, valuesAttribute] = [
|
||||
'id',
|
||||
DEFAULT_MESSAGE_KEY,
|
||||
CONTEXT_KEY,
|
||||
VALUES_KEY,
|
||||
].map(key => node.attributes.find(attribute => isJSXIdentifier(attribute.name, { name: key })));
|
||||
|
||||
const messageId = messageIdProperty
|
||||
? formatHTMLString(extractMessageId(messageIdProperty.value))
|
||||
const messageId = messageIdAttribute
|
||||
? formatHTMLString(extractMessageIdFromNode(messageIdAttribute.value))
|
||||
: undefined;
|
||||
|
||||
if (!messageId) {
|
||||
throw createFailError(`Empty "id" value in <FormattedMessage> is not allowed.`);
|
||||
}
|
||||
|
||||
const message = messageProperty
|
||||
? formatHTMLString(extractMessageValue(messageProperty.value, messageId))
|
||||
const message = messageAttribute
|
||||
? formatHTMLString(extractMessageValueFromNode(messageAttribute.value, messageId))
|
||||
: undefined;
|
||||
|
||||
const context = contextAttribute
|
||||
? formatHTMLString(extractContextValueFromNode(contextAttribute.value, messageId))
|
||||
: undefined;
|
||||
|
||||
if (!message) {
|
||||
|
@ -122,9 +116,21 @@ export function extractFormattedMessages(node) {
|
|||
);
|
||||
}
|
||||
|
||||
const context = contextProperty
|
||||
? formatHTMLString(extractContextValue(contextProperty.value, messageId))
|
||||
: undefined;
|
||||
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, context }];
|
||||
}
|
||||
|
|
|
@ -20,14 +20,19 @@
|
|||
import {
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectProperty,
|
||||
isMemberExpression,
|
||||
isNode,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
} from '@babel/types';
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
import { promisify } from 'util';
|
||||
import chalk from 'chalk';
|
||||
import parser from 'intl-messageformat-parser';
|
||||
|
||||
import { createFailError } from '../run';
|
||||
|
||||
const ESCAPE_LINE_BREAK_REGEX = /(?<!\\)\\\n/g;
|
||||
const HTML_LINE_BREAK_REGEX = /[\s]*\n[\s]*/g;
|
||||
|
@ -36,6 +41,10 @@ export const readFileAsync = promisify(fs.readFile);
|
|||
export const writeFileAsync = promisify(fs.writeFile);
|
||||
export const globAsync = promisify(glob);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
@ -114,3 +123,102 @@ export function createParserErrorMessage(content, error) {
|
|||
|
||||
return `${error.message}:\n${context}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether values from "values" and "defaultMessage" correspond to each other.
|
||||
*
|
||||
* @param {string[]} valuesKeys 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(valuesKeys, defaultMessage, messageId) {
|
||||
// skip validation if defaultMessage doesn't use ICU and values prop has no keys
|
||||
if (!valuesKeys.length && !defaultMessage.includes('{')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultMessageAst;
|
||||
|
||||
try {
|
||||
defaultMessageAst = parser.parse(defaultMessage);
|
||||
} catch (error) {
|
||||
if (error.name === 'SyntaxError') {
|
||||
const errorWithContext = createParserErrorMessage(defaultMessage, {
|
||||
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;
|
||||
}
|
||||
|
||||
const ARGUMENT_ELEMENT_TYPE = 'argumentElement';
|
||||
|
||||
// skip validation if intl-messageformat-parser didn't return an AST with nonempty elements array
|
||||
if (!defaultMessageAst || !defaultMessageAst.elements || !defaultMessageAst.elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultMessageValueReferences = defaultMessageAst.elements.reduce((keys, element) => {
|
||||
if (element.type === ARGUMENT_ELEMENT_TYPE) {
|
||||
keys.push(element.id);
|
||||
}
|
||||
return keys;
|
||||
}, []);
|
||||
|
||||
const missingValuesKeys = difference(defaultMessageValueReferences, valuesKeys);
|
||||
if (missingValuesKeys.length) {
|
||||
throw createFailError(
|
||||
`some properties are missing in "values" object ("${messageId}"):\n[${missingValuesKeys}].`
|
||||
);
|
||||
}
|
||||
|
||||
const unusedValuesKeys = difference(valuesKeys, defaultMessageValueReferences);
|
||||
if (unusedValuesKeys.length) {
|
||||
throw createFailError(
|
||||
`"values" object contains unused properties ("${messageId}"):\n[${unusedValuesKeys}].`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMessageIdFromNode(node) {
|
||||
if (!isStringLiteral(node)) {
|
||||
throw createFailError(`Message id should be a string literal.`);
|
||||
}
|
||||
|
||||
return node.value;
|
||||
}
|
||||
|
||||
export function extractMessageValueFromNode(node, messageId) {
|
||||
if (!isStringLiteral(node)) {
|
||||
throw createFailError(`defaultMessage value should be a string literal ("${messageId}").`);
|
||||
}
|
||||
|
||||
return node.value;
|
||||
}
|
||||
|
||||
export function extractContextValueFromNode(node, messageId) {
|
||||
if (!isStringLiteral(node)) {
|
||||
throw createFailError(`context value should be a string literal ("${messageId}").`);
|
||||
}
|
||||
|
||||
return node.value;
|
||||
}
|
||||
|
||||
export function extractValuesKeysFromNode(node, messageId) {
|
||||
if (!isObjectExpression(node)) {
|
||||
throw createFailError(`"values" value should be an object expression ("${messageId}").`);
|
||||
}
|
||||
|
||||
return node.properties.map(
|
||||
property => (isStringLiteral(property.key) ? property.key.value : property.key.name)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
isPropertyWithKey,
|
||||
traverseNodes,
|
||||
formatJSString,
|
||||
checkValuesProperty,
|
||||
createParserErrorMessage,
|
||||
} from './utils';
|
||||
|
||||
|
@ -53,7 +54,7 @@ describe('i18n utils', () => {
|
|||
|
||||
test('should not escape linebreaks', () => {
|
||||
expect(
|
||||
formatJSString(`Text \n with
|
||||
formatJSString(`Text\n with
|
||||
line-breaks
|
||||
`)
|
||||
).toMatchSnapshot();
|
||||
|
@ -104,4 +105,52 @@ describe('i18n utils', () => {
|
|||
expect(createParserErrorMessage(content, error)).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();
|
||||
});
|
||||
|
||||
test('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();
|
||||
});
|
||||
|
||||
test('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();
|
||||
});
|
||||
|
||||
test('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();
|
||||
});
|
||||
|
||||
test(`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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8351,7 +8351,7 @@ intl-format-cache@^2.0.5, intl-format-cache@^2.1.0:
|
|||
resolved "https://registry.yarnpkg.com/intl-format-cache/-/intl-format-cache-2.1.0.tgz#04a369fecbfad6da6005bae1f14333332dcf9316"
|
||||
integrity sha1-BKNp/sv61tpgBbrh8UMzMy3PkxY=
|
||||
|
||||
intl-messageformat-parser@1.4.0:
|
||||
intl-messageformat-parser@1.4.0, intl-messageformat-parser@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-1.4.0.tgz#b43d45a97468cadbe44331d74bb1e8dea44fc075"
|
||||
integrity sha1-tD1FqXRoytvkQzHXS7Ho3qRPwHU=
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue