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.
- package/README.md +148 -13
- package/package.json +4 -3
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +77 -8
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +12 -4
- package/site/content/docs/reference/environment-variables.mdx +42 -13
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
- package/src/drivers/ai-ollama.integration.test.ts +181 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +25 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/index.ts +9 -0
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +84 -14
- package/src/release/official-plugins.ts +46 -0
- 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,
|