@shaferllc/keel 0.68.0 → 0.74.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/AGENTS.md +2 -0
- package/README.md +14 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +118 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +193 -13
- package/docs/database.md +101 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +149 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +251 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2370 -248
- package/llms.txt +12 -1
- package/package.json +18 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Type-check harness for docs/telemetry.md. Compile-only — never executed.
|
|
2
|
+
import {
|
|
3
|
+
setTelemetry,
|
|
4
|
+
telemetry,
|
|
5
|
+
Tracer,
|
|
6
|
+
Span,
|
|
7
|
+
otlpExporter,
|
|
8
|
+
consoleExporter,
|
|
9
|
+
MemoryExporter,
|
|
10
|
+
tracing,
|
|
11
|
+
trace,
|
|
12
|
+
currentSpan,
|
|
13
|
+
setAttributes,
|
|
14
|
+
addEvent,
|
|
15
|
+
traceIds,
|
|
16
|
+
flushTelemetry,
|
|
17
|
+
parseTraceparent,
|
|
18
|
+
traceparent,
|
|
19
|
+
injectTraceContext,
|
|
20
|
+
logger,
|
|
21
|
+
onShutdown,
|
|
22
|
+
env,
|
|
23
|
+
HttpKernel,
|
|
24
|
+
type Application,
|
|
25
|
+
type SpanData,
|
|
26
|
+
type SpanContext,
|
|
27
|
+
type SpanExporter,
|
|
28
|
+
type TracerOptions,
|
|
29
|
+
type TracingOptions,
|
|
30
|
+
} from "@shaferllc/keel/core";
|
|
31
|
+
|
|
32
|
+
declare const order: { id: number };
|
|
33
|
+
declare const stripe: { charge(id: number): Promise<{ ok: boolean }> };
|
|
34
|
+
declare const id: number;
|
|
35
|
+
declare const key: string;
|
|
36
|
+
declare const orderId: number;
|
|
37
|
+
declare const url: string;
|
|
38
|
+
|
|
39
|
+
export function setup() {
|
|
40
|
+
const options: TracerOptions = {
|
|
41
|
+
serviceName: "api",
|
|
42
|
+
exporter: otlpExporter({ url: "http://localhost:4318/v1/traces" }),
|
|
43
|
+
sampleRatio: 0.1,
|
|
44
|
+
enabled: true,
|
|
45
|
+
resource: { "deployment.environment": "prod" },
|
|
46
|
+
batchSize: 100,
|
|
47
|
+
};
|
|
48
|
+
return setTelemetry(new Tracer(options));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class Kernel extends HttpKernel {
|
|
52
|
+
constructor(app: Application) {
|
|
53
|
+
super(app);
|
|
54
|
+
this.use(tracing());
|
|
55
|
+
|
|
56
|
+
const options: TracingOptions = {
|
|
57
|
+
ignore: (path) => path.startsWith("/internal"),
|
|
58
|
+
name: (method, path) => `${method} ${path.replace(/\/\d+/, "/:id")}`,
|
|
59
|
+
};
|
|
60
|
+
this.use(tracing(options));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function spans() {
|
|
65
|
+
const receipt = await trace("charge", async (span: Span) => {
|
|
66
|
+
span.setAttributes({ "order.id": order.id, currency: "USD" });
|
|
67
|
+
span.setAttribute("retries", 0);
|
|
68
|
+
span.addEvent("calling stripe");
|
|
69
|
+
return stripe.charge(id);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await trace("checkout", async () => {
|
|
73
|
+
await trace("reserve-stock", async () => {});
|
|
74
|
+
await trace("charge", async () => {});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return receipt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fromInsideASpan() {
|
|
81
|
+
setAttributes({ tenant: "acme" });
|
|
82
|
+
addEvent("cache miss", { key });
|
|
83
|
+
const span: Span | undefined = currentSpan();
|
|
84
|
+
return span;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function logsAndTraces() {
|
|
88
|
+
const log = logger().child(traceIds());
|
|
89
|
+
log.info("charging card", { orderId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function propagation() {
|
|
93
|
+
await fetch(url, { headers: injectTraceContext({ accept: "application/json" }) });
|
|
94
|
+
|
|
95
|
+
const incoming: SpanContext | null = parseTraceparent("00-" + "a".repeat(32) + "-" + "b".repeat(16) + "-01");
|
|
96
|
+
return incoming ? traceparent(incoming) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function exporters(): SpanExporter[] {
|
|
100
|
+
return [
|
|
101
|
+
otlpExporter({
|
|
102
|
+
url: "https://api.honeycomb.io/v1/traces",
|
|
103
|
+
headers: { "x-honeycomb-team": env("HONEYCOMB_KEY", "") },
|
|
104
|
+
resource: { "service.name": "api", "deployment.environment": "prod" },
|
|
105
|
+
}),
|
|
106
|
+
consoleExporter(),
|
|
107
|
+
new MemoryExporter(),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function flushing() {
|
|
112
|
+
onShutdown(() => flushTelemetry());
|
|
113
|
+
return telemetry().flush();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function testing() {
|
|
117
|
+
const exporter = new MemoryExporter();
|
|
118
|
+
setTelemetry(new Tracer({ exporter, batchSize: 1 }));
|
|
119
|
+
|
|
120
|
+
await trace("charge", async (span) => span.setAttributes({ "order.id": 1 }));
|
|
121
|
+
|
|
122
|
+
const span: SpanData | undefined = exporter.named("charge")[0];
|
|
123
|
+
const all: SpanData[] = exporter.trace(span!.traceId);
|
|
124
|
+
exporter.clear();
|
|
125
|
+
|
|
126
|
+
return { span, all };
|
|
127
|
+
}
|
package/docs/examples/testing.ts
CHANGED
|
@@ -64,3 +64,152 @@ export async function withMiddleware() {
|
|
|
64
64
|
kernel.use(requestLogger());
|
|
65
65
|
return testClient(kernel);
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/* --- Auth, forms, assertions, doubles, time, state, db, console --- */
|
|
69
|
+
|
|
70
|
+
import {
|
|
71
|
+
spy,
|
|
72
|
+
spyOn,
|
|
73
|
+
restoreSpies,
|
|
74
|
+
freezeTime,
|
|
75
|
+
timeTravel,
|
|
76
|
+
restoreTime,
|
|
77
|
+
resetState,
|
|
78
|
+
truncate,
|
|
79
|
+
assertDatabaseHas,
|
|
80
|
+
assertDatabaseMissing,
|
|
81
|
+
assertDatabaseCount,
|
|
82
|
+
assertDatabaseEmpty,
|
|
83
|
+
runCommand,
|
|
84
|
+
fakeMail,
|
|
85
|
+
fakeQueue,
|
|
86
|
+
swap,
|
|
87
|
+
jwt,
|
|
88
|
+
Job,
|
|
89
|
+
type Spy,
|
|
90
|
+
type CommandResult,
|
|
91
|
+
} from "@shaferllc/keel/core";
|
|
92
|
+
|
|
93
|
+
declare const bytes: Blob;
|
|
94
|
+
declare function notify(send: (message: string) => void): void;
|
|
95
|
+
declare function registerUser(user: { email: string }): Promise<void>;
|
|
96
|
+
declare const gateway: { charge(amount: number): string };
|
|
97
|
+
declare const receipt: string;
|
|
98
|
+
declare class PaymentGateway {}
|
|
99
|
+
declare class FakeGateway {}
|
|
100
|
+
declare class SendWelcome extends Job {
|
|
101
|
+
handle(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function authenticatedRequests(client: TestClient) {
|
|
105
|
+
const authed = client.withToken("tok_123");
|
|
106
|
+
await authed.get("/me");
|
|
107
|
+
await client.withBasicAuth("ada", "s3cret").get("/me");
|
|
108
|
+
await client.withHeader("x-tenant", "acme").get("/me");
|
|
109
|
+
await client.withHeaders({ "x-a": "1", "x-b": "2" }).get("/me");
|
|
110
|
+
await client.withCookie("session", "abc").get("/me");
|
|
111
|
+
await client.withCookies({ session: "abc" }).get("/me");
|
|
112
|
+
await client.acceptJson().get("/me");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function formsAndUploads(client: TestClient) {
|
|
116
|
+
await client.form("/login", { email: "a@b.com", password: "s3cret" });
|
|
117
|
+
await client.multipart("/avatar", { file: bytes, name: "ada" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function moreAssertions(client: TestClient) {
|
|
121
|
+
const res = await client.get("/users/1");
|
|
122
|
+
|
|
123
|
+
res.assertOk();
|
|
124
|
+
res.assertCreated();
|
|
125
|
+
res.assertNoContent();
|
|
126
|
+
res.assertBadRequest();
|
|
127
|
+
res.assertUnauthorized();
|
|
128
|
+
res.assertForbidden();
|
|
129
|
+
res.assertNotFound();
|
|
130
|
+
res.assertUnprocessable();
|
|
131
|
+
res.assertServerError();
|
|
132
|
+
|
|
133
|
+
res.assertJsonContains({ user: { email: "a@b.com" } });
|
|
134
|
+
res.assertSee("Welcome back");
|
|
135
|
+
res.assertDontSee("Sign up");
|
|
136
|
+
|
|
137
|
+
res.assertHeader("content-type", "application/json");
|
|
138
|
+
res.assertHeaderMissing("x-debug");
|
|
139
|
+
|
|
140
|
+
res.assertCookie("session");
|
|
141
|
+
res.assertCookie("session", "abc123");
|
|
142
|
+
res.assertCookieMissing("admin");
|
|
143
|
+
|
|
144
|
+
res.assertValidationErrors("email", "password");
|
|
145
|
+
res.assertNoValidationError("name");
|
|
146
|
+
|
|
147
|
+
res.dump();
|
|
148
|
+
return res.cookies();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function doubles() {
|
|
152
|
+
const mailer = fakeMail();
|
|
153
|
+
const queue = fakeQueue();
|
|
154
|
+
|
|
155
|
+
await registerUser({ email: "ada@example.com" });
|
|
156
|
+
|
|
157
|
+
mailer.assertQueued((m) => m.subject === "Welcome");
|
|
158
|
+
queue.assertPushed(SendWelcome);
|
|
159
|
+
|
|
160
|
+
swap(PaymentGateway, () => new FakeGateway());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function spies() {
|
|
164
|
+
const send: Spy<[string], void> = spy<[string], void>();
|
|
165
|
+
notify(send);
|
|
166
|
+
void send.callCount;
|
|
167
|
+
void send.calledWith("hello");
|
|
168
|
+
|
|
169
|
+
const charge = spyOn(gateway, "charge");
|
|
170
|
+
charge.returns(receipt);
|
|
171
|
+
restoreSpies();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function controllingTime() {
|
|
175
|
+
freezeTime("2026-07-11T12:00:00Z");
|
|
176
|
+
|
|
177
|
+
const token = await jwt.sign({ sub: "1" }, { expiresIn: "1h" });
|
|
178
|
+
await jwt.verify(token);
|
|
179
|
+
|
|
180
|
+
timeTravel(61 * 60 * 1000);
|
|
181
|
+
await jwt.verify(token);
|
|
182
|
+
|
|
183
|
+
restoreTime();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function stateReset() {
|
|
187
|
+
resetState();
|
|
188
|
+
await truncate("comments", "posts", "users");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function databaseAssertions() {
|
|
192
|
+
await assertDatabaseHas("users", { email: "ada@example.com" });
|
|
193
|
+
await assertDatabaseHas("users", { active: 1 }, 1);
|
|
194
|
+
await assertDatabaseMissing("users", { email: "deleted@example.com" });
|
|
195
|
+
await assertDatabaseCount("users", 1);
|
|
196
|
+
await assertDatabaseEmpty("sessions");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
declare function run(argv: string[]): Promise<void>;
|
|
200
|
+
|
|
201
|
+
export async function consoleTests(): Promise<CommandResult> {
|
|
202
|
+
const result = await runCommand(() => run(["node", "keel", "routes"]));
|
|
203
|
+
|
|
204
|
+
result
|
|
205
|
+
.assertSucceeded()
|
|
206
|
+
.assertOutputContains("GET /users")
|
|
207
|
+
.assertOutputMatches(/POST\s+\/users/);
|
|
208
|
+
|
|
209
|
+
result.assertExitCode(0);
|
|
210
|
+
void result.stdout;
|
|
211
|
+
void result.stderr;
|
|
212
|
+
void result.exitCode;
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
package/docs/health.md
CHANGED
|
@@ -63,10 +63,10 @@ with **200** while `isHealthy` is true and **503** the moment a check fails.
|
|
|
63
63
|
| `RedisCheck` | Reads a key — a failed read means a broken connection |
|
|
64
64
|
| `CacheCheck` | Writes a key, reads it back, deletes it |
|
|
65
65
|
|
|
66
|
-
Notably **absent**:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
Notably **absent**: disk-space, heap, and RSS checks. Those measure a Node
|
|
67
|
+
process, and on Workers there isn't one — a memory threshold you can't observe is
|
|
68
|
+
worse than no check at all. If you're on Node and want them, `check()` below takes
|
|
69
|
+
ten lines.
|
|
70
70
|
|
|
71
71
|
## Your own checks
|
|
72
72
|
|
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Internationalization
|
|
2
|
+
|
|
3
|
+
Translations with ICU message formatting, plus the `Intl` formatters that go with
|
|
4
|
+
them.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { setTranslations, t } from "@shaferllc/keel/core";
|
|
8
|
+
|
|
9
|
+
setTranslations({
|
|
10
|
+
en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
|
|
11
|
+
fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
t("cart.items", { count: 3 }); // "3 items" — in the request's locale
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
There is **no dependency here, and there doesn't need to be.** `Intl` ships with
|
|
18
|
+
every modern runtime — Node and Cloudflare Workers both carry the full ICU data —
|
|
19
|
+
so plurals, currencies, dates, and relative times are the platform's job. What Keel
|
|
20
|
+
adds is the message parser on top, which is the part `Intl` doesn't do.
|
|
21
|
+
|
|
22
|
+
## Setting it up
|
|
23
|
+
|
|
24
|
+
Register translations once (in a service provider), and add the middleware that
|
|
25
|
+
works out each request's locale:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core";
|
|
29
|
+
|
|
30
|
+
export class I18nServiceProvider extends ServiceProvider {
|
|
31
|
+
boot(): void {
|
|
32
|
+
setI18n(new I18nManager({ defaultLocale: "en" }));
|
|
33
|
+
|
|
34
|
+
setTranslations({
|
|
35
|
+
en: await import("../resources/lang/en.json", { with: { type: "json" } }).then((m) => m.default),
|
|
36
|
+
fr: await import("../resources/lang/fr.json", { with: { type: "json" } }).then((m) => m.default),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.app.make(HttpKernel).use(detectLocale());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Now `t()` works anywhere in the request — a controller, a view, a transformer —
|
|
45
|
+
without threading a locale through every call.
|
|
46
|
+
|
|
47
|
+
## Translation files
|
|
48
|
+
|
|
49
|
+
Nested objects and flat dot-keys are the same thing, and you can mix them:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"cart": {
|
|
54
|
+
"items": "{count, plural, one {# item} other {# items}}",
|
|
55
|
+
"empty": "Your cart is empty"
|
|
56
|
+
},
|
|
57
|
+
"checkout.title": "Checkout"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both `t("cart.items")` and `t("checkout.title")` resolve.
|
|
62
|
+
|
|
63
|
+
## The message format
|
|
64
|
+
|
|
65
|
+
The supported ICU subset is the part people actually use.
|
|
66
|
+
|
|
67
|
+
### Interpolation
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Hello {name}!
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Plurals
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
{count, plural, =0 {Your cart is empty} one {# item} other {# items}}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`#` becomes the count, formatted for the locale (`1,234 items`). An exact `=N`
|
|
80
|
+
branch beats the plural category — which is the whole point of `=0`, because "Your
|
|
81
|
+
cart is empty" reads better than "0 items".
|
|
82
|
+
|
|
83
|
+
**Categories are the locale's, not English's.** French treats 0 and 1 as singular;
|
|
84
|
+
Polish has `one`/`few`/`many`/`other`. That's exactly why you write a message rather
|
|
85
|
+
than `count === 1 ? "item" : "items"` — that ternary is a bug in most of the world.
|
|
86
|
+
|
|
87
|
+
### Ordinals
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
→ 1st, 2nd, 3rd, 4th, 11th.
|
|
94
|
+
|
|
95
|
+
### Select
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
{gender, select, male {He} female {She} other {They}} replied
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
An unmatched value (or a missing one) takes the `other` branch.
|
|
102
|
+
|
|
103
|
+
### Numbers, dates, times
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
{n, number} 1,234.5
|
|
107
|
+
{n, number, percent} 25%
|
|
108
|
+
{n, number, integer} 4
|
|
109
|
+
{n, number, ::currency/USD} $9.50
|
|
110
|
+
{d, date, medium} Jul 11, 2026
|
|
111
|
+
{d, time, short} 3:30 PM
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Nesting
|
|
115
|
+
|
|
116
|
+
Branches are themselves messages, so they nest as deep as you need:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
{count, plural,
|
|
120
|
+
=0 {No messages for {name}}
|
|
121
|
+
one {{name} has # message}
|
|
122
|
+
other {{name} has # messages}}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Literal braces
|
|
126
|
+
|
|
127
|
+
`'{'` and `'}'` render as literal braces.
|
|
128
|
+
|
|
129
|
+
## Formatters
|
|
130
|
+
|
|
131
|
+
`Intl`, bound to the request's locale:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const l = i18n();
|
|
135
|
+
|
|
136
|
+
l.formatNumber(1234.5); // "1,234.5" (de-DE: "1.234,5")
|
|
137
|
+
l.formatCurrency(9.5, "USD"); // "$9.50"
|
|
138
|
+
l.formatDate(order.createdAt); // "Jul 11, 2026"
|
|
139
|
+
l.formatTime(order.createdAt); // "3:30:00 PM"
|
|
140
|
+
l.formatRelativeTime(post.publishedAt); // "3 days ago"
|
|
141
|
+
l.formatList(["a", "b", "c"]); // "a, b, and c"
|
|
142
|
+
l.formatList(names, { type: "disjunction" }); // "a, b, or c"
|
|
143
|
+
l.formatPlural(5); // "other"
|
|
144
|
+
l.formatDisplayName("fr"); // "French"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`formatRelativeTime` picks a sensible unit from the distance on its own — seconds,
|
|
148
|
+
hours, days — or takes one: `formatRelativeTime(date, "hour")`.
|
|
149
|
+
|
|
150
|
+
These are worth using even in a **single-locale** app: they're the correct way to
|
|
151
|
+
render money and dates, and they cost nothing.
|
|
152
|
+
|
|
153
|
+
## Locale detection
|
|
154
|
+
|
|
155
|
+
`detectLocale()` works out the request's locale and stashes it, in this order:
|
|
156
|
+
|
|
157
|
+
1. a custom `resolve(c)` you supply
|
|
158
|
+
2. a query param — `detectLocale({ query: "lang" })` → `?lang=fr`
|
|
159
|
+
3. a cookie — `detectLocale({ cookie: "locale" })`
|
|
160
|
+
4. the `Accept-Language` header (turn it off with `header: false`)
|
|
161
|
+
5. the default locale
|
|
162
|
+
|
|
163
|
+
**Only supported locales are honored**, so `?lang=xx` can't push the app into a
|
|
164
|
+
locale you have no translations for — it falls through to the next source.
|
|
165
|
+
|
|
166
|
+
`negotiateLocale()` is the header parser on its own, if you want it:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en"); // "fr"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It honors `q` weights and matches `fr-CA` against a supported `fr`.
|
|
173
|
+
|
|
174
|
+
## Fallbacks
|
|
175
|
+
|
|
176
|
+
A key with no translation in the active locale falls back — down a chain, not off
|
|
177
|
+
a cliff:
|
|
178
|
+
|
|
179
|
+
1. the locale itself (`es-MX`)
|
|
180
|
+
2. its configured fallback (`fallbackLocales: { "es-MX": "es" }`)
|
|
181
|
+
3. its **base language** (`es`)
|
|
182
|
+
4. the default locale (`en`)
|
|
183
|
+
|
|
184
|
+
So you can ship `es` fully and `es-MX` as a handful of regional overrides, and
|
|
185
|
+
everything else still resolves:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
setTranslations({
|
|
189
|
+
es: { greeting: "Hola", chair: "silla" },
|
|
190
|
+
"es-MX": { chair: "banca" }, // just the override
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
i18n("es-MX").t("chair"); // "banca"
|
|
194
|
+
i18n("es-MX").t("greeting"); // "Hola" — from `es`
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Missing keys
|
|
198
|
+
|
|
199
|
+
A missing key **does not throw**. It renders as the key itself (`cart.items`), so
|
|
200
|
+
the page still works and the gap is obvious rather than blank. It also fires an
|
|
201
|
+
`i18n.missing` event, which is how you find them in production:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
listen("i18n.missing", ({ key, locale }) => {
|
|
205
|
+
logger().warn("missing translation", { key, locale });
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Override what's rendered with the `missing` option:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
new I18nManager({ missing: (key, locale) => `[${locale}:${key}]` });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## API reference
|
|
218
|
+
|
|
219
|
+
### `t(key, data?)`
|
|
220
|
+
|
|
221
|
+
`t(key: string, data?: Record<string, unknown>): string`
|
|
222
|
+
|
|
223
|
+
Translate a key in the **current request's** locale (or the default, outside a
|
|
224
|
+
request), formatting its ICU message with `data`.
|
|
225
|
+
|
|
226
|
+
### `i18n(locale?)`
|
|
227
|
+
|
|
228
|
+
`i18n(locale?: string): I18n`
|
|
229
|
+
|
|
230
|
+
An `I18n` for a locale — or, with no argument, the current request's.
|
|
231
|
+
|
|
232
|
+
### `I18n`
|
|
233
|
+
|
|
234
|
+
| Method | Signature |
|
|
235
|
+
|--------|-----------|
|
|
236
|
+
| `t` | `(key, data?) => string` |
|
|
237
|
+
| `has` | `(key) => boolean` |
|
|
238
|
+
| `formatNumber` | `(value, options?: Intl.NumberFormatOptions) => string` |
|
|
239
|
+
| `formatCurrency` | `(value, currency, options?) => string` |
|
|
240
|
+
| `formatDate` | `(value, options?: Intl.DateTimeFormatOptions) => string` |
|
|
241
|
+
| `formatTime` | `(value, options?) => string` |
|
|
242
|
+
| `formatRelativeTime` | `(value, unit?, options?) => string` |
|
|
243
|
+
| `formatList` | `(items, options?: Intl.ListFormatOptions) => string` |
|
|
244
|
+
| `formatPlural` | `(count, options?) => Intl.LDMLPluralRule` |
|
|
245
|
+
| `formatDisplayName` | `(code, type?) => string` |
|
|
246
|
+
| `locale` | the locale code |
|
|
247
|
+
|
|
248
|
+
### `I18nManager`
|
|
249
|
+
|
|
250
|
+
| Method | Signature |
|
|
251
|
+
|--------|-----------|
|
|
252
|
+
| `add` | `(data: TranslationsByLocale) => this` |
|
|
253
|
+
| `load` | `(...loaders: TranslationLoader[]) => Promise<this>` |
|
|
254
|
+
| `locale` | `(code?) => I18n` |
|
|
255
|
+
| `supported` | `() => string[]` |
|
|
256
|
+
| `defaultLocale` | the default locale code |
|
|
257
|
+
|
|
258
|
+
`new I18nManager(options)` — see `I18nOptions`.
|
|
259
|
+
|
|
260
|
+
### `setI18n(manager)` / `getI18n()` / `setTranslations(data)`
|
|
261
|
+
|
|
262
|
+
Replace the active manager, read it, or add translations to it.
|
|
263
|
+
|
|
264
|
+
### `detectLocale(options?)`
|
|
265
|
+
|
|
266
|
+
`detectLocale(options?: DetectLocaleOptions): MiddlewareHandler`
|
|
267
|
+
|
|
268
|
+
Work out the request's locale and stash it for `t()` / `i18n()`.
|
|
269
|
+
|
|
270
|
+
### `negotiateLocale(header, supported, defaultLocale)`
|
|
271
|
+
|
|
272
|
+
`negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string`
|
|
273
|
+
|
|
274
|
+
The `Accept-Language` parser, standalone.
|
|
275
|
+
|
|
276
|
+
### `formatMessage(message, data?, locale?)`
|
|
277
|
+
|
|
278
|
+
`formatMessage(message: string, data?: Record<string, unknown>, locale?: string): string`
|
|
279
|
+
|
|
280
|
+
Format an ICU message directly, without a translation lookup.
|
|
281
|
+
|
|
282
|
+
### `objectLoader(data)`
|
|
283
|
+
|
|
284
|
+
`objectLoader(data: TranslationsByLocale): TranslationLoader` — the simplest loader.
|
|
285
|
+
|
|
286
|
+
### Interfaces & types
|
|
287
|
+
|
|
288
|
+
#### `I18nOptions`
|
|
289
|
+
|
|
290
|
+
`{ defaultLocale?, supportedLocales?, fallbackLocales?, missing? }`.
|
|
291
|
+
|
|
292
|
+
#### `DetectLocaleOptions`
|
|
293
|
+
|
|
294
|
+
`{ query?, cookie?, header?, resolve? }`.
|
|
295
|
+
|
|
296
|
+
#### `Translations` / `TranslationsByLocale`
|
|
297
|
+
|
|
298
|
+
A locale's messages (nested or flat), and those keyed by locale.
|
|
299
|
+
|
|
300
|
+
#### `TranslationLoader`
|
|
301
|
+
|
|
302
|
+
`{ load(): Promise<TranslationsByLocale> | TranslationsByLocale }`.
|