#!/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* 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.", );