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

183 lines
3.9 KiB
JavaScript

// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const crypto = require('crypto');
const messages = require('../../_locales/en/messages.json');
const messageKeys = Object.keys(messages).sort((a, b) => {
return a.localeCompare(b);
});
const hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
const messagesCacheKey = hashSum.digest('hex');
function isI18nCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.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;
}
if (node.type !== 'TemplateLiteral') {
return null;
}
if (node.quasis.length === 1) {
return node.quasis[0].value.cooked;
}
const parts = node.quasis.map(element => {
return element.value.cooked;
});
return new RegExp(`^${parts.join('(.*)')}$`);
}
function getI18nCallMessageKey(node) {
if (node.arguments.length < 1) {
return null;
}
let arg1 = node.arguments[0];
if (arg1 == null) {
return null;
}
return valueToMessageKey(arg1);
}
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;
if (value.type === 'JSXExpressionContainer') {
value = value.expression;
}
return valueToMessageKey(value);
}
function isValidMessageKey(key) {
if (typeof key === 'string') {
if (Object.hasOwn(messages, key)) {
return true;
}
} else if (key instanceof RegExp) {
if (messageKeys.some(k => key.test(k))) {
return true;
}
}
return false;
}
module.exports = {
messagesCacheKey,
meta: {
type: 'problem',
hasSuggestions: false,
fixable: false,
schema: [
{
type: 'object',
properties: {
messagesCacheKey: {
type: 'string',
},
},
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})`
);
}
return {
JSXOpeningElement(node) {
if (!isIntlElement(node)) {
return;
}
let 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(key)) {
return;
}
context.report({
node,
message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
});
},
CallExpression(node) {
if (!isI18nCall(node)) {
return;
}
let key = getI18nCallMessageKey(node);
if (key == null) {
context.report({
node,
message:
"i18n()'s first argument should always be a literal string",
});
return;
}
if (isValidMessageKey(key)) {
return;
}
context.report({
node,
message: `i18n() key "${key}" not found in _locales/en/messages.json`,
});
},
};
},
};