@pithy-sh/i18n 0.1.2 → 0.1.3

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.
Files changed (62) hide show
  1. package/dist/adapters/adapters.d.ts +47 -0
  2. package/dist/adapters/adapters.d.ts.map +1 -0
  3. package/dist/adapters/adapters.js +87 -0
  4. package/dist/browser/document.d.ts +36 -0
  5. package/dist/browser/document.d.ts.map +1 -0
  6. package/dist/browser/document.js +85 -0
  7. package/dist/browser/signals.d.ts +36 -0
  8. package/dist/browser/signals.d.ts.map +1 -0
  9. package/dist/browser/signals.js +70 -0
  10. package/dist/capability.d.ts +53 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +110 -0
  13. package/dist/catalogs/browser.d.ts +13 -0
  14. package/dist/catalogs/browser.d.ts.map +1 -0
  15. package/dist/catalogs/browser.js +33 -0
  16. package/dist/catalogs/es/errors.d.ts +30 -0
  17. package/dist/catalogs/es/errors.d.ts.map +1 -0
  18. package/dist/catalogs/es/errors.js +154 -0
  19. package/dist/catalogs/es/index.d.ts +23 -0
  20. package/dist/catalogs/es/index.d.ts.map +1 -0
  21. package/dist/catalogs/es/index.js +29 -0
  22. package/dist/catalogs/es/screens.d.ts +28 -0
  23. package/dist/catalogs/es/screens.d.ts.map +1 -0
  24. package/dist/catalogs/es/screens.js +105 -0
  25. package/dist/catalogs/kit.d.ts +21 -0
  26. package/dist/catalogs/kit.d.ts.map +1 -0
  27. package/dist/catalogs/kit.js +23 -0
  28. package/dist/client/projection.d.ts +47 -0
  29. package/dist/client/projection.d.ts.map +1 -0
  30. package/dist/client/projection.js +0 -0
  31. package/dist/config/config.d.ts +71 -0
  32. package/dist/config/config.d.ts.map +1 -0
  33. package/dist/config/config.js +103 -0
  34. package/dist/http/middleware.d.ts +36 -0
  35. package/dist/http/middleware.d.ts.map +1 -0
  36. package/dist/http/middleware.js +93 -0
  37. package/dist/index.d.ts +17 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +3 -0
  40. package/dist/react/translator.d.ts +73 -0
  41. package/dist/react/translator.d.ts.map +1 -0
  42. package/dist/react/translator.js +81 -0
  43. package/dist/react/useNegotiatedLocale.d.ts +67 -0
  44. package/dist/react/useNegotiatedLocale.d.ts.map +1 -0
  45. package/dist/react/useNegotiatedLocale.js +108 -0
  46. package/dist/resolve/browser.d.ts +69 -0
  47. package/dist/resolve/browser.d.ts.map +1 -0
  48. package/dist/resolve/browser.js +62 -0
  49. package/dist/resolve/chain.d.ts +52 -0
  50. package/dist/resolve/chain.d.ts.map +1 -0
  51. package/dist/resolve/chain.js +49 -0
  52. package/dist/resolve/server.d.ts +31 -0
  53. package/dist/resolve/server.d.ts.map +1 -0
  54. package/dist/resolve/server.js +32 -0
  55. package/dist/settings/coverage.d.ts +21 -0
  56. package/dist/settings/coverage.d.ts.map +1 -0
  57. package/dist/settings/coverage.js +48 -0
  58. package/dist/version.generated.d.ts +5 -0
  59. package/dist/version.generated.d.ts.map +1 -0
  60. package/dist/version.generated.js +7 -0
  61. package/package.json +18 -10
  62. package/src/version.generated.ts +1 -1
@@ -0,0 +1,103 @@
1
+ import { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { Locale } from "@pithy-sh/core/src/i18n/locale";
3
+ import { z } from "zod";
4
+ //#region src/config/config.ts
5
+ /**
6
+ * One link of the **server** resolver chain — the order a Worker asks "what language is this request in?".
7
+ *
8
+ * Split from the browser chain because `localStorage` does not exist in a Worker and
9
+ * `navigator.language` inside workerd is the constant `"en"`, carrying no request information at all.
10
+ * Two chains over one contract is honest; one chain with half its links inert is not.
11
+ */
12
+ const ServerResolver = z.enum([
13
+ "param",
14
+ "user",
15
+ "cookie",
16
+ "header",
17
+ "default"
18
+ ]).describe("A link of the server resolver chain: an explicit param, the account, a cookie, the header, the default.");
19
+ /**
20
+ * One link of the **browser** resolver chain.
21
+ *
22
+ * `account` sits above `storage` deliberately: `pithy_auth_users.locale` is where a person's locale
23
+ * lives, so a signed-in reader's choice must not silently diverge per device. See `docs/I18N.md`.
24
+ */
25
+ const BrowserResolver = z.enum([
26
+ "query",
27
+ "account",
28
+ "storage",
29
+ "navigator",
30
+ "server",
31
+ "default"
32
+ ]).describe("A link of the browser resolver chain: `?lang=`, the account, local storage, the browser's own languages, the server, the default.");
33
+ /** The server chain's default order — explicit param, account, cookie, `Accept-Language`, project default. */
34
+ const DEFAULT_SERVER_RESOLVERS = [
35
+ "param",
36
+ "user",
37
+ "cookie",
38
+ "header",
39
+ "default"
40
+ ];
41
+ /** The browser chain's default order — `?lang=`, account, local storage, the server's answer, default. */
42
+ /**
43
+ * The browser chain's default order — `?lang=`, account, this device's memory, the reader's own
44
+ * browser languages, what the server declared, the project default.
45
+ *
46
+ * **`navigator` sits above `server`, and that ordering is the whole reason the link exists.** The
47
+ * `server` link reads `document.documentElement.lang`, and a scaffolded SPA is served from a static
48
+ * `index.html` that says `lang="en"` with no substitution token — so without a browser link, a
49
+ * first-time Spanish visitor to a project shipping `es` resolved to the default and read English on
50
+ * every screen. Automatic negotiation worked on the server and nowhere else.
51
+ *
52
+ * Placing it above `server` costs a genuinely server-rendered app nothing: if that server negotiated,
53
+ * it negotiated from `Accept-Language`, which is the same preference `navigator.languages` reports —
54
+ * and anything it knew that the browser does not, the reader's account, is already ranked higher.
55
+ */
56
+ const DEFAULT_BROWSER_RESOLVERS = [
57
+ "query",
58
+ "account",
59
+ "storage",
60
+ "navigator",
61
+ "server",
62
+ "default"
63
+ ];
64
+ /** The locale the kit writes in, and what every unconfigured project falls back to. */
65
+ const DEFAULT_LOCALE = "en";
66
+ /**
67
+ * The i18n capability's configuration.
68
+ *
69
+ * Everything an adopter decides about language lives here, including their own catalogs — **a config
70
+ * object passed to the capability, never files discovered on disk.** Two reasons, and both are hard
71
+ * constraints rather than preferences. `configEntrypoints.test.ts` imports every capability's config
72
+ * entry point in a plain Node process, so a catalog loader doing `readFileSync` at module scope breaks
73
+ * `pithy upgrade`, `migrate` and `deploy` for every project composing it. And a catalog file copied
74
+ * into an adopter's repository is a fork on the day they scaffold: a typo fix or a new locale could
75
+ * never reach them again.
76
+ */
77
+ const I18nConfig = z.object({
78
+ supportedLocales: z.array(Locale).min(1).default([DEFAULT_LOCALE]).describe("Every locale this project serves. Ship the least specific tag that is true — `es`, not `es-ES`."),
79
+ defaultLocale: Locale.default(DEFAULT_LOCALE).describe("The locale served when nothing else answers. Must be one of `supportedLocales`."),
80
+ messages: LocaleCatalogs.default({}).describe("This project's own catalogs. Overriding one kit key is one entry; everything unmentioned keeps flowing from the package."),
81
+ exceptions: z.record(z.string().describe("The language range a reader sends that no truncation of it would match."), Locale.describe("The supported locale it means.")).default({}).transform((exceptions) => Object.fromEntries(Object.entries(exceptions).map(([range, locale]) => [range.toLowerCase(), locale]))).describe("Language ranges the matcher cannot derive — historical pairs like `nb` meaning `no`."),
82
+ cookie: z.string().min(1).default("pithy_locale").describe("The cookie the server chain's `cookie` link reads a chosen locale from."),
83
+ queryParam: z.string().min(1).default("lang").describe("The query parameter an explicit choice arrives on, on both the server and the browser."),
84
+ storageKey: z.string().min(1).default("pithy.locale").describe("The `localStorage` key the browser chain's `storage` link reads and writes."),
85
+ serverResolvers: z.array(ServerResolver).min(1).default([...DEFAULT_SERVER_RESOLVERS]).describe("The server chain, in the order it is asked. Reorder or shorten it; `default` is the last resort."),
86
+ browserResolvers: z.array(BrowserResolver).min(1).default([...DEFAULT_BROWSER_RESOLVERS]).describe("The browser chain, in the order it is asked.")
87
+ }).check((ctx) => {
88
+ const { supportedLocales, defaultLocale } = ctx.value;
89
+ if (!supportedLocales.includes(defaultLocale)) ctx.issues.push({
90
+ code: "custom",
91
+ input: ctx.value,
92
+ path: ["defaultLocale"],
93
+ message: `\`${defaultLocale}\` is the default locale and is not in \`supportedLocales\`, so nothing would answer when negotiation fails.`
94
+ });
95
+ for (const locale of Object.keys(ctx.value.messages)) if (!supportedLocales.includes(locale)) ctx.issues.push({
96
+ code: "custom",
97
+ input: ctx.value,
98
+ path: ["messages", locale],
99
+ message: `\`${locale}\` carries messages and is not in \`supportedLocales\`, so nothing would ever read them.`
100
+ });
101
+ }).describe("How this project negotiates and renders language, and the catalogs it adds of its own.");
102
+ //#endregion
103
+ export { BrowserResolver, I18nConfig, ServerResolver };
@@ -0,0 +1,36 @@
1
+ import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
2
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
3
+ import type { I18nConfig } from "../config/config";
4
+ /** How a request's locale is looked up, given the locale that won. Supplied by the capability. */
5
+ export type LayersFor = (locale: string) => readonly (MessageCatalog | undefined)[];
6
+ /**
7
+ * The one middleware this capability contributes: resolve the request's locale, and put a real
8
+ * translator on it.
9
+ *
10
+ * It **replaces** `c.var.t` rather than filling a null. Core seeds a translator over the baked English
11
+ * for every request whether or not this capability is composed, which is what lets every capability
12
+ * call `c.var.t(...)` with no null check, and what makes a project that never opts in behave byte for
13
+ * byte as it did before.
14
+ *
15
+ * A fresh translator per request, never a cached one. A Worker isolate is reused across requests from
16
+ * different readers, so anything holding a locale across that boundary applies request A's language to
17
+ * request B. It is the same hazard `z.config()` is banned repo-wide for, and it is why the `Intl`
18
+ * formatters are held on the translator — which lives and dies with one request — rather than in a
19
+ * module-level cache.
20
+ *
21
+ * ## Why the query string is read off the URL here, and not through `zValidator`
22
+ *
23
+ * §HTTP's rule is that a route declares its request contract on the route line and a handler reads
24
+ * `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
25
+ * unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
26
+ * is no route line to declare a validator on and no handler to hand a typed value to — the rule's
27
+ * replacement does not exist at this position.
28
+ *
29
+ * What the rule is *for* still holds, and it holds structurally rather than by care: the value is one
30
+ * language range among four sources, every one of them is matched against `config.supportedLocales`
31
+ * before anything is done with it, and a range that matches nothing falls through to the next link.
32
+ * Nothing downstream ever sees a string this middleware did not already find in the project's own
33
+ * configured set. A reader who sends `?lang=<script>` gets the default locale.
34
+ */
35
+ export declare function i18nMiddleware(config: I18nConfig, layersFor: LayersFor): PithyMiddleware;
36
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/http/middleware.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0CAA0C,CAAC;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,kGAAkG;AAClG,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,SAAS,CAAC,cAAc,GAAG,SAAS,CAAC,EAAE,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,GAAG,eAAe,CAwExF"}
@@ -0,0 +1,93 @@
1
+ import { resolveServerLocale } from "../resolve/server.js";
2
+ import { createTranslator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { getCookie } from "hono/cookie";
4
+ //#region src/http/middleware.ts
5
+ /**
6
+ * The one middleware this capability contributes: resolve the request's locale, and put a real
7
+ * translator on it.
8
+ *
9
+ * It **replaces** `c.var.t` rather than filling a null. Core seeds a translator over the baked English
10
+ * for every request whether or not this capability is composed, which is what lets every capability
11
+ * call `c.var.t(...)` with no null check, and what makes a project that never opts in behave byte for
12
+ * byte as it did before.
13
+ *
14
+ * A fresh translator per request, never a cached one. A Worker isolate is reused across requests from
15
+ * different readers, so anything holding a locale across that boundary applies request A's language to
16
+ * request B. It is the same hazard `z.config()` is banned repo-wide for, and it is why the `Intl`
17
+ * formatters are held on the translator — which lives and dies with one request — rather than in a
18
+ * module-level cache.
19
+ *
20
+ * ## Why the query string is read off the URL here, and not through `zValidator`
21
+ *
22
+ * §HTTP's rule is that a route declares its request contract on the route line and a handler reads
23
+ * `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
24
+ * unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
25
+ * is no route line to declare a validator on and no handler to hand a typed value to — the rule's
26
+ * replacement does not exist at this position.
27
+ *
28
+ * What the rule is *for* still holds, and it holds structurally rather than by care: the value is one
29
+ * language range among four sources, every one of them is matched against `config.supportedLocales`
30
+ * before anything is done with it, and a range that matches nothing falls through to the next link.
31
+ * Nothing downstream ever sees a string this middleware did not already find in the project's own
32
+ * configured set. A reader who sends `?lang=<script>` gets the default locale.
33
+ */
34
+ function i18nMiddleware(config, layersFor) {
35
+ return (app) => {
36
+ app.use("*", async (c, next) => {
37
+ let resolved;
38
+ const locale = () => resolved ??= resolveServerLocale({
39
+ param: queryParam(c.req.url, config.queryParam),
40
+ user: c.var.auth?.locale ?? null,
41
+ cookie: getCookie(c, config.cookie) ?? null,
42
+ header: c.req.header("accept-language") ?? null
43
+ }, config);
44
+ let translator;
45
+ const t = () => translator ??= createTranslator({
46
+ catalogLocale: locale().catalogLocale,
47
+ formattingLocale: locale().formattingLocale,
48
+ layers: layersFor(locale().catalogLocale)
49
+ });
50
+ c.set("locale", {
51
+ get catalogLocale() {
52
+ return locale().catalogLocale;
53
+ },
54
+ get formattingLocale() {
55
+ return locale().formattingLocale;
56
+ },
57
+ get direction() {
58
+ return locale().direction;
59
+ }
60
+ });
61
+ c.set("t", {
62
+ get catalogLocale() {
63
+ return t().catalogLocale;
64
+ },
65
+ get formattingLocale() {
66
+ return t().formattingLocale;
67
+ },
68
+ get direction() {
69
+ return t().direction;
70
+ },
71
+ t: (key, params) => t().t(key, params),
72
+ maybe: (key, params) => t().maybe(key, params),
73
+ plural: (key, count, params) => t().plural(key, count, params),
74
+ formatNumber: (value, options) => t().formatNumber(value, options),
75
+ formatCurrency: (value, currency, options) => t().formatCurrency(value, currency, options),
76
+ formatDate: (value, options) => t().formatDate(value, options),
77
+ formatList: (values, options) => t().formatList(values, options),
78
+ formatRelativeTime: (value, unit, options) => t().formatRelativeTime(value, unit, options)
79
+ });
80
+ await next();
81
+ });
82
+ };
83
+ }
84
+ /** One query parameter off a request URL, or `null` — total, so a URL Hono accepted cannot throw here. */
85
+ function queryParam(url, name) {
86
+ try {
87
+ return new URL(url).searchParams.get(name);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+ //#endregion
93
+ export { i18nMiddleware };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The package entrypoint — the surface `pithy add i18n` wires into `pithy.config.ts`. Deliberately
3
+ * narrow: the capability factory plus the config types an app declares.
4
+ *
5
+ * The React bindings are a first-class public API of this package and are imported by deep path
6
+ * (`@pithy-sh/i18n/src/react/translator`) at the component that mounts them — the documented contract,
7
+ * not a barrel over the package, and what keeps `react` off the Worker program's import graph. The
8
+ * adapters (`@pithy-sh/i18n/src/adapters/*`) and the browser helpers
9
+ * (`@pithy-sh/i18n/src/browser/signals`) are reached the same way.
10
+ *
11
+ * `Translator` itself lives in `@pithy-sh/core/src/i18n/translator` and is importable **without
12
+ * composing this capability**, so an adopter's own module can type against the seam whether or not
13
+ * they ever opt in.
14
+ */
15
+ export { type I18nCapability, i18n, isI18nCapability } from "./capability";
16
+ export { BrowserResolver, type I18nConfig, type I18nConfigInput, ServerResolver } from "./config/config";
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,KAAK,cAAc,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { BrowserResolver, ServerResolver } from "./config/config.js";
2
+ import { i18n, isI18nCapability } from "./capability.js";
3
+ export { BrowserResolver, ServerResolver, i18n, isI18nCapability };
@@ -0,0 +1,73 @@
1
+ import { type MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { type ReactNode } from "react";
4
+ /**
5
+ * The React bindings — **a first-class public API of this package**, not something the kit's own
6
+ * templates happen to import.
7
+ *
8
+ * An adopter rendering entirely their own screens has to be able to consume the seam without ever
9
+ * touching a kit template, and after the first day most of them are: the screens are *copied*, so
10
+ * `pithy ui add` reaches a project scaffolded after this landed and no command retrofits one that was
11
+ * not. The seam is what an already-scaffolded adopter consumes. So this is the deliverable, and the
12
+ * templates are one of its consumers.
13
+ */
14
+ /**
15
+ * What the provider carries: the two locales, and the catalog layers to walk.
16
+ *
17
+ * **The pieces, not a finished `Translator`.** A screen supplies the English it was scaffolded with as
18
+ * a final fallback layer, and that layer can only be appended if the layers are still a list. Handing
19
+ * the context a built translator would make a screen's own English unreachable — which is the one
20
+ * thing that has to keep working, because it is the only catalog that survives being copied.
21
+ */
22
+ export interface TranslatorSource {
23
+ /** The locale whose catalog answers `t()`. */
24
+ readonly catalogLocale: string;
25
+ /** The locale handed to `Intl`. May be more specific than the catalog locale — `es-AR` over `es`. */
26
+ readonly formattingLocale: string;
27
+ /** The catalogs to walk, most-specific first: the adopter's, then the kit's translation. */
28
+ readonly layers: readonly (MessageCatalog | undefined)[];
29
+ }
30
+ /**
31
+ * What a provider may be given: the pieces, or a translator somebody else already built.
32
+ *
33
+ * The second arm is what makes the adapters reachable. `fromI18next`, `fromIntl` and `fromLingui`
34
+ * return a `Translator` — an adopter's whole message layer, already resolved — and with only the
35
+ * pieces arm they had nowhere to go: an adapted translator could be passed screen by screen as a `t`
36
+ * prop, and `useTranslator()` in the adopter's *own* components would never see it. A stack you plug
37
+ * in that only half the tree can read is not plugged in.
38
+ */
39
+ export type TranslatorValue = TranslatorSource | Translator;
40
+ /** Mount a resolved locale over a subtree. Everything under it reads the same translator. */
41
+ export declare function TranslatorProvider({ value, children }: {
42
+ value: TranslatorValue;
43
+ children: ReactNode;
44
+ }): import("react").JSX.Element;
45
+ /**
46
+ * The translator for this subtree, with `fallback` as its last layer.
47
+ *
48
+ * **Never throws when there is no provider**, and that is deliberate rather than lenient. A screen
49
+ * copied into an adopter's repository renders in a project that may not compose `i18n` at all, and in
50
+ * that project it must render the English it was scaffolded with, byte for byte. With no provider this
51
+ * is exactly a `bakedTranslator` over `fallback` — no negotiation, no merge, no config.
52
+ *
53
+ * With a provider, `fallback` goes **last**: the adopter's own catalog, then the kit's translation,
54
+ * then the screen's baked English. So a key nobody translated still renders a sentence.
55
+ *
56
+ * That holds for a provider given a finished `Translator` too — an adapted i18next, FormatJS or Lingui
57
+ * instance answers first and the screen's own English is behind it, so plugging your stack in never
58
+ * costs you the sentences the kit already wrote.
59
+ */
60
+ export declare function useTranslator(fallback?: MessageCatalog): Translator;
61
+ /**
62
+ * The prop every screen that renders copy takes.
63
+ *
64
+ * Optional, and injected the same way `fetch` and `redirect` already are on the kit's screens: a
65
+ * rendered fact no assertion about source text can reach is what earns a prop there, and a
66
+ * locale-dependent render is exactly that. A screen with no `t` passed reads the context, and with no
67
+ * context reads its own English.
68
+ */
69
+ export interface TranslatorProp {
70
+ /** The translator this screen renders through. Defaults to the context, then to the baked English. */
71
+ t?: Translator;
72
+ }
73
+ //# sourceMappingURL=translator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translator.d.ts","sourceRoot":"","sources":["../../src/react/translator.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACnF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAErE,OAAO,EAAiB,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAE3E;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,8CAA8C;IAC9C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,qGAAqG;IACrG,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,cAAc,GAAG,SAAS,CAAC,EAAE,CAAC;CAC1D;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,gBAAgB,GAAG,UAAU,CAAC;AA2C5D,6FAA6F;AAC7F,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAEtG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,UAAU,CAWnE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,sGAAsG;IACtG,CAAC,CAAC,EAAE,UAAU,CAAC;CAChB"}
@@ -0,0 +1,81 @@
1
+ import { bakedTranslator, createTranslator } from "@pithy-sh/core/src/i18n/translator";
2
+ import { interpolate } from "@pithy-sh/core/src/i18n/catalog";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/react/translator.tsx
6
+ const TranslatorContext = createContext(null);
7
+ /** Whether a provider was handed the pieces rather than a finished translator. */
8
+ function isSource(value) {
9
+ return "layers" in value;
10
+ }
11
+ /**
12
+ * `primary` first, then `fallback` — the screen's own English behind somebody else's message layer.
13
+ *
14
+ * Built by delegation rather than by spreading `primary`, because a `Translator` may carry getters
15
+ * (the request-scoped one does) and spreading would evaluate them once, here, freezing the answer.
16
+ *
17
+ * `t` and `maybe` consult the fallback because `maybe` is exactly the miss signal they need. `plural`
18
+ * has a weaker one — it answers the key on a miss, which is the documented contract — so it falls back
19
+ * only when it sees that, and an adapter whose library selects plurals itself (i18next does) keeps
20
+ * answering for every key it knows.
21
+ */
22
+ function overlay(primary, fallback) {
23
+ const own = (key, params) => Object.hasOwn(fallback, key) ? interpolate(fallback[key], params) : null;
24
+ return {
25
+ catalogLocale: primary.catalogLocale,
26
+ formattingLocale: primary.formattingLocale,
27
+ direction: primary.direction,
28
+ t: (key, params) => primary.maybe(key, params) ?? own(key, params) ?? key,
29
+ maybe: (key, params) => primary.maybe(key, params) ?? own(key, params),
30
+ plural: (key, count, params) => {
31
+ const answered = primary.plural(key, count, params);
32
+ if (answered !== key) return answered;
33
+ const withCount = {
34
+ count,
35
+ ...params
36
+ };
37
+ return own(`${key}.other`, withCount) ?? own(key, withCount) ?? key;
38
+ },
39
+ formatNumber: (value, options) => primary.formatNumber(value, options),
40
+ formatCurrency: (value, currency, options) => primary.formatCurrency(value, currency, options),
41
+ formatDate: (value, options) => primary.formatDate(value, options),
42
+ formatList: (values, options) => primary.formatList(values, options),
43
+ formatRelativeTime: (value, unit, options) => primary.formatRelativeTime(value, unit, options)
44
+ };
45
+ }
46
+ /** Mount a resolved locale over a subtree. Everything under it reads the same translator. */
47
+ function TranslatorProvider({ value, children }) {
48
+ return /* @__PURE__ */ jsx(TranslatorContext.Provider, {
49
+ value,
50
+ children
51
+ });
52
+ }
53
+ /**
54
+ * The translator for this subtree, with `fallback` as its last layer.
55
+ *
56
+ * **Never throws when there is no provider**, and that is deliberate rather than lenient. A screen
57
+ * copied into an adopter's repository renders in a project that may not compose `i18n` at all, and in
58
+ * that project it must render the English it was scaffolded with, byte for byte. With no provider this
59
+ * is exactly a `bakedTranslator` over `fallback` — no negotiation, no merge, no config.
60
+ *
61
+ * With a provider, `fallback` goes **last**: the adopter's own catalog, then the kit's translation,
62
+ * then the screen's baked English. So a key nobody translated still renders a sentence.
63
+ *
64
+ * That holds for a provider given a finished `Translator` too — an adapted i18next, FormatJS or Lingui
65
+ * instance answers first and the screen's own English is behind it, so plugging your stack in never
66
+ * costs you the sentences the kit already wrote.
67
+ */
68
+ function useTranslator(fallback) {
69
+ const source = useContext(TranslatorContext);
70
+ return useMemo(() => {
71
+ if (!source) return bakedTranslator(fallback ?? {});
72
+ if (!isSource(source)) return fallback ? overlay(source, fallback) : source;
73
+ return createTranslator({
74
+ catalogLocale: source.catalogLocale,
75
+ formattingLocale: source.formattingLocale,
76
+ layers: fallback ? [...source.layers, fallback] : source.layers
77
+ });
78
+ }, [source, fallback]);
79
+ }
80
+ //#endregion
81
+ export { TranslatorProvider, useTranslator };
@@ -0,0 +1,67 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { I18nClientProjection } from "../client/projection";
3
+ import type { TranslatorSource } from "./translator";
4
+ /** What the hook hands back: what to mount, what was chosen, and how to choose again. */
5
+ export interface NegotiatedLocale {
6
+ /**
7
+ * Pass this straight to `TranslatorProvider`. `null` until the locale's catalog has loaded — and
8
+ * permanently `null` for a project with no `i18n` composed, which is the same branch and needs no
9
+ * second one: render the children untouched and every screen keeps its baked English.
10
+ */
11
+ readonly source: TranslatorSource | null;
12
+ /** The catalog locale in force — the words the app has. */
13
+ readonly locale: string;
14
+ /** Choose a language. Remembers it on this device and puts `lang`/`dir` on the document. */
15
+ readonly choose: (locale: string) => void;
16
+ }
17
+ /** What the caller knows that the browser cannot work out for itself. */
18
+ export interface NegotiatedLocaleOptions {
19
+ /** The signed-in reader's stored locale, when the app knows it. Outranks this device's memory. */
20
+ readonly account?: string | null;
21
+ /** The adopter's own catalogs, keyed by locale — the top layer, above the kit's translation. */
22
+ readonly messages?: Readonly<Record<string, MessageCatalog | undefined>>;
23
+ /**
24
+ * Write a signed-in reader's choice through to their account. Omit it and a choice is remembered on
25
+ * this device only.
26
+ *
27
+ * **A seam rather than something this package does for you, because it cannot.** `pithy_auth_users.locale`
28
+ * is written through `updateUser` (`@pithy-sh/auth/src/client/api`) — a call `@pithy-sh/auth` owns and
29
+ * this package never imports. What it can do is call you at the moment the choice is made.
30
+ *
31
+ * It matters more than a convenience: `account` outranks `storage` in the browser chain precisely so
32
+ * a reader who picks Spanish on their phone is not reading French on their laptop. Without the
33
+ * write-through, that ordering describes a value nothing updates, and the second device keeps
34
+ * answering from its own older memory forever. Pass this whenever a reader is signed in.
35
+ *
36
+ * **Failures are yours to handle, inside this function.** It is called and not awaited, and a
37
+ * rejection is caught rather than reported: the reader already has the language they asked for and
38
+ * the device already remembers it, so a failed preference write must not become an unhandled
39
+ * rejection in their console or an error in your Worker. If a dropped write is worth knowing about,
40
+ * catch it here where you know what your API meant.
41
+ */
42
+ readonly persist?: (locale: string) => void | Promise<void>;
43
+ }
44
+ /**
45
+ * Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
46
+ * step.
47
+ *
48
+ * **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
49
+ * else — so a screen reads the chain order **this project** configured rather than one the front end
50
+ * assumed, and no page is asked for the catalogs, the cookie name or the server chain that only a
51
+ * Worker has. Your own catalogs reach it through `options.messages`, above the kit's translation.
52
+ *
53
+ * The catalog arrives by dynamic import, one Vite chunk per locale, so a reader downloads only their
54
+ * own language. Until it lands, `source` is `null` and every screen renders the English it was
55
+ * scaffolded with — which is the right thing to show for the handful of milliseconds involved, and is
56
+ * also exactly what a reader in the default locale sees permanently.
57
+ *
58
+ * **`{ enabled: false }` is an answer, not an error, and it costs the caller no branch of its own.** A
59
+ * project that never composed `i18n` projects it, and the hook then negotiates nothing, downloads no
60
+ * chunk, writes nothing to `localStorage`, and leaves the `lang` `index.html` declared exactly where it
61
+ * is: `source` stays `null` permanently, `locale` is `en`, and `choose` does nothing. `source === null`
62
+ * is the same null a caller already renders through while a catalog is in flight — so the screen shows
63
+ * the English it was scaffolded with, byte for byte as it did before any of this landed, and removing
64
+ * the capability puts the app back where it started.
65
+ */
66
+ export declare function useNegotiatedLocale(projection: I18nClientProjection, options?: NegotiatedLocaleOptions): NegotiatedLocale;
67
+ //# sourceMappingURL=useNegotiatedLocale.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useNegotiatedLocale.d.ts","sourceRoot":"","sources":["../../src/react/useNegotiatedLocale.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAKtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACzC,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAED,yEAAyE;AACzE,MAAM,WAAW,uBAAuB;IACtC,kGAAkG;IAClG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC,CAAC,CAAC;IACzE;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAWD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,oBAAoB,EAChC,OAAO,GAAE,uBAA4B,GACpC,gBAAgB,CA6GlB"}
@@ -0,0 +1,108 @@
1
+ import { resolveBrowserLocale } from "../resolve/browser.js";
2
+ import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals.js";
3
+ import { loadKitCatalog } from "../catalogs/browser.js";
4
+ import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ //#region src/react/useNegotiatedLocale.ts
7
+ /**
8
+ * What `locale` answers, and what nothing writes to the document, when the capability is not composed.
9
+ *
10
+ * The kit writes in English and a scaffolded `templates/index.html` ships `lang="en"` as static text —
11
+ * so this is both the language on screen and the tag already on the document. Nothing negotiated it,
12
+ * which is exactly why the document is left alone rather than restamped with it.
13
+ */
14
+ const UNNEGOTIATED_LOCALE = "en";
15
+ /**
16
+ * Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
17
+ * step.
18
+ *
19
+ * **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
20
+ * else — so a screen reads the chain order **this project** configured rather than one the front end
21
+ * assumed, and no page is asked for the catalogs, the cookie name or the server chain that only a
22
+ * Worker has. Your own catalogs reach it through `options.messages`, above the kit's translation.
23
+ *
24
+ * The catalog arrives by dynamic import, one Vite chunk per locale, so a reader downloads only their
25
+ * own language. Until it lands, `source` is `null` and every screen renders the English it was
26
+ * scaffolded with — which is the right thing to show for the handful of milliseconds involved, and is
27
+ * also exactly what a reader in the default locale sees permanently.
28
+ *
29
+ * **`{ enabled: false }` is an answer, not an error, and it costs the caller no branch of its own.** A
30
+ * project that never composed `i18n` projects it, and the hook then negotiates nothing, downloads no
31
+ * chunk, writes nothing to `localStorage`, and leaves the `lang` `index.html` declared exactly where it
32
+ * is: `source` stays `null` permanently, `locale` is `en`, and `choose` does nothing. `source === null`
33
+ * is the same null a caller already renders through while a catalog is in flight — so the screen shows
34
+ * the English it was scaffolded with, byte for byte as it did before any of this landed, and removing
35
+ * the capability puts the app back where it started.
36
+ */
37
+ function useNegotiatedLocale(projection, options = {}) {
38
+ const config = projection.enabled ? projection : null;
39
+ const enabled = projection.enabled;
40
+ const resolved = useMemo(() => config === null ? null : resolveBrowserLocale({
41
+ ...readBrowserSignals(config),
42
+ account: options.account
43
+ }, config), [config, options.account]);
44
+ const [chosen, setChosen] = useState(null);
45
+ const [kit, setKit] = useState(null);
46
+ const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
47
+ const formattingLocale = chosen ?? resolved?.formattingLocale ?? UNNEGOTIATED_LOCALE;
48
+ const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
49
+ useEffect(() => {
50
+ if (!enabled) return;
51
+ let live = true;
52
+ loadKitCatalog(locale).then((catalog) => {
53
+ if (live) setKit({
54
+ locale,
55
+ catalog
56
+ });
57
+ }).catch(() => {
58
+ if (live) setKit({
59
+ locale,
60
+ catalog: {}
61
+ });
62
+ });
63
+ return () => {
64
+ live = false;
65
+ };
66
+ }, [enabled, locale]);
67
+ const direction = localeDirection(locale);
68
+ useEffect(() => {
69
+ if (!enabled) return;
70
+ applyDocumentLocale(locale, direction);
71
+ }, [
72
+ enabled,
73
+ locale,
74
+ direction
75
+ ]);
76
+ const persist = options.persist;
77
+ const choose = useCallback((next) => {
78
+ if (config === null) return;
79
+ if (!config.supportedLocales.includes(next)) return;
80
+ rememberBrowserLocale(next, config);
81
+ setChosen(next);
82
+ Promise.resolve(persist?.(next)).catch(() => void 0);
83
+ }, [config, persist]);
84
+ return {
85
+ source: useMemo(() => {
86
+ if (kit?.locale !== locale) return null;
87
+ return {
88
+ catalogLocale: locale,
89
+ formattingLocale,
90
+ layers: [
91
+ options.messages?.[locale],
92
+ options.messages?.[defaultLocale],
93
+ kit.catalog
94
+ ]
95
+ };
96
+ }, [
97
+ kit,
98
+ locale,
99
+ formattingLocale,
100
+ options.messages,
101
+ defaultLocale
102
+ ]),
103
+ locale,
104
+ choose
105
+ };
106
+ }
107
+ //#endregion
108
+ export { useNegotiatedLocale };