morphit/docs/PER-LOCALE-PRERENDERING-DESIGN.md

14 KiB
Raw Permalink Blame History

Per-locale prerendering — design discussion

Status: SHIPPED in Part 121 cp7 (2026-05-14). Route restructure complete; at cp7 the build produced 200 locale-prefixed HTML files (20 routes × 10 locales) + redirect shell. Route count grows as new pages ship (cp24 added cheat-sheet, cp26 added privacy index + per-asset privacy pages); the current authoritative list is whatever apps/web/src/routes/[lang]/**/+page.svelte enumerates at build time. Helpers shipped in cp6 (2026-05-13); route restructure in cp7 (next day). Date: 2026-04-21 (design); 2026-05-13 (cp6 partial: helpers + smoke + i18n module split); 2026-05-14 (cp7 full: route restructure + redirect shell + 88 link-site sweep + verification) Interacts with: apps/web/svelte.config.js (prerender + adapter-static config + handleUnseenRoutes:'ignore'), apps/web/src/app.html (pre-paint locale script), apps/web/src/hooks.client.ts (post-hydration locale sync), all routes (now under [lang]/).

Shipping status

All Option C scope SHIPPED:

cp6 (2026-05-13):

  • apps/web/src/lib/i18n/locales.ts — pure SSoT (zero SvelteKit deps).
  • apps/web/src/lib/i18n/path.tslocalePath(), stripLocalePrefix(), pickLocaleFromAcceptLanguages(), isLocalePrefixed().
  • apps/web/scripts/i18n-path-helpers-smoke.ts — 22 scenarios.
  • apps/web/src/lib/auth/pairingPhoneSigner.ts Buffer-import build blocker fixed.
  • scripts/build-sitemap.mjs ROUTES re-synced with routes.ts (14→17).

cp7 (2026-05-14):

  • Physical route move: 24 subdirs + +layout.{svelte,ts} + +page.svelte moved under [lang]/.
  • apps/web/src/routes/[lang]/+layout.tsprerender = true, ssr = true, load({params}) validates lang + calls initI18nFor + waitLocale.
  • apps/web/src/routes/[lang]/+page.tsentries() enumerating SUPPORTED_LOCALES (must live on +page.ts per SvelteKit).
  • Root redirect shell: new +page.svelte (pickLocaleFromAcceptLanguages + window.location.replace + noscript meta-refresh fallback to /en + meta robots noindex), +layout.ts (prerender=true, ssr=false), +layout.svelte (minimal, imports app.css).
  • svelte.config.js handleUnseenRoutes:'ignore' for the 7 dynamic-param routes.
  • Head.svelte url.search/url.hash gated behind building flag.
  • 88 internal link sites wrapped in localePath() across 21 page files + 10 components + the layout's navLinks array.
  • LanguageSwitcher.svelte rewired to goto(localePath(stripLocalePrefix(currentPath), code)).
  • 6 P121-CP7 persona-walkthrough sentinels (CP7-1..6).
  • 11 smoke scripts updated to point at new [lang]/ paths.
  • href-xss-smoke whitelisted lp / localePath + ALLOWLIST entry for link.href.

Verification:

  • npm run build produces 200 locale-prefixed HTMLs + redirect shell + degraded.
  • Triple-pulse smokes: 2,470 × 3, 0 failures.
  • Rendered de.html carries /de/ prefix on all nav + footer + CTAs; 0 bare paths.
  • Same symmetry confirmed for fa.html (RTL).

Follow-on SEO refinements (NOT cp7 scope, not blocking launch):

  • Sitemap hreflang <xhtml:link rel="alternate"> tags per URL (sitemap.xml already emits 170 indexable URLs but lacks the per-URL alternates block).
  • Per-locale RSS feeds at /<lang>/rss/orderbook.xml (currently single-locale at /rss/orderbook.xml).
  • Canonical + hreflang <head> tags per page in [lang]/+layout.svelte (currently inherits from Head.svelte's existing logic — worth verifying it still emits correctly under the new prefix structure).

The problem

Morphit's frontend is prerendered to static HTML (apps/web/src/routes/+layout.ts: prerender = true). Every route is baked at build time into a single HTML file — in English — and shipped via adapter-static.

When a non-English user loads the page, the sequence is:

  1. Browser downloads the English-prerendered HTML, paints.
  2. JS loads, initI18n() runs, detects the user's preferred locale from ?lang=... or navigator.languages.
  3. svelte-i18n swaps every $_('…') call to the matching string for that locale.
  4. Page re-renders in the correct language.

The user sees a flash of English content between steps 1 and 4. On a fast device this is ~100ms; on a slow phone or CGNAT link it's noticeable. A pre-paint script in app.html already sets <html lang> and dir correctly so the outer chrome (scrollbar direction, browser translation UI) is right from frame zero — but the text content of the page is English until hydration completes.

This is a known architectural issue. The revisit list has carried "per-locale prerendering" as a pending fix.


The fix, at concept level

Instead of one HTML per route, generate N HTMLs per route (one per supported locale). At request time, serve the right one. No client-side string swap needed — the HTML is already in the user's language.

For Morphit's 10 locales × ~15 routes, that's ~150 HTML files instead of ~15. Modest footprint (each HTML is ~30KB; the full set is ~5MB, comfortably cacheable).

The interesting design question is how to route.


Option A — URL-prefix routing (/es/orderbook)

Each locale gets its own URL namespace:

  • /orderbook → English
  • /es/orderbook → Spanish
  • /de/orderbook → German

SvelteKit pattern: a [lang] param in the route tree, or a per-locale +layout.ts that sets the locale from the URL.

Pros:

  • Shareable locale-specific URLs (Spanish user copies /es/orderbook, recipient opens it, gets Spanish).
  • Search-engine-friendly — Google indexes each locale as its own pages.
  • Works with pure static hosting — no server content-negotiation logic needed.
  • Simple mental model.

Cons:

  • URL structure changes. Every existing link (/orderbook, /post, …) has to be updated or a redirect rule added. External links from blurt.blog or Matrix messages point at the bare paths, so we need /orderbook/<detect>/orderbook redirect at the root.
  • Duplicates our sitemap / RSS URL structures.
  • Share this page buttons need to emit the locale-prefixed URL.

Option B — Accept-Language content negotiation (nginx)

Single URL structure (/orderbook), nginx looks at the Accept-Language header, maps it to one of the 10 locales, serves the right HTML.

Pros:

  • No URL structure change.
  • Existing links keep working.
  • The user's browser negotiates automatically — same URL yields different content per user.

Cons:

  • Breaks static hosting. Morphit operators would need nginx (or equivalent) with a content-negotiation rule — more setup burden than "serve the build/ folder."
  • Doesn't play well with caching CDNs — Cloudflare and friends cache by URL, not by Accept-Language, so a user who caches the English version sees English forever.
  • Share-a-link breaks across locales (Spanish user shares /orderbook, English recipient opens it, gets English — not what was shared).
  • Doesn't help users who want to force a locale different from their browser default.

Option C — Two-stage: detection redirect + prefix routing

Combines A and B. Root paths (/orderbook) redirect to the detected-locale prefix (/es/orderbook), then the prefix is the source of truth.

Pros:

  • First-time visitors get auto-detection.
  • Shared links have stable locale.
  • Works with static hosting (redirect is a <meta http-equiv="refresh"> + small JS fallback, or an nginx rule for operators who have nginx).
  • No string-swap FOUC in the locale-prefixed pages — they're baked correctly.
  • The detection redirect is the ONLY place FOUC can occur, and it's instant (same-origin redirect with a fresh HTML download that's already correct).

Cons:

  • Two HTMLs touched per first visit (bare-path + locale-prefix). Mitigated by aggressive caching.
  • Slightly more complex routing logic than A alone.

Recommendation

Option C. It keeps all of A's upsides (shareable URLs, search-engine friendliness, pure static hosting) while solving A's "first visit with no prefix" case gracefully.

The detection redirect is small: a 20-line script in app.html that reads navigator.languages, picks the best-matching SUPPORTED_LOCALE, and does window.location.replace('/' + code + window.location.pathname). The prerendered bare-path HTML ships with that script and no other content — it's a redirect shell, not a rendered page.


Implementation sketch

1. Route tree restructure

Move everything under a [lang] param:

routes/
  +layout.ts                 ← redirect shell; no prerender
  +page.svelte               ← redirect shell
  [lang]/
    +layout.ts               ← prerender = true; set locale from params
    +layout.svelte           ← existing layout chrome
    +page.svelte             ← existing home
    orderbook/+page.svelte
    faq/+page.svelte
    post/+page.svelte
    …

The root +layout.ts exports prerender = false (or 'auto') and handles the redirect. The [lang]/ subtree has prerender = true and entries() to enumerate all 10 locales.

2. Prerender entries

apps/web/src/routes/[lang]/+layout.ts:

import { SUPPORTED_LOCALES } from '$i18n';

export const prerender = true;

export function entries() {
  return SUPPORTED_LOCALES.map((l) => ({ lang: l.code }));
}

Each descendant route inherits this, so the build generates /en/orderbook, /es/orderbook, … for every route.

3. Set locale from URL param

apps/web/src/routes/[lang]/+layout.ts:

import { waitLocale, locale } from 'svelte-i18n';
import { initI18nFor } from '$i18n';

export async function load({ params }) {
  if (!SUPPORTED_LOCALES.some((l) => l.code === params.lang)) {
    throw error(404);
  }
  await initI18nFor(params.lang);
  await waitLocale(params.lang);
  return { lang: params.lang };
}

initI18nFor(code) is a new function that sets the locale before any Svelte rendering happens, so prerender output is in the right language.

4. Detection-redirect shell at root

apps/web/src/routes/+page.svelte:

<script>
  import { onMount } from 'svelte';
  import { SUPPORTED_LOCALES } from '$i18n';
  onMount(() => {
    const preferred = pickLocale(navigator.languages);
    const path = window.location.pathname === '/'
      ? '/' + preferred
      : '/' + preferred + window.location.pathname;
    window.location.replace(path + window.location.search);
  });

  function pickLocale(accept) {
    for (const a of accept) {
      const short = a.toLowerCase().split('-')[0];
      const match = SUPPORTED_LOCALES.find((l) =>
        l.code === a || l.code.split('-')[0] === short
      );
      if (match) return match.code;
    }
    return 'en';
  }
</script>

<noscript>
  <meta http-equiv="refresh" content="0; url=/en/" />
</noscript>

Every <a href="/orderbook"> etc. becomes <a href={/${$page.data.lang}/orderbook}>. A localePath helper centralizes this.

Existing goto('/orderbook') calls become goto(localePath('/orderbook')).

6. Sitemap + RSS + canonical tags

Update apps/web/src/routes/sitemap.xml/+server.ts to emit 10× entries per route with <xhtml:link rel="alternate" hreflang="…"> tags. RSS feed gains per-locale variants at /es/rss/orderbook.xml etc. Canonical <link rel="canonical"> in <head> points to the current locale's URL.

7. Language picker

A dropdown in the header that navigates to the same path under a different locale prefix. Already exists (LanguagePicker.svelte) — updated to use localePath and emit locale-prefixed URLs.


Risks + mitigations

Risk Mitigation
Build time 10×'s, local dev becomes slow Generate only 1 locale (en) in dev; full matrix only on production build. Gate via VITE_LOCALES=en env.
External links to /orderbook break Root +page.svelte handles the redirect gracefully. Plus a nginx-config snippet in OPERATIONS.md for operators who prefer header-based routing.
Every i18n string needs re-verification at build time Already run a parity script; gate the build on it. Missing key → build fails, not a runtime mystery.
RTL locales (fa) need dir="rtl" baked into HTML The [lang]/+layout.svelte sets <svelte:head> dir attribute from SUPPORTED_LOCALES[params.lang].rtl.
Indexer/relay URLs don't change They don't — only the frontend restructure. API endpoints stay at /v1/*.

What I'd verify before shipping

  1. A working local npm run build produces the expected 150+ HTML files and they serve correctly via npx serve build/.
  2. / loads the redirect shell and correctly detects navigator.languages → locale prefix.
  3. /es/orderbook serves Spanish HTML with no English FOUC.
  4. /zh-HK/faq serves Traditional Chinese.
  5. /fa/post serves Persian RTL with dir="rtl" set on <html> in the prerendered HTML (not just set by JS).
  6. Language picker works (navigating /en/faq → pick Spanish → lands on /es/faq).
  7. RSS feed at /es/rss/orderbook.xml renders.
  8. Canonical + hreflang tags are correct in <head>.

None of these can be verified in the current sandbox because it lacks a working SvelteKit build pipeline. The shape of the work is clear; the execution should happen on a machine with a working npm run build.


Estimated scope

  • Route tree restructure: ~2 hours
  • initI18nFor(code) implementation: ~30 min
  • Internal link audit (find every href="/...") and localePath() wrapper: ~2 hours
  • Sitemap + RSS per-locale: ~1 hour
  • Language picker fix: ~30 min
  • Canonical + hreflang in <head>: ~30 min
  • Local verification + staging test: ~2 hours
  • OPERATIONS.md nginx fallback documentation: ~30 min

Total: one focused day. No new dependencies, no schema changes, no translation work (all 10 locales are already complete — this is purely a rendering-pipeline fix).


Why this hasn't shipped yet

The work is small but requires a working build environment to iterate against. The Morphit sandbox environment this session ran in doesn't run npm run build successfully (missing native deps for some SvelteKit plugins), so a blind set of edits would have high risk of a build-breaking typo that I couldn't detect. Operator with a working checkout can pick this up and complete it in a day.