@canva/cli
Version:
The official Canva CLI.
182 lines (173 loc) • 5.96 kB
JavaScript
/**
* ESLint rule that identifies and flags untranslated user-facing strings in object properties.
*
* This rule helps maintain internationalization consistency by detecting untranslated
* strings in specific object properties (default: 'label'). It suggests using
* intl.formatMessage for proper translation.
*
* Note: The rule is currently implemented as a local rule, with plans to publish as
* an npm package to make it available to the broader development community.
*
* @example
* // ❌ Incorrect - Untranslated strings
* const options = [
* { value: "inbox", label: "Inbox" },
* { value: "starred", label: "Starred messages" },
* { value: "spam", label: "Spam folder" }
* ];
*
* // ✅ Correct - Using intl.formatMessage with descriptions
* const options = [
* {
* value: "inbox",
* label: intl.formatMessage({
* defaultMessage: "Inbox",
* description: "Label for main message inbox folder option"
* })
* },
* {
* value: "starred",
* label: intl.formatMessage({
* defaultMessage: "Starred messages",
* description: "Label for folder containing messages marked as important"
* })
* },
* {
* value: "spam",
* label: intl.formatMessage({
* defaultMessage: "Spam folder",
* description: "Label for folder containing filtered spam messages"
* })
* }
* ];
*
* @see https://www.canva.dev/docs/apps/localization/
*/
export default {
rules: {
"enforce-object-property-translation": {
meta: {
type: "problem",
docs: {
description:
"Enforce translation of specific properties using intl.formatMessage",
category: "Possible Errors",
recommended: true,
},
fixable: "code",
schema: [
{
type: "object",
properties: {
properties: {
type: "array",
items: { type: "string" },
default: ["label"],
},
intlObjectName: {
type: "string",
default: "intl",
},
},
additionalProperties: false,
},
],
messages: {
untranslatedProperty: `If "{{ originalMessage }}" is a user-facing string, you should translate it using "intl.formatMessage". See https://www.canva.dev/docs/apps/localization/.`,
},
},
create(context) {
const config = context.options[0] || {};
const propertiesToCheck = config.properties || ["label"];
const intlObjectName = config.intlObjectName || "intl";
function getTemplateLiteralString(node) {
const src = context.getSourceCode();
return src.getText(node);
}
// Extract string content from different node types
function extractStringContent(node) {
if (!node) return [];
switch (node.type) {
// label: "Foo"
case "Literal":
return typeof node.value === "string"
? [{ node, value: node.value }]
: [];
// label: `Foo ${bar}`
case "TemplateLiteral":
return [{ node, value: getTemplateLiteralString(node) }];
// label: foo || "Bar"
case "LogicalExpression": {
if (node.operator === "||") {
return [
...extractStringContent(node.left),
...extractStringContent(node.right),
];
}
return [];
}
// label: "Foo" + "Bar" + "Baz"
case "BinaryExpression":
if (node.operator === "+") {
return [
...extractStringContent(node.left),
...extractStringContent(node.right),
];
}
return [];
// label: foo ? "Foo" : "Bar"
case "ConditionalExpression":
return [
...extractStringContent(node.consequent),
...extractStringContent(node.alternate),
];
default:
return [];
}
}
function isTranslated(node) {
return (
node.parent.type === "CallExpression" &&
node.parent.callee.type === "MemberExpression" &&
node.parent.callee.object.name === intlObjectName &&
node.parent.callee.property.name === "formatMessage"
);
}
return {
Property(node) {
const keyName = node.key.name || node.key.value;
if (propertiesToCheck.includes(keyName)) {
const results = extractStringContent(node.value);
if (!results) return;
results.forEach((result) => {
const { node: stringNode, value: stringValue } = result;
if (!isTranslated(stringNode)) {
context.report({
node: stringNode,
messageId: "untranslatedProperty",
data: {
property: keyName,
originalMessage:
stringValue.length > 40
? stringValue.split(" ").slice(0, 4).join(" ") + "..."
: stringValue,
intlObjectName,
},
fix(fixer) {
const newText = `${intlObjectName}.formatMessage({
defaultMessage: ${JSON.stringify(stringValue)},
// TODO: Provide a meaningful description for translators
description: ""
})`;
return fixer.replaceText(stringNode, newText);
},
});
}
});
}
},
};
},
},
},
};