From 52149c0d9b6b1d6bd21ea3aac0c7a0f9ae26e7e3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:49:31 +0800 Subject: [PATCH] chore(web): add ESLint rules for i18n JSON validation (#30491) --- web/eslint-rules/index.js | 4 ++ web/eslint-rules/rules/no-extra-keys.js | 70 +++++++++++++++++++++++ web/eslint-rules/rules/valid-i18n-keys.js | 61 ++++++++++++++++++++ web/eslint-rules/utils.js | 10 ++++ web/eslint.config.mjs | 24 +++++--- 5 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 web/eslint-rules/rules/no-extra-keys.js create mode 100644 web/eslint-rules/rules/valid-i18n-keys.js create mode 100644 web/eslint-rules/utils.js diff --git a/web/eslint-rules/index.js b/web/eslint-rules/index.js index edb6b96ba4..66c2034625 100644 --- a/web/eslint-rules/index.js +++ b/web/eslint-rules/index.js @@ -1,6 +1,8 @@ import noAsAnyInT from './rules/no-as-any-in-t.js' +import noExtraKeys from './rules/no-extra-keys.js' import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js' import requireNsOption from './rules/require-ns-option.js' +import validI18nKeys from './rules/valid-i18n-keys.js' /** @type {import('eslint').ESLint.Plugin} */ const plugin = { @@ -10,8 +12,10 @@ const plugin = { }, rules: { 'no-as-any-in-t': noAsAnyInT, + 'no-extra-keys': noExtraKeys, 'no-legacy-namespace-prefix': noLegacyNamespacePrefix, 'require-ns-option': requireNsOption, + 'valid-i18n-keys': validI18nKeys, }, } diff --git a/web/eslint-rules/rules/no-extra-keys.js b/web/eslint-rules/rules/no-extra-keys.js new file mode 100644 index 0000000000..eb47f60934 --- /dev/null +++ b/web/eslint-rules/rules/no-extra-keys.js @@ -0,0 +1,70 @@ +import fs from 'node:fs' +import path, { normalize, sep } from 'node:path' + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { + description: 'Ensure non-English JSON files don\'t have extra keys not present in en-US', + }, + fixable: 'code', + }, + create(context) { + return { + Program(node) { + const { filename, sourceCode } = context + + if (!filename.endsWith('.json')) + return + + const parts = normalize(filename).split(sep) + // e.g., i18n/ar-TN/common.json -> jsonFile = common.json, lang = ar-TN + const jsonFile = parts.at(-1) + const lang = parts.at(-2) + + // Skip English files + if (lang === 'en-US') + return + + let currentJson = {} + let englishJson = {} + + try { + currentJson = JSON.parse(sourceCode.text) + // Look for the same filename in en-US folder + // e.g., i18n/ar-TN/common.json -> i18n/en-US/common.json + const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '') + englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8')) + } + catch (error) { + context.report({ + node, + message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`, + }) + return + } + + const extraKeys = Object.keys(currentJson).filter( + key => !Object.prototype.hasOwnProperty.call(englishJson, key), + ) + + for (const key of extraKeys) { + context.report({ + node, + message: `Key "${key}" is present in ${lang}/${jsonFile} but not in en-US/${jsonFile}`, + fix(fixer) { + const newJson = Object.fromEntries( + Object.entries(currentJson).filter(([k]) => !extraKeys.includes(k)), + ) + + const newText = `${JSON.stringify(newJson, null, 2)}\n` + + return fixer.replaceText(node, newText) + }, + }) + } + }, + } + }, +} diff --git a/web/eslint-rules/rules/valid-i18n-keys.js b/web/eslint-rules/rules/valid-i18n-keys.js new file mode 100644 index 0000000000..08d863a19a --- /dev/null +++ b/web/eslint-rules/rules/valid-i18n-keys.js @@ -0,0 +1,61 @@ +import { cleanJsonText } from '../utils.js' + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { + description: 'Ensure i18n JSON keys are flat and valid as object paths', + }, + }, + create(context) { + return { + Program(node) { + const { filename, sourceCode } = context + + if (!filename.endsWith('.json')) + return + + let json + try { + json = JSON.parse(cleanJsonText(sourceCode.text)) + } + catch { + context.report({ + node, + message: 'Invalid JSON format', + }) + return + } + + const keys = Object.keys(json) + const keyPrefixes = new Set() + + for (const key of keys) { + if (key.includes('.')) { + const parts = key.split('.') + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join('.') + if (keys.includes(prefix)) { + context.report({ + node, + message: `Invalid key structure: '${key}' conflicts with '${prefix}'`, + }) + } + keyPrefixes.add(prefix) + } + } + } + + for (const key of keys) { + if (keyPrefixes.has(key)) { + context.report({ + node, + message: `Invalid key structure: '${key}' is a prefix of another key`, + }) + } + } + }, + } + }, +} diff --git a/web/eslint-rules/utils.js b/web/eslint-rules/utils.js new file mode 100644 index 0000000000..2030c96b5a --- /dev/null +++ b/web/eslint-rules/utils.js @@ -0,0 +1,10 @@ +export const cleanJsonText = (text) => { + const cleaned = text.replaceAll(/,\s*\}/g, '}') + try { + JSON.parse(cleaned) + return cleaned + } + catch { + return text + } +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 3cdd3efedb..2cfe2e5e13 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -130,15 +130,6 @@ export default antfu( sonarjs: sonar, }, }, - // allow generated i18n files (like i18n/*/workflow.ts) to exceed max-lines - { - files: ['i18n/**'], - rules: { - 'sonarjs/max-lines': 'off', - 'max-lines': 'off', - 'jsonc/sort-keys': 'error', - }, - }, tailwind.configs['flat/recommended'], { settings: { @@ -191,4 +182,19 @@ export default antfu( 'dify-i18n/require-ns-option': 'error', }, }, + // i18n JSON validation rules + { + files: ['i18n/**/*.json'], + plugins: { + 'dify-i18n': difyI18n, + }, + rules: { + 'sonarjs/max-lines': 'off', + 'max-lines': 'off', + 'jsonc/sort-keys': 'error', + + 'dify-i18n/valid-i18n-keys': 'error', + 'dify-i18n/no-extra-keys': 'error', + }, + }, )