mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 00:18:03 +08:00
chore: use react-grab to replace code-inspector-plugin (#33078)
This commit is contained in:
22
web/plugins/eslint/index.js
Normal file
22
web/plugins/eslint/index.js
Normal file
@ -0,0 +1,22 @@
|
||||
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'
|
||||
import requireNsOption from './rules/require-ns-option.js'
|
||||
|
||||
/** @type {import('eslint').ESLint.Plugin} */
|
||||
const plugin = {
|
||||
meta: {
|
||||
name: 'dify-i18n',
|
||||
version: '1.0.0',
|
||||
},
|
||||
rules: {
|
||||
'consistent-placeholders': consistentPlaceholders,
|
||||
'no-as-any-in-t': noAsAnyInT,
|
||||
'no-extra-keys': noExtraKeys,
|
||||
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
|
||||
'require-ns-option': requireNsOption,
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
87
web/plugins/eslint/namespaces.js
Normal file
87
web/plugins/eslint/namespaces.js
Normal file
@ -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
|
||||
}
|
||||
175
web/plugins/eslint/rules/consistent-placeholders.js
Normal file
175
web/plugins/eslint/rules/consistent-placeholders.js
Normal file
@ -0,0 +1,175 @@
|
||||
import fs from 'node:fs'
|
||||
import path, { normalize, sep } from 'node:path'
|
||||
import { cleanJsonText } from '../utils.js'
|
||||
|
||||
function extractPlaceholders(str) {
|
||||
const matches = str.match(/\{\{\w+\}\}/g) || []
|
||||
return matches.map(m => m.slice(2, -2)).sort()
|
||||
}
|
||||
|
||||
function extractTagMarkers(str) {
|
||||
const matches = Array.from(str.matchAll(/<\/?([A-Z][\w-]*)\b[^>]*>/gi))
|
||||
const markers = matches.map((match) => {
|
||||
const fullMatch = match[0]
|
||||
const name = match[1]
|
||||
const isClosing = fullMatch.startsWith('</')
|
||||
const isSelfClosing = !isClosing && fullMatch.endsWith('/>')
|
||||
|
||||
if (isClosing)
|
||||
return `close:${name}`
|
||||
if (isSelfClosing)
|
||||
return `self:${name}`
|
||||
return `open:${name}`
|
||||
})
|
||||
|
||||
return markers.sort()
|
||||
}
|
||||
|
||||
function formatTagMarker(marker) {
|
||||
if (marker.startsWith('close:'))
|
||||
return marker.slice('close:'.length)
|
||||
if (marker.startsWith('self:'))
|
||||
return marker.slice('self:'.length)
|
||||
return marker.slice('open:'.length)
|
||||
}
|
||||
|
||||
function arraysEqual(arr1, arr2) {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false
|
||||
return arr1.every((val, i) => val === arr2[i])
|
||||
}
|
||||
|
||||
function uniqueSorted(items) {
|
||||
return Array.from(new Set(items)).sort()
|
||||
}
|
||||
|
||||
function getJsonLiteralValue(node) {
|
||||
if (!node)
|
||||
return undefined
|
||||
return node.type === 'JSONLiteral' ? node.value : undefined
|
||||
}
|
||||
|
||||
function buildPlaceholderMessage(key, englishPlaceholders, currentPlaceholders) {
|
||||
const missing = englishPlaceholders.filter(p => !currentPlaceholders.includes(p))
|
||||
const extra = currentPlaceholders.filter(p => !englishPlaceholders.includes(p))
|
||||
|
||||
const details = []
|
||||
if (missing.length > 0)
|
||||
details.push(`missing {{${missing.join('}}, {{')}}}`)
|
||||
if (extra.length > 0)
|
||||
details.push(`extra {{${extra.join('}}, {{')}}}`)
|
||||
|
||||
return `Placeholder mismatch with en-US in "${key}": ${details.join('; ')}. `
|
||||
+ `Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}`
|
||||
}
|
||||
|
||||
function buildTagMessage(key, englishTagMarkers, currentTagMarkers) {
|
||||
const missing = englishTagMarkers.filter(p => !currentTagMarkers.includes(p))
|
||||
const extra = currentTagMarkers.filter(p => !englishTagMarkers.includes(p))
|
||||
|
||||
const details = []
|
||||
if (missing.length > 0)
|
||||
details.push(`missing ${uniqueSorted(missing.map(formatTagMarker)).join(', ')}`)
|
||||
if (extra.length > 0)
|
||||
details.push(`extra ${uniqueSorted(extra.map(formatTagMarker)).join(', ')}`)
|
||||
|
||||
return `Trans tag mismatch with en-US in "${key}": ${details.join('; ')}. `
|
||||
+ `Expected: ${uniqueSorted(englishTagMarkers.map(formatTagMarker)).join(', ') || 'none'}`
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure placeholders and Trans tags in translations match the en-US source',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const state = {
|
||||
enabled: false,
|
||||
englishJson: null,
|
||||
}
|
||||
|
||||
function isTopLevelProperty(node) {
|
||||
const objectNode = node.parent
|
||||
if (!objectNode || objectNode.type !== 'JSONObjectExpression')
|
||||
return false
|
||||
const expressionNode = objectNode.parent
|
||||
return !!expressionNode
|
||||
&& (expressionNode.type === 'JSONExpressionStatement'
|
||||
|| expressionNode.type === 'Program'
|
||||
|| expressionNode.type === 'JSONProgram')
|
||||
}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const { filename } = context
|
||||
|
||||
if (!filename.endsWith('.json'))
|
||||
return
|
||||
|
||||
const parts = normalize(filename).split(sep)
|
||||
const jsonFile = parts.at(-1)
|
||||
const lang = parts.at(-2)
|
||||
|
||||
if (lang === 'en-US')
|
||||
return
|
||||
|
||||
state.enabled = true
|
||||
|
||||
try {
|
||||
const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '')
|
||||
const englishText = fs.readFileSync(englishFilePath, 'utf8')
|
||||
state.englishJson = JSON.parse(cleanJsonText(englishText))
|
||||
}
|
||||
catch (error) {
|
||||
state.enabled = false
|
||||
context.report({
|
||||
node,
|
||||
message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
JSONProperty(node) {
|
||||
if (!state.enabled)
|
||||
return
|
||||
|
||||
if (!state.englishJson || !isTopLevelProperty(node))
|
||||
return
|
||||
|
||||
const key = node.key.value ?? node.key.name
|
||||
if (!key)
|
||||
return
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(state.englishJson, key))
|
||||
return
|
||||
|
||||
const currentNode = node.value ?? node
|
||||
const currentValue = getJsonLiteralValue(currentNode)
|
||||
const englishValue = state.englishJson[key]
|
||||
|
||||
if (typeof currentValue !== 'string' || typeof englishValue !== 'string')
|
||||
return
|
||||
|
||||
const currentPlaceholders = extractPlaceholders(currentValue)
|
||||
const englishPlaceholders = extractPlaceholders(englishValue)
|
||||
const currentTagMarkers = extractTagMarkers(currentValue)
|
||||
const englishTagMarkers = extractTagMarkers(englishValue)
|
||||
|
||||
if (!arraysEqual(currentPlaceholders, englishPlaceholders)) {
|
||||
context.report({
|
||||
node: currentNode,
|
||||
message: buildPlaceholderMessage(key, englishPlaceholders, currentPlaceholders),
|
||||
})
|
||||
}
|
||||
|
||||
if (!arraysEqual(currentTagMarkers, englishTagMarkers)) {
|
||||
context.report({
|
||||
node: currentNode,
|
||||
message: buildTagMessage(key, englishTagMarkers, currentTagMarkers),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
105
web/plugins/eslint/rules/no-as-any-in-t.js
Normal file
105
web/plugins/eslint/rules/no-as-any-in-t.js
Normal file
@ -0,0 +1,105 @@
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow using type assertions in t() function calls',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['any', 'all'],
|
||||
default: 'any',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
noAsAnyInT:
|
||||
'Avoid using "as any" in t() function calls. Use proper i18n key types instead.',
|
||||
noAsInT:
|
||||
'Avoid using type assertions in t() function calls. Use proper i18n key types instead.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const mode = options.mode || 'any'
|
||||
|
||||
function isTCall(node) {
|
||||
// Direct t() call
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 't')
|
||||
return true
|
||||
// i18n.t() or similar member expression
|
||||
if (
|
||||
node.callee.type === 'MemberExpression'
|
||||
&& node.callee.property.type === 'Identifier'
|
||||
&& node.callee.property.name === 't'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a TSAsExpression with "any" type
|
||||
* @param {object} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAsAny(node) {
|
||||
return (
|
||||
node.type === 'TSAsExpression'
|
||||
&& node.typeAnnotation
|
||||
&& node.typeAnnotation.type === 'TSAnyKeyword'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a TSAsExpression (excluding "as const")
|
||||
* @param {object} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAsExpression(node) {
|
||||
if (node.type !== 'TSAsExpression')
|
||||
return false
|
||||
// Ignore "as const"
|
||||
if (node.typeAnnotation && node.typeAnnotation.type === 'TSTypeReference') {
|
||||
const typeName = node.typeAnnotation.typeName
|
||||
if (typeName && typeName.type === 'Identifier' && typeName.name === 'const')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isTCall(node) || node.arguments.length === 0)
|
||||
return
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
|
||||
if (mode === 'all') {
|
||||
// Check for any type assertion
|
||||
if (isAsExpression(firstArg)) {
|
||||
context.report({
|
||||
node: firstArg,
|
||||
messageId: 'noAsInT',
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Check only for "as any"
|
||||
if (isAsAny(firstArg)) {
|
||||
context.report({
|
||||
node: firstArg,
|
||||
messageId: 'noAsAnyInT',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
70
web/plugins/eslint/rules/no-extra-keys.js
Normal file
70
web/plugins/eslint/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)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
407
web/plugins/eslint/rules/no-legacy-namespace-prefix.js
Normal file
407
web/plugins/eslint/rules/no-legacy-namespace-prefix.js
Normal file
@ -0,0 +1,407 @@
|
||||
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
|
||||
|
||||
const tCallsToFix = []
|
||||
const variablesToFix = new Map()
|
||||
const namespacesUsed = new Set()
|
||||
const variableValues = new Map()
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
// Case 1: Static string literal
|
||||
if (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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Template literal with static first quasi containing namespace prefix
|
||||
// e.g., const i18nPrefix = `billing.plans.${plan}`
|
||||
if (node.init.type === 'TemplateLiteral') {
|
||||
const firstQuasi = node.init.quasis[0].value.raw
|
||||
const extracted = extractNamespace(firstQuasi)
|
||||
if (extracted) {
|
||||
// Store the first quasi value for template literal analysis
|
||||
variableValues.set(node.id.name, firstQuasi)
|
||||
|
||||
variablesToFix.set(node.id.name, {
|
||||
node,
|
||||
name: node.id.name,
|
||||
oldValue: firstQuasi,
|
||||
newValue: extracted.localKey,
|
||||
ns: extracted.ns,
|
||||
isTemplateLiteral: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
if (varInfo.isTemplateLiteral) {
|
||||
// For template literals, rebuild with updated first quasi
|
||||
const templateLiteral = varInfo.node.init
|
||||
const quasis = templateLiteral.quasis.map((q, i) =>
|
||||
i === 0 ? varInfo.newValue : q.value.raw,
|
||||
)
|
||||
const newTemplate = buildTemplateLiteral(quasis, templateLiteral.expressions)
|
||||
fixes.push(fixer.replaceText(varInfo.node.init, newTemplate))
|
||||
}
|
||||
else {
|
||||
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 && secondArg.type === 'Literal' && typeof secondArg.value === 'string') {
|
||||
// Second arg is a string (default value): 'default' -> { ns: 'xxx', defaultValue: 'default' }
|
||||
fixes.push(fixer.replaceText(secondArg, `{ ns: '${ns}', defaultValue: ${sourceCode.getText(secondArg)} }`))
|
||||
}
|
||||
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 or string, 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
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
51
web/plugins/eslint/rules/require-ns-option.js
Normal file
51
web/plugins/eslint/rules/require-ns-option.js
Normal file
@ -0,0 +1,51 @@
|
||||
/** @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) {
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
10
web/plugins/eslint/utils.js
Normal file
10
web/plugins/eslint/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
|
||||
}
|
||||
}
|
||||
80
web/plugins/vite/custom-i18n-hmr.ts
Normal file
80
web/plugins/vite/custom-i18n-hmr.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import fs from 'node:fs'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils'
|
||||
|
||||
type CustomI18nHmrPluginOptions = {
|
||||
injectTarget: string
|
||||
}
|
||||
|
||||
export const customI18nHmrPlugin = ({ injectTarget }: CustomI18nHmrPluginOptions): Plugin => {
|
||||
const i18nHmrClientMarker = 'custom-i18n-hmr-client'
|
||||
const i18nHmrClientSnippet = `/* ${i18nHmrClientMarker} */
|
||||
if (import.meta.hot) {
|
||||
const getI18nUpdateTarget = (file) => {
|
||||
const match = file.match(/[/\\\\]i18n[/\\\\]([^/\\\\]+)[/\\\\]([^/\\\\]+)\\.json$/)
|
||||
if (!match)
|
||||
return null
|
||||
const [, locale, namespaceFile] = match
|
||||
return { locale, namespaceFile }
|
||||
}
|
||||
|
||||
import.meta.hot.on('i18n-update', async ({ file, content }) => {
|
||||
const target = getI18nUpdateTarget(file)
|
||||
if (!target)
|
||||
return
|
||||
|
||||
const [{ getI18n }, { camelCase }] = await Promise.all([
|
||||
import('react-i18next'),
|
||||
import('es-toolkit/string'),
|
||||
])
|
||||
|
||||
const i18n = getI18n()
|
||||
if (!i18n)
|
||||
return
|
||||
if (target.locale !== i18n.language)
|
||||
return
|
||||
|
||||
let resources
|
||||
try {
|
||||
resources = JSON.parse(content)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
|
||||
const namespace = camelCase(target.namespaceFile)
|
||||
i18n.addResourceBundle(target.locale, namespace, resources, true, true)
|
||||
i18n.emit('languageChanged', i18n.language)
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
return {
|
||||
name: 'custom-i18n-hmr',
|
||||
apply: 'serve',
|
||||
handleHotUpdate({ file, server }) {
|
||||
if (file.endsWith('.json') && file.includes('/i18n/')) {
|
||||
server.ws.send({
|
||||
type: 'custom',
|
||||
event: 'i18n-update',
|
||||
data: {
|
||||
file,
|
||||
content: fs.readFileSync(file, 'utf-8'),
|
||||
},
|
||||
})
|
||||
|
||||
return []
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
const cleanId = normalizeViteModuleId(id)
|
||||
if (cleanId !== injectTarget)
|
||||
return null
|
||||
|
||||
const nextCode = injectClientSnippet(code, i18nHmrClientMarker, i18nHmrClientSnippet)
|
||||
if (nextCode === code)
|
||||
return null
|
||||
return { code: nextCode, map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
92
web/plugins/vite/react-grab-open-file.ts
Normal file
92
web/plugins/vite/react-grab-open-file.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import { injectClientSnippet, normalizeViteModuleId } from './utils'
|
||||
|
||||
type ReactGrabOpenFilePluginOptions = {
|
||||
injectTarget: string
|
||||
projectRoot: string
|
||||
}
|
||||
|
||||
export const reactGrabOpenFilePlugin = ({
|
||||
injectTarget,
|
||||
projectRoot,
|
||||
}: ReactGrabOpenFilePluginOptions): Plugin => {
|
||||
const reactGrabOpenFileClientMarker = 'react-grab-open-file-client'
|
||||
const reactGrabOpenFileClientSnippet = `/* ${reactGrabOpenFileClientMarker} */
|
||||
if (typeof window !== 'undefined') {
|
||||
const projectRoot = ${JSON.stringify(projectRoot)};
|
||||
const pluginName = 'dify-vite-open-file';
|
||||
const rootRelativeSourcePathPattern = /^\\/(?!@|node_modules)(?:.+)\\.(?:[cm]?[jt]sx?|mdx?)$/;
|
||||
|
||||
const normalizeProjectRoot = (input) => {
|
||||
return input.endsWith('/') ? input.slice(0, -1) : input;
|
||||
};
|
||||
|
||||
const resolveFilePath = (filePath) => {
|
||||
if (filePath.startsWith('/@fs/')) {
|
||||
return filePath.slice('/@fs'.length);
|
||||
}
|
||||
|
||||
if (!rootRelativeSourcePathPattern.test(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const normalizedProjectRoot = normalizeProjectRoot(projectRoot);
|
||||
if (filePath.startsWith(normalizedProjectRoot)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return \`\${normalizedProjectRoot}\${filePath}\`;
|
||||
};
|
||||
|
||||
const registerPlugin = () => {
|
||||
if (window.__DIFY_REACT_GRAB_OPEN_FILE_PLUGIN_REGISTERED__) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reactGrab = window.__REACT_GRAB__;
|
||||
if (!reactGrab) {
|
||||
return;
|
||||
}
|
||||
|
||||
reactGrab.registerPlugin({
|
||||
name: pluginName,
|
||||
hooks: {
|
||||
onOpenFile(filePath, lineNumber) {
|
||||
const params = new URLSearchParams({
|
||||
file: resolveFilePath(filePath),
|
||||
column: '1',
|
||||
});
|
||||
|
||||
if (lineNumber) {
|
||||
params.set('line', String(lineNumber));
|
||||
}
|
||||
|
||||
void fetch(\`/__open-in-editor?\${params.toString()}\`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
window.__DIFY_REACT_GRAB_OPEN_FILE_PLUGIN_REGISTERED__ = true;
|
||||
};
|
||||
|
||||
registerPlugin();
|
||||
window.addEventListener('react-grab:init', registerPlugin);
|
||||
}
|
||||
`
|
||||
|
||||
return {
|
||||
name: 'react-grab-open-file',
|
||||
apply: 'serve',
|
||||
transform(code, id) {
|
||||
const cleanId = normalizeViteModuleId(id)
|
||||
if (cleanId !== injectTarget)
|
||||
return null
|
||||
|
||||
const nextCode = injectClientSnippet(code, reactGrabOpenFileClientMarker, reactGrabOpenFileClientSnippet)
|
||||
if (nextCode === code)
|
||||
return null
|
||||
return { code: nextCode, map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
20
web/plugins/vite/utils.ts
Normal file
20
web/plugins/vite/utils.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const normalizeViteModuleId = (id: string): string => {
|
||||
const withoutQuery = id.split('?', 1)[0]
|
||||
|
||||
if (withoutQuery.startsWith('/@fs/'))
|
||||
return withoutQuery.slice('/@fs'.length)
|
||||
|
||||
return withoutQuery
|
||||
}
|
||||
|
||||
export const injectClientSnippet = (code: string, marker: string, snippet: string): string => {
|
||||
if (code.includes(marker))
|
||||
return code
|
||||
|
||||
const useClientMatch = code.match(/(['"])use client\1;?\s*\n/)
|
||||
if (!useClientMatch)
|
||||
return `${snippet}\n${code}`
|
||||
|
||||
const insertAt = (useClientMatch.index ?? 0) + useClientMatch[0].length
|
||||
return `${code.slice(0, insertAt)}\n${snippet}\n${code.slice(insertAt)}`
|
||||
}
|
||||
Reference in New Issue
Block a user