chore: lint custom tag in i18n (#31301)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Stephen Zhou
2026-01-21 12:21:30 +08:00
committed by GitHub
parent a8764694ed
commit db4fb06c5f
29 changed files with 205 additions and 139 deletions

View File

@@ -2,41 +2,108 @@ import fs from 'node:fs'
import path, { normalize, sep } from 'node:path'
import { cleanJsonText } from '../utils.js'
/**
* Extract placeholders from a string
* Matches patterns like {{name}}, {{count}}, etc.
* @param {string} str
* @returns {string[]} Sorted array of placeholder names
*/
function extractPlaceholders(str) {
const matches = str.match(/\{\{\w+\}\}/g) || []
return matches.map(m => m.slice(2, -2)).sort()
}
/**
* Compare two arrays and return if they're equal
* @param {string[]} arr1
* @param {string[]} arr2
* @returns {boolean} True if arrays contain the same elements in the same order
*/
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])
}
/** @type {import('eslint').Rule.RuleModule} */
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 in translations match the en-US source',
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, sourceCode } = context
const { filename } = context
if (!filename.endsWith('.json'))
return
@@ -45,63 +112,62 @@ export default {
const jsonFile = parts.at(-1)
const lang = parts.at(-2)
// Skip English files - they are the source of truth
if (lang === 'en-US')
return
let currentJson = {}
let englishJson = {}
state.enabled = true
try {
currentJson = JSON.parse(cleanJsonText(sourceCode.text))
const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '')
englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8'))
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),
})
}
// Check each key in the current translation
for (const key of Object.keys(currentJson)) {
// Skip if the key doesn't exist in English (handled by no-extra-keys rule)
if (!Object.prototype.hasOwnProperty.call(englishJson, key))
continue
const currentValue = currentJson[key]
const englishValue = englishJson[key]
// Skip non-string values
if (typeof currentValue !== 'string' || typeof englishValue !== 'string')
continue
const currentPlaceholders = extractPlaceholders(currentValue)
const englishPlaceholders = extractPlaceholders(englishValue)
if (!arraysEqual(currentPlaceholders, englishPlaceholders)) {
const missing = englishPlaceholders.filter(p => !currentPlaceholders.includes(p))
const extra = currentPlaceholders.filter(p => !englishPlaceholders.includes(p))
let message = `Placeholder mismatch in "${key}": `
const details = []
if (missing.length > 0)
details.push(`missing {{${missing.join('}}, {{')}}}`)
if (extra.length > 0)
details.push(`extra {{${extra.join('}}, {{')}}}`)
message += details.join('; ')
message += `. Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}`
context.report({
node,
message,
})
}
if (!arraysEqual(currentTagMarkers, englishTagMarkers)) {
context.report({
node: currentNode,
message: buildTagMessage(key, englishTagMarkers, currentTagMarkers),
})
}
},
}