signal-desktop/.eslint/rules/valid-i18n-keys.js

471 lines
12 KiB
JavaScript

// Copyright 2022 Signal Messenger, LLC
// 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 DEFAULT_RICH_TEXT_ELEMENT_NAMES = ['emojify'];
const hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
hashSum.update(allIcuParams.join('\n'));
hashSum.update(DEFAULT_RICH_TEXT_ELEMENT_NAMES.join('\n'));
const messagesCacheKey = hashSum.digest('hex');
function isI18nCall(node) {
return (
(node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'i18n') ||
(node.callee.type === 'MemberExpression' &&
node.callee.property.name === 'i18n')
);
}
function isIntlElement(node) {
return (
node.type === 'JSXOpeningElement' &&
node.name.type === 'JSXIdentifier' &&
node.name.name === 'Intl'
);
}
function isStringLiteral(node) {
return node.type === 'Literal' && typeof node.value === 'string';
}
function valueToMessageKey(node) {
if (isStringLiteral(node)) {
return node.value;
}
return null;
}
function getI18nCallMessageKey(node) {
if (node.arguments.length < 1) {
return null;
}
let arg1 = node.arguments[0];
if (arg1 == null) {
return null;
}
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 (
attribute.type === 'JSXAttribute' &&
attribute.name.type === 'JSXIdentifier' &&
attribute.name.name === 'id'
);
});
if (idAttribute == null) {
return null;
}
let value = idAttribute.value;
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);
}
function isIcuMessageKey(messages, key) {
if (!key.startsWith('icu:')) {
return false;
}
const message = messages[key];
return message?.messageformat != null;
}
function isDeletedMessageKey(messages, key) {
const description = messages[key]?.description;
return description?.toLowerCase().startsWith('(deleted ');
}
function getIcuMessageParams(message, defaultRichTextElementNames = []) {
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));
for (const defaultRichTextElementName of defaultRichTextElementNames) {
params.delete(defaultRichTextElementName);
}
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: {
type: 'problem',
hasSuggestions: false,
fixable: false,
schema: [
{
type: 'object',
properties: {
messagesCacheKey: {
type: 'string',
},
__mockMessages__: {
type: 'object',
patternProperties: {
'.*': {
oneOf: [
{
type: 'object',
properties: {
message: { type: 'string' },
description: { type: 'string' },
},
required: ['message'],
},
{
type: 'object',
properties: {
messageformat: { type: 'string' },
description: { type: 'string' },
},
required: ['messageformat'],
},
],
},
},
},
},
required: ['messagesCacheKey'],
additionalProperties: false,
},
],
},
create(context) {
const messagesCacheKeyOption = context.options[0].messagesCacheKey;
if (messagesCacheKeyOption !== messagesCacheKey) {
throw new Error(
`The cache key for the i18n rule does not match the current messages.json file (expected: ${messagesCacheKey}, received: ${messagesCacheKeyOption})`
);
}
const mockMessages = context.options[0].__mockMessages__;
const messages = mockMessages ?? globalMessages;
return {
JSXOpeningElement(node) {
if (!isIntlElement(node)) {
return;
}
const key = getIntlElementMessageKey(node);
if (key == null) {
context.report({
node,
message:
"<Intl> must always be provided an 'id' attribute with a literal string",
});
return;
}
if (!isValidMessageKey(messages, key)) {
context.report({
node,
message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
});
return;
}
if (!isIcuMessageKey(messages, key)) {
context.report({
node,
message: `<Intl> id "${key}" is not an ICU message in _locales/en/messages.json`,
});
return;
}
if (isDeletedMessageKey(messages, key)) {
context.report({
node,
message: `<Intl> id "${key}" is marked as deleted in _locales/en/messages.json`,
});
return;
}
const params = getIcuMessageParams(
messages[key].messageformat,
DEFAULT_RICH_TEXT_ELEMENT_NAMES
);
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;
}
const key = getI18nCallMessageKey(node);
if (key == null) {
context.report({
node,
message:
"i18n()'s first argument should always be a literal string",
});
return;
}
if (!isValidMessageKey(messages, key)) {
context.report({
node,
message: `i18n() key "${key}" not found in _locales/en/messages.json`,
});
return;
}
if (!isIcuMessageKey(messages, key)) {
context.report({
node,
message: `i18n() key "${key}" is not an ICU message in _locales/en/messages.json`,
});
return;
}
if (isDeletedMessageKey(messages, key)) {
context.report({
node,
message: `i18n() key "${key}" is marked as deleted in _locales/en/messages.json`,
});
return;
}
const params = getIcuMessageParams(
messages[key].messageformat,
DEFAULT_RICH_TEXT_ELEMENT_NAMES
);
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;
}
},
};
},
};