From 33e44f1a5e9729e360878a96c1ef77d9d64551a8 Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 23 Sep 2026 14:21:41 +0300 Subject: [PATCH 1/3] Add a lint rule for conditional text that breaks under page translation --- eslint.config.js | 7 ++ eslint/rules/translationSafeText.js | 86 ++++++++++++++++++++++++ eslint/rules/translationSafeText.test.js | 28 ++++++++ jest/config.js | 2 +- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 eslint/rules/translationSafeText.js create mode 100644 eslint/rules/translationSafeText.test.js 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/eslint/rules/translationSafeText.test.js b/eslint/rules/translationSafeText.test.js new file mode 100644 index 00000000..39d894fd --- /dev/null +++ b/eslint/rules/translationSafeText.test.js @@ -0,0 +1,28 @@ +const v8 = require("node:v8") +globalThis.structuredClone ??= value => v8.deserialize(v8.serialize(value)) + +const { RuleTester } = require("eslint") +const rule = require("./translationSafeText") + +const ruleTester = new RuleTester({ + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, +}) + +ruleTester.run("translation-safe-text", rule, { + valid: [ + "{`${title ? '• ' : ''}${name}`}", + "{title ? 'a' : 'b'}{name}", + "{cond && x}y", + "{cond && x}y", + "{cond ? 'x' : null}", + "{cond && }{other}", + ], + invalid: [ + { code: "{title ? '• ' : ''}{name}", errors: [{ messageId: "unsafe" }] }, + { code: "{cond && x}text", errors: [{ messageId: "unsafe" }] }, + { code: "{cond && 'x'}y", errors: [{ messageId: "unsafe" }] }, + { code: "{n} item{n > 1 ? 's' : ''}", errors: [{ messageId: "unsafe" }] }, + { code: "

{cond && <>on node }.

", errors: [{ messageId: "unsafe" }] }, + { code: "

a {cond && `x`}

", errors: [{ messageId: "unsafe" }] }, + ], +}) diff --git a/jest/config.js b/jest/config.js index 7ee73e26..281edeb0 100644 --- a/jest/config.js +++ b/jest/config.js @@ -17,7 +17,7 @@ module.exports = { "^.+\\.js$": "babel-jest", }, verbose: true, - roots: ["src/"], + roots: ["src/", "eslint/"], coverageDirectory: "/coverage", coveragePathIgnorePatterns: ["/node_modules/", "/jest/"], coverageThreshold: { From 45166f413c1573f44a1407b380182ad169738d63 Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 23 Sep 2026 16:19:10 +0300 Subject: [PATCH 2/3] v5.5.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From c9186f87bffb4ac48e19562d719b048bdd9066ac Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 23 Sep 2026 17:06:43 +0300 Subject: [PATCH 3/3] Drop the translation-safe-text rule test from netdata-ui --- eslint/rules/translationSafeText.test.js | 28 ------------------------ jest/config.js | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 eslint/rules/translationSafeText.test.js diff --git a/eslint/rules/translationSafeText.test.js b/eslint/rules/translationSafeText.test.js deleted file mode 100644 index 39d894fd..00000000 --- a/eslint/rules/translationSafeText.test.js +++ /dev/null @@ -1,28 +0,0 @@ -const v8 = require("node:v8") -globalThis.structuredClone ??= value => v8.deserialize(v8.serialize(value)) - -const { RuleTester } = require("eslint") -const rule = require("./translationSafeText") - -const ruleTester = new RuleTester({ - languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, -}) - -ruleTester.run("translation-safe-text", rule, { - valid: [ - "{`${title ? '• ' : ''}${name}`}", - "{title ? 'a' : 'b'}{name}", - "{cond && x}y", - "{cond && x}y", - "{cond ? 'x' : null}", - "{cond && }{other}", - ], - invalid: [ - { code: "{title ? '• ' : ''}{name}", errors: [{ messageId: "unsafe" }] }, - { code: "{cond && x}text", errors: [{ messageId: "unsafe" }] }, - { code: "{cond && 'x'}y", errors: [{ messageId: "unsafe" }] }, - { code: "{n} item{n > 1 ? 's' : ''}", errors: [{ messageId: "unsafe" }] }, - { code: "

{cond && <>on node }.

", errors: [{ messageId: "unsafe" }] }, - { code: "

a {cond && `x`}

", errors: [{ messageId: "unsafe" }] }, - ], -}) diff --git a/jest/config.js b/jest/config.js index 281edeb0..7ee73e26 100644 --- a/jest/config.js +++ b/jest/config.js @@ -17,7 +17,7 @@ module.exports = { "^.+\\.js$": "babel-jest", }, verbose: true, - roots: ["src/", "eslint/"], + roots: ["src/"], coverageDirectory: "/coverage", coveragePathIgnorePatterns: ["/node_modules/", "/jest/"], coverageThreshold: {