@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,86 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { LocaleContext } from "@pithy-sh/core/src/i18n/locale";
|
|
5
|
+
import { formattingLocaleOf, localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
6
|
+
import type { LocaleExceptions } from "@pithy-sh/core/src/i18n/match";
|
|
7
|
+
import { matchLocale } from "@pithy-sh/core/src/i18n/match";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The languages a chain may land on: what it matches against, what it falls back to, and the pairs no
|
|
11
|
+
* truncation of a tag would reach.
|
|
12
|
+
*
|
|
13
|
+
* **Three fields rather than `I18nConfig`, because a browser holds neither the catalogs nor the cookie
|
|
14
|
+
* name nor the server chain.** `virtual:pithy/i18n` projects locale metadata and nothing else, and this
|
|
15
|
+
* is the part of it a match is made from — so the projection satisfies this shape as it stands and the
|
|
16
|
+
* resolved config satisfies it too. Structural on purpose: one type both sides already are beats a
|
|
17
|
+
* conversion every caller has to work out for itself.
|
|
18
|
+
*/
|
|
19
|
+
export interface LocaleSet {
|
|
20
|
+
/** Every locale this project serves. A chain answers with one of these or with nothing. */
|
|
21
|
+
readonly supportedLocales: readonly string[];
|
|
22
|
+
/** The locale served when no link answers. Always one of `supportedLocales`. */
|
|
23
|
+
readonly defaultLocale: string;
|
|
24
|
+
/** Language ranges the matcher cannot derive, as range → supported locale. Usually empty. */
|
|
25
|
+
readonly exceptions: LocaleExceptions;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One link of a resolver chain: where the ranges came from, and what they were. */
|
|
29
|
+
export interface ResolverLink {
|
|
30
|
+
/** The link's name, as it appears in `serverResolvers` / `browserResolvers`. */
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/** The language ranges this link offers, most-wanted first. Empty when the link has nothing to say. */
|
|
33
|
+
readonly ranges: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A resolved locale, plus which link answered — so `pithy doctor` and a log can say why. */
|
|
37
|
+
export interface ResolvedLocale extends LocaleContext {
|
|
38
|
+
/** The chain link that answered, or `"default"` when the chain fell through to the project default. */
|
|
39
|
+
readonly resolvedBy: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A single tag as a one-element range list, or nothing when there is no tag.
|
|
44
|
+
*
|
|
45
|
+
* The links that read a stored or supplied value all have this shape. A blank string is nothing, not a
|
|
46
|
+
* range: a cookie the browser cleared and a cookie that was never set mean the same thing here.
|
|
47
|
+
*/
|
|
48
|
+
export function tagLink(name: string, tag: string | null | undefined): ResolverLink {
|
|
49
|
+
const trimmed = tag?.trim();
|
|
50
|
+
return { name, ranges: trimmed ? [trimmed] : [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk `links` in order and take the first that matches a supported locale.
|
|
55
|
+
*
|
|
56
|
+
* **The two locales are decided here, and only one of them falls back.** The catalog locale is what
|
|
57
|
+
* matched — the words the kit actually has. The formatting locale is the *range the reader sent*, kept
|
|
58
|
+
* whole when it is a tag `Intl` accepts: an `es-AR` visitor reads the `es` catalog and formats as
|
|
59
|
+
* `es-AR`, which `Intl` supports natively whether or not anyone wrote a string for it.
|
|
60
|
+
*
|
|
61
|
+
* Total. Every construction on the way through is guarded, so a chain fed nothing but malformed input
|
|
62
|
+
* lands on the project default rather than throwing.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveChain(links: readonly ResolverLink[], locales: LocaleSet): ResolvedLocale {
|
|
65
|
+
for (const link of links) {
|
|
66
|
+
if (link.ranges.length === 0) continue;
|
|
67
|
+
const match = matchLocale(link.ranges, locales.supportedLocales, locales.exceptions);
|
|
68
|
+
if (!match) continue;
|
|
69
|
+
// The reader's own tag, canonicalized and stripped of extension subtags — `es-ar` becomes `es-AR`,
|
|
70
|
+
// and `en-u-nu-hanidec` becomes `en`. A range that is not a constructible tag (a wildcard, an
|
|
71
|
+
// exception-map key) formats as the catalog locale.
|
|
72
|
+
const requested = formattingLocaleOf(match.range);
|
|
73
|
+
return {
|
|
74
|
+
catalogLocale: match.locale,
|
|
75
|
+
formattingLocale: requested ?? match.locale,
|
|
76
|
+
direction: localeDirection(match.locale),
|
|
77
|
+
resolvedBy: link.name,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
catalogLocale: locales.defaultLocale,
|
|
82
|
+
formattingLocale: locales.defaultLocale,
|
|
83
|
+
direction: localeDirection(locales.defaultLocale),
|
|
84
|
+
resolvedBy: "default",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { parseAcceptLanguage } from "@pithy-sh/core/src/i18n/acceptLanguage";
|
|
5
|
+
import type { I18nConfig, ServerResolver } from "../config/config";
|
|
6
|
+
import { type ResolvedLocale, type ResolverLink, resolveChain, tagLink } from "./chain";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a Worker knows about a request's language, before any of it is trusted.
|
|
10
|
+
*
|
|
11
|
+
* Every field is caller-supplied and every one of them is guarded downstream: a `*`, an `en_US`, an
|
|
12
|
+
* empty token or a fragment still carrying `;q=0.9` falls out of the match instead of reaching
|
|
13
|
+
* `Intl.Locale` and raising a `RangeError`.
|
|
14
|
+
*/
|
|
15
|
+
export interface ServerLocaleSignals {
|
|
16
|
+
/** An explicit choice on the query string — `?lang=es`. */
|
|
17
|
+
readonly param?: string | null;
|
|
18
|
+
/** The signed-in reader's own preference, from `pithy_auth_users.locale`. */
|
|
19
|
+
readonly user?: string | null;
|
|
20
|
+
/** The locale cookie, for a reader who chose one and is not signed in. */
|
|
21
|
+
readonly cookie?: string | null;
|
|
22
|
+
/** The raw `Accept-Language` header, q-weights and all. */
|
|
23
|
+
readonly header?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The request's locale, by the configured server chain.
|
|
28
|
+
*
|
|
29
|
+
* Default order: explicit param, the account, the cookie, `Accept-Language`, the project default. An
|
|
30
|
+
* adopter reorders or shortens it in config; `default` is the last resort whether or not it is listed.
|
|
31
|
+
*
|
|
32
|
+
* `Accept-Language` is honored as the **full q-weighted list**, not as its first entry —
|
|
33
|
+
* `pt-PT;q=1.0, es;q=0.8, en;q=0.5` from a reader with no Portuguese is a request for Spanish, and
|
|
34
|
+
* reading only the head answers English.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveServerLocale(signals: ServerLocaleSignals, config: I18nConfig): ResolvedLocale {
|
|
37
|
+
const links: ResolverLink[] = config.serverResolvers.map((resolver) => linkFor(resolver, signals, config));
|
|
38
|
+
return resolveChain(links, config);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One link, by name. A standalone function so the switch stays exhaustive under `verbatimModuleSyntax`. */
|
|
42
|
+
function linkFor(resolver: ServerResolver, signals: ServerLocaleSignals, config: I18nConfig): ResolverLink {
|
|
43
|
+
switch (resolver) {
|
|
44
|
+
case "param":
|
|
45
|
+
return tagLink(resolver, signals.param);
|
|
46
|
+
case "user":
|
|
47
|
+
return tagLink(resolver, signals.user);
|
|
48
|
+
case "cookie":
|
|
49
|
+
return tagLink(resolver, signals.cookie);
|
|
50
|
+
case "header":
|
|
51
|
+
return { name: resolver, ranges: parseAcceptLanguage(signals.header).map((entry) => entry.range) };
|
|
52
|
+
case "default":
|
|
53
|
+
return tagLink(resolver, config.defaultLocale);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CapabilitySettings, SettingsFinding } from "@pithy-sh/core/src/capability/settings";
|
|
5
|
+
import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
import { KIT_CATALOGS } from "../catalogs/kit";
|
|
7
|
+
import type { I18nConfig } from "../config/config";
|
|
8
|
+
|
|
9
|
+
/** How many missing keys a finding names before it stops counting out loud. */
|
|
10
|
+
const LISTED_KEYS = 5;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Catalog coverage as `pithy doctor`'s **local** tier — offline, project-file-only, and already fatal
|
|
14
|
+
* to `doctor`'s exit code.
|
|
15
|
+
*
|
|
16
|
+
* This is the whole of what a `pithy i18n check` command would have been, and it costs no new command,
|
|
17
|
+
* no `docs/commands/` page, and no edit to the five exact-count or byte-pinned CLI gates a new command
|
|
18
|
+
* moves (`dispatch.test.ts`'s `DECLARED`/`GROUPS`, `binDocs.test.ts`'s byte comparison of `docs/CLI.md`
|
|
19
|
+
* §4.1 against real `pithy --help`, `doctorDocs.test.ts`'s mandated page sections and `--json` key
|
|
20
|
+
* register). The seam already exists and this is textbook for it.
|
|
21
|
+
*
|
|
22
|
+
* It answers one question: **would a reader in a supported locale meet a sentence nobody wrote?** For
|
|
23
|
+
* each locale the project serves, every key reachable in the default locale must be reachable in that
|
|
24
|
+
* one too, through the adopter's own catalog or the kit's translation.
|
|
25
|
+
*
|
|
26
|
+
* There is no `account` tier. Nothing about language is a question for the Cloudflare API.
|
|
27
|
+
*/
|
|
28
|
+
export function i18nSettings(config: I18nConfig, composed: () => LocaleCatalogs): CapabilitySettings {
|
|
29
|
+
return {
|
|
30
|
+
local: () => {
|
|
31
|
+
const findings: SettingsFinding[] = [];
|
|
32
|
+
const capabilityMessages = composed();
|
|
33
|
+
const baseline = new Set([
|
|
34
|
+
...Object.keys(capabilityMessages[config.defaultLocale] ?? {}),
|
|
35
|
+
...Object.keys(config.messages[config.defaultLocale] ?? {}),
|
|
36
|
+
]);
|
|
37
|
+
for (const locale of config.supportedLocales) {
|
|
38
|
+
if (locale === config.defaultLocale) continue;
|
|
39
|
+
const covered = new Set([
|
|
40
|
+
...Object.keys(KIT_CATALOGS[locale] ?? {}),
|
|
41
|
+
...Object.keys(capabilityMessages[locale] ?? {}),
|
|
42
|
+
...Object.keys(config.messages[locale] ?? {}),
|
|
43
|
+
]);
|
|
44
|
+
const missing = [...baseline].filter((key) => !covered.has(key)).sort();
|
|
45
|
+
if (missing.length === 0) continue;
|
|
46
|
+
const named = missing.slice(0, LISTED_KEYS).join(", ");
|
|
47
|
+
const rest = missing.length > LISTED_KEYS ? `, and ${missing.length - LISTED_KEYS} more` : "";
|
|
48
|
+
findings.push({
|
|
49
|
+
setting: `i18n.supportedLocales.${locale}`,
|
|
50
|
+
environment: null,
|
|
51
|
+
problem: `${missing.length} ${missing.length === 1 ? "message has" : "messages have"} no \`${locale}\` translation, so a reader in that language meets ${config.defaultLocale} instead.`,
|
|
52
|
+
action: `Add them to \`i18n({ messages: { ${locale}: … } })\` in your \`pithy.config.ts\`: ${named}${rest}.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/i18n knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/i18n";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|