import { NextResponse } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; import { en } from '@/app/i18n/translations/en'; import { de } from '@/app/i18n/translations/de'; import { flattenObject } from '@/app/i18n/dynamicTranslations'; import { requireAdminSession } from '../../_utils/backendAuth'; const EXCLUDED_DIRS = new Set([ '.git', '.next', 'node_modules', 'dist', 'build', 'coverage', 'out', '.turbo', ]); const SCANNED_EXTENSIONS = new Set([ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.html', '.md', ]); const MAX_FILE_SIZE_BYTES = 1024 * 1024; // 1 MB per file type MissingKeyMap = Map>; interface ScanResult { scannedFiles: number; scannedDirectories: number; translationCallCount: number; uniqueKeyCount: number; missingKeys: Array<{ key: string; files: string[] }>; untranslatedLiterals: Array<{ text: string; files: string[] }>; autoFixEligibleFiles: string[]; autoFixForceConvertibleFiles: string[]; } interface AutoFixChange { file: string; replacements: number; addedImport: boolean; addedHook: boolean; } interface AutoFixResult { changedFiles: AutoFixChange[]; skippedFiles: Array<{ file: string; reason: string }>; createdKeys: string[]; debugEntries: AutoFixDebugEntry[]; } interface AutoFixDebugEntry { file: string; status: 'changed' | 'skipped' | 'no-op'; beforeLiteralCount: number; textReplacements: number; attrReplacements: number; afterLiteralCount: number; reason?: string; } interface AutoFixOptions { targetFiles?: Set; forceConvertToClient?: boolean; } interface AddMissingKeysResult { createdKeys: string[]; } const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const; const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/; const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/; const JSX_TAG_REGEX = /<(?:[^'"<>]|"[^"]*"|'[^']*'|\{[^{}]*\})+>/g; async function walk(dir: string, outFiles: string[], counters: { dirs: number }) { counters.dirs += 1; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!EXCLUDED_DIRS.has(entry.name)) { await walk(fullPath, outFiles, counters); } continue; } if (!entry.isFile()) continue; const ext = path.extname(entry.name).toLowerCase(); if (!SCANNED_EXTENSIONS.has(ext)) continue; const stat = await fs.stat(fullPath); if (stat.size > MAX_FILE_SIZE_BYTES) continue; outFiles.push(fullPath); } } function extractTranslationKeys(content: string): string[] { const keys: string[] = []; const regexes = [ /\bt\(\s*['"`]([^'"`]+)['"`]\s*[,)\]]/g, /\bgetEnglishValue\(\s*['"`]([^'"`]+)['"`]\s*\)/g, ]; for (const regex of regexes) { let match: RegExpExecArray | null = null; while ((match = regex.exec(content)) !== null) { if (match[1]) keys.push(match[1]); } } return keys; } function isLikelyStaticTranslationKey(key: string): boolean { const trimmed = key.trim(); if (!trimmed) return false; // Dynamic template fragments cannot be validated as missing static keys. if (trimmed.includes('${')) return false; // Accept typical dotted key paths only. if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) return false; // Ignore placeholder examples like "..." that appear in help text. if (!/[A-Za-z0-9]/.test(trimmed)) return false; return true; } function isUiCodeFile(filePath: string): boolean { const ext = path.extname(filePath).toLowerCase(); return ext === '.tsx' || ext === '.jsx'; } function extractPotentialUiLiterals(content: string): string[] { const literals: string[] = []; // Text nodes in JSX, e.g. >Welcome back< const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*\n][^"'<>\n]*)\1/g; while ((match = attrRegex.exec(content)) !== null) { const text = match[2]?.replace(/\s+/g, ' ').trim(); if (!text) continue; literals.push(text); } // Simple JSX child expressions, e.g. >{isSaving ? 'Saving' : 'Save'}< const jsxExpressionRegex = />\s*\{([^{}\n][^{}]*?)\}\s*|===|!==|&&|\|\||::/.test(trimmed)) return true; if (/React\.|TouchEvent|MouseEvent|ChangeEvent|KeyboardEvent|SyntheticEvent/.test(trimmed)) return true; if (/^[0-9]+\s*[)&|]/.test(trimmed)) return true; if (/^[|><=+\-/*]+/.test(trimmed)) return true; if (/^[a-z0-9._/-]+$/i.test(trimmed) && !/\s/.test(trimmed)) return true; if (/^(use client|true|false|null|undefined)$/i.test(trimmed)) return true; if (/(className|onClick|href|src|aria-|data-)/.test(trimmed)) return true; if (/^[A-Za-z0-9_.]+\s*:\s*[A-Za-z0-9_.]+$/.test(trimmed)) return true; if (/\|/.test(trimmed) && /\b(void|Promise|string|number|boolean|any|unknown|never|null|undefined|Record|React)\b/.test(trimmed)) return true; return false; } function escapeForTsString(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/\r?\n/g, ' ') .trim(); } function escapeForQuotedString(value: string, quote: '\'' | '"'): string { const normalized = value .replace(/\\/g, '\\\\') .replace(/\r?\n/g, ' ') .trim(); return quote === '\'' ? normalized.replace(/'/g, "\\'") : normalized.replace(/"/g, '\\"'); } function hashText(input: string): string { let hash = 5381; for (let i = 0; i < input.length; i += 1) { hash = ((hash << 5) + hash) ^ input.charCodeAt(i); } return Math.abs(hash >>> 0).toString(16).padStart(8, '0'); } function toAutofixKey(text: string): string { return `autofix.k${hashText(text)}`; } function hasUseClientDirective(content: string): boolean { return USE_CLIENT_PREFIX_REGEX.test(content); } function isAutoFixCandidatePath(relPath: string): boolean { if (!relPath.startsWith('src/app/')) return false; if (relPath.startsWith('src/app/api/')) return false; if (relPath.startsWith('src/app/i18n/')) return false; // Portal component renders outside any React context provider — hooks like // useTranslation must not be injected here by the autofix. if (relPath.endsWith('components/toast/toastComponent.tsx')) return false; return true; } function hasUseTranslationImport(content: string): boolean { return /from\s+['\"][^'\"]*\/i18n\/useTranslation['\"];?/.test(content); } function hasTHook(content: string): boolean { return /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(/.test(content); } function hasExistingTUsage(content: string): boolean { return /\bt\(\s*['"`][^'"`]+['"`]\s*[,\)\]]/.test(content); } function validateAutoFixOutput(content: string): string | null { const duplicateImports = content.match(/import\s+\{\s*useTranslation\s*\}\s+from\s+['\"][^'\"]*\/i18n\/useTranslation['\"];?/g) ?? []; if (duplicateImports.length > 1) { return 'Unsafe autofix output: duplicate useTranslation import detected.'; } const functionPatterns = [ /(?:^|\n)\s*export\s+default\s+async\s+function\s+\w*\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*export\s+default\s+function\s+\w*\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*export\s+async\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*export\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*async\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*function\s+\w+\s*\([\s\S]*?\)\s*\{/gm, /(?:^|\n)\s*const\s+\w+\s*:\s*[^=\n]+\s*=\s*async\s*\([\s\S]*?\)\s*=>\s*\{/gm, /(?:^|\n)\s*const\s+\w+\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm, /(?:^|\n)\s*const\s+\w+\s*=\s*async\s*\([\s\S]*?\)\s*=>\s*\{/gm, /(?:^|\n)\s*const\s+\w+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm, ]; const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => { let depth = 1; let inSingleQuote = false; let inDoubleQuote = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; for (let index = bodyStart; index < source.length; index += 1) { const char = source[index]; const prev = index > 0 ? source[index - 1] : ''; const next = index + 1 < source.length ? source[index + 1] : ''; if (inLineComment) { if (char === '\n') inLineComment = false; continue; } if (inBlockComment) { if (prev === '*' && char === '/') inBlockComment = false; continue; } if (inSingleQuote) { if (char === '\'' && prev !== '\\') inSingleQuote = false; continue; } if (inDoubleQuote) { if (char === '"' && prev !== '\\') inDoubleQuote = false; continue; } if (inTemplate) { if (char === '`' && prev !== '\\') inTemplate = false; continue; } if (char === '/' && next === '/') { inLineComment = true; index += 1; continue; } if (char === '/' && next === '*') { inBlockComment = true; index += 1; continue; } if (char === '\'') { inSingleQuote = true; continue; } if (char === '"') { inDoubleQuote = true; continue; } if (char === '`') { inTemplate = true; continue; } if (char === '{') { depth += 1; continue; } if (char === '}') { depth -= 1; if (depth === 0) return index; } } return null; }; const processedBodyStarts = new Set(); for (const pattern of functionPatterns) { for (const match of content.matchAll(pattern)) { if (typeof match.index !== 'number') continue; const bodyStart = match.index + match[0].length; if (processedBodyStarts.has(bodyStart)) continue; processedBodyStarts.add(bodyStart); const bodyEnd = findFunctionBodyEnd(content, bodyStart); if (bodyEnd === null) continue; const bodyContent = content.slice(bodyStart, bodyEnd); const hookMatches = bodyContent.match(/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\)\s*;?/g) ?? []; if (hookMatches.length > 1) { return 'Unsafe autofix output: duplicate useTranslation hooks detected in a function scope.'; } } } return null; } function hasServerOnlySignals(content: string): string | null { if (/from\s+['\"]next\/headers['\"]/.test(content)) return 'uses next/headers'; if (/from\s+['\"]next\/server['\"]/.test(content)) return 'uses next/server'; if (/\bexport\s+const\s+metadata\b/.test(content)) return 'exports metadata'; if (/\bexport\s+async\s+function\s+generateMetadata\b/.test(content)) return 'uses generateMetadata'; if (/\bexport\s+default\s+async\s+function\b/.test(content)) return 'default component is async (server-style)'; return null; } function insertUseClientDirective(content: string): { content: string; added: boolean } { if (hasUseClientDirective(content)) return { content, added: false }; const match = content.match(LEADING_PREAMBLE_REGEX); const at = match ? match[0].length : 0; const before = content.slice(0, at); const after = content.slice(at); const spacer = before.endsWith('\n') || before.length === 0 ? '' : '\n'; const next = `${before}${spacer}'use client';\n\n${after}`; return { content: next, added: true }; } function ensureUseTranslation( content: string, filePathAbs: string, options: { forceConvertToClient?: boolean } = {} ): { content: string; addedImport: boolean; addedHook: boolean; reason?: string } { let next = content; if (!hasUseClientDirective(content)) { if (hasTHook(content) || hasExistingTUsage(content)) { return { content, addedImport: false, addedHook: false }; } if (!options.forceConvertToClient) { return { content, addedImport: false, addedHook: false, reason: "File is not a client component ('use client')." }; } const signal = hasServerOnlySignals(content); if (signal) { return { content, addedImport: false, addedHook: false, reason: `Force convert skipped: file appears server-only (${signal}).`, }; } next = insertUseClientDirective(content).content; } let addedImport = false; let addedHook = false; if (!hasUseTranslationImport(next)) { const fileDir = path.dirname(filePathAbs); const importTarget = path.join(process.cwd(), 'src', 'app', 'i18n', 'useTranslation'); let rel = path.relative(fileDir, importTarget).split(path.sep).join('/'); if (!rel.startsWith('.')) rel = `./${rel}`; const importLine = `import { useTranslation } from '${rel}';\n`; const useClientMatch = next.match(USE_CLIENT_PREFIX_REGEX); if (!useClientMatch || typeof useClientMatch.index !== 'number') { return { content, addedImport: false, addedHook: false, reason: 'Could not place import statement.' }; } const at = useClientMatch.index + useClientMatch[0].length; next = `${next.slice(0, at)}\n\n${importLine}${next.slice(at)}`; addedImport = true; } if (!hasTHook(next)) { const fnPatterns = [ /export\s+default\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/m, /function\s+\w+\s*\([\s\S]*?\)\s*\{/m, /const\s+\w+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/m, /const\s+\w+\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/m, ]; let inserted = false; for (const pattern of fnPatterns) { const match = next.match(pattern); if (!match || typeof match.index !== 'number') continue; const at = match.index + match[0].length; next = `${next.slice(0, at)}\n const { t } = useTranslation();${next.slice(at)}`; inserted = true; addedHook = true; break; } if (!inserted) { return { content, addedImport: false, addedHook: false, reason: 'Could not locate a component function to inject useTranslation().' }; } } return { content: next, addedImport, addedHook }; } function ensureUseTranslationHooksInComponents(content: string): { content: string; addedHooks: number } { if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(content)) { return { content, addedHooks: 0 }; } const componentPatterns = [ /export\s+default\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm, /export\s+function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm, /function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm, /const\s+[A-Z]\w*\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm, /const\s+[A-Z]\w*\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm, ]; const hookPrefixRegex = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\)\s*;?/; const insertPositions = new Set(); const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => { let depth = 1; let inSingleQuote = false; let inDoubleQuote = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; for (let index = bodyStart; index < source.length; index += 1) { const char = source[index]; const prev = index > 0 ? source[index - 1] : ''; const next = index + 1 < source.length ? source[index + 1] : ''; if (inLineComment) { if (char === '\n') inLineComment = false; continue; } if (inBlockComment) { if (prev === '*' && char === '/') inBlockComment = false; continue; } if (inSingleQuote) { if (char === '\'' && prev !== '\\') inSingleQuote = false; continue; } if (inDoubleQuote) { if (char === '"' && prev !== '\\') inDoubleQuote = false; continue; } if (inTemplate) { if (char === '`' && prev !== '\\') inTemplate = false; continue; } if (char === '/' && next === '/') { inLineComment = true; index += 1; continue; } if (char === '/' && next === '*') { inBlockComment = true; index += 1; continue; } if (char === '\'') { inSingleQuote = true; continue; } if (char === '"') { inDoubleQuote = true; continue; } if (char === '`') { inTemplate = true; continue; } if (char === '{') { depth += 1; continue; } if (char === '}') { depth -= 1; if (depth === 0) { return index; } } } return null; }; for (const pattern of componentPatterns) { for (const match of content.matchAll(pattern)) { if (typeof match.index !== 'number') continue; const bodyStart = match.index + match[0].length; const bodyEnd = findFunctionBodyEnd(content, bodyStart); if (bodyEnd === null) continue; const bodyContent = content.slice(bodyStart, bodyEnd); if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(bodyContent)) continue; const bodyPrefix = content.slice(bodyStart, bodyStart + 200); if (hookPrefixRegex.test(bodyPrefix)) continue; insertPositions.add(bodyStart); } } if (insertPositions.size === 0) { return { content, addedHooks: 0 }; } let next = content; const positions = Array.from(insertPositions).sort((a, b) => b - a); for (const position of positions) { next = `${next.slice(0, position)}\n const { t } = useTranslation();${next.slice(position)}`; } return { content: next, addedHooks: positions.length }; } function isIndexInsideStringOrComment(source: string, index: number): boolean { let inSingleQuote = false; let inDoubleQuote = false; let inTemplateLiteral = false; let inLineComment = false; let inBlockComment = false; for (let i = 0; i < source.length; i += 1) { if (i >= index) { return inSingleQuote || inDoubleQuote || inTemplateLiteral || inLineComment || inBlockComment; } const char = source[i]; const prev = i > 0 ? source[i - 1] : ''; const next = i + 1 < source.length ? source[i + 1] : ''; if (inLineComment) { if (char === '\n') { inLineComment = false; } continue; } if (inBlockComment) { if (prev === '*' && char === '/') { inBlockComment = false; } continue; } if (inSingleQuote) { if (char === '\'' && prev !== '\\') { inSingleQuote = false; } continue; } if (inDoubleQuote) { if (char === '"' && prev !== '\\') { inDoubleQuote = false; } continue; } if (inTemplateLiteral) { if (char === '`' && prev !== '\\') { inTemplateLiteral = false; } continue; } if (char === '/' && next === '/') { inLineComment = true; i += 1; continue; } if (char === '/' && next === '*') { inBlockComment = true; i += 1; continue; } if (char === '\'') { inSingleQuote = true; continue; } if (char === '"') { inDoubleQuote = true; continue; } if (char === '`') { inTemplateLiteral = true; } } return inSingleQuote || inDoubleQuote || inTemplateLiteral || inLineComment || inBlockComment; } function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map } { const keyValueMap = new Map(); let replacements = 0; const attrsPattern = TRANSLATABLE_ATTRIBUTES.map((a) => a.replace('-', '\\-')).join('|'); const attrRegex = new RegExp(`\\b(${attrsPattern})\\s*=\\s*(["'])([^"'{}\\n][^"'\\n]*)\\2`, 'g'); // Only rewrite inside JSX tag bodies, never in TS/JS function params or object literals. const next = content.replace(JSX_TAG_REGEX, (tag, tagOffset: number) => { if (isIndexInsideStringOrComment(content, tagOffset)) { return tag; } return tag.replace(attrRegex, (full, attrName: string, quote: string, captured: string) => { const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `${attrName}={t('${key}')}`; }); }); return { content: next, replacements, keyValueMap }; } function replaceJsxTextNodes(content: string): { content: string; replacements: number; keyValueMap: Map } { const keyValueMap = new Map(); let replacements = 0; const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s* { if (isIndexInsideStringOrComment(content, offset)) { return full; } const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `>{t('${key}')}<`; }); return { content: next, replacements, keyValueMap }; } function replaceSimpleExpressionLiterals(expression: string, keyValueMap: Map): { expression: string; replacements: number } { let replacements = 0; const directMatch = expression.match(/^\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*$/); if (directMatch?.[2]) { const text = directMatch[2].replace(/\s+/g, ' ').trim(); if (!shouldIgnoreLiteral(text)) { const key = toAutofixKey(text); keyValueMap.set(key, text); return { expression: `t('${key}')`, replacements: 1 }; } } const nextExpression = expression.replace(/(\?\?|\|\||\?|:)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\2/g, (full, operator: string, quote: string, captured: string) => { const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `${operator} t('${key}')`; }); return { expression: nextExpression, replacements }; } function replaceJsxExpressionLiterals(content: string): { content: string; replacements: number; keyValueMap: Map } { const keyValueMap = new Map(); let replacements = 0; const next = content.replace(/>\s*\{([^{}\n][^{}]*?)\}\s* { if (isIndexInsideStringOrComment(content, offset)) { return full; } const result = replaceSimpleExpressionLiterals(String(captured ?? ''), keyValueMap); replacements += result.replacements; return result.replacements > 0 ? `>{${result.expression}}<` : full; }); return { content: next, replacements, keyValueMap }; } function replaceCommonCallLiterals(content: string): { content: string; replacements: number; keyValueMap: Map } { const keyValueMap = new Map(); let replacements = 0; let next = content.replace(/\bwindow\.confirm\(\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*\)/g, (full, quote: string, captured: string, offset: number) => { if (isIndexInsideStringOrComment(content, offset)) { return full; } const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `window.confirm(t('${key}'))`; }); next = next.replace(/\bmessage\s*:\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g, (full, quote: string, captured: string, offset: number) => { if (isIndexInsideStringOrComment(next, offset)) { return full; } const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `message: t('${key}')`; }); next = next.replace(/\bmessage\s*:\s*([^,\n]+?)(\?\?|\|\|)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\3/g, (full, before: string, operator: string, quote: string, captured: string, offset: number) => { if (isIndexInsideStringOrComment(next, offset)) { return full; } const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); if (shouldIgnoreLiteral(text)) return full; const key = toAutofixKey(text); keyValueMap.set(key, text); replacements += 1; return `message: ${before}${operator} t('${key}')`; }); return { content: next, replacements, keyValueMap }; } function upsertAutofixNamespace(content: string, entries: Map): string { if (entries.size === 0) return content; const autofixBlockRegex = /\n(\s*)((?:"autofix"|autofix)\s*:\s*\{)([\s\S]*?)\n\1\},/m; const match = content.match(autofixBlockRegex); if (match) { const indent = match[1] ?? ' '; const blockHeader = match[2] ?? 'autofix: {'; const blockBody = match[3] ?? ''; const trimmedBlockBody = blockBody.replace(/\s*$/, ''); const trailingWhitespace = blockBody.slice(trimmedBlockBody.length); const existing = new Set(); for (const m of blockBody.matchAll(/\n\s{4}(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:\s*['"]/g)) { const keyName = m[1] || m[2]; if (keyName) { existing.add(`autofix.${keyName}`); } } const useQuotedKeys = /"[A-Za-z0-9_]+"\s*:/.test(blockBody) || /"autofix"\s*:/.test(blockHeader); const valueQuote: '\'' | '"' = /:\s*"/.test(blockBody) ? '"' : '\''; const entryLines = Array.from(entries.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([key, value]) => { const shortKey = key.replace(/^autofix\./, ''); const renderedKey = useQuotedKeys ? `"${shortKey}"` : shortKey; return `${indent} ${renderedKey}: ${valueQuote}${escapeForQuotedString(value, valueQuote)}${valueQuote},`; }); const missing = entryLines.filter((line) => { const m = line.match(/^\s+(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:/); if (!m) return false; const keyName = m[1] || m[2]; return keyName ? !existing.has(`autofix.${keyName}`) : false; }); if (missing.length === 0) return content; const normalizedBlockBody = trimmedBlockBody.length === 0 || trimmedBlockBody.endsWith(',') ? blockBody : `${trimmedBlockBody},${trailingWhitespace}`; const replacement = `\n${indent}${blockHeader}${normalizedBlockBody}\n${missing.join('\n')}\n${indent}},`; return content.replace(autofixBlockRegex, replacement); } const toastsKeyMatch = content.match(/\n(\s*)((?:"toasts"|toasts)\s*:)/m); if (toastsKeyMatch) { const indent = toastsKeyMatch[1] ?? ' '; const useQuotedKeys = /"toasts"\s*:/.test(toastsKeyMatch[2] ?? ''); const valueQuote: '\'' | '"' = /"[A-Za-z0-9_]+"\s*:\s*"/.test(content) ? '"' : '\''; const entryLines = Array.from(entries.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([key, value]) => { const shortKey = key.replace(/^autofix\./, ''); const renderedKey = useQuotedKeys ? `"${shortKey}"` : shortKey; return `${indent} ${renderedKey}: ${valueQuote}${escapeForQuotedString(value, valueQuote)}${valueQuote},`; }); const autofixHeader = useQuotedKeys ? '"autofix": {' : 'autofix: {'; const autofixBlock = `\n${indent}${autofixHeader}\n${entryLines.join('\n')}\n${indent}},\n`; return content.replace(/\n\s*(?:"toasts"|toasts)\s*:/m, `${autofixBlock}\n${indent}${toastsKeyMatch[2]}`); } return content; } function upsertAutofixType(content: string): string { // Already has a Record entry (any indentation) if (/\n\s*autofix:\s*Record;/.test(content)) { return content; } // Already has a proper block entry if (/\n\s*autofix:\s*\{/.test(content)) { return content; } const insertBefore = /\n\s{2}\/\/\s*─+\s*Notifications\s*\/\s*Toasts[\s\S]*?\n\s{2}toasts:/m; if (!insertBefore.test(content)) return content; const block = `\n autofix: Record;\n`; return content.replace(insertBefore, `${block}\n // ─── Notifications / Toasts ────────────────────────────\n toasts:`); } async function runAutoFix(options: AutoFixOptions = {}): Promise { const workspaceRoot = process.cwd(); const files: string[] = []; const counters = { dirs: 0 }; await walk(workspaceRoot, files, counters); const uiFiles = files.filter((f) => { const ext = path.extname(f).toLowerCase(); if (ext !== '.tsx' && ext !== '.jsx') return false; const rel = toRelativeWorkspacePath(f); if (!isAutoFixCandidatePath(rel)) return false; if (options.targetFiles && options.targetFiles.size > 0 && !options.targetFiles.has(rel)) return false; return true; }); const changedFiles: AutoFixChange[] = []; const skippedFiles: Array<{ file: string; reason: string }> = []; const createdEntries = new Map(); const debugEntries: AutoFixDebugEntry[] = []; if (options.targetFiles && options.targetFiles.size > 0) { const discoveredSet = new Set(files.map((f) => toRelativeWorkspacePath(f))); const eligibleSet = new Set(uiFiles.map((f) => toRelativeWorkspacePath(f))); for (const target of options.targetFiles) { if (!discoveredSet.has(target)) { skippedFiles.push({ file: target, reason: 'Target file was not found in workspace scan.' }); debugEntries.push({ file: target, status: 'skipped', beforeLiteralCount: 0, textReplacements: 0, attrReplacements: 0, afterLiteralCount: 0, reason: 'Target file was not found in workspace scan.', }); continue; } if (!eligibleSet.has(target)) { skippedFiles.push({ file: target, reason: 'Target file is not eligible for auto-fix (requires TSX/JSX under src/app).' }); debugEntries.push({ file: target, status: 'skipped', beforeLiteralCount: 0, textReplacements: 0, attrReplacements: 0, afterLiteralCount: 0, reason: 'Target file is not eligible for auto-fix (requires TSX/JSX under src/app).', }); } } } for (const absPath of uiFiles) { const relPath = toRelativeWorkspacePath(absPath); const raw = await fs.readFile(absPath, 'utf8'); const literalCheck = extractPotentialUiLiterals(raw).filter((text) => !shouldIgnoreLiteral(text)); if (literalCheck.length === 0) { debugEntries.push({ file: relPath, status: 'no-op', beforeLiteralCount: 0, textReplacements: 0, attrReplacements: 0, afterLiteralCount: 0, reason: 'No potential untranslated literals remained in this file.', }); continue; } const ensured = ensureUseTranslation(raw, absPath, { forceConvertToClient: options.forceConvertToClient }); if (ensured.reason) { skippedFiles.push({ file: relPath, reason: ensured.reason }); debugEntries.push({ file: relPath, status: 'skipped', beforeLiteralCount: literalCheck.length, textReplacements: 0, attrReplacements: 0, afterLiteralCount: literalCheck.length, reason: ensured.reason, }); continue; } const textReplaced = replaceJsxTextNodes(ensured.content); const exprReplaced = replaceJsxExpressionLiterals(textReplaced.content); const attrReplaced = replaceJsxAttributeLiterals(exprReplaced.content); const callReplaced = replaceCommonCallLiterals(attrReplaced.content); const ensuredHooks = ensureUseTranslationHooksInComponents(callReplaced.content); const totalReplacements = textReplaced.replacements + exprReplaced.replacements + attrReplaced.replacements + callReplaced.replacements; const afterLiteralCount = extractPotentialUiLiterals(ensuredHooks.content).filter((text) => !shouldIgnoreLiteral(text)).length; if (totalReplacements === 0) { const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).'; skippedFiles.push({ file: relPath, reason }); debugEntries.push({ file: relPath, status: 'skipped', beforeLiteralCount: literalCheck.length, textReplacements: 0, attrReplacements: 0, afterLiteralCount, reason, }); continue; } const validationIssue = validateAutoFixOutput(ensuredHooks.content); if (validationIssue) { skippedFiles.push({ file: relPath, reason: validationIssue }); debugEntries.push({ file: relPath, status: 'skipped', beforeLiteralCount: literalCheck.length, textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements, attrReplacements: attrReplaced.replacements, afterLiteralCount, reason: validationIssue, }); continue; } for (const [k, v] of textReplaced.keyValueMap.entries()) { createdEntries.set(k, v); } for (const [k, v] of exprReplaced.keyValueMap.entries()) { createdEntries.set(k, v); } for (const [k, v] of attrReplaced.keyValueMap.entries()) { createdEntries.set(k, v); } for (const [k, v] of callReplaced.keyValueMap.entries()) { createdEntries.set(k, v); } if (ensuredHooks.content !== raw) { await fs.writeFile(absPath, ensuredHooks.content, 'utf8'); changedFiles.push({ file: relPath, replacements: totalReplacements, addedImport: ensured.addedImport, addedHook: ensured.addedHook || ensuredHooks.addedHooks > 0, }); debugEntries.push({ file: relPath, status: 'changed', beforeLiteralCount: literalCheck.length, textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements, attrReplacements: attrReplaced.replacements, afterLiteralCount, }); } else { debugEntries.push({ file: relPath, status: 'no-op', beforeLiteralCount: literalCheck.length, textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements, attrReplacements: attrReplaced.replacements, afterLiteralCount, reason: 'Generated output matched input; no file write needed.', }); } } if (createdEntries.size > 0) { const enPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'en.ts'); const dePath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'de.ts'); const typesPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'types.ts'); const [enRaw, deRaw, typesRaw] = await Promise.all([ fs.readFile(enPath, 'utf8'), fs.readFile(dePath, 'utf8'), fs.readFile(typesPath, 'utf8'), ]); const nextEn = upsertAutofixNamespace(enRaw, createdEntries); const nextDe = upsertAutofixNamespace(deRaw, createdEntries); const nextTypes = upsertAutofixType(typesRaw); await Promise.all([ nextEn !== enRaw ? fs.writeFile(enPath, nextEn, 'utf8') : Promise.resolve(), nextDe !== deRaw ? fs.writeFile(dePath, nextDe, 'utf8') : Promise.resolve(), nextTypes !== typesRaw ? fs.writeFile(typesPath, nextTypes, 'utf8') : Promise.resolve(), ]); } return { changedFiles, skippedFiles, createdKeys: Array.from(createdEntries.keys()).sort(), debugEntries, }; } async function addMissingAutofixKeysToTranslations(missingKeys: string[]): Promise { const autofixMissing = missingKeys .map((key) => key.trim()) .filter((key) => /^autofix\.[A-Za-z0-9_]+$/.test(key)); if (autofixMissing.length === 0) { return { createdKeys: [] }; } const enFlat = flattenObject(en as unknown as Record); const deFlat = flattenObject(de as unknown as Record); const entriesForEn = new Map(); const entriesForDe = new Map(); for (const key of autofixMissing) { if (Object.prototype.hasOwnProperty.call(enFlat, key)) continue; const deValue = typeof deFlat[key] === 'string' ? String(deFlat[key]) : ''; const fallbackValue = key; entriesForEn.set(key, deValue || fallbackValue); entriesForDe.set(key, deValue || fallbackValue); } if (entriesForEn.size === 0) { return { createdKeys: [] }; } const enPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'en.ts'); const dePath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'de.ts'); const typesPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'types.ts'); const [enRaw, deRaw, typesRaw] = await Promise.all([ fs.readFile(enPath, 'utf8'), fs.readFile(dePath, 'utf8'), fs.readFile(typesPath, 'utf8'), ]); const nextEn = upsertAutofixNamespace(enRaw, entriesForEn); const nextDe = upsertAutofixNamespace(deRaw, entriesForDe); const nextTypes = upsertAutofixType(typesRaw); await Promise.all([ nextEn !== enRaw ? fs.writeFile(enPath, nextEn, 'utf8') : Promise.resolve(), nextDe !== deRaw ? fs.writeFile(dePath, nextDe, 'utf8') : Promise.resolve(), nextTypes !== typesRaw ? fs.writeFile(typesPath, nextTypes, 'utf8') : Promise.resolve(), ]); return { createdKeys: Array.from(entriesForEn.keys()).sort(), }; } function toRelativeWorkspacePath(absPath: string): string { const rel = path.relative(process.cwd(), absPath); return rel.split(path.sep).join('/'); } async function runWorkspaceScan(): Promise { const workspaceRoot = process.cwd(); const files: string[] = []; const counters = { dirs: 0 }; await walk(workspaceRoot, files, counters); const englishKeys = new Set(Object.keys(flattenObject(en as unknown as Record))); const uniqueUsedKeys = new Set(); const missingKeyFiles: MissingKeyMap = new Map(); const untranslatedLiteralFiles: Map> = new Map(); const autoFixEligibleFilesSet = new Set(); const autoFixForceConvertibleFilesSet = new Set(); let translationCallCount = 0; for (const filePath of files) { const raw = await fs.readFile(filePath, 'utf8'); const relativePath = toRelativeWorkspacePath(filePath); const usedKeys = extractTranslationKeys(raw); if (usedKeys.length > 0) { const staticKeys = usedKeys.filter(isLikelyStaticTranslationKey); translationCallCount += staticKeys.length; for (const key of staticKeys) { uniqueUsedKeys.add(key); if (!englishKeys.has(key)) { if (!missingKeyFiles.has(key)) { missingKeyFiles.set(key, new Set()); } missingKeyFiles.get(key)?.add(relativePath); } } } if (isUiCodeFile(filePath)) { const literals = extractPotentialUiLiterals(raw).filter((text) => !shouldIgnoreLiteral(text)); for (const text of literals) { if (!untranslatedLiteralFiles.has(text)) { untranslatedLiteralFiles.set(text, new Set()); } untranslatedLiteralFiles.get(text)?.add(relativePath); } if (literals.length > 0 && isAutoFixCandidatePath(relativePath) && (hasUseClientDirective(raw) || hasExistingTUsage(raw))) { autoFixEligibleFilesSet.add(relativePath); } if (literals.length > 0 && isAutoFixCandidatePath(relativePath)) { autoFixForceConvertibleFilesSet.add(relativePath); } } } const missingKeys = Array.from(missingKeyFiles.entries()) .map(([key, fileSet]) => ({ key, files: Array.from(fileSet).sort() })) .sort((a, b) => a.key.localeCompare(b.key)); const untranslatedLiterals = Array.from(untranslatedLiteralFiles.entries()) .map(([text, fileSet]) => ({ text, files: Array.from(fileSet).sort() })) .sort((a, b) => b.files.length - a.files.length || a.text.localeCompare(b.text)) .slice(0, 300); return { scannedFiles: files.length, scannedDirectories: counters.dirs, translationCallCount, uniqueKeyCount: uniqueUsedKeys.size, missingKeys, untranslatedLiterals, autoFixEligibleFiles: Array.from(autoFixEligibleFilesSet).sort((a, b) => a.localeCompare(b)), autoFixForceConvertibleFiles: Array.from(autoFixForceConvertibleFilesSet).sort((a, b) => a.localeCompare(b)), }; } export async function GET(request: Request) { const access = await requireAdminSession(request); if (!access.ok) return access.response; try { const result = await runWorkspaceScan(); return NextResponse.json({ ok: true, scannedAt: new Date().toISOString(), ...result, }); } catch (error) { console.error('Workspace i18n scan failed:', error); return NextResponse.json( { ok: false, message: 'Workspace scan failed.', }, { status: 500 } ); } } export async function POST(request: Request) { const access = await requireAdminSession(request); if (!access.ok) return access.response; try { let targetFilesSet: Set | undefined; let forceConvertToClient = false; let mode: 'autofix' | 'add-missing-keys' = 'autofix'; try { const body = await request.json(); if (body?.mode === 'add-missing-keys') { mode = 'add-missing-keys'; } const raw = Array.isArray(body?.targetFiles) ? body.targetFiles : []; forceConvertToClient = Boolean(body?.forceConvertToClient); const cleaned = raw .filter((v: unknown) => typeof v === 'string') .map((v: string) => v.trim()) .filter((v: string) => v.length > 0); if (cleaned.length > 0) { targetFilesSet = new Set(cleaned); } } catch { // allow empty body (fix all eligible files) } if (mode === 'add-missing-keys') { const preScan = await runWorkspaceScan(); const addResult = await addMissingAutofixKeysToTranslations(preScan.missingKeys.map((entry) => entry.key)); const scanResult = await runWorkspaceScan(); return NextResponse.json({ ok: true, mode, fixedAt: new Date().toISOString(), changedFileCount: 0, changedFiles: [], skippedFiles: [], autoFixDebug: [], createdKeyCount: addResult.createdKeys.length, createdKeys: addResult.createdKeys, ...scanResult, }); } const fixResult = await runAutoFix({ targetFiles: targetFilesSet, forceConvertToClient }); const scanResult = await runWorkspaceScan(); console.info('[i18n-autofix] Summary', { targetFileCount: targetFilesSet ? targetFilesSet.size : null, forceConvertToClient, changedFileCount: fixResult.changedFiles.length, skippedFileCount: fixResult.skippedFiles.length, createdKeyCount: fixResult.createdKeys.length, }); for (const entry of fixResult.debugEntries.slice(0, 200)) { console.info('[i18n-autofix:file]', entry); } return NextResponse.json({ ok: true, mode: 'autofix', fixedAt: new Date().toISOString(), forceConvertToClient, targetFilesApplied: targetFilesSet ? Array.from(targetFilesSet).sort() : null, changedFileCount: fixResult.changedFiles.length, changedFiles: fixResult.changedFiles, skippedFiles: fixResult.skippedFiles, autoFixDebug: fixResult.debugEntries, createdKeyCount: fixResult.createdKeys.length, createdKeys: fixResult.createdKeys, ...scanResult, }); } catch (error) { console.error('Workspace i18n auto-fix failed:', error); return NextResponse.json( { ok: false, message: 'Workspace auto-fix failed.', }, { status: 500 } ); } }