Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/eslint-rules/index.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
},
}

Expand Down
70 changes: 70 additions & 0 deletions web/eslint-rules/rules/no-extra-keys.js
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions web/eslint-rules/rules/valid-i18n-keys.js
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions web/eslint-rules/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const cleanJsonText = (text) => {
const cleaned = text.replaceAll(/,\s*\}/g, '}')
try {
JSON.parse(cleaned)
return cleaned
}
catch {
return text
}
}
24 changes: 15 additions & 9 deletions web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
},
},
)
Loading