diff --git a/eslint.config.js b/eslint.config.js index bec51948..104b3406 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import globals from "globals" import pluginJs from "@eslint/js" import pluginReact from "eslint-plugin-react" import storybook from "eslint-plugin-storybook" +import translationSafeText from "./eslint/rules/translationSafeText.js" export default [ { files: ["**/*.{js,mjs,cjs,jsx}"] }, @@ -26,6 +27,12 @@ export default [ "react/display-name": "off", }, }, + { + files: ["src/**/*.{js,jsx}"], + ignores: ["**/*.test.js", "**/*.stories.js"], + plugins: { local: { rules: { "translation-safe-text": translationSafeText } } }, + rules: { "local/translation-safe-text": "error" }, + }, { ignores: [ ".*.js", diff --git a/eslint/rules/translationSafeText.js b/eslint/rules/translationSafeText.js new file mode 100644 index 00000000..746b3300 --- /dev/null +++ b/eslint/rules/translationSafeText.js @@ -0,0 +1,86 @@ +const isDroppedWhitespace = node => + node.type === "JSXText" && /^\s*$/.test(node.value) && node.value.includes("\n") + +const isMeaningfulText = node => node.type === "JSXText" && !isDroppedWhitespace(node) + +const yields = node => { + switch (node.type) { + case "Literal": + if (typeof node.value === "string") return new Set([node.value === "" ? "empty" : "text"]) + if (typeof node.value === "number") return new Set(["text"]) + if (node.value === null || typeof node.value === "boolean") return new Set(["empty"]) + return new Set(["unknown"]) + case "TemplateLiteral": + return new Set(["text"]) + case "Identifier": + return new Set([node.name === "undefined" ? "empty" : "unknown"]) + case "JSXElement": + return new Set(["element"]) + case "JSXFragment": + return new Set([node.children.some(isMeaningfulText) ? "text" : "element"]) + case "LogicalExpression": + if (node.operator === "&&") return new Set([...yields(node.right), "empty"]) + return new Set([...yields(node.left), ...yields(node.right)]) + case "ConditionalExpression": + return new Set([...yields(node.consequent), ...yields(node.alternate)]) + default: + return new Set(["unknown"]) + } +} + +const describeChild = child => { + if (child.type === "JSXText") return { conditional: false, yields: new Set(["text"]), certainText: true } + if (child.type === "JSXExpressionContainer") { + if (child.expression.type === "JSXEmptyExpression") return null + const childYields = yields(child.expression) + return { + conditional: + child.expression.type === "LogicalExpression" || + child.expression.type === "ConditionalExpression", + yields: childYields, + certainText: childYields.has("text") && !childYields.has("unknown"), + } + } + return { conditional: false, yields: new Set(["element"]), certainText: false } +} + +const checkChildren = (context, node) => { + const children = node.children + .filter(child => !isDroppedWhitespace(child)) + .map(child => ({ child, info: describeChild(child) })) + .filter(({ info }) => info) + + if (children.length < 2) return + + children.forEach(({ child, info }, index) => { + if (!info.conditional) return + const toggles = + info.yields.has("empty") || + (info.yields.has("element") && (info.yields.has("text") || info.yields.has("unknown"))) + if (!toggles) return + + const next = children[index + 1] + const removesText = info.yields.has("text") + const insertsBeforeText = !!next && next.info.certainText + if (removesText || insertsBeforeText) context.report({ node: child, messageId: "unsafe" }) + }) +} + +module.exports = { + meta: { + type: "problem", + docs: { + description: + "Disallow conditional text next to other children; it crashes React when the page is translated", + }, + messages: { + unsafe: + "Conditional text next to other children crashes when the page is translated. Merge the pieces into one string, or wrap every text piece in its own element.", + }, + schema: [], + }, + create: context => ({ + JSXElement: node => checkChildren(context, node), + JSXFragment: node => checkChildren(context, node), + }), +} diff --git a/package.json b/package.json index 50eb0759..59f69afb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/netdata-ui", - "version": "5.5.12", + "version": "5.5.13", "description": "netdata UI kit", "main": "dist/index.js", "module": "dist/es6/index.js",