fix: use ast not regex (#5007)

* fix: use ast not regex

* packages/ui incl
This commit is contained in:
Calum H.
2025-12-31 17:48:27 +00:00
committed by GitHub
parent 7fb6401613
commit 9e4317a262
3 changed files with 281 additions and 58 deletions

View File

@@ -26,6 +26,8 @@
}, },
"devDependencies": { "devDependencies": {
"@modrinth/tooling-config": "workspace:*", "@modrinth/tooling-config": "workspace:*",
"@types/node": "^20.1.0",
"@vue/compiler-dom": "^3.5.26",
"@vue/compiler-sfc": "^3.5.26", "@vue/compiler-sfc": "^3.5.26",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"if-ci": "^3.0.0", "if-ci": "^3.0.0",

6
pnpm-lock.yaml generated
View File

@@ -19,6 +19,12 @@ importers:
'@modrinth/tooling-config': '@modrinth/tooling-config':
specifier: workspace:* specifier: workspace:*
version: link:packages/tooling-config version: link:packages/tooling-config
'@types/node':
specifier: ^20.1.0
version: 20.19.27
'@vue/compiler-dom':
specifier: ^3.5.26
version: 3.5.26
'@vue/compiler-sfc': '@vue/compiler-sfc':
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.26 version: 3.5.26

View File

@@ -1,4 +1,15 @@
import { parse } from '@vue/compiler-sfc' import { parse as parseVue } from '@vue/compiler-sfc'
import {
parse as parseTemplate,
NodeTypes,
type RootNode,
type TemplateChildNode,
type ElementNode,
type AttributeNode,
type TextNode,
} from '@vue/compiler-dom'
import { parse as parseTs, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'
import type { TSESTree } from '@typescript-eslint/typescript-estree'
import chalk from 'chalk' import chalk from 'chalk'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
@@ -51,7 +62,7 @@ const icons = {
sparkle: chalk.yellow('★'), sparkle: chalk.yellow('★'),
} }
const TRANSLATABLE_ATTRS = [ const TRANSLATABLE_ATTRS = new Set([
'label', 'label',
'placeholder', 'placeholder',
'title', 'title',
@@ -63,7 +74,11 @@ const TRANSLATABLE_ATTRS = [
'message', 'message',
'hint', 'hint',
'tooltip', 'tooltip',
] ])
// i18n symbols that indicate i18n usage
const I18N_SYMBOLS = ['useVIntl', 'defineMessage', 'defineMessages', 'IntlFormatted', 'useI18n'] as const
const I18N_CALL_PATTERNS = ['formatMessage', '$t'] as const
function findVueFiles(dir: string): string[] { function findVueFiles(dir: string): string[] {
const files: string[] = [] const files: string[] = []
@@ -89,70 +104,266 @@ function findVueFiles(dir: string): string[] {
function isPlainTextString(text: string): boolean { function isPlainTextString(text: string): boolean {
const trimmed = text.trim() const trimmed = text.trim()
if (!trimmed) return false if (!trimmed) return false
if (/^[\s\d\-_./\\:;,!?@#$%^&*()[\]{}|<>+=~`'"]+$/.test(trimmed)) return false
if (/^[a-z0-9_-]+$/i.test(trimmed) && !trimmed.includes(' ')) return false
if (trimmed.length < 2) return false if (trimmed.length < 2) return false
// Only punctuation/symbols/numbers
if (/^[\s\d\-_./\\:;,!?@#$%^&*()[\]{}|<>+=~`'"]+$/.test(trimmed)) return false
// Single identifier-like word (no spaces)
if (/^[a-z0-9_-]+$/i.test(trimmed) && !trimmed.includes(' ')) return false
// Just a Vue interpolation
if (/^\{\{.*\}\}$/.test(trimmed)) return false if (/^\{\{.*\}\}$/.test(trimmed)) return false
// No letters at all
if (!/[a-zA-Z]/.test(trimmed)) return false if (!/[a-zA-Z]/.test(trimmed)) return false
// URLs
if (/^https?:\/\//.test(trimmed)) return false
// File/route paths (but not "/ month" style text)
if (/^\/[a-zA-Z_][\w\-/[\]]*$/.test(trimmed)) return false
// Email addresses
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return false
return true return true
} }
/**
* Walk TypeScript AST and call visitor for each node
*/
function walkTsAst(node: TSESTree.Node, visitor: (node: TSESTree.Node) => void) {
visitor(node)
for (const key of Object.keys(node)) {
const child = (node as unknown as Record<string, unknown>)[key]
if (child && typeof child === 'object') {
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
walkTsAst(item as TSESTree.Node, visitor)
}
}
} else if ('type' in child) {
walkTsAst(child as TSESTree.Node, visitor)
}
}
}
}
/**
* Walk Vue template AST and call visitor for each node
*/
function walkTemplateAst(
node: RootNode | TemplateChildNode,
visitor: (node: RootNode | TemplateChildNode) => void,
) {
visitor(node)
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children as TemplateChildNode[]) {
walkTemplateAst(child, visitor)
}
}
// Handle v-if/v-for branches
if (node.type === NodeTypes.IF) {
for (const branch of node.branches) {
walkTemplateAst(branch, visitor)
}
}
if (node.type === NodeTypes.FOR) {
for (const child of node.children) {
walkTemplateAst(child, visitor)
}
}
}
/**
* Parse TypeScript/JavaScript content into AST
*/
function parseTsContent(content: string, isJsx: boolean = false): TSESTree.Program | null {
try {
return parseTs(content, {
jsx: isJsx,
loc: true,
range: true,
})
} catch {
return null
}
}
/**
* Count i18n calls in a JavaScript expression using AST
*/
function countI18nCallsInExpression(expression: string): number {
// Wrap expression to make it parseable
const wrappedCode = `(${expression})`
const ast = parseTsContent(wrappedCode, false)
if (!ast) return 0
let count = 0
walkTsAst(ast, (node) => {
if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee
if (callee.type === AST_NODE_TYPES.Identifier) {
if (I18N_CALL_PATTERNS.includes(callee.name as (typeof I18N_CALL_PATTERNS)[number])) {
count++
}
}
// Also handle this.formatMessage() or intl.formatMessage()
if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) {
if (I18N_CALL_PATTERNS.includes(callee.property.name as (typeof I18N_CALL_PATTERNS)[number])) {
count++
}
}
}
})
return count
}
/**
* Check if script has i18n imports or usage using AST
*/
function checkScriptForI18n(scriptContent: string): { hasI18n: boolean; i18nUsages: number } {
const ast = parseTsContent(scriptContent, true)
if (!ast) {
return { hasI18n: false, i18nUsages: 0 }
}
let hasI18n = false
let i18nUsages = 0
// Check imports
for (const node of ast.body) {
if (node.type === AST_NODE_TYPES.ImportDeclaration) {
const source = node.source.value as string
// Check for @modrinth/ui import
if (source === '@modrinth/ui') {
for (const specifier of node.specifiers) {
if (specifier.type === AST_NODE_TYPES.ImportSpecifier) {
const importedName =
specifier.imported.type === AST_NODE_TYPES.Identifier
? specifier.imported.name
: String(specifier.imported.value)
if (I18N_SYMBOLS.includes(importedName as (typeof I18N_SYMBOLS)[number])) {
hasI18n = true
}
}
}
}
}
}
// Walk AST for call expressions
walkTsAst(ast, (node) => {
if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee
if (callee.type === AST_NODE_TYPES.Identifier) {
const name = callee.name
// Check for i18n function calls
if (I18N_SYMBOLS.includes(name as (typeof I18N_SYMBOLS)[number])) {
hasI18n = true
}
if (I18N_CALL_PATTERNS.includes(name as (typeof I18N_CALL_PATTERNS)[number])) {
hasI18n = true
i18nUsages++
}
}
}
// Check for JSX elements: <IntlFormatted>
if (node.type === AST_NODE_TYPES.JSXOpeningElement) {
const name = node.name
if (name.type === AST_NODE_TYPES.JSXIdentifier && name.name === 'IntlFormatted') {
hasI18n = true
i18nUsages++
}
}
})
return { hasI18n, i18nUsages }
}
/**
* Extract plain text strings from template AST
*/
function extractTemplateStrings(templateContent: string): { function extractTemplateStrings(templateContent: string): {
plainStrings: string[] plainStrings: string[]
hasI18nPatterns: boolean hasI18nPatterns: boolean
i18nUsages: number
} { } {
const plainStrings: string[] = [] const plainStrings: string[] = []
let hasI18nPatterns = false let hasI18nPatterns = false
let i18nUsages = 0
if (/formatMessage\s*\(/.test(templateContent)) hasI18nPatterns = true let ast: RootNode
if (/<IntlFormatted/.test(templateContent)) hasI18nPatterns = true try {
if (/\$t\s*\(/.test(templateContent)) hasI18nPatterns = true ast = parseTemplate(templateContent)
} catch {
const tagContentRegex = />([^<]+)</g // If parsing fails, return empty results
let match return { plainStrings: [], hasI18nPatterns: false, i18nUsages: 0 }
while ((match = tagContentRegex.exec(templateContent)) !== null) {
const text = match[1]
if (/^\s*\{\{.*\}\}\s*$/.test(text)) continue
const withoutInterpolation = text.replace(/\{\{[^}]+\}\}/g, '')
if (isPlainTextString(withoutInterpolation)) {
plainStrings.push(text.trim())
}
} }
for (const attr of TRANSLATABLE_ATTRS) { walkTemplateAst(ast, (node) => {
const attrRegex = new RegExp(`(?<![:\\w])${attr}="([^"]+)"`, 'g') // Check for text nodes with plain text content
while ((match = attrRegex.exec(templateContent)) !== null) { if (node.type === NodeTypes.TEXT) {
if (isPlainTextString(match[1])) { const textNode = node as TextNode
plainStrings.push(`[${attr}]: ${match[1]}`) if (isPlainTextString(textNode.content)) {
plainStrings.push(textNode.content.trim())
} }
} }
const singleQuoteRegex = new RegExp(`(?<![:\\w])${attr}='([^']+)'`, 'g')
while ((match = singleQuoteRegex.exec(templateContent)) !== null) { // Check element nodes
if (isPlainTextString(match[1])) { if (node.type === NodeTypes.ELEMENT) {
plainStrings.push(`[${attr}]: ${match[1]}`) const elementNode = node as ElementNode
const tagName = elementNode.tag
// Check for IntlFormatted component
if (tagName === 'IntlFormatted') {
hasI18nPatterns = true
i18nUsages++
}
// Check attributes for translatable content
for (const prop of elementNode.props) {
// Static attributes
if (prop.type === NodeTypes.ATTRIBUTE) {
const attrNode = prop as AttributeNode
if (TRANSLATABLE_ATTRS.has(attrNode.name) && attrNode.value) {
if (isPlainTextString(attrNode.value.content)) {
plainStrings.push(`[${attrNode.name}]: ${attrNode.value.content}`)
}
}
}
// Directive attributes (v-bind, :attr, etc.)
if (prop.type === NodeTypes.DIRECTIVE) {
// Check for formatMessage or $t calls in directive expressions using AST
if (prop.exp && prop.exp.type === NodeTypes.SIMPLE_EXPRESSION) {
const callCount = countI18nCallsInExpression(prop.exp.content)
if (callCount > 0) {
hasI18nPatterns = true
i18nUsages += callCount
}
}
}
} }
} }
}
return { plainStrings, hasI18nPatterns } // Check interpolation expressions for i18n calls using AST
} if (node.type === NodeTypes.INTERPOLATION) {
if (node.content && node.content.type === NodeTypes.SIMPLE_EXPRESSION) {
const callCount = countI18nCallsInExpression(node.content.content)
if (callCount > 0) {
hasI18nPatterns = true
i18nUsages += callCount
}
}
}
})
function checkScriptForI18n(scriptContent: string): boolean { return { plainStrings, hasI18nPatterns, i18nUsages }
const patterns = [
/from\s+['"]@modrinth\/ui['"]/,
/defineMessages?\s*\(/,
/useVIntl\s*\(/,
/formatMessage/,
/IntlFormatted/,
/useI18n/,
/\$t\s*\(/,
]
return patterns.some((pattern) => pattern.test(scriptContent))
} }
function analyzeVueFile(filePath: string): FileResult { function analyzeVueFile(filePath: string): FileResult {
const content = fs.readFileSync(filePath, 'utf-8') const content = fs.readFileSync(filePath, 'utf-8')
const { descriptor } = parse(content) const { descriptor } = parseVue(content)
const result: FileResult = { const result: FileResult = {
path: filePath, path: filePath,
@@ -161,22 +372,22 @@ function analyzeVueFile(filePath: string): FileResult {
i18nUsages: 0, i18nUsages: 0,
} }
// Analyze script content using AST
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '' const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || ''
result.hasI18n = checkScriptForI18n(scriptContent) if (scriptContent) {
const scriptAnalysis = checkScriptForI18n(scriptContent)
const formatMessageMatches = scriptContent.match(/formatMessage\s*\(/g) result.hasI18n = scriptAnalysis.hasI18n
result.i18nUsages += formatMessageMatches?.length || 0 result.i18nUsages = scriptAnalysis.i18nUsages
}
// Analyze template content using AST
if (descriptor.template?.content) { if (descriptor.template?.content) {
const templateAnalysis = extractTemplateStrings(descriptor.template.content) const templateAnalysis = extractTemplateStrings(descriptor.template.content)
result.plainStrings = templateAnalysis.plainStrings result.plainStrings = templateAnalysis.plainStrings
if (templateAnalysis.hasI18nPatterns) { if (templateAnalysis.hasI18nPatterns) {
result.hasI18n = true result.hasI18n = true
} }
const templateFormatMessage = descriptor.template.content.match(/formatMessage\s*\(/g) result.i18nUsages += templateAnalysis.i18nUsages
const intlFormattedMatches = descriptor.template.content.match(/<IntlFormatted/g)
result.i18nUsages += templateFormatMessage?.length || 0
result.i18nUsages += intlFormattedMatches?.length || 0
} }
return result return result
@@ -340,8 +551,13 @@ function main() {
const jsonOutput = args.includes('--json') const jsonOutput = args.includes('--json')
const rootDir = path.resolve(__dirname, '..') const rootDir = path.resolve(__dirname, '..')
const frontendDir = path.join(rootDir, 'apps/frontend/src')
const appFrontendDir = path.join(rootDir, 'apps/app-frontend/src') // Directories to scan for Vue files
const scanDirs = [
'apps/frontend/src',
'apps/app-frontend/src',
'packages/ui/src',
]
if (!jsonOutput) { if (!jsonOutput) {
console.log() console.log()
@@ -350,12 +566,11 @@ function main() {
const allFiles: string[] = [] const allFiles: string[] = []
if (fs.existsSync(frontendDir)) { for (const dir of scanDirs) {
allFiles.push(...findVueFiles(frontendDir)) const fullPath = path.join(rootDir, dir)
} if (fs.existsSync(fullPath)) {
allFiles.push(...findVueFiles(fullPath))
if (fs.existsSync(appFrontendDir)) { }
allFiles.push(...findVueFiles(appFrontendDir))
} }
if (!jsonOutput) { if (!jsonOutput) {