Vai al contenuto
ErmisAI

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
supportedUiLocalesen, el, pl, it, es, pt, sv, da, nb, fi
supportedContentLocalesen, el, it, es, pt, pl, sv, da, nb, fi
routing.localesen, 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):

localetaglocaletag
enen-USptpt-PT
elel-GRsvsv-SE
plpl-PLdada-DK
itit-ITnbnb-NO
eses-ESfifi-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:

  1. The [locale] URL segment on the public site, or setRequestLocale(profile.uiLocale) in the tenant layout.
  2. The ermis-ui-locale cookie.
  3. 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

surfacehow 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 codesEN 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:

emaillocale source
Welcomesignup-time UI locale (no newsroom exists yet)
Upsellnewsroom content locale
Erasure confirmationerased newsroom's UI locale, captured pre-purge
Team inviteinviter'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):

layoutallowlist
[locale] (public)common, nav, footer, auth, cookieConsent, home, pricing, animations
(tenant)/appcommon, nav, footer, auth, cookieConsent, admin, app
(internal)/admincommon, 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

wherecolumn / keydefault
Tenant profiletenantNewsrooms.contentLocale (src/lib/db/schema.ts:679)SQL literal 'en'
Tenant profiletenantNewsrooms.uiLocale (:677)defaultUiLocale
Draft snapshoteditorialStoryDrafts.contentLocale (:569)SQL literal 'en'
Platform defaultERMIS_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 ?? defaultUiLocale
  • contentLocale = 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.

assetfileenforcement
Lyra compose persona (2 versions each)src/lib/ai/prompts/story-compose.ts:16-285as const only — silent English fallback
Compose prompt phrasesstory-compose.ts:289-353satisfies Record<ContentLocale, …>
"unspecified category" wordstory-compose.ts:355-366satisfies Record<ContentLocale, string>
Lyra refinement persona (2 versions each)src/lib/ai/prompts/story-refinement.ts:11-242as const only — silent English fallback
Refinement prompt phrasesstory-refinement.ts:246-377satisfies
First-party-voice directivesrc/lib/ai/prompts/first-party-voice.ts:10-21satisfies Record<ContentLocale, string>
Inline-completion system linessrc/app/api/stories/[storyId]/completion/route.ts:57-128satisfies Record<ContentLocale, readonly string[]>
Placeholder draft textsrc/lib/stories/draft-state.ts:3-84satisfies
Suggested prompt chipssrc/lib/ui/suggested-story-prompts.ts:10typed Record<ContentLocale, readonly string[]>
Category labels (56 entries)src/lib/i18n/categories.ts:13-812Partial<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.json does not are never flagged.
  • Array item counts. collectKeys treats an array as a single leaf (:13-24Array.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-136 names the intentional anglicisms (Cybersecurity, Esports, LGBTQ+, True Crime, Wrestling, Cannabis).
  • completionSystemLinesByLocale, which is caught at compile time by its satisfies constraint instead.
  • Placeholder integrity across locales, translation quality, or whether supportedUiLocales and supportedContentLocales still have identical membership.

pnpm i18n:translate

scriptcommand
i18n:translatetsx scripts/i18n/translate-messages.ts
i18n:translate:drytsx 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_]*\}/g matches must be identical between source and translation. Otherwise the leaf is skipped, not written, and reported under Skipped 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 match messages/en.json and 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.locales and have a MessagesLocaleConfig entry, 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

namewheredefaulteffect
ERMIS_CONTENT_LOCALEenv, 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-localecookie (src/lib/i18n/config.ts:63)UI locale carrier. 1-year max-age, SameSite=Lax, Path=/.
tenantNewsrooms.uiLocaleDB (schema.ts:677)'en'Authoritative UI locale for the authenticated app.
tenantNewsrooms.contentLocaleDB (schema.ts:679)'en'Authoritative article language per tenant. Overrides ERMIS_CONTENT_LOCALE.
editorialStoryDrafts.contentLocaleDB (schema.ts:569)'en'Per-draft snapshot; wins over the live profile for chat, compose, and completion.
ERMIS_PROMPT_VERSION_STORY_COMPOSEenv (story-compose.ts:434)unsetPins a compose prompt template version.
ERMIS_PROMPT_VERSION_STORY_REFINEMENTenv (story-refinement.ts:411)unsetPins 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.

In questa pagina