feat(i18n): add mobile localization release gates
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs";
|
||||
|
||||
const registry = [
|
||||
{ entityType: "project", table: "projects", fields: { name: "name", description: "description", coverImageAlt: "cover_image_alt" } },
|
||||
{ entityType: "planning_section", table: "project_planning_sections", fields: { title: "title", content: "content" } },
|
||||
{ entityType: "task", table: "tasks", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "branding", table: "instance_branding", fields: { portalWelcome: "portal_welcome_text", portalFooter: "portal_footer_text" } },
|
||||
{ entityType: "calendar_event", table: "calendar_events", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "client", table: "clients", fields: { notes: "notes" } },
|
||||
{ entityType: "client_activity", table: "client_activities", fields: { title: "title", content: "content" } },
|
||||
{ entityType: "finance_transaction", table: "finance_transactions", fields: { category: "category", description: "description" } },
|
||||
{ entityType: "journal_entry", table: "journal_entries", fields: { moodLabel: "mood_label", note: "note" } },
|
||||
{ entityType: "chat_session", table: "chat_sessions", fields: { title: "title" } },
|
||||
{ entityType: "proposal", table: "proposals", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "subscription", table: "subscriptions", fields: { name: "name", category: "category" } },
|
||||
];
|
||||
|
||||
export function runBackfill(argv = process.argv.slice(2)) {
|
||||
const write = argv.includes("--write");
|
||||
const startedAt = Date.now();
|
||||
const config = ensureDataLayout();
|
||||
const sqlite = new Database(config.databasePath);
|
||||
|
||||
try {
|
||||
applySqlitePragmas(sqlite);
|
||||
assertTable(sqlite, "content_translations");
|
||||
const defaultLocale = getDefaultLocale(sqlite);
|
||||
const insert = sqlite.prepare(`
|
||||
insert into content_translations (entity_type, entity_id, field, locale, value, created_at, updated_at)
|
||||
values (@entityType, @entityId, @field, @locale, @value, @now, @now)
|
||||
on conflict(entity_type, entity_id, field, locale) do nothing
|
||||
`);
|
||||
const existing = sqlite.prepare(`
|
||||
select 1
|
||||
from content_translations
|
||||
where entity_type = ? and entity_id = ? and field = ? and locale = ?
|
||||
limit 1
|
||||
`);
|
||||
const summary = {
|
||||
dryRun: !write,
|
||||
databasePath: config.databasePath,
|
||||
defaultLocale,
|
||||
planned: 0,
|
||||
inserted: 0,
|
||||
skippedTables: [],
|
||||
byEntity: {},
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
const apply = sqlite.transaction(() => {
|
||||
for (const item of registry) {
|
||||
if (!tableExists(sqlite, item.table)) {
|
||||
summary.skippedTables.push(item.table);
|
||||
continue;
|
||||
}
|
||||
const columns = Object.values(item.fields);
|
||||
const rows = sqlite
|
||||
.prepare(`select id, ${columns.map((column) => `"${column}"`).join(", ")} from "${item.table}"`)
|
||||
.all();
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [field, column] of Object.entries(item.fields)) {
|
||||
const value = normalizeText(row[column]);
|
||||
if (!value) continue;
|
||||
if (existing.get(item.entityType, String(row.id), field, defaultLocale)) continue;
|
||||
|
||||
summary.planned += 1;
|
||||
summary.byEntity[item.entityType] = (summary.byEntity[item.entityType] ?? 0) + 1;
|
||||
if (write) {
|
||||
const result = insert.run({
|
||||
entityType: item.entityType,
|
||||
entityId: String(row.id),
|
||||
field,
|
||||
locale: defaultLocale,
|
||||
value,
|
||||
now: Date.now(),
|
||||
});
|
||||
summary.inserted += result.changes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
apply();
|
||||
summary.durationMs = Date.now() - startedAt;
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultLocale(sqlite) {
|
||||
if (!tableExists(sqlite, "instance_i18n_settings")) return "tr";
|
||||
return sqlite
|
||||
.prepare("select default_locale from instance_i18n_settings where key = 'default'")
|
||||
.get()
|
||||
?.default_locale ?? "tr";
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function assertTable(sqlite, table) {
|
||||
if (!tableExists(sqlite, table)) {
|
||||
throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(sqlite, table) {
|
||||
return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runBackfill();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs";
|
||||
|
||||
const registry = {
|
||||
branding: { table: "instance_branding", fields: ["portalWelcome", "portalFooter"] },
|
||||
calendar_event: { table: "calendar_events", fields: ["title", "description"] },
|
||||
chat_session: { table: "chat_sessions", fields: ["title"] },
|
||||
client: { table: "clients", fields: ["notes"] },
|
||||
client_activity: { table: "client_activities", fields: ["title", "content"] },
|
||||
finance_transaction: { table: "finance_transactions", fields: ["category", "description"] },
|
||||
journal_entry: { table: "journal_entries", fields: ["moodLabel", "note"] },
|
||||
planning_section: { table: "project_planning_sections", fields: ["title", "content"] },
|
||||
project: { table: "projects", fields: ["name", "description", "coverImageAlt"] },
|
||||
proposal: { table: "proposals", fields: ["title", "description"] },
|
||||
subscription: { table: "subscriptions", fields: ["name", "category"] },
|
||||
task: { table: "tasks", fields: ["title", "description"] },
|
||||
};
|
||||
|
||||
export function runIntegrityCheck(argv = process.argv.slice(2)) {
|
||||
const fix = argv.includes("--fix");
|
||||
const failOnIssue = !argv.includes("--report-only");
|
||||
const config = ensureDataLayout();
|
||||
const sqlite = new Database(config.databasePath);
|
||||
|
||||
try {
|
||||
applySqlitePragmas(sqlite);
|
||||
for (const table of ["instance_locales", "content_translations"]) {
|
||||
assertTable(sqlite, table);
|
||||
}
|
||||
|
||||
const locales = sqlite.prepare("select code, status from instance_locales").all();
|
||||
const knownLocales = new Set(locales.map((locale) => locale.code));
|
||||
const activeLocales = new Set(locales.filter((locale) => locale.status === "active").map((locale) => locale.code));
|
||||
const issues = {
|
||||
invalidUserPreferences: tableExists(sqlite, "user_preferences")
|
||||
? sqlite.prepare(`select owner_user_id as id, language as locale from user_preferences where language is not null and language not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
invalidClientPortalLocales: tableExists(sqlite, "clients")
|
||||
? sqlite.prepare(`select id, portal_locale as locale from clients where portal_locale is not null and portal_locale not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
invalidInvitationLocales: tableExists(sqlite, "portal_invitations")
|
||||
? sqlite.prepare(`select id, locale from portal_invitations where locale is not null and locale not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
unknownTranslationLocales: sqlite.prepare(`select id, entity_type as entityType, entity_id as entityId, field, locale from content_translations where locale not in (${placeholders([...knownLocales])})`).all([...knownLocales]),
|
||||
unsupportedTranslationFields: [],
|
||||
orphanTranslations: [],
|
||||
};
|
||||
|
||||
const translations = sqlite
|
||||
.prepare("select id, entity_type as entityType, entity_id as entityId, field from content_translations")
|
||||
.all();
|
||||
for (const row of translations) {
|
||||
const definition = registry[row.entityType];
|
||||
if (!definition) {
|
||||
issues.orphanTranslations.push(row);
|
||||
continue;
|
||||
}
|
||||
if (!definition.fields.includes(row.field)) {
|
||||
issues.unsupportedTranslationFields.push(row);
|
||||
continue;
|
||||
}
|
||||
if (!tableExists(sqlite, definition.table)) {
|
||||
issues.orphanTranslations.push(row);
|
||||
continue;
|
||||
}
|
||||
const exists = sqlite.prepare(`select 1 from "${definition.table}" where id = ? limit 1`).get(row.entityId);
|
||||
if (!exists) issues.orphanTranslations.push(row);
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
if (fix) {
|
||||
const remove = sqlite.prepare("delete from content_translations where id = ?");
|
||||
const ids = uniqueIds([...issues.orphanTranslations, ...issues.unsupportedTranslationFields]);
|
||||
const transaction = sqlite.transaction(() => {
|
||||
for (const id of ids) fixed += remove.run(id).changes;
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
const counts = Object.fromEntries(
|
||||
Object.entries(issues).map(([key, rows]) => [key, rows.length]),
|
||||
);
|
||||
const totalIssues = Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
const summary = {
|
||||
ok: fix ? totalIssues === fixed : totalIssues === 0,
|
||||
databasePath: config.databasePath,
|
||||
fix,
|
||||
fixed,
|
||||
counts,
|
||||
samples: Object.fromEntries(Object.entries(issues).map(([key, rows]) => [key, rows.slice(0, 10)])),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (failOnIssue && totalIssues > 0 && !fix) process.exitCode = 1;
|
||||
return summary;
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function placeholders(values) {
|
||||
return values.length ? values.map(() => "?").join(",") : "''";
|
||||
}
|
||||
|
||||
function uniqueIds(rows) {
|
||||
return [...new Set(rows.map((row) => row.id))];
|
||||
}
|
||||
|
||||
function assertTable(sqlite, table) {
|
||||
if (!tableExists(sqlite, table)) {
|
||||
throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(sqlite, table) {
|
||||
return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runIntegrityCheck();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
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 hardCodedSamples = scanHardCodedUserText();
|
||||
const failures = {
|
||||
missingInEn,
|
||||
missingInTr,
|
||||
interpolationMismatches,
|
||||
authLanguageSelectors,
|
||||
};
|
||||
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,
|
||||
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 collectLocaleKeys(root) {
|
||||
const files = listFiles(root).filter((file) => file.endsWith(".ts"));
|
||||
const entries = new Map();
|
||||
const keyPattern = /"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/g;
|
||||
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
let match;
|
||||
while ((match = keyPattern.exec(source))) {
|
||||
entries.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user