diff --git a/web/eslint-rules/index.js b/web/eslint-rules/index.js index 66c2034625..0aafbc7f2d 100644 --- a/web/eslint-rules/index.js +++ b/web/eslint-rules/index.js @@ -1,3 +1,4 @@ +import consistentPlaceholders from './rules/consistent-placeholders.js' 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' @@ -11,6 +12,7 @@ const plugin = { version: '1.0.0', }, rules: { + 'consistent-placeholders': consistentPlaceholders, 'no-as-any-in-t': noAsAnyInT, 'no-extra-keys': noExtraKeys, 'no-legacy-namespace-prefix': noLegacyNamespacePrefix, diff --git a/web/eslint-rules/rules/consistent-placeholders.js b/web/eslint-rules/rules/consistent-placeholders.js new file mode 100644 index 0000000000..dcbdbb0375 --- /dev/null +++ b/web/eslint-rules/rules/consistent-placeholders.js @@ -0,0 +1,109 @@ +import fs from 'node:fs' +import path, { normalize, sep } from 'node:path' +import { cleanJsonText } from '../utils.js' + +/** + * Extract placeholders from a string + * Matches patterns like {{name}}, {{count}}, etc. + * @param {string} str + * @returns {string[]} Sorted array of placeholder names + */ +function extractPlaceholders(str) { + const matches = str.match(/\{\{\w+\}\}/g) || [] + return matches.map(m => m.slice(2, -2)).sort() +} + +/** + * Compare two arrays and return if they're equal + * @param {string[]} arr1 + * @param {string[]} arr2 + * @returns {boolean} True if arrays contain the same elements in the same order + */ +function arraysEqual(arr1, arr2) { + if (arr1.length !== arr2.length) + return false + return arr1.every((val, i) => val === arr2[i]) +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { + description: 'Ensure placeholders in translations match the en-US source', + }, + }, + create(context) { + return { + Program(node) { + const { filename, sourceCode } = context + + if (!filename.endsWith('.json')) + return + + const parts = normalize(filename).split(sep) + const jsonFile = parts.at(-1) + const lang = parts.at(-2) + + // Skip English files - they are the source of truth + if (lang === 'en-US') + return + + let currentJson = {} + let englishJson = {} + + try { + currentJson = JSON.parse(cleanJsonText(sourceCode.text)) + 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 + } + + // Check each key in the current translation + for (const key of Object.keys(currentJson)) { + // Skip if the key doesn't exist in English (handled by no-extra-keys rule) + if (!Object.prototype.hasOwnProperty.call(englishJson, key)) + continue + + const currentValue = currentJson[key] + const englishValue = englishJson[key] + + // Skip non-string values + if (typeof currentValue !== 'string' || typeof englishValue !== 'string') + continue + + const currentPlaceholders = extractPlaceholders(currentValue) + const englishPlaceholders = extractPlaceholders(englishValue) + + if (!arraysEqual(currentPlaceholders, englishPlaceholders)) { + const missing = englishPlaceholders.filter(p => !currentPlaceholders.includes(p)) + const extra = currentPlaceholders.filter(p => !englishPlaceholders.includes(p)) + + let message = `Placeholder mismatch in "${key}": ` + const details = [] + + if (missing.length > 0) + details.push(`missing {{${missing.join('}}, {{')}}}`) + + if (extra.length > 0) + details.push(`extra {{${extra.join('}}, {{')}}}`) + + message += details.join('; ') + message += `. Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}` + + context.report({ + node, + message, + }) + } + } + }, + } + }, +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 12f32c5dce..94b47b69d0 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -131,6 +131,7 @@ export default antfu( 'dify-i18n/valid-i18n-keys': 'error', 'dify-i18n/no-extra-keys': 'error', + 'dify-i18n/consistent-placeholders': 'error', }, }, )