@shaferllc/keel 0.68.0 → 0.77.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 (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -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 +93 -0
  13. package/dist/api/resource.js +185 -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/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Internationalization — translations with ICU message formatting, and the
3
+ * `Intl` formatters that go with them.
4
+ *
5
+ * setTranslations({
6
+ * en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
7
+ * fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
8
+ * });
9
+ *
10
+ * t("cart.items", { count: 3 }); // "3 items" (the request's locale)
11
+ * i18n("fr").t("cart.items", { count: 3 }); // "3 articles"
12
+ *
13
+ * There is **no dependency** here, and there doesn't need to be: `Intl` ships with
14
+ * every modern runtime — Node and Workers both carry the full ICU data — so
15
+ * plurals, currencies, dates, and relative times are the platform's job. What Keel
16
+ * adds is the message parser on top, which is the part `Intl` doesn't do.
17
+ *
18
+ * The supported ICU subset is the part people actually use: interpolation,
19
+ * `plural`, `selectordinal`, `select`, `number`, `date`, and `time` — nested
20
+ * arbitrarily deep. See the i18n guide for the grammar.
21
+ */
22
+ import type { MiddlewareHandler } from "hono";
23
+ /** A locale's messages — nested objects or flat dot-separated keys, or both. */
24
+ export type Translations = Record<string, unknown>;
25
+ /** Messages keyed by locale: `{ en: {...}, fr: {...} }`. */
26
+ export type TranslationsByLocale = Record<string, Translations>;
27
+ /** Where translations come from. Return them; Keel flattens and indexes them. */
28
+ export interface TranslationLoader {
29
+ load(): Promise<TranslationsByLocale> | TranslationsByLocale;
30
+ }
31
+ /**
32
+ * Format an ICU message. The grammar we support:
33
+ *
34
+ * {name} interpolation
35
+ * {n, number} {n, number, percent} {n, number, ::currency/USD}
36
+ * {d, date, medium} {d, time, short}
37
+ * {c, plural, =0 {none} one {# item} other {# items}}
38
+ * {c, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
39
+ * {g, select, male {He} female {She} other {They}}
40
+ *
41
+ * `#` inside a plural branch becomes the formatted number. Branches are
42
+ * themselves messages, so they nest. `'{'` and `'}'` are literal braces.
43
+ */
44
+ export declare function formatMessage(message: string, data?: Record<string, unknown>, locale?: string): string;
45
+ /**
46
+ * Pick the best locale for an `Accept-Language` header.
47
+ *
48
+ * negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en") // "fr"
49
+ *
50
+ * Exact matches win, then the language part (`fr-CA` → `fr`), in the client's
51
+ * order of preference. Falls back to `defaultLocale` when nothing matches.
52
+ */
53
+ export declare function negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string;
54
+ /** One locale, bound to its messages. Get one with `i18n(locale)`. */
55
+ export declare class I18n {
56
+ readonly locale: string;
57
+ private messages;
58
+ private manager;
59
+ constructor(locale: string, messages: Record<string, string>, manager: I18nManager);
60
+ /** Whether a key resolves in this locale (or its fallbacks). */
61
+ has(key: string): boolean;
62
+ /**
63
+ * Translate a key, formatting its ICU message with `data`.
64
+ *
65
+ * t("cart.items", { count: 3 }); // "3 items"
66
+ *
67
+ * A missing key doesn't throw — it goes through the configured `missing`
68
+ * handler (which returns the key itself by default, so the page still renders
69
+ * and the gap is obvious) and fires an `i18n.missing` event.
70
+ */
71
+ t(key: string, data?: Record<string, unknown>): string;
72
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
73
+ formatCurrency(value: number, currency: string, options?: Intl.NumberFormatOptions): string;
74
+ formatDate(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string;
75
+ formatTime(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string;
76
+ /**
77
+ * "3 days ago", "in 2 hours". With no unit, the best one is picked from the
78
+ * distance between `value` and now.
79
+ */
80
+ formatRelativeTime(value: Date | number | string, unit?: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
81
+ /** "a, b, and c" — or "a, b, or c" with `{ type: "disjunction" }`. */
82
+ formatList(items: string[], options?: Intl.ListFormatOptions): string;
83
+ /** The plural category for a count: "one", "other", … */
84
+ formatPlural(count: number, options?: Intl.PluralRulesOptions): Intl.LDMLPluralRule;
85
+ /** A language/region/currency code as its display name: "fr" → "French". */
86
+ formatDisplayName(code: string, type?: Intl.DisplayNamesOptions["type"]): string;
87
+ }
88
+ export interface I18nOptions {
89
+ /** Used when nothing else matches. Default: `"en"`. */
90
+ defaultLocale?: string;
91
+ /**
92
+ * Which locales this app serves. Derived from the loaded translations when
93
+ * omitted.
94
+ */
95
+ supportedLocales?: string[];
96
+ /** Per-locale fallbacks: `{ "es-MX": "es" }`. A language prefix is tried anyway. */
97
+ fallbackLocales?: Record<string, string>;
98
+ /**
99
+ * What to render for a key with no translation. Default: the key itself — so
100
+ * the page still renders and the gap is visible rather than blank.
101
+ */
102
+ missing?: (key: string, locale: string) => string;
103
+ }
104
+ export declare class I18nManager {
105
+ private translations;
106
+ private options;
107
+ constructor(options?: I18nOptions);
108
+ get defaultLocale(): string;
109
+ /** Add translations, merging into whatever is already loaded. */
110
+ add(data: TranslationsByLocale): this;
111
+ /** Load from one or more loaders. */
112
+ load(...loaders: TranslationLoader[]): Promise<this>;
113
+ /** The locales this app serves. */
114
+ supported(): string[];
115
+ /**
116
+ * The chain to try for a locale: itself, its configured fallback, its language
117
+ * prefix (`es-MX` → `es`), then the default. That's what makes a regional
118
+ * locale usable with only the base language translated.
119
+ */
120
+ private chain;
121
+ /** An `I18n` for a locale, with its fallback chain already flattened in. */
122
+ locale(code?: string): I18n;
123
+ /** What to render for a missing key — and a chance to notice it. */
124
+ missing(key: string, locale: string): string;
125
+ }
126
+ /** Replace the i18n manager (options and all). */
127
+ export declare function setI18n(next: I18nManager): I18nManager;
128
+ /** The active i18n manager. */
129
+ export declare function getI18n(): I18nManager;
130
+ /** Register translations on the active manager. */
131
+ export declare function setTranslations(data: TranslationsByLocale): I18nManager;
132
+ /**
133
+ * An `I18n` for a locale — or, with no argument, the **current request's** locale
134
+ * (set by `detectLocale()`), falling back to the default outside a request.
135
+ */
136
+ export declare function i18n(locale?: string): I18n;
137
+ /** Translate a key in the current request's locale. */
138
+ export declare function t(key: string, data?: Record<string, unknown>): string;
139
+ /** Translations from a plain object — the simplest loader. */
140
+ export declare function objectLoader(data: TranslationsByLocale): TranslationLoader;
141
+ export interface DetectLocaleOptions {
142
+ /** Read `?lang=fr` (or another param). Highest precedence when set. */
143
+ query?: string;
144
+ /** Read a cookie. Checked after the query param, before the header. */
145
+ cookie?: string;
146
+ /** Skip the `Accept-Language` header. Default: false. */
147
+ header?: boolean;
148
+ /** Your own resolver — wins over everything if it returns a locale. */
149
+ resolve?: (c: Parameters<MiddlewareHandler>[0]) => string | undefined;
150
+ }
151
+ /**
152
+ * Work out the request's locale and stash it, so `t()` and `i18n()` pick it up
153
+ * anywhere in the request without threading it through.
154
+ *
155
+ * this.use(detectLocale()); // Accept-Language
156
+ * this.use(detectLocale({ query: "lang" })); // ?lang=fr wins
157
+ *
158
+ * Precedence: `resolve` → query → cookie → `Accept-Language` → the default. Only
159
+ * **supported** locales are honored, so `?lang=xx` can't put the app into a locale
160
+ * you have no translations for.
161
+ */
162
+ export declare function detectLocale(options?: DetectLocaleOptions): MiddlewareHandler;
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Internationalization — translations with ICU message formatting, and the
3
+ * `Intl` formatters that go with them.
4
+ *
5
+ * setTranslations({
6
+ * en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
7
+ * fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
8
+ * });
9
+ *
10
+ * t("cart.items", { count: 3 }); // "3 items" (the request's locale)
11
+ * i18n("fr").t("cart.items", { count: 3 }); // "3 articles"
12
+ *
13
+ * There is **no dependency** here, and there doesn't need to be: `Intl` ships with
14
+ * every modern runtime — Node and Workers both carry the full ICU data — so
15
+ * plurals, currencies, dates, and relative times are the platform's job. What Keel
16
+ * adds is the message parser on top, which is the part `Intl` doesn't do.
17
+ *
18
+ * The supported ICU subset is the part people actually use: interpolation,
19
+ * `plural`, `selectordinal`, `select`, `number`, `date`, and `time` — nested
20
+ * arbitrarily deep. See the i18n guide for the grammar.
21
+ */
22
+ import { getContext } from "hono/context-storage";
23
+ import { hasApplication, emit } from "./helpers.js";
24
+ /**
25
+ * Flatten nested translations to dot paths, so `{ cart: { items: "…" } }` and
26
+ * `{ "cart.items": "…" }` are the same thing and can be mixed freely.
27
+ */
28
+ function flatten(input, prefix = "", out = {}) {
29
+ for (const [key, value] of Object.entries(input)) {
30
+ const path = prefix ? `${prefix}.${key}` : key;
31
+ if (value && typeof value === "object" && !Array.isArray(value)) {
32
+ flatten(value, path, out);
33
+ }
34
+ else if (value != null) {
35
+ out[path] = String(value);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ /* ---------------------------- the ICU formatter --------------------------- */
41
+ /**
42
+ * Find the index of the `}` matching the `{` at `open`. Brace-counting, because a
43
+ * plural branch contains whole sub-messages that have braces of their own.
44
+ */
45
+ function matchBrace(message, open) {
46
+ let depth = 0;
47
+ for (let i = open; i < message.length; i++) {
48
+ const char = message[i];
49
+ if (char === "{")
50
+ depth++;
51
+ else if (char === "}") {
52
+ depth--;
53
+ if (depth === 0)
54
+ return i;
55
+ }
56
+ }
57
+ throw new Error(`i18n: unbalanced braces in message: ${message}`);
58
+ }
59
+ /** Split `one {# item} other {# items}` into its branches. */
60
+ function parseBranches(style) {
61
+ const branches = {};
62
+ let i = 0;
63
+ while (i < style.length) {
64
+ // The branch key: everything up to the next `{`.
65
+ while (i < style.length && /\s/.test(style[i]))
66
+ i++;
67
+ const open = style.indexOf("{", i);
68
+ if (open === -1)
69
+ break;
70
+ const key = style.slice(i, open).trim();
71
+ const close = matchBrace(style, open);
72
+ if (key)
73
+ branches[key] = style.slice(open + 1, close);
74
+ i = close + 1;
75
+ }
76
+ return branches;
77
+ }
78
+ /** Split an argument's innards into `name`, `type`, and the raw `style`. */
79
+ function parseArg(inner) {
80
+ const firstComma = inner.indexOf(",");
81
+ if (firstComma === -1)
82
+ return { name: inner.trim() };
83
+ const name = inner.slice(0, firstComma).trim();
84
+ const rest = inner.slice(firstComma + 1);
85
+ const secondComma = rest.indexOf(",");
86
+ if (secondComma === -1)
87
+ return { name, type: rest.trim() };
88
+ return {
89
+ name,
90
+ type: rest.slice(0, secondComma).trim(),
91
+ style: rest.slice(secondComma + 1).trim(),
92
+ };
93
+ }
94
+ function numberFormat(locale, style) {
95
+ if (!style)
96
+ return {};
97
+ if (style === "percent")
98
+ return { style: "percent" };
99
+ if (style === "integer")
100
+ return { maximumFractionDigits: 0 };
101
+ // The ICU skeleton for currency: `::currency/USD`.
102
+ const currency = /^::currency\/([A-Z]{3})$/.exec(style);
103
+ if (currency)
104
+ return { style: "currency", currency: currency[1] };
105
+ return {};
106
+ }
107
+ const DATE_STYLES = new Set(["full", "long", "medium", "short"]);
108
+ function dateStyleOf(style, key) {
109
+ const value = style && DATE_STYLES.has(style) ? style : "medium";
110
+ return { [key]: value };
111
+ }
112
+ function toDate(value) {
113
+ if (value instanceof Date)
114
+ return value;
115
+ if (typeof value === "number" || typeof value === "string")
116
+ return new Date(value);
117
+ throw new Error(`i18n: expected a date, got ${typeof value}`);
118
+ }
119
+ /**
120
+ * Format an ICU message. The grammar we support:
121
+ *
122
+ * {name} interpolation
123
+ * {n, number} {n, number, percent} {n, number, ::currency/USD}
124
+ * {d, date, medium} {d, time, short}
125
+ * {c, plural, =0 {none} one {# item} other {# items}}
126
+ * {c, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
127
+ * {g, select, male {He} female {She} other {They}}
128
+ *
129
+ * `#` inside a plural branch becomes the formatted number. Branches are
130
+ * themselves messages, so they nest. `'{'` and `'}'` are literal braces.
131
+ */
132
+ export function formatMessage(message, data = {}, locale = "en") {
133
+ let out = "";
134
+ let i = 0;
135
+ while (i < message.length) {
136
+ const char = message[i];
137
+ // ICU escaping: '{' and '}' are literal braces.
138
+ if (char === "'" && (message[i + 1] === "{" || message[i + 1] === "}")) {
139
+ const end = message.indexOf("'", i + 1);
140
+ if (end === -1) {
141
+ out += message.slice(i + 1);
142
+ break;
143
+ }
144
+ out += message.slice(i + 1, end);
145
+ i = end + 1;
146
+ continue;
147
+ }
148
+ if (char !== "{") {
149
+ out += char;
150
+ i++;
151
+ continue;
152
+ }
153
+ const close = matchBrace(message, i);
154
+ const { name, type, style } = parseArg(message.slice(i + 1, close));
155
+ const value = data[name];
156
+ i = close + 1;
157
+ if (!type) {
158
+ out += value == null ? "" : String(value);
159
+ continue;
160
+ }
161
+ switch (type) {
162
+ case "number":
163
+ out += new Intl.NumberFormat(locale, numberFormat(locale, style)).format(Number(value));
164
+ break;
165
+ case "date":
166
+ out += new Intl.DateTimeFormat(locale, dateStyleOf(style, "dateStyle")).format(toDate(value));
167
+ break;
168
+ case "time":
169
+ out += new Intl.DateTimeFormat(locale, dateStyleOf(style, "timeStyle")).format(toDate(value));
170
+ break;
171
+ case "plural":
172
+ case "selectordinal": {
173
+ const count = Number(value);
174
+ const branches = parseBranches(style ?? "");
175
+ // An exact `=N` match wins over the plural category — that's what lets
176
+ // "=0 {Your cart is empty}" say something other than "0 items".
177
+ const category = new Intl.PluralRules(locale, {
178
+ type: type === "plural" ? "cardinal" : "ordinal",
179
+ }).select(count);
180
+ const branch = branches[`=${count}`] ?? branches[category] ?? branches.other ?? "";
181
+ const formatted = new Intl.NumberFormat(locale).format(count);
182
+ // `#` is the count. Recurse first, then substitute, so a nested message
183
+ // can't accidentally eat the marker.
184
+ out += formatMessage(branch, data, locale).replaceAll("#", formatted);
185
+ break;
186
+ }
187
+ case "select": {
188
+ const branches = parseBranches(style ?? "");
189
+ const branch = branches[String(value)] ?? branches.other ?? "";
190
+ out += formatMessage(branch, data, locale);
191
+ break;
192
+ }
193
+ default:
194
+ // An unknown type: fall back to interpolation rather than throwing.
195
+ out += value == null ? "" : String(value);
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+ /* --------------------------------- locales -------------------------------- */
201
+ /**
202
+ * Pick the best locale for an `Accept-Language` header.
203
+ *
204
+ * negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en") // "fr"
205
+ *
206
+ * Exact matches win, then the language part (`fr-CA` → `fr`), in the client's
207
+ * order of preference. Falls back to `defaultLocale` when nothing matches.
208
+ */
209
+ export function negotiateLocale(header, supported, defaultLocale) {
210
+ if (!header)
211
+ return defaultLocale;
212
+ const wanted = header
213
+ .split(",")
214
+ .map((part) => {
215
+ const [tag, ...params] = part.trim().split(";");
216
+ const q = params.find((p) => p.trim().startsWith("q="));
217
+ return { tag: (tag ?? "").trim().toLowerCase(), q: q ? Number(q.split("=")[1]) : 1 };
218
+ })
219
+ .filter((entry) => entry.tag && !Number.isNaN(entry.q))
220
+ .sort((a, b) => b.q - a.q);
221
+ const lower = new Map(supported.map((locale) => [locale.toLowerCase(), locale]));
222
+ for (const { tag } of wanted) {
223
+ const exact = lower.get(tag);
224
+ if (exact)
225
+ return exact;
226
+ // `fr-CA` should match a supported `fr`.
227
+ const language = tag.split("-")[0];
228
+ const partial = lower.get(language);
229
+ if (partial)
230
+ return partial;
231
+ }
232
+ return defaultLocale;
233
+ }
234
+ /* ---------------------------------- i18n ---------------------------------- */
235
+ /** One locale, bound to its messages. Get one with `i18n(locale)`. */
236
+ export class I18n {
237
+ locale;
238
+ messages;
239
+ manager;
240
+ constructor(locale, messages, manager) {
241
+ this.locale = locale;
242
+ this.messages = messages;
243
+ this.manager = manager;
244
+ }
245
+ /** Whether a key resolves in this locale (or its fallbacks). */
246
+ has(key) {
247
+ return this.messages[key] !== undefined;
248
+ }
249
+ /**
250
+ * Translate a key, formatting its ICU message with `data`.
251
+ *
252
+ * t("cart.items", { count: 3 }); // "3 items"
253
+ *
254
+ * A missing key doesn't throw — it goes through the configured `missing`
255
+ * handler (which returns the key itself by default, so the page still renders
256
+ * and the gap is obvious) and fires an `i18n.missing` event.
257
+ */
258
+ t(key, data) {
259
+ const message = this.messages[key];
260
+ if (message === undefined)
261
+ return this.manager.missing(key, this.locale);
262
+ return formatMessage(message, data, this.locale);
263
+ }
264
+ formatNumber(value, options) {
265
+ return new Intl.NumberFormat(this.locale, options).format(value);
266
+ }
267
+ formatCurrency(value, currency, options) {
268
+ return new Intl.NumberFormat(this.locale, { ...options, style: "currency", currency }).format(value);
269
+ }
270
+ formatDate(value, options) {
271
+ return new Intl.DateTimeFormat(this.locale, options ?? { dateStyle: "medium" }).format(toDate(value));
272
+ }
273
+ formatTime(value, options) {
274
+ return new Intl.DateTimeFormat(this.locale, options ?? { timeStyle: "medium" }).format(toDate(value));
275
+ }
276
+ /**
277
+ * "3 days ago", "in 2 hours". With no unit, the best one is picked from the
278
+ * distance between `value` and now.
279
+ */
280
+ formatRelativeTime(value, unit, options) {
281
+ const formatter = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto", ...options });
282
+ const deltaMs = toDate(value).getTime() - Date.now();
283
+ if (unit)
284
+ return formatter.format(Math.round(deltaMs / MS[unit]), unit);
285
+ // Walk from the coarsest unit down; the first one the delta clears wins.
286
+ for (const candidate of AUTO_UNITS) {
287
+ const size = MS[candidate];
288
+ if (Math.abs(deltaMs) >= size || candidate === "second") {
289
+ return formatter.format(Math.round(deltaMs / size), candidate);
290
+ }
291
+ }
292
+ return formatter.format(0, "second");
293
+ }
294
+ /** "a, b, and c" — or "a, b, or c" with `{ type: "disjunction" }`. */
295
+ formatList(items, options) {
296
+ return new Intl.ListFormat(this.locale, options).format(items);
297
+ }
298
+ /** The plural category for a count: "one", "other", … */
299
+ formatPlural(count, options) {
300
+ return new Intl.PluralRules(this.locale, options).select(count);
301
+ }
302
+ /** A language/region/currency code as its display name: "fr" → "French". */
303
+ formatDisplayName(code, type = "language") {
304
+ return new Intl.DisplayNames([this.locale], { type }).of(code) ?? code;
305
+ }
306
+ }
307
+ const MS = {
308
+ year: 31_536_000_000,
309
+ quarter: 7_884_000_000,
310
+ month: 2_628_000_000,
311
+ week: 604_800_000,
312
+ day: 86_400_000,
313
+ hour: 3_600_000,
314
+ minute: 60_000,
315
+ second: 1_000,
316
+ };
317
+ const AUTO_UNITS = [
318
+ "year",
319
+ "month",
320
+ "week",
321
+ "day",
322
+ "hour",
323
+ "minute",
324
+ "second",
325
+ ];
326
+ export class I18nManager {
327
+ translations = new Map();
328
+ options;
329
+ constructor(options = {}) {
330
+ this.options = {
331
+ defaultLocale: options.defaultLocale ?? "en",
332
+ fallbackLocales: options.fallbackLocales ?? {},
333
+ missing: options.missing ?? ((key) => key),
334
+ supportedLocales: options.supportedLocales,
335
+ };
336
+ }
337
+ get defaultLocale() {
338
+ return this.options.defaultLocale;
339
+ }
340
+ /** Add translations, merging into whatever is already loaded. */
341
+ add(data) {
342
+ for (const [locale, messages] of Object.entries(data)) {
343
+ const existing = this.translations.get(locale) ?? {};
344
+ this.translations.set(locale, { ...existing, ...flatten(messages) });
345
+ }
346
+ return this;
347
+ }
348
+ /** Load from one or more loaders. */
349
+ async load(...loaders) {
350
+ for (const loader of loaders)
351
+ this.add(await loader.load());
352
+ return this;
353
+ }
354
+ /** The locales this app serves. */
355
+ supported() {
356
+ return this.options.supportedLocales ?? [...this.translations.keys()];
357
+ }
358
+ /**
359
+ * The chain to try for a locale: itself, its configured fallback, its language
360
+ * prefix (`es-MX` → `es`), then the default. That's what makes a regional
361
+ * locale usable with only the base language translated.
362
+ */
363
+ chain(locale) {
364
+ const chain = [locale];
365
+ const configured = this.options.fallbackLocales[locale];
366
+ if (configured)
367
+ chain.push(configured);
368
+ const language = locale.split("-")[0];
369
+ if (language !== locale)
370
+ chain.push(language);
371
+ chain.push(this.options.defaultLocale);
372
+ return [...new Set(chain)];
373
+ }
374
+ /** An `I18n` for a locale, with its fallback chain already flattened in. */
375
+ locale(code) {
376
+ const locale = code ?? this.options.defaultLocale;
377
+ // Later entries in the chain fill only the gaps the earlier ones leave.
378
+ const merged = {};
379
+ for (const candidate of this.chain(locale).reverse()) {
380
+ Object.assign(merged, this.translations.get(candidate) ?? {});
381
+ }
382
+ return new I18n(locale, merged, this);
383
+ }
384
+ /** What to render for a missing key — and a chance to notice it. */
385
+ missing(key, locale) {
386
+ if (hasApplication())
387
+ void emit("i18n.missing", { key, locale });
388
+ return this.options.missing(key, locale);
389
+ }
390
+ }
391
+ /* --------------------------------- global --------------------------------- */
392
+ let manager = new I18nManager();
393
+ /** Replace the i18n manager (options and all). */
394
+ export function setI18n(next) {
395
+ manager = next;
396
+ return manager;
397
+ }
398
+ /** The active i18n manager. */
399
+ export function getI18n() {
400
+ return manager;
401
+ }
402
+ /** Register translations on the active manager. */
403
+ export function setTranslations(data) {
404
+ return manager.add(data);
405
+ }
406
+ /**
407
+ * An `I18n` for a locale — or, with no argument, the **current request's** locale
408
+ * (set by `detectLocale()`), falling back to the default outside a request.
409
+ */
410
+ export function i18n(locale) {
411
+ return manager.locale(locale ?? currentLocale());
412
+ }
413
+ /** Translate a key in the current request's locale. */
414
+ export function t(key, data) {
415
+ return i18n().t(key, data);
416
+ }
417
+ /** Translations from a plain object — the simplest loader. */
418
+ export function objectLoader(data) {
419
+ return { load: () => data };
420
+ }
421
+ /* ------------------------------- the request ------------------------------ */
422
+ /**
423
+ * The locale `detectLocale()` stored on this request, if we're in one. Outside a
424
+ * request — a CLI command, a queue worker — there's no context, and that's fine:
425
+ * `i18n()` falls back to the default locale.
426
+ */
427
+ function currentLocale() {
428
+ try {
429
+ return getContext().get("locale");
430
+ }
431
+ catch {
432
+ return undefined;
433
+ }
434
+ }
435
+ /**
436
+ * Work out the request's locale and stash it, so `t()` and `i18n()` pick it up
437
+ * anywhere in the request without threading it through.
438
+ *
439
+ * this.use(detectLocale()); // Accept-Language
440
+ * this.use(detectLocale({ query: "lang" })); // ?lang=fr wins
441
+ *
442
+ * Precedence: `resolve` → query → cookie → `Accept-Language` → the default. Only
443
+ * **supported** locales are honored, so `?lang=xx` can't put the app into a locale
444
+ * you have no translations for.
445
+ */
446
+ export function detectLocale(options = {}) {
447
+ return async (c, next) => {
448
+ const supported = manager.supported();
449
+ const isSupported = (value) => value !== undefined && supported.includes(value);
450
+ let locale;
451
+ const custom = options.resolve?.(c);
452
+ if (isSupported(custom))
453
+ locale = custom;
454
+ if (!locale && options.query) {
455
+ const value = new URL(c.req.url).searchParams.get(options.query) ?? undefined;
456
+ if (isSupported(value))
457
+ locale = value;
458
+ }
459
+ if (!locale && options.cookie) {
460
+ const cookies = c.req.header("Cookie") ?? "";
461
+ const match = new RegExp(`(?:^|;\\s*)${options.cookie}=([^;]+)`).exec(cookies);
462
+ const value = match?.[1];
463
+ if (isSupported(value))
464
+ locale = value;
465
+ }
466
+ if (!locale && options.header !== false) {
467
+ locale = negotiateLocale(c.req.header("Accept-Language"), supported, manager.defaultLocale);
468
+ }
469
+ c.set("locale", locale ?? manager.defaultLocale);
470
+ await next();
471
+ };
472
+ }