diff --git a/package.json b/package.json index 94b2206a69..02cde59cd3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "prepr:frontend:lib": "turbo run prepr --filter=@modrinth/ui --filter=@modrinth/moderation --filter=@modrinth/assets --filter=@modrinth/blog --filter=@modrinth/api-client --filter=@modrinth/utils --filter=@modrinth/tooling-config", "prepr:frontend:web": "turbo run prepr --filter=@modrinth/frontend", "prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend", - "icons:add": "pnpm --filter @modrinth/assets icons:add" + "icons:add": "pnpm --filter @modrinth/assets icons:add", + "scripts": "node scripts/run.mjs" }, "devDependencies": { "@modrinth/tooling-config": "workspace:*", diff --git a/scripts/coverage-i18n.ts b/scripts/coverage-i18n.ts new file mode 100644 index 0000000000..ca2b0bf058 --- /dev/null +++ b/scripts/coverage-i18n.ts @@ -0,0 +1,383 @@ +import { parse } from '@vue/compiler-sfc' +import chalk from 'chalk' +import * as fs from 'fs' +import * as path from 'path' + +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[] +} + +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 = [ + 'label', + 'placeholder', + 'title', + 'alt', + 'aria-label', + 'description', + 'header', + 'text', + 'message', + 'hint', + 'tooltip', +] + +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 (/^[\s\d\-_./\\:;,!?@#$%^&*()[\]{}|<>+=~`'"]+$/.test(trimmed)) return false + if (/^[a-z0-9_-]+$/i.test(trimmed) && !trimmed.includes(' ')) return false + if (trimmed.length < 2) return false + if (/^\{\{.*\}\}$/.test(trimmed)) return false + if (!/[a-zA-Z]/.test(trimmed)) return false + return true +} + +function extractTemplateStrings(templateContent: string): { + plainStrings: string[] + hasI18nPatterns: boolean +} { + const plainStrings: string[] = [] + let hasI18nPatterns = false + + if (/formatMessage\s*\(/.test(templateContent)) hasI18nPatterns = true + if (/([^<]+) pattern.test(scriptContent)) +} + +function analyzeVueFile(filePath: string): FileResult { + const content = fs.readFileSync(filePath, 'utf-8') + const { descriptor } = parse(content) + + const result: FileResult = { + path: filePath, + hasI18n: false, + plainStrings: [], + i18nUsages: 0, + } + + 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 (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(/ 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 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 rootDir = path.resolve(__dirname, '..') + const frontendDir = path.join(rootDir, 'apps/frontend/src') + const appFrontendDir = path.join(rootDir, 'apps/app-frontend/src') + + if (!jsonOutput) { + console.log() + process.stdout.write(theme.muted(' Scanning Vue files... ')) + } + + const allFiles: string[] = [] + + if (fs.existsSync(frontendDir)) { + allFiles.push(...findVueFiles(frontendDir)) + } + + if (fs.existsSync(appFrontendDir)) { + allFiles.push(...findVueFiles(appFrontendDir)) + } + + if (!jsonOutput) { + 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) + + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)) + } else { + printReport(report, rootDir, verbose) + } +} + +main() diff --git a/scripts/run.mjs b/scripts/run.mjs new file mode 100644 index 0000000000..98c211306a --- /dev/null +++ b/scripts/run.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { spawn } from 'child_process' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const [scriptName, ...args] = process.argv.slice(2) + +if (!scriptName) { + console.error('Usage: pnpm scripts [args...]') + console.error('Example: pnpm scripts coverage-i18n --verbose') + process.exit(1) +} + +const scriptPath = join(__dirname, `${scriptName}.ts`) + +const child = spawn('pnpx', ['tsx', scriptPath, ...args], { + stdio: 'inherit', + shell: true, +}) + +child.on('exit', (code) => { + process.exit(code ?? 0) +})