From 1c52a7e78a83aa4cf41a8daae7fac37f6ba4bb01 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Sun, 27 Sep 2026 20:21:45 -0500 Subject: [PATCH 1/7] docs: runtime spec for React, Next.js, and Astro One config drives translation and routing: locale detection and redirects, cookie, provider, t()/t.rich with ICU via Intl, source-locale fallback, switcher, Link, , hreflang, static params. Entry points rosetta-i18n/react, /next, /astro over an internal zero-dep core. --- docs/spec-runtime.md | 390 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/spec-runtime.md diff --git a/docs/spec-runtime.md b/docs/spec-runtime.md new file mode 100644 index 0000000..7990876 --- /dev/null +++ b/docs/spec-runtime.md @@ -0,0 +1,390 @@ +# Rosetta runtime spec: React, Next.js, Astro + +- **Status:** Draft, for discussion +- **Builds on:** [`spec-v1.md`](./spec-v1.md), which covers translation. This spec covers + rendering and routing. +- **Package:** the same `rosetta-i18n`, with new entry points `rosetta-i18n/react`, + `rosetta-i18n/next` and `rosetta-i18n/astro` + +## 1. Summary + +Rosetta v1 writes translated locale files. Rendering them, and all the plumbing +around locales, is still left to each project. That plumbing includes: + +- a locale list copied into several places; +- middleware that detects the visitor's language and redirects `/pricing` to `/es/pricing`; +- a cookie that remembers the choice; +- a provider that loads the right messages; +- a locale switcher, and links that keep the current locale; +- ``, `hreflang` alternates, and static params for every locale. + +Every project rebuilds this, and it's the most tedious part of internationalization. + +The runtime removes it. **`.rosetta/config.json` drives both translation and +routing.** Its `sourceLocale`, `targetLocales` and file layout already say which +locales exist and where their messages live. Add a locale to the config, run +`rosetta push`, and routing, the switcher, and SEO tags pick it up with no other +changes. + +## 2. Goals and non-goals + +### Goals + +- **Minimal setup.** Next.js: about three small files. Astro: one integration line. + React: one provider. +- **One source of truth** for locales: the Rosetta config. +- **Automatic locale handling.** Detect the locale (URL, then cookie, then + `Accept-Language`, then the default), redirect, remember the choice, and switch. +- **Correct by default.** ``, `dir="rtl"` for RTL locales, canonical and + `hreflang` alternates, and no hydration mismatches. +- **Missing translations fall back to the source locale** instead of breaking. + Shipping a new English key before its translations land is safe. +- **The same `t()` everywhere:** client components, server components, and `.astro` + files. +- **Zero runtime dependencies.** Formatting uses the built-in `Intl` APIs. React, + Next.js and Astro are optional peer dependencies. +- **Edge-safe.** Nothing on a request path imports `node:*`. + +### Non-goals for the first release + +- Frameworks other than React, Next.js (App Router), and Astro. That rules out + Vue, Svelte, Solid, Remix-specific adapters, and the Next.js Pages Router. +- Domain- or subdomain-per-locale routing (e.g. `es.example.com`). Planned; the + config leaves room for it. +- Translating on the client at runtime. The runtime renders pre-translated catalogs. + Dynamic content still uses the server-only `cachedTranslate`. +- Localized URL slugs (`/es/precios`). Planned. +- A full ICU implementation. We support what `Intl` covers (§6); anything else is a + validation error. + +## 3. Packaging + +| Entry point | Built on | Runs in | Provides | +|---|---|---|---| +| `rosetta-i18n/react` | internal core | browser, server | `RosettaProvider`, `useT`, `useLocale`, ``, ``, `detectBrowserLocale` | +| `rosetta-i18n/next` | `/react` + core | Node, edge, browser | `withRosetta`, `defineI18n` (middleware/proxy, layout, `getT`, `Link`, alternates, static params) | +| `rosetta-i18n/astro` | core | Node (build/SSR), browser (switcher) | the integration, `getT`, ``, ``, `localePaths` | + +- The **internal core** (`src/runtime/`) is not a public entry point. It holds + message lookup and fallback, the ICU formatter, locale negotiation, and turning + the config into a runtime locale list. It is about 300 lines of TypeScript with no + dependencies. The ICU parser already exists in `src/validate.ts`; the core adds + a formatter on top. +- **Optional peers:** `react >= 18`, `next >= 15`, `astro >= 5`. +- **Astro components** (`.astro` files) ship as source under + `rosetta-i18n/astro/components/*`. Rollup doesn't bundle them. +- **Why not a preset over next-intl?** It would only cover Next.js and would tie us + to next-intl's versions. Our own small runtime gives one `t()` across all three + frameworks, and it reuses the ICU parser we already maintain. + +## 4. Configuration + +The runtime reads the existing config. An optional `routing` block adds routing +settings; everything in it has a default. + +```jsonc +// .rosetta/config.json +{ + "sourceLocale": "en", + "targetLocales": ["es", "ja", "ar"], + "files": [{ "pattern": "messages/en.json" }], + "routing": { + "strategy": "prefix-except-default", // or "prefix-always" + "detect": ["cookie", "accept-language"], // order after the URL prefix; [] disables detection + "cookie": { "name": "locale", "maxAge": 31536000 }, + "localizedPaths": ["/", "/pricing", "/blog/**"] // optional allowlist; default: every path + } +} +``` + +| Field | Default | Meaning | +|---|---|---| +| `strategy` | `prefix-except-default` | `prefix-except-default`: the source locale is served at `/pricing` and the others at `/es/pricing`. `prefix-always`: every locale is prefixed, and `/` redirects. | +| `detect` | `["cookie", "accept-language"]` | How a visitor with no locale prefix is matched to a locale. | +| `cookie` | `{ name: "locale", maxAge: 1 year }` | Set whenever a locale is chosen explicitly (a prefixed URL, or the switcher). | +| `localizedPaths` | every path | Glob allowlist. Other paths stay source-only and never redirect (e.g. `/app/**`, `/api/**`). | + +- **Derived values.** Each locale's display name comes from `Intl.DisplayNames`, + shown in that locale's own language. Text direction comes from + `Intl.Locale.prototype.textInfo` where available, with a fallback list + (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `yi`, `dv`, `ug`, `ku`). +- **Getting the config to the runtime.** It's JSONC and read from disk, so the + runtime never parses it at request time. + - **Next.js:** `withRosetta()` reads it at build time and inlines a compact JSON + manifest through `env` (`process.env.ROSETTA_I18N`). That works in both the edge + and Node runtimes. + - **Astro:** the integration reads it at build or startup time and passes the + manifest to the virtual module `virtual:rosetta/manifest`. + - **Plain React:** there's no config file at runtime. Pass `locales` to the + provider, or generate a manifest with `rosetta manifest > src/i18n.json` (a new + CLI command, §9). + +## 5. Messages and fallback + +- **Loading.** Messages load through a `load(locale)` function you supply, e.g. + `(locale) => import(\`./messages/${locale}.json\`)`, so the bundler can split them + into one chunk per locale. Projects with several files (namespaces) return a + merged object, and helpers are provided. +- **Fallback chain:** + 1. the requested locale; + 2. its base language, if it's configured (e.g. `pt-BR` falls back to `pt`); + 3. the source locale; + 4. the key itself. + + Steps 3 and 4 log a warning in development. +- **Merging with the source locale.** The source locale's messages are always + loaded and deep-merged underneath the target's. This is what makes "ship the + English key now, translations follow" safe. It also means projects no longer need + locale-parity tests to protect production, though `rosetta check` can still gate + CI. +- **Performance.** Compiled messages are cached per locale and key, so each message + is parsed once. + +## 6. Formatting API + +The same API is available everywhere: `useT()` in client components, `await getT()` +in server components, and `getT(Astro)` in `.astro` files. + +```ts +const t = useT("Pricing"); // namespace = key prefix (optional) + +t("cta"); // "Start free" +t("trial", { days: 14 }); // ICU: "{days, plural, one {# day} other {# days}} free" +t.rich("terms", { terms: (chunks) => {chunks} }); // … +t.has("beta"); // boolean +t.raw("list"); // unformatted value (arrays, objects) +``` + +- **ICU support:** + - simple `{arg}`; + - `plural` and `selectordinal`, with `=N` cases and `offset:`, via `Intl.PluralRules`; + - `select`, with `#` inside plural cases; + - `number` (default, `percent`, `integer`, `::currency/USD` skeleton subset) via + `Intl.NumberFormat`; + - `date` and `time` (`short`, `medium`, `long`, `full`) via `Intl.DateTimeFormat`; + - apostrophe quoting. + + Anything else is rejected by `rosetta check`, so it can't reach the runtime. +- **Rich text:** `t.rich` maps each tag to a function that returns a React node, or + an HTML string in Astro. Tags without a handler render their inner text, and warn + in development. +- **Deterministic output.** Formatting uses the request's locale and an explicit + `timeZone` (config `routing.timeZone`, default `UTC`), so server and client + render identical output. +- **Types.** An optional generated `rosetta.d.ts` (`rosetta types`, §9) gives key + autocompletion and type-checks the values each message needs. + +## 7. Locale negotiation + +`negotiateLocale(request)` is pure and runs in middleware and Astro SSR: + +1. **URL prefix.** `/es/...` means `es`. It's an exact match against the configured + locales, case-insensitive (`/pt-br/` is accepted and redirected to `/pt-BR/`). +2. **Cookie**, if `detect` includes it and the value is a configured locale. +3. **`Accept-Language`**, if `detect` includes it. It's parsed with q-values, then + matched in this order: exact match, then same language with a different region + (`pt-PT` → `pt-BR`), then the base language. +4. **Otherwise**, the source locale. + +Bots and crawlers (by user-agent) skip steps 2 and 3, so search engines always see +the URL's own locale. + +## 8. Adapters + +### 8.1 Next.js (App Router) + +```ts +// next.config.ts +import { withRosetta } from "rosetta-i18n/next/plugin"; +export default withRosetta({ /* your Next config */ }); + +// i18n.ts +import { defineI18n } from "rosetta-i18n/next"; +export const i18n = defineI18n({ + load: (locale) => import(`./messages/${locale}.json`), +}); + +// proxy.ts (Next 16+) or middleware.ts (Next 15) +export { proxy, config } from "./i18n"; // also exported as `middleware` + +// app/[locale]/layout.tsx +import { i18n } from "@/i18n"; +export const generateStaticParams = i18n.generateStaticParams; +export default i18n.Layout; // provider + + default alternates metadata +``` + +**Middleware or proxy behavior** under `prefix-except-default`: + +| Request | Result | +|---|---| +| `/es/pricing` | Rewrite to the `[locale]` route with `es`; set the cookie to `es` | +| `/pricing`, negotiated `es` | 307 redirect to `/es/pricing` | +| `/pricing`, negotiated `en` (source) | Internal rewrite to `/en/pricing` (the URL stays `/pricing`) | +| `/en/pricing` | 308 redirect to `/pricing` (the source locale is never prefixed) | +| `/_next/*`, `/api/*`, files with extensions, non-`localizedPaths` | Untouched | + +Next.js 16 renamed `middleware.ts` to `proxy.ts`, and proxy runs on the Node.js +runtime by default. Next 15's `middleware.ts` runs on the edge by default. The same +handler is exported as both `proxy` and `middleware` and must stay edge-safe: no +`node:*` imports, and the manifest is inlined at build time. + +Every localized request gets an `x-rosetta-locale` header. The default `config` +matcher excludes static assets. Users with their own middleware can compose +`i18n.handle(request)` into it. + +**Server components:** + +```tsx +import { i18n } from "@/i18n"; +export default async function Page() { + const t = await i18n.getT("Pricing"); // locale comes from x-rosetta-locale + return

{t("title")}

; +} +export const generateMetadata = i18n.metadata(async (t) => ({ title: t("Pricing.meta.title") })); +``` + +**Client components:** + +- `useT`, `useLocale` and `` come from `rosetta-i18n/react`, and they work because + `i18n.Layout` renders the provider. +- **Only the messages a client component needs are sent to the browser.** + `i18n.Layout` takes an optional `clientNamespaces` list, and by default sends the + whole catalog, since keeping it lean is opt-in in v1. + +**Navigation:** + +- `i18n.Link` wraps `next/link`: `href="/pricing"` becomes `/es/pricing` under the + current locale. A `locale` prop switches locale. +- `i18n.redirect`, `i18n.usePathname` and `i18n.useRouter` all work on paths without + the locale prefix. +- ``: shows the current locale, lists the rest by display name, sets + the cookie, and navigates to the same path in the new locale. It has no styles and + can be replaced by a render prop. + +**SEO:** `i18n.alternates("/pricing")` returns: + +- the canonical URL; +- a `languages` map for every locale; +- an `x-default` entry pointing at the source-locale URL. + +`i18n.Layout` adds these automatically for the current path. `i18n.sitemap(paths)` +expands a sitemap with an entry per locale. + +### 8.2 React (any setup: Vite, custom SSR, …) + +```tsx +import { RosettaProvider, useT } from "rosetta-i18n/react"; +import manifest from "./i18n.json"; // from `rosetta manifest` + + import(`./messages/${l}.json`)} +> + + +``` + +- The provider: + - suspends while a locale loads; + - keeps the previous locale on screen during a switch, so the page doesn't flash; + - sets `document.documentElement.lang` and `dir`. +- `useLocale()` returns `{ locale, locales, setLocale }`. `setLocale` sets the cookie + and loads the new catalog. +- Routing is out of scope. The provider works with any router, and a path-prefix + helper (`localizePath`, `stripLocale`) is exported. + +### 8.3 Astro + +```js +// astro.config.mjs +import rosetta from "rosetta-i18n/astro"; +export default defineConfig({ integrations: [rosetta()] }); +``` + +- The integration calls `updateConfig` to set Astro's built-in i18n from the Rosetta + config: + - `locales`; + - `defaultLocale` = `sourceLocale`; + - `routing.prefixDefaultLocale` = (`strategy === "prefix-always"`). +- **Detection:** + - **SSR** (`output: "server"` or on-demand pages): it injects middleware with the + same negotiation and cookie behavior as Next.js. + - **Static builds:** it can't redirect on the server. There's an optional, + opt-in inline script that does a one-time client-side redirect from `/` using + the cookie and `navigator.languages`. +- **Pages:** + + ```astro + --- + import { getT, localePaths } from "rosetta-i18n/astro"; + export const getStaticPaths = localePaths; // one page per locale under src/pages/[...locale]/ + const t = await getT(Astro, "Home"); + --- +

{t("title")}

+

`${c}` })} /> + ``` + +- **Components:** `` (plain `` links and no JavaScript, or + `client:load` for the cookie), `` (`hreflang` `` tags), and + `` for `lang` and `dir`. +- **Messages** are loaded through `import.meta.glob` over the config's `files` + patterns, so no `load()` function is needed. +- **Proving ground:** rosetta.tools, translated into several languages with Rosetta + itself. + +## 9. CLI additions + +| Command | What it does | +|---|---| +| `rosetta init --next` | Writes `i18n.ts`, `proxy.ts` (or `middleware.ts` for Next 15) and the `withRosetta` wrapper, and prints the one manual step: moving `app/*` into `app/[locale]/`. `--move-app` does that move with `git mv`. | +| `rosetta init --astro` | Adds the integration to `astro.config.*` and, with `--move-pages`, moves pages under `src/pages/[...locale]/`. | +| `rosetta manifest` | Prints the runtime manifest (locales, display names, directions, routing) for plain React apps. | +| `rosetta types` | Writes `rosetta.d.ts`, with message keys and argument types from the source catalog. | + +## 10. Testing + +- **Core:** + - the formatter, tested against a table of ICU cases, with pinned output for every + locale's plural rules; + - the fallback chain; + - negotiation, including q-values, region matching and bots. +- **React:** Testing Library tests for the provider, hooks, ``, switching, and + suspense. +- **Next.js:** + - unit tests for every row in the middleware table (§8.1), with real `NextRequest` + objects; + - an `examples/next` app built and smoke-tested in CI with `next build`, + `next start`, and `curl` for redirects, cookies, `lang`/`dir` and `hreflang`. +- **Astro:** an `examples/astro` static site plus an SSR build in CI, and the + rosetta.tools site itself. +- **Hydration:** a test that renders on the server and hydrates on the client, + asserting no mismatch warnings. This covers dates, numbers and plurals in `ar`, + `ja` and `pt-BR`. + +## 11. Phases + +1. **Core and `/react`:** formatter, fallback, negotiation, provider, hooks, ``, + switcher, and `rosetta manifest`. +2. **`/next`:** `withRosetta`, `defineI18n`, middleware/proxy, layout, `getT`, + navigation, SEO helpers, `examples/next`, and `rosetta init --next`. Then + replace Jot's hand-written locale layer (`locales.ts`, the `x-jot-locale` header, + the cookie, `LOCALIZED_PUBLIC_PATHS`) as the real-world test. +3. **`/astro`:** the integration, components, `examples/astro`, + `rosetta init --astro`, and translating rosetta.tools. +4. **Types:** `rosetta types`, plus typed `t()` in all three adapters. + +## 12. Decisions to confirm + +1. **Own runtime, not a preset on top of next-intl.** Proposed: our own runtime, for + one API across all three frameworks and no dependencies. +2. **Default strategy `prefix-except-default`** (the source locale has no prefix), + matching Jot. Proposed: yes. +3. **Missing keys fall back to the source locale at runtime.** Proposed: yes. This is + a behavior change for projects that currently rely on parity tests to catch + missing keys, but `rosetta check` still does that in CI. +4. **The Next.js minimum is 15**, with the `proxy.ts` naming on 16+ and + `middleware.ts` on 15. Proposed: yes; Next 14 isn't supported. +5. **Scaffolding writes files but doesn't move `app/` unless `--move-app` is passed.** + Proposed: yes. From d7baaa588a6d395f8e9be90354d464cc9932e457 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Tue, 29 Sep 2026 20:45:09 -0500 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20runtime=20spec=20draft=202=20?= =?UTF-8?q?=E2=80=94=20static-first=20Next.js=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/spec-runtime.md | 676 +++++++++++++++++++++++++++---------------- 1 file changed, 419 insertions(+), 257 deletions(-) diff --git a/docs/spec-runtime.md b/docs/spec-runtime.md index 7990876..d6198ba 100644 --- a/docs/spec-runtime.md +++ b/docs/spec-runtime.md @@ -1,10 +1,10 @@ # Rosetta runtime spec: React, Next.js, Astro -- **Status:** Draft, for discussion +- **Status:** Draft 2, for discussion - **Builds on:** [`spec-v1.md`](./spec-v1.md), which covers translation. This spec covers rendering and routing. -- **Package:** the same `rosetta-i18n`, with new entry points `rosetta-i18n/react`, - `rosetta-i18n/next` and `rosetta-i18n/astro` +- **Package:** the same `rosetta-i18n`, with new entry points under + `rosetta-i18n/react`, `rosetta-i18n/next/*` and `rosetta-i18n/astro` ## 1. Summary @@ -12,288 +12,422 @@ Rosetta v1 writes translated locale files. Rendering them, and all the plumbing around locales, is still left to each project. That plumbing includes: - a locale list copied into several places; -- middleware that detects the visitor's language and redirects `/pricing` to `/es/pricing`; -- a cookie that remembers the choice; +- routing that serves `/es/pricing` and detects the visitor's language; - a provider that loads the right messages; - a locale switcher, and links that keep the current locale; - ``, `hreflang` alternates, and static params for every locale. -Every project rebuilds this, and it's the most tedious part of internationalization. - The runtime removes it. **`.rosetta/config.json` drives both translation and -routing.** Its `sourceLocale`, `targetLocales` and file layout already say which -locales exist and where their messages live. Add a locale to the config, run -`rosetta push`, and routing, the switcher, and SEO tags pick it up with no other -changes. - -## 2. Goals and non-goals +routing.** Add a locale to the config, run `rosetta push`, rebuild, and routing, +the switcher, and SEO tags pick it up with no other changes. + +The runtime is designed **performance first, ergonomics second**. Where the +two conflict, the runtime picks the fast option and makes the slow option an +explicit opt-in. It never silently falls back to a slower rendering mode. + +## 2. Performance invariants + +These are requirements, not goals. Each has a CI check (§10). + +1. **Every localized page can be prerendered.** No runtime API used in rendering + reads `headers()`, `cookies()`, or anything else that opts a Next.js route + into dynamic rendering. The locale always comes from the route (`[locale]`), + never from the request. +2. **Server-rendered text sends no catalog to the browser.** A server component + that calls `t()` sends only the resulting HTML. Messages reach the client only + for namespaces you explicitly hand to a client provider (§5.3). +3. **No duplicate payloads.** Fallback to the source locale is resolved on the + server, once per locale. The client never receives both a target catalog and + the source catalog. +4. **Small client runtime.** Lookup plus the ICU formatter is tree-shakeable and + budgeted at **≤ 3 kB min+gzip**. A page with no client-side `t()` ships + **0 bytes** of formatter; `Link` and `useLocale` add only a context read. +5. **No routing function when you don't need one.** With detection off, routing + is expressed as static `rewrites`/`redirects` in `next.config`, so no + middleware or proxy runs. With detection on, the proxy imports only the + manifest (budget **≤ 5 kB**), never catalogs, and does no work on prefixed + URLs. +6. **At most one redirect, once.** Crawlers are never redirected. A human is + redirected at most once (first visit to an unprefixed URL, when their language + isn't the source). Redirect responses are `Cache-Control: private` and + `Vary: Cookie, Accept-Language`; page responses keep their normal caching. +7. **No `Set-Cookie` on page responses.** Only an explicit choice (the switcher) + writes the locale cookie, and it does so on the client. Pages stay cacheable + at the CDN. +8. **Fail loudly instead of going dynamic.** If the runtime can't find the locale + statically (e.g. on a Next.js version without `next/root-params`, or in a + Server Action), it throws a build-time or dev error that says which + argument to pass. It never falls back to reading headers. + +## 3. Goals and non-goals ### Goals -- **Minimal setup.** Next.js: about three small files. Astro: one integration line. - React: one provider. +- **Minimal setup.** Next.js: `next.config`, one `i18n.ts`, and the root layout. + Astro: one integration line. React: one provider. - **One source of truth** for locales: the Rosetta config. -- **Automatic locale handling.** Detect the locale (URL, then cookie, then - `Accept-Language`, then the default), redirect, remember the choice, and switch. -- **Correct by default.** ``, `dir="rtl"` for RTL locales, canonical and - `hreflang` alternates, and no hydration mismatches. +- **The same `t()` everywhere:** server components, client components, and + `.astro` files. +- **Correct by default:** ``, `dir="rtl"` for RTL locales, canonical + and `hreflang` alternates. - **Missing translations fall back to the source locale** instead of breaking. - Shipping a new English key before its translations land is safe. -- **The same `t()` everywhere:** client components, server components, and `.astro` - files. - **Zero runtime dependencies.** Formatting uses the built-in `Intl` APIs. React, Next.js and Astro are optional peer dependencies. -- **Edge-safe.** Nothing on a request path imports `node:*`. ### Non-goals for the first release -- Frameworks other than React, Next.js (App Router), and Astro. That rules out - Vue, Svelte, Solid, Remix-specific adapters, and the Next.js Pages Router. -- Domain- or subdomain-per-locale routing (e.g. `es.example.com`). Planned; the - config leaves room for it. -- Translating on the client at runtime. The runtime renders pre-translated catalogs. - Dynamic content still uses the server-only `cachedTranslate`. +- Frameworks other than React, Next.js (App Router), and Astro. +- Domain- or subdomain-per-locale routing. Planned; the config leaves room. - Localized URL slugs (`/es/precios`). Planned. -- A full ICU implementation. We support what `Intl` covers (§6); anything else is a - validation error. - -## 3. Packaging - -| Entry point | Built on | Runs in | Provides | -|---|---|---|---| -| `rosetta-i18n/react` | internal core | browser, server | `RosettaProvider`, `useT`, `useLocale`, ``, ``, `detectBrowserLocale` | -| `rosetta-i18n/next` | `/react` + core | Node, edge, browser | `withRosetta`, `defineI18n` (middleware/proxy, layout, `getT`, `Link`, alternates, static params) | -| `rosetta-i18n/astro` | core | Node (build/SSR), browser (switcher) | the integration, `getT`, ``, ``, `localePaths` | - -- The **internal core** (`src/runtime/`) is not a public entry point. It holds - message lookup and fallback, the ICU formatter, locale negotiation, and turning - the config into a runtime locale list. It is about 300 lines of TypeScript with no - dependencies. The ICU parser already exists in `src/validate.ts`; the core adds - a formatter on top. -- **Optional peers:** `react >= 18`, `next >= 15`, `astro >= 5`. -- **Astro components** (`.astro` files) ship as source under - `rosetta-i18n/astro/components/*`. Rollup doesn't bundle them. -- **Why not a preset over next-intl?** It would only cover Next.js and would tie us - to next-intl's versions. Our own small runtime gives one `t()` across all three - frameworks, and it reuses the ICU parser we already maintain. - -## 4. Configuration - -The runtime reads the existing config. An optional `routing` block adds routing -settings; everything in it has a default. +- Translating on the client at runtime. The runtime renders pre-translated + catalogs; dynamic content still uses the server-only `cachedTranslate`. +- A full ICU implementation. We support what `Intl` covers (§6); `rosetta check` + rejects the rest. + +## 4. Packaging + +| Entry point | Runs in | Provides | +|---|---|---| +| `rosetta-i18n/react` | browser, server | `RosettaProvider`, `useT`, `useLocale`, `LocaleSwitcher`, `localizePath`, `stripLocale` (`"use client"`) | +| `rosetta-i18n/next` | server only | `defineI18n` (`getT`, `getLocale`, `htmlAttrs`, `Provider`, `alternates`, `sitemap`, `generateStaticParams`), plus the existing `createRosetta` / `cachedTranslate` | +| `rosetta-i18n/next/client` | browser, server | `Link`, `usePathname`, `useRouter`, `LocaleSwitcher`, `LocaleSuggestion`, and re-exports of `useT` / `useLocale` (`"use client"`) | +| `rosetta-i18n/next/plugin` | Node (build) | `withRosetta` | +| `rosetta-i18n/next/proxy` | edge, Node | `createProxy` (detection only; imports the manifest, nothing else) | +| `rosetta-i18n/astro` | Node (build/SSR), browser | the integration, `getT`, `localePaths`, components | + +- **Server/client split.** `rosetta-i18n/next` is server-only (it imports + `server-only`), which lets it keep the existing `createRosetta` and + `cachedTranslate` exports. Everything a client component can import lives in + `/next/client` or `/react`. The rollup build must keep `"use client"` + directives at the top of those output chunks, using a directive-preserving + plugin or `preserveModules`. +- **Internal core** (`src/runtime/`, not public): message lookup and fallback, + the ICU formatter, locale negotiation, and building the manifest. No + dependencies. It needs an **AST-producing ICU parser**. Today's parser in + `src/validate.ts` only returns a structural signature, so it gets refactored to + build an AST, and `validate.ts` derives its signature from that AST. Validation + and rendering then share one parser. +- **Peer dependencies:** `react >= 18`, `next >= 14` (unchanged, because + `cachedTranslate` still supports 14), `astro >= 5`. The routing runtime needs + `next >= 15` and checks for it at build time; automatic locale resolution + needs `>= 16.3` (§8.1). +- **Astro components** ship as source under `rosetta-i18n/astro/components/*`. + +## 5. Configuration and messages + +### 5.1 Config + +The runtime reads the existing config. An optional `routing` block, in which +every field has a default, adds routing settings. An optional `format` block +adds formatting settings. ```jsonc // .rosetta/config.json { "sourceLocale": "en", - "targetLocales": ["es", "ja", "ar"], + "targetLocales": ["es", "ja", "ar", "pt-BR"], "files": [{ "pattern": "messages/en.json" }], "routing": { - "strategy": "prefix-except-default", // or "prefix-always" - "detect": ["cookie", "accept-language"], // order after the URL prefix; [] disables detection - "cookie": { "name": "locale", "maxAge": 31536000 }, - "localizedPaths": ["/", "/pricing", "/blog/**"] // optional allowlist; default: every path - } + "strategy": "prefix-except-default", // or "prefix-always" + "detect": "redirect", // "redirect" | "suggest" | "off" + "cookie": "rosetta-locale", + "localizedPaths": ["/", "/pricing", "/blog/**"] // default: every path + }, + "format": { "timeZone": "UTC" } } ``` | Field | Default | Meaning | |---|---|---| -| `strategy` | `prefix-except-default` | `prefix-except-default`: the source locale is served at `/pricing` and the others at `/es/pricing`. `prefix-always`: every locale is prefixed, and `/` redirects. | -| `detect` | `["cookie", "accept-language"]` | How a visitor with no locale prefix is matched to a locale. | -| `cookie` | `{ name: "locale", maxAge: 1 year }` | Set whenever a locale is chosen explicitly (a prefixed URL, or the switcher). | -| `localizedPaths` | every path | Glob allowlist. Other paths stay source-only and never redirect (e.g. `/app/**`, `/api/**`). | - -- **Derived values.** Each locale's display name comes from `Intl.DisplayNames`, - shown in that locale's own language. Text direction comes from - `Intl.Locale.prototype.textInfo` where available, with a fallback list - (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `yi`, `dv`, `ug`, `ku`). -- **Getting the config to the runtime.** It's JSONC and read from disk, so the - runtime never parses it at request time. - - **Next.js:** `withRosetta()` reads it at build time and inlines a compact JSON - manifest through `env` (`process.env.ROSETTA_I18N`). That works in both the edge - and Node runtimes. - - **Astro:** the integration reads it at build or startup time and passes the - manifest to the virtual module `virtual:rosetta/manifest`. - - **Plain React:** there's no config file at runtime. Pass `locales` to the - provider, or generate a manifest with `rosetta manifest > src/i18n.json` (a new - CLI command, §9). - -## 5. Messages and fallback - -- **Loading.** Messages load through a `load(locale)` function you supply, e.g. - `(locale) => import(\`./messages/${locale}.json\`)`, so the bundler can split them - into one chunk per locale. Projects with several files (namespaces) return a - merged object, and helpers are provided. -- **Fallback chain:** - 1. the requested locale; - 2. its base language, if it's configured (e.g. `pt-BR` falls back to `pt`); - 3. the source locale; - 4. the key itself. - - Steps 3 and 4 log a warning in development. -- **Merging with the source locale.** The source locale's messages are always - loaded and deep-merged underneath the target's. This is what makes "ship the - English key now, translations follow" safe. It also means projects no longer need - locale-parity tests to protect production, though `rosetta check` can still gate - CI. -- **Performance.** Compiled messages are cached per locale and key, so each message - is parsed once. +| `routing.strategy` | `prefix-except-default` | `prefix-except-default`: the source locale is served at `/pricing`, others at `/es/pricing`. `prefix-always`: every locale is prefixed. | +| `routing.detect` | `redirect` | `redirect`: a small proxy sends first-time visitors to their language (§7). `suggest`: no server work; a client `` banner offers the switch. `off`: no detection, no proxy. | +| `routing.cookie` | `rosetta-locale` | Name of the cookie the switcher writes. It's read only by the `redirect` proxy. | +| `routing.localizedPaths` | every path | Glob allowlist. Other paths stay source-only and are never rewritten or redirected. | +| `format.timeZone` | `UTC` | Time zone for `date`/`time` formatting. It's fixed so the server and client produce the same output. | + +- **Schema compatibility.** `schema/config.json` has `additionalProperties: false`, + so today's CLI rejects `routing` and `format`. Both are added to the schema in + the release that ships the runtime, and the docs say to upgrade the CLI and the + pinned GitHub Action together. +- **Locale codes.** Codes in the config are normalized to BCP 47 (`pt_BR` becomes + `pt-BR`) for URLs, `lang`, `hreflang` and the manifest. File paths keep + whatever form the files use; the mapping reuses `src/project/paths.ts`. +- **Derived values.** Display names come from `Intl.DisplayNames`, in each + locale's own language. Direction comes from `Intl.Locale#textInfo` (or + `getTextInfo()`), with a fallback list (`ar he fa ur ps sd yi dv ug ku`). + Both are computed **at build time** and stored in the manifest. + +### 5.2 The manifest + +The runtime never parses JSONC at request time. A build step turns the config +into a compact **manifest**: locales, source locale, display names, directions, +routing settings, and the format settings. + +- **Next.js:** `withRosetta()` builds it when Next loads its config and inlines + it through `env.ROSETTA_I18N`. It's available on the server, on the edge, and + in the browser, and it's a few hundred bytes. Changing `routing` requires + restarting the dev server. +- **Astro:** the integration exposes it as `virtual:rosetta/manifest`. +- **Plain React:** `rosetta manifest > src/i18n.json`. `rosetta check` fails + (exit `3`) if that file is stale, so it can't drift from the config. + +### 5.3 Loading, fallback, and what reaches the client + +- **Loading.** On the server, messages load through a `load(locale)` you supply + (Next, React) or a generated virtual module (Astro). Multi-file configs + (`locales/en/**/*.json`) map each file to a namespace from its path relative to + the pattern's static prefix: `locales/en/auth/login.json` becomes + `auth.login`. `rosetta init` generates the matching `load`. +- **Fallback chain:** requested locale, then its base language if configured + (`pt-BR` → `pt`), then the source locale, then the key itself. Steps 3 and 4 + warn in development. +- **Resolved once, on the server.** For each locale, the server merges the + target catalog over the source catalog once and caches the result in module + scope. During `next build` that happens once per locale for the whole build. +- **A fallback message formats with the source locale's plural rules and the + requested locale's number and date formatting.** An English `one`/`other` + message therefore stays grammatical when shown to an Arabic visitor. +- **`ignoredKeys`** (omitted from target files) render in the source language. + That's the intent of ignoring a key. +- **What the client gets.** Nothing, by default. A client component that needs + `useT` must sit under a `Provider` that names its namespaces (§8.1). Each + provider sends only those namespaces, already merged, as plain strings. It + sends them once, as part of the RSC payload for the segment it's rendered in. + In development, `useT` on a namespace that wasn't provided throws an error + naming the namespace to add. In production it renders the key and logs once. +- **Recommended pattern.** Translate in server components and pass the resulting + strings to client components as props. Use `useT` on the client only where the + message changes with client state (a live count, a form error). ## 6. Formatting API -The same API is available everywhere: `useT()` in client components, `await getT()` -in server components, and `getT(Astro)` in `.astro` files. +The same API is used everywhere: `await i18n.getT()` in server components, +`useT()` in client components, and `getT(Astro)` in `.astro` files. ```ts -const t = useT("Pricing"); // namespace = key prefix (optional) +const t = await i18n.getT("Pricing"); // namespace = key prefix (optional) t("cta"); // "Start free" -t("trial", { days: 14 }); // ICU: "{days, plural, one {# day} other {# days}} free" -t.rich("terms", { terms: (chunks) => {chunks} }); // … +t("trial", { days: 14 }); // "{days, plural, one {# day} other {# days}} free" +t.rich("terms", { terms: (chunks) => {chunks} }); t.has("beta"); // boolean t.raw("list"); // unformatted value (arrays, objects) ``` -- **ICU support:** - - simple `{arg}`; - - `plural` and `selectordinal`, with `=N` cases and `offset:`, via `Intl.PluralRules`; - - `select`, with `#` inside plural cases; - - `number` (default, `percent`, `integer`, `::currency/USD` skeleton subset) via - `Intl.NumberFormat`; - - `date` and `time` (`short`, `medium`, `long`, `full`) via `Intl.DateTimeFormat`; - - apostrophe quoting. - - Anything else is rejected by `rosetta check`, so it can't reach the runtime. -- **Rich text:** `t.rich` maps each tag to a function that returns a React node, or - an HTML string in Astro. Tags without a handler render their inner text, and warn - in development. -- **Deterministic output.** Formatting uses the request's locale and an explicit - `timeZone` (config `routing.timeZone`, default `UTC`), so server and client - render identical output. -- **Types.** An optional generated `rosetta.d.ts` (`rosetta types`, §9) gives key - autocompletion and type-checks the values each message needs. +- **ICU subset:** `{arg}`; `plural` and `selectordinal` with `=N` cases and + `offset:` (`Intl.PluralRules`); `select`; `#`; `number` (default, `percent`, + `integer`, and a `::currency/XXX` skeleton subset) via `Intl.NumberFormat`; + `date` and `time` (`short`, `medium`, `long`, `full`) via + `Intl.DateTimeFormat`; apostrophe quoting. +- **Enforced by `check`.** Today `check` only compares each target with its + source, and it falls back to loose matching for non-ICU sources such as + i18next `{{name}}`. The runtime adds a **source lint**: when a `routing` block + is present, every source message must parse into the supported subset, or + `check` fails with the key and the unsupported construct. Projects without + `routing` keep today's behavior. If an invalid message reaches the runtime + anyway, it renders the raw string and logs once. It never throws in + production. +- **Fast paths.** A message with no `{`, `<` or `'` is returned as-is, with no + parse. Other messages are compiled on first use and memoized per locale and + key. +- **Rich text:** in React, `t.rich` maps each tag to a function that returns a + node. In Astro it returns an HTML string, and **message text and arguments + are HTML-escaped before the tag functions run**, since translations come + from an LLM. Tags with no handler render their inner text and warn in + development. +- **Deterministic output.** Formatting uses the route's locale and + `format.timeZone`. Node's ICU data and the browser's can still differ in + small ways (e.g. U+202F in times). That's one more reason to format on the + server; the hydration test (§10) covers the client path. +- **Build-time freeze.** Statically rendered pages format dates at build time. + Relative or "now"-based values belong in a client component, or in a + revalidated or dynamic segment. ## 7. Locale negotiation -`negotiateLocale(request)` is pure and runs in middleware and Astro SSR: +`negotiateLocale(input)` is a pure function over the URL, the cookie value and +the `Accept-Language` value. It runs in the Next proxy, in Astro SSR middleware, +and in `` in the browser. -1. **URL prefix.** `/es/...` means `es`. It's an exact match against the configured - locales, case-insensitive (`/pt-br/` is accepted and redirected to `/pt-BR/`). -2. **Cookie**, if `detect` includes it and the value is a configured locale. -3. **`Accept-Language`**, if `detect` includes it. It's parsed with q-values, then - matched in this order: exact match, then same language with a different region - (`pt-PT` → `pt-BR`), then the base language. -4. **Otherwise**, the source locale. +1. **URL prefix.** An exact, case-insensitive match against the configured + locales. `/pt-br/…` redirects to `/pt-BR/…`. +2. **Cookie**, if it holds a configured locale. +3. **Language list** (`Accept-Language` on the server, `navigator.languages` in + the browser), weighted by q-value and matched in this order: exact match, then + same language with a different region (`pt-PT` → `pt-BR`), then base language. +4. Otherwise, the source locale. -Bots and crawlers (by user-agent) skip steps 2 and 3, so search engines always see -the URL's own locale. +Requests from bots (by user-agent) skip steps 2 and 3. ## 8. Adapters ### 8.1 Next.js (App Router) +**Locale resolution.** On Next.js **16.3+**, `next/root-params` lets any server +component read the `[locale]` root param. That includes nested components and +`generateMetadata`, and it works without `headers()`, so pages stay fully +static. It doesn't yet work in Route Handlers or Server Actions. There, and on +older Next.js versions, you pass the locale explicitly (the "explicit mode" +below). The runtime never reads a request header to find the locale. + +`next/root-params` is a module Next generates for the app. So the import lives in +the user's `i18n.ts` rather than inside our package, and older versions of Next +simply leave it out: + ```ts // next.config.ts import { withRosetta } from "rosetta-i18n/next/plugin"; export default withRosetta({ /* your Next config */ }); -// i18n.ts +// i18n.ts — server-only +import { locale } from "next/root-params"; // Next 16.3+ import { defineI18n } from "rosetta-i18n/next"; export const i18n = defineI18n({ - load: (locale) => import(`./messages/${locale}.json`), + locale, // omit on Next 15–16.2 + load: (l) => import(`./messages/${l}.json`), }); +``` -// proxy.ts (Next 16+) or middleware.ts (Next 15) -export { proxy, config } from "./i18n"; // also exported as `middleware` - -// app/[locale]/layout.tsx +```tsx +// app/[locale]/layout.tsx — the root layout (no app/layout.tsx above it) import { i18n } from "@/i18n"; export const generateStaticParams = i18n.generateStaticParams; -export default i18n.Layout; // provider + + default alternates metadata -``` - -**Middleware or proxy behavior** under `prefix-except-default`: - -| Request | Result | -|---|---| -| `/es/pricing` | Rewrite to the `[locale]` route with `es`; set the cookie to `es` | -| `/pricing`, negotiated `es` | 307 redirect to `/es/pricing` | -| `/pricing`, negotiated `en` (source) | Internal rewrite to `/en/pricing` (the URL stays `/pricing`) | -| `/en/pricing` | 308 redirect to `/pricing` (the source locale is never prefixed) | -| `/_next/*`, `/api/*`, files with extensions, non-`localizedPaths` | Untouched | -Next.js 16 renamed `middleware.ts` to `proxy.ts`, and proxy runs on the Node.js -runtime by default. Next 15's `middleware.ts` runs on the edge by default. The same -handler is exported as both `proxy` and `middleware` and must stay edge-safe: no -`node:*` imports, and the manifest is inlined at build time. +export default async function RootLayout({ children }: LayoutProps<"/[locale]">) { + return ( + {/* lang + dir */} + + {children} + + + ); +} +``` -Every localized request gets an `x-rosetta-locale` header. The default `config` -matcher excludes static assets. Users with their own middleware can compose -`i18n.handle(request)` into it. +- The layout is yours. The runtime supplies attributes and a provider, and + doesn't own ``, fonts, or ``. +- `i18n.Provider` is an async server component. It renders the client provider + with the locale (always) and the listed namespaces (only those). A provider + with no `messages` costs a context value and no catalog. Providers nest, and + a nested provider adds namespaces for its subtree. +- `i18n.getLocale()` validates the param and calls `notFound()` for unknown + locales. That works with Cache Components, where `dynamicParams = false` + doesn't. Without Cache Components, `rosetta init --next` also emits + `export const dynamicParams = false`. -**Server components:** +**Pages and metadata:** ```tsx +// app/[locale]/pricing/page.tsx import { i18n } from "@/i18n"; +import { Calculator } from "./calculator"; // "use client", uses useT("Pricing.calc") + export default async function Page() { - const t = await i18n.getT("Pricing"); // locale comes from x-rosetta-locale - return

{t("title")}

; + const t = await i18n.getT("Pricing"); + return ( + <> +

{t("title")}

+ + + ); +} + +export async function generateMetadata() { + const t = await i18n.getT("Pricing"); + return { title: t("meta.title"), alternates: await i18n.alternates("/pricing") }; } -export const generateMetadata = i18n.metadata(async (t) => ({ title: t("Pricing.meta.title") })); ``` -**Client components:** +- **Alternates are per page**, because layouts in Next don't know the current + path. Dynamic routes build the path from params, e.g. + ``i18n.alternates(`/blog/${slug}`)``. +- **`i18n.sitemap(entries)`** adds `alternates.languages` to each entry of a + `sitemap.ts`. Google accepts `hreflang` in the sitemap, so this is the + recommended default: one build-time file instead of tags on every page. + +**Explicit mode** (Server Actions, Route Handlers, `"use cache"` functions, and +Next 15–16.2): every server API accepts `{ locale }`: + +```ts +const t = await i18n.getT({ locale, namespace: "ContactForm" }); +``` -- `useT`, `useLocale` and `` come from `rosetta-i18n/react`, and they work because - `i18n.Layout` renders the provider. -- **Only the messages a client component needs are sent to the browser.** - `i18n.Layout` takes an optional `clientNamespaces` list, and by default sends the - whole catalog, since keeping it lean is opt-in in v1. +In explicit mode, calling a server API without `locale` throws an error that +says "pass `{ locale }` or upgrade to Next 16.3". Client code gets the locale +from `useLocale()` and binds it into the action. -**Navigation:** +**Routing, with no middleware by default.** `withRosetta` adds static +`rewrites` and `redirects`, merged with any you already have: -- `i18n.Link` wraps `next/link`: `href="/pricing"` becomes `/es/pricing` under the - current locale. A `locale` prop switches locale. -- `i18n.redirect`, `i18n.usePathname` and `i18n.useRouter` all work on paths without - the locale prefix. -- ``: shows the current locale, lists the rest by display name, sets - the cookie, and navigates to the same path in the new locale. It has no styles and - can be replaced by a render prop. +| Request | Handled by | Result | +|---|---|---| +| `/es/pricing` | the `[locale]` route | served statically | +| `/pricing` | `beforeFiles` rewrite | served from `/en/pricing`; the URL stays `/pricing` | +| `/en/pricing` | `redirects` | 308 to `/pricing` (the source locale is never prefixed) | +| `/pt-br/pricing` | `redirects` | 308 to `/pt-BR/pricing` | +| `/_next/*`, `/api/*`, files, non-`localizedPaths` | — | untouched | -**SEO:** `i18n.alternates("/pricing")` returns: +Under `prefix-always`, unprefixed paths get a 307 redirect to the source locale, +and the proxy replaces that redirect when detection is on. -- the canonical URL; -- a `languages` map for every locale; -- an `x-default` entry pointing at the source-locale URL. +**Detection (`detect: "redirect"`)** adds a proxy that only decides whether to +redirect: -`i18n.Layout` adds these automatically for the current path. `i18n.sitemap(paths)` -expands a sitemap with an entry per locale. +```ts +// proxy.ts (Next 16+) or middleware.ts (Next 15) +export { proxy } from "rosetta-i18n/next/proxy"; +export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"] }; // must be a literal here +``` -### 8.2 React (any setup: Vite, custom SSR, …) +- It returns immediately for prefixed paths and non-localized paths, and for + bots. +- For an unprefixed path, it negotiates (§7). If the result is the source + locale, it passes the request through to the static rewrite. Otherwise it + sends a 307 to the prefixed URL with `Cache-Control: private` and + `Vary: Cookie, Accept-Language`. +- It never sets cookies and never touches catalogs. Users with their own + middleware call `rosettaProxy(request)` and use the returned response or + `null`. +- The matcher is generic on purpose, so adding a locale doesn't require editing + it. + +**`detect: "suggest"`** skips the proxy entirely. `` from +`/next/client` runs after hydration. If `navigator.languages` negotiates to a +different locale and no cookie is set, it shows a dismissible link to the same +page in that locale. It adds no server work and no redirect, and it doesn't +affect LCP. + +**Navigation (`/next/client`):** `Link` localizes `href` using the locale from +context (`href="/pricing"` becomes `/es/pricing`), and a `locale` prop switches +locale. `usePathname` and `useRouter` work on paths without the prefix. +`LocaleSwitcher` is unstyled and supports a render prop. It sets the cookie on +the client, then navigates. + +### 8.2 React (Vite, custom SSR, …) ```tsx -import { RosettaProvider, useT } from "rosetta-i18n/react"; +import { RosettaProvider } from "rosetta-i18n/react"; import manifest from "./i18n.json"; // from `rosetta manifest` import(`./messages/${l}.json`)} + locale={locale} // you own routing, or: + detect // URL prefix → cookie → navigator.languages + load={(l) => import(`./messages/${l}.json`)} // one chunk per locale + initialMessages={ssrMessages} // optional: skip the first fetch > ``` -- The provider: - - suspends while a locale loads; - - keeps the previous locale on screen during a switch, so the page doesn't flash; - - sets `document.documentElement.lang` and `dir`. -- `useLocale()` returns `{ locale, locales, setLocale }`. `setLocale` sets the cookie - and loads the new catalog. -- Routing is out of scope. The provider works with any router, and a path-prefix - helper (`localizePath`, `stripLocale`) is exported. +- The first locale's catalog should load in parallel with the app, not after + it. The provider exports `preloadLocale(locale)` so an entry point can start + the import before React renders. +- When the locale changes, the provider keeps the previous locale on screen + until the new catalog is ready (via `useTransition`, not a Suspense fallback), + then updates `document.documentElement.lang` and `dir`. +- `useLocale()` returns `{ locale, locales, setLocale }`. Routing is out of + scope; `localizePath` and `stripLocale` are exported. ### 8.3 Astro @@ -303,88 +437,116 @@ import rosetta from "rosetta-i18n/astro"; export default defineConfig({ integrations: [rosetta()] }); ``` -- The integration calls `updateConfig` to set Astro's built-in i18n from the Rosetta - config: - - `locales`; - - `defaultLocale` = `sourceLocale`; - - `routing.prefixDefaultLocale` = (`strategy === "prefix-always"`). -- **Detection:** - - **SSR** (`output: "server"` or on-demand pages): it injects middleware with the - same negotiation and cookie behavior as Next.js. - - **Static builds:** it can't redirect on the server. There's an optional, - opt-in inline script that does a one-time client-side redirect from `/` using - the cookie and `navigator.languages`. -- **Pages:** +- The integration sets Astro's `i18n` config (`locales`, `defaultLocale`, + `prefixDefaultLocale`) with `routing: "manual"`, so Astro supplies + `Astro.currentLocale` and its URL helpers and Rosetta owns routing. That + avoids running two routing layers. +- **Pages** use one `src/pages/[...locale]/` tree. `localePaths` returns an + undefined `locale` param for the source locale and the prefix for the others: ```astro --- import { getT, localePaths } from "rosetta-i18n/astro"; - export const getStaticPaths = localePaths; // one page per locale under src/pages/[...locale]/ + export const getStaticPaths = localePaths; const t = await getT(Astro, "Home"); ---

{t("title")}

`${c}` })} /> ``` -- **Components:** `` (plain `` links and no JavaScript, or - `client:load` for the cookie), `` (`hreflang` `` tags), and - `` for `lang` and `dir`. -- **Messages** are loaded through `import.meta.glob` over the config's `files` - patterns, so no `load()` function is needed. -- **Proving ground:** rosetta.tools, translated into several languages with Rosetta - itself. +- **Messages** come from a generated virtual module containing one + `import.meta.glob` call per config pattern. Vite requires literal glob + patterns, so the patterns are written into generated code rather than read at + runtime. +- **Detection:** in SSR, `detect: "redirect"` injects middleware that behaves the + same way as the Next proxy. For static builds, `redirect` isn't possible, so the + integration falls back to `suggest` and warns at build time. There's no + client-side redirect script, because redirecting after the page loads costs + more than it saves. +- **Components:** `` (plain links, no JavaScript), + `` (a small island), ``, and ``. +- **Proving ground:** rosetta.tools, translated with Rosetta itself. ## 9. CLI additions -| Command | What it does | -|---|---| -| `rosetta init --next` | Writes `i18n.ts`, `proxy.ts` (or `middleware.ts` for Next 15) and the `withRosetta` wrapper, and prints the one manual step: moving `app/*` into `app/[locale]/`. `--move-app` does that move with `git mv`. | -| `rosetta init --astro` | Adds the integration to `astro.config.*` and, with `--move-pages`, moves pages under `src/pages/[...locale]/`. | -| `rosetta manifest` | Prints the runtime manifest (locales, display names, directions, routing) for plain React apps. | -| `rosetta types` | Writes `rosetta.d.ts`, with message keys and argument types from the source catalog. | +| Command | What it does | Exit codes | +|---|---|---| +| `rosetta init --next` | Writes `i18n.ts` (with `next/root-params` on 16.3+), wraps `next.config`, and on `detect: "redirect"` writes `proxy.ts` or `middleware.ts`. Prints the manual move of `app/*` into `app/[locale]/`; `--move-app` does the move with `git mv`, and moves non-localized routes into a `(unlocalized)` group with its own root layout. | `0`, `2` | +| `rosetta init --astro` | Adds the integration; `--move-pages` moves pages under `src/pages/[...locale]/`. | `0`, `2` | +| `rosetta manifest` | Prints the runtime manifest for plain React apps. | `0`, `2` | +| `rosetta types` | Writes `rosetta.d.ts`, with message keys and argument types from the source catalog. | `0`, `2` | +| `rosetta check` (extended) | Adds the source lint (§6) and the stale-manifest check (§5.2). | unchanged: `0`, `2`, `3` | ## 10. Testing -- **Core:** - - the formatter, tested against a table of ICU cases, with pinned output for every - locale's plural rules; - - the fallback chain; - - negotiation, including q-values, region matching and bots. -- **React:** Testing Library tests for the provider, hooks, ``, switching, and - suspense. -- **Next.js:** - - unit tests for every row in the middleware table (§8.1), with real `NextRequest` - objects; - - an `examples/next` app built and smoke-tested in CI with `next build`, - `next start`, and `curl` for redirects, cookies, `lang`/`dir` and `hreflang`. -- **Astro:** an `examples/astro` static site plus an SSR build in CI, and the - rosetta.tools site itself. -- **Hydration:** a test that renders on the server and hydrates on the client, - asserting no mismatch warnings. This covers dates, numbers and plurals in `ar`, - `ja` and `pt-BR`. +- **Core:** a table of ICU cases, with pinned output for every locale's plural + rules; the fallback chain, including fallback plural rules; negotiation, + including q-values, region matching and bots. +- **React:** Testing Library tests for the provider, hooks, switching without + a flash, and `preloadLocale`. +- **Next.js:** `examples/next` is built in CI on the minimum supported Next.js + (explicit mode) and on the latest (root-params mode). CI asserts: + - **every localized route is prerendered** for every locale, checked by + reading `.next/prerender-manifest.json`. A route that turns dynamic fails + the build (invariant 1); + - **a page with no client provider has no message strings in its RSC payload** + (invariant 2); + - the client runtime and proxy bundle sizes stay within budget, using + `size-limit` (invariants 4 and 5); + - after `next start`, `curl` checks each row of the routing table, and checks + that the proxy never redirects a bot, that redirects carry `Vary`, and that + page responses have no `Set-Cookie` (invariants 6 and 7). +- **Astro:** `examples/astro` built as static and as SSR, plus the + rosetta.tools site. CI checks that the HTML output escapes a rich-text + message containing `