Update i18n eslint rule to validate missing/extra icu params

This commit is contained in:
Jamie Kyle
2023-04-04 11:41:14 -07:00
committed by GitHub
parent 4e6c3ba9df
commit 8ca192a48d
16 changed files with 449 additions and 66 deletions

View File

@@ -2,14 +2,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
const crypto = require('crypto');
const icuParser = require('@formatjs/icu-messageformat-parser');
const globalMessages = require('../../_locales/en/messages.json');
const messageKeys = Object.keys(globalMessages).sort((a, b) => {
return a.localeCompare(b);
});
const allIcuParams = messageKeys
.filter(key => {
return isIcuMessageKey(globalMessages, key);
})
.map(key => {
return Array.from(
getIcuMessageParams(globalMessages[key].messageformat)
).join('\n');
});
const hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
hashSum.update(allIcuParams.join('\n'));
const messagesCacheKey = hashSum.digest('hex');
function isI18nCall(node) {
@@ -54,6 +65,14 @@ function getI18nCallMessageKey(node) {
return valueToMessageKey(arg1);
}
function getI18nCallValues(node) {
// babel-eslint messes with elements arrays in some cases because of TS
if (node.arguments.length < 2) {
return null;
}
return node.arguments[1];
}
function getIntlElementMessageKey(node) {
let idAttribute = node.attributes.find(attribute => {
return (
@@ -72,6 +91,27 @@ function getIntlElementMessageKey(node) {
return valueToMessageKey(value);
}
function getIntlElementComponents(node) {
let componentsAttribute = node.attributes.find(attribute => {
return (
attribute.type === 'JSXAttribute' &&
attribute.name.type === 'JSXIdentifier' &&
attribute.name.name === 'components'
);
});
if (componentsAttribute == null) {
return null;
}
let value = componentsAttribute.value;
if (value?.type !== 'JSXExpressionContainer') {
return null;
}
return value.expression;
}
function isValidMessageKey(messages, key) {
return Object.hasOwn(messages, key);
}
@@ -89,6 +129,67 @@ function isDeletedMessageKey(messages, key) {
return description?.toLowerCase().startsWith('(deleted ');
}
function getIcuMessageParams(message) {
const params = new Set();
function visitOptions(options) {
for (const option of Object.values(options)) {
visit(option.value);
}
}
function visit(elements) {
for (const element of elements) {
switch (element.type) {
case icuParser.TYPE.argument:
params.add(element.value);
break;
case icuParser.TYPE.date:
params.add(element.value);
break;
case icuParser.TYPE.literal:
break;
case icuParser.TYPE.number:
params.add(element.value);
break;
case icuParser.TYPE.plural:
params.add(element.value);
visitOptions(element.options);
break;
case icuParser.TYPE.pound:
break;
case icuParser.TYPE.select:
params.add(element.value);
visitOptions(element.options);
break;
case icuParser.TYPE.tag:
params.add(element.value);
visit(element.children);
break;
case icuParser.TYPE.time:
params.add(element.value);
break;
default:
throw new Error(`Unknown element type: ${element.type}`);
}
}
}
visit(icuParser.parse(message));
return params;
}
function getMissingFromSet(expected, actual) {
const result = new Set();
for (const item of expected) {
if (!actual.has(item)) {
result.add(item);
}
}
return result;
}
module.exports = {
messagesCacheKey,
meta: {
@@ -150,7 +251,7 @@ module.exports = {
return;
}
let key = getIntlElementMessageKey(node);
const key = getIntlElementMessageKey(node);
if (key == null) {
context.report({
@@ -184,13 +285,76 @@ module.exports = {
});
return;
}
const params = getIcuMessageParams(messages[key].messageformat);
const components = getIntlElementComponents(node);
if (params.size === 0) {
if (components != null) {
context.report({
node,
message: `<Intl> message "${key}" does not have any params, but has a "components" attribute`,
});
}
return;
}
if (components == null) {
context.report({
node,
message: `<Intl> message "${key}" has params, but is missing a "components" attribute`,
});
return;
}
if (components.type !== 'ObjectExpression') {
context.report({
node: components,
message: `<Intl> "components" attribute must be an object literal`,
});
return;
}
const props = new Set();
for (const property of components.properties) {
if (property.type !== 'Property' || property.computed) {
context.report({
node: property,
message: `<Intl> "components" attribute must only contain literal keys`,
});
return;
}
props.add(property.key.name);
}
const missingParams = getMissingFromSet(params, props);
if (missingParams.size > 0) {
for (const param of missingParams) {
context.report({
node: components,
message: `<Intl> message "${key}" has a param "${param}", but no corresponding component`,
});
}
return;
}
const extraComponents = getMissingFromSet(props, params);
if (extraComponents.size > 0) {
for (const prop of extraComponents) {
context.report({
node: components,
message: `<Intl> message "${key}" has a component "${prop}", but no corresponding param`,
});
}
return;
}
},
CallExpression(node) {
if (!isI18nCall(node)) {
return;
}
let key = getI18nCallMessageKey(node);
const key = getI18nCallMessageKey(node);
if (key == null) {
context.report({
@@ -222,6 +386,70 @@ module.exports = {
node,
message: `i18n() key "${key}" is marked as deleted in _locales/en/messages.json`,
});
return;
}
const params = getIcuMessageParams(messages[key].messageformat);
const values = getI18nCallValues(node);
if (params.size === 0) {
if (values != null) {
context.report({
node,
message: `i18n() message "${key}" does not have any params, but has a "values" argument`,
});
}
return;
}
if (values == null) {
context.report({
node,
message: `i18n() message "${key}" has params, but is missing a "values" argument`,
});
return;
}
if (values.type !== 'ObjectExpression') {
context.report({
node: values,
message: `i18n() "values" argument must be an object literal`,
});
return;
}
const props = new Set();
for (const property of values.properties) {
if (property.type !== 'Property' || property.computed) {
context.report({
node: property,
message: `i18n() "values" argument must only contain literal keys`,
});
return;
}
props.add(property.key.name);
}
const missingParams = getMissingFromSet(params, props);
if (missingParams.size > 0) {
for (const param of missingParams) {
context.report({
node: values,
message: `i18n() message "${key}" has a param "${param}", but no corresponding value`,
});
}
return;
}
const extraProps = getMissingFromSet(props, params);
if (extraProps.size > 0) {
for (const prop of extraProps) {
context.report({
node: values,
message: `i18n() message "${key}" has a value "${prop}", but no corresponding param`,
});
}
return;
}
},
};

View File

@@ -17,6 +17,12 @@ const __mockMessages__ = {
messageformat: 'shouldnt use me anymore',
description: '(deleted 01/01/1970)',
},
'icu:no_params': {
messageformat: 'ICU message',
},
'icu:nested': {
messageformat: '{one, select, other {{two, plural, other {{three}}}}}}',
},
};
// Need to load so mocha doesn't complain about polluting the global namespace
@@ -36,15 +42,27 @@ const ruleTester = new RuleTester({
ruleTester.run('valid-i18n-keys', rule, {
valid: [
{
code: `i18n("icu:real_message")`,
code: `i18n("icu:real_message", { message: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
code: `window.i18n("icu:real_message")`,
code: `window.i18n("icu:real_message", { message: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
code: `let jsx = <Intl id="icu:real_message"/>`,
code: `let jsx = <Intl id="icu:real_message" components={{ message: "foo" }}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
code: `i18n("icu:no_params")`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
code: `let jsx = <Intl id="icu:no_params"/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
code: `i18n("icu:nested", { one: "1", two: "2", three: "3" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
],
@@ -221,5 +239,166 @@ ruleTester.run('valid-i18n-keys', rule, {
},
],
},
{
code: `i18n("icu:no_params", { message: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'i18n() message "icu:no_params" does not have any params, but has a "values" argument',
type: 'CallExpression',
},
],
},
{
code: `i18n("icu:real_message")`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'i18n() message "icu:real_message" has params, but is missing a "values" argument',
type: 'CallExpression',
},
],
},
{
code: `i18n("icu:real_message", null)`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message: 'i18n() "values" argument must be an object literal',
type: 'Literal',
},
],
},
{
code: `i18n("icu:real_message", { [foo]: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message: 'i18n() "values" argument must only contain literal keys',
type: 'Property',
},
],
},
{
code: `i18n("icu:real_message", { ...props })`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message: 'i18n() "values" argument must only contain literal keys',
type: 'SpreadElement',
},
],
},
{
code: `i18n("icu:real_message", {})`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'i18n() message "icu:real_message" has a param "message", but no corresponding value',
type: 'ObjectExpression',
},
],
},
{
code: `i18n("icu:real_message", { message: "foo", foo: "bar" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'i18n() message "icu:real_message" has a value "foo", but no corresponding param',
type: 'ObjectExpression',
},
],
},
{
code: `let jsx = <Intl id="icu:no_params" components={{ message: "foo" }}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> message "icu:no_params" does not have any params, but has a "components" attribute',
type: 'JSXOpeningElement',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message"/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> message "icu:real_message" has params, but is missing a "components" attribute',
type: 'JSXOpeningElement',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message" components={null}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message: '<Intl> "components" attribute must be an object literal',
type: 'Literal',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message" components={{ [foo]: "foo" }}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> "components" attribute must only contain literal keys',
type: 'Property',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message" components={{ ...props }}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> "components" attribute must only contain literal keys',
type: 'SpreadElement',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message" components={{}}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> message "icu:real_message" has a param "message", but no corresponding component',
type: 'ObjectExpression',
},
],
},
{
code: `let jsx = <Intl id="icu:real_message" components={{ message: "foo", foo: "bar" }}/>`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'<Intl> message "icu:real_message" has a component "foo", but no corresponding param',
type: 'ObjectExpression',
},
],
},
{
code: `i18n("icu:nested", { one: "1", two: "2" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
errors: [
{
message:
'i18n() message "icu:nested" has a param "three", but no corresponding value',
type: 'ObjectExpression',
},
],
},
],
});