@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/i18n
|
|
2
|
+
|
|
3
|
+
Language for a Pithy app — negotiated per request, rendered through one seam. One piece of middleware. No tables, no migrations, no bindings, and no error codes of its own.
|
|
4
|
+
|
|
5
|
+
It answers one question — *what language is this reader in?* — and hands every capability, every screen and every email a translator that already knows the answer.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add i18n
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/i18n](https://pithy.sh/docs/capabilities/i18n).** Overview, adding it, using it, and the reference: the translator interface, catalog keys, layer order.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/i18n"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/react/translator": "./src/react/translator.tsx",
|
|
21
|
+
"./src/*": "./src/*.ts"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:node": "vitest run --project=node --project=dom",
|
|
28
|
+
"test:workers": "vitest run --project=workers",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@pithy-sh/core": "workspace:*",
|
|
34
|
+
"hono": "^4.13.2",
|
|
35
|
+
"zod": "^4.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": "^19.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
47
|
+
"@pithy-sh/auth": "workspace:*",
|
|
48
|
+
"@pithy-sh/payments": "workspace:*",
|
|
49
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
50
|
+
"@types/node": "^22.15.0",
|
|
51
|
+
"@types/react": "^19.2.17",
|
|
52
|
+
"@types/react-dom": "^19.2.3",
|
|
53
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
54
|
+
"happy-dom": "^20.11.1",
|
|
55
|
+
"react": "^19.2.8",
|
|
56
|
+
"react-dom": "^19.2.8",
|
|
57
|
+
"typescript": "^7.0.2",
|
|
58
|
+
"vitest": "^4.1.0",
|
|
59
|
+
"wrangler": "^4.115.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "i18n",
|
|
3
|
+
"package": "@pithy-sh/i18n",
|
|
4
|
+
"requiredBindings": [],
|
|
5
|
+
"whenToEnable": "Enable it when your app serves readers in more than one language. It adds a translator seam every screen, error and email renders through, negotiates each reader's locale from the URL, their account, a cookie and Accept-Language, and formats dates, numbers and currency with Intl. Ships Spanish; add your own catalogs a sentence at a time.",
|
|
6
|
+
"scaffold": [
|
|
7
|
+
"Set `supportedLocales` to the languages you serve, least specific first — `es`, not `es-ES`.",
|
|
8
|
+
"Run `pithy doctor` to see which messages have no translation yet in each locale.",
|
|
9
|
+
"Override any kit sentence with one entry in `messages`; everything you do not mention keeps arriving with each release."
|
|
10
|
+
],
|
|
11
|
+
"configOptions": [
|
|
12
|
+
{
|
|
13
|
+
"key": "defaultLocale",
|
|
14
|
+
"default": "en",
|
|
15
|
+
"describe": "The locale served when nothing else answers. Must be one of supportedLocales."
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
6
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
7
|
+
import { createTranslator } from "@pithy-sh/core/src/i18n/translator";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapters, so an adopter who already runs an i18n stack plugs it into the seam instead of migrating.
|
|
11
|
+
*
|
|
12
|
+
* **No runtime dependency on any of them, and none is possible here.** Each adapter takes the instance
|
|
13
|
+
* the adopter already constructed and duck-types the minimal shape it needs — three or four members,
|
|
14
|
+
* declared as a structural type in this file. Nothing is imported, nothing is installed, and a version
|
|
15
|
+
* of i18next that moved a method is a compile error in *their* repository rather than a broken
|
|
16
|
+
* dependency in ours.
|
|
17
|
+
*
|
|
18
|
+
* The shapes are minimal on purpose. An adapter that named every member of `i18next.TFunction` would
|
|
19
|
+
* break on a release that added one; naming only what is called means it keeps working across the
|
|
20
|
+
* versions that do not touch those three.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Everything the wrapper needs, once the words are somebody else's problem. */
|
|
24
|
+
interface Wrapping {
|
|
25
|
+
/** The locale whose catalog answers. */
|
|
26
|
+
readonly catalogLocale: string;
|
|
27
|
+
/** The locale handed to `Intl`. Defaults to the catalog locale. */
|
|
28
|
+
readonly formattingLocale?: string;
|
|
29
|
+
/** The other library's lookup, already bound to its own catalogs. */
|
|
30
|
+
readonly translate: (key: string, params?: MessageParams) => string;
|
|
31
|
+
/** The other library's plural lookup, when it has one worth using. */
|
|
32
|
+
readonly plural?: (key: string, count: number, params?: MessageParams) => string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A `Translator` whose words come from `translate` and whose formatting comes from `Intl`.
|
|
37
|
+
*
|
|
38
|
+
* The formatting half is never delegated. Every one of these libraries formats through `Intl` anyway,
|
|
39
|
+
* workerd embeds full ICU, and a `Translator` that formatted through a wrapper would answer a
|
|
40
|
+
* different date for the same locale depending on which adapter was in use.
|
|
41
|
+
*/
|
|
42
|
+
function wrap(source: Wrapping): Translator {
|
|
43
|
+
const base = createTranslator({
|
|
44
|
+
catalogLocale: source.catalogLocale,
|
|
45
|
+
formattingLocale: source.formattingLocale,
|
|
46
|
+
layers: [],
|
|
47
|
+
});
|
|
48
|
+
const translate = (key: string, params?: MessageParams): string => source.translate(key, params);
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
direction: localeDirection(source.catalogLocale),
|
|
52
|
+
t: translate,
|
|
53
|
+
/**
|
|
54
|
+
* **A miss is the key coming back**, which is what every library this adapts already does:
|
|
55
|
+
* i18next returns the key, FormatJS returns the message id, Lingui returns the id.
|
|
56
|
+
*
|
|
57
|
+
* It has to be implemented here rather than inherited. `wrap` builds its base from
|
|
58
|
+
* `createTranslator` with **no layers**, so an inherited `maybe` answers `null` for everything —
|
|
59
|
+
* and `maybe` is the whole of the documented error contract, `t.maybe(code, params) ?? message`.
|
|
60
|
+
* Left inherited, every adapted translator silently rendered the English fallback for every code
|
|
61
|
+
* it could in fact translate, and nothing failed to say so.
|
|
62
|
+
*/
|
|
63
|
+
maybe: (key, params) => {
|
|
64
|
+
const answered = translate(key, params);
|
|
65
|
+
return answered === key ? null : answered;
|
|
66
|
+
},
|
|
67
|
+
plural: (key, count, params) =>
|
|
68
|
+
source.plural ? source.plural(key, count, params) : translate(key, { count, ...params } satisfies MessageParams),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The part of an i18next instance this adapter calls. */
|
|
73
|
+
export interface I18nextLike {
|
|
74
|
+
/** The active language tag. */
|
|
75
|
+
readonly language: string;
|
|
76
|
+
/** i18next's lookup. `count` in the options object is what drives its own plural selection. */
|
|
77
|
+
t(key: string, options?: Record<string, unknown>): string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A `Translator` backed by an i18next instance.
|
|
82
|
+
*
|
|
83
|
+
* i18next selects plurals from a `count` option rather than from a key suffix, so `plural` hands it
|
|
84
|
+
* `count` and lets it choose — the kit's `<key>.<category>` convention is not imposed on a catalog
|
|
85
|
+
* i18next already owns.
|
|
86
|
+
*/
|
|
87
|
+
export function fromI18next(instance: I18nextLike, formattingLocale?: string): Translator {
|
|
88
|
+
return wrap({
|
|
89
|
+
catalogLocale: instance.language,
|
|
90
|
+
formattingLocale,
|
|
91
|
+
translate: (key, params) => instance.t(key, params),
|
|
92
|
+
plural: (key, count, params) => instance.t(key, { count, ...params }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The part of a FormatJS / react-intl `IntlShape` this adapter calls. */
|
|
97
|
+
export interface IntlShapeLike {
|
|
98
|
+
/** The active locale tag. */
|
|
99
|
+
readonly locale: string;
|
|
100
|
+
/** FormatJS's lookup, keyed by message id. */
|
|
101
|
+
formatMessage(descriptor: { id: string }, values?: Record<string, unknown>): string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A `Translator` backed by a FormatJS / react-intl `IntlShape`.
|
|
106
|
+
*
|
|
107
|
+
* FormatJS resolves plurals inside the ICU message itself, so `plural` passes `count` as a value and
|
|
108
|
+
* lets the message's own `{count, plural, …}` arm decide.
|
|
109
|
+
*/
|
|
110
|
+
export function fromIntl(intl: IntlShapeLike, formattingLocale?: string): Translator {
|
|
111
|
+
return wrap({
|
|
112
|
+
catalogLocale: intl.locale,
|
|
113
|
+
formattingLocale,
|
|
114
|
+
translate: (key, params) => intl.formatMessage({ id: key }, params),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The part of a Lingui `I18n` instance this adapter calls. */
|
|
119
|
+
export interface LinguiLike {
|
|
120
|
+
/** The active locale tag. */
|
|
121
|
+
readonly locale: string;
|
|
122
|
+
/** Lingui's lookup, keyed by message id. */
|
|
123
|
+
_(id: string, values?: Record<string, unknown>): string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A `Translator` backed by a Lingui `I18n` instance.
|
|
128
|
+
*
|
|
129
|
+
* Lingui also resolves plurals inside the message, so this is the same shape as {@link fromIntl} — the
|
|
130
|
+
* two differ only in what the lookup is called.
|
|
131
|
+
*/
|
|
132
|
+
export function fromLingui(i18n: LinguiLike, formattingLocale?: string): Translator {
|
|
133
|
+
return wrap({
|
|
134
|
+
catalogLocale: i18n.locale,
|
|
135
|
+
formattingLocale,
|
|
136
|
+
translate: (key, params) => i18n._(key, params),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { formattingLocaleOf, type LocaleContext, localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
5
|
+
import { matchLocale } from "@pithy-sh/core/src/i18n/match";
|
|
6
|
+
import type { I18nClientProjection } from "../client/projection";
|
|
7
|
+
import type { BrowserResolver } from "../config/config";
|
|
8
|
+
import { recognizedResolvers } from "../resolve/browser";
|
|
9
|
+
import { applyDocumentLocale } from "./signals";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The document's opening language, negotiated from what `virtual:pithy/i18n` projects.
|
|
13
|
+
*
|
|
14
|
+
* **The entry point a scaffolded `client.tsx` calls, and the reason it is one line there.** A template
|
|
15
|
+
* is copied into an adopter's repository and never rewritten, so anything it does by hand is frozen on
|
|
16
|
+
* the day they scaffold. Two things in particular must not be: the guarded storage reads — `localStorage`
|
|
17
|
+
* throws in a private window, behind a site-data block, and inside some embedded views — and the chain
|
|
18
|
+
* order itself, which is the project's configuration rather than the front end's assumption.
|
|
19
|
+
*
|
|
20
|
+
* **It takes the projection, not an `I18nConfig`.** The projection is browser-safe by construction and
|
|
21
|
+
* is what a client actually holds: `browserResolvers` arrives as `string[]` because the generated
|
|
22
|
+
* ambient declaration in an adopter's Worker cannot name a type it does not import, so the chain is
|
|
23
|
+
* walked by name and an unrecognized link contributes nothing rather than throwing. `recognizedResolvers`
|
|
24
|
+
* is where that happens, once, for this pass and for `useNegotiatedLocale` both.
|
|
25
|
+
*
|
|
26
|
+
* **This is the first pass, not the decision.** It reaches only what a page can see before it renders:
|
|
27
|
+
* the URL, this device's memory, and whatever the server put on `<html lang>`. A signed-in reader's
|
|
28
|
+
* stored preference needs a session, which needs a render — `useNegotiatedLocale` picks that up and
|
|
29
|
+
* calls {@link applyDocumentLocale} again if it lands somewhere else. Doing it here as well is what
|
|
30
|
+
* keeps a right-to-left reader from watching the page reflow after the first paint.
|
|
31
|
+
*
|
|
32
|
+
* Answers **both** locales it resolved, or `null` when the capability is not composed and there was
|
|
33
|
+
* nothing to negotiate — a project without it keeps the `lang` its `index.html` shipped.
|
|
34
|
+
*
|
|
35
|
+
* **Both, and not the one tag `lang` carries.** This returned a single string at first, and the
|
|
36
|
+
* scaffolded provider then passed it as `catalogLocale` *and* `formattingLocale` — which is exactly the
|
|
37
|
+
* collapse the two-locale design exists to prevent, reintroduced in the one place a reader actually
|
|
38
|
+
* looks at a page. An `es-AR` visitor got Argentine dates from the Worker and Spanish-from-Spain dates
|
|
39
|
+
* from the SPA, on the same account, in the same session. The region survives here for the same reason
|
|
40
|
+
* `resolveChain` keeps it on the server: the range the reader asked for is a fact the match already
|
|
41
|
+
* knows, and throwing it away is a decision, not an omission.
|
|
42
|
+
*/
|
|
43
|
+
export function applyProjectedLocale(projection: I18nClientProjection): LocaleContext | null {
|
|
44
|
+
if (!projection.enabled) return null;
|
|
45
|
+
const wanted: string[] = [];
|
|
46
|
+
for (const resolver of recognizedResolvers(projection.browserResolvers)) {
|
|
47
|
+
const tag = signal(resolver, projection);
|
|
48
|
+
if (tag) wanted.push(tag);
|
|
49
|
+
}
|
|
50
|
+
const matched = matchLocale(wanted, projection.supportedLocales, projection.exceptions);
|
|
51
|
+
const catalogLocale = matched?.locale ?? projection.defaultLocale;
|
|
52
|
+
// The reader's own tag, canonicalized, when the range that matched is one `Intl` accepts — `es-ar`
|
|
53
|
+
// becomes `es-AR`. A range that is not a constructible tag (a wildcard, an exception-map key)
|
|
54
|
+
// formats as the catalog locale, which is the same rule the server chain applies.
|
|
55
|
+
const requested = matched ? formattingLocaleOf(matched.range) : null;
|
|
56
|
+
const resolved: LocaleContext = {
|
|
57
|
+
catalogLocale,
|
|
58
|
+
formattingLocale: requested ?? catalogLocale,
|
|
59
|
+
direction: localeDirection(catalogLocale),
|
|
60
|
+
};
|
|
61
|
+
applyDocumentLocale(resolved.catalogLocale, resolved.direction);
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One link of the browser chain, by name.
|
|
67
|
+
*
|
|
68
|
+
* `account` answers nothing: there is no session before a render, and inventing one here would be a
|
|
69
|
+
* second source of truth for a fact `pithy_auth_users.locale` owns. It stays in the list so the
|
|
70
|
+
* configured order is walked whole, and so the link that follows it is still asked in its own place.
|
|
71
|
+
*
|
|
72
|
+
* Takes a `BrowserResolver` rather than a `string`, so the switch is exhaustive: `recognizedResolvers`
|
|
73
|
+
* has already dropped anything this build does not know, and a link added to the enum tomorrow is a red
|
|
74
|
+
* build here rather than a silently inert first paint.
|
|
75
|
+
*/
|
|
76
|
+
function signal(
|
|
77
|
+
resolver: BrowserResolver,
|
|
78
|
+
projection: Extract<I18nClientProjection, { enabled: true }>,
|
|
79
|
+
): string | null {
|
|
80
|
+
switch (resolver) {
|
|
81
|
+
case "query":
|
|
82
|
+
return read(() => new URL(window.location.href).searchParams.get(projection.queryParam));
|
|
83
|
+
case "account":
|
|
84
|
+
return null;
|
|
85
|
+
case "storage":
|
|
86
|
+
return read(() => window.localStorage.getItem(projection.storageKey));
|
|
87
|
+
case "navigator":
|
|
88
|
+
// The pre-render pass takes only the reader's first choice: this runs before paint to keep an
|
|
89
|
+
// RTL page from reflowing, and each link here offers one range. The full weighted list is walked
|
|
90
|
+
// a moment later by `useNegotiatedLocale`, which can afford it.
|
|
91
|
+
return read(() => window.navigator.languages?.[0] ?? window.navigator.language ?? null);
|
|
92
|
+
case "server":
|
|
93
|
+
return read(() => document.documentElement.lang || null);
|
|
94
|
+
case "default":
|
|
95
|
+
return projection.defaultLocale;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read a global, answering `null` rather than throwing when the global is absent or refuses. */
|
|
100
|
+
function read(get: () => string | null): string | null {
|
|
101
|
+
try {
|
|
102
|
+
return get();
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BrowserChain, BrowserLocaleSignals } from "../resolve/browser";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The browser-side edge of locale resolution: the three signals a page can read for itself.
|
|
8
|
+
*
|
|
9
|
+
* **Separate from `resolve/browser.ts` on purpose.** That module is a pure function — signals in,
|
|
10
|
+
* locale out — so it typechecks and tests in the Worker program with no DOM library in scope. This one
|
|
11
|
+
* touches globals, so it lives in the browser program (`tsconfig.browser.json`) and nothing a Worker
|
|
12
|
+
* bundles can reach it. Effects at the edge; the decision in the middle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the page can see: the query string, this device's memory, and what the server put on `<html lang>`.
|
|
17
|
+
*
|
|
18
|
+
* `account` is deliberately absent — the app knows whether someone is signed in and this function does
|
|
19
|
+
* not. Merge it in at the call site.
|
|
20
|
+
*
|
|
21
|
+
* **Every read is wrapped**, because two of the three throw in real browsers. `localStorage` raises in
|
|
22
|
+
* a private window, behind a site-data block, and in some embedded views; and during a server render
|
|
23
|
+
* there is no `window` or `document` at all. A reader whose browser refuses storage still gets a
|
|
24
|
+
* language — just not a remembered one.
|
|
25
|
+
*
|
|
26
|
+
* Takes the two names a page looks itself up by and nothing else, so the projection a browser holds and
|
|
27
|
+
* the config a Worker resolves both answer it without either being widened into the other.
|
|
28
|
+
*/
|
|
29
|
+
export function readBrowserSignals(config: Pick<BrowserChain, "queryParam" | "storageKey">): BrowserLocaleSignals {
|
|
30
|
+
return {
|
|
31
|
+
query: read(() => new URL(window.location.href).searchParams.get(config.queryParam)),
|
|
32
|
+
storage: read(() => window.localStorage.getItem(config.storageKey)),
|
|
33
|
+
// The reader's own preference order. Guarded like the rest: `navigator` is absent during a server
|
|
34
|
+
// render, and `languages` is absent in a few older browsers where `language` is the whole of it.
|
|
35
|
+
navigator: readList(() => [...(window.navigator.languages ?? [window.navigator.language])]),
|
|
36
|
+
server: read(() => document.documentElement.lang || null),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remember `locale` on this device. Silent when storage is unavailable — a preference is not worth a throw. */
|
|
41
|
+
export function rememberBrowserLocale(locale: string, config: Pick<BrowserChain, "storageKey">): void {
|
|
42
|
+
try {
|
|
43
|
+
window.localStorage.setItem(config.storageKey, locale);
|
|
44
|
+
} catch {
|
|
45
|
+
// A private window, a cleared site-data setting, a browser told to block storage. The reader still
|
|
46
|
+
// gets the language they asked for this visit; only the memory of it is lost.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Put `lang` and `dir` on the document.
|
|
52
|
+
*
|
|
53
|
+
* The one thing no catalog can do and every screen needs: assistive technology, hyphenation, and the
|
|
54
|
+
* browser's own text handling all read `lang`, and `dir` is what makes a right-to-left locale lay out
|
|
55
|
+
* at all. `templates/index.html` ships `lang="en"` as static text with no substitution token, so the
|
|
56
|
+
* script side is the only hook there is.
|
|
57
|
+
*/
|
|
58
|
+
export function applyDocumentLocale(locale: string, direction: "ltr" | "rtl"): void {
|
|
59
|
+
try {
|
|
60
|
+
document.documentElement.lang = locale;
|
|
61
|
+
document.documentElement.dir = direction;
|
|
62
|
+
} catch {
|
|
63
|
+
// No document — a server render, or a test environment without one. Nothing to set and nothing
|
|
64
|
+
// to report: the markup a server render produces carries the same two attributes already.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read a list off a global, answering `null` rather than throwing when the global is absent. */
|
|
69
|
+
function readList(get: () => string[]): string[] | null {
|
|
70
|
+
try {
|
|
71
|
+
const list = get().filter((tag) => typeof tag === "string" && tag.trim().length > 0);
|
|
72
|
+
return list.length > 0 ? list : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read a global, answering `null` rather than throwing when the global is absent or refuses. */
|
|
79
|
+
function read(get: () => string | null): string | null {
|
|
80
|
+
try {
|
|
81
|
+
return get();
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type { LocaleCatalogs, MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
import { composeMessages } from "@pithy-sh/core/src/i18n/registry";
|
|
7
|
+
import { DEFAULT_LOCALE } from "@pithy-sh/core/src/i18n/translator";
|
|
8
|
+
import { KIT_CATALOGS } from "./catalogs/kit";
|
|
9
|
+
import type { I18nClientProjection } from "./client/projection";
|
|
10
|
+
import { I18nConfig, type I18nConfigInput } from "./config/config";
|
|
11
|
+
import { i18nMiddleware } from "./http/middleware";
|
|
12
|
+
import { i18nSettings } from "./settings/coverage";
|
|
13
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The i18n capability, with its resolved config and the catalogs it composed attached for inspection.
|
|
17
|
+
*
|
|
18
|
+
* The composed catalogs are populated by the `compose` hook rather than at construction, because a
|
|
19
|
+
* capability sees only itself when it is constructed and the kit's English lives on the capabilities
|
|
20
|
+
* that own it.
|
|
21
|
+
*/
|
|
22
|
+
export interface I18nCapability extends Capability {
|
|
23
|
+
/** The validated config, for `@pithy-sh/ui-react` and anything else that needs the negotiated set. */
|
|
24
|
+
i18nConfig: I18nConfig;
|
|
25
|
+
/** Every composed capability's English, merged under the domain rule. Empty until `compose` runs. */
|
|
26
|
+
composedMessages: LocaleCatalogs;
|
|
27
|
+
/** The catalog layers `t()` walks for `locale`, most-specific first. */
|
|
28
|
+
layersFor(locale: string): readonly (MessageCatalog | undefined)[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The i18n capability.
|
|
33
|
+
*
|
|
34
|
+
* **Stateless, like `@pithy-sh/turnstile`** — no tables, no migrations, no `<NAME>_MIGRATION_ORDER`, no
|
|
35
|
+
* error codes, no bindings of its own. It contributes one middleware, which resolves the request's
|
|
36
|
+
* locale and replaces `c.var.t` with a translator over the merged catalogs.
|
|
37
|
+
*
|
|
38
|
+
* It declares no error codes deliberately. The moment an `i18n/*` code entered `KitErrorPayload` the
|
|
39
|
+
* whole `i18n` domain would be reserved against adopters, and `i18n` is a generic enough word that
|
|
40
|
+
* somebody has already defined `i18n/missing_catalog` through `defineErrorPayload`. Nothing here needs
|
|
41
|
+
* a code that `core/*` and `validation/*` do not already carry.
|
|
42
|
+
*
|
|
43
|
+
* ## What the layers are, and why the order is this
|
|
44
|
+
*
|
|
45
|
+
* `t(key)` walks, per key, in this order:
|
|
46
|
+
*
|
|
47
|
+
* 1. the adopter's catalog for the resolved locale,
|
|
48
|
+
* 2. the adopter's catalog for the project default,
|
|
49
|
+
* 3. **the kit's translation** for the resolved locale — the `es` this package ships,
|
|
50
|
+
* 4. every composed capability's English, for the resolved locale,
|
|
51
|
+
* 5. every composed capability's copy for the project default,
|
|
52
|
+
* 6. and their English, which is the language the kit is written in.
|
|
53
|
+
*
|
|
54
|
+
* Per key, never per catalog: overriding one sentence is one entry, and every key an adopter did not
|
|
55
|
+
* mention keeps flowing from the package. That is what makes an override a merge rather than a fork,
|
|
56
|
+
* and it is why kit catalogs are never copied into an adopter's repository.
|
|
57
|
+
*
|
|
58
|
+
* Errors are not in any of those layers and do not need to be: the payload already carries its English
|
|
59
|
+
* `message`, and a translating client renders `t.maybe(payload.code, payload.params) ?? payload.message`. So
|
|
60
|
+
* the kit ships translations for the 120 codes and no English duplicate of what is already on the wire.
|
|
61
|
+
*/
|
|
62
|
+
export function i18n(config: I18nConfigInput = {}): I18nCapability {
|
|
63
|
+
const resolved = I18nConfig.parse(config);
|
|
64
|
+
let composedMessages: LocaleCatalogs = {};
|
|
65
|
+
|
|
66
|
+
const layersFor = (locale: string): readonly (MessageCatalog | undefined)[] => [
|
|
67
|
+
resolved.messages[locale],
|
|
68
|
+
resolved.messages[resolved.defaultLocale],
|
|
69
|
+
KIT_CATALOGS[locale],
|
|
70
|
+
composedMessages[locale],
|
|
71
|
+
composedMessages[resolved.defaultLocale],
|
|
72
|
+
// **The source language, explicitly, and last.** This was `catalogFor(composedMessages, default)`,
|
|
73
|
+
// which answers `catalogs[default] ?? catalogs.en` — so it only ever reached English because no
|
|
74
|
+
// capability had contributed a non-`en` locale. `@pithy-sh/email` does now (#442), which made the
|
|
75
|
+
// fallback silently stop existing for any project whose `defaultLocale` is not `en`: layers 5 and
|
|
76
|
+
// 6 collapsed into the same Spanish map, and an adopter's own English-only key rendered as its raw
|
|
77
|
+
// key on a Spanish page. English is the language the kit is written in, so it is a layer of its
|
|
78
|
+
// own rather than something another layer resolves to by coincidence.
|
|
79
|
+
composedMessages[DEFAULT_LOCALE],
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const capability = defineCapability({
|
|
83
|
+
name: "i18n",
|
|
84
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
85
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
86
|
+
version: PACKAGE_VERSION,
|
|
87
|
+
config: I18nConfig,
|
|
88
|
+
/**
|
|
89
|
+
* Merge every composed capability's English once, at assembly.
|
|
90
|
+
*
|
|
91
|
+
* Here rather than at construction because a capability sees only itself when it is built, and the
|
|
92
|
+
* kit's English is contributed by the capabilities that own it — `@pithy-sh/email`'s template copy
|
|
93
|
+
* under `email/`, an adopter's own `app` capability under theirs. `composeMessages` enforces that
|
|
94
|
+
* each may only write keys under its own domain.
|
|
95
|
+
*/
|
|
96
|
+
compose: ({ capabilities }) => {
|
|
97
|
+
composedMessages = composeMessages(capabilities);
|
|
98
|
+
},
|
|
99
|
+
middleware: [i18nMiddleware(resolved, layersFor)],
|
|
100
|
+
/**
|
|
101
|
+
* Locale **metadata**, never catalogs. `renderVirtualModule` inlines this as a `JSON.stringify`
|
|
102
|
+
* literal in the main chunk, so a catalog here would be downloaded by every reader in every
|
|
103
|
+
* language before first paint and would defeat the per-locale splitting the design rests on.
|
|
104
|
+
*
|
|
105
|
+
* The return type is {@link I18nClientProjection} — declared, not inferred, so a dropped field is a
|
|
106
|
+
* compile error here rather than a browser's problem.
|
|
107
|
+
*/
|
|
108
|
+
client: (): I18nClientProjection => ({
|
|
109
|
+
enabled: true,
|
|
110
|
+
supportedLocales: [...resolved.supportedLocales],
|
|
111
|
+
defaultLocale: resolved.defaultLocale,
|
|
112
|
+
queryParam: resolved.queryParam,
|
|
113
|
+
storageKey: resolved.storageKey,
|
|
114
|
+
browserResolvers: [...resolved.browserResolvers],
|
|
115
|
+
exceptions: { ...resolved.exceptions },
|
|
116
|
+
}),
|
|
117
|
+
/**
|
|
118
|
+
* Catalog coverage, as `pithy doctor`'s local tier rather than as a `pithy i18n check` command.
|
|
119
|
+
*
|
|
120
|
+
* It is offline, project-file-only, and a local finding already fails `doctor`'s exit code — which
|
|
121
|
+
* is exactly what the command would have been, at the cost of a new page in `docs/commands/`, a
|
|
122
|
+
* row in five exact-count CLI gates, and a byte-pinned re-paste of `pithy --help`.
|
|
123
|
+
*/
|
|
124
|
+
settings: i18nSettings(resolved, () => composedMessages),
|
|
125
|
+
requiredBindings: [],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const composed = Object.assign(capability, { i18nConfig: resolved, layersFor });
|
|
129
|
+
// `composedMessages` is defined on the target rather than declared in the literal above, and that is
|
|
130
|
+
// not a style choice. `Object.assign` **reads** an accessor on its source and copies the value it
|
|
131
|
+
// returned, so a `get composedMessages()` in that literal would be evaluated once — before `compose`
|
|
132
|
+
// has run — and every reader would see the empty object it held at construction, permanently.
|
|
133
|
+
// Defined here it stays an accessor over the live binding, which is what the interface promises.
|
|
134
|
+
Object.defineProperty(composed, "composedMessages", { get: () => composedMessages, enumerable: true });
|
|
135
|
+
return composed as I18nCapability;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether a capability is the i18n capability — carries its resolved config and composed catalogs. */
|
|
139
|
+
export function isI18nCapability(capability: Capability): capability is I18nCapability {
|
|
140
|
+
return capability.name === "i18n" && "i18nConfig" in capability;
|
|
141
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The kit's catalogs, as one dynamic import per locale.
|
|
8
|
+
*
|
|
9
|
+
* **A static map of thunks, not a computed specifier.** `import(\`./es/${locale}\`)` is a specifier no
|
|
10
|
+
* bundler can resolve, so it either fails the build or pulls every match into one chunk; written out,
|
|
11
|
+
* Rollup emits exactly one chunk per locale and a reader downloads only their own. That is the whole
|
|
12
|
+
* reason catalogs are not on the `virtual:pithy/i18n` projection, which `renderVirtualModule` inlines
|
|
13
|
+
* as a `JSON.stringify` literal into the main chunk.
|
|
14
|
+
*
|
|
15
|
+
* English is absent for the same reason it is absent from {@link KIT_CATALOGS}: a copied screen already
|
|
16
|
+
* carries the English it was scaffolded with, and that is the only catalog that survives being copied.
|
|
17
|
+
*/
|
|
18
|
+
const LOADERS: Record<string, () => Promise<MessageCatalog>> = {
|
|
19
|
+
es: () => import("./es/index").then((module) => module.es),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the kit ships a catalog for `locale`. English answers `false` — it is the baked fallback.
|
|
24
|
+
*
|
|
25
|
+
* **`Object.hasOwn`, never `in`.** The argument is a language tag, and a language tag is whatever a
|
|
26
|
+
* reader put after `?lang=`. `"__proto__" in LOADERS` is `true` — the accessor is on `Object.prototype`
|
|
27
|
+
* — so `in` answered yes for a locale the kit has never written, and {@link loadKitCatalog} then read
|
|
28
|
+
* `Object.prototype` back as a loader and called it.
|
|
29
|
+
*/
|
|
30
|
+
export function hasKitCatalog(locale: string): boolean {
|
|
31
|
+
return Object.hasOwn(LOADERS, locale);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The kit's catalog for `locale`, or an empty one when the kit writes nothing in that language. */
|
|
35
|
+
export async function loadKitCatalog(locale: string): Promise<MessageCatalog> {
|
|
36
|
+
if (!hasKitCatalog(locale)) return {};
|
|
37
|
+
const load = LOADERS[locale];
|
|
38
|
+
return load ? await load() : {};
|
|
39
|
+
}
|