import type { TSESTree } from '@typescript-eslint/typescript-estree' import { AST_NODE_TYPES, parse as parseTs } from '@typescript-eslint/typescript-estree' import { NodeTypes, parse as parseTemplate, type AttributeNode, type ElementNode, type RootNode, type TemplateChildNode, type TextNode, } from '@vue/compiler-dom' import { parse as parseVue } from '@vue/compiler-sfc' import chalk from 'chalk' import * as fs from 'node:fs' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { contractFromMessage, translationCompatibleWithSource } from './i18n-icu-contract.ts' interface FileResult { path: string hasI18n: boolean plainStrings: string[] i18nUsages: number } interface CoverageReport { totalFiles: number filesWithI18n: number filesWithPlainStrings: number fullyConverted: number coverage: number byDirectory: Record< string, { total: number withI18n: number fullyConverted: number coverage: number } > filesNeedingWork: FileResult[] } type MessageEntry = string | { message?: string; defaultMessage?: string } type MessageFile = Record type LanguageProduct = 'app' | 'website' interface LanguageCoverageStats { percentage: number interfaceCoverage: number translationCoverage: number translatedMessages: number totalMessages: number unlocalizedStrings: number } type LanguageCoverageByProduct = Record< LanguageProduct, Record > const PRODUCT_SCOPES: Record< LanguageProduct, { sourceDirectories: string[]; catalogScopes: string[] } > = { app: { sourceDirectories: ['apps/app-frontend/src', 'packages/ui/src'], catalogScopes: ['apps/app-frontend', 'packages/ui'], }, website: { sourceDirectories: ['apps/frontend/src', 'packages/moderation/src', 'packages/ui/src'], catalogScopes: ['packages/ui', 'packages/moderation', 'apps/frontend'], }, } const theme = { primary: chalk.cyan, success: chalk.green, warning: chalk.yellow, error: chalk.red, muted: chalk.gray, highlight: chalk.white.bold, title: chalk.bold.cyan, subtitle: chalk.dim, } const icons = { check: chalk.green('✓'), cross: chalk.red('✗'), arrow: chalk.cyan('→'), dot: '●', warning: chalk.yellow('⚠'), file: '◦', folder: '▸', globe: '◎', sparkle: chalk.yellow('★'), } const TRANSLATABLE_ATTRS = new Set([ 'label', 'placeholder', 'title', 'alt', 'aria-label', 'description', 'header', 'text', '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[] = [] function walk(currentDir: string) { const entries = fs.readdirSync(currentDir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(currentDir, entry.name) if (entry.isDirectory()) { if ( !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'legal' ) { walk(fullPath) } } else if (entry.isFile() && entry.name.endsWith('.vue')) { files.push(fullPath) } } } walk(dir) return files } 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 // 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 let i18nUsages = 0 let ast: RootNode try { ast = parseTemplate(templateContent) } catch { // If parsing fails, return empty results return { plainStrings: [], hasI18nPatterns: false, i18nUsages: 0 } } walkTemplateAst(ast, (node) => { // 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()) } } // Check element nodes if (node.type === NodeTypes.ELEMENT) { 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 } } } } } // 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 } } } }) return { plainStrings, hasI18nPatterns, i18nUsages } } function analyzeVueFile(filePath: string): FileResult { const content = fs.readFileSync(filePath, 'utf-8') const { descriptor } = parseVue(content) const result: FileResult = { path: filePath, hasI18n: false, plainStrings: [], i18nUsages: 0, } // Analyze script content using AST const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '' 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 } result.i18nUsages += templateAnalysis.i18nUsages } return result } function generateReport(results: FileResult[], rootDir: string): CoverageReport { const report: CoverageReport = { totalFiles: results.length, filesWithI18n: 0, filesWithPlainStrings: 0, fullyConverted: 0, coverage: 0, byDirectory: {}, filesNeedingWork: [], } for (const result of results) { const relativePath = path.relative(rootDir, result.path) const dirParts = relativePath.split(path.sep) const dirKey = dirParts.slice(0, 3).join('/') if (!report.byDirectory[dirKey]) { report.byDirectory[dirKey] = { total: 0, withI18n: 0, fullyConverted: 0, coverage: 0 } } report.byDirectory[dirKey].total++ if (result.hasI18n) { report.filesWithI18n++ report.byDirectory[dirKey].withI18n++ } if (result.plainStrings.length > 0) { report.filesWithPlainStrings++ report.filesNeedingWork.push(result) } else if (result.hasI18n || result.i18nUsages > 0) { report.fullyConverted++ report.byDirectory[dirKey].fullyConverted++ } } report.coverage = report.totalFiles > 0 ? Math.round((report.fullyConverted / report.totalFiles) * 100) : 0 for (const dir of Object.keys(report.byDirectory)) { const dirStats = report.byDirectory[dir] dirStats.coverage = dirStats.total > 0 ? Math.round((dirStats.fullyConverted / dirStats.total) * 100) : 0 } return report } function messageText(entry: MessageEntry | undefined): string | undefined { if (typeof entry === 'string') return entry return entry?.message ?? entry?.defaultMessage } function readMessageFile(filePath: string): MessageFile { if (!fs.existsSync(filePath)) return {} return JSON.parse(fs.readFileSync(filePath, 'utf8')) as MessageFile } function mergeProductCatalog( rootDir: string, catalogScopes: string[], locale: string, ): MessageFile { const merged: MessageFile = {} for (const scope of catalogScopes) { Object.assign( merged, readMessageFile(path.join(rootDir, scope, 'src/locales', locale, 'index.json')), ) } return merged } function getLocaleCodes(rootDir: string): string[] { const localesDirectory = path.join(rootDir, 'packages/ui/src/locales') return fs .readdirSync(localesDirectory, { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && fs.existsSync(path.join(localesDirectory, entry.name, 'index.json')), ) .map((entry) => entry.name) .sort((left, right) => left.localeCompare(right)) } function isValidTranslation( key: string, sourceEntry: MessageEntry, translationEntry: MessageEntry | undefined, ): boolean { const source = messageText(sourceEntry) const translation = messageText(translationEntry) if (!source || !translation) return false try { return translationCompatibleWithSource( contractFromMessage(source, `source:${key}`), contractFromMessage(translation, `translation:${key}`), ) } catch { return false } } function generateLanguageCoverage( results: FileResult[], rootDir: string, ): LanguageCoverageByProduct { const localeCodes = getLocaleCodes(rootDir) const coverage: LanguageCoverageByProduct = { app: {}, website: {} } for (const [product, definition] of Object.entries(PRODUCT_SCOPES) as [ LanguageProduct, (typeof PRODUCT_SCOPES)[LanguageProduct], ][]) { const sourceRoots = definition.sourceDirectories.map((directory) => `${path.join(rootDir, directory)}${path.sep}`, ) const productResults = results.filter((result) => sourceRoots.some((sourceRoot) => result.path.startsWith(sourceRoot)), ) const localizedUsages = productResults.reduce( (total, result) => total + result.i18nUsages, 0, ) const unlocalizedStrings = productResults.reduce( (total, result) => total + result.plainStrings.length, 0, ) const detectedStrings = localizedUsages + unlocalizedStrings const interfaceCoverage = detectedStrings > 0 ? Math.round((localizedUsages / detectedStrings) * 100) : 100 const sourceCatalog = mergeProductCatalog(rootDir, definition.catalogScopes, 'en-US') const sourceEntries = Object.entries(sourceCatalog).filter(([, entry]) => messageText(entry)) for (const locale of localeCodes) { const translationCatalog = mergeProductCatalog(rootDir, definition.catalogScopes, locale) const translatedMessages = locale === 'en-US' ? sourceEntries.length : sourceEntries.filter(([key, source]) => isValidTranslation(key, source, translationCatalog[key]), ).length const translationCoverage = sourceEntries.length > 0 ? Math.round((translatedMessages / sourceEntries.length) * 100) : 100 const percentage = locale === 'en-US' ? 100 : Math.round((interfaceCoverage * translationCoverage) / 100) coverage[product][locale] = { percentage, interfaceCoverage, translationCoverage, translatedMessages, totalMessages: sourceEntries.length, unlocalizedStrings, } } } return coverage } function renderLanguageCoverageModule(coverage: LanguageCoverageByProduct): string { const products = ( Object.entries(coverage) as [LanguageProduct, Record][] ).map(([product, locales]) => { const localeEntries = Object.entries(locales) .map( ([locale, stats]) => `\t\t'${locale}': {\n` + `\t\t\tpercentage: ${stats.percentage},\n` + `\t\t\tinterfaceCoverage: ${stats.interfaceCoverage},\n` + `\t\t\ttranslationCoverage: ${stats.translationCoverage},\n` + `\t\t\ttranslatedMessages: ${stats.translatedMessages},\n` + `\t\t\ttotalMessages: ${stats.totalMessages},\n` + `\t\t\tunlocalizedStrings: ${stats.unlocalizedStrings},\n` + `\t\t},`, ) .join('\n') return `\t${product}: {\n${localeEntries}\n\t},` }) .join('\n') return `import type { LanguageCoverageByProduct } from './language-settings-coverage'\n\nexport const languageCoverage = {\n${products}\n} satisfies LanguageCoverageByProduct\n` } function writeLanguageCoverageModule( rootDir: string, outputPath: string, coverage: LanguageCoverageByProduct, ): void { const resolvedOutput = path.resolve(rootDir, outputPath) fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true }) fs.writeFileSync(resolvedOutput, renderLanguageCoverageModule(coverage)) } function progressBar(percent: number, width: number = 20): string { const filled = Math.round((percent / 100) * width) const empty = width - filled let color: (s: string) => string if (percent >= 80) color = chalk.green else if (percent >= 50) color = chalk.yellow else if (percent >= 25) color = chalk.hex('#FFA500') else color = chalk.red return color('━'.repeat(filled)) + chalk.gray('━'.repeat(empty)) } function colorPercent(percent: number): string { if (percent >= 80) return chalk.green.bold(`${percent}%`) if (percent >= 50) return chalk.yellow.bold(`${percent}%`) if (percent >= 25) return chalk.hex('#FFA500').bold(`${percent}%`) return chalk.red.bold(`${percent}%`) } function printReport(report: CoverageReport, rootDir: string, verbose: boolean) { console.log() console.log(theme.title(` ${icons.globe} i18n Coverage Report`)) console.log(theme.muted(` ${'─'.repeat(45)}`)) console.log() console.log(chalk.bold(' Summary')) console.log() console.log(` ${theme.muted('Total files')} ${theme.highlight(report.totalFiles)}`) console.log(` ${theme.muted('Using i18n')} ${theme.highlight(report.filesWithI18n)}`) console.log( ` ${theme.muted('Converted')} ${report.fullyConverted > 0 ? chalk.green.bold(report.fullyConverted) : theme.highlight(report.fullyConverted)}`, ) console.log( ` ${theme.muted('Need work')} ${report.filesWithPlainStrings > 0 ? chalk.yellow.bold(report.filesWithPlainStrings) : theme.highlight(report.filesWithPlainStrings)}`, ) console.log() console.log(` ${theme.muted('Coverage')} ${colorPercent(report.coverage)}`) console.log(` ${progressBar(report.coverage, 32)}`) console.log() console.log(theme.muted(` ${'─'.repeat(45)}`)) console.log(chalk.bold(' By Directory')) console.log() const sortedDirs = Object.entries(report.byDirectory).sort(([, a], [, b]) => b.total - a.total) for (const [dir, stats] of sortedDirs) { const shortDir = dir.replace('apps/', '').replace('/src', '') const paddedDir = shortDir.padEnd(20) console.log( ` ${theme.primary(paddedDir)} ${colorPercent(stats.coverage).padStart(12)} ${progressBar(stats.coverage, 12)} ${theme.muted(`${stats.fullyConverted}/${stats.total}`)}`, ) } console.log() if (verbose && report.filesNeedingWork.length > 0) { console.log(theme.muted(` ${'─'.repeat(45)}`)) console.log(chalk.bold(' Files Needing Work')) console.log() const sorted = [...report.filesNeedingWork].sort( (a, b) => b.plainStrings.length - a.plainStrings.length, ) for (const file of sorted.slice(0, 20)) { const relativePath = path.relative(rootDir, file.path) const shortPath = relativePath.replace('apps/', '').replace('/src/', '/') const count = file.plainStrings.length let countStr: string if (count >= 50) countStr = chalk.red.bold(`${count}`) else if (count >= 20) countStr = chalk.yellow.bold(`${count}`) else countStr = chalk.white(`${count}`) console.log(` ${icons.arrow} ${chalk.white(shortPath)}`) console.log(` ${countStr} ${theme.muted('plain strings')}`) for (const str of file.plainStrings.slice(0, 2)) { const cleaned = str.replace(/\n/g, ' ').replace(/\t/g, ' ').trim() const truncated = cleaned.length > 45 ? cleaned.slice(0, 42) + '...' : cleaned console.log(` ${theme.muted(`"${truncated}"`)}`) } if (file.plainStrings.length > 2) { console.log(` ${theme.subtitle(`+${file.plainStrings.length - 2} more`)}`) } console.log() } if (sorted.length > 20) { console.log(` ${theme.subtitle(`... and ${sorted.length - 20} more files`)}`) console.log() } } console.log(theme.muted(` ${'─'.repeat(45)}`)) if (!verbose) { console.log(theme.subtitle(` Run with ${chalk.cyan('--verbose')} to see files needing work`)) } console.log() } function main() { const args = process.argv.slice(2) const verbose = args.includes('--verbose') || args.includes('-v') const jsonOutput = args.includes('--json') const quiet = args.includes('--quiet') const writeIndex = args.indexOf('--write') const writeOutput = writeIndex >= 0 ? args[writeIndex + 1] : undefined if (writeIndex >= 0 && (!writeOutput || writeOutput.startsWith('--'))) { throw new Error('--write requires an output path') } const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') // Directories to scan for Vue files const scanDirs = [ 'apps/frontend/src', 'apps/app-frontend/src', 'packages/ui/src', 'packages/moderation/src', ] if (!jsonOutput && !quiet) { console.log() process.stdout.write(theme.muted(' Scanning Vue files... ')) } const allFiles: string[] = [] for (const dir of scanDirs) { const fullPath = path.join(rootDir, dir) if (fs.existsSync(fullPath)) { allFiles.push(...findVueFiles(fullPath)) } } if (!jsonOutput && !quiet) { console.log(`${icons.check} ${theme.highlight(allFiles.length)} files`) } const results: FileResult[] = [] for (const file of allFiles) { try { results.push(analyzeVueFile(file)) } catch { // Silent fail } } const report = generateReport(results, rootDir) const languageCoverage = generateLanguageCoverage(results, rootDir) if (writeOutput) { writeLanguageCoverageModule(rootDir, writeOutput, languageCoverage) } if (jsonOutput) { console.log(JSON.stringify({ ...report, languageCoverage }, null, 2)) } else if (!quiet) { printReport(report, rootDir, verbose) } } main()