chore: use react-grab to replace code-inspector-plugin (#33078)

This commit is contained in:
Stephen Zhou
2026-03-06 14:54:24 +08:00
committed by GitHub
parent e74cda6535
commit f05f0be55f
18 changed files with 250 additions and 427 deletions

View 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

View 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
}

View 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),
})
}
},
}
},
}

View 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',
})
}
}
},
}
},
}

View 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)
},
})
}
},
}
},
}

View 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
},
})
},
}
},
}

View 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',
})
}
}
},
}
},
}

View File

@ -0,0 +1,10 @@
export const cleanJsonText = (text) => {
const cleaned = text.replaceAll(/,\s*\}/g, '}')
try {
JSON.parse(cleaned)
return cleaned
}
catch {
return text
}
}

View 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 }
},
}
}

View 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
View 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)}`
}