Skip to content

Commit

Permalink
Update i18n eslint rule to validate missing/extra icu params
Browse files Browse the repository at this point in the history
  • Loading branch information
jamiebuilds-signal committed Apr 4, 2023
1 parent 4e6c3ba commit 8ca192a
Show file tree
Hide file tree
Showing 16 changed files with 449 additions and 66 deletions.
232 changes: 230 additions & 2 deletions .eslint/rules/valid-i18n-keys.js
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
Expand All @@ -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);
}
Expand All @@ -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: {
Expand Down Expand Up @@ -150,7 +251,7 @@ module.exports = {
return;
}

let key = getIntlElementMessageKey(node);
const key = getIntlElementMessageKey(node);

if (key == null) {
context.report({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}
},
};
Expand Down

0 comments on commit 8ca192a

Please sign in to comment.