Localization system
UI locales versus content locales, the parity gate, and what adding a locale actually requires.
ErmisAI ships ten UI locales and ten content locales. They are the same ten codes, they are declared in two different tuples, and they mean two different things.
- UI locale — the language of the chrome: navigation, buttons, labels, legal pages, emails.
Drawn from
supportedUiLocales(src/lib/i18n/config.ts:1-12). - Content locale — the language of AI-generated editorial output: synthesized stories, composed
drafts, refinements, inline completions. Drawn from
supportedContentLocales(src/lib/i18n/config.ts:21-32).
The two are independent settings. A tenant can run an English dashboard while producing Greek
articles. The split is documented in the helper docblocks themselves — getServerUiLocale() versus
getServerContentLocale(userId) (src/lib/i18n/server.ts:23-46), where the UI helper carries an
explicit instruction in code: "Do NOT use this to drive AI generation, prompt selection, or any
machine-output content."
The two locale lists
| codes, in declaration order | |
|---|---|
supportedUiLocales | en, el, pl, it, es, pt, sv, da, nb, fi |
supportedContentLocales | en, el, it, es, pt, pl, sv, da, nb, fi |
routing.locales | en, el, pl, it, es, pt, sv, da, nb, fi |
Same membership, different order — it/es/pt precede pl in the content list. The order is not
cosmetic-only: it is the order of <option> elements in the "Article language" select in Settings →
Newsroom.
defaultUiLocale is 'en' (src/lib/i18n/config.ts:35). UiLocale and ContentLocale are derived
from the tuples (:13, :33).
BCP-47 mapping comes from an exhaustive switch, resolveIntlLocale
(src/lib/i18n/config.ts:111-134):
| locale | tag | locale | tag |
|---|---|---|---|
en | en-US | pt | pt-PT |
el | el-GR | sv | sv-SE |
pl | pl-PL | da | da-DK |
it | it-IT | nb | nb-NO |
es | es-ES | fi | fi-FI |
This is what <html lang> and every hreflang entry use. nb is Bokmål; there is no nn. pt is
European Portuguese and es is peninsular Spanish — enforced by the voice briefs, not by code
(scripts/i18n/translate-messages.config.ts).
Membership narrowing is done by two hand-written literal-comparison guards, isSupportedUiLocale
(src/lib/i18n/config.ts:65-78) and isSupportedContentLocale (:80-93) — not by .includes()
on the tuple. Adding a locale to a tuple without adding it to the matching guard produces a type
that claims the locale is supported and a runtime that normalizes it back to English.
normalizeUiLocale / normalizeContentLocale (:95-103) trim, lowercase, and fall back to the
default. They never throw. getDefaultContentLocaleForUiLocale (:105-109) is
normalizeContentLocale(normalizeUiLocale(x)) — "the content locale follows the UI locale when that
code is also a content locale".
UI locale resolution
Three tiers, implemented in src/i18n/request.ts:77-96:
- The
[locale]URL segment on the public site, orsetRequestLocale(profile.uiLocale)in the tenant layout. - The
ermis-ui-localecookie. routing.defaultLocale—'en'.
The cookie tier exists for non-[locale] routes that never call setRequestLocale, so a user's last
choice is still honoured instead of hard-falling to English.
Routing config (src/i18n/routing.ts:5-14):
export const routing = defineRouting({
locales: ['en', 'el', 'pl', 'it', 'es', 'pt', 'sv', 'da', 'nb', 'fi'],
defaultLocale: 'en',
localePrefix: 'always',
localeCookie: {
name: uiLocaleCookieName,
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
},
})localePrefix: 'always' means every public URL carries a locale segment. /pricing is redirected to
/en/pricing by the proxy (src/proxy.ts:309-323), which runs the next-intl middleware only for
routes that are not API, admin, app, or auth.
timeZone: 'Europe/Athens' is hard-coded for every locale (src/i18n/request.ts:84,94).
Per surface
| surface | how the locale is set |
|---|---|
Public marketing (src/app/[locale]/layout.tsx) | setRequestLocale(locale) from the URL segment; generateStaticParams() emits all ten (:11-13); an unknown segment notFound()s (:23-27). |
Tenant app (src/app/(tenant)/app/layout.tsx:80) | setRequestLocale(newsroomProfile.uiLocale) — the DB profile, not the URL. |
Admin (src/app/(internal)/admin/layout.tsx) | Never calls setRequestLocale; it only uses getMessages() / getTranslations(), so it falls through to the ermis-ui-locale cookie. There is no language switcher in /admin. |
The public header renders a <select> of uppercase locale codes — EN EL PL IT ES PT SV DA NB FI — not language names (src/components/marketing/LocaleSwitcher.tsx:35-39). Its accessible name
is nav.languageMenu = "Change language". Changing it calls
router.replace(pathname, { locale: next }) — same path, new prefix (:29-31).
The client-side holder
AppLocaleProvider (src/components/i18n/AppLocaleProvider.tsx) is seeded from the DB profile by
the tenant layout. On every state change it writes document.documentElement.lang,
document.documentElement.dataset.contentLocale, and the ermis-ui-locale cookie (:105-109).
useAppLocales() exposes { uiLocale, contentLocale, intlUiLocale, setLocales }.
useUiMessages() returns the app.* subtree and throws when that namespace is absent
(:163-174).
The English deep-merge fallback
This is the single most important runtime behaviour of the message layer. Every non-English catalog
is deep-merged over messages/en.json before it reaches next-intl
(deepMergeMessages, src/i18n/request.ts:17-31; applied at :53-62):
async function loadMessages(locale: string): Promise<Messages> {
const localeMessages = await loadRawMessages(locale)
if (locale === routing.defaultLocale) {
return localeMessages
}
const baseMessages = await loadRawMessages(routing.defaultLocale)
return deepMergeMessages(baseMessages, localeMessages)
}A key a locale has not translated renders the English string, never a raw key path. That is why
adding a string to messages/en.json is safe to ship before the other nine catch up, and it is why a
missing translation is invisible in QA.
The email path does not deep-merge. createEmailTranslator(locale) loads exactly one catalog
and builds a standalone createTranslator({ locale, messages, namespace: 'emails' }) because
templates render outside the next-intl request context (src/emails/i18n.ts:28-37). A key present in
en.json but missing from nb.json renders English everywhere in the app and hits next-intl's
missing-message behaviour in a Norwegian email.
Which locale each transactional mail uses (src/lib/email/send.ts) — all required, no defaults:
| locale source | |
|---|---|
| Welcome | signup-time UI locale (no newsroom exists yet) |
| Upsell | newsroom content locale |
| Erasure confirmation | erased newsroom's UI locale, captured pre-purge |
| Team invite | inviter's newsroom content locale |
Client bundles get an allowlisted subset
Server components resolve the full catalog through src/i18n/request.ts. Client components get only
the namespaces their layout declares, via pickMessages
(src/lib/i18n/client-messages.ts:15-31):
| layout | allowlist |
|---|---|
[locale] (public) | common, nav, footer, auth, cookieConsent, home, pricing, animations |
(tenant)/app | common, nav, footer, auth, cookieConsent, admin, app |
(internal)/admin | common, nav, footer, auth, cookieConsent, admin, app |
The tenant list includes admin because the editorial board at /app/editorial embeds the shared
review-queue components, which read the admin.* tree.
Add a client-side useTranslations('X') under a layout without adding 'X' to that layout's list
and the component renders raw message keys in production. tests/client-message-scoping.vitest.ts
catches this: it walks the real module-import graph from every route entry file under each segment,
descends into every module reachable from a 'use client' boundary, collects the top-level
namespaces read via useTranslations('<ns>'), and fails with the exact gap. useMessages() cannot
be analysed statically and is reviewed by hand — the note is in the test at :120-123.
Content locale: storage, snapshotting, and reads
| where | column / key | default |
|---|---|---|
| Tenant profile | tenantNewsrooms.contentLocale (src/lib/db/schema.ts:679) | SQL literal 'en' |
| Tenant profile | tenantNewsrooms.uiLocale (:677) | defaultUiLocale |
| Draft snapshot | editorialStoryDrafts.contentLocale (:569) | SQL literal 'en' |
| Platform default | ERMIS_CONTENT_LOCALE (src/lib/i18n/config.ts:37-61) | unset → 'en' |
Both DB columns default to the literal 'en' rather than the env-derived platform default. The
comment at schema.ts:565-569 gives the reason: a column DEFAULT baked from ERMIS_CONTENT_LOCALE
would make the generated Drizzle schema drift with whichever environment generated it. Application
code always writes contentLocale explicitly.
The draft snapshot is authoritative for in-flight work. editorialStoryDrafts.contentLocale is
written at draft creation, so a mid-flight change to the newsroom setting does not switch the
language of a story already in progress. Reads that must use the snapshot rather than the live
profile:
- chat / compose / refine —
detail.draft.contentLocale(src/app/api/stories/[storyId]/chat/route.ts:571-586) - inline completion —
normalizeContentLocale(detail.draft.contentLocale)(src/app/api/stories/[storyId]/completion/route.ts:285-288)
The zod contract is newsroomBaseProfileSchema with uiLocale: z.enum(supportedUiLocales) and
contentLocale: z.enum(supportedContentLocales) (src/lib/contracts/newsroom.ts:80-81). The PUT
route lowercases and trims before the enum check
(src/app/api/newsroom/profile/route.ts:81-88), and upsertNewsroomProfile normalizes both on write
(src/lib/platform/newsroom-preferences.ts:1277-1280).
Signup inheritance
createDefaultProfile (src/lib/platform/newsroom-preferences.ts:789-824) reads the
ermis-ui-locale cookie via readSignupUiLocaleHint (:773-787) and seeds:
uiLocale = cookieLocale ?? defaultUiLocalecontentLocale = getDefaultContentLocaleForUiLocale(cookieLocale) ?? defaultContentLocale
So signing up at /el/sign-up produces a Greek UI and a Greek article language. The two can be
decoupled afterwards.
This depends on the cookie surviving. applyMarketingEdgeCache deletes the set-cookie header on
every cacheable marketing route (src/proxy.ts:205-215) because Vercel will not cache a response
that carries Set-Cookie. Sign-in, sign-up, and waitlist are deliberately excluded from
isCacheableMarketingRoute for exactly this reason. Adding one of them to the cacheable list would
silently reset every non-English signup to English.
Content locale is part of feed-cache identity
getFeedScopeKey (src/lib/services/rss-aggregation.ts:3438-3456) appends |locale:<code> for any
non-default content locale, so two tenants with identical source sets but different content locales
never share synthesized stories. The default locale keeps the historical un-suffixed key so
already-persisted caches stay valid — which also means that if the platform default is ever changed,
content cached under the old default is silently reinterpreted as the new one.
Output language is a setting, not a detection
The synthesis prompt states it literally. editorialRequirements.languageRule
(src/lib/services/rss-aggregation.ts:2846):
Write the entire article - headline, body, and summary - in {targetLanguageName}. The output language is the newsroom's configured content language (a setting), NOT the source language: translate and rewrite source material into {targetLanguageName} wherever needed.
The system instructions repeat it (:2981). targetLanguageName is the English display name of
the content locale, getContentLanguageName(contentLocale, 'en') — the comment at :2884-2886
explains why: models follow "write in Greek" more reliably than a bare locale code.
The cluster stage is the exception. Cluster refinement is instructed to "Write the eventSummary
and category in the dominant language already present in the source material."
(rss-aggregation.ts:2576). Only the synthesis stage is locale-pinned. Do not describe the whole
pipeline as setting-driven.
Nothing verifies the produced article is actually in the configured locale. The synthesis quality
gate is length-only — one bounded regeneration when the body is under MIN_SYNTHESIZED_BODY_WORDS
(rss-aggregation.ts:3033-3058).
Per-content-locale AI assets
None of this lives in messages/*.json. All of it is hand-written per content locale, in code.
| asset | file | enforcement |
|---|---|---|
| Lyra compose persona (2 versions each) | src/lib/ai/prompts/story-compose.ts:16-285 | as const only — silent English fallback |
| Compose prompt phrases | story-compose.ts:289-353 | satisfies Record<ContentLocale, …> |
| "unspecified category" word | story-compose.ts:355-366 | satisfies Record<ContentLocale, string> |
| Lyra refinement persona (2 versions each) | src/lib/ai/prompts/story-refinement.ts:11-242 | as const only — silent English fallback |
| Refinement prompt phrases | story-refinement.ts:246-377 | satisfies |
| First-party-voice directive | src/lib/ai/prompts/first-party-voice.ts:10-21 | satisfies Record<ContentLocale, string> |
| Inline-completion system lines | src/app/api/stories/[storyId]/completion/route.ts:57-128 | satisfies Record<ContentLocale, readonly string[]> |
| Placeholder draft text | src/lib/stories/draft-state.ts:3-84 | satisfies |
| Suggested prompt chips | src/lib/ui/suggested-story-prompts.ts:10 | typed Record<ContentLocale, readonly string[]> |
| Category labels (56 entries) | src/lib/i18n/categories.ts:13-812 | Partial<Record<UiLocale, string>> — silent English fallback |
composeTemplateVersionsByLocale (story-compose.ts:285) and refinementTemplateVersionsByLocale
(story-refinement.ts:242) are plain as const with no satisfies. Both resolvers guard with
contentLocale in <map> ? <map>[contentLocale] : <map>.en
(story-compose.ts:429, story-refinement.ts:406), so a missing persona is a silent quality
regression, not a build error and not a runtime throw. Parity-gate tests 2 and 3 exist solely to
catch that hole.
Category labels have the same shape of problem: pickCategoryLabel is
labels[locale] ?? labels.en ?? '' (categories.ts:928-929), so a new locale gets English category
names until all 56 entries are filled in.
The suggested-prompt chips in the story workspace are written in the content locale rather than
the UI locale, because they are sent verbatim to the model
(src/lib/ui/suggested-story-prompts.ts).
The parity gate
tests/locale-parity.vitest.ts — 39 cases in four describe blocks. It runs in the normal
pnpm test suite and fails CI.
messages/<locale>.json key coverage (:36-64). For each of the nine non-en UI locales: the
file must exist, and every dotted key path collected from en.json must be present. The failure
message tells you to run pnpm i18n:translate <locale>.
Lyra compose persona per content locale (:66-94). Calls resolveStoryComposePrompt for every
content locale and asserts that for non-en the prompt does not start with
'You are Lyra by ErmisAI' — the tell-tale of falling through to the English persona.
Lyra refinement persona per content locale (:96-117). The same assertion against
resolveStoryRefinementPrompt.
Category label coverage per UI locale (:119-144). Every id from listCategoryFilterIds() must
return a label that is neither empty nor the raw id, in every UI locale.
What it does not check:
- Extra keys. It is a subset test in one direction only — keys a locale has that
en.jsondoes not are never flagged. - Array item counts.
collectKeystreats an array as a single leaf (:13-24—Array.isArray(value)short-circuits to[prefix]), so a locale whose array has fewer items than English still passes. - English labels in a non-English locale. A category label byte-identical to English is
explicitly accepted; the comment at
:132-136names the intentional anglicisms (Cybersecurity, Esports, LGBTQ+, True Crime, Wrestling, Cannabis). completionSystemLinesByLocale, which is caught at compile time by itssatisfiesconstraint instead.- Placeholder integrity across locales, translation quality, or whether
supportedUiLocalesandsupportedContentLocalesstill have identical membership.
pnpm i18n:translate
| script | command |
|---|---|
i18n:translate | tsx scripts/i18n/translate-messages.ts |
i18n:translate:dry | tsx scripts/i18n/translate-messages.ts --dry |
pnpm i18n:translate el # translate new/changed leaves
pnpm i18n:translate:dry el # report only, write nothing
pnpm i18n:translate el --force # re-translate every leaf
pnpm i18n:translate el --seed # record source hashes against an existing hand-translated file
pnpm i18n:translate el --only emails # scope the diff, seed and purge to one subtree
pnpm i18n:translate el --from en # override the source locale (default: en)The script shells out to the local claude CLI as a subprocess, not the Anthropic API — it
reuses your Claude Code auth and needs no ANTHROPIC_API_KEY
(scripts/i18n/translate-messages.ts:348-412). The spawn is
claude -p --system-prompt <s> --tools "" --no-session-persistence --output-format text. A missing
binary produces an explicit "Install Claude Code … and run claude once to authenticate" error
(:385-392).
How it decides what to translate
Both files are flattened to dotted.path → string; arrays flatten with numeric indices
(partners.types.items.0). A non-string leaf throws — "next-intl message files must be
string-only" (:176-195).
The state file is messages/.translation-state.json, git-tracked, currently 2,251 entries for
each of the nine non-en locales:
{
"el": {
"common.siteName": {
"sourceHash": "9d4c15ac53a4873efa8e4bfcc317258b72bc05c873af8cf74d06116637c4296f",
"translatedAt": "2026-07-10T15:16:34.429Z"
}
}
}A leaf is re-translated when mode === 'force', the source SHA-256 changed, or the path is
missing from the target (:566-571). That last condition is the recovery path: a key that is absent
from a message file is always re-fetched even when the state file claims it was already translated.
State entries whose path no longer exists in the source are purged (:577-585).
Guarantees worth knowing
- Batching. 50 leaves per CLI call (
BATCH_SIZE,:64), 6-minute per-call timeout (CLAUDE_CALL_TIMEOUT_MS,:73). - Voice anchor. 25 already-translated leaves are sampled from the existing target file and injected into every user prompt, so hand-tuned register propagates forward.
- Placeholder validation. The set of
/\{[a-zA-Z_][a-zA-Z0-9_]*\}/gmatches must be identical between source and translation. Otherwise the leaf is skipped, not written, and reported underSkipped paths(:702-712). - Batch failure → per-leaf retry. A batch that fails JSON parsing is retried one leaf at a time;
residual failures set
process.exitCode = 1(:665-690). - Incremental persistence. Target file and state file are rewritten after every successful batch,
so a crash loses at most one batch (
:652-656). - Stable ordering. Output is rebuilt by walking the source structure (
buildNode,:212-233), so key and array order always matchmessages/en.jsonand diffs stay readable. A leaf missing from the model response falls back to the English source string rather than being dropped. - Validation before running. The target must be in
routing.localesand have aMessagesLocaleConfigentry, or the script exits 1 with an explicit message (:145-163).
Per-locale configuration
scripts/i18n/translate-messages.config.ts holds a MessagesLocaleConfig for nine locales —
el, it, es, pt, sv, da, nb, fi, pl. There is deliberately no en entry; en is the source. Each
config carries:
export type MessagesLocaleConfig = {
displayName: string
voiceBrief: string
glossary: Record<string, string>
preserveAnglicisms: string[]
}baseRules (:26-43) are the eight universal hard rules prepended to every batch: preserve
placeholders byte-for-byte, preserve embedded code and identifiers, preserve inline tags, preserve
brand names, output JSON only, preserve the exact key shape, string values only, and emit valid JSON
with typographic quotes rather than escaped ASCII ones.
The pipeline is explicitly best-effort. scripts/i18n/README.md states it: "Always review the diff
before committing. The pipeline is a productivity tool, not publication-ready output."
Adding a locale
The canonical runbook is scripts/i18n/ADD-A-LOCALE.md (ten numbered steps plus a paste-ready agent
prompt). What the type system and the parity gate actually force:
Routing. Add the code to routing.locales in src/i18n/routing.ts.
Config. In src/lib/i18n/config.ts: add to supportedUiLocales, to isSupportedUiLocale, and —
if it is also a content locale — to supportedContentLocales and isSupportedContentLocale. Then
add a case to resolveIntlLocale and to getContentArticleLabel; both are exhaustive switches with
no default, so omitting either is a compile error.
Translator config. Add a MessagesLocaleConfig entry to
scripts/i18n/translate-messages.config.ts with a voice brief, glossary, and preserved-anglicism
list. The script refuses to run without one.
Typecheck. Run pnpm typecheck before translating. Failures here are almost always a
Record<'en' | 'el', T> shape added since the last locale. The fix pattern is widening to
Partial<Record<UiLocale, T>> with an ?? copy.en! fallback
(scripts/i18n/ADD-A-LOCALE.md:117-134).
Messages. Run pnpm i18n:translate <locale>, then review the diff by hand — placeholders, brand
names, glossary terms, and string length.
Content-locale assets (only if it is a content locale). Hand-written entries in
src/lib/ai/prompts/story-compose.ts, story-refinement.ts, first-party-voice.ts; a branch in
completionSystemLinesByLocale in src/app/api/stories/[storyId]/completion/route.ts; a placeholder
entry in src/lib/stories/draft-state.ts; chips in src/lib/ui/suggested-story-prompts.ts; and
labels for all 56 entries in src/lib/i18n/categories.ts.
Verify. pnpm test for the parity and client-scoping gates, then pnpm build.
The satisfies Record<ContentLocale, …> maps break the build when a locale is missing. The two
persona maps and the category registry do not — those are the ones to check by hand, and the ones
parity tests 2, 3, and 4 cover.
Two sections of scripts/i18n/ADD-A-LOCALE.md are stale and should not be followed. Step 3 tells you
to maintain nav.languageNames; that key exists in all ten message files but nothing in src/
reads it — the public switcher renders raw uppercase codes and the in-app picker uses
Intl.DisplayNames. The "Content locale considerations" section (:174-187) claims the prompt files
have only en and el entries plus a Polish placeholder; all ten content locales are hand-written
now, and the parity gate asserts it.
Configuration reference
| name | where | default | effect |
|---|---|---|---|
ERMIS_CONTENT_LOCALE | env, read at module load (src/lib/i18n/config.ts:42) | unset → 'en' | Platform default content locale, used for the global/admin feed scope and any consumer with no tenant contentLocale. Trimmed and lowercased. An unsupported value falls back to 'en' and writes a stderr warning. Documented at README.md:66. |
ermis-ui-locale | cookie (src/lib/i18n/config.ts:63) | — | UI locale carrier. 1-year max-age, SameSite=Lax, Path=/. |
tenantNewsrooms.uiLocale | DB (schema.ts:677) | 'en' | Authoritative UI locale for the authenticated app. |
tenantNewsrooms.contentLocale | DB (schema.ts:679) | 'en' | Authoritative article language per tenant. Overrides ERMIS_CONTENT_LOCALE. |
editorialStoryDrafts.contentLocale | DB (schema.ts:569) | 'en' | Per-draft snapshot; wins over the live profile for chat, compose, and completion. |
ERMIS_PROMPT_VERSION_STORY_COMPOSE | env (story-compose.ts:434) | unset | Pins a compose prompt template version. |
ERMIS_PROMPT_VERSION_STORY_REFINEMENT | env (story-refinement.ts:411) | unset | Pins a refinement prompt template version. |
The locale list itself is not configurable — it is compile-time, spread across the routing tuple, the two config tuples, and the two literal guards.
The stderr warning for an unsupported ERMIS_CONTENT_LOCALE reads:
[i18n] ERMIS_CONTENT_LOCALE="xx" is not a supported content locale; falling back to "en"Traps
defaultContentLocale is computed once at module load. It is a module-level const
(src/lib/i18n/config.ts:61), so changing ERMIS_CONTENT_LOCALE requires a redeploy. config.ts is
also imported by client components, and non-NEXT_PUBLIC_ env vars are not in the browser bundle —
so defaultContentLocale evaluates to 'en' on the client regardless of the server value. This only
surfaces in the fallback branch of normalizeContentLocale when it is called client-side with an
unrecognized value.
ERMIS_CONTENT_LOCALE is not neutralized in tests. tests/setup.env.ts deletes 15 ambient env
keys but not this one, so a developer with it set in .env changes defaultContentLocale under
Vitest and gets different results from CI.
{language} in feed cards is not ICU. app.feed.card.needsFirstDraft and
app.feed.card.noArticleSaved are substituted with a plain
String.replace('{language}', …) in src/components/features/feed/StoryCard.tsx:49-59, not by
next-intl interpolation. The value is getContentLanguageName(contentLocale, uiLocale) — the
lower-cased Intl.DisplayNames name of the content locale rendered in the UI locale
(src/lib/i18n/config.ts:179-183). Renaming that placeholder does not fail the translator's
placeholder check in any useful way, because the check only compares source and translation.
getLocalizedCategoryLabel is typed UiLocale (src/lib/i18n/categories.ts:982-985) but is
called with a ContentLocale from the compose and refinement prompt builders
(story-compose.ts:459, story-refinement.ts:430). It typechecks only because the two unions are
currently identical. Diverging the lists breaks this.
Category search collation is el or en only. NewsroomConfigurator.tsx:315 computes
categorySearchLocale = categoryLocale === 'el' ? 'el' : 'en', so Polish, Swedish, and the rest sort
under English collation. The comment above it claiming that only en and el have category labels
is stale — all ten do.
The deterministic compose fallback is en/el only. src/lib/services/story-compose.ts
branches on contentLocale === 'el' throughout, and textMostlyMatchesContentLocale (:246-264) is
a Greek-versus-Latin script heuristic: for any locale other than 'en' it returns
greek >= latin, so correct Polish, Italian, Spanish, Portuguese, Swedish, Danish, Norwegian, or
Finnish text is judged "wrong language" and replaced with a generic English headline. This path runs
only when the enableAiCompose flag is off, but it is wrong for eight of the ten content locales
when it does.
The claimed oxlint guard does not exist. docs/i18n-system.md asserts an oxlint rule banning
next-intl imports in AI paths. .oxlintrc.json contains no next-intl reference at all; its only
no-restricted-imports rule targets date libraries. The invariant — chrome translation never drives
model output — is held by convention, and it currently holds: grepping next-intl under
src/lib/ai, src/app/api/stories, and src/app/api/admin/review returns nothing. Treat it as a
review checklist item, not an enforced rule.
Non-English copy is longer. messages/en.json is about 160 KB; messages/el.json is about
250 KB. docs/i18n-system.md:445 advises reserving roughly 30-40% extra horizontal space for
non-English locales, or truncating with tooltips.
hreflang and the sitemap
buildAlternateLanguages(pathname) (src/lib/marketing/metadata.ts:104-122) emits one entry per
locale keyed by the full BCP-47 tag from resolveIntlLocale, plus an x-default pointing at the
en URL. Every public marketing page carries the full map, and src/app/sitemap.ts emits each page
once per locale with the same alternates block.
Interface language and article language
The same split, from the newsroom's side.
Scripts, quality gates and CI
Where the parity and client-scoping gates sit in the suite.
AI runtime: providers, stages and prompts
Prompt versioning and the per-locale template families.
Transactional and campaign email
The one message path with no English fallback.
Public site, SEO and marketing routes
Locale-prefixed routes, edge caching and metadata.
Environment variable reference
ERMIS_CONTENT_LOCALE and everything else.
