import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; const i18nNamespaces = [ "common", "auth", "navigation", "dashboard", "clients", "projects", "tasks", "calendar", "finance", "journal", "chat", "settings", "portal", "status", "validation", "api", "analytics", "business", ]; const requiredDynamicKeys = [ ...i18nNamespaces.map((namespace) => `settings.languageDetail.namespaces.${namespace}`), ...["light", "dark", "system"].flatMap((mode) => [ `settings.appearance.theme.${mode}.label`, `settings.appearance.theme.${mode}.description`, ]), ...["all", "draft", "active", "archived"].map((status) => `settings.languages.filters.${status}`), ...["draft", "active", "archived", "test"].map((status) => `settings.languages.status.${status}`), ...["ltr", "rtl"].map((direction) => `settings.languageNew.direction.${direction}`), ...["activate", "default", "archive"].flatMap((action) => [ `settings.languageDetail.messages.${action}`, `settings.languageDetail.dialog.${action}.title`, `settings.languageDetail.dialog.${action}.description`, `settings.languageDetail.dialog.${action}.confirm`, ]), ...["defaultSettings", "fallbacks", "userPreferences", "clients", "portalInvitations", "contentTranslations"] .map((key) => `settings.languageDetail.usage.${key}`), ]; const localeRoots = { tr: path.join(process.cwd(), "locales", "tr"), en: path.join(process.cwd(), "locales", "en"), }; const authRoutes = [ path.join(process.cwd(), "app", "login"), path.join(process.cwd(), "app", "register"), path.join(process.cwd(), "app", "forgot-password"), path.join(process.cwd(), "app", "reset-password"), ]; export function runReleaseGate() { const tr = collectLocaleKeys(localeRoots.tr); const en = collectLocaleKeys(localeRoots.en); const missingInEn = [...tr.keys()].filter((key) => !en.has(key)).sort(); const missingInTr = [...en.keys()].filter((key) => !tr.has(key)).sort(); const interpolationMismatches = []; for (const key of [...new Set([...tr.keys(), ...en.keys()])].sort()) { const trVars = interpolationVariables(tr.get(key) ?? ""); const enVars = interpolationVariables(en.get(key) ?? ""); if (trVars.join(",") !== enVars.join(",")) { interpolationMismatches.push({ key, tr: trVars, en: enVars }); } } const authLanguageSelectors = scanAuthLanguageSelectors(); const missingSettingsProviders = scanSettingsNamespaceProviders(); const missingDynamicKeys = scanRequiredDynamicKeys(tr, en); const missingUsedLiteralKeys = scanUsedLiteralTranslationKeys(tr, en); const hardCodedSamples = scanHardCodedUserText(); const failures = { missingInEn, missingInTr, interpolationMismatches, authLanguageSelectors, missingSettingsProviders, missingDynamicKeys, missingUsedLiteralKeys, }; const ok = Object.values(failures).every((rows) => rows.length === 0); const summary = { ok, catalog: { trKeys: tr.size, enKeys: en.size, missingInEn: missingInEn.slice(0, 25), missingInTr: missingInTr.slice(0, 25), interpolationMismatches: interpolationMismatches.slice(0, 25), }, authLanguageSelectors, missingSettingsProviders, missingDynamicKeys, missingUsedLiteralKeys: missingUsedLiteralKeys.slice(0, 50), hardCodedSamples, notes: [ "hardCodedSamples bilgilendirme amaçlıdır; false-positive üretmemesi için gate'i fail ettirmez.", "Browser smoke, RTL ve payload ölçümleri için docs/self-hosted-redesign/release/i18n-self-host-upgrade.md dosyasındaki kabul adımlarını takip et.", ], }; console.log(JSON.stringify(summary, null, 2)); if (!ok) process.exitCode = 1; return summary; } function scanRequiredDynamicKeys(tr, en) { return requiredDynamicKeys .filter((key) => !tr.has(key) || !en.has(key)) .map((key) => ({ key, tr: tr.has(key), en: en.has(key) })); } function scanUsedLiteralTranslationKeys(tr, en) { const roots = ["app", "components"].map((root) => path.join(process.cwd(), root)); const used = new Map(); const pattern = /\bt\(\s*["']([a-zA-Z][\w]*\.[^"'`{}]+)["']/g; for (const file of roots.flatMap((root) => (fs.existsSync(root) ? listFiles(root) : []))) { if (!/\.(tsx?|jsx?)$/.test(file)) continue; const source = fs.readFileSync(file, "utf8"); let match; while ((match = pattern.exec(source))) { const key = match[1]; const files = used.get(key) ?? new Set(); files.add(path.relative(process.cwd(), file)); used.set(key, files); } } return [...used.entries()] .filter(([key]) => !tr.has(key) || !en.has(key)) .map(([key, files]) => ({ key, tr: tr.has(key), en: en.has(key), files: [...files].slice(0, 5) })) .sort((a, b) => a.key.localeCompare(b.key)); } function scanSettingsNamespaceProviders() { const requiredFiles = [ { file: path.join(process.cwd(), "app", "(dashboard)", "layout.tsx"), label: "admin dashboard shell" }, { file: path.join(process.cwd(), "app", "portal", "layout.tsx"), label: "portal shell" }, ]; return requiredFiles.flatMap(({ file, label }) => { if (!fs.existsSync(file)) return [{ file: path.relative(process.cwd(), file), label, reason: "missing file" }]; const source = fs.readFileSync(file, "utf8"); if (!source.includes("getClientI18nPayload")) { return [{ file: path.relative(process.cwd(), file), label, reason: "missing getClientI18nPayload" }]; } if (!source.includes('"settings"') && !source.includes("'settings'")) { return [{ file: path.relative(process.cwd(), file), label, reason: "settings namespace is not included" }]; } return []; }); } function collectLocaleKeys(root) { const files = listFiles(root).filter((file) => file.endsWith(".ts")); const entries = new Map(); for (const file of files) { const source = fs.readFileSync(file, "utf8"); if (path.basename(file) === "common.ts") { collectObjectKeys(source, "common", entries); continue; } if (path.basename(file) !== "index.ts") continue; let namespace = null; for (const line of source.split("\n")) { const namespaceMatch = line.match(/^ ([a-zA-Z_][\w]*): \{/); if (namespaceMatch) { namespace = namespaceMatch[1]; continue; } if (namespace && line.startsWith(" },")) { namespace = null; continue; } const keyMatch = line.match(/^\s+"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/); if (namespace && keyMatch) { entries.set(`${namespace}.${keyMatch[1]}`, keyMatch[2]); } } } return entries; } function collectObjectKeys(source, namespace, entries) { const keyPattern = /"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/g; let match; while ((match = keyPattern.exec(source))) { entries.set(`${namespace}.${match[1]}`, match[2]); } } function interpolationVariables(value) { return [...new Set([...value.matchAll(/\{([a-zA-Z][\w.-]*)(?:[,}])/g)].map((match) => match[1]))].sort(); } function scanAuthLanguageSelectors() { const patterns = [ /LocaleSelector/, /LanguageSelector/, /neta_locale/, /setLanguagePreference/, /updateLanguagePreference/, ]; return authRoutes .flatMap((route) => (fs.existsSync(route) ? listFiles(route) : [])) .filter((file) => /\.(tsx?|jsx?)$/.test(file)) .flatMap((file) => { const source = fs.readFileSync(file, "utf8"); return patterns .filter((pattern) => pattern.test(source)) .map((pattern) => ({ file: path.relative(process.cwd(), file), pattern: String(pattern) })); }); } function scanHardCodedUserText() { const roots = ["app", "components"].map((root) => path.join(process.cwd(), root)); const pattern = /[A-Za-zÇĞİÖŞÜçğıöşü]{2,}\s+[A-Za-zÇĞİÖŞÜçğıöşü]{2,}/; const allowed = [ "className", "import ", "from ", "aria-hidden", "export ", "type ", "interface ", "console.", ]; const samples = []; for (const file of roots.flatMap((root) => (fs.existsSync(root) ? listFiles(root) : []))) { if (!/\.(tsx?|jsx?)$/.test(file)) continue; const lines = fs.readFileSync(file, "utf8").split("\n"); for (const [index, line] of lines.entries()) { if (samples.length >= 50) return samples; if (allowed.some((token) => line.includes(token))) continue; if (pattern.test(line) && /[">']/.test(line)) { samples.push({ file: path.relative(process.cwd(), file), line: index + 1, sample: line.trim().slice(0, 180), }); } } } return samples; } function listFiles(root) { const result = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") continue; const fullPath = path.join(root, entry.name); if (entry.isDirectory()) { result.push(...listFiles(fullPath)); } else { result.push(fullPath); } } return result; } if (import.meta.url === pathToFileURL(process.argv[1]).href) { runReleaseGate(); }