# Production Web delivery

`lampclaw/i18n` 0.4 generates independently deployable catalog chunks while
leaving the canonical authoring files unchanged. A top-level schema message
group is a deployment namespace. Locale authors continue to edit one JSON file
per locale; they do not maintain chunk files or hashes.

## Generated layout

For locales `en-US` and `zh-CN` with namespaces `account` and `common`, the CLI
owns this complete output directory:

~~~text
public/i18n/
├── manifest.json
├── en-US--account.json
├── en-US--common.json
├── zh-CN--account.json
└── zh-CN--common.json
~~~

Each chunk remains catalog v2 and adds a `namespace` field. Every message ID in
that chunk must begin with the declared namespace. `manifest.json` records the
catalog version, profile, contract hash, fallback and embedded locales, plus
the path, UTF-8 byte count, SHA-256, message count, direction, locale, and
namespace of every chunk. Arrays and paths are emitted in deterministic order.

The generated facade exposes typed `CatalogNamespace` values. Installation is
data-only:

~~~moonbit nocheck
let i18n = @app_i18n.I18n::new()
match i18n.install_catalog_chunk_source(
  @app_i18n.ZhCN,
  @app_i18n.CatalogAccount,
  verified_source,
) {
  Ok(_) => ()
  Err(message) => println("chunk rejected: \{message}")
}

if i18n.has_catalog_namespace(
  @app_i18n.ZhCN,
  @app_i18n.CatalogAccount,
) {
  // The account route may now use its Chinese messages.
}
~~~

`has_catalog(locale)` becomes true only when every generated namespace is
installed. Applications that need only selected routes should use
`has_catalog_namespace`. A missing namespace or message continues through the
normal locale and fallback chains. A malformed, stale-contract, wrong-profile,
wrong-locale, wrong-namespace, or conflicting-direction chunk is rejected
before it can replace working data.

Whole-locale catalog v2 parsing and installation remain supported for
compatibility. New CLI output uses chunks and the deployment manifest.

## Application-owned loading recipe

The runtime deliberately contains no HTTP, storage, service-worker, or retry
policy. The application should fetch `manifest.json` with revalidation, select
only the namespaces needed by the current route, verify the exact response
bytes, decode UTF-8, and then pass the source to the generated facade.

This framework-neutral browser helper demonstrates the host side of that
boundary:

~~~javascript
const hex = bytes =>
  [...new Uint8Array(bytes)]
    .map(value => value.toString(16).padStart(2, "0"))
    .join("");

async function fetchVerifiedChunk(baseURL, entry, retry = true) {
  const response = await fetch(new URL(entry.path, baseURL), {
    cache: retry ? "default" : "reload",
  });
  if (!response.ok) throw new Error(`catalog HTTP ${response.status}`);
  const bytes = await response.arrayBuffer();
  if (bytes.byteLength !== entry.bytes) {
    if (retry) return fetchVerifiedChunk(baseURL, entry, false);
    throw new Error("catalog byte count mismatch");
  }
  const digest = hex(await crypto.subtle.digest("SHA-256", bytes));
  if (digest !== entry.sha256) {
    if (retry) return fetchVerifiedChunk(baseURL, entry, false);
    throw new Error("catalog SHA-256 mismatch");
  }
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}

async function loadRouteNamespaces(baseURL, locale, names, install) {
  const response = await fetch(new URL("manifest.json", baseURL), {
    cache: "no-cache",
  });
  if (!response.ok) throw new Error(`manifest HTTP ${response.status}`);
  const manifest = await response.json();
  const entries = names.map(name => {
    const entry = manifest.chunks.find(
      value => value.locale === locale && value.namespace === name,
    );
    if (!entry) throw new Error(`missing catalog chunk: ${locale}/${name}`);
    return entry;
  });
  const sources = await Promise.all(
    entries.map(entry => fetchVerifiedChunk(baseURL, entry)),
  );
  // `install` bridges to install_catalog_chunk_source. Commit the requested
  // locale only after every required call succeeds.
  entries.forEach((entry, index) => install(entry, sources[index]));
}
~~~

Recommended policy:

- keep the embedded fallback locale active while loading;
- revalidate the small manifest and let normal HTTP caching serve chunks;
- on an integrity mismatch, bypass the cache once and then fail visibly;
- install chunks independently, but commit a route or locale switch only after
  all namespaces required for that view succeed;
- retain already validated chunks across retries; and
- record request, integrity, and runtime rejection failures in application
  telemetry without logging translated user data.

The maintained Rabbita Todo example follows this ownership boundary. It embeds
English, requests the `common` and `todo_ui` Chinese chunks independently,
keeps English on failure, retries only missing chunks, and persists an explicit
locale choice only after both required namespaces validate.

## Published release budgets

The release gate reads the generated manifest, verifies every chunk hash, and
enforces these raw release limits:

| Artifact | Budget |
| --- | ---: |
| Browser release JavaScript | 448 KiB |
| Gzip-compressed browser JavaScript | 128 KiB |
| All chunks for embedded locales | 8 KiB |
| One dynamic namespace chunk | 64 KiB |
| Deployment manifest | 64 KiB |

The 0.8 standards-profile reference application measures 429 KiB JavaScript,
116 KiB gzip (76 KiB Brotli), 2.0 KiB of embedded-locale chunks, a 1.1 KiB
largest dynamic chunk, and a 2.1 KiB manifest with the pinned release
toolchain. The raw/gzip ceilings increased from the 0.4 compatibility baseline
because runtime-installed standards catalogs must retain the complete MF2
syntax/data-model validator, resolver, bidi behavior, and default-function
dispatch in the browser bundle. Generation-only code is still unreachable.
These checked limits are regression ceilings, not promises that every
application bundle has the same size.

The `0.9.2` maintained application, built with MoonBit `0.10.9`, Rabbita
`0.15.4`, Warren `0.3.2`, and Terser `5.50.0`, measures 455,928 JavaScript
bytes (445.2 KiB) and 122,607 gzip bytes (119.7 KiB). Embedded chunks, the
largest dynamic chunk, and the deployment manifest measure 2,068, 1,108, and
2,131 bytes. Raw headroom is 2,824 bytes. These are whole-application sizes,
including the framework. The application-owned same-origin text loader avoids
linking Rabbita's general-purpose HTTP stack; future framework or compiler
upgrades must continue to pass this gate.

No framework-specific package is introduced in 0.4. The current evidence does
not yet show two independent consumers needing the same owned lifecycle
adapter and a dedicated maintainer.
