@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.
Files changed (140) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +14 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +91 -0
  13. package/dist/api/resource.js +188 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/stubs.d.ts +14 -0
  18. package/dist/core/cli/stubs.js +105 -0
  19. package/dist/core/console-prompt.d.ts +79 -0
  20. package/dist/core/console-prompt.js +239 -0
  21. package/dist/core/console-ui.d.ts +96 -0
  22. package/dist/core/console-ui.js +187 -0
  23. package/dist/core/console.d.ts +188 -0
  24. package/dist/core/console.js +395 -0
  25. package/dist/core/database.d.ts +70 -1
  26. package/dist/core/database.js +174 -15
  27. package/dist/core/env.d.ts +96 -0
  28. package/dist/core/env.js +140 -0
  29. package/dist/core/health.d.ts +2 -2
  30. package/dist/core/health.js +2 -2
  31. package/dist/core/http/kernel.d.ts +2 -0
  32. package/dist/core/http/kernel.js +48 -0
  33. package/dist/core/http/router.d.ts +5 -5
  34. package/dist/core/http/router.js +5 -5
  35. package/dist/core/i18n.d.ts +162 -0
  36. package/dist/core/i18n.js +472 -0
  37. package/dist/core/index.d.ts +25 -4
  38. package/dist/core/index.js +13 -3
  39. package/dist/core/instrumentation.d.ts +113 -0
  40. package/dist/core/instrumentation.js +52 -0
  41. package/dist/core/logger.d.ts +7 -0
  42. package/dist/core/logger.js +28 -1
  43. package/dist/core/notification.js +10 -1
  44. package/dist/core/package.d.ts +120 -0
  45. package/dist/core/package.js +169 -0
  46. package/dist/core/pages.d.ts +108 -0
  47. package/dist/core/pages.js +199 -0
  48. package/dist/core/queue.js +26 -5
  49. package/dist/core/repl.d.ts +33 -0
  50. package/dist/core/repl.js +88 -0
  51. package/dist/core/scheduler.js +6 -0
  52. package/dist/core/social.d.ts +4 -4
  53. package/dist/core/social.js +4 -4
  54. package/dist/core/storage.js +15 -3
  55. package/dist/core/telemetry.d.ts +208 -0
  56. package/dist/core/telemetry.js +383 -0
  57. package/dist/core/template.d.ts +2 -3
  58. package/dist/core/template.js +2 -3
  59. package/dist/core/testing.d.ts +170 -1
  60. package/dist/core/testing.js +504 -2
  61. package/dist/db/d1.js +13 -0
  62. package/dist/db/pg.d.ts +13 -0
  63. package/dist/db/pg.js +46 -4
  64. package/dist/openapi/config.d.ts +28 -0
  65. package/dist/openapi/config.js +25 -0
  66. package/dist/openapi/doc.d.ts +40 -0
  67. package/dist/openapi/doc.js +20 -0
  68. package/dist/openapi/export.d.ts +8 -0
  69. package/dist/openapi/export.js +19 -0
  70. package/dist/openapi/gate.d.ts +15 -0
  71. package/dist/openapi/gate.js +27 -0
  72. package/dist/openapi/index.d.ts +19 -0
  73. package/dist/openapi/index.js +15 -0
  74. package/dist/openapi/openapi.config.stub +29 -0
  75. package/dist/openapi/provider.d.ts +18 -0
  76. package/dist/openapi/provider.js +35 -0
  77. package/dist/openapi/routes.d.ts +9 -0
  78. package/dist/openapi/routes.js +23 -0
  79. package/dist/openapi/spec.d.ts +23 -0
  80. package/dist/openapi/spec.js +132 -0
  81. package/dist/openapi/ui.d.ts +8 -0
  82. package/dist/openapi/ui.js +31 -0
  83. package/dist/openapi/zod.d.ts +12 -0
  84. package/dist/openapi/zod.js +46 -0
  85. package/dist/watch/config.d.ts +33 -0
  86. package/dist/watch/config.js +38 -0
  87. package/dist/watch/entry.d.ts +53 -0
  88. package/dist/watch/entry.js +105 -0
  89. package/dist/watch/gate.d.ts +20 -0
  90. package/dist/watch/gate.js +32 -0
  91. package/dist/watch/index.d.ts +21 -0
  92. package/dist/watch/index.js +17 -0
  93. package/dist/watch/migration.d.ts +7 -0
  94. package/dist/watch/migration.js +20 -0
  95. package/dist/watch/provider.d.ts +22 -0
  96. package/dist/watch/provider.js +58 -0
  97. package/dist/watch/prune.d.ts +11 -0
  98. package/dist/watch/prune.js +20 -0
  99. package/dist/watch/recorder.d.ts +24 -0
  100. package/dist/watch/recorder.js +39 -0
  101. package/dist/watch/routes.d.ts +13 -0
  102. package/dist/watch/routes.js +55 -0
  103. package/dist/watch/store.d.ts +54 -0
  104. package/dist/watch/store.js +158 -0
  105. package/dist/watch/ui/dist/watch.css +1 -0
  106. package/dist/watch/ui/dist/watch.js +555 -0
  107. package/dist/watch/ui-shell.d.ts +12 -0
  108. package/dist/watch/ui-shell.js +24 -0
  109. package/dist/watch/watch.config.stub +47 -0
  110. package/dist/watch/watchers.d.ts +12 -0
  111. package/dist/watch/watchers.js +156 -0
  112. package/docs/ai-manifest.json +737 -3
  113. package/docs/api-resources.md +118 -0
  114. package/docs/configuration.md +74 -0
  115. package/docs/console.md +193 -13
  116. package/docs/database.md +101 -0
  117. package/docs/examples/configuration.ts +40 -0
  118. package/docs/examples/console.ts +134 -0
  119. package/docs/examples/database.ts +90 -0
  120. package/docs/examples/i18n.ts +117 -0
  121. package/docs/examples/logger.ts +74 -0
  122. package/docs/examples/mail.ts +103 -0
  123. package/docs/examples/pages.ts +82 -0
  124. package/docs/examples/telemetry.ts +127 -0
  125. package/docs/examples/testing.ts +149 -0
  126. package/docs/health.md +4 -4
  127. package/docs/i18n.md +302 -0
  128. package/docs/logger.md +156 -10
  129. package/docs/mail.md +73 -0
  130. package/docs/openapi.md +111 -0
  131. package/docs/packages.md +118 -0
  132. package/docs/pages.md +217 -0
  133. package/docs/storage.md +5 -3
  134. package/docs/telemetry.md +263 -0
  135. package/docs/templates.md +3 -4
  136. package/docs/testing.md +251 -0
  137. package/docs/watch.md +118 -0
  138. package/llms-full.txt +2370 -248
  139. package/llms.txt +12 -1
  140. 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
+ }
@@ -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**: AdonisJS's disk-space, heap, and RSS checks. Those measure a
67
- Node process, and on Workers there isn't one — a memory threshold you can't
68
- observe is worse than no check at all. If you're on Node and want them, `check()`
69
- below takes ten lines.
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 }`.