18388bb31c
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
(blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).
Co-Authored-By: Claude <noreply@anthropic.com>
206 lines
8.6 KiB
JavaScript
206 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* i18n-vakt: fäller bygget om översättningarna inte är kompletta eller om det
|
||
* finns hårdkodad text i UI:t. Kör: `pnpm --filter @app/mobile i18n:check`.
|
||
*
|
||
* Poängen (långsiktigt): när ett nytt språk läggs till behöver ingen leta
|
||
* skärm för skärm – vakten listar exakt vilka nycklar som saknas, och hindrar
|
||
* att ny hårdkodad svenska smyger in. Wire:a in den i CI så kan bygget aldrig
|
||
* gå live med halvfärdig lokalisering.
|
||
*
|
||
* FEL (exit 1):
|
||
* 1. Ett språk saknar en nyckel som svenska (källan) har → faller till svenska.
|
||
* 2. En använd t("nyckel") saknas i svenska.
|
||
* 3. Hårdkodad användartext (svenska diakriter, eller råsträng i label/
|
||
* placeholder/title/text/Alert/setError som inte går via t()).
|
||
* VARNING (exit 0): språk med extra nycklar som svenska saknar (t.ex. pl-plural).
|
||
*
|
||
* Undanta enskild rad med kommentaren // i18n-ignore
|
||
*/
|
||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { join, relative } from "node:path";
|
||
|
||
const ROOT = process.cwd(); // apps/mobile
|
||
const LOCALES_DIR = join(ROOT, "src/locales");
|
||
const SRC_DIR = join(ROOT, "src");
|
||
const SOURCE_LANG = "sv";
|
||
|
||
// Filer/mönster som INTE är UI-text: locale-data och enhetsvokabulär per språk.
|
||
const SKIP_FILES = [/src\/locales\//, /src\/lib\/i18n\.ts$/, /src\/lib\/units\.ts$/];
|
||
// Råsträngar som är legitima (varumärke, format, tekniska värden).
|
||
const ALLOW = [
|
||
/^[\s\p{Emoji}\p{P}\p{S}·•⏳⭐★☆✍️🍽️🥣🥗🥐🍎🍰🧺🧠🛒👥⚙️❄️🗄️🥕→↔↩︎▸▾⚠︎%–—-]+$/u, // bara symboler/emoji
|
||
/^https?:\/\//,
|
||
/Cibello/,
|
||
];
|
||
// Ord som är identiska/universella på alla målspråk – ingen översättning behövs.
|
||
const ALLOW_EXACT = new Set(["OK", "kcal", "g", "kg", "ml", "dl", "l", "st"]);
|
||
|
||
/** Ta bort kommentarer men BEHÅLL radnummer (ersätt med blanksteg, ej borttag). */
|
||
function stripComments(src) {
|
||
const keepNL = (m) => m.replace(/[^\n]/g, " ");
|
||
return src
|
||
.replace(/\{\/\*[\s\S]*?\*\/\}/g, keepNL) // {/* JSX-kommentar */}
|
||
.replace(/\/\*[\s\S]*?\*\//g, keepNL) // /* blockkommentar */
|
||
.split("\n")
|
||
.map((line) => line.replace(/([^:"'`\\])\/\/.*$/, "$1").replace(/^\s*\/\/.*$/, "")) // // radslut (ej URL)
|
||
.join("\n");
|
||
}
|
||
|
||
function walk(dir) {
|
||
const out = [];
|
||
for (const name of readdirSync(dir)) {
|
||
const p = join(dir, name);
|
||
const st = statSync(p);
|
||
if (st.isDirectory()) out.push(...walk(p));
|
||
else if (/\.(tsx|ts)$/.test(p)) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function loadLocales() {
|
||
const langs = readdirSync(LOCALES_DIR).filter((d) =>
|
||
statSync(join(LOCALES_DIR, d)).isDirectory(),
|
||
);
|
||
const map = {};
|
||
for (const lang of langs) {
|
||
map[lang] = JSON.parse(readFileSync(join(LOCALES_DIR, lang, "common.json"), "utf8"));
|
||
}
|
||
return map;
|
||
}
|
||
|
||
const errors = [];
|
||
const warnings = [];
|
||
|
||
// ---- 1. Nyckel-paritet ----------------------------------------------------
|
||
const locales = loadLocales();
|
||
const svKeys = new Set(Object.keys(locales[SOURCE_LANG]));
|
||
for (const [lang, data] of Object.entries(locales)) {
|
||
if (lang === SOURCE_LANG) continue;
|
||
const keys = new Set(Object.keys(data));
|
||
const missing = [...svKeys].filter((k) => !keys.has(k));
|
||
const extra = [...keys].filter((k) => !svKeys.has(k));
|
||
if (missing.length)
|
||
errors.push(
|
||
`[${lang}] saknar ${missing.length} nyckel/nycklar som ${SOURCE_LANG} har (faller till svenska):\n ` +
|
||
missing.slice(0, 30).join("\n ") +
|
||
(missing.length > 30 ? `\n …(+${missing.length - 30})` : ""),
|
||
);
|
||
if (extra.length)
|
||
warnings.push(
|
||
`[${lang}] har ${extra.length} extra nyckel/nycklar (oanvända?): ${extra.slice(0, 8).join(", ")}${extra.length > 8 ? "…" : ""}`,
|
||
);
|
||
}
|
||
|
||
// ---- 2 & 3. Använda nycklar + hårdkodad text ------------------------------
|
||
const files = walk(SRC_DIR).filter((f) => !SKIP_FILES.some((re) => re.test(f)));
|
||
const usedStatic = new Set();
|
||
let dynamicUses = 0;
|
||
const DIACRITIC = /[åäöÅÄÖ]/;
|
||
// t("nyckel") / t('nyckel') / t(`nyckel`) – statisk nyckel utan ${}
|
||
const T_STATIC = /\bt\(\s*["'`]([^"'`$}{]+)["'`]/g;
|
||
// t(`...${...}`) – dynamisk nyckel, hoppa men räkna
|
||
const T_DYNAMIC = /\bt\(\s*`[^`]*\$\{/g;
|
||
// användarnära positioner med RÅSTRÄNG (inte {t(...)})
|
||
const POS =
|
||
/(?:label|placeholder|title|text|header|message)\s*[=:]\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
||
const ALERT =
|
||
/(?:Alert\.alert|setError|EmptyState\s+text=)\s*\(?\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
||
// prop={`…`} – mall-literal i JSX-UTTRYCK. POS ser inte {…}, så no-diakritisk
|
||
// svenska (»Skapat av ${x}«) slank förr igenom. Granska den literala resten.
|
||
const PROP_EXPR =
|
||
/(?:label|placeholder|title|text|header|message)\s*=\s*\{\s*`((?:[^`\\]|\\.)*)`\s*\}/g;
|
||
// JSX-text: >Svensk text< (fångar diakriter i textnoder)
|
||
const JSXTEXT = />\s*([^<>{}\n]*[åäöÅÄÖ][^<>{}\n]*?)\s*</g;
|
||
|
||
const allowed = (s) => ALLOW_EXACT.has(s.trim()) || ALLOW.some((re) => re.test(s.trim()));
|
||
|
||
for (const file of files) {
|
||
const rel = relative(ROOT, file);
|
||
const raw = readFileSync(file, "utf8");
|
||
const rawLines = raw.split("\n");
|
||
const src = stripComments(raw);
|
||
let m;
|
||
while ((m = T_STATIC.exec(src))) usedStatic.add(m[1]);
|
||
dynamicUses += (src.match(T_DYNAMIC) || []).length;
|
||
|
||
const lines = src.split("\n");
|
||
lines.forEach((line, i) => {
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return;
|
||
// i18n-ignore läses på RÅraden (kommentaren strippas ju bort i `line`).
|
||
if (/i18n-ignore/.test(rawLines[i] ?? "")) return;
|
||
const hits = new Set();
|
||
for (const re of [POS, ALERT]) {
|
||
re.lastIndex = 0;
|
||
let mm;
|
||
while ((mm = re.exec(line))) {
|
||
const val = mm[2];
|
||
// Mallsträngar granskas nu på den LITERALA resten (blind fläck förr –
|
||
// `label={\`Skapat av ${namn}\`}` slank igenom hela). Strippa ${…} och
|
||
// se om ord blir kvar; bara emoji/tal/enheter kvar = ok.
|
||
const bare = val.replace(/\$\{[^}]*\}/g, " ");
|
||
if (!/[A-Za-zÅÄÖåäö]{2,}/.test(bare)) continue;
|
||
if (!allowed(bare)) hits.add(val);
|
||
}
|
||
}
|
||
PROP_EXPR.lastIndex = 0;
|
||
let pm;
|
||
while ((pm = PROP_EXPR.exec(line))) {
|
||
const bare = pm[1].replace(/\$\{[^}]*\}/g, " ");
|
||
if (/[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(bare)) hits.add(pm[1]);
|
||
}
|
||
JSXTEXT.lastIndex = 0;
|
||
let jm;
|
||
while ((jm = JSXTEXT.exec(line))) {
|
||
const val = jm[1];
|
||
if (val.includes("{") || val.includes("`")) continue;
|
||
if (!allowed(val)) hits.add(val);
|
||
}
|
||
// diakriter i råsträngar OCH mallsträngar (även med ${…} – svensk text runt
|
||
// interpolationen behöver ändå gå via t() med parametrar).
|
||
if (DIACRITIC.test(line)) {
|
||
const strs = line.match(/"[^"\n]*[åäöÅÄÖ][^"\n]*"|`[^`\n]*[åäöÅÄÖ][^`\n]*`/g) || [];
|
||
for (const s of strs) {
|
||
const val = s.slice(1, -1);
|
||
const bare = val.replace(/\$\{[^}]*\}/g, ""); // ta bort interpolation, kolla resten
|
||
if (DIACRITIC.test(bare) && !allowed(val)) hits.add(val);
|
||
}
|
||
}
|
||
// fristående sträng-/mallliteral på egen rad (multi-rad call-argument, t.ex.
|
||
// `Bild ${count} tagen`) – en fras (innehåller mellanslag) är nästan alltid UI-text.
|
||
const st = trimmed.match(/^(["'`])((?:(?!\1).)*)\1,?$/);
|
||
if (st) {
|
||
const val = st[2];
|
||
const bare = val.replace(/\$\{[^}]*\}/g, "");
|
||
if (/\s/.test(bare) && /[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(val)) hits.add(val);
|
||
}
|
||
for (const h of hits) errors.push(`[hårdkodad] ${rel}:${i + 1} «${h.slice(0, 60)}»`);
|
||
});
|
||
}
|
||
|
||
// använda nycklar måste finnas i svenska (plural: tillåt _one/_other-varianter)
|
||
const missingUsed = [...usedStatic].filter(
|
||
(k) => !svKeys.has(k) && !svKeys.has(`${k}_other`) && !svKeys.has(`${k}_one`),
|
||
);
|
||
if (missingUsed.length)
|
||
errors.push(
|
||
`Använda t()-nycklar som saknas i ${SOURCE_LANG}:\n ` + missingUsed.join("\n "),
|
||
);
|
||
|
||
// ---- Rapport --------------------------------------------------------------
|
||
const langCount = Object.keys(locales).length;
|
||
console.log(
|
||
`i18n-vakt: ${langCount} språk, ${svKeys.size} nycklar, ${usedStatic.size} statiska t()-anrop, ${dynamicUses} dynamiska.`,
|
||
);
|
||
for (const w of warnings) console.log("VARNING " + w);
|
||
if (errors.length) {
|
||
console.error(`\n✗ ${errors.length} fel:\n`);
|
||
for (const e of errors) console.error(" " + e);
|
||
console.error("\nÅtgärda ovan (eller markera en rad med // i18n-ignore om den är avsiktlig).");
|
||
process.exit(1);
|
||
}
|
||
console.log(
|
||
"✓ i18n komplett: alla språk har alla nycklar, inga saknade nycklar, ingen hårdkodad UI-text.",
|
||
);
|