okengine 0.5.1 → 0.6.1

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 (105) hide show
  1. package/README.md +148 -13
  2. package/package.json +4 -3
  3. package/site/content/docs/elements/ai.mdx +82 -1
  4. package/site/content/docs/elements/channel.mdx +77 -8
  5. package/site/content/docs/elements/flow.mdx +20 -17
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/magic-link.mdx +27 -21
  8. package/site/content/docs/reference/configuration.mdx +12 -4
  9. package/site/content/docs/reference/environment-variables.mdx +42 -13
  10. package/site/content/docs/reference/errors.mdx +14 -0
  11. package/site/content/docs/reference/fx.mdx +68 -16
  12. package/site/content/docs/reference/i18n.mdx +313 -0
  13. package/site/content/docs/reference/index.mdx +6 -1
  14. package/site/content/docs/reference/meta.json +1 -0
  15. package/site/content/docs/reference/plugins.mdx +1 -0
  16. package/src/auth/auth.test.ts +3 -0
  17. package/src/auth/bindings.ts +1 -1
  18. package/src/auth/method-context.ts +12 -2
  19. package/src/cli/openbao-restart.integration.test.ts +106 -97
  20. package/src/compiler/aot.test.ts +16 -13
  21. package/src/compiler/effects-infer.ts +46 -0
  22. package/src/console/server/ai.test.ts +34 -5
  23. package/src/docker/compose.ts +9 -0
  24. package/src/docker/docker.test.ts +39 -0
  25. package/src/docker/dockerfile.integration.test.ts +126 -119
  26. package/src/docker/index.ts +11 -1
  27. package/src/docker/recipes/index.ts +3 -1
  28. package/src/docker/recipes/ollama.ts +43 -0
  29. package/src/docker/stack-id.ts +2 -0
  30. package/src/docker/stack.integration.test.ts +118 -102
  31. package/src/drivers/ai-mock.ts +60 -0
  32. package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
  33. package/src/drivers/ai-ollama.integration.test.ts +181 -0
  34. package/src/drivers/ai-ollama.ts +327 -0
  35. package/src/drivers/ai-openai-compatible.ts +211 -21
  36. package/src/drivers/ai-providers.test.ts +179 -2
  37. package/src/drivers/ai-stream.test.ts +195 -0
  38. package/src/drivers/ai-types.ts +42 -1
  39. package/src/drivers/channel-fcm.ts +49 -53
  40. package/src/drivers/channel-msegat.ts +61 -0
  41. package/src/drivers/channel-sently-map.ts +57 -0
  42. package/src/drivers/channel-sently.test.ts +99 -0
  43. package/src/drivers/channel-smtp.ts +8 -2
  44. package/src/drivers/channel-sndr.ts +28 -0
  45. package/src/drivers/channel-taqnyat.ts +57 -0
  46. package/src/drivers/channel-types.ts +79 -2
  47. package/src/drivers/channel-unifonic.ts +26 -43
  48. package/src/drivers/channel-wa-cloud.ts +33 -47
  49. package/src/drivers/channel-webpush.ts +39 -239
  50. package/src/drivers/index.ts +25 -1
  51. package/src/drivers/ollama.ts +14 -0
  52. package/src/elements/ai/rate.test.ts +53 -0
  53. package/src/elements/ai/rate.ts +66 -0
  54. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  55. package/src/elements/ai/runtime.ts +330 -100
  56. package/src/elements/ai/tools.test.ts +99 -0
  57. package/src/elements/ai.test.ts +26 -2
  58. package/src/elements/ai.ts +10 -1
  59. package/src/elements/channel/costs.test.ts +2 -2
  60. package/src/elements/channel/costs.ts +14 -2
  61. package/src/elements/channel/mime.ts +11 -0
  62. package/src/elements/channel/runtime.ts +94 -0
  63. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  64. package/src/elements/channel.ts +10 -1
  65. package/src/elements/index.ts +9 -0
  66. package/src/i18n/catalogs/ar.ts +67 -0
  67. package/src/i18n/catalogs/en.ts +68 -0
  68. package/src/i18n/failure-message.test.ts +56 -0
  69. package/src/i18n/failure-message.ts +93 -0
  70. package/src/i18n/format.ts +67 -0
  71. package/src/i18n/index.ts +57 -0
  72. package/src/i18n/locale-context.ts +48 -0
  73. package/src/i18n/messages.test.ts +173 -0
  74. package/src/i18n/messages.ts +169 -0
  75. package/src/i18n/types.ts +90 -0
  76. package/src/index.ts +26 -0
  77. package/src/kernel/app.ts +92 -2
  78. package/src/kernel/boot-bind/ai.test.ts +60 -0
  79. package/src/kernel/boot-bind/ai.ts +125 -2
  80. package/src/kernel/boot-bind/channel.test.ts +68 -3
  81. package/src/kernel/boot-bind/channel.ts +93 -2
  82. package/src/kernel/boot.test.ts +4 -3
  83. package/src/kernel/boot.ts +1 -1
  84. package/src/kernel/errors.ts +56 -5
  85. package/src/kernel/fx.test.ts +27 -0
  86. package/src/kernel/fx.ts +74 -18
  87. package/src/kernel/pipeline.test.ts +4 -0
  88. package/src/kernel/pipeline.ts +1 -1
  89. package/src/kernel/plugin.ts +16 -0
  90. package/src/kernel/registry.ts +15 -0
  91. package/src/plugins/auth/shared.ts +5 -1
  92. package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
  93. package/src/plugins/auth-methods.security.test.ts +12 -10
  94. package/src/plugins/email-otp.ts +54 -1
  95. package/src/plugins/index.ts +16 -2
  96. package/src/plugins/magic-link.ts +63 -3
  97. package/src/plugins/username-policy.test.ts +302 -0
  98. package/src/plugins/username.ts +290 -9
  99. package/src/release/exports.test.ts +26 -0
  100. package/src/release/exports.ts +64 -5
  101. package/src/release/index.ts +5 -0
  102. package/src/release/measure.exports.test.ts +13 -1
  103. package/src/release/measure.ts +84 -14
  104. package/src/release/official-plugins.ts +46 -0
  105. package/src/release/readme.test.ts +30 -2
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ICU MessageFormat formatting via FormatJS (`intl-messageformat`).
3
+ *
4
+ * Supports interpolation, cardinal/ordinal plurals, `select` / `selectordinal`,
5
+ * and rich-text tags (`<tag>…</tag>` with function values).
6
+ */
7
+
8
+ import IntlMessageFormat from "intl-messageformat";
9
+ import type { MessageValues } from "./types.ts";
10
+
11
+ const cache = new Map<string, IntlMessageFormat>();
12
+
13
+ /**
14
+ * Clear the compiled-message cache (tests only).
15
+ */
16
+ export function clearMessageFormatCache(): void {
17
+ cache.clear();
18
+ }
19
+
20
+ /**
21
+ * Format an ICU message for a locale.
22
+ *
23
+ * @param message - ICU MessageFormat source
24
+ * @param locale - BCP 47 locale tag
25
+ * @param values - Interpolation / plural / select / rich-text values
26
+ */
27
+ export function formatMessage(message: string, locale: string, values?: MessageValues): string {
28
+ const cacheKey = `${locale}\0${message}`;
29
+ let formatter = cache.get(cacheKey);
30
+ if (!formatter) {
31
+ try {
32
+ formatter = new IntlMessageFormat(message, locale);
33
+ } catch {
34
+ // Malformed ICU — treat as a plain template.
35
+ return message;
36
+ }
37
+ cache.set(cacheKey, formatter);
38
+ }
39
+
40
+ try {
41
+ const result = formatter.format(values as Record<string, unknown> | undefined);
42
+ return partsToString(result);
43
+ } catch {
44
+ // Missing args — leave the source for callers that fall back.
45
+ return message;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Collapse FormatJS rich-text / string output to a single string.
51
+ *
52
+ * @param result - `format()` return value
53
+ */
54
+ function partsToString(result: unknown): string {
55
+ if (typeof result === "string") return result;
56
+ if (typeof result === "number" || typeof result === "boolean") return String(result);
57
+ if (Array.isArray(result)) {
58
+ return result
59
+ .map((part) => {
60
+ if (typeof part === "string" || typeof part === "number") return String(part);
61
+ return "";
62
+ })
63
+ .join("");
64
+ }
65
+ if (result == null) return "";
66
+ return String(result);
67
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * App i18n — ICU message catalogs for `fx.t`, typed keys, locale helpers.
3
+ *
4
+ * Channel locale resolution lives in `elements/channel/locale.ts`; this
5
+ * module owns Flow-facing string catalogs registered via {@link defineLocale}.
6
+ */
7
+
8
+ export { clearMessageFormatCache, formatMessage } from "./format.ts";
9
+
10
+ export {
11
+ catalogReasonKey,
12
+ failureMessageValues,
13
+ isCatalogReason,
14
+ resolveFailureMessage,
15
+ } from "./failure-message.ts";
16
+
17
+ export {
18
+ getActiveDefaultLocale,
19
+ getActiveLocale,
20
+ getLocaleContext,
21
+ runWithLocale,
22
+ type LocaleContext,
23
+ } from "./locale-context.ts";
24
+
25
+ export {
26
+ clearMessageCatalogs,
27
+ defineLocale,
28
+ defineMessages,
29
+ flattenMessages,
30
+ getMessageCatalogs,
31
+ interpolateMessage,
32
+ matchConfiguredLocale,
33
+ translate,
34
+ type MessageCatalog,
35
+ type MessageCatalogs,
36
+ type TranslateOptions,
37
+ } from "./messages.ts";
38
+
39
+ export type {
40
+ AppMessageKey,
41
+ FlattenKeys,
42
+ MessageTree,
43
+ MessageValue,
44
+ MessageValues,
45
+ MessagesFor,
46
+ Register,
47
+ } from "./types.ts";
48
+
49
+ export {
50
+ formatLocaleChain,
51
+ isRtlLocale,
52
+ parseAcceptLanguage,
53
+ resolveLocale,
54
+ type LocaleChainStep,
55
+ type LocaleResolution,
56
+ type ResolveLocaleOptions,
57
+ } from "../elements/channel/locale.ts";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Request-scoped locale for {@link fail} / {@link OkeError} localization.
3
+ */
4
+
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+
7
+ /** Active locale bag for the current async context. */
8
+ export interface LocaleContext {
9
+ readonly locale: string;
10
+ readonly defaultLocale: string;
11
+ }
12
+
13
+ const storage = new AsyncLocalStorage<LocaleContext>();
14
+
15
+ /**
16
+ * Run `fn` with an active locale context (request / invocation scope).
17
+ *
18
+ * @param ctx - Locale + default
19
+ * @param fn - Work to run
20
+ */
21
+ export function runWithLocale<T>(ctx: LocaleContext, fn: () => T): T {
22
+ return storage.run(ctx, fn);
23
+ }
24
+
25
+ /**
26
+ * Active locale context, if any.
27
+ */
28
+ export function getLocaleContext(): LocaleContext | undefined {
29
+ return storage.getStore();
30
+ }
31
+
32
+ /**
33
+ * Active locale, or `fallback` when none is set.
34
+ *
35
+ * @param fallback - Default when outside a request (default `"en"`)
36
+ */
37
+ export function getActiveLocale(fallback = "en"): string {
38
+ return storage.getStore()?.locale ?? fallback;
39
+ }
40
+
41
+ /**
42
+ * Active default locale for catalog fallback.
43
+ *
44
+ * @param fallback - Default when outside a request (default `"en"`)
45
+ */
46
+ export function getActiveDefaultLocale(fallback = "en"): string {
47
+ return storage.getStore()?.defaultLocale ?? fallback;
48
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clearMessageFormatCache, formatMessage } from "./format.ts";
3
+ import {
4
+ clearMessageCatalogs,
5
+ defineLocale,
6
+ defineMessages,
7
+ flattenMessages,
8
+ getMessageCatalogs,
9
+ matchConfiguredLocale,
10
+ translate,
11
+ } from "./messages.ts";
12
+ import type { FlattenKeys, MessagesFor } from "./types.ts";
13
+
14
+ describe("flattenMessages", () => {
15
+ test("flattens nested trees with dot keys", () => {
16
+ expect(
17
+ flattenMessages({
18
+ errors: { notFound: "Missing" },
19
+ hello: "Hi",
20
+ }),
21
+ ).toEqual({
22
+ "errors.notFound": "Missing",
23
+ hello: "Hi",
24
+ });
25
+ });
26
+ });
27
+
28
+ describe("defineLocale / getMessageCatalogs", () => {
29
+ test("registers overlays that win over built-ins", () => {
30
+ clearMessageCatalogs();
31
+ defineLocale("en", { greeting: "Hello, {name}" });
32
+ defineLocale("ar", { greeting: "مرحباً، {name}" });
33
+ const catalogs = getMessageCatalogs();
34
+ expect(catalogs.en?.greeting).toBe("Hello, {name}");
35
+ expect(catalogs.ar?.greeting).toBe("مرحباً، {name}");
36
+ // Built-ins remain available under the same locale.
37
+ expect(catalogs.en?.["errors.Unauthorized"]).toBe("Authentication required.");
38
+ defineLocale("en", { greeting: "Hi, {name}" });
39
+ expect(getMessageCatalogs().en?.greeting).toBe("Hi, {name}");
40
+ clearMessageCatalogs();
41
+ });
42
+ });
43
+
44
+ describe("formatMessage — ICU", () => {
45
+ test("interpolates simple placeholders", () => {
46
+ clearMessageFormatCache();
47
+ expect(formatMessage("Hello, {name}", "en", { name: "Ada" })).toBe("Hello, Ada");
48
+ });
49
+
50
+ test("cardinal plurals", () => {
51
+ clearMessageFormatCache();
52
+ const msg = "{count, plural, =0 {no items} one {# item} other {# items}}";
53
+ expect(formatMessage(msg, "en", { count: 0 })).toBe("no items");
54
+ expect(formatMessage(msg, "en", { count: 1 })).toBe("1 item");
55
+ expect(formatMessage(msg, "en", { count: 5 })).toBe("5 items");
56
+ });
57
+
58
+ test("ordinal plurals via selectordinal", () => {
59
+ clearMessageFormatCache();
60
+ const msg = "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}!";
61
+ expect(formatMessage(msg, "en", { place: 1 })).toBe("You finished 1st!");
62
+ expect(formatMessage(msg, "en", { place: 2 })).toBe("You finished 2nd!");
63
+ expect(formatMessage(msg, "en", { place: 3 })).toBe("You finished 3rd!");
64
+ expect(formatMessage(msg, "en", { place: 11 })).toBe("You finished 11th!");
65
+ });
66
+
67
+ test("enum select", () => {
68
+ clearMessageFormatCache();
69
+ const msg = "{status, select, online {Online} offline {Offline} other {Unknown}}";
70
+ expect(formatMessage(msg, "en", { status: "online" })).toBe("Online");
71
+ expect(formatMessage(msg, "en", { status: "offline" })).toBe("Offline");
72
+ expect(formatMessage(msg, "en", { status: "away" })).toBe("Unknown");
73
+ });
74
+
75
+ test("rich text tags via function values", () => {
76
+ clearMessageFormatCache();
77
+ const msg = "Read <docs>the docs</docs>.";
78
+ expect(
79
+ formatMessage(msg, "en", {
80
+ docs: (chunks) => `[${chunks.join("")}]`,
81
+ }),
82
+ ).toBe("Read [the docs].");
83
+ });
84
+
85
+ test("Arabic plural sample", () => {
86
+ clearMessageFormatCache();
87
+ const msg =
88
+ "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصراً} other {# عنصر}}";
89
+ expect(formatMessage(msg, "ar", { count: 0 })).toBe("لا عناصر");
90
+ expect(formatMessage(msg, "ar", { count: 1 })).toBe("عنصر واحد");
91
+ expect(formatMessage(msg, "ar", { count: 2 })).toBe("عنصران");
92
+ });
93
+ });
94
+
95
+ describe("translate", () => {
96
+ const catalogs = {
97
+ en: {
98
+ "errors.notFound": "Not found",
99
+ greeting: "Hello, {name}",
100
+ items: "{count, plural, one {# item} other {# items}}",
101
+ },
102
+ ar: {
103
+ greeting: "مرحباً، {name}",
104
+ },
105
+ };
106
+
107
+ test("uses locale, then default, then key", () => {
108
+ expect(
109
+ translate({
110
+ locale: "ar",
111
+ defaultLocale: "en",
112
+ catalogs,
113
+ key: "greeting",
114
+ values: { name: "Ada" },
115
+ }),
116
+ ).toBe("مرحباً، Ada");
117
+ expect(
118
+ translate({
119
+ locale: "ar",
120
+ defaultLocale: "en",
121
+ catalogs,
122
+ key: "errors.notFound",
123
+ }),
124
+ ).toBe("Not found");
125
+ expect(
126
+ translate({
127
+ locale: "en",
128
+ defaultLocale: "en",
129
+ catalogs,
130
+ key: "items",
131
+ values: { count: 3 },
132
+ }),
133
+ ).toBe("3 items");
134
+ expect(
135
+ translate({
136
+ locale: "ar",
137
+ defaultLocale: "en",
138
+ catalogs,
139
+ key: "missing",
140
+ values: { x: 1 },
141
+ }),
142
+ ).toBe('missing:{"x":1}');
143
+ });
144
+ });
145
+
146
+ describe("matchConfiguredLocale", () => {
147
+ test("matches exact, base tag, then default", () => {
148
+ const locales = ["en", "ar"] as const;
149
+ expect(matchConfiguredLocale("ar", locales, "en")).toBe("ar");
150
+ expect(matchConfiguredLocale("ar-SA", locales, "en")).toBe("ar");
151
+ expect(matchConfiguredLocale("fr", locales, "en")).toBe("en");
152
+ expect(matchConfiguredLocale(undefined, locales, "en")).toBe("en");
153
+ });
154
+ });
155
+
156
+ describe("type helpers (compile-time shapes)", () => {
157
+ test("defineMessages preserves tree; MessagesFor aligns locales", () => {
158
+ const en = defineMessages({
159
+ errors: { notFound: "Not found" },
160
+ greeting: "Hello, {name}",
161
+ });
162
+ const ar = {
163
+ errors: { notFound: "غير موجود" },
164
+ greeting: "مرحباً، {name}",
165
+ } satisfies MessagesFor<typeof en>;
166
+
167
+ type Keys = FlattenKeys<typeof en>;
168
+ const key: Keys = "errors.notFound";
169
+ expect(en.errors.notFound).toBe("Not found");
170
+ expect(ar.greeting).toContain("{name}");
171
+ expect(key).toBe("errors.notFound");
172
+ });
173
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * App message catalogs for {@link Fx.t}.
3
+ *
4
+ * Locales register via {@link defineLocale}; boot wires catalogs into `fx`
5
+ * with the active request locale and the `i18n.default` fallback from
6
+ * `oke.config.ts`. Messages use ICU MessageFormat syntax.
7
+ *
8
+ * Built-in EN/AR catalogs (framework OKE codes + typed failures) are always
9
+ * present; app keys override built-ins for the same locale/key.
10
+ */
11
+
12
+ import { builtinAr } from "./catalogs/ar.ts";
13
+ import { builtinEn } from "./catalogs/en.ts";
14
+ import { formatMessage } from "./format.ts";
15
+ import type { MessageTree, MessageValues } from "./types.ts";
16
+
17
+ export type { MessageTree, MessageValues } from "./types.ts";
18
+
19
+ /** Flat key → message map after {@link flattenMessages}. */
20
+ export type MessageCatalog = Readonly<Record<string, string>>;
21
+
22
+ /** All registered locale catalogs (locale → flat map). */
23
+ export type MessageCatalogs = Readonly<Record<string, MessageCatalog>>;
24
+
25
+ /**
26
+ * Flatten a nested message tree into dot-separated keys.
27
+ *
28
+ * @param tree - Nested or flat messages
29
+ * @param prefix - Key prefix for recursion
30
+ */
31
+ export function flattenMessages(tree: MessageTree, prefix = ""): Record<string, string> {
32
+ const out: Record<string, string> = {};
33
+ for (const [key, value] of Object.entries(tree)) {
34
+ const path = prefix ? `${prefix}.${key}` : key;
35
+ if (typeof value === "string") {
36
+ out[path] = value;
37
+ } else {
38
+ Object.assign(out, flattenMessages(value, path));
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** App-registered overlays (win over built-ins per key). */
45
+ const registry = new Map<string, Record<string, string>>();
46
+
47
+ /** Built-in EN/AR catalogs (framework + typed failures). */
48
+ const builtins: Readonly<Record<string, MessageCatalog>> = {
49
+ en: flattenMessages(builtinEn as unknown as MessageTree),
50
+ ar: flattenMessages(builtinAr as unknown as MessageTree),
51
+ };
52
+
53
+ /**
54
+ * Identity helper that preserves a `const` message tree for typing.
55
+ *
56
+ * @param messages - Canonical catalog (usually English)
57
+ */
58
+ export function defineMessages<const T extends MessageTree>(messages: T): T {
59
+ return messages;
60
+ }
61
+
62
+ /**
63
+ * Register (or replace) messages for a locale.
64
+ *
65
+ * Side-effect import from `src/locales/<locale>.ts` before boot.
66
+ * Prefer `as const` / {@link defineMessages} on the default locale, then
67
+ * `satisfies MessagesFor<typeof en>` on translations so keys stay aligned.
68
+ *
69
+ * @param locale - BCP 47 locale tag (e.g. `"en"`, `"ar"`)
70
+ * @param messages - Nested or flat ICU message tree
71
+ */
72
+ export function defineLocale<const T extends MessageTree>(locale: string, messages: T): T {
73
+ const tag = locale.trim();
74
+ if (!tag) throw new Error("defineLocale: locale must be non-empty");
75
+ registry.set(tag, flattenMessages(messages));
76
+ return messages;
77
+ }
78
+
79
+ /**
80
+ * Snapshot of every catalog — built-ins merged under app overlays.
81
+ */
82
+ export function getMessageCatalogs(): MessageCatalogs {
83
+ const locales = new Set([...Object.keys(builtins), ...registry.keys()]);
84
+ const out: Record<string, MessageCatalog> = {};
85
+ for (const locale of locales) {
86
+ out[locale] = { ...(builtins[locale] ?? {}), ...(registry.get(locale) ?? {}) };
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Clear app-registered catalogs (tests only). Built-ins remain.
93
+ */
94
+ export function clearMessageCatalogs(): void {
95
+ registry.clear();
96
+ }
97
+
98
+ /**
99
+ * @deprecated Use ICU `{name}` via {@link formatMessage} / `fx.t`. Kept for
100
+ * channel-style `{{name}}` callers during migration; not used by `fx.t`.
101
+ *
102
+ * @param template - Message template
103
+ * @param params - Replacement values
104
+ */
105
+ export function interpolateMessage(
106
+ template: string,
107
+ params?: Readonly<Record<string, unknown>>,
108
+ ): string {
109
+ if (!params) return template;
110
+ return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
111
+ const value = params[name];
112
+ return value === undefined || value === null ? `{{${name}}}` : String(value);
113
+ });
114
+ }
115
+
116
+ /** Options for {@link translate}. */
117
+ export interface TranslateOptions {
118
+ readonly locale: string;
119
+ readonly defaultLocale: string;
120
+ readonly catalogs: MessageCatalogs;
121
+ readonly key: string;
122
+ readonly values?: MessageValues;
123
+ /** @deprecated Prefer {@link TranslateOptions.values}. */
124
+ readonly params?: MessageValues;
125
+ }
126
+
127
+ /**
128
+ * Resolve a message key through locale → default → key fallback, then format
129
+ * with ICU MessageFormat.
130
+ *
131
+ * @param options - Locale, catalogs, key, values
132
+ */
133
+ export function translate(options: TranslateOptions): string {
134
+ const { locale, defaultLocale, catalogs, key } = options;
135
+ const values = options.values ?? options.params;
136
+ const primary = catalogs[locale]?.[key];
137
+ if (primary !== undefined) return formatMessage(primary, locale, values);
138
+ if (locale !== defaultLocale) {
139
+ const fallback = catalogs[defaultLocale]?.[key];
140
+ if (fallback !== undefined) return formatMessage(fallback, defaultLocale, values);
141
+ }
142
+ if (values === undefined) return key;
143
+ return `${key}:${JSON.stringify(values)}`;
144
+ }
145
+
146
+ /**
147
+ * Pick the best catalog locale from a candidate against configured locales.
148
+ *
149
+ * Exact match, then language subtag (`ar-SA` → `ar`), then default.
150
+ *
151
+ * @param candidate - Requested locale (may be undefined)
152
+ * @param locales - Configured locales
153
+ * @param defaultLocale - Fallback locale
154
+ */
155
+ export function matchConfiguredLocale(
156
+ candidate: string | undefined,
157
+ locales: readonly string[],
158
+ defaultLocale: string,
159
+ ): string {
160
+ if (!candidate?.trim()) return defaultLocale;
161
+ const tag = candidate.trim();
162
+ if (locales.includes(tag)) return tag;
163
+ const base = tag.toLowerCase().split("-")[0] ?? tag;
164
+ const byBase = locales.find(
165
+ (l) => l.toLowerCase() === base || l.toLowerCase().startsWith(`${base}-`),
166
+ );
167
+ if (byBase) return byBase;
168
+ return defaultLocale;
169
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Type-level helpers for message catalogs — keys, shapes, Register slot.
3
+ */
4
+
5
+ /** Nested or flat message tree (leaf values are ICU message strings). */
6
+ export type MessageTree = {
7
+ readonly [key: string]: string | MessageTree;
8
+ };
9
+
10
+ /**
11
+ * Flatten a message tree into dot-separated key unions.
12
+ *
13
+ * @typeParam T - Message tree
14
+ * @typeParam Prefix - Accumulated path prefix
15
+ */
16
+ export type FlattenKeys<T, Prefix extends string = ""> = {
17
+ [K in keyof T & string]: T[K] extends string
18
+ ? Prefix extends ""
19
+ ? K
20
+ : `${Prefix}.${K}`
21
+ : T[K] extends MessageTree
22
+ ? FlattenKeys<T[K], Prefix extends "" ? K : `${Prefix}.${K}`>
23
+ : never;
24
+ }[keyof T & string];
25
+
26
+ /**
27
+ * Same key structure as {@link Base}; every leaf is an ICU message string.
28
+ * Use with `satisfies` so Arabic (etc.) cannot drift from English keys.
29
+ *
30
+ * @typeParam Base - Canonical (usually English) tree
31
+ */
32
+ export type MessagesFor<Base extends MessageTree> = {
33
+ [K in keyof Base]: Base[K] extends string
34
+ ? string
35
+ : Base[K] extends MessageTree
36
+ ? MessagesFor<Base[K]>
37
+ : never;
38
+ };
39
+
40
+ /**
41
+ * Module-augmentation slot for typed `fx.t` keys.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const en = { greeting: "Hello, {name}" } as const;
46
+ * defineLocale("en", en);
47
+ *
48
+ * declare module "okengine" {
49
+ * interface Register {
50
+ * messages: typeof en;
51
+ * }
52
+ * }
53
+ * ```
54
+ */
55
+ export interface Register {
56
+ // intentionally empty — augmented by the app
57
+ }
58
+
59
+ /** Messages from {@link Register}, when the app augments them. */
60
+ type MessagesFromRegister = Register extends { readonly messages: infer M }
61
+ ? M extends MessageTree
62
+ ? M
63
+ : never
64
+ : never;
65
+
66
+ /**
67
+ * Autocomplete / compile-time keys for {@link Fx.t}.
68
+ * Falls back to `string` until the app augments {@link Register}.
69
+ */
70
+ export type AppMessageKey = [MessagesFromRegister] extends [never]
71
+ ? string
72
+ : FlattenKeys<MessagesFromRegister>;
73
+
74
+ /**
75
+ * Values passed to ICU messages — primitives plus rich-text tag functions.
76
+ *
77
+ * Tag functions receive the formatted chunks inside `<tag>…</tag>` and return
78
+ * a string (or stringable) replacement.
79
+ */
80
+ export type MessageValue =
81
+ | string
82
+ | number
83
+ | boolean
84
+ | Date
85
+ | null
86
+ | undefined
87
+ | ((chunks: readonly string[]) => string);
88
+
89
+ /** Named values for one `fx.t` / {@link formatMessage} call. */
90
+ export type MessageValues = Readonly<Record<string, MessageValue>>;
package/src/index.ts CHANGED
@@ -176,6 +176,32 @@ export {
176
176
  type ContextInference,
177
177
  } from "./compiler/index.ts";
178
178
 
179
+ export {
180
+ defineLocale,
181
+ defineMessages,
182
+ flattenMessages,
183
+ formatMessage,
184
+ getMessageCatalogs,
185
+ interpolateMessage,
186
+ isRtlLocale,
187
+ matchConfiguredLocale,
188
+ parseAcceptLanguage,
189
+ resolveFailureMessage,
190
+ resolveLocale,
191
+ runWithLocale,
192
+ translate,
193
+ type AppMessageKey,
194
+ type FlattenKeys,
195
+ type LocaleContext,
196
+ type MessageCatalog,
197
+ type MessageCatalogs,
198
+ type MessageTree,
199
+ type MessageValue,
200
+ type MessageValues,
201
+ type MessagesFor,
202
+ type Register,
203
+ } from "./i18n/index.ts";
204
+
179
205
  export {
180
206
  createBunRuntime,
181
207
  createWebStandardRuntime,