@pithy-sh/core 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/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The longest language tag this kit accepts. RFC 5646's own grammar allows far more, but a tag that
|
|
8
|
+
* long is not a locale anyone serves — it is a header a caller filled with something else. The bound
|
|
9
|
+
* exists so a hostile `Accept-Language` cannot make the matcher walk a megabyte per request.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_LOCALE_TAG_LENGTH = 64;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The shape of a well-formed language tag, checked before `Intl.Locale` ever sees it.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately looser than RFC 5646 and deliberately stricter than "any string". Looser, because the
|
|
17
|
+
* authority on whether `es-419` or `zh-Hans-CN` is meaningful is ICU, not a regular expression we would
|
|
18
|
+
* have to keep in step with it. Stricter, because {@link https://tc39.es/ecma402 `Intl.Locale`} throws a
|
|
19
|
+
* `RangeError` rather than returning a signal, and the four things it throws on — `*`, `en_US`, the empty
|
|
20
|
+
* string, and a token still carrying `;q=0.9` — all appear in real `Accept-Language` headers.
|
|
21
|
+
*/
|
|
22
|
+
const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A BCP-47 language tag — the identity of a locale everywhere in the kit.
|
|
26
|
+
*
|
|
27
|
+
* One string, two jobs, and only one of them falls back: the tag that picks a **catalog** is the words
|
|
28
|
+
* we have written, and the tag that drives **formatting** is whatever the reader asked for. See
|
|
29
|
+
* {@link LocaleContext} for why collapsing them is the bug where an Argentine reads Spanish and sees
|
|
30
|
+
* `1,234.56`.
|
|
31
|
+
*/
|
|
32
|
+
export const Locale = z
|
|
33
|
+
.string()
|
|
34
|
+
.min(2)
|
|
35
|
+
.max(MAX_LOCALE_TAG_LENGTH)
|
|
36
|
+
.regex(LANGUAGE_TAG, { message: "A locale is a BCP-47 tag like `en`, `es`, or `es-AR`." })
|
|
37
|
+
// **And then ICU has to accept it**, which is a strictly narrower thing than matching the shape.
|
|
38
|
+
// `en-x`, `en-t`, `en-u`, `en-1` and `en-a-bbb-a-ccc` are all well-formed by the grammar above and
|
|
39
|
+
// all raise `RangeError` in `new Intl.Locale()`: a singleton subtag with nothing after it, and a
|
|
40
|
+
// repeated extension, are shapes the pattern cannot see.
|
|
41
|
+
//
|
|
42
|
+
// The check is here rather than at each render site because of where the values end up. This schema
|
|
43
|
+
// guards `pithy_auth_users.locale` and `pithy_email_jobs.locale`, so a tag it admits is a tag stored
|
|
44
|
+
// in D1 — and the place it is read back is `renderEmail`, inside the send Workflow, with no request
|
|
45
|
+
// on it. An `Intl` construction that throws there is a raw `RangeError` rather than a `PithyError`,
|
|
46
|
+
// `classifySendError` sees no code it knows, and the job burns its retries and wedges. Refusing the
|
|
47
|
+
// value on the way in costs one `try` at a boundary that was already validating; catching it on the
|
|
48
|
+
// way out costs a guard at every site that ever formats, forever, and one of them will be forgotten.
|
|
49
|
+
.refine((tag) => parseLocale(tag) !== null, {
|
|
50
|
+
message: "That is not a language tag `Intl` accepts. `en-x` and `en-a-bbb-a-ccc` are the usual shapes that miss.",
|
|
51
|
+
})
|
|
52
|
+
.describe("A BCP-47 language tag (`en`, `es`, `es-AR`) — the identity of a locale across the kit.");
|
|
53
|
+
export type Locale = z.infer<typeof Locale>;
|
|
54
|
+
|
|
55
|
+
/** Which way a locale's script runs. Drives the `dir` attribute on the document and the email shell. */
|
|
56
|
+
export const TextDirection = z
|
|
57
|
+
.enum(["ltr", "rtl"])
|
|
58
|
+
.describe("Which way a locale's script runs — the value of the `dir` attribute.");
|
|
59
|
+
export type TextDirection = z.infer<typeof TextDirection>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The per-request locale seam, published on `c.var.locale` by `@pithy-sh/i18n` and `null` in a Worker
|
|
63
|
+
* that does not compose it. Core defines the shape; one capability populates it — the same split
|
|
64
|
+
* `AuthContext` uses, and for the same reason: `@pithy-sh/email` and the error module both need a
|
|
65
|
+
* locale and neither may import an i18n capability.
|
|
66
|
+
*/
|
|
67
|
+
export const LocaleContext = z
|
|
68
|
+
.object({
|
|
69
|
+
catalogLocale: Locale.describe("The locale whose catalog answers `t()` — the words the kit actually has."),
|
|
70
|
+
formattingLocale: Locale.describe("The locale handed to `Intl` — what the reader asked for, region and all."),
|
|
71
|
+
direction: TextDirection.describe("The catalog locale's text direction, for `lang`/`dir` on the document."),
|
|
72
|
+
})
|
|
73
|
+
.describe("Per-request resolved locale; the seam capabilities read instead of negotiating their own.");
|
|
74
|
+
export type LocaleContext = z.infer<typeof LocaleContext>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `tag` as an `Intl.Locale`, or `null` when it is not one.
|
|
78
|
+
*
|
|
79
|
+
* **Every construction on header- or adopter-supplied input goes through this.** `new Intl.Locale()`
|
|
80
|
+
* throws `RangeError` on `*`, on `en_US`, on the empty string, and on any token still carrying
|
|
81
|
+
* `;q=0.9` — so a malformed `Accept-Language` is an uncaught 500 rather than a fallback, unless the
|
|
82
|
+
* throw is caught somewhere. It is caught here, once, and nowhere else.
|
|
83
|
+
*/
|
|
84
|
+
export function parseLocale(tag: string): Intl.Locale | null {
|
|
85
|
+
if (!LANGUAGE_TAG.test(tag) || tag.length > MAX_LOCALE_TAG_LENGTH) return null;
|
|
86
|
+
try {
|
|
87
|
+
return new Intl.Locale(tag);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The part of `tag` that may be handed to `Intl` as a **formatting** locale, or `null` when it is not a
|
|
95
|
+
* tag at all: language, script, region and variants, with every extension subtag stripped.
|
|
96
|
+
*
|
|
97
|
+
* **A `-u-` extension is not a locale, it is an instruction**, and the range a formatting locale is
|
|
98
|
+
* built from is caller-supplied. `?lang=en-u-nu-hanidec` truncates to `en`, matches a project that
|
|
99
|
+
* ships English, and — kept whole — reaches `Intl.NumberFormat` verbatim, where it renders `1,234` as
|
|
100
|
+
* `一,二三四`. `-u-ca-islamic` does the same to every date on the page. Nothing is disclosed and nothing
|
|
101
|
+
* persists, so it is spoofing rather than a breach; it is also a link somebody can send, and the whole
|
|
102
|
+
* point of resolving against `supportedLocales` is that a reader cannot pick a rendering the project
|
|
103
|
+
* did not offer.
|
|
104
|
+
*
|
|
105
|
+
* `baseName` is exactly the right cut, and is why this is a helper rather than a regex: `es-AR`,
|
|
106
|
+
* `zh-Hant-TW`, `es-419` and `de-DE-1996` all survive whole, because none of them is an extension.
|
|
107
|
+
*/
|
|
108
|
+
export function formattingLocaleOf(tag: string): string | null {
|
|
109
|
+
return parseLocale(tag)?.baseName ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether `tag` is a language tag `Intl` will accept — the predicate half of {@link parseLocale}. */
|
|
113
|
+
export function isLocale(tag: string): tag is Locale {
|
|
114
|
+
return parseLocale(tag) !== null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The languages written right-to-left, for the runtimes that expose neither text-info accessor.
|
|
119
|
+
*
|
|
120
|
+
* A last resort, not the answer: `getTextInfo()` knows about scripts this list cannot name, and it is
|
|
121
|
+
* asked first. The five here are the ones a fallback has to get right.
|
|
122
|
+
*/
|
|
123
|
+
const RTL_LANGUAGES: ReadonlySet<string> = new Set(["ar", "he", "fa", "ur", "ps"]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Text info as either of the two names the runtimes Pithy targets expose it under.
|
|
127
|
+
*
|
|
128
|
+
* Neither is on the standard's own `Intl.Locale` type in every TypeScript lib, and the split is real:
|
|
129
|
+
* **workerd and Bun expose `getTextInfo()` only; Node 22 — the declared floor, so the CLI — exposes
|
|
130
|
+
* `textInfo` only; Node 24 has both.** Firefox has the method from 153 and Safari the accessor from
|
|
131
|
+
* 15.4. A helper written against either name alone is wrong on roughly half the matrix.
|
|
132
|
+
*/
|
|
133
|
+
type LocaleWithTextInfo = Intl.Locale & {
|
|
134
|
+
getTextInfo?: () => { direction?: string };
|
|
135
|
+
textInfo?: { direction?: string };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Which way `tag`'s script runs, feature-detected across both spellings and falling back to a small
|
|
140
|
+
* right-to-left language set when a runtime exposes neither.
|
|
141
|
+
*
|
|
142
|
+
* Total: an unparseable tag is `ltr`, because a document still needs a `dir` and guessing the other way
|
|
143
|
+
* mirrors a page nobody asked to mirror.
|
|
144
|
+
*/
|
|
145
|
+
export function localeDirection(tag: string): TextDirection {
|
|
146
|
+
const locale = parseLocale(tag);
|
|
147
|
+
if (!locale) return "ltr";
|
|
148
|
+
const withTextInfo = locale as LocaleWithTextInfo;
|
|
149
|
+
const info = typeof withTextInfo.getTextInfo === "function" ? withTextInfo.getTextInfo() : withTextInfo.textInfo;
|
|
150
|
+
if (info?.direction === "rtl") return "rtl";
|
|
151
|
+
if (info?.direction === "ltr") return "ltr";
|
|
152
|
+
return RTL_LANGUAGES.has(locale.language) ? "rtl" : "ltr";
|
|
153
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reading the locale a source file declares in its head — the reader for the marker `docs/I18N.md` publishes.
|
|
6
|
+
*
|
|
7
|
+
* A prose census reads every committed file as text and refuses the spellings a project does not write. This
|
|
8
|
+
* repository runs one (`packages/cli/src/ci/americanEnglish.test.ts`: the words this project writes are
|
|
9
|
+
* American), and any project that ships translated copy eventually needs the same thing, because a catalog
|
|
10
|
+
* written in Spanish is prose that is *supposed* not to be English. A path list would answer that and is
|
|
11
|
+
* exactly what a census must not grow — a list is right on the day it is written, wrong afterwards, and the
|
|
12
|
+
* way it goes wrong is that the gate keeps passing. So the file says what it is instead, in one marker, in
|
|
13
|
+
* its head, where a reader meets it: the **locale** it is written in, and that the copy is an **unreviewed
|
|
14
|
+
* first pass**. Both facts are load-bearing and neither is checkable from the outside.
|
|
15
|
+
*
|
|
16
|
+
* ## The half that is easy to get backwards
|
|
17
|
+
*
|
|
18
|
+
* **The marker exempts the file's quoted values, not the file.** A catalog is not written in Spanish — its
|
|
19
|
+
* *values* are. Everything around them is a docblock, an import and an identifier: the project's own English
|
|
20
|
+
* prose, and exactly as subject to the rule as any other file's. So a declaring file widens what counts as a
|
|
21
|
+
* value — every quoted run, whole sentences included, rather than only the whitespace-free tokens exempt
|
|
22
|
+
* everywhere else — and every character outside those quotes is censused as it would be anywhere.
|
|
23
|
+
*
|
|
24
|
+
* Reading the marker as *skip this file* is the port that goes wrong, and it goes wrong quietly. It made this
|
|
25
|
+
* repository's own `es/errors.ts` a place where a twenty-seven-line English argument went unread, which is a
|
|
26
|
+
* gate that cannot fail on most of what it is looking at, and it costs an adopter the docblocks of every
|
|
27
|
+
* translated catalog they ever add.
|
|
28
|
+
*
|
|
29
|
+
* **`en` and `en-*` are a declaration and never an exemption**, which is why {@link localeDeclared} hands back
|
|
30
|
+
* the tag rather than a yes or a no. The words a project writes *in English* are the ones a census is about,
|
|
31
|
+
* and a marker cannot become a way to opt English prose out of it. The caller decides what a tag means;
|
|
32
|
+
* treating `en` as translated is the same mistake as treating `es` as skippable.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it lives in `core/src/i18n`
|
|
35
|
+
*
|
|
36
|
+
* The folder is the import path, so it is named for the caller. The marker is a locale declaration — the same
|
|
37
|
+
* thing `./locale` and `./catalog` are about, one artifact earlier — and an adopter reaching for it is asking
|
|
38
|
+
* a question about translated copy, not about linting. It is deliberately not in the CLI: the CLI is a
|
|
39
|
+
* development tool an adopter's own test suite has no reason to depend on, and this is the kit's precedent for
|
|
40
|
+
* a pure textual scanner that a repository's tests want back (`../entitlement/gateScan`, which exports
|
|
41
|
+
* `withoutComments` for the same reason). No `node:` import, no filesystem: the caller brings the text,
|
|
42
|
+
* whether from a walked directory or from `import.meta.glob(…, "?raw")` inside a Workers-typed program.
|
|
43
|
+
*
|
|
44
|
+
* The census that owns the rest of the rule stays where it is. What ships here is the pair a port gets wrong.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a file written in a language other than English declares in its head.
|
|
49
|
+
*
|
|
50
|
+
* The exact spelling is published in `docs/I18N.md` — `LOCALE <tag>`, an em dash, and the unreviewed-first-pass
|
|
51
|
+
* sentence — and `packages/cli/src/ci/americanEnglish.test.ts` reads the line out of that document and holds it
|
|
52
|
+
* to this pattern, so an adopter copying the published marker cannot copy one that matches nothing. The tag is
|
|
53
|
+
* captured, never merely detected, because the caller's decision turns on which language it names.
|
|
54
|
+
*/
|
|
55
|
+
const LOCALE_DECLARED = /\bLOCALE ([a-z]{2,8}(?:-[A-Za-z0-9]{1,8})*) — an unreviewed first pass\./;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How far into a file the marker has to be, so a file mentioning it in prose is still read.
|
|
59
|
+
*
|
|
60
|
+
* A documented number rather than a tuning knob: `docs/I18N.md` publishes this window to adopters, and
|
|
61
|
+
* `packages/cli/src/ci/americanEnglish.test.ts` holds the count that document states to where this reader
|
|
62
|
+
* actually stops — so moving the number here fails the build until the document moves with it. This module's
|
|
63
|
+
* own test pins both sides of the boundary. It is an implementation detail of {@link localeDeclared} and is
|
|
64
|
+
* not exported — a caller hands over a whole source and gets an answer.
|
|
65
|
+
*/
|
|
66
|
+
const HEAD_LINES = 25;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The locale tag this source declares in its head, or `null` if it declares none.
|
|
70
|
+
*
|
|
71
|
+
* `null` is the ordinary answer: almost every file in a tree declares nothing, and a file that does is saying
|
|
72
|
+
* something about its own values, not asking to be skipped. See this module's docblock for what a caller owes
|
|
73
|
+
* the answer — in particular that `en` comes back like any other tag and exempts nothing.
|
|
74
|
+
*/
|
|
75
|
+
export function localeDeclared(source: string): string | null {
|
|
76
|
+
for (const line of source.split("\n").slice(0, HEAD_LINES)) {
|
|
77
|
+
const found = LOCALE_DECLARED.exec(line);
|
|
78
|
+
if (found !== null) return found[1] as string;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every span on one line whose quoted content is a value rather than prose — as `[open, close)` offsets.
|
|
85
|
+
*
|
|
86
|
+
* `open` is the first character inside the quote and `close` is the closing quote, so a match at or after
|
|
87
|
+
* `open` and ending at or before `close` sits inside a value. Left to right, in whichever of the three quote
|
|
88
|
+
* characters opened it.
|
|
89
|
+
*
|
|
90
|
+
* By default a run holding whitespace is a sentence, not a value, and **a span that turns out to hold one is
|
|
91
|
+
* passed over rather than skipped**: the scan keeps reading inside it, which is how a backticked token inside
|
|
92
|
+
* a prose string literal still counts — this repository names wire values that way in a `.describe()`.
|
|
93
|
+
*
|
|
94
|
+
* `everyQuoted` widens that to include the sentences, for one case and one only: a line in a file that has
|
|
95
|
+
* declared itself written in another language, where the quoted runs are the translation. It is the mechanism
|
|
96
|
+
* the marker drives, and it is why the marker exempts a file's values without exempting the file.
|
|
97
|
+
*
|
|
98
|
+
* A run is one line's worth. A quote that never closes on its line opens no value, so the inner lines of a
|
|
99
|
+
* multi-line template literal are read as prose — deliberately, since that is where a long English argument
|
|
100
|
+
* would otherwise hide.
|
|
101
|
+
*/
|
|
102
|
+
export function valueSpans(line: string, everyQuoted = false): [number, number][] {
|
|
103
|
+
const spans: [number, number][] = [];
|
|
104
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
105
|
+
const quote = line[index];
|
|
106
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") continue;
|
|
107
|
+
const close = line.indexOf(quote, index + 1);
|
|
108
|
+
if (close === -1) continue;
|
|
109
|
+
const inner = line.slice(index + 1, close);
|
|
110
|
+
if (inner.length === 0) continue;
|
|
111
|
+
if (!everyQuoted && /\s/.test(inner)) continue;
|
|
112
|
+
spans.push([index + 1, close]);
|
|
113
|
+
index = close;
|
|
114
|
+
}
|
|
115
|
+
return spans;
|
|
116
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { parseLocale } from "./locale";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A declared exception map: a language range the truncation walk would miss, and the supported locale
|
|
8
|
+
* it means. `{ nb: "no", tl: "fil" }` — the pairs no algorithm derives, because they are historical
|
|
9
|
+
* rather than structural.
|
|
10
|
+
*/
|
|
11
|
+
export type LocaleExceptions = Readonly<Record<string, string>>;
|
|
12
|
+
|
|
13
|
+
/** How a match was reached, so a caller can tell an answer from a default. */
|
|
14
|
+
export interface LocaleMatch {
|
|
15
|
+
/** The supported locale that answered, exactly as the caller spelled it in `supported`. */
|
|
16
|
+
readonly locale: string;
|
|
17
|
+
/** The range that matched it, lower-cased — `*` when a wildcard took the first supported locale. */
|
|
18
|
+
readonly range: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Lower-cased index of the supported list, so matching is case-insensitive without lower-casing answers. */
|
|
22
|
+
function index(supported: readonly string[]): Map<string, string> {
|
|
23
|
+
const map = new Map<string, string>();
|
|
24
|
+
for (const locale of supported) {
|
|
25
|
+
const key = locale.toLowerCase();
|
|
26
|
+
if (!map.has(key)) map.set(key, locale);
|
|
27
|
+
}
|
|
28
|
+
return map;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* RFC 4647 §3.4 lookup: `tag`, then `tag` with its right-most subtag removed, and so on.
|
|
33
|
+
*
|
|
34
|
+
* A trailing single-character subtag is removed with the one before it, as the RFC requires —
|
|
35
|
+
* `en-a-bbb` truncates to `en`, never to the meaningless `en-a`.
|
|
36
|
+
*/
|
|
37
|
+
function truncationWalk(tag: string, supported: Map<string, string>): string | null {
|
|
38
|
+
let candidate = tag.toLowerCase();
|
|
39
|
+
while (candidate.length > 0) {
|
|
40
|
+
const hit = supported.get(candidate);
|
|
41
|
+
if (hit) return hit;
|
|
42
|
+
const cut = candidate.lastIndexOf("-");
|
|
43
|
+
if (cut < 0) return null;
|
|
44
|
+
candidate = candidate.slice(0, cut);
|
|
45
|
+
const tail = candidate.lastIndexOf("-");
|
|
46
|
+
if (tail >= 0 && candidate.length - tail === 2) candidate = candidate.slice(0, tail);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The supported locale that best answers one range, or `null`.
|
|
53
|
+
*
|
|
54
|
+
* Three passes, in the order that makes each one earn its place: the declared exception map first,
|
|
55
|
+
* because it exists to override what follows; then the truncation walk on the range as written, which
|
|
56
|
+
* answers `es-AR` with `es`; then the walk again over the **maximized** form, which answers `zh-TW`
|
|
57
|
+
* with `zh-Hant` by way of `zh-Hant-TW`.
|
|
58
|
+
*
|
|
59
|
+
* **Maximizing is the third step, not the first.** `Intl.Locale` throws on input the first step handles
|
|
60
|
+
* fine, and maximizing a range before trying it as written turns `es` into `es-Latn-ES` and then walks
|
|
61
|
+
* back down to `es` — the same answer, three constructions later, and a `RangeError` away from a 500.
|
|
62
|
+
*/
|
|
63
|
+
function lookupRange(range: string, supported: Map<string, string>, exceptions: LocaleExceptions): string | null {
|
|
64
|
+
// `Object.hasOwn`, never a bare index — `exceptions` is a plain object built by Zod, so
|
|
65
|
+
// `exceptions["constructor"]` answers `Object` and `exceptions["__proto__"]` answers the prototype.
|
|
66
|
+
// Read bare, both are truthy and neither is a string, so `declared.toLowerCase()` threw a
|
|
67
|
+
// `TypeError` — inside a global middleware, which makes `?lang=constructor` a 500 on every request,
|
|
68
|
+
// and a stale `pithy_locale=__proto__` cookie a 500 on every request that client makes until it is
|
|
69
|
+
// cleared. `catalogs/browser.ts` already guards its own thunk map for exactly this reason.
|
|
70
|
+
const key = range.toLowerCase();
|
|
71
|
+
const declared = Object.hasOwn(exceptions, key) ? exceptions[key] : undefined;
|
|
72
|
+
if (declared) {
|
|
73
|
+
const hit = supported.get(declared.toLowerCase());
|
|
74
|
+
if (hit) return hit;
|
|
75
|
+
}
|
|
76
|
+
const direct = truncationWalk(range, supported);
|
|
77
|
+
if (direct) return direct;
|
|
78
|
+
const maximized = parseLocale(range)?.maximize().toString();
|
|
79
|
+
return maximized ? truncationWalk(maximized, supported) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The first supported locale any of `desired` asks for, most-wanted first, or `null` when none does.
|
|
84
|
+
*
|
|
85
|
+
* `desired` is already in preference order — {@link parseAcceptLanguage} sorts by q-weight, and a
|
|
86
|
+
* browser resolver chain builds it from the reader's explicit choice downward. A `*` range takes the
|
|
87
|
+
* first supported locale, which is what "anything you have" means; it is checked in place rather than
|
|
88
|
+
* up front, so a reader who wrote `de, *` is answered in German when German ships.
|
|
89
|
+
*
|
|
90
|
+
* Total, and never throws: every `Intl` construction on the way through is guarded, so a header full
|
|
91
|
+
* of `en_US` and `;q=0.9` fragments falls through to `null` and the caller's default.
|
|
92
|
+
*/
|
|
93
|
+
export function matchLocale(
|
|
94
|
+
desired: readonly string[],
|
|
95
|
+
supported: readonly string[],
|
|
96
|
+
exceptions: LocaleExceptions = {},
|
|
97
|
+
): LocaleMatch | null {
|
|
98
|
+
if (supported.length === 0) return null;
|
|
99
|
+
const lookup = index(supported);
|
|
100
|
+
for (const range of desired) {
|
|
101
|
+
const normalized = range.trim().toLowerCase();
|
|
102
|
+
if (normalized.length === 0) continue;
|
|
103
|
+
if (normalized === "*") {
|
|
104
|
+
const first = supported[0];
|
|
105
|
+
return first ? { locale: first, range: normalized } : null;
|
|
106
|
+
}
|
|
107
|
+
const hit = lookupRange(normalized, lookup, exceptions);
|
|
108
|
+
if (hit) return { locale: hit, range: normalized };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import { ValidationError } from "../error/pithyError";
|
|
6
|
+
import { type LocaleCatalogs, type MessageCatalog, messageDomain } from "./catalog";
|
|
7
|
+
import { DEFAULT_LOCALE } from "./translator";
|
|
8
|
+
|
|
9
|
+
/** One capability's contribution, kept beside the capability that made it so a refusal can name it. */
|
|
10
|
+
interface Contribution {
|
|
11
|
+
/** The contributing capability's `name` — the only domain it may write keys under. */
|
|
12
|
+
readonly capability: string;
|
|
13
|
+
/** That capability's catalogs, keyed by locale. */
|
|
14
|
+
readonly catalogs: LocaleCatalogs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every composed capability's `messages`, merged into one set of catalogs keyed by locale.
|
|
19
|
+
*
|
|
20
|
+
* **A capability may only declare keys under its own domain**, and the merge refuses anything else.
|
|
21
|
+
* That is the same rule as `pithy_<capability>_<table>` and `auth/invalid_token`, enforced for the same
|
|
22
|
+
* reason: the domain segment is what makes two capabilities' contributions incapable of colliding, so
|
|
23
|
+
* merge order stops being a thing anyone has to reason about. It applies to the adopter's own `app`
|
|
24
|
+
* capability exactly as it applies to a kit package — `board/nav.settings` is theirs to declare, and
|
|
25
|
+
* `auth/sign_in.title` is not.
|
|
26
|
+
*
|
|
27
|
+
* Library-before-app, like the migration registry: capabilities are merged in composition order, so a
|
|
28
|
+
* later contribution wins a key an earlier one wrote. With the domain rule in force that can only
|
|
29
|
+
* happen between two capabilities sharing a name, which `createBackend` already refuses.
|
|
30
|
+
*/
|
|
31
|
+
export function composeMessages(capabilities: readonly Capability[]): LocaleCatalogs {
|
|
32
|
+
const contributions: Contribution[] = [];
|
|
33
|
+
for (const capability of capabilities) {
|
|
34
|
+
if (capability.messages) contributions.push({ capability: capability.name, catalogs: capability.messages });
|
|
35
|
+
}
|
|
36
|
+
// **Accumulated in a `Map`, not in an object literal, and that is not a style preference.**
|
|
37
|
+
//
|
|
38
|
+
// `Object.entries` really does hand back an own `__proto__` key — `JSON.parse('{"__proto__":{}}')`
|
|
39
|
+
// creates one, and a capability's `messages` can arrive from parsed JSON. Assigning `merged[locale]`
|
|
40
|
+
// for that key goes *through* the inherited setter rather than into the map: the locale is silently
|
|
41
|
+
// lost, and the object's prototype is replaced. Guarding the read with `Object.hasOwn` does not help,
|
|
42
|
+
// because the hazard is the write. A `Map` has no prototype chain to walk and no setter to trip, and
|
|
43
|
+
// `Object.fromEntries` defines rather than assigns, so the object handed back is an ordinary one
|
|
44
|
+
// with an ordinary prototype.
|
|
45
|
+
//
|
|
46
|
+
// Only an adopter's own capability could reach this, in their own runtime, so it is a consistency
|
|
47
|
+
// fix rather than a vulnerability — but the rest of this merge path is hardened and a lone exception
|
|
48
|
+
// is the one somebody copies.
|
|
49
|
+
const merged = new Map<string, Map<string, string>>();
|
|
50
|
+
for (const { capability, catalogs } of contributions) {
|
|
51
|
+
for (const [locale, catalog] of Object.entries(catalogs)) {
|
|
52
|
+
const target = merged.get(locale) ?? new Map<string, string>();
|
|
53
|
+
for (const [key, message] of Object.entries(catalog)) {
|
|
54
|
+
const domain = messageDomain(key);
|
|
55
|
+
if (domain !== capability) {
|
|
56
|
+
throw new ValidationError({
|
|
57
|
+
message: "A capability may only contribute messages under its own domain.",
|
|
58
|
+
action: `Rename \`${key}\` to start with \`${capability}/\`, or move it to the capability that owns that domain.`,
|
|
59
|
+
detail: `\`${capability}\` contributed \`${key}\` in locale \`${locale}\`, whose domain is \`${domain}\`.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
target.set(key, message);
|
|
63
|
+
}
|
|
64
|
+
merged.set(locale, target);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return Object.fromEntries([...merged].map(([locale, catalog]) => [locale, Object.fromEntries(catalog)]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The catalog for `locale`, or the default locale's, or an empty one.
|
|
72
|
+
*
|
|
73
|
+
* The fallback that makes the seam optional: a Worker composing no i18n capability reads the baked
|
|
74
|
+
* English and behaves byte for byte as it did before any of this landed.
|
|
75
|
+
*/
|
|
76
|
+
export function catalogFor(catalogs: LocaleCatalogs, locale: string): MessageCatalog {
|
|
77
|
+
return catalogs[locale] ?? catalogs[DEFAULT_LOCALE] ?? {};
|
|
78
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { interpolate, lookupMessage, type MessageCatalog, type MessageParams } from "./catalog";
|
|
5
|
+
import { localeDirection, type TextDirection } from "./locale";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The i18n seam. `@pithy-sh/core` defines this shape; `@pithy-sh/i18n` populates it from the resolved
|
|
9
|
+
* request locale. Every other capability, and every screen, depends only on this object — the same
|
|
10
|
+
* split `AuthContext` uses, and for the same reason: `@pithy-sh/email` and the error module both need
|
|
11
|
+
* a locale and neither may import an i18n capability.
|
|
12
|
+
*
|
|
13
|
+
* **The two locales are both here, as plain strings.** An adopter who already owns their date and
|
|
14
|
+
* number rendering needs the locale as a *value* to hand to `Intl`; forcing it through
|
|
15
|
+
* {@link Translator.formatDate} turns a one-line repair into an every-call-site one, against gates that
|
|
16
|
+
* exist to keep those calls in one place.
|
|
17
|
+
*/
|
|
18
|
+
export interface Translator {
|
|
19
|
+
/**
|
|
20
|
+
* The locale whose catalog answers {@link Translator.t} — the words the kit actually has.
|
|
21
|
+
*
|
|
22
|
+
* An `es-AR` reader gets `es` here, because `es` is what is written. See
|
|
23
|
+
* {@link Translator.formattingLocale} for the half that does not fall back.
|
|
24
|
+
*/
|
|
25
|
+
readonly catalogLocale: string;
|
|
26
|
+
/**
|
|
27
|
+
* The locale handed to `Intl` — what the reader asked for, region and all.
|
|
28
|
+
*
|
|
29
|
+
* An `es-AR` reader gets `es-AR` here, which `Intl` supports natively whether or not anyone wrote a
|
|
30
|
+
* string for it. Collapsing this into {@link Translator.catalogLocale} is the bug where an Argentine
|
|
31
|
+
* reads Spanish and sees `1,234.56`.
|
|
32
|
+
*/
|
|
33
|
+
readonly formattingLocale: string;
|
|
34
|
+
/** The catalog locale's text direction — what `lang`/`dir` on the document and the email shell get. */
|
|
35
|
+
readonly direction: TextDirection;
|
|
36
|
+
/**
|
|
37
|
+
* The message `key` names, with `{placeholder}` substitution.
|
|
38
|
+
*
|
|
39
|
+
* Returns the key itself when nothing has it. That is the honest answer — a blank reads like finished
|
|
40
|
+
* copy — and it is unreachable for a kit key, because catalog coverage is a CI gate.
|
|
41
|
+
*/
|
|
42
|
+
t(key: string, params?: MessageParams): string;
|
|
43
|
+
/**
|
|
44
|
+
* The message `key` names, or `null` when no layer has it.
|
|
45
|
+
*
|
|
46
|
+
* **The half {@link Translator.t} cannot express, and the error path needs.** `t` is total because a
|
|
47
|
+
* screen always has to render something, so a miss comes back as the key — which is the honest thing
|
|
48
|
+
* to show and is unusable as a signal. An error is the other case: the payload already carries an
|
|
49
|
+
* English `message`, and a client that could not translate the code must render *that*, not the code.
|
|
50
|
+
*
|
|
51
|
+
* So the documented client contract is `t.maybe(payload.code, payload.params) ?? payload.message`.
|
|
52
|
+
* Written against `t` it silently never falls back — `t` returns the key, `??` sees a string, and a
|
|
53
|
+
* caller in an uncovered locale reads `auth/invalid_token` on their screen instead of the sentence
|
|
54
|
+
* the server took care to send them.
|
|
55
|
+
*/
|
|
56
|
+
maybe(key: string, params?: MessageParams): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* The message `key` names, in the plural form `count` calls for.
|
|
59
|
+
*
|
|
60
|
+
* Looks up `<key>.<category>` for the `Intl.PluralRules` category of `count` in the catalog locale,
|
|
61
|
+
* falling back to `<key>.other`. `count` is available to the message as `{count}` without being
|
|
62
|
+
* passed twice.
|
|
63
|
+
*
|
|
64
|
+
* Explicit rather than folded into {@link Translator.t}, because plural selection is the thing a
|
|
65
|
+
* second locale exposes: English has two forms, Spanish has two, Russian has three, and a call site
|
|
66
|
+
* that concatenated a number onto a noun has no form at all.
|
|
67
|
+
*/
|
|
68
|
+
plural(key: string, count: number, params?: MessageParams): string;
|
|
69
|
+
/** `value` as a number in the formatting locale. */
|
|
70
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
71
|
+
/** `value` as an amount of `currency` in the formatting locale. */
|
|
72
|
+
formatCurrency(value: number, currency: string, options?: Intl.NumberFormatOptions): string;
|
|
73
|
+
/** `value` as a date in the formatting locale. Accepts a `Date` or an epoch-milliseconds number. */
|
|
74
|
+
formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
|
|
75
|
+
/** `values` as a list in the formatting locale — `a, b y c` in Spanish, `a, b, and c` in English. */
|
|
76
|
+
formatList(values: readonly string[], options?: Intl.ListFormatOptions): string;
|
|
77
|
+
/** `value` `unit`s from now, in the formatting locale — `ayer`, `in 3 days`. */
|
|
78
|
+
formatRelativeTime(
|
|
79
|
+
value: number,
|
|
80
|
+
unit: Intl.RelativeTimeFormatUnit,
|
|
81
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
82
|
+
): string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a translator is built from: the locales it answers in, and the layers it looks messages up through. */
|
|
86
|
+
export interface TranslatorInput {
|
|
87
|
+
/** The locale whose catalog answers `t()`. */
|
|
88
|
+
readonly catalogLocale: string;
|
|
89
|
+
/** The locale handed to `Intl`; defaults to {@link TranslatorInput.catalogLocale}. */
|
|
90
|
+
readonly formattingLocale?: string;
|
|
91
|
+
/**
|
|
92
|
+
* The catalogs to walk, most-specific first. `@pithy-sh/i18n` passes adopter-locale, adopter-default,
|
|
93
|
+
* kit-locale, kit-default; a copied screen passes its own baked English and nothing else.
|
|
94
|
+
*/
|
|
95
|
+
readonly layers: readonly (MessageCatalog | undefined)[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A translator over `layers`.
|
|
100
|
+
*
|
|
101
|
+
* The `Intl` formatters are constructed lazily and held for the life of the translator. A translator is
|
|
102
|
+
* per request (or per mounted screen), so nothing here outlives a locale — which is the whole reason
|
|
103
|
+
* `z.config()` is banned repo-wide and a per-request global is not an option.
|
|
104
|
+
*/
|
|
105
|
+
export function createTranslator(input: TranslatorInput): Translator {
|
|
106
|
+
const catalogLocale = input.catalogLocale;
|
|
107
|
+
const formattingLocale = input.formattingLocale ?? catalogLocale;
|
|
108
|
+
const layers = input.layers;
|
|
109
|
+
|
|
110
|
+
let plurals: Intl.PluralRules | undefined;
|
|
111
|
+
const pluralRules = (): Intl.PluralRules => {
|
|
112
|
+
plurals ??= new Intl.PluralRules(catalogLocale);
|
|
113
|
+
return plurals;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const maybe = (key: string, params?: MessageParams): string | null => {
|
|
117
|
+
const message = lookupMessage(layers, key);
|
|
118
|
+
return message === null ? null : interpolate(message, params);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const translate = (key: string, params?: MessageParams): string => maybe(key, params) ?? key;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
catalogLocale,
|
|
125
|
+
formattingLocale,
|
|
126
|
+
direction: localeDirection(catalogLocale),
|
|
127
|
+
t: translate,
|
|
128
|
+
maybe,
|
|
129
|
+
plural(key, count, params) {
|
|
130
|
+
const category = pluralRules().select(count);
|
|
131
|
+
const withCount: MessageParams = { count, ...params };
|
|
132
|
+
const exact = lookupMessage(layers, `${key}.${category}`);
|
|
133
|
+
if (exact !== null) return interpolate(exact, withCount);
|
|
134
|
+
const other = lookupMessage(layers, `${key}.other`);
|
|
135
|
+
return other === null ? key : interpolate(other, withCount);
|
|
136
|
+
},
|
|
137
|
+
formatNumber(value, options) {
|
|
138
|
+
return new Intl.NumberFormat(formattingLocale, options).format(value);
|
|
139
|
+
},
|
|
140
|
+
formatCurrency(value, currency, options) {
|
|
141
|
+
return new Intl.NumberFormat(formattingLocale, { ...options, style: "currency", currency }).format(value);
|
|
142
|
+
},
|
|
143
|
+
formatDate(value, options) {
|
|
144
|
+
return new Intl.DateTimeFormat(formattingLocale, options).format(value);
|
|
145
|
+
},
|
|
146
|
+
formatList(values, options) {
|
|
147
|
+
return new Intl.ListFormat(formattingLocale, options).format(values);
|
|
148
|
+
},
|
|
149
|
+
formatRelativeTime(value, unit, options) {
|
|
150
|
+
return new Intl.RelativeTimeFormat(formattingLocale, options).format(value, unit);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The locale a project falls back to when nothing else answers, and the locale the kit writes in. */
|
|
156
|
+
export const DEFAULT_LOCALE = "en";
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A translator over one baked catalog — **the seam's behavior when `@pithy-sh/i18n` is not composed.**
|
|
160
|
+
*
|
|
161
|
+
* This is what makes the capability optional. A project that never composes `i18n` renders the baked
|
|
162
|
+
* English fallback, byte for byte as it does today, with no negotiation, no merge and no config. A
|
|
163
|
+
* copied screen builds one from the English it was scaffolded with, which is the only catalog that
|
|
164
|
+
* survives being copied into an adopter's repository.
|
|
165
|
+
*/
|
|
166
|
+
export function bakedTranslator(catalog: MessageCatalog, locale: string = DEFAULT_LOCALE): Translator {
|
|
167
|
+
return createTranslator({ catalogLocale: locale, layers: [catalog] });
|
|
168
|
+
}
|