profit-planet-frontend/src/app/api/i18n/scan/route.ts
DeathKaioken 4074ea4eee Bibelbumser
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 23:48:09 +02:00

1374 lines
45 KiB
TypeScript

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<string, Set<string>>;
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<string>;
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*</g;
let match: RegExpExecArray | null = null;
while ((match = jsxTextRegex.exec(content)) !== null) {
const text = match[1]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
// Simple quoted JSX attribute literals, e.g. placeholder="Search..."
const attrRegex = /\b(?:placeholder|title|alt|aria-label)\s*=\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*</g;
while ((match = jsxExpressionRegex.exec(content)) !== null) {
const expression = String(match[1] ?? '');
for (const text of extractSimpleExpressionLiterals(expression)) {
literals.push(text);
}
}
const confirmRegex = /\bwindow\.confirm\(\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*\)/g;
while ((match = confirmRegex.exec(content)) !== null) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
const messageRegex = /\bmessage\s*:\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g;
while ((match = messageRegex.exec(content)) !== null) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
return literals;
}
function extractSimpleExpressionLiterals(expression: string): string[] {
const literals: string[] = [];
const directMatch = expression.match(/^\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*$/);
if (directMatch?.[2]) {
literals.push(directMatch[2].replace(/\s+/g, ' ').trim());
}
for (const match of expression.matchAll(/(?:\?\?|\|\||\?|:)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g)) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
return literals;
}
function shouldIgnoreLiteral(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return true;
if (trimmed.length < 3) return true;
// Ignore non-user-facing technical fragments
if (/^(https?:|\/|\.|#|\{|\}|\[|\]|\(|\)|;|,|\+|-|\*|=|&&|\|\|)/.test(trimmed)) return true;
if (!/[A-Za-zÀ-ÿ]/.test(trimmed)) return true;
if (/^&[a-z]+;$/i.test(trimmed)) return true;
if (/[{}()[\];]|=>|===|!==|&&|\|\||::/.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<number>();
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<number>();
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<string, string> } {
const keyValueMap = new Map<string, string>();
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<string, string> } {
const keyValueMap = new Map<string, string>();
let replacements = 0;
const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*</g;
const next = content.replace(jsxTextRegex, (full, 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 `>{t('${key}')}<`;
});
return { content: next, replacements, keyValueMap };
}
function replaceSimpleExpressionLiterals(expression: string, keyValueMap: Map<string, string>): { 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<string, string> } {
const keyValueMap = new Map<string, string>();
let replacements = 0;
const next = content.replace(/>\s*\{([^{}\n][^{}]*?)\}\s*</g, (full, captured: string, offset: number) => {
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<string, string> } {
const keyValueMap = new Map<string, string>();
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, string>): 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<string>();
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<string,string> entry (any indentation)
if (/\n\s*autofix:\s*Record<string,\s*string>;/.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<string, string>;\n`;
return content.replace(insertBefore, `${block}\n // ─── Notifications / Toasts ────────────────────────────\n toasts:`);
}
async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult> {
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<string, string>();
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<AddMissingKeysResult> {
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<string, unknown>);
const deFlat = flattenObject(de as unknown as Record<string, unknown>);
const entriesForEn = new Map<string, string>();
const entriesForDe = new Map<string, string>();
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<ScanResult> {
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<string, unknown>)));
const uniqueUsedKeys = new Set<string>();
const missingKeyFiles: MissingKeyMap = new Map();
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();
const autoFixEligibleFilesSet = new Set<string>();
const autoFixForceConvertibleFilesSet = new Set<string>();
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<string>());
}
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<string>());
}
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<string> | 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 }
);
}
}