From 46003b0589687cccaa204040b13c30535aadd12b Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 31 Dec 2025 12:53:16 +0000 Subject: [PATCH 1/2] fix: use ast not regex --- package.json | 2 + pnpm-lock.yaml | 6 + scripts/coverage-i18n.ts | 311 ++++++++++++++++++++++++++++++++------- 3 files changed, 269 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index a8a8d76981..ceb7e6f482 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ }, "devDependencies": { "@modrinth/tooling-config": "workspace:*", + "@types/node": "^20.1.0", + "@vue/compiler-dom": "^3.5.26", "@vue/compiler-sfc": "^3.5.26", "chalk": "^5.6.2", "if-ci": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 698403a55a..ebc317cf57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,12 @@ importers: '@modrinth/tooling-config': specifier: workspace:* 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': specifier: ^3.5.26 version: 3.5.26 diff --git a/scripts/coverage-i18n.ts b/scripts/coverage-i18n.ts index ca2b0bf058..9d01cfe6d3 100644 --- a/scripts/coverage-i18n.ts +++ b/scripts/coverage-i18n.ts @@ -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 * as fs from 'fs' import * as path from 'path' @@ -51,7 +62,7 @@ const icons = { sparkle: chalk.yellow('★'), } -const TRANSLATABLE_ATTRS = [ +const TRANSLATABLE_ATTRS = new Set([ 'label', 'placeholder', 'title', @@ -63,7 +74,11 @@ const TRANSLATABLE_ATTRS = [ 'message', 'hint', '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[] { const files: string[] = [] @@ -89,70 +104,266 @@ function findVueFiles(dir: string): string[] { function isPlainTextString(text: string): boolean { const trimmed = text.trim() if (!trimmed) 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 - if (trimmed.length < 2) return false + // Just a Vue interpolation if (/^\{\{.*\}\}$/.test(trimmed)) return false + // No letters at all 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 } +/** + * 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)[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: + 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): { plainStrings: string[] hasI18nPatterns: boolean + i18nUsages: number } { const plainStrings: string[] = [] let hasI18nPatterns = false - - if (/formatMessage\s*\(/.test(templateContent)) hasI18nPatterns = true - if (/([^<]+) { + // Check for text nodes with plain text content + if (node.type === NodeTypes.TEXT) { + const textNode = node as TextNode + if (isPlainTextString(textNode.content)) { + plainStrings.push(textNode.content.trim()) } } - const singleQuoteRegex = new RegExp(`(? 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 { - const patterns = [ - /from\s+['"]@modrinth\/ui['"]/, - /defineMessages?\s*\(/, - /useVIntl\s*\(/, - /formatMessage/, - /IntlFormatted/, - /useI18n/, - /\$t\s*\(/, - ] - return patterns.some((pattern) => pattern.test(scriptContent)) + return { plainStrings, hasI18nPatterns, i18nUsages } } function analyzeVueFile(filePath: string): FileResult { const content = fs.readFileSync(filePath, 'utf-8') - const { descriptor } = parse(content) + const { descriptor } = parseVue(content) const result: FileResult = { path: filePath, @@ -161,22 +372,22 @@ function analyzeVueFile(filePath: string): FileResult { i18nUsages: 0, } + // Analyze script content using AST const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '' - result.hasI18n = checkScriptForI18n(scriptContent) - - const formatMessageMatches = scriptContent.match(/formatMessage\s*\(/g) - result.i18nUsages += formatMessageMatches?.length || 0 + if (scriptContent) { + const scriptAnalysis = checkScriptForI18n(scriptContent) + result.hasI18n = scriptAnalysis.hasI18n + result.i18nUsages = scriptAnalysis.i18nUsages + } + // Analyze template content using AST if (descriptor.template?.content) { const templateAnalysis = extractTemplateStrings(descriptor.template.content) result.plainStrings = templateAnalysis.plainStrings if (templateAnalysis.hasI18nPatterns) { result.hasI18n = true } - const templateFormatMessage = descriptor.template.content.match(/formatMessage\s*\(/g) - const intlFormattedMatches = descriptor.template.content.match(/ Date: Wed, 31 Dec 2025 13:07:59 +0000 Subject: [PATCH 2/2] packages/ui incl --- scripts/coverage-i18n.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/coverage-i18n.ts b/scripts/coverage-i18n.ts index 9d01cfe6d3..499e904193 100644 --- a/scripts/coverage-i18n.ts +++ b/scripts/coverage-i18n.ts @@ -551,8 +551,13 @@ function main() { const jsonOutput = args.includes('--json') 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) { console.log() @@ -561,12 +566,11 @@ function main() { const allFiles: string[] = [] - if (fs.existsSync(frontendDir)) { - allFiles.push(...findVueFiles(frontendDir)) - } - - if (fs.existsSync(appFrontendDir)) { - allFiles.push(...findVueFiles(appFrontendDir)) + for (const dir of scanDirs) { + const fullPath = path.join(rootDir, dir) + if (fs.existsSync(fullPath)) { + allFiles.push(...findVueFiles(fullPath)) + } } if (!jsonOutput) {