diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b366d3a..07152b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: i18n-vakt (alla språk kompletta, ingen hårdkodad UI-text) + run: pnpm --filter @app/mobile i18n:check + - name: Test run: pnpm test diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c8c269b..b9acd13 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -8,7 +8,8 @@ "android": "expo start --android", "ios": "expo start --ios", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "i18n:check": "node scripts/i18n-check.mjs" }, "dependencies": { "@app/analytics": "workspace:*", diff --git a/apps/mobile/scripts/i18n-check.mjs b/apps/mobile/scripts/i18n-check.mjs new file mode 100644 index 0000000..e8f2cda --- /dev/null +++ b/apps/mobile/scripts/i18n-check.mjs @@ -0,0 +1,191 @@ +#!/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; +// 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]; + if (val.includes("${")) continue; // ren interpolationsdel + if (!allowed(val)) hits.add(val); + } + } + 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.", +); diff --git a/apps/mobile/src/app/(auth)/login.tsx b/apps/mobile/src/app/(auth)/login.tsx index 9f84098..ef8d45d 100644 --- a/apps/mobile/src/app/(auth)/login.tsx +++ b/apps/mobile/src/app/(auth)/login.tsx @@ -51,7 +51,7 @@ export default function LoginScreen() { {BRAND.name} - Mindre svinn, mindre stress, mer kvar i plånboken + {t("auth.tagline")} { if (password.length < 8) { - setError("Lösenordet måste vara minst 8 tecken."); + setError(t("auth.passwordMin8")); return; } if (password !== confirmPassword) { - setError("Lösenorden matchar inte."); + setError(t("auth.passwordMismatch")); return; } setBusy(true); @@ -112,7 +112,7 @@ export default function RegisterScreen() { onChangeText={setPassword} /> @@ -249,7 +249,10 @@ export default function WhatToEatScreen() { {/* "Menade du …?" – stavningsförslag när sök gav noll träffar. */} {allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && ( submitSearch(query.data!.context.searchSuggestion!)}> - + )} diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx index bdafdeb..a8d6c28 100644 --- a/apps/mobile/src/app/(tabs)/plan.tsx +++ b/apps/mobile/src/app/(tabs)/plan.tsx @@ -92,10 +92,7 @@ export default function PlanScreen() { }, []); // Veckan börjar ALLTID på måndag (mån–sön), oavsett vilken dag det är idag. const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]); - const weekStart = useMemo( - () => addDays(thisMonday, weekOffset * 7), - [thisMonday, weekOffset], - ); + const weekStart = useMemo(() => addDays(thisMonday, weekOffset * 7), [thisMonday, weekOffset]); const weekStartKey = toKey(weekStart); const weekDays = useMemo( () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), @@ -116,7 +113,11 @@ export default function PlanScreen() { const toggleSlot = (dayIdx: number, meal: string) => setMealSlots((prev) => prev.map((meals, i) => - i !== dayIdx ? meals : meals.includes(meal) ? meals.filter((m) => m !== meal) : [...meals, meal], + i !== dayIdx + ? meals + : meals.includes(meal) + ? meals.filter((m) => m !== meal) + : [...meals, meal], ), ); @@ -362,10 +363,18 @@ export default function PlanScreen() { {weekDays.map((d, i) => ( - {cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))} + {cap( + new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format( + d, + ), + )} {(["breakfast", "lunch", "dinner"] as const).map((meal) => ( @@ -509,7 +518,7 @@ export default function PlanScreen() { ) : null}