@pithy-sh/i18n 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +61 -0
- package/pithy.manifest.json +18 -0
- package/src/adapters/adapters.ts +138 -0
- package/src/browser/document.ts +106 -0
- package/src/browser/signals.ts +85 -0
- package/src/capability.ts +141 -0
- package/src/catalogs/browser.ts +39 -0
- package/src/catalogs/es/errors.ts +197 -0
- package/src/catalogs/es/index.ts +30 -0
- package/src/catalogs/es/screens.ts +149 -0
- package/src/catalogs/kit.ts +25 -0
- package/src/client/projection.ts +51 -0
- package/src/config/config.ts +152 -0
- package/src/http/middleware.ts +125 -0
- package/src/index.ts +20 -0
- package/src/react/translator.tsx +133 -0
- package/src/react/useNegotiatedLocale.ts +198 -0
- package/src/resolve/browser.ts +122 -0
- package/src/resolve/chain.ts +86 -0
- package/src/resolve/server.ts +55 -0
- package/src/settings/coverage.ts +58 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
import { createTranslator, type Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
7
|
+
import { getCookie } from "hono/cookie";
|
|
8
|
+
import type { I18nConfig } from "../config/config";
|
|
9
|
+
import type { ResolvedLocale } from "../resolve/chain";
|
|
10
|
+
import { resolveServerLocale } from "../resolve/server";
|
|
11
|
+
|
|
12
|
+
/** How a request's locale is looked up, given the locale that won. Supplied by the capability. */
|
|
13
|
+
export type LayersFor = (locale: string) => readonly (MessageCatalog | undefined)[];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one middleware this capability contributes: resolve the request's locale, and put a real
|
|
17
|
+
* translator on it.
|
|
18
|
+
*
|
|
19
|
+
* It **replaces** `c.var.t` rather than filling a null. Core seeds a translator over the baked English
|
|
20
|
+
* for every request whether or not this capability is composed, which is what lets every capability
|
|
21
|
+
* call `c.var.t(...)` with no null check, and what makes a project that never opts in behave byte for
|
|
22
|
+
* byte as it did before.
|
|
23
|
+
*
|
|
24
|
+
* A fresh translator per request, never a cached one. A Worker isolate is reused across requests from
|
|
25
|
+
* different readers, so anything holding a locale across that boundary applies request A's language to
|
|
26
|
+
* request B. It is the same hazard `z.config()` is banned repo-wide for, and it is why the `Intl`
|
|
27
|
+
* formatters are held on the translator — which lives and dies with one request — rather than in a
|
|
28
|
+
* module-level cache.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the query string is read off the URL here, and not through `zValidator`
|
|
31
|
+
*
|
|
32
|
+
* §HTTP's rule is that a route declares its request contract on the route line and a handler reads
|
|
33
|
+
* `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
|
|
34
|
+
* unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
|
|
35
|
+
* is no route line to declare a validator on and no handler to hand a typed value to — the rule's
|
|
36
|
+
* replacement does not exist at this position.
|
|
37
|
+
*
|
|
38
|
+
* What the rule is *for* still holds, and it holds structurally rather than by care: the value is one
|
|
39
|
+
* language range among four sources, every one of them is matched against `config.supportedLocales`
|
|
40
|
+
* before anything is done with it, and a range that matches nothing falls through to the next link.
|
|
41
|
+
* Nothing downstream ever sees a string this middleware did not already find in the project's own
|
|
42
|
+
* configured set. A reader who sends `?lang=<script>` gets the default locale.
|
|
43
|
+
*/
|
|
44
|
+
export function i18nMiddleware(config: I18nConfig, layersFor: LayersFor): PithyMiddleware {
|
|
45
|
+
return (app) => {
|
|
46
|
+
app.use("*", async (c, next) => {
|
|
47
|
+
// **Resolved on first read, not here, and that is what makes the `user` link work at all.**
|
|
48
|
+
//
|
|
49
|
+
// `createBackend` applies each capability's middleware in composition order — the order an
|
|
50
|
+
// adopter happened to list them in `pithy.config.ts`. Resolving eagerly therefore reads
|
|
51
|
+
// `c.var.auth` before `@pithy-sh/auth` has filled it whenever i18n is listed first, and the
|
|
52
|
+
// reader's stored language is silently dropped for half of all projects. Nothing fails; the
|
|
53
|
+
// chain just quietly becomes `param → cookie → header → default`.
|
|
54
|
+
//
|
|
55
|
+
// Both are `app.use("*")`, so both have run by the time any handler reads `c.var.t`. Deferring
|
|
56
|
+
// the resolution to that first read makes the answer identical in either order, which is a
|
|
57
|
+
// property rather than a convention — there is no ordering left to get wrong.
|
|
58
|
+
let resolved: ResolvedLocale | undefined;
|
|
59
|
+
const locale = (): ResolvedLocale =>
|
|
60
|
+
(resolved ??= resolveServerLocale(
|
|
61
|
+
{
|
|
62
|
+
param: queryParam(c.req.url, config.queryParam),
|
|
63
|
+
// The reader's own stored choice, off the row the session lookup already loaded.
|
|
64
|
+
user: c.var.auth?.locale ?? null,
|
|
65
|
+
cookie: getCookie(c, config.cookie) ?? null,
|
|
66
|
+
header: c.req.header("accept-language") ?? null,
|
|
67
|
+
},
|
|
68
|
+
config,
|
|
69
|
+
));
|
|
70
|
+
|
|
71
|
+
let translator: Translator | undefined;
|
|
72
|
+
const t = (): Translator =>
|
|
73
|
+
(translator ??= createTranslator({
|
|
74
|
+
catalogLocale: locale().catalogLocale,
|
|
75
|
+
formattingLocale: locale().formattingLocale,
|
|
76
|
+
layers: layersFor(locale().catalogLocale),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
c.set("locale", {
|
|
80
|
+
get catalogLocale() {
|
|
81
|
+
return locale().catalogLocale;
|
|
82
|
+
},
|
|
83
|
+
get formattingLocale() {
|
|
84
|
+
return locale().formattingLocale;
|
|
85
|
+
},
|
|
86
|
+
get direction() {
|
|
87
|
+
return locale().direction;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Every member delegates, so nothing is negotiated for a request that renders no copy — a health
|
|
92
|
+
// check and a webhook pay nothing for a capability the rest of the Worker composes.
|
|
93
|
+
c.set("t", {
|
|
94
|
+
get catalogLocale() {
|
|
95
|
+
return t().catalogLocale;
|
|
96
|
+
},
|
|
97
|
+
get formattingLocale() {
|
|
98
|
+
return t().formattingLocale;
|
|
99
|
+
},
|
|
100
|
+
get direction() {
|
|
101
|
+
return t().direction;
|
|
102
|
+
},
|
|
103
|
+
t: (key, params) => t().t(key, params),
|
|
104
|
+
maybe: (key, params) => t().maybe(key, params),
|
|
105
|
+
plural: (key, count, params) => t().plural(key, count, params),
|
|
106
|
+
formatNumber: (value, options) => t().formatNumber(value, options),
|
|
107
|
+
formatCurrency: (value, currency, options) => t().formatCurrency(value, currency, options),
|
|
108
|
+
formatDate: (value, options) => t().formatDate(value, options),
|
|
109
|
+
formatList: (values, options) => t().formatList(values, options),
|
|
110
|
+
formatRelativeTime: (value, unit, options) => t().formatRelativeTime(value, unit, options),
|
|
111
|
+
} satisfies Translator);
|
|
112
|
+
|
|
113
|
+
await next();
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One query parameter off a request URL, or `null` — total, so a URL Hono accepted cannot throw here. */
|
|
119
|
+
function queryParam(url: string, name: string): string | null {
|
|
120
|
+
try {
|
|
121
|
+
return new URL(url).searchParams.get(name);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add i18n` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory plus the config types an app declares.
|
|
7
|
+
*
|
|
8
|
+
* The React bindings are a first-class public API of this package and are imported by deep path
|
|
9
|
+
* (`@pithy-sh/i18n/src/react/translator`) at the component that mounts them — the documented contract,
|
|
10
|
+
* not a barrel over the package, and what keeps `react` off the Worker program's import graph. The
|
|
11
|
+
* adapters (`@pithy-sh/i18n/src/adapters/*`) and the browser helpers
|
|
12
|
+
* (`@pithy-sh/i18n/src/browser/signals`) are reached the same way.
|
|
13
|
+
*
|
|
14
|
+
* `Translator` itself lives in `@pithy-sh/core/src/i18n/translator` and is importable **without
|
|
15
|
+
* composing this capability**, so an adopter's own module can type against the seam whether or not
|
|
16
|
+
* they ever opt in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { type I18nCapability, i18n, isI18nCapability } from "./capability";
|
|
20
|
+
export { BrowserResolver, type I18nConfig, type I18nConfigInput, ServerResolver } from "./config/config";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { interpolate, type MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
6
|
+
import { bakedTranslator, createTranslator } from "@pithy-sh/core/src/i18n/translator";
|
|
7
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The React bindings — **a first-class public API of this package**, not something the kit's own
|
|
11
|
+
* templates happen to import.
|
|
12
|
+
*
|
|
13
|
+
* An adopter rendering entirely their own screens has to be able to consume the seam without ever
|
|
14
|
+
* touching a kit template, and after the first day most of them are: the screens are *copied*, so
|
|
15
|
+
* `pithy ui add` reaches a project scaffolded after this landed and no command retrofits one that was
|
|
16
|
+
* not. The seam is what an already-scaffolded adopter consumes. So this is the deliverable, and the
|
|
17
|
+
* templates are one of its consumers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the provider carries: the two locales, and the catalog layers to walk.
|
|
22
|
+
*
|
|
23
|
+
* **The pieces, not a finished `Translator`.** A screen supplies the English it was scaffolded with as
|
|
24
|
+
* a final fallback layer, and that layer can only be appended if the layers are still a list. Handing
|
|
25
|
+
* the context a built translator would make a screen's own English unreachable — which is the one
|
|
26
|
+
* thing that has to keep working, because it is the only catalog that survives being copied.
|
|
27
|
+
*/
|
|
28
|
+
export interface TranslatorSource {
|
|
29
|
+
/** The locale whose catalog answers `t()`. */
|
|
30
|
+
readonly catalogLocale: string;
|
|
31
|
+
/** The locale handed to `Intl`. May be more specific than the catalog locale — `es-AR` over `es`. */
|
|
32
|
+
readonly formattingLocale: string;
|
|
33
|
+
/** The catalogs to walk, most-specific first: the adopter's, then the kit's translation. */
|
|
34
|
+
readonly layers: readonly (MessageCatalog | undefined)[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a provider may be given: the pieces, or a translator somebody else already built.
|
|
39
|
+
*
|
|
40
|
+
* The second arm is what makes the adapters reachable. `fromI18next`, `fromIntl` and `fromLingui`
|
|
41
|
+
* return a `Translator` — an adopter's whole message layer, already resolved — and with only the
|
|
42
|
+
* pieces arm they had nowhere to go: an adapted translator could be passed screen by screen as a `t`
|
|
43
|
+
* prop, and `useTranslator()` in the adopter's *own* components would never see it. A stack you plug
|
|
44
|
+
* in that only half the tree can read is not plugged in.
|
|
45
|
+
*/
|
|
46
|
+
export type TranslatorValue = TranslatorSource | Translator;
|
|
47
|
+
|
|
48
|
+
const TranslatorContext = createContext<TranslatorValue | null>(null);
|
|
49
|
+
|
|
50
|
+
/** Whether a provider was handed the pieces rather than a finished translator. */
|
|
51
|
+
function isSource(value: TranslatorValue): value is TranslatorSource {
|
|
52
|
+
return "layers" in value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `primary` first, then `fallback` — the screen's own English behind somebody else's message layer.
|
|
57
|
+
*
|
|
58
|
+
* Built by delegation rather than by spreading `primary`, because a `Translator` may carry getters
|
|
59
|
+
* (the request-scoped one does) and spreading would evaluate them once, here, freezing the answer.
|
|
60
|
+
*
|
|
61
|
+
* `t` and `maybe` consult the fallback because `maybe` is exactly the miss signal they need. `plural`
|
|
62
|
+
* has a weaker one — it answers the key on a miss, which is the documented contract — so it falls back
|
|
63
|
+
* only when it sees that, and an adapter whose library selects plurals itself (i18next does) keeps
|
|
64
|
+
* answering for every key it knows.
|
|
65
|
+
*/
|
|
66
|
+
function overlay(primary: Translator, fallback: MessageCatalog): Translator {
|
|
67
|
+
const own = (key: string, params?: Parameters<Translator["t"]>[1]): string | null =>
|
|
68
|
+
Object.hasOwn(fallback, key) ? interpolate(fallback[key] as string, params) : null;
|
|
69
|
+
return {
|
|
70
|
+
catalogLocale: primary.catalogLocale,
|
|
71
|
+
formattingLocale: primary.formattingLocale,
|
|
72
|
+
direction: primary.direction,
|
|
73
|
+
t: (key, params) => primary.maybe(key, params) ?? own(key, params) ?? key,
|
|
74
|
+
maybe: (key, params) => primary.maybe(key, params) ?? own(key, params),
|
|
75
|
+
plural: (key, count, params) => {
|
|
76
|
+
const answered = primary.plural(key, count, params);
|
|
77
|
+
if (answered !== key) return answered;
|
|
78
|
+
const withCount = { count, ...params };
|
|
79
|
+
return own(`${key}.other`, withCount) ?? own(key, withCount) ?? key;
|
|
80
|
+
},
|
|
81
|
+
formatNumber: (value, options) => primary.formatNumber(value, options),
|
|
82
|
+
formatCurrency: (value, currency, options) => primary.formatCurrency(value, currency, options),
|
|
83
|
+
formatDate: (value, options) => primary.formatDate(value, options),
|
|
84
|
+
formatList: (values, options) => primary.formatList(values, options),
|
|
85
|
+
formatRelativeTime: (value, unit, options) => primary.formatRelativeTime(value, unit, options),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Mount a resolved locale over a subtree. Everything under it reads the same translator. */
|
|
90
|
+
export function TranslatorProvider({ value, children }: { value: TranslatorValue; children: ReactNode }) {
|
|
91
|
+
return <TranslatorContext.Provider value={value}>{children}</TranslatorContext.Provider>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The translator for this subtree, with `fallback` as its last layer.
|
|
96
|
+
*
|
|
97
|
+
* **Never throws when there is no provider**, and that is deliberate rather than lenient. A screen
|
|
98
|
+
* copied into an adopter's repository renders in a project that may not compose `i18n` at all, and in
|
|
99
|
+
* that project it must render the English it was scaffolded with, byte for byte. With no provider this
|
|
100
|
+
* is exactly a `bakedTranslator` over `fallback` — no negotiation, no merge, no config.
|
|
101
|
+
*
|
|
102
|
+
* With a provider, `fallback` goes **last**: the adopter's own catalog, then the kit's translation,
|
|
103
|
+
* then the screen's baked English. So a key nobody translated still renders a sentence.
|
|
104
|
+
*
|
|
105
|
+
* That holds for a provider given a finished `Translator` too — an adapted i18next, FormatJS or Lingui
|
|
106
|
+
* instance answers first and the screen's own English is behind it, so plugging your stack in never
|
|
107
|
+
* costs you the sentences the kit already wrote.
|
|
108
|
+
*/
|
|
109
|
+
export function useTranslator(fallback?: MessageCatalog): Translator {
|
|
110
|
+
const source = useContext(TranslatorContext);
|
|
111
|
+
return useMemo(() => {
|
|
112
|
+
if (!source) return bakedTranslator(fallback ?? {});
|
|
113
|
+
if (!isSource(source)) return fallback ? overlay(source, fallback) : source;
|
|
114
|
+
return createTranslator({
|
|
115
|
+
catalogLocale: source.catalogLocale,
|
|
116
|
+
formattingLocale: source.formattingLocale,
|
|
117
|
+
layers: fallback ? [...source.layers, fallback] : source.layers,
|
|
118
|
+
});
|
|
119
|
+
}, [source, fallback]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The prop every screen that renders copy takes.
|
|
124
|
+
*
|
|
125
|
+
* Optional, and injected the same way `fetch` and `redirect` already are on the kit's screens: a
|
|
126
|
+
* rendered fact no assertion about source text can reach is what earns a prop there, and a
|
|
127
|
+
* locale-dependent render is exactly that. A screen with no `t` passed reads the context, and with no
|
|
128
|
+
* context reads its own English.
|
|
129
|
+
*/
|
|
130
|
+
export interface TranslatorProp {
|
|
131
|
+
/** The translator this screen renders through. Defaults to the context, then to the baked English. */
|
|
132
|
+
t?: Translator;
|
|
133
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
6
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals";
|
|
8
|
+
import { loadKitCatalog } from "../catalogs/browser";
|
|
9
|
+
import type { I18nClientProjection } from "../client/projection";
|
|
10
|
+
import { resolveBrowserLocale } from "../resolve/browser";
|
|
11
|
+
import type { TranslatorSource } from "./translator";
|
|
12
|
+
|
|
13
|
+
/** What the hook hands back: what to mount, what was chosen, and how to choose again. */
|
|
14
|
+
export interface NegotiatedLocale {
|
|
15
|
+
/**
|
|
16
|
+
* Pass this straight to `TranslatorProvider`. `null` until the locale's catalog has loaded — and
|
|
17
|
+
* permanently `null` for a project with no `i18n` composed, which is the same branch and needs no
|
|
18
|
+
* second one: render the children untouched and every screen keeps its baked English.
|
|
19
|
+
*/
|
|
20
|
+
readonly source: TranslatorSource | null;
|
|
21
|
+
/** The catalog locale in force — the words the app has. */
|
|
22
|
+
readonly locale: string;
|
|
23
|
+
/** Choose a language. Remembers it on this device and puts `lang`/`dir` on the document. */
|
|
24
|
+
readonly choose: (locale: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the caller knows that the browser cannot work out for itself. */
|
|
28
|
+
export interface NegotiatedLocaleOptions {
|
|
29
|
+
/** The signed-in reader's stored locale, when the app knows it. Outranks this device's memory. */
|
|
30
|
+
readonly account?: string | null;
|
|
31
|
+
/** The adopter's own catalogs, keyed by locale — the top layer, above the kit's translation. */
|
|
32
|
+
readonly messages?: Readonly<Record<string, MessageCatalog | undefined>>;
|
|
33
|
+
/**
|
|
34
|
+
* Write a signed-in reader's choice through to their account. Omit it and a choice is remembered on
|
|
35
|
+
* this device only.
|
|
36
|
+
*
|
|
37
|
+
* **A seam rather than something this package does for you, because it cannot.** `pithy_auth_users.locale`
|
|
38
|
+
* is written through `updateUser` (`@pithy-sh/auth/src/client/api`) — a call `@pithy-sh/auth` owns and
|
|
39
|
+
* this package never imports. What it can do is call you at the moment the choice is made.
|
|
40
|
+
*
|
|
41
|
+
* It matters more than a convenience: `account` outranks `storage` in the browser chain precisely so
|
|
42
|
+
* a reader who picks Spanish on their phone is not reading French on their laptop. Without the
|
|
43
|
+
* write-through, that ordering describes a value nothing updates, and the second device keeps
|
|
44
|
+
* answering from its own older memory forever. Pass this whenever a reader is signed in.
|
|
45
|
+
*
|
|
46
|
+
* **Failures are yours to handle, inside this function.** It is called and not awaited, and a
|
|
47
|
+
* rejection is caught rather than reported: the reader already has the language they asked for and
|
|
48
|
+
* the device already remembers it, so a failed preference write must not become an unhandled
|
|
49
|
+
* rejection in their console or an error in your Worker. If a dropped write is worth knowing about,
|
|
50
|
+
* catch it here where you know what your API meant.
|
|
51
|
+
*/
|
|
52
|
+
readonly persist?: (locale: string) => void | Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `locale` answers, and what nothing writes to the document, when the capability is not composed.
|
|
57
|
+
*
|
|
58
|
+
* The kit writes in English and a scaffolded `templates/index.html` ships `lang="en"` as static text —
|
|
59
|
+
* so this is both the language on screen and the tag already on the document. Nothing negotiated it,
|
|
60
|
+
* which is exactly why the document is left alone rather than restamped with it.
|
|
61
|
+
*/
|
|
62
|
+
const UNNEGOTIATED_LOCALE = "en";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
|
|
66
|
+
* step.
|
|
67
|
+
*
|
|
68
|
+
* **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
|
|
69
|
+
* else — so a screen reads the chain order **this project** configured rather than one the front end
|
|
70
|
+
* assumed, and no page is asked for the catalogs, the cookie name or the server chain that only a
|
|
71
|
+
* Worker has. Your own catalogs reach it through `options.messages`, above the kit's translation.
|
|
72
|
+
*
|
|
73
|
+
* The catalog arrives by dynamic import, one Vite chunk per locale, so a reader downloads only their
|
|
74
|
+
* own language. Until it lands, `source` is `null` and every screen renders the English it was
|
|
75
|
+
* scaffolded with — which is the right thing to show for the handful of milliseconds involved, and is
|
|
76
|
+
* also exactly what a reader in the default locale sees permanently.
|
|
77
|
+
*
|
|
78
|
+
* **`{ enabled: false }` is an answer, not an error, and it costs the caller no branch of its own.** A
|
|
79
|
+
* project that never composed `i18n` projects it, and the hook then negotiates nothing, downloads no
|
|
80
|
+
* chunk, writes nothing to `localStorage`, and leaves the `lang` `index.html` declared exactly where it
|
|
81
|
+
* is: `source` stays `null` permanently, `locale` is `en`, and `choose` does nothing. `source === null`
|
|
82
|
+
* is the same null a caller already renders through while a catalog is in flight — so the screen shows
|
|
83
|
+
* the English it was scaffolded with, byte for byte as it did before any of this landed, and removing
|
|
84
|
+
* the capability puts the app back where it started.
|
|
85
|
+
*/
|
|
86
|
+
export function useNegotiatedLocale(
|
|
87
|
+
projection: I18nClientProjection,
|
|
88
|
+
options: NegotiatedLocaleOptions = {},
|
|
89
|
+
): NegotiatedLocale {
|
|
90
|
+
// Narrowed once, at the top. `null` is the whole of what "the capability is not composed" means here,
|
|
91
|
+
// and every hook below runs either way and answers for it rather than being skipped — a hook behind a
|
|
92
|
+
// condition is the one thing React does not allow.
|
|
93
|
+
const config = projection.enabled ? projection : null;
|
|
94
|
+
// The same fact as a boolean, and the two effects below depend on **this** rather than on `config`.
|
|
95
|
+
// Composed, `config` is `projection` under another name, so its identity is the caller's — and a
|
|
96
|
+
// caller is free to hand the hook a fresh object every render (`{ ...i18nConfig }`, or a parse in a
|
|
97
|
+
// component body). An effect keyed on that identity re-runs on every render; the catalog effect sets
|
|
98
|
+
// state, so it re-renders itself, forever, with no error anywhere to read. Both effects want nothing
|
|
99
|
+
// from `config` but whether it is there, and a boolean is stable by value however the caller spells
|
|
100
|
+
// its projection.
|
|
101
|
+
const enabled = projection.enabled;
|
|
102
|
+
|
|
103
|
+
const resolved = useMemo(
|
|
104
|
+
() =>
|
|
105
|
+
config === null
|
|
106
|
+
? null
|
|
107
|
+
: resolveBrowserLocale({ ...readBrowserSignals(config), account: options.account }, config),
|
|
108
|
+
[config, options.account],
|
|
109
|
+
);
|
|
110
|
+
const [chosen, setChosen] = useState<string | null>(null);
|
|
111
|
+
const [kit, setKit] = useState<{ locale: string; catalog: MessageCatalog } | null>(null);
|
|
112
|
+
|
|
113
|
+
const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
|
|
114
|
+
// A chosen locale is a catalog locale, so it carries no region; the negotiated one may be more
|
|
115
|
+
// specific than the catalog it reads, and that specificity is what `Intl` should keep.
|
|
116
|
+
const formattingLocale = chosen ?? resolved?.formattingLocale ?? UNNEGOTIATED_LOCALE;
|
|
117
|
+
// The project's default locale, whose layer catches a key the reader's own locale has not
|
|
118
|
+
// translated. `en` when nothing is composed — both the language on screen and the only layer a
|
|
119
|
+
// project without the capability could mean.
|
|
120
|
+
const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
// No chunk is asked for at all with the capability uncomposed, and that is the whole of what keeps
|
|
124
|
+
// `source` null there: `kit` never arrives, so the memo below has nothing to mount and the caller
|
|
125
|
+
// renders through the same null it already renders through while a catalog is in flight.
|
|
126
|
+
if (!enabled) return;
|
|
127
|
+
let live = true;
|
|
128
|
+
loadKitCatalog(locale)
|
|
129
|
+
.then((catalog) => {
|
|
130
|
+
if (live) setKit({ locale, catalog });
|
|
131
|
+
})
|
|
132
|
+
// A per-locale chunk that will not load — a 404 after a deploy, an offline tab — must not leave
|
|
133
|
+
// the provider unmounted forever with `lang` already changed on the document. That is the
|
|
134
|
+
// "declares a language it does not speak" failure by another road: a screen reader believes the
|
|
135
|
+
// attribute and mispronounces English. An empty catalog mounts the provider, so every screen
|
|
136
|
+
// falls through to the English it was scaffolded with, which is what is actually on screen.
|
|
137
|
+
.catch(() => {
|
|
138
|
+
if (live) setKit({ locale, catalog: {} });
|
|
139
|
+
});
|
|
140
|
+
return () => {
|
|
141
|
+
live = false;
|
|
142
|
+
};
|
|
143
|
+
}, [enabled, locale]);
|
|
144
|
+
|
|
145
|
+
// Derived from the locale in force, never taken off `resolved`. `resolveBrowserLocale` answers for
|
|
146
|
+
// what the *chain* negotiated, and `choose` moves past it: a reader who picks Arabic from a language
|
|
147
|
+
// menu on an English page would otherwise be served `lang="ar" dir="ltr"` — the words right and the
|
|
148
|
+
// layout backwards, which is the one failure a suite that only reads sentences cannot see. Same
|
|
149
|
+
// derivation the chain itself uses (`localeDirection(match.locale)`), so the negotiated path is
|
|
150
|
+
// byte-identical to what it answered before.
|
|
151
|
+
const direction = localeDirection(locale);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
// Nothing negotiated it, so nothing declares it. A project without the capability keeps the `lang`
|
|
155
|
+
// its `index.html` shipped — restamping the document with a locale no chain chose would be the
|
|
156
|
+
// "declares a language it does not speak" failure with the negotiation removed rather than added.
|
|
157
|
+
if (!enabled) return;
|
|
158
|
+
applyDocumentLocale(locale, direction);
|
|
159
|
+
}, [enabled, locale, direction]);
|
|
160
|
+
|
|
161
|
+
const persist = options.persist;
|
|
162
|
+
const choose = useCallback(
|
|
163
|
+
(next: string) => {
|
|
164
|
+
// No languages to choose between, and no `storageKey` to remember one under.
|
|
165
|
+
if (config === null) return;
|
|
166
|
+
if (!config.supportedLocales.includes(next)) return;
|
|
167
|
+
// The device first, always: it is synchronous, it cannot fail in a way worth waiting for, and it
|
|
168
|
+
// is what answers on the next visit if the account write does not land.
|
|
169
|
+
rememberBrowserLocale(next, config);
|
|
170
|
+
setChosen(next);
|
|
171
|
+
// Then the account, when the app knows who is reading. Not awaited — a language switch is a
|
|
172
|
+
// render, not a round trip, and nothing on screen should wait for a preference.
|
|
173
|
+
//
|
|
174
|
+
// **Caught, and that is not the same as swallowed by accident.** An unawaited promise that
|
|
175
|
+
// rejects is an unhandled rejection: noise in a browser console, and a reported error in a
|
|
176
|
+
// Worker. A failed preference write must not do either, because the reader already has the
|
|
177
|
+
// language they asked for and the device already remembers it. Whether that failure is worth
|
|
178
|
+
// reporting is a question about the adopter's own API, so it is answered inside `persist`.
|
|
179
|
+
Promise.resolve(persist?.(next)).catch(() => undefined);
|
|
180
|
+
},
|
|
181
|
+
[config, persist],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const source = useMemo<TranslatorSource | null>(() => {
|
|
185
|
+
// Only once the catalog for *this* locale has landed. Mounting the previous locale's catalog under
|
|
186
|
+
// the new locale's tag is a page that says it is Spanish and reads English. This is also the one
|
|
187
|
+
// test the uncomposed case has to pass: nothing ever loads a catalog there, so `kit` stays null
|
|
188
|
+
// and no provider is ever mounted over screens already rendering their baked English.
|
|
189
|
+
if (kit?.locale !== locale) return null;
|
|
190
|
+
return {
|
|
191
|
+
catalogLocale: locale,
|
|
192
|
+
formattingLocale,
|
|
193
|
+
layers: [options.messages?.[locale], options.messages?.[defaultLocale], kit.catalog],
|
|
194
|
+
};
|
|
195
|
+
}, [kit, locale, formattingLocale, options.messages, defaultLocale]);
|
|
196
|
+
|
|
197
|
+
return { source, locale, choose };
|
|
198
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BrowserResolver } from "../config/config";
|
|
5
|
+
import { type LocaleSet, type ResolvedLocale, type ResolverLink, resolveChain, tagLink } from "./chain";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the browser side of this package reads: the languages, the chain order, and the two names a page
|
|
9
|
+
* looks itself up by.
|
|
10
|
+
*
|
|
11
|
+
* **The shape `I18nClientProjection` and `I18nConfig` both already are.** The browser holds the
|
|
12
|
+
* projection — locale metadata and nothing else, no catalogs, no cookie name, no server chain — so
|
|
13
|
+
* asking a page for an `I18nConfig` asks it for three fields it has no business knowing, and every
|
|
14
|
+
* adopter widens one into the other the same five ways. Declaring what is actually read costs nothing
|
|
15
|
+
* on the server, where the resolved config satisfies it as it stands.
|
|
16
|
+
*
|
|
17
|
+
* `browserResolvers` is `readonly string[]` for the same reason the projection's is: the ambient
|
|
18
|
+
* declaration `pithy ui add react` copies into an adopter's Worker cannot name a type it does not
|
|
19
|
+
* import. The chain is walked by name — see {@link recognizedResolvers}.
|
|
20
|
+
*/
|
|
21
|
+
export interface BrowserChain extends LocaleSet {
|
|
22
|
+
/** The query parameter an explicit choice arrives on — `?lang=es`. */
|
|
23
|
+
readonly queryParam: string;
|
|
24
|
+
/** The `localStorage` key this device's remembered locale is written under. */
|
|
25
|
+
readonly storageKey: string;
|
|
26
|
+
/** The browser chain, in the order it is asked. Names this build does not know contribute nothing. */
|
|
27
|
+
readonly browserResolvers: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every link this build knows, as a record rather than a list.
|
|
32
|
+
*
|
|
33
|
+
* **A record so a new `BrowserResolver` cannot be forgotten here.** Adding a member to the enum leaves
|
|
34
|
+
* this object missing a property, which is a red build — the same guarantee the exhaustive switch in
|
|
35
|
+
* {@link linkFor} gives, in the one place a `string[]` has to be turned back into enum members.
|
|
36
|
+
*
|
|
37
|
+
* Written out rather than read off `BrowserResolver.options`, and that is a bundle decision, not a
|
|
38
|
+
* preference. `config/config.ts` is a Zod module, and everything under `src/browser/**` and
|
|
39
|
+
* `src/react/**` imports the config type-only precisely so that no scaffolded SPA ships Zod to walk a
|
|
40
|
+
* chain of six names. A value import here would put it in every adopter's main chunk.
|
|
41
|
+
*/
|
|
42
|
+
const KNOWN_RESOLVERS: Readonly<Record<BrowserResolver, true>> = {
|
|
43
|
+
query: true,
|
|
44
|
+
account: true,
|
|
45
|
+
storage: true,
|
|
46
|
+
navigator: true,
|
|
47
|
+
server: true,
|
|
48
|
+
default: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The links of `names` this build recognizes, in the order given, dropping the rest.
|
|
53
|
+
*
|
|
54
|
+
* The browser walks its chain by name because that is how the projection carries it, so a link this
|
|
55
|
+
* build has never heard of — a project one release ahead of the SPA bundle it is serving — contributes
|
|
56
|
+
* nothing rather than throwing. The links around it are still asked, each in its own place.
|
|
57
|
+
*/
|
|
58
|
+
export function recognizedResolvers(names: readonly string[]): BrowserResolver[] {
|
|
59
|
+
return names.filter((name): name is BrowserResolver => Object.hasOwn(KNOWN_RESOLVERS, name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a browser knows about the reader's language.
|
|
64
|
+
*
|
|
65
|
+
* A separate chain from the server's, because half the server's links do not exist here and half of
|
|
66
|
+
* these do not exist there: `localStorage` is absent from a Worker, and `navigator.language` inside
|
|
67
|
+
* workerd is the constant `"en"`, carrying no request information at all.
|
|
68
|
+
*/
|
|
69
|
+
export interface BrowserLocaleSignals {
|
|
70
|
+
/** An explicit choice on the URL — `?lang=es`. */
|
|
71
|
+
readonly query?: string | null;
|
|
72
|
+
/** The signed-in reader's own preference, as the app already holds it. */
|
|
73
|
+
readonly account?: string | null;
|
|
74
|
+
/** What this device remembers, from `localStorage`. */
|
|
75
|
+
readonly storage?: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* The reader's own browser languages, most-wanted first, from `navigator.languages`.
|
|
78
|
+
*
|
|
79
|
+
* The browser's equivalent of `Accept-Language`, and the only link that answers for a first-time
|
|
80
|
+
* visitor who has chosen nothing and is signed in to nothing. A list rather than one tag, because
|
|
81
|
+
* `navigator.languages` is one and the whole of it is a preference order worth honoring.
|
|
82
|
+
*/
|
|
83
|
+
readonly navigator?: readonly string[] | null;
|
|
84
|
+
/** What the server negotiated for the document, off `<html lang>`. */
|
|
85
|
+
readonly server?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The reader's locale, by the configured browser chain.
|
|
90
|
+
*
|
|
91
|
+
* Default order: `?lang=`, the account, local storage, the server's answer, the project default.
|
|
92
|
+
*
|
|
93
|
+
* **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
|
|
94
|
+
* signed-in reader who picks a language on one device must not silently be reading another language on
|
|
95
|
+
* the next. A `?lang=` choice by a signed-in reader is written through to their account rather than
|
|
96
|
+
* only to `localStorage`, which is what keeps the fact in one home; `docs/I18N.md` states it once.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveBrowserLocale(signals: BrowserLocaleSignals, config: BrowserChain): ResolvedLocale {
|
|
99
|
+
const chain = recognizedResolvers(config.browserResolvers);
|
|
100
|
+
const links: ResolverLink[] = chain.map((resolver) => linkFor(resolver, signals, config));
|
|
101
|
+
return resolveChain(links, config);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One link, by name. A standalone function so the switch stays exhaustive under `verbatimModuleSyntax`. */
|
|
105
|
+
function linkFor(resolver: BrowserResolver, signals: BrowserLocaleSignals, config: LocaleSet): ResolverLink {
|
|
106
|
+
switch (resolver) {
|
|
107
|
+
case "query":
|
|
108
|
+
return tagLink(resolver, signals.query);
|
|
109
|
+
case "account":
|
|
110
|
+
return tagLink(resolver, signals.account);
|
|
111
|
+
case "storage":
|
|
112
|
+
return tagLink(resolver, signals.storage);
|
|
113
|
+
case "navigator":
|
|
114
|
+
// The whole weighted list, not its head — `navigator.languages` is already in preference order,
|
|
115
|
+
// and a reader whose first language the project does not ship still gets their second.
|
|
116
|
+
return { name: resolver, ranges: (signals.navigator ?? []).filter((tag) => tag.trim().length > 0) };
|
|
117
|
+
case "server":
|
|
118
|
+
return tagLink(resolver, signals.server);
|
|
119
|
+
case "default":
|
|
120
|
+
return tagLink(resolver, config.defaultLocale);
|
|
121
|
+
}
|
|
122
|
+
}
|