Skip to content

Latest commit

 

History

History
364 lines (301 loc) · 19 KB

08-localization.md

File metadata and controls

364 lines (301 loc) · 19 KB

08 — Localization Standards

Status: Active Derives from: ADR 0008 — Localization Schema. Public-read additions derive from ADR-0052. Frontend UI additions derive from ADR-0027.

i18n is a platform-level concern. It affects translatable content, slugs, URLs, SEO, dates, numbers, currencies, formats, and the Admin Studio UI itself. See docs/architecture/12-localization.md for the strategy.

Scope

Localization covers:

  • Public content (pages, blocks, course metadata).
  • Slugs.
  • SEO metadata (title, description, OG tags).
  • Notification templates.
  • Admin Studio UI.
  • Learner portal UI.
  • Validation error messages.
  • Date, time, number, currency formatting.

Locale Model

  • A tenant has a default locale.
  • A tenant has a set of enabled locales.
  • Translatable fields use Pattern A or Pattern B storage below. There is no isLocalized keyword in the content-type schema profile. Education's inline lesson body is a JSON object per translation locale, including repeated non-translatable values; the exact content-type revision pin belongs to its lesson root (Education data model).
  • Slug is unique per (tenant_id, locale), enforced on the translation table and flat across organizations — see § Pattern A.
  • Display fallback follows the single owner, Localization § Fallback Rules, under ADR-0008 and P02d-3's Accepted G24 answer. It never authorizes URL/body fallback or enabled-locale membership. Resolved values carry their actual locale.

P02d-4 response contract delivered — 2026-10-04. Pattern A content uses exact enabled query locale; display-only Pattern B values carry authored {value,locale}. Neither fallback nor headers authorize a content locale. Internal fallback remains the delivered P02d-3 contract; language attributes are P02d-6.

P02d-6 Step 1 UI foundation implemented — 2026-10-10. ADR-0027 supplies next-intl and one app-local catalogue home, with UI-language fallback separate from content admission. The delivery record owns foundation validation. Step 2 adds catalog/course views with actual resolved label languages and exact-locale metadata; Step 3 adds ordered lesson fields and lesson metadata with eligible translated course/lesson slugs. Phase 04 inherits this foundation for CMS/Studio coverage.

URL Strategy

Public URLs:

/{locale}/{slug}                # primary
/{locale}/{section}/{slug}      # nested

Rules:

  • Locale is always in the path on public URLs.
  • The default locale does not get a special slot (no /default/...); requests without a locale prefix redirect to /{tenant-default-locale}/....
  • Custom domains resolve tenant first; locale resolves from the path.

Accepted P02d-5 entry policy — 2026-10-08. The entry matrix owns Education path admission: bootstrap first, then bounded LocaleTag canonicalization and enabled membership before any shorthand/scaffold rule. Enabled noncanonical prefixes get 308; default/locale-root entry gets 307. Unsupported prefixes get masked 404; no cookie, Accept-Language or query override supplies a locale. Grammar-valid names such as courses are not reserved; membership has precedence. Redirect origins use the verified visitor host and configured scheme/port. P5 implements this policy; P6 owns pages and localized section names remain Phase 06.

Translatable Storage

Per ADR-0008 two storage patterns coexist; the choice is per-entity, not project-global. Both are encapsulated behind the application contract — consumers see resolved values, not the on-disk shape.

Pattern A — Side translation table (default for content-shaped entities)

Used for Course, Lesson, Page, ContentEntry, and anything with multiple translatable fields, per-locale slugs, or SEO metadata. The parent table holds non-translatable columns; a <entity>_translations table holds translatable fields keyed by PRIMARY KEY (<entity>_id, locale).

Four rules. A migration reviewer checks all four.

  1. The parent holds no translatable column. No title, no description, no slug. A parent may hold a slug_key — a stable, locale-independent authoring handle — but nothing routes on it.
  2. The translation table carries tenant_id as a real column and declares its own ENABLE + FORCE ROW LEVEL SECURITY and full policy set from the canonical template in Database Standards. Row Level Security is per table; it is not inherited from a parent through a check constraint, and a satellite carrying title and slug carries the content. It also carries a mirrored organization_id when the parent is [OrganizationScoped], for the isolation predicate only, and its foreign key to the parent is composite on tenant_id. The factory derives this scope; the independent INSERT/UPDATE parent-scope trigger verifies it, alongside the organization immutability guard. Neither RLS nor the tenant-only composite foreign key can prove a correct organization mirror alone.
  3. Slug uniqueness is UNIQUE (tenant_id, locale, slug) on the translation table. UNIQUE (<entity>_id, locale, slug) is forbidden: its columns are a proper superset of the primary key, so it can reject no row the table would otherwise accept, and two courses in one tenant end up sharing /en/courses/beginner.
  4. organization_id does not belong in a slug unique key, even when the entity is organization-scoped. Two reasons, and the second survives fixing the first. In a standard UNIQUE constraint PostgreSQL treats nulls as distinct, so UNIQUE (tenant_id, organization_id, locale, slug) places no constraint at all on tenant-wide rows — the rows a tenant authors first. And repairing that with NULLS NOT DISTINCT still leaves an organization-scoped row and a tenant-wide row free to claim one slug, while a host resolving to (tenant_id, organization_id) serves both tiers and would have to pick a winner at render time. One flat namespace per (tenant_id, locale) is the rule; an organization that wants its own variant of a shared course gives it its own slug.
CREATE TABLE course_translations (
    course_id       uuid NOT NULL,
    tenant_id       uuid NOT NULL,
    organization_id uuid NULL,        -- mirrors the parent; for RLS, never for uniqueness
    locale          varchar(35) NOT NULL,
    title           text NOT NULL,
    summary         text NULL,
    slug            varchar(160) NOT NULL,
    PRIMARY KEY (course_id, locale),
    CONSTRAINT ux_course_translations_tenant_id_locale_slug
        UNIQUE (tenant_id, locale, slug),
    CONSTRAINT fk_course_translations_course
        FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id)
        ON DELETE CASCADE
);

This sketch shows where the translatable columns and the slug key live. The table's complete DDL — its foreign-key index, policy set and triggers — is Database Standards § Translation satellite tables, the canonical artefact.

The accepted P02d-1 Education satellites have no independent id, audit timestamps, concurrency token or deleted_at. They belong to their own aggregate root's lifecycle and audit capture. A draft translation reserves its slug on insertion; the flat key keeps that reservation when the parent is soft-deleted. Parent eligibility excludes the content from public reads. Phase 05 decides any future release together with Phase 04's redirect/slug registry.

Education slug grammar

Education's routable slugs and non-routable course slug_key use 1–160 characters: lowercase ASCII letters and digits, separated by single interior hyphens. UUIDs in 32-hex (N) or hyphenated (D) form are refused. Invalid case, whitespace or native script is rejected with no implicit lowercasing, trimming or transliteration; the content itself remains Unicode. The same predicate is an application check and a named database check in the canonical DDL. The Education width is separate from Tenancy's 63-character hostname slug limit. This accepted storage grammar does not choose P02d-4's public route template or parameter handling.

Slug lookup is exact on (tenant_id, locale, slug). The fallback chain resolves display fields after the entity is found; it never resolves a slug. An entity with no translation in the requested locale has no URL in that locale, and a link to it is omitted rather than rendered dead.

A slug collision is refused when the translation is inserted; its writing command returns Result.Fail(business_rule_violation, …). The accepted 2026-10-02 P02d-2 G11 answer selects AddCourseTranslationCommand and AddLessonTranslationCommand for Education, mapping their named localized-slug uniqueness constraints at insertion. Publication does not reserve or newly collide a slug. P02d-2 Step 3 implements the mapping and filtered collision-identity disclosure; Phase 04's criterion requires verification of these insertion-time refusals. The refusal names the conflicting entity when the caller may read it — tenant-wide rows and the caller's own organization's rows both qualify under the canonical policy — and otherwise names only the slug and the locale, because naming a row in another organization would leak across the boundary Row Level Security exists to hold.

Pattern B — JSONB localized field (for compact taxonomy-style fields)

Used for Level.display_name, Tag.label, Category.name, and similar short atomic strings where joining a translation table would be overkill.

{
  "title": {
    "tr": "İngilizce Kursları",
    "en": "English Courses"
  }
}

Choosing between patterns

Field shape Pattern
Long text, multiple fields per entity, SEO metadata A (side table)
Short atomic string, few fields B (JSONB)
Rich content with version history A (side table; the entity's lifecycle owns publication and versioning)
Taxonomy display names B

The full table + worked examples live in 12-localization.md § Storage Schema. In both patterns the application contract returns a resolved string for the requested locale, applying the fallback chain.

SEO

  • <html lang="{locale}"> set per page.
  • hreflang annotations for every translated public page.
  • Canonical URL is the requested locale.
  • Open Graph locale properties use language_TERRITORY only for admitted tags with an explicit two-letter territory and no script, variant or extension to discard. Omit unrepresentable values; never guess a territory. HTML language, URL and hreflang retain BCP-47. The approved G40 addendum records this bounded projection and the optional-property tradeoff.

Formatting

  • Dates / times: Intl.DateTimeFormat on the frontend, IFormatProvider on the backend.
  • Numbers: Intl.NumberFormat / CultureInfo.
  • Currency: never store amounts as strings; store integer minor units + ISO currency code; format at presentation.
  • Pluralization: use ICU MessageFormat ({count, plural, one {# lesson} other {# lessons}}).

Strings in Code

  • Frontend: server-first next-intl 4.14.9, with app-local apps/web/src/i18n/messages/{en,tr}/public.json and src/i18n/request.ts. P02d-6 Step 1 implements the dependency/plugin, complete catalogues and key/ICU/ argument/callsite controls. No shared i18n package is created without ADR-0009's duplication trigger.
  • Backend: localized strings live in resource files under each module.
  • Strings are referenced by key, never duplicated:
const t = await getTranslations("public");
return <p>{t("catalog.course_count", {count})}</p>;
var msg = _stringLocalizer["lockey_course_publish_success"];

General UI identifiers use lowercase dotted feature namespaces and snake_case segments (public.catalog.course_count); JSON nesting supplies the dots. Backend LocalizedMessage.Key and Problem Details messageKey retain lockey_*, including consumer-owned backend error resources. The SDK normalizes errors and owns no translated resources. P6 maps closed page outcomes to explicitly owned UI keys; arbitrary wire keys/titles/errors/parameters never become lookup identifiers or copy.

Request configuration shares the server-only request-cached admission loader used by document/layout/page consumers. It re-verifies the ingress envelope and live enabled membership using the signed target's canonical content locale. No i18n routing middleware, middleware-derived requestLocale, callsite override, cookie or Accept-Language is authority, and message loading adds no bootstrap call.

Select a whole UI catalogue by exact tag, then remove rightmost subtags, then use platform en. Missing required keys in supported bundled catalogues fail the build; no per-key fallback or raw-key output. ICU formatting uses the selected UI catalogue's locale. Plain text parameters only; no HTML/rich callbacks or authored URL attributes. Formatter failure yields a bounded translated unavailable state. Nonempty/equal key sets, ICU argument names/types and callsite coverage require planted failure controls, per ADR-0027 and the canonical test catalogue.

The exact admitted content locale still selects API reads and document lang. UI groups carry their selected catalogue's actual lang/direction when different; Pattern B labels carry the API-resolved locale. Neither UI fallback nor label fallback substitutes Pattern A titles, summaries or bodies. Studio/portal scaffolds retain platform English until Phase 06 supplies their UI.

Locale Codes

  • IETF BCP 47 identity uses the shipped LocaleTag validator and canonicalizer: lowercase language, Title-cased script and uppercase region (tr, en-GB, tr-TR, zh-Hans).
  • Education translation keys store the full canonical code in varchar(35), never a truncated or fully lowercased form. This is G6 (a)'s accepted storage decision.
  • Tenant locale membership is validated through a Tenancy application contract when the P02d-2 writer lands, not through a cross-chain Education foreign key.

P02d-4 request contract delivered — 2026-10-04. Education requires one query locale. Validate LocaleTag's 35-character grammar without trimming, canonicalize case, then use enabled tenant membership, exact lookup and cursor binding. Missing/repeated/malformed/overlength values return 400 validation_failed for locale; a well-formed absent/disabled locale returns 400 unsupported_locale. Enabled but untranslated means empty catalog or masked detail 404, not fallback. X-Locale and Accept-Language do not select content. No locale rows authorize none; bootstrap returns 404. Invalid stored locale/default configuration is 503 dependency_unavailable, never a synthesized default. URL-segment redirects are delivered by P02d-5 Step 3 under G6(c). ADR-0052 and the accepted locale contract record the authority; no platform locale registry is introduced.

G13 reader implemented in P02d-2 Step 1 — 2026-10-02. The Tenancy contract selects no platform registry: use LocaleTag's existing grammar, canonicalization and 35-character bound, then the tenant's enabled membership. No locale rows authorize no content locale, rather than an implicit en. P02d-4 accepts request G6(b) and G24's public fields; P02d-3 implements the internal display fallback. P02d-2 Step 2 implements locale command admission and the default-enabled CHECK; request-language negotiation is not part of those writers.

Right-to-Left

  • The platform supports RTL languages from the start.
  • Layout uses logical CSS properties (padding-inline-start, not padding-left).
  • Components flip via dir="rtl" on the document root.

Accepted P02d-6 derives document direction from the admitted content locale using runtime internationalization data; an undescribed admitted tag falls back to ltr, never refusal. UI fallback groups retain their own direction. Test-owned enabled ar content proves lang="ar"/dir="rtl" and labelled English fallback UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue. Step 1 implements direction selection and labelled fallback chrome; Step 2 proves an empty admitted Arabic catalog in production. Step 3 adds per-field label direction. Step 4 adds eligible authored RTL content with concurrent host/locale HTML/Flight proofs. Passing manual accessibility is recorded in the delivery record.

Admin Studio UI

Admin Studio separates:

  • Platform UI language — what the editor sees (Turkish or English).
  • Tenant content language — what the editor edits.
  • Learner-facing course language — the locale the learner experiences.

These three are independent. An editor may use Admin Studio in English while editing Turkish public pages and English course content.

Notification Templates

  • Templates are per-locale.
  • A tenant can override the platform default template per locale.
  • The dispatch system picks the recipient's locale, with fallback to tenant default.

Testing

Localized content requires tests for:

  • Requested locale render.
  • Fallback locale render.
  • Missing-translation render.
  • Locale-specific slug uniqueness, including the tenant-wide-versus-organization-scoped collision and the same-slug-different-locale case.
  • Notification template selection per recipient locale.

Forbidden

  • Hardcoded user-facing strings in code (every visible string goes through the i18n layer).
  • Concatenating sentences across translation keys (use ICU placeholders).
  • Storing localized text in a non-localized field then "interpreting" it.
  • Using locale-derived ifs (if (locale === "tr")); branch on capabilities, not on locale identity.
  • Truncating BCP 47 codes (en-GB ≠ en).
  • Putting a translatable field — title, description, slug — on the parent table when the entity uses Pattern A.
  • Putting organization_id inside a slug unique key (§ Pattern A rule 4), or declaring a slug constraint whose columns are a superset of the translation table's primary key.
  • Falling back to another locale to resolve a slug. Fallback applies to display fields after the entity is found, never to the lookup.