mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
chore(web): add ESLint rules for i18n JSON validation (#30491)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import noAsAnyInT from './rules/no-as-any-in-t.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'
|
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
|
||||||
import requireNsOption from './rules/require-ns-option.js'
|
import requireNsOption from './rules/require-ns-option.js'
|
||||||
|
import validI18nKeys from './rules/valid-i18n-keys.js'
|
||||||
|
|
||||||
/** @type {import('eslint').ESLint.Plugin} */
|
/** @type {import('eslint').ESLint.Plugin} */
|
||||||
const plugin = {
|
const plugin = {
|
||||||
@@ -10,8 +12,10 @@ const plugin = {
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-as-any-in-t': noAsAnyInT,
|
'no-as-any-in-t': noAsAnyInT,
|
||||||
|
'no-extra-keys': noExtraKeys,
|
||||||
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
|
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
|
||||||
'require-ns-option': requireNsOption,
|
'require-ns-option': requireNsOption,
|
||||||
|
'valid-i18n-keys': validI18nKeys,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
web/eslint-rules/rules/no-extra-keys.js
Normal file
70
web/eslint-rules/rules/no-extra-keys.js
Normal file
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
61
web/eslint-rules/rules/valid-i18n-keys.js
Normal file
61
web/eslint-rules/rules/valid-i18n-keys.js
Normal file
@@ -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`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
10
web/eslint-rules/utils.js
Normal file
10
web/eslint-rules/utils.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const cleanJsonText = (text) => {
|
||||||
|
const cleaned = text.replaceAll(/,\s*\}/g, '}')
|
||||||
|
try {
|
||||||
|
JSON.parse(cleaned)
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,15 +130,6 @@ export default antfu(
|
|||||||
sonarjs: sonar,
|
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'],
|
tailwind.configs['flat/recommended'],
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
@@ -191,4 +182,19 @@ export default antfu(
|
|||||||
'dify-i18n/require-ns-option': 'error',
|
'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',
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user