@junejs/i18n 0.0.29-dev.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/dist/codegen.d.ts +22 -0
- package/dist/codegen.js +80 -0
- package/dist/compile.d.ts +13 -0
- package/dist/compile.js +45 -0
- package/dist/format-C_VeKYVf.d.ts +9 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +42 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +97 -0
- package/dist/rich.d.ts +9 -0
- package/dist/rich.js +51 -0
- package/package.json +42 -0
- package/src/codegen.ts +105 -0
- package/src/compile.ts +64 -0
- package/src/format.ts +70 -0
- package/src/index.ts +168 -0
- package/src/rich.ts +82 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { t as CompiledCatalog } from "./format-C_VeKYVf.js";
|
|
2
|
+
import "./compile.js";
|
|
3
|
+
//#region src/codegen.d.ts
|
|
4
|
+
/** Read + compile messages → compiled catalogs keyed by locale. Two layouts,
|
|
5
|
+
* mergeable: flat `messages/<locale>.json` (root keys) and namespaced
|
|
6
|
+
* `messages/<locale>/<ns>.json` (keys prefixed `<ns>.`). The runtime half of the
|
|
7
|
+
* codegen, exposed for tests. */
|
|
8
|
+
declare function loadMessages(messagesDir: string): {
|
|
9
|
+
catalogs: Record<string, CompiledCatalog>;
|
|
10
|
+
raw: Record<string, Record<string, string>>;
|
|
11
|
+
locales: string[];
|
|
12
|
+
};
|
|
13
|
+
/** Generate the frozen `_messages.ts` module text from `messages/`. The default
|
|
14
|
+
* locale is the type source of truth (its keys define `Messages`). */
|
|
15
|
+
declare function generateMessagesModule(messagesDir: string, opts: {
|
|
16
|
+
defaultLocale: string;
|
|
17
|
+
}): {
|
|
18
|
+
code: string;
|
|
19
|
+
locales: string[];
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { generateMessagesModule, loadMessages };
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { compileCatalog, deriveParams } from "./compile.js";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/codegen.ts
|
|
5
|
+
const TS_TYPE = {
|
|
6
|
+
string: "string",
|
|
7
|
+
number: "number",
|
|
8
|
+
Date: "Date",
|
|
9
|
+
tag: "(chunks: ReactNode) => ReactNode"
|
|
10
|
+
};
|
|
11
|
+
/** Read + compile messages → compiled catalogs keyed by locale. Two layouts,
|
|
12
|
+
* mergeable: flat `messages/<locale>.json` (root keys) and namespaced
|
|
13
|
+
* `messages/<locale>/<ns>.json` (keys prefixed `<ns>.`). The runtime half of the
|
|
14
|
+
* codegen, exposed for tests. */
|
|
15
|
+
function loadMessages(messagesDir) {
|
|
16
|
+
const raw = {};
|
|
17
|
+
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
const merge = (locale, entries) => Object.assign(raw[locale] ??= {}, entries);
|
|
19
|
+
for (const ent of readdirSync(messagesDir, { withFileTypes: true })) if (ent.isFile() && ent.name.endsWith(".json")) merge(ent.name.replace(/\.json$/, ""), readJson(join(messagesDir, ent.name)));
|
|
20
|
+
else if (ent.isDirectory()) {
|
|
21
|
+
const locale = ent.name;
|
|
22
|
+
const sub = join(messagesDir, locale);
|
|
23
|
+
for (const f of readdirSync(sub).filter((x) => x.endsWith(".json"))) {
|
|
24
|
+
const ns = f.replace(/\.json$/, "");
|
|
25
|
+
const prefixed = {};
|
|
26
|
+
for (const [k, v] of Object.entries(readJson(join(sub, f)))) prefixed[`${ns}.${k}`] = v;
|
|
27
|
+
merge(locale, prefixed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const catalogs = {};
|
|
31
|
+
for (const [locale, entries] of Object.entries(raw)) catalogs[locale] = compileCatalog(entries);
|
|
32
|
+
return {
|
|
33
|
+
catalogs,
|
|
34
|
+
raw,
|
|
35
|
+
locales: Object.keys(catalogs)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function paramsType(catalog, key) {
|
|
39
|
+
const params = deriveParams(catalog[key]);
|
|
40
|
+
const names = Object.keys(params);
|
|
41
|
+
if (names.length === 0) return "Record<never, never>";
|
|
42
|
+
return `{ ${names.map((n) => `${JSON.stringify(n)}: ${TS_TYPE[params[n]]}`).join("; ")} }`;
|
|
43
|
+
}
|
|
44
|
+
/** Generate the frozen `_messages.ts` module text from `messages/`. The default
|
|
45
|
+
* locale is the type source of truth (its keys define `Messages`). */
|
|
46
|
+
function generateMessagesModule(messagesDir, opts) {
|
|
47
|
+
const { catalogs, locales } = loadMessages(messagesDir);
|
|
48
|
+
const defaultCatalog = catalogs[opts.defaultLocale] ?? {};
|
|
49
|
+
const entries = Object.keys(defaultCatalog).sort().map((key) => ` ${JSON.stringify(key)}: ${paramsType(defaultCatalog, key)};`).join("\n");
|
|
50
|
+
return {
|
|
51
|
+
code: `// AUTO-GENERATED by \`june gen\` — edit messages/*.json, not this file.
|
|
52
|
+
import { defineMessages, t as rawT, type CompiledCatalog } from "@junejs/i18n";
|
|
53
|
+
import type { ReactNode } from "react";
|
|
54
|
+
|
|
55
|
+
const CATALOGS: Record<string, CompiledCatalog> = ${JSON.stringify(catalogs)};\n\ndefineMessages(CATALOGS, { defaultLocale: ${JSON.stringify(opts.defaultLocale)} });\n\n// The message keys + their params, derived from the default locale's ICU ASTs.
|
|
56
|
+
export type Messages = {\n${entries}\n};\n\n// A key with a <tag> param has a function param → it must go through t.rich.
|
|
57
|
+
type HasTag<P> = true extends
|
|
58
|
+
{ [K in keyof P]: P[K] extends (...a: never[]) => unknown ? true : never }[keyof P]
|
|
59
|
+
? true : false;
|
|
60
|
+
type PlainKey = { [K in keyof Messages]: HasTag<Messages[K]> extends true ? never : K }[keyof Messages];
|
|
61
|
+
|
|
62
|
+
// Typed translate: key AND params are checked against messages/*.json. A no-
|
|
63
|
+
// param message takes no second arg; a wrong/missing param or unknown key is a
|
|
64
|
+
// compile error. t.rich renders <tag>s to ReactNode (tag keys require it).
|
|
65
|
+
export const t = rawT as unknown as {
|
|
66
|
+
<K extends PlainKey>(
|
|
67
|
+
key: K,
|
|
68
|
+
...args: keyof Messages[K] extends never ? [] : [params: Messages[K]]
|
|
69
|
+
): string;
|
|
70
|
+
rich: <K extends keyof Messages>(
|
|
71
|
+
key: K,
|
|
72
|
+
...args: keyof Messages[K] extends never ? [] : [params: Messages[K]]
|
|
73
|
+
) => ReactNode;
|
|
74
|
+
};
|
|
75
|
+
`,
|
|
76
|
+
locales
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { generateMessagesModule, loadMessages };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { n as CompiledMessage, t as CompiledCatalog } from "./format-C_VeKYVf.js";
|
|
2
|
+
//#region src/compile.d.ts
|
|
3
|
+
/** Parse one ICU string to its AST. */
|
|
4
|
+
declare function parseMessage(icu: string): CompiledMessage;
|
|
5
|
+
/** Parse a whole `{ key: icuString }` catalog to ASTs. */
|
|
6
|
+
declare function compileCatalog(raw: Record<string, string>): CompiledCatalog;
|
|
7
|
+
type ParamType = "string" | "number" | "Date" | "tag";
|
|
8
|
+
/** The params a message needs, derived from its AST. Plural/number args are
|
|
9
|
+
* numbers, date/time are Dates, plain `{x}` and select keys are strings. This is
|
|
10
|
+
* what the codegen turns into `t`'s typed signature. */
|
|
11
|
+
declare function deriveParams(msg: CompiledMessage, into?: Record<string, ParamType>): Record<string, ParamType>;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { type CompiledCatalog, type CompiledMessage, ParamType, compileCatalog, deriveParams, parseMessage };
|
package/dist/compile.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { TYPE, parse } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
//#region src/compile.ts
|
|
3
|
+
/** Parse one ICU string to its AST. */
|
|
4
|
+
function parseMessage(icu) {
|
|
5
|
+
return parse(icu);
|
|
6
|
+
}
|
|
7
|
+
/** Parse a whole `{ key: icuString }` catalog to ASTs. */
|
|
8
|
+
function compileCatalog(raw) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [key, icu] of Object.entries(raw)) out[key] = parseMessage(icu);
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** The params a message needs, derived from its AST. Plural/number args are
|
|
14
|
+
* numbers, date/time are Dates, plain `{x}` and select keys are strings. This is
|
|
15
|
+
* what the codegen turns into `t`'s typed signature. */
|
|
16
|
+
function deriveParams(msg, into = {}) {
|
|
17
|
+
for (const el of msg) switch (el.type) {
|
|
18
|
+
case TYPE.argument:
|
|
19
|
+
into[el.value] ??= "string";
|
|
20
|
+
break;
|
|
21
|
+
case TYPE.number:
|
|
22
|
+
into[el.value] = "number";
|
|
23
|
+
break;
|
|
24
|
+
case TYPE.date:
|
|
25
|
+
case TYPE.time:
|
|
26
|
+
into[el.value] = "Date";
|
|
27
|
+
break;
|
|
28
|
+
case TYPE.plural:
|
|
29
|
+
into[el.value] = "number";
|
|
30
|
+
for (const o of Object.values(el.options)) deriveParams(o.value, into);
|
|
31
|
+
break;
|
|
32
|
+
case TYPE.select:
|
|
33
|
+
into[el.value] ??= "string";
|
|
34
|
+
for (const o of Object.values(el.options)) deriveParams(o.value, into);
|
|
35
|
+
break;
|
|
36
|
+
case TYPE.tag:
|
|
37
|
+
into[el.value] = "tag";
|
|
38
|
+
deriveParams(el.children, into);
|
|
39
|
+
break;
|
|
40
|
+
default: break;
|
|
41
|
+
}
|
|
42
|
+
return into;
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { compileCatalog, deriveParams, parseMessage };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MessageFormatElement } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
//#region src/format.d.ts
|
|
3
|
+
/** A compiled message: the parsed ICU AST (what defineMessages ships). */
|
|
4
|
+
type CompiledMessage = MessageFormatElement[];
|
|
5
|
+
type CompiledCatalog = Record<string, CompiledMessage>;
|
|
6
|
+
/** Format a compiled message in `locale` with `params`. */
|
|
7
|
+
declare function formatMessage(msg: CompiledMessage, locale: string, params?: Record<string, unknown>): string;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { CompiledMessage as n, formatMessage as r, CompiledCatalog as t };
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TYPE } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
//#region src/format.ts
|
|
3
|
+
const num = (locale, n) => new Intl.NumberFormat(locale).format(n);
|
|
4
|
+
function evaluate(els, locale, params, pound) {
|
|
5
|
+
let out = "";
|
|
6
|
+
for (const el of els) switch (el.type) {
|
|
7
|
+
case TYPE.literal:
|
|
8
|
+
out += el.value;
|
|
9
|
+
break;
|
|
10
|
+
case TYPE.argument:
|
|
11
|
+
out += el.value in params ? String(params[el.value]) : `{${el.value}}`;
|
|
12
|
+
break;
|
|
13
|
+
case TYPE.number:
|
|
14
|
+
out += num(locale, Number(params[el.value]));
|
|
15
|
+
break;
|
|
16
|
+
case TYPE.pound:
|
|
17
|
+
out += num(locale, pound ?? 0);
|
|
18
|
+
break;
|
|
19
|
+
case TYPE.plural: {
|
|
20
|
+
const n = Number(params[el.value]);
|
|
21
|
+
const exact = `=${n}`;
|
|
22
|
+
const cat = el.options[exact] !== void 0 ? exact : new Intl.PluralRules(locale, { type: el.pluralType === "ordinal" ? "ordinal" : "cardinal" }).select(n - el.offset);
|
|
23
|
+
const opt = el.options[cat] ?? el.options.other;
|
|
24
|
+
if (opt) out += evaluate(opt.value, locale, params, n - el.offset);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case TYPE.select: {
|
|
28
|
+
const v = String(params[el.value]);
|
|
29
|
+
const opt = el.options[v] ?? el.options.other;
|
|
30
|
+
if (opt) out += evaluate(opt.value, locale, params, pound);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
default: break;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Format a compiled message in `locale` with `params`. */
|
|
38
|
+
function formatMessage(msg, locale, params) {
|
|
39
|
+
return evaluate(msg, locale, params ?? {});
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { formatMessage };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { n as CompiledMessage, r as formatMessage, t as CompiledCatalog } from "./format-C_VeKYVf.js";
|
|
2
|
+
import { formatRich } from "./rich.js";
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
/** A translator: callable for a plain string, `.rich` for embedded components. */
|
|
6
|
+
type Translator = {
|
|
7
|
+
(key: string, params?: Record<string, unknown>): string;
|
|
8
|
+
rich(key: string, params?: Record<string, unknown>): ReactNode;
|
|
9
|
+
};
|
|
10
|
+
type MessagesConfig = {
|
|
11
|
+
catalogs: Record<string, CompiledCatalog>;
|
|
12
|
+
defaultLocale: string;
|
|
13
|
+
};
|
|
14
|
+
/** Register the compiled message catalogs (called once at app boot). A build step
|
|
15
|
+
* generates this call — with compiled ASTs — from `messages/*.json`. */
|
|
16
|
+
declare function defineMessages(catalogs: Record<string, CompiledCatalog>, opts: {
|
|
17
|
+
defaultLocale: string;
|
|
18
|
+
}): void;
|
|
19
|
+
/** A pure translator bound to a locale + compiled catalogs. The fallback chain is
|
|
20
|
+
* variant → default locale → the key itself (dev-warns on a miss). Exposed for
|
|
21
|
+
* tests and for non-ambient use (islands receive a translator built from props). */
|
|
22
|
+
declare function createTranslator(locale: string, catalogs: Record<string, CompiledCatalog>, defaultLocale: string): Translator;
|
|
23
|
+
/** Ambient translate: resolves the locale from the request scope (`ctx.locale`),
|
|
24
|
+
* then formats `key` from the registered catalogs. Falls back to the default
|
|
25
|
+
* locale, then the key. `t.rich` renders embedded `<tag>`s to ReactNode. Server-
|
|
26
|
+
* side (loaders/views/actions); islands take a translator via props instead. */
|
|
27
|
+
declare const t: Translator;
|
|
28
|
+
/** SERVER: the compiled subset of `keys` for `locale` (defaults to the scope
|
|
29
|
+
* locale), with per-key fallback to the default locale. Pass it as an island
|
|
30
|
+
* prop alongside the locale. */
|
|
31
|
+
declare function pickMessages(keys: string[], locale?: string): CompiledCatalog;
|
|
32
|
+
/** CLIENT: a translator over a one-locale subset (from an island's props). No
|
|
33
|
+
* scope, no registry — just the messages it was handed. */
|
|
34
|
+
declare function clientTranslator(messages: CompiledCatalog, locale: string): Translator;
|
|
35
|
+
declare function __resetMessages(): void;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { type CompiledCatalog, type CompiledMessage, MessagesConfig, Translator, __resetMessages, clientTranslator, createTranslator, defineMessages, formatMessage, formatRich, pickMessages, t };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { formatMessage } from "./format.js";
|
|
2
|
+
import { formatRich } from "./rich.js";
|
|
3
|
+
import { currentLocale } from "@junejs/db";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
let registry = null;
|
|
6
|
+
/** Register the compiled message catalogs (called once at app boot). A build step
|
|
7
|
+
* generates this call — with compiled ASTs — from `messages/*.json`. */
|
|
8
|
+
function defineMessages(catalogs, opts) {
|
|
9
|
+
registry = {
|
|
10
|
+
catalogs,
|
|
11
|
+
defaultLocale: opts.defaultLocale
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const warn = (msg) => {
|
|
15
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[june i18n] ${msg}`);
|
|
16
|
+
};
|
|
17
|
+
const warnedMissing = /* @__PURE__ */ new Set();
|
|
18
|
+
const warnMissing = (key, locale) => {
|
|
19
|
+
const id = `${locale}:${key}`;
|
|
20
|
+
if (warnedMissing.has(id)) return;
|
|
21
|
+
warnedMissing.add(id);
|
|
22
|
+
warn(`missing key "${key}" (locale "${locale}")`);
|
|
23
|
+
};
|
|
24
|
+
/** A pure translator bound to a locale + compiled catalogs. The fallback chain is
|
|
25
|
+
* variant → default locale → the key itself (dev-warns on a miss). Exposed for
|
|
26
|
+
* tests and for non-ambient use (islands receive a translator built from props). */
|
|
27
|
+
function createTranslator(locale, catalogs, defaultLocale) {
|
|
28
|
+
const lookup = (key) => catalogs[locale]?.[key] ?? catalogs[defaultLocale]?.[key];
|
|
29
|
+
const tr = ((key, params) => {
|
|
30
|
+
const msg = lookup(key);
|
|
31
|
+
if (msg === void 0) {
|
|
32
|
+
warnMissing(key, locale);
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
return formatMessage(msg, locale, params);
|
|
36
|
+
});
|
|
37
|
+
tr.rich = (key, params) => {
|
|
38
|
+
const msg = lookup(key);
|
|
39
|
+
if (msg === void 0) {
|
|
40
|
+
warnMissing(key, locale);
|
|
41
|
+
return key;
|
|
42
|
+
}
|
|
43
|
+
return formatRich(msg, locale, params);
|
|
44
|
+
};
|
|
45
|
+
return tr;
|
|
46
|
+
}
|
|
47
|
+
function ambientTranslator() {
|
|
48
|
+
if (!registry) return null;
|
|
49
|
+
return createTranslator(currentLocale() ?? registry.defaultLocale, registry.catalogs, registry.defaultLocale);
|
|
50
|
+
}
|
|
51
|
+
/** Ambient translate: resolves the locale from the request scope (`ctx.locale`),
|
|
52
|
+
* then formats `key` from the registered catalogs. Falls back to the default
|
|
53
|
+
* locale, then the key. `t.rich` renders embedded `<tag>`s to ReactNode. Server-
|
|
54
|
+
* side (loaders/views/actions); islands take a translator via props instead. */
|
|
55
|
+
const t = Object.assign((key, params) => {
|
|
56
|
+
const tr = ambientTranslator();
|
|
57
|
+
if (!tr) {
|
|
58
|
+
warn(`t("${key}") called before defineMessages()`);
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
return tr(key, params);
|
|
62
|
+
}, { rich: (key, params) => {
|
|
63
|
+
const tr = ambientTranslator();
|
|
64
|
+
if (!tr) {
|
|
65
|
+
warn(`t.rich("${key}") called before defineMessages()`);
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
return tr.rich(key, params);
|
|
69
|
+
} });
|
|
70
|
+
/** SERVER: the compiled subset of `keys` for `locale` (defaults to the scope
|
|
71
|
+
* locale), with per-key fallback to the default locale. Pass it as an island
|
|
72
|
+
* prop alongside the locale. */
|
|
73
|
+
function pickMessages(keys, locale) {
|
|
74
|
+
if (!registry) {
|
|
75
|
+
warn(`pickMessages(${JSON.stringify(keys)}) called before defineMessages()`);
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
const loc = locale ?? currentLocale() ?? registry.defaultLocale;
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
const msg = registry.catalogs[loc]?.[key] ?? registry.catalogs[registry.defaultLocale]?.[key];
|
|
82
|
+
if (msg) out[key] = msg;
|
|
83
|
+
else warn(`pickMessages: missing key "${key}"`);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** CLIENT: a translator over a one-locale subset (from an island's props). No
|
|
88
|
+
* scope, no registry — just the messages it was handed. */
|
|
89
|
+
function clientTranslator(messages, locale) {
|
|
90
|
+
return createTranslator(locale, { [locale]: messages }, locale);
|
|
91
|
+
}
|
|
92
|
+
function __resetMessages() {
|
|
93
|
+
registry = null;
|
|
94
|
+
warnedMissing.clear();
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { __resetMessages, clientTranslator, createTranslator, defineMessages, formatMessage, formatRich, pickMessages, t };
|
package/dist/rich.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { n as CompiledMessage } from "./format-C_VeKYVf.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/rich.d.ts
|
|
4
|
+
type RichParams = Record<string, unknown>;
|
|
5
|
+
/** Format a compiled message to ReactNode, rendering `<tag>` elements via the
|
|
6
|
+
* matching `(chunks) => ReactNode` param. */
|
|
7
|
+
declare function formatRich(msg: CompiledMessage, locale: string, params?: RichParams): ReactNode;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { formatRich };
|
package/dist/rich.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TYPE } from "@formatjs/icu-messageformat-parser";
|
|
2
|
+
import { Fragment, createElement } from "react";
|
|
3
|
+
//#region src/rich.ts
|
|
4
|
+
const num = (locale, n) => new Intl.NumberFormat(locale).format(n);
|
|
5
|
+
const keyed = (nodes) => nodes.length === 1 ? nodes[0] : nodes.map((n, i) => createElement(Fragment, { key: i }, n));
|
|
6
|
+
function evaluate(els, locale, params, pound) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const el of els) switch (el.type) {
|
|
9
|
+
case TYPE.literal:
|
|
10
|
+
out.push(el.value);
|
|
11
|
+
break;
|
|
12
|
+
case TYPE.argument:
|
|
13
|
+
out.push(el.value in params ? params[el.value] : `{${el.value}}`);
|
|
14
|
+
break;
|
|
15
|
+
case TYPE.number:
|
|
16
|
+
out.push(num(locale, Number(params[el.value])));
|
|
17
|
+
break;
|
|
18
|
+
case TYPE.pound:
|
|
19
|
+
out.push(num(locale, pound ?? 0));
|
|
20
|
+
break;
|
|
21
|
+
case TYPE.tag: {
|
|
22
|
+
const fn = params[el.value];
|
|
23
|
+
const children = keyed(evaluate(el.children, locale, params, pound));
|
|
24
|
+
out.push(fn ? fn(children) : children);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case TYPE.plural: {
|
|
28
|
+
const n = Number(params[el.value]);
|
|
29
|
+
const exact = `=${n}`;
|
|
30
|
+
const cat = el.options[exact] !== void 0 ? exact : new Intl.PluralRules(locale, { type: el.pluralType === "ordinal" ? "ordinal" : "cardinal" }).select(n - el.offset);
|
|
31
|
+
const opt = el.options[cat] ?? el.options.other;
|
|
32
|
+
if (opt) out.push(...evaluate(opt.value, locale, params, n - el.offset));
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case TYPE.select: {
|
|
36
|
+
const v = String(params[el.value]);
|
|
37
|
+
const opt = el.options[v] ?? el.options.other;
|
|
38
|
+
if (opt) out.push(...evaluate(opt.value, locale, params, pound));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
default: break;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Format a compiled message to ReactNode, rendering `<tag>` elements via the
|
|
46
|
+
* matching `(chunks) => ReactNode` param. */
|
|
47
|
+
function formatRich(msg, locale, params) {
|
|
48
|
+
return keyed(evaluate(msg, locale, params ?? {}));
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { formatRich };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@junejs/i18n",
|
|
3
|
+
"version": "0.0.29-dev.1",
|
|
4
|
+
"description": "June — the agent-native React framework. ICU messages (typed t()).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://june.build",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./codegen": {
|
|
16
|
+
"source": "./src/codegen.ts",
|
|
17
|
+
"bun": "./src/codegen.ts",
|
|
18
|
+
"types": "./dist/codegen.d.ts",
|
|
19
|
+
"default": "./dist/codegen.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"build": "tsdown"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@formatjs/icu-messageformat-parser": "^2.7.0",
|
|
33
|
+
"@junejs/db": ">=0.0.33-dev.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": "^19.2.0"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/junebuild/june"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/codegen.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// BUILD-time codegen for messages (the @junejs/i18n/codegen entry — node:fs lives
|
|
2
|
+
// here, never in the runtime index). Reads `messages/<locale>.json` (ICU strings),
|
|
3
|
+
// compiles each to its AST, and emits a module that:
|
|
4
|
+
// - registers the COMPILED catalogs (defineMessages) — the request bundle ships
|
|
5
|
+
// ASTs + the evaluator, never the parser; and
|
|
6
|
+
// - exports a TYPED `t` whose key AND params are derived from the ICU AST of the
|
|
7
|
+
// default locale — the differentiator (`t("cart.items", { n: number })`).
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { compileCatalog, deriveParams, type CompiledCatalog, type ParamType } from "./compile";
|
|
13
|
+
|
|
14
|
+
const TS_TYPE: Record<ParamType, string> = {
|
|
15
|
+
string: "string",
|
|
16
|
+
number: "number",
|
|
17
|
+
Date: "Date",
|
|
18
|
+
tag: "(chunks: ReactNode) => ReactNode",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Read + compile messages → compiled catalogs keyed by locale. Two layouts,
|
|
22
|
+
* mergeable: flat `messages/<locale>.json` (root keys) and namespaced
|
|
23
|
+
* `messages/<locale>/<ns>.json` (keys prefixed `<ns>.`). The runtime half of the
|
|
24
|
+
* codegen, exposed for tests. */
|
|
25
|
+
export function loadMessages(messagesDir: string): {
|
|
26
|
+
catalogs: Record<string, CompiledCatalog>;
|
|
27
|
+
raw: Record<string, Record<string, string>>;
|
|
28
|
+
locales: string[];
|
|
29
|
+
} {
|
|
30
|
+
const raw: Record<string, Record<string, string>> = {};
|
|
31
|
+
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
|
|
32
|
+
const merge = (locale: string, entries: Record<string, string>) =>
|
|
33
|
+
Object.assign((raw[locale] ??= {}), entries);
|
|
34
|
+
|
|
35
|
+
for (const ent of readdirSync(messagesDir, { withFileTypes: true })) {
|
|
36
|
+
if (ent.isFile() && ent.name.endsWith(".json")) {
|
|
37
|
+
merge(ent.name.replace(/\.json$/, ""), readJson(join(messagesDir, ent.name)));
|
|
38
|
+
} else if (ent.isDirectory()) {
|
|
39
|
+
const locale = ent.name;
|
|
40
|
+
const sub = join(messagesDir, locale);
|
|
41
|
+
for (const f of readdirSync(sub).filter((x) => x.endsWith(".json"))) {
|
|
42
|
+
const ns = f.replace(/\.json$/, "");
|
|
43
|
+
const prefixed: Record<string, string> = {};
|
|
44
|
+
for (const [k, v] of Object.entries(readJson(join(sub, f)))) prefixed[`${ns}.${k}`] = v;
|
|
45
|
+
merge(locale, prefixed);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const catalogs: Record<string, CompiledCatalog> = {};
|
|
51
|
+
for (const [locale, entries] of Object.entries(raw)) catalogs[locale] = compileCatalog(entries);
|
|
52
|
+
return { catalogs, raw, locales: Object.keys(catalogs) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The TS shape of one message's params (from its AST), or Record<string, never>.
|
|
56
|
+
function paramsType(catalog: CompiledCatalog, key: string): string {
|
|
57
|
+
const params = deriveParams(catalog[key]!);
|
|
58
|
+
const names = Object.keys(params);
|
|
59
|
+
// Record<never, never> (not Record<string, never>!) so `keyof` is `never` — the
|
|
60
|
+
// signal that lets the typed `t` take NO second arg for a param-less message.
|
|
61
|
+
if (names.length === 0) return "Record<never, never>";
|
|
62
|
+
return `{ ${names.map((n) => `${JSON.stringify(n)}: ${TS_TYPE[params[n]!]}`).join("; ")} }`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Generate the frozen `_messages.ts` module text from `messages/`. The default
|
|
66
|
+
* locale is the type source of truth (its keys define `Messages`). */
|
|
67
|
+
export function generateMessagesModule(
|
|
68
|
+
messagesDir: string,
|
|
69
|
+
opts: { defaultLocale: string },
|
|
70
|
+
): { code: string; locales: string[] } {
|
|
71
|
+
const { catalogs, locales } = loadMessages(messagesDir);
|
|
72
|
+
const defaultCatalog = catalogs[opts.defaultLocale] ?? {};
|
|
73
|
+
const entries = Object.keys(defaultCatalog)
|
|
74
|
+
.sort()
|
|
75
|
+
.map((key) => ` ${JSON.stringify(key)}: ${paramsType(defaultCatalog, key)};`)
|
|
76
|
+
.join("\n");
|
|
77
|
+
|
|
78
|
+
const code =
|
|
79
|
+
"// AUTO-GENERATED by `june gen` — edit messages/*.json, not this file.\n" +
|
|
80
|
+
'import { defineMessages, t as rawT, type CompiledCatalog } from "@junejs/i18n";\n' +
|
|
81
|
+
'import type { ReactNode } from "react";\n\n' +
|
|
82
|
+
`const CATALOGS: Record<string, CompiledCatalog> = ${JSON.stringify(catalogs)};\n\n` +
|
|
83
|
+
`defineMessages(CATALOGS, { defaultLocale: ${JSON.stringify(opts.defaultLocale)} });\n\n` +
|
|
84
|
+
"// The message keys + their params, derived from the default locale's ICU ASTs.\n" +
|
|
85
|
+
`export type Messages = {\n${entries}\n};\n\n` +
|
|
86
|
+
"// A key with a <tag> param has a function param → it must go through t.rich.\n" +
|
|
87
|
+
"type HasTag<P> = true extends\n" +
|
|
88
|
+
" { [K in keyof P]: P[K] extends (...a: never[]) => unknown ? true : never }[keyof P]\n" +
|
|
89
|
+
" ? true : false;\n" +
|
|
90
|
+
"type PlainKey = { [K in keyof Messages]: HasTag<Messages[K]> extends true ? never : K }[keyof Messages];\n\n" +
|
|
91
|
+
"// Typed translate: key AND params are checked against messages/*.json. A no-\n" +
|
|
92
|
+
"// param message takes no second arg; a wrong/missing param or unknown key is a\n" +
|
|
93
|
+
"// compile error. t.rich renders <tag>s to ReactNode (tag keys require it).\n" +
|
|
94
|
+
"export const t = rawT as unknown as {\n" +
|
|
95
|
+
" <K extends PlainKey>(\n" +
|
|
96
|
+
" key: K,\n" +
|
|
97
|
+
" ...args: keyof Messages[K] extends never ? [] : [params: Messages[K]]\n" +
|
|
98
|
+
" ): string;\n" +
|
|
99
|
+
" rich: <K extends keyof Messages>(\n" +
|
|
100
|
+
" key: K,\n" +
|
|
101
|
+
" ...args: keyof Messages[K] extends never ? [] : [params: Messages[K]]\n" +
|
|
102
|
+
" ) => ReactNode;\n" +
|
|
103
|
+
"};\n";
|
|
104
|
+
return { code, locales };
|
|
105
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// BUILD-time ICU compilation. The @formatjs PARSER lives HERE only (ICU string →
|
|
2
|
+
// AST) — never imported by the runtime (index.ts → format.ts). What ships is the
|
|
3
|
+
// AST + the format.ts evaluator. The same AST also DERIVES the param types (the
|
|
4
|
+
// edge: `t("cart.items", { n: number })` types key AND params).
|
|
5
|
+
|
|
6
|
+
import { parse, TYPE } from "@formatjs/icu-messageformat-parser";
|
|
7
|
+
|
|
8
|
+
import type { CompiledCatalog, CompiledMessage } from "./format";
|
|
9
|
+
|
|
10
|
+
export type { CompiledCatalog, CompiledMessage } from "./format";
|
|
11
|
+
|
|
12
|
+
/** Parse one ICU string to its AST. */
|
|
13
|
+
export function parseMessage(icu: string): CompiledMessage {
|
|
14
|
+
return parse(icu);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse a whole `{ key: icuString }` catalog to ASTs. */
|
|
18
|
+
export function compileCatalog(raw: Record<string, string>): CompiledCatalog {
|
|
19
|
+
const out: CompiledCatalog = {};
|
|
20
|
+
for (const [key, icu] of Object.entries(raw)) out[key] = parseMessage(icu);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// "tag" = a `<x>…</x>` rich element → a (chunks: ReactNode) => ReactNode param,
|
|
25
|
+
// rendered by t.rich (not the plain string t).
|
|
26
|
+
export type ParamType = "string" | "number" | "Date" | "tag";
|
|
27
|
+
|
|
28
|
+
/** The params a message needs, derived from its AST. Plural/number args are
|
|
29
|
+
* numbers, date/time are Dates, plain `{x}` and select keys are strings. This is
|
|
30
|
+
* what the codegen turns into `t`'s typed signature. */
|
|
31
|
+
export function deriveParams(
|
|
32
|
+
msg: CompiledMessage,
|
|
33
|
+
into: Record<string, ParamType> = {},
|
|
34
|
+
): Record<string, ParamType> {
|
|
35
|
+
for (const el of msg) {
|
|
36
|
+
switch (el.type) {
|
|
37
|
+
case TYPE.argument:
|
|
38
|
+
into[el.value] ??= "string";
|
|
39
|
+
break;
|
|
40
|
+
case TYPE.number:
|
|
41
|
+
into[el.value] = "number";
|
|
42
|
+
break;
|
|
43
|
+
case TYPE.date:
|
|
44
|
+
case TYPE.time:
|
|
45
|
+
into[el.value] = "Date";
|
|
46
|
+
break;
|
|
47
|
+
case TYPE.plural:
|
|
48
|
+
into[el.value] = "number";
|
|
49
|
+
for (const o of Object.values(el.options)) deriveParams(o.value, into);
|
|
50
|
+
break;
|
|
51
|
+
case TYPE.select:
|
|
52
|
+
into[el.value] ??= "string";
|
|
53
|
+
for (const o of Object.values(el.options)) deriveParams(o.value, into);
|
|
54
|
+
break;
|
|
55
|
+
case TYPE.tag:
|
|
56
|
+
into[el.value] = "tag";
|
|
57
|
+
deriveParams(el.children, into);
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return into;
|
|
64
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// RUNTIME message formatting — the AST evaluator. It imports only the `TYPE` enum
|
|
2
|
+
// from @formatjs (the parser `parse` is NOT reachable from here), so the runtime
|
|
3
|
+
// bundle ships the evaluator + Intl, never the ICU string parser. The build path
|
|
4
|
+
// (compile.ts) owns parsing; this owns evaluating what the build produced.
|
|
5
|
+
|
|
6
|
+
import { TYPE, type MessageFormatElement } from "@formatjs/icu-messageformat-parser";
|
|
7
|
+
|
|
8
|
+
/** A compiled message: the parsed ICU AST (what defineMessages ships). */
|
|
9
|
+
export type CompiledMessage = MessageFormatElement[];
|
|
10
|
+
export type CompiledCatalog = Record<string, CompiledMessage>;
|
|
11
|
+
|
|
12
|
+
const num = (locale: string, n: number) => new Intl.NumberFormat(locale).format(n);
|
|
13
|
+
|
|
14
|
+
// Walk the AST. `pound` is the active plural's number (the `#`).
|
|
15
|
+
function evaluate(
|
|
16
|
+
els: CompiledMessage,
|
|
17
|
+
locale: string,
|
|
18
|
+
params: Record<string, unknown>,
|
|
19
|
+
pound?: number,
|
|
20
|
+
): string {
|
|
21
|
+
let out = "";
|
|
22
|
+
for (const el of els) {
|
|
23
|
+
switch (el.type) {
|
|
24
|
+
case TYPE.literal:
|
|
25
|
+
out += el.value;
|
|
26
|
+
break;
|
|
27
|
+
case TYPE.argument:
|
|
28
|
+
out += el.value in params ? String(params[el.value]) : `{${el.value}}`;
|
|
29
|
+
break;
|
|
30
|
+
case TYPE.number:
|
|
31
|
+
out += num(locale, Number(params[el.value]));
|
|
32
|
+
break;
|
|
33
|
+
case TYPE.pound:
|
|
34
|
+
out += num(locale, pound ?? 0);
|
|
35
|
+
break;
|
|
36
|
+
case TYPE.plural: {
|
|
37
|
+
const n = Number(params[el.value]);
|
|
38
|
+
const exact = `=${n}`;
|
|
39
|
+
const cat =
|
|
40
|
+
el.options[exact] !== undefined
|
|
41
|
+
? exact
|
|
42
|
+
: new Intl.PluralRules(locale, {
|
|
43
|
+
type: el.pluralType === "ordinal" ? "ordinal" : "cardinal",
|
|
44
|
+
}).select(n - el.offset);
|
|
45
|
+
const opt = el.options[cat] ?? el.options.other;
|
|
46
|
+
if (opt) out += evaluate(opt.value, locale, params, n - el.offset);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case TYPE.select: {
|
|
50
|
+
const v = String(params[el.value]);
|
|
51
|
+
const opt = el.options[v] ?? el.options.other;
|
|
52
|
+
if (opt) out += evaluate(opt.value, locale, params, pound);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
// date/time/tag: handled by t.rich / later; ignored in the plain path.
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Format a compiled message in `locale` with `params`. */
|
|
64
|
+
export function formatMessage(
|
|
65
|
+
msg: CompiledMessage,
|
|
66
|
+
locale: string,
|
|
67
|
+
params?: Record<string, unknown>,
|
|
68
|
+
): string {
|
|
69
|
+
return evaluate(msg, locale, params ?? {});
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @junejs/i18n — ICU messages, the OPT-IN Layer 2 of June's i18n (routing is the
|
|
2
|
+
// in-box Layer 1; see docs/i18n.md §Packaging). Ambient `t`: read the current
|
|
3
|
+
// request locale off the scope (no ctx threading) and format the catalog message.
|
|
4
|
+
//
|
|
5
|
+
// import { defineMessages, compileCatalog, t } from "@junejs/i18n";
|
|
6
|
+
// defineMessages(
|
|
7
|
+
// { en: compileCatalog({ hi: "Hello, {name}!" }),
|
|
8
|
+
// de: compileCatalog({ hi: "Hallo, {name}!" }) },
|
|
9
|
+
// { defaultLocale: "en" },
|
|
10
|
+
// );
|
|
11
|
+
// t("hi", { name: "Ada" }); // server: reads ctx.locale via the request scope
|
|
12
|
+
//
|
|
13
|
+
// The catalogs ship COMPILED (parsed ASTs) — the @formatjs parser runs at build,
|
|
14
|
+
// never in the request path (see compile.ts). A build step will generate the
|
|
15
|
+
// defineMessages call + the typed `t` signature from `messages/*.json` (3.2b).
|
|
16
|
+
|
|
17
|
+
// currentLocale is the generic request-scope read (the host sets it after locale
|
|
18
|
+
// resolution); the core pipeline never imports this package — the locale crosses
|
|
19
|
+
// via the scope, not a direct dependency.
|
|
20
|
+
import type { ReactNode } from "react";
|
|
21
|
+
|
|
22
|
+
import { currentLocale } from "@junejs/db";
|
|
23
|
+
|
|
24
|
+
// Runtime only — format.ts/rich.ts do NOT reach the @formatjs parser (that's
|
|
25
|
+
// build-only in compile.ts / codegen.ts), so the request bundle never ships one.
|
|
26
|
+
import { formatMessage, type CompiledCatalog, type CompiledMessage } from "./format";
|
|
27
|
+
import { formatRich } from "./rich";
|
|
28
|
+
|
|
29
|
+
export { formatMessage, type CompiledCatalog, type CompiledMessage } from "./format";
|
|
30
|
+
export { formatRich } from "./rich";
|
|
31
|
+
|
|
32
|
+
/** A translator: callable for a plain string, `.rich` for embedded components. */
|
|
33
|
+
export type Translator = {
|
|
34
|
+
(key: string, params?: Record<string, unknown>): string;
|
|
35
|
+
rich(key: string, params?: Record<string, unknown>): ReactNode;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type MessagesConfig = {
|
|
39
|
+
catalogs: Record<string, CompiledCatalog>;
|
|
40
|
+
defaultLocale: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let registry: MessagesConfig | null = null;
|
|
44
|
+
|
|
45
|
+
/** Register the compiled message catalogs (called once at app boot). A build step
|
|
46
|
+
* generates this call — with compiled ASTs — from `messages/*.json`. */
|
|
47
|
+
export function defineMessages(
|
|
48
|
+
catalogs: Record<string, CompiledCatalog>,
|
|
49
|
+
opts: { defaultLocale: string },
|
|
50
|
+
): void {
|
|
51
|
+
registry = { catalogs, defaultLocale: opts.defaultLocale };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const warn = (msg: string) => {
|
|
55
|
+
// Tree-shaken out of production bundles (NODE_ENV baked at build).
|
|
56
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[june i18n] ${msg}`);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Missing-key warnings dedupe: a key missing on every render would otherwise spam
|
|
60
|
+
// the dev console once per request — warn each (locale, key) once.
|
|
61
|
+
const warnedMissing = new Set<string>();
|
|
62
|
+
const warnMissing = (key: string, locale: string) => {
|
|
63
|
+
const id = `${locale}:${key}`;
|
|
64
|
+
if (warnedMissing.has(id)) return;
|
|
65
|
+
warnedMissing.add(id);
|
|
66
|
+
warn(`missing key "${key}" (locale "${locale}")`);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A pure translator bound to a locale + compiled catalogs. The fallback chain is
|
|
70
|
+
* variant → default locale → the key itself (dev-warns on a miss). Exposed for
|
|
71
|
+
* tests and for non-ambient use (islands receive a translator built from props). */
|
|
72
|
+
export function createTranslator(
|
|
73
|
+
locale: string,
|
|
74
|
+
catalogs: Record<string, CompiledCatalog>,
|
|
75
|
+
defaultLocale: string,
|
|
76
|
+
): Translator {
|
|
77
|
+
const lookup = (key: string): CompiledMessage | undefined =>
|
|
78
|
+
catalogs[locale]?.[key] ?? catalogs[defaultLocale]?.[key];
|
|
79
|
+
const tr = ((key, params) => {
|
|
80
|
+
const msg = lookup(key);
|
|
81
|
+
if (msg === undefined) {
|
|
82
|
+
warnMissing(key, locale);
|
|
83
|
+
return key;
|
|
84
|
+
}
|
|
85
|
+
return formatMessage(msg, locale, params);
|
|
86
|
+
}) as Translator;
|
|
87
|
+
tr.rich = (key, params) => {
|
|
88
|
+
const msg = lookup(key);
|
|
89
|
+
if (msg === undefined) {
|
|
90
|
+
warnMissing(key, locale);
|
|
91
|
+
return key; // a string ReactNode
|
|
92
|
+
}
|
|
93
|
+
return formatRich(msg, locale, params);
|
|
94
|
+
};
|
|
95
|
+
return tr;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ambientTranslator(): Translator | null {
|
|
99
|
+
if (!registry) return null;
|
|
100
|
+
return createTranslator(
|
|
101
|
+
currentLocale() ?? registry.defaultLocale,
|
|
102
|
+
registry.catalogs,
|
|
103
|
+
registry.defaultLocale,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Ambient translate: resolves the locale from the request scope (`ctx.locale`),
|
|
108
|
+
* then formats `key` from the registered catalogs. Falls back to the default
|
|
109
|
+
* locale, then the key. `t.rich` renders embedded `<tag>`s to ReactNode. Server-
|
|
110
|
+
* side (loaders/views/actions); islands take a translator via props instead. */
|
|
111
|
+
export const t: Translator = Object.assign(
|
|
112
|
+
(key: string, params?: Record<string, unknown>): string => {
|
|
113
|
+
const tr = ambientTranslator();
|
|
114
|
+
if (!tr) {
|
|
115
|
+
warn(`t("${key}") called before defineMessages()`);
|
|
116
|
+
return key;
|
|
117
|
+
}
|
|
118
|
+
return tr(key, params);
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
rich: (key: string, params?: Record<string, unknown>): ReactNode => {
|
|
122
|
+
const tr = ambientTranslator();
|
|
123
|
+
if (!tr) {
|
|
124
|
+
warn(`t.rich("${key}") called before defineMessages()`);
|
|
125
|
+
return key;
|
|
126
|
+
}
|
|
127
|
+
return tr.rich(key, params);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// --- islands (client code-split) ------------------------------------------
|
|
133
|
+
// A page renders with ambient `t` server-side and ships ZERO message catalog to
|
|
134
|
+
// the browser (the HTML carries the formatted strings). An ISLAND can't read the
|
|
135
|
+
// scope on the client, so it ships ONLY the keys it uses — `pickMessages` (server,
|
|
136
|
+
// builds the subset for this request's locale) into the island's props, and
|
|
137
|
+
// `clientTranslator` (client, formats that subset). The parser never ships; only
|
|
138
|
+
// the tiny evaluator (format.ts/rich.ts) + the island's own messages do.
|
|
139
|
+
|
|
140
|
+
/** SERVER: the compiled subset of `keys` for `locale` (defaults to the scope
|
|
141
|
+
* locale), with per-key fallback to the default locale. Pass it as an island
|
|
142
|
+
* prop alongside the locale. */
|
|
143
|
+
export function pickMessages(keys: string[], locale?: string): CompiledCatalog {
|
|
144
|
+
if (!registry) {
|
|
145
|
+
warn(`pickMessages(${JSON.stringify(keys)}) called before defineMessages()`);
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
const loc = locale ?? currentLocale() ?? registry.defaultLocale;
|
|
149
|
+
const out: CompiledCatalog = {};
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
const msg = registry.catalogs[loc]?.[key] ?? registry.catalogs[registry.defaultLocale]?.[key];
|
|
152
|
+
if (msg) out[key] = msg;
|
|
153
|
+
else warn(`pickMessages: missing key "${key}"`);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** CLIENT: a translator over a one-locale subset (from an island's props). No
|
|
159
|
+
* scope, no registry — just the messages it was handed. */
|
|
160
|
+
export function clientTranslator(messages: CompiledCatalog, locale: string): Translator {
|
|
161
|
+
return createTranslator(locale, { [locale]: messages }, locale);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Test-only: reset the module registry + dedup state between cases.
|
|
165
|
+
export function __resetMessages(): void {
|
|
166
|
+
registry = null;
|
|
167
|
+
warnedMissing.clear();
|
|
168
|
+
}
|
package/src/rich.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Rich formatting (t.rich): the AST evaluator that returns ReactNode, so a
|
|
2
|
+
// message can embed components — `"Read the <link>docs</link>"` with
|
|
3
|
+
// `{ link: (chunks) => <a href="/docs">{chunks}</a> }`. Like format.ts it imports
|
|
4
|
+
// only TYPE (no parser); it additionally pulls React (a peer dep) for the tags.
|
|
5
|
+
|
|
6
|
+
import { createElement, Fragment, type ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
import { TYPE } from "@formatjs/icu-messageformat-parser";
|
|
9
|
+
|
|
10
|
+
import type { CompiledMessage } from "./format";
|
|
11
|
+
|
|
12
|
+
type RichParams = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
const num = (locale: string, n: number) => new Intl.NumberFormat(locale).format(n);
|
|
15
|
+
|
|
16
|
+
// Key each node so an array of mixed strings/elements renders without React's
|
|
17
|
+
// "missing key" warning.
|
|
18
|
+
const keyed = (nodes: ReactNode[]): ReactNode =>
|
|
19
|
+
nodes.length === 1 ? nodes[0] : nodes.map((n, i) => createElement(Fragment, { key: i }, n));
|
|
20
|
+
|
|
21
|
+
function evaluate(
|
|
22
|
+
els: CompiledMessage,
|
|
23
|
+
locale: string,
|
|
24
|
+
params: RichParams,
|
|
25
|
+
pound?: number,
|
|
26
|
+
): ReactNode[] {
|
|
27
|
+
const out: ReactNode[] = [];
|
|
28
|
+
for (const el of els) {
|
|
29
|
+
switch (el.type) {
|
|
30
|
+
case TYPE.literal:
|
|
31
|
+
out.push(el.value);
|
|
32
|
+
break;
|
|
33
|
+
case TYPE.argument:
|
|
34
|
+
out.push(el.value in params ? (params[el.value] as ReactNode) : `{${el.value}}`);
|
|
35
|
+
break;
|
|
36
|
+
case TYPE.number:
|
|
37
|
+
out.push(num(locale, Number(params[el.value])));
|
|
38
|
+
break;
|
|
39
|
+
case TYPE.pound:
|
|
40
|
+
out.push(num(locale, pound ?? 0));
|
|
41
|
+
break;
|
|
42
|
+
case TYPE.tag: {
|
|
43
|
+
const fn = params[el.value] as ((chunks: ReactNode) => ReactNode) | undefined;
|
|
44
|
+
const children = keyed(evaluate(el.children, locale, params, pound));
|
|
45
|
+
out.push(fn ? fn(children) : children);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case TYPE.plural: {
|
|
49
|
+
const n = Number(params[el.value]);
|
|
50
|
+
const exact = `=${n}`;
|
|
51
|
+
const cat =
|
|
52
|
+
el.options[exact] !== undefined
|
|
53
|
+
? exact
|
|
54
|
+
: new Intl.PluralRules(locale, {
|
|
55
|
+
type: el.pluralType === "ordinal" ? "ordinal" : "cardinal",
|
|
56
|
+
}).select(n - el.offset);
|
|
57
|
+
const opt = el.options[cat] ?? el.options.other;
|
|
58
|
+
if (opt) out.push(...evaluate(opt.value, locale, params, n - el.offset));
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case TYPE.select: {
|
|
62
|
+
const v = String(params[el.value]);
|
|
63
|
+
const opt = el.options[v] ?? el.options.other;
|
|
64
|
+
if (opt) out.push(...evaluate(opt.value, locale, params, pound));
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Format a compiled message to ReactNode, rendering `<tag>` elements via the
|
|
75
|
+
* matching `(chunks) => ReactNode` param. */
|
|
76
|
+
export function formatRich(
|
|
77
|
+
msg: CompiledMessage,
|
|
78
|
+
locale: string,
|
|
79
|
+
params?: RichParams,
|
|
80
|
+
): ReactNode {
|
|
81
|
+
return keyed(evaluate(msg, locale, params ?? {}));
|
|
82
|
+
}
|