diff --git a/web/eslint-rules/index.js b/web/eslint-rules/index.js new file mode 100644 index 0000000000..9d01838e35 --- /dev/null +++ b/web/eslint-rules/index.js @@ -0,0 +1,16 @@ +import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js' +import requireNsOption from './rules/require-ns-option.js' + +/** @type {import('eslint').ESLint.Plugin} */ +const plugin = { + meta: { + name: 'dify-i18n', + version: '1.0.0', + }, + rules: { + 'no-legacy-namespace-prefix': noLegacyNamespacePrefix, + 'require-ns-option': requireNsOption, + }, +} + +export default plugin diff --git a/web/eslint-rules/namespaces.js b/web/eslint-rules/namespaces.js new file mode 100644 index 0000000000..77cae65756 --- /dev/null +++ b/web/eslint-rules/namespaces.js @@ -0,0 +1,87 @@ +// Auto-generated from i18n-config/i18next-config.ts +// Keep in sync with the namespaces object + +// @keep-sorted +export const NAMESPACES = [ + 'app', + 'appAnnotation', + 'appApi', + 'appDebug', + 'appLog', + 'appOverview', + 'billing', + 'common', + 'custom', + 'dataset', + 'datasetCreation', + 'datasetDocuments', + 'datasetHitTesting', + 'datasetPipeline', + 'datasetSettings', + 'education', + 'explore', + 'layout', + 'login', + 'oauth', + 'pipeline', + 'plugin', + 'pluginTags', + 'pluginTrigger', + 'register', + 'runLog', + 'share', + 'time', + 'tools', + 'workflow', +] + +// Sort by length descending to match longer prefixes first +// e.g., 'datasetDocuments' before 'dataset' +export const NAMESPACES_BY_LENGTH = [...NAMESPACES].sort((a, b) => b.length - a.length) + +/** + * Extract namespace from a translation key + * Returns null if no namespace prefix found or if already in namespace:key format + * @param {string} key + * @returns {{ ns: string, localKey: string } | null} + */ +export function extractNamespace(key) { + // Skip if already in namespace:key format + for (const ns of NAMESPACES_BY_LENGTH) { + if (key.startsWith(`${ns}:`)) { + return null + } + } + // Check for legacy namespace.key format + for (const ns of NAMESPACES_BY_LENGTH) { + if (key.startsWith(`${ns}.`)) { + return { ns, localKey: key.slice(ns.length + 1) } + } + } + return null +} + +/** + * Remove namespace prefix from a string value + * Used for fixing variable declarations + * @param {string} value + * @returns {{ ns: string, newValue: string } | null} + */ +export function removeNamespacePrefix(value) { + // Skip if already in namespace:key format + for (const ns of NAMESPACES_BY_LENGTH) { + if (value.startsWith(`${ns}:`)) { + return null + } + } + // Check for legacy namespace.key format + for (const ns of NAMESPACES_BY_LENGTH) { + if (value.startsWith(`${ns}.`)) { + return { ns, newValue: value.slice(ns.length + 1) } + } + if (value === ns) { + return { ns, newValue: '' } + } + } + return null +} diff --git a/web/eslint-rules/rules/no-legacy-namespace-prefix.js b/web/eslint-rules/rules/no-legacy-namespace-prefix.js new file mode 100644 index 0000000000..b61aca6ad1 --- /dev/null +++ b/web/eslint-rules/rules/no-legacy-namespace-prefix.js @@ -0,0 +1,398 @@ +import { extractNamespace, removeNamespacePrefix } from '../namespaces.js' + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'Disallow legacy namespace prefix in i18n translation keys', + }, + fixable: 'code', + schema: [], + messages: { + legacyNamespacePrefix: + 'Translation key "{{key}}" should not include namespace prefix. Use t(\'{{localKey}}\') with useTranslation(\'{{ns}}\') instead.', + legacyNamespacePrefixInVariable: + 'Variable "{{name}}" contains namespace prefix "{{ns}}". Remove the prefix and use useTranslation(\'{{ns}}\') instead.', + }, + }, + create(context) { + const sourceCode = context.sourceCode + + // Track all t() calls to fix + /** @type {Array<{ node: import('estree').CallExpression }>} */ + const tCallsToFix = [] + + // Track variables with namespace prefix + /** @type {Map} */ + const variablesToFix = new Map() + + // Track all namespaces used in the file (from legacy prefix detection) + /** @type {Set} */ + const namespacesUsed = new Set() + + // Track variable values for template literal analysis + /** @type {Map} */ + const variableValues = new Map() + + /** + * Analyze a template literal and extract namespace info + * @param {import('estree').TemplateLiteral} node + */ + function analyzeTemplateLiteral(node) { + const quasis = node.quasis + const expressions = node.expressions + + const firstQuasi = quasis[0].value.raw + + // Check if first quasi starts with namespace + const extracted = extractNamespace(firstQuasi) + if (extracted) { + const fixedQuasis = [extracted.localKey, ...quasis.slice(1).map(q => q.value.raw)] + return { ns: extracted.ns, canFix: true, fixedQuasis, variableToUpdate: null } + } + + // Check if first expression is a variable with namespace prefix + if (expressions.length > 0 && firstQuasi === '') { + const firstExpr = expressions[0] + if (firstExpr.type === 'Identifier') { + const varValue = variableValues.get(firstExpr.name) + if (varValue) { + const extracted = removeNamespacePrefix(varValue) + if (extracted) { + return { + ns: extracted.ns, + canFix: true, + fixedQuasis: null, + variableToUpdate: { + name: firstExpr.name, + newValue: extracted.newValue, + ns: extracted.ns, + }, + } + } + } + } + } + + return { ns: null, canFix: false, fixedQuasis: null, variableToUpdate: null } + } + + /** + * Build a fixed template literal string + * @param {string[]} quasis + * @param {import('estree').Expression[]} expressions + */ + function buildTemplateLiteral(quasis, expressions) { + let result = '`' + for (let i = 0; i < quasis.length; i++) { + result += quasis[i] + if (i < expressions.length) { + result += `\${${sourceCode.getText(expressions[i])}}` + } + } + result += '`' + return result + } + + /** + * Check if a t() call already has ns in its second argument + * @param {import('estree').CallExpression} node + * @returns {boolean} + */ + function hasNsArgument(node) { + if (node.arguments.length < 2) + return false + const secondArg = node.arguments[1] + if (secondArg.type !== 'ObjectExpression') + return false + return secondArg.properties.some( + prop => prop.type === 'Property' + && prop.key.type === 'Identifier' + && prop.key.name === 'ns', + ) + } + + return { + // Track variable declarations + VariableDeclarator(node) { + if ( + node.id.type === 'Identifier' + && node.init + && node.init.type === 'Literal' + && typeof node.init.value === 'string' + ) { + variableValues.set(node.id.name, node.init.value) + + const extracted = removeNamespacePrefix(node.init.value) + if (extracted) { + variablesToFix.set(node.id.name, { + node, + name: node.id.name, + oldValue: node.init.value, + newValue: extracted.newValue, + ns: extracted.ns, + }) + } + } + }, + + CallExpression(node) { + // Check for t() calls - both direct t() and i18n.t() + const isTCall = ( + node.callee.type === 'Identifier' + && node.callee.name === 't' + ) || ( + node.callee.type === 'MemberExpression' + && node.callee.property.type === 'Identifier' + && node.callee.property.name === 't' + ) + + if (isTCall && node.arguments.length > 0) { + // Skip if already has ns argument + if (hasNsArgument(node)) + return + + // Unwrap TSAsExpression (e.g., `key as any`) + let firstArg = node.arguments[0] + const hasTsAsExpression = firstArg.type === 'TSAsExpression' + if (hasTsAsExpression) { + firstArg = firstArg.expression + } + + // Case 1: Static string literal + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') { + const extracted = extractNamespace(firstArg.value) + if (extracted) { + namespacesUsed.add(extracted.ns) + tCallsToFix.push({ node }) + + context.report({ + node: firstArg, + messageId: 'legacyNamespacePrefix', + data: { + key: firstArg.value, + localKey: extracted.localKey, + ns: extracted.ns, + }, + }) + } + } + + // Case 2: Template literal + if (firstArg.type === 'TemplateLiteral') { + const analysis = analyzeTemplateLiteral(firstArg) + if (analysis.ns) { + namespacesUsed.add(analysis.ns) + tCallsToFix.push({ node }) + + if (!analysis.variableToUpdate) { + const firstQuasi = firstArg.quasis[0].value.raw + const extracted = extractNamespace(firstQuasi) + if (extracted) { + context.report({ + node: firstArg, + messageId: 'legacyNamespacePrefix', + data: { + key: `${firstQuasi}...`, + localKey: `${extracted.localKey}...`, + ns: extracted.ns, + }, + }) + } + } + } + } + + // Case 3: Conditional expression + if (firstArg.type === 'ConditionalExpression') { + const consequent = firstArg.consequent + const alternate = firstArg.alternate + let hasNs = false + + if (consequent.type === 'Literal' && typeof consequent.value === 'string') { + const extracted = extractNamespace(consequent.value) + if (extracted) { + hasNs = true + namespacesUsed.add(extracted.ns) + } + } + + if (alternate.type === 'Literal' && typeof alternate.value === 'string') { + const extracted = extractNamespace(alternate.value) + if (extracted) { + hasNs = true + namespacesUsed.add(extracted.ns) + } + } + + if (hasNs) { + tCallsToFix.push({ node }) + + context.report({ + node: firstArg, + messageId: 'legacyNamespacePrefix', + data: { + key: '(conditional)', + localKey: '...', + ns: '...', + }, + }) + } + } + } + }, + + 'Program:exit': function (program) { + if (namespacesUsed.size === 0) + return + + // Report variables with namespace prefix (once per variable) + for (const [, varInfo] of variablesToFix) { + if (namespacesUsed.has(varInfo.ns)) { + context.report({ + node: varInfo.node, + messageId: 'legacyNamespacePrefixInVariable', + data: { + name: varInfo.name, + ns: varInfo.ns, + }, + }) + } + } + + // Report on program with fix + const sortedNamespaces = Array.from(namespacesUsed).sort() + + context.report({ + node: program, + messageId: 'legacyNamespacePrefix', + data: { + key: '(file)', + localKey: '...', + ns: sortedNamespaces.join(', '), + }, + fix(fixer) { + /** @type {import('eslint').Rule.Fix[]} */ + const fixes = [] + + // Fix variable declarations - remove namespace prefix + for (const [, varInfo] of variablesToFix) { + if (namespacesUsed.has(varInfo.ns) && varInfo.node.init) { + fixes.push(fixer.replaceText(varInfo.node.init, `'${varInfo.newValue}'`)) + } + } + + // Fix t() calls - use { ns: 'xxx' } as second argument + for (const { node } of tCallsToFix) { + const originalFirstArg = node.arguments[0] + const secondArg = node.arguments[1] + const hasSecondArg = node.arguments.length >= 2 + + // Unwrap TSAsExpression for analysis, but keep it for replacement + const hasTsAs = originalFirstArg.type === 'TSAsExpression' + const firstArg = hasTsAs ? originalFirstArg.expression : originalFirstArg + + /** + * Add ns to existing object or create new object + * @param {string} ns + */ + const addNsToArgs = (ns) => { + if (hasSecondArg && secondArg.type === 'ObjectExpression') { + // Add ns property to existing object + if (secondArg.properties.length === 0) { + // Empty object: {} -> { ns: 'xxx' } + fixes.push(fixer.replaceText(secondArg, `{ ns: '${ns}' }`)) + } + else { + // Non-empty object: { foo } -> { ns: 'xxx', foo } + const firstProp = secondArg.properties[0] + fixes.push(fixer.insertTextBefore(firstProp, `ns: '${ns}', `)) + } + } + else if (!hasSecondArg) { + // No second argument, add new object + fixes.push(fixer.insertTextAfter(originalFirstArg, `, { ns: '${ns}' }`)) + } + // If second arg exists but is not an object, skip (can't safely add ns) + } + + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') { + const extracted = extractNamespace(firstArg.value) + if (extracted) { + // Replace key (preserve as any if present) + if (hasTsAs) { + fixes.push(fixer.replaceText(originalFirstArg, `'${extracted.localKey}' as any`)) + } + else { + fixes.push(fixer.replaceText(firstArg, `'${extracted.localKey}'`)) + } + // Add ns + addNsToArgs(extracted.ns) + } + } + else if (firstArg.type === 'TemplateLiteral') { + const analysis = analyzeTemplateLiteral(firstArg) + if (analysis.canFix && analysis.fixedQuasis) { + // For template literals with namespace prefix directly in template + const newTemplate = buildTemplateLiteral(analysis.fixedQuasis, firstArg.expressions) + if (hasTsAs) { + fixes.push(fixer.replaceText(originalFirstArg, `${newTemplate} as any`)) + } + else { + fixes.push(fixer.replaceText(firstArg, newTemplate)) + } + addNsToArgs(analysis.ns) + } + else if (analysis.canFix && analysis.variableToUpdate) { + // Variable's namespace prefix is being removed + const quasis = firstArg.quasis.map(q => q.value.raw) + // If variable becomes empty and next quasi starts with '.', remove the dot + if (analysis.variableToUpdate.newValue === '' && quasis.length > 1 && quasis[1].startsWith('.')) { + quasis[1] = quasis[1].slice(1) + } + const newTemplate = buildTemplateLiteral(quasis, firstArg.expressions) + if (hasTsAs) { + fixes.push(fixer.replaceText(originalFirstArg, `${newTemplate} as any`)) + } + else { + fixes.push(fixer.replaceText(firstArg, newTemplate)) + } + addNsToArgs(analysis.ns) + } + } + else if (firstArg.type === 'ConditionalExpression') { + const consequent = firstArg.consequent + const alternate = firstArg.alternate + let ns = null + + if (consequent.type === 'Literal' && typeof consequent.value === 'string') { + const extracted = extractNamespace(consequent.value) + if (extracted) { + ns = extracted.ns + fixes.push(fixer.replaceText(consequent, `'${extracted.localKey}'`)) + } + } + + if (alternate.type === 'Literal' && typeof alternate.value === 'string') { + const extracted = extractNamespace(alternate.value) + if (extracted) { + ns = ns || extracted.ns + fixes.push(fixer.replaceText(alternate, `'${extracted.localKey}'`)) + } + } + + // Add ns argument + if (ns) { + addNsToArgs(ns) + } + } + } + + return fixes + }, + }) + }, + } + }, +} diff --git a/web/eslint-rules/rules/require-ns-option.js b/web/eslint-rules/rules/require-ns-option.js new file mode 100644 index 0000000000..df8f7ec2e8 --- /dev/null +++ b/web/eslint-rules/rules/require-ns-option.js @@ -0,0 +1,56 @@ +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { + description: 'Require ns option in t() function calls', + }, + schema: [], + messages: { + missingNsOption: + 'Translation call is missing { ns: \'xxx\' } option. Add a second argument with ns property.', + }, + }, + create(context) { + /** + * Check if a t() call has ns in its second argument + * @param {import('estree').CallExpression} node + * @returns {boolean} + */ + function hasNsOption(node) { + if (node.arguments.length < 2) + return false + const secondArg = node.arguments[1] + if (secondArg.type !== 'ObjectExpression') + return false + return secondArg.properties.some( + prop => prop.type === 'Property' + && prop.key.type === 'Identifier' + && prop.key.name === 'ns', + ) + } + + return { + CallExpression(node) { + // Check for t() calls - both direct t() and i18n.t() + const isTCall = ( + node.callee.type === 'Identifier' + && node.callee.name === 't' + ) || ( + node.callee.type === 'MemberExpression' + && node.callee.property.type === 'Identifier' + && node.callee.property.name === 't' + ) + + if (isTCall && node.arguments.length > 0) { + if (!hasNsOption(node)) { + context.report({ + node, + messageId: 'missingNsOption', + }) + } + } + }, + } + }, +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 7ea3815910..e0cf64d30f 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -3,6 +3,7 @@ import antfu from '@antfu/eslint-config' import sonar from 'eslint-plugin-sonarjs' import storybook from 'eslint-plugin-storybook' import tailwind from 'eslint-plugin-tailwindcss' +import difyI18n from './eslint-rules/index.js' export default antfu( { @@ -156,4 +157,16 @@ export default antfu( 'tailwindcss/migration-from-tailwind-2': 'warn', }, }, + // dify i18n namespace migration + { + files: ['**/*.ts', '**/*.tsx'], + ignores: ['eslint-rules/**', 'i18n/**', 'i18n-config/**'], + plugins: { + 'dify-i18n': difyI18n, + }, + rules: { + 'dify-i18n/no-legacy-namespace-prefix': 'error', + 'dify-i18n/require-ns-option': 'error', + }, + }, )