@streetui/i18n 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StreetUI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @streetui/i18n
2
+
3
+ Minimal, framework-native internationalization for StreetUI. Built **entirely**
4
+ on `@streetui/state` signals — the active locale is a writable signal and `t()`
5
+ returns a derived signal, so translations plug straight into the DSL's reactive
6
+ text bindings. There is no second reactive system, no ICU parser, and no
7
+ third-party dependency.
8
+
9
+ ```bash
10
+ # part of the StreetUI monorepo — no separate install
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Creating an instance
16
+
17
+ ```ts
18
+ import { createI18n } from '@streetui/i18n';
19
+
20
+ const messages = {
21
+ en: { 'app.title': 'Accounts', 'hello': 'Hello, {name}!', 'items.one': '{count} item', 'items.other': '{count} items' },
22
+ fr: { 'app.title': 'Comptes', 'hello': 'Bonjour, {name} !', 'items.one': '{count} article', 'items.other': '{count} articles' },
23
+ } as const;
24
+
25
+ // A typed message shape keeps keys checked at compile time.
26
+ type MessageKey = keyof (typeof messages)['en'];
27
+ type Messages = Record<MessageKey, string>;
28
+
29
+ const i18n = createI18n<Messages>({
30
+ locale: 'en',
31
+ messages,
32
+ fallbackLocale: 'en', // consulted when a key is missing in the active locale
33
+ });
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Reactive vs. one-shot translation
39
+
40
+ There are two lookups, and the distinction matters:
41
+
42
+ - **`t(key, params?)` → `ReadonlySignal<string>`** — reactive. It recomputes when
43
+ the locale changes, so it belongs in DSL bindings that should re-render on a
44
+ locale switch.
45
+ - **`translate(key, params?)` → `string`** — a non-reactive one-shot read of the
46
+ current locale. Use it for values captured once at build time (e.g. a
47
+ validator message or a static `aria-label`) that should not re-render.
48
+
49
+ ```ts
50
+ page.heading(i18n.t('app.title')); // reactive — flips on setLocale
51
+ page.button('Save', { ariaLabel: i18n.translate('actions.save') }); // one-shot
52
+ ```
53
+
54
+ `key` is typed as `keyof M & string`, so unknown keys are a compile error:
55
+
56
+ ```ts
57
+ i18n.t('app.title'); // ok
58
+ // @ts-expect-error — 'nope' is not a message key
59
+ i18n.t('nope');
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Reactive locale switching
65
+
66
+ `i18n.locale` is a read-only signal and `setLocale()` writes it. Every `t()` /
67
+ `plural()` signal created from the instance recomputes, so a single toggle
68
+ re-renders every bound string in place:
69
+
70
+ ```ts
71
+ shell.button(derived(() => i18n.locale.get().toUpperCase()), {
72
+ onClick: () => i18n.setLocale(i18n.locale.get() === 'en' ? 'fr' : 'en'),
73
+ });
74
+ ```
75
+
76
+ `i18n.locales` lists the locales that have a message map, in declaration order.
77
+
78
+ ---
79
+
80
+ ## Interpolation
81
+
82
+ Placeholders use `{name}` syntax. Provide values via `params`; unknown
83
+ placeholders are left intact (never replaced with `undefined`).
84
+
85
+ ```ts
86
+ i18n.t('hello', { name: 'Ada' }).get(); // "Hello, Ada!"
87
+ ```
88
+
89
+ The bundled `interpolate(template, params?)` helper is exported for direct use.
90
+
91
+ ---
92
+
93
+ ## Pluralization
94
+
95
+ `plural(key, count, params?)` selects a message using the platform
96
+ `Intl.PluralRules` for the active locale. It looks up `${key}.${category}`
97
+ (e.g. `items.one`, `items.many`) and falls back to `${key}.other`. `count` is
98
+ automatically available to interpolation as `{count}`.
99
+
100
+ ```ts
101
+ i18n.plural('items', 1).get(); // "1 item" (en → 'one')
102
+ i18n.plural('items', 5).get(); // "5 items" (en → 'other')
103
+ ```
104
+
105
+ Because category selection is delegated to `Intl`, locale-specific plural rules
106
+ (e.g. French, Russian, Arabic) work without any custom tables.
107
+
108
+ ---
109
+
110
+ ## Determinism, SSR & hydration
111
+
112
+ Translation is deterministic by design:
113
+
114
+ - a **missing key resolves to the key itself** (a stable, visible marker — never
115
+ a thrown error or a random placeholder), and
116
+ - the same `(locale, key, params)` always produces the same string.
117
+
118
+ That determinism is exactly what keeps `renderToString()` and `hydrate()` in
119
+ agreement — **provided the client boots with the same initial locale the server
120
+ rendered with.** Pass the server's locale into `createI18n({ locale })` on the
121
+ client and the hydrated markup matches byte-for-byte before any `setLocale()`.
122
+ `i18n` coexists cleanly with the router, forms, and context — see
123
+ `examples/streetui-account` for all of them in one universal app.
124
+
125
+ ## Use with `has()`
126
+
127
+ `has(key)` reports whether the active (or fallback) locale defines a key —
128
+ useful for optional/feature-flagged copy without triggering the missing-key
129
+ echo.
package/dist/index.cjs ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createI18n: () => createI18n,
24
+ interpolate: () => interpolate
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/i18n.ts
29
+ var import_state = require("@streetui/state");
30
+ var INTERPOLATION = /\{(\w+)\}/g;
31
+ function interpolate(template, params) {
32
+ if (params === void 0) return template;
33
+ return template.replace(INTERPOLATION, (whole, name) => {
34
+ const value = params[name];
35
+ return value === void 0 ? whole : String(value);
36
+ });
37
+ }
38
+ function createI18n(config) {
39
+ const messages = config.messages;
40
+ const fallback = config.fallbackLocale;
41
+ const localeSignal = (0, import_state.signal)(config.locale);
42
+ const locales = Object.keys(messages);
43
+ function lookup(loc, key) {
44
+ const active = messages[loc];
45
+ const hit = active === void 0 ? void 0 : active[key];
46
+ if (hit !== void 0) return hit;
47
+ if (fallback !== void 0 && fallback !== loc) {
48
+ const fb = messages[fallback];
49
+ if (fb !== void 0) return fb[key];
50
+ }
51
+ return void 0;
52
+ }
53
+ function resolve(loc, key, params) {
54
+ const template = lookup(loc, key);
55
+ return template === void 0 ? key : interpolate(template, params);
56
+ }
57
+ function pluralKey(loc, key, count) {
58
+ const category = new Intl.PluralRules(loc).select(count);
59
+ if (lookup(loc, `${key}.${category}`) !== void 0) return `${key}.${category}`;
60
+ return `${key}.other`;
61
+ }
62
+ return {
63
+ locale: localeSignal,
64
+ locales,
65
+ setLocale(loc) {
66
+ localeSignal.set(loc);
67
+ },
68
+ t(key, params) {
69
+ return (0, import_state.derived)(() => resolve(localeSignal.get(), key, params));
70
+ },
71
+ translate(key, params) {
72
+ return resolve(localeSignal.peek(), key, params);
73
+ },
74
+ plural(key, count, params) {
75
+ const merged = { count, ...params ?? {} };
76
+ return (0, import_state.derived)(() => {
77
+ const loc = localeSignal.get();
78
+ return resolve(loc, pluralKey(loc, key, count), merged);
79
+ });
80
+ },
81
+ has(key) {
82
+ return lookup(localeSignal.peek(), key) !== void 0;
83
+ }
84
+ };
85
+ }
86
+ // Annotate the CommonJS export names for ESM import in node:
87
+ 0 && (module.exports = {
88
+ createI18n,
89
+ interpolate
90
+ });
91
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/i18n.ts"],"sourcesContent":["export * from './i18n.js';\n","/**\n * Minimal, framework-native internationalization for StreetUI.\n *\n * Built entirely on `@streetui/state` signals — there is no second reactive\n * system. The active locale is a writable signal; `t()` returns a derived\n * signal that recomputes when the locale changes, so translations plug\n * straight into the DSL's reactive text bindings (`text(() => i18n.t(...).get())`\n * or `text(i18n.t(...))`).\n *\n * Translation is deterministic: a missing key resolves to the key itself, and\n * the same (locale, key, params) always produces the same string on the server\n * and the client. That determinism is what keeps `renderToString()` and\n * `hydrate()` in agreement — provided the app boots the client with the same\n * initial locale it rendered with on the server.\n */\n\nimport {\n signal,\n derived,\n type ReadonlySignal,\n type DerivedSignal,\n} from '@streetui/state';\n\n/** A flat dictionary of message templates for a single locale. */\nexport type MessageMap = Record<string, string>;\n\n/** Values allowed in interpolation params. */\nexport type InterpolationParams = Record<string, string | number>;\n\nexport interface I18nConfig<M extends MessageMap> {\n /** The initial (and server-rendered) locale. */\n readonly locale: string;\n /** Messages keyed by locale, e.g. `{ en: {...}, fr: {...} }`. */\n readonly messages: Readonly<Record<string, M>>;\n /** Locale consulted when a key is absent from the active locale. */\n readonly fallbackLocale?: string;\n}\n\nexport interface I18n<M extends MessageMap> {\n /** The active locale as a reactive, read-only signal. */\n readonly locale: ReadonlySignal<string>;\n /** Switch the active locale; all `t()`/`plural()` signals recompute. */\n setLocale(locale: string): void;\n /** The locales that have a message map, in declaration order. */\n readonly locales: ReadonlyArray<string>;\n /** Reactive translation. Returns a derived signal — call `.get()` to read. */\n t(key: keyof M & string, params?: InterpolationParams): ReadonlySignal<string>;\n /** Non-reactive translation for the current locale (a one-shot read). */\n translate(key: keyof M & string, params?: InterpolationParams): string;\n /**\n * Reactive pluralization via `Intl.PluralRules`. Selects the message whose\n * key is `${key}.${category}` (e.g. `items.one`), falling back to\n * `${key}.other`. `count` is available to interpolation as `{count}`.\n */\n plural(key: string, count: number, params?: InterpolationParams): ReadonlySignal<string>;\n /** True when the active (or fallback) locale defines `key`. */\n has(key: string): boolean;\n}\n\nconst INTERPOLATION = /\\{(\\w+)\\}/g;\n\n/** Replace `{name}` placeholders using `params`; unknown names are left intact. */\nexport function interpolate(template: string, params?: InterpolationParams): string {\n if (params === undefined) return template;\n return template.replace(INTERPOLATION, (whole, name: string) => {\n const value = params[name];\n return value === undefined ? whole : String(value);\n });\n}\n\nexport function createI18n<M extends MessageMap>(config: I18nConfig<M>): I18n<M> {\n const messages = config.messages;\n const fallback = config.fallbackLocale;\n const localeSignal = signal<string>(config.locale);\n const locales = Object.keys(messages);\n\n /** Resolve a raw template for `key` in `loc`, then the fallback locale. */\n function lookup(loc: string, key: string): string | undefined {\n const active = messages[loc];\n const hit = active === undefined ? undefined : active[key];\n if (hit !== undefined) return hit;\n if (fallback !== undefined && fallback !== loc) {\n const fb = messages[fallback];\n if (fb !== undefined) return fb[key];\n }\n return undefined;\n }\n\n function resolve(loc: string, key: string, params?: InterpolationParams): string {\n const template = lookup(loc, key);\n // Deterministic miss: echo the key so output is stable server/client.\n return template === undefined ? key : interpolate(template, params);\n }\n\n function pluralKey(loc: string, key: string, count: number): string {\n const category = new Intl.PluralRules(loc).select(count);\n if (lookup(loc, `${key}.${category}`) !== undefined) return `${key}.${category}`;\n return `${key}.other`;\n }\n\n return {\n locale: localeSignal,\n locales,\n setLocale(loc: string): void {\n localeSignal.set(loc);\n },\n t(key, params): DerivedSignal<string> {\n return derived<string>(() => resolve(localeSignal.get(), key, params));\n },\n translate(key, params): string {\n return resolve(localeSignal.peek(), key, params);\n },\n plural(key, count, params): DerivedSignal<string> {\n const merged: InterpolationParams = { count, ...(params ?? {}) };\n return derived<string>(() => {\n const loc = localeSignal.get();\n return resolve(loc, pluralKey(loc, key, count), merged);\n });\n },\n has(key: string): boolean {\n return lookup(localeSignal.peek(), key) !== undefined;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACgBA,mBAKO;AAsCP,IAAM,gBAAgB;AAGf,SAAS,YAAY,UAAkB,QAAsC;AAClF,MAAI,WAAW,OAAW,QAAO;AACjC,SAAO,SAAS,QAAQ,eAAe,CAAC,OAAO,SAAiB;AAC9D,UAAM,QAAQ,OAAO,IAAI;AACzB,WAAO,UAAU,SAAY,QAAQ,OAAO,KAAK;AAAA,EACnD,CAAC;AACH;AAEO,SAAS,WAAiC,QAAgC;AAC/E,QAAM,WAAW,OAAO;AACxB,QAAM,WAAW,OAAO;AACxB,QAAM,mBAAe,qBAAe,OAAO,MAAM;AACjD,QAAM,UAAU,OAAO,KAAK,QAAQ;AAGpC,WAAS,OAAO,KAAa,KAAiC;AAC5D,UAAM,SAAS,SAAS,GAAG;AAC3B,UAAM,MAAM,WAAW,SAAY,SAAY,OAAO,GAAG;AACzD,QAAI,QAAQ,OAAW,QAAO;AAC9B,QAAI,aAAa,UAAa,aAAa,KAAK;AAC9C,YAAM,KAAK,SAAS,QAAQ;AAC5B,UAAI,OAAO,OAAW,QAAO,GAAG,GAAG;AAAA,IACrC;AACA,WAAO;AAAA,EACT;AAEA,WAAS,QAAQ,KAAa,KAAa,QAAsC;AAC/E,UAAM,WAAW,OAAO,KAAK,GAAG;AAEhC,WAAO,aAAa,SAAY,MAAM,YAAY,UAAU,MAAM;AAAA,EACpE;AAEA,WAAS,UAAU,KAAa,KAAa,OAAuB;AAClE,UAAM,WAAW,IAAI,KAAK,YAAY,GAAG,EAAE,OAAO,KAAK;AACvD,QAAI,OAAO,KAAK,GAAG,GAAG,IAAI,QAAQ,EAAE,MAAM,OAAW,QAAO,GAAG,GAAG,IAAI,QAAQ;AAC9E,WAAO,GAAG,GAAG;AAAA,EACf;AAEA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,UAAU,KAAmB;AAC3B,mBAAa,IAAI,GAAG;AAAA,IACtB;AAAA,IACA,EAAE,KAAK,QAA+B;AACpC,iBAAO,sBAAgB,MAAM,QAAQ,aAAa,IAAI,GAAG,KAAK,MAAM,CAAC;AAAA,IACvE;AAAA,IACA,UAAU,KAAK,QAAgB;AAC7B,aAAO,QAAQ,aAAa,KAAK,GAAG,KAAK,MAAM;AAAA,IACjD;AAAA,IACA,OAAO,KAAK,OAAO,QAA+B;AAChD,YAAM,SAA8B,EAAE,OAAO,GAAI,UAAU,CAAC,EAAG;AAC/D,iBAAO,sBAAgB,MAAM;AAC3B,cAAM,MAAM,aAAa,IAAI;AAC7B,eAAO,QAAQ,KAAK,UAAU,KAAK,KAAK,KAAK,GAAG,MAAM;AAAA,MACxD,CAAC;AAAA,IACH;AAAA,IACA,IAAI,KAAsB;AACxB,aAAO,OAAO,aAAa,KAAK,GAAG,GAAG,MAAM;AAAA,IAC9C;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,55 @@
1
+ import { ReadonlySignal } from '@streetui/state';
2
+
3
+ /**
4
+ * Minimal, framework-native internationalization for StreetUI.
5
+ *
6
+ * Built entirely on `@streetui/state` signals — there is no second reactive
7
+ * system. The active locale is a writable signal; `t()` returns a derived
8
+ * signal that recomputes when the locale changes, so translations plug
9
+ * straight into the DSL's reactive text bindings (`text(() => i18n.t(...).get())`
10
+ * or `text(i18n.t(...))`).
11
+ *
12
+ * Translation is deterministic: a missing key resolves to the key itself, and
13
+ * the same (locale, key, params) always produces the same string on the server
14
+ * and the client. That determinism is what keeps `renderToString()` and
15
+ * `hydrate()` in agreement — provided the app boots the client with the same
16
+ * initial locale it rendered with on the server.
17
+ */
18
+
19
+ /** A flat dictionary of message templates for a single locale. */
20
+ type MessageMap = Record<string, string>;
21
+ /** Values allowed in interpolation params. */
22
+ type InterpolationParams = Record<string, string | number>;
23
+ interface I18nConfig<M extends MessageMap> {
24
+ /** The initial (and server-rendered) locale. */
25
+ readonly locale: string;
26
+ /** Messages keyed by locale, e.g. `{ en: {...}, fr: {...} }`. */
27
+ readonly messages: Readonly<Record<string, M>>;
28
+ /** Locale consulted when a key is absent from the active locale. */
29
+ readonly fallbackLocale?: string;
30
+ }
31
+ interface I18n<M extends MessageMap> {
32
+ /** The active locale as a reactive, read-only signal. */
33
+ readonly locale: ReadonlySignal<string>;
34
+ /** Switch the active locale; all `t()`/`plural()` signals recompute. */
35
+ setLocale(locale: string): void;
36
+ /** The locales that have a message map, in declaration order. */
37
+ readonly locales: ReadonlyArray<string>;
38
+ /** Reactive translation. Returns a derived signal — call `.get()` to read. */
39
+ t(key: keyof M & string, params?: InterpolationParams): ReadonlySignal<string>;
40
+ /** Non-reactive translation for the current locale (a one-shot read). */
41
+ translate(key: keyof M & string, params?: InterpolationParams): string;
42
+ /**
43
+ * Reactive pluralization via `Intl.PluralRules`. Selects the message whose
44
+ * key is `${key}.${category}` (e.g. `items.one`), falling back to
45
+ * `${key}.other`. `count` is available to interpolation as `{count}`.
46
+ */
47
+ plural(key: string, count: number, params?: InterpolationParams): ReadonlySignal<string>;
48
+ /** True when the active (or fallback) locale defines `key`. */
49
+ has(key: string): boolean;
50
+ }
51
+ /** Replace `{name}` placeholders using `params`; unknown names are left intact. */
52
+ declare function interpolate(template: string, params?: InterpolationParams): string;
53
+ declare function createI18n<M extends MessageMap>(config: I18nConfig<M>): I18n<M>;
54
+
55
+ export { type I18n, type I18nConfig, type InterpolationParams, type MessageMap, createI18n, interpolate };
@@ -0,0 +1,55 @@
1
+ import { ReadonlySignal } from '@streetui/state';
2
+
3
+ /**
4
+ * Minimal, framework-native internationalization for StreetUI.
5
+ *
6
+ * Built entirely on `@streetui/state` signals — there is no second reactive
7
+ * system. The active locale is a writable signal; `t()` returns a derived
8
+ * signal that recomputes when the locale changes, so translations plug
9
+ * straight into the DSL's reactive text bindings (`text(() => i18n.t(...).get())`
10
+ * or `text(i18n.t(...))`).
11
+ *
12
+ * Translation is deterministic: a missing key resolves to the key itself, and
13
+ * the same (locale, key, params) always produces the same string on the server
14
+ * and the client. That determinism is what keeps `renderToString()` and
15
+ * `hydrate()` in agreement — provided the app boots the client with the same
16
+ * initial locale it rendered with on the server.
17
+ */
18
+
19
+ /** A flat dictionary of message templates for a single locale. */
20
+ type MessageMap = Record<string, string>;
21
+ /** Values allowed in interpolation params. */
22
+ type InterpolationParams = Record<string, string | number>;
23
+ interface I18nConfig<M extends MessageMap> {
24
+ /** The initial (and server-rendered) locale. */
25
+ readonly locale: string;
26
+ /** Messages keyed by locale, e.g. `{ en: {...}, fr: {...} }`. */
27
+ readonly messages: Readonly<Record<string, M>>;
28
+ /** Locale consulted when a key is absent from the active locale. */
29
+ readonly fallbackLocale?: string;
30
+ }
31
+ interface I18n<M extends MessageMap> {
32
+ /** The active locale as a reactive, read-only signal. */
33
+ readonly locale: ReadonlySignal<string>;
34
+ /** Switch the active locale; all `t()`/`plural()` signals recompute. */
35
+ setLocale(locale: string): void;
36
+ /** The locales that have a message map, in declaration order. */
37
+ readonly locales: ReadonlyArray<string>;
38
+ /** Reactive translation. Returns a derived signal — call `.get()` to read. */
39
+ t(key: keyof M & string, params?: InterpolationParams): ReadonlySignal<string>;
40
+ /** Non-reactive translation for the current locale (a one-shot read). */
41
+ translate(key: keyof M & string, params?: InterpolationParams): string;
42
+ /**
43
+ * Reactive pluralization via `Intl.PluralRules`. Selects the message whose
44
+ * key is `${key}.${category}` (e.g. `items.one`), falling back to
45
+ * `${key}.other`. `count` is available to interpolation as `{count}`.
46
+ */
47
+ plural(key: string, count: number, params?: InterpolationParams): ReadonlySignal<string>;
48
+ /** True when the active (or fallback) locale defines `key`. */
49
+ has(key: string): boolean;
50
+ }
51
+ /** Replace `{name}` placeholders using `params`; unknown names are left intact. */
52
+ declare function interpolate(template: string, params?: InterpolationParams): string;
53
+ declare function createI18n<M extends MessageMap>(config: I18nConfig<M>): I18n<M>;
54
+
55
+ export { type I18n, type I18nConfig, type InterpolationParams, type MessageMap, createI18n, interpolate };
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ // src/i18n.ts
2
+ import {
3
+ signal,
4
+ derived
5
+ } from "@streetui/state";
6
+ var INTERPOLATION = /\{(\w+)\}/g;
7
+ function interpolate(template, params) {
8
+ if (params === void 0) return template;
9
+ return template.replace(INTERPOLATION, (whole, name) => {
10
+ const value = params[name];
11
+ return value === void 0 ? whole : String(value);
12
+ });
13
+ }
14
+ function createI18n(config) {
15
+ const messages = config.messages;
16
+ const fallback = config.fallbackLocale;
17
+ const localeSignal = signal(config.locale);
18
+ const locales = Object.keys(messages);
19
+ function lookup(loc, key) {
20
+ const active = messages[loc];
21
+ const hit = active === void 0 ? void 0 : active[key];
22
+ if (hit !== void 0) return hit;
23
+ if (fallback !== void 0 && fallback !== loc) {
24
+ const fb = messages[fallback];
25
+ if (fb !== void 0) return fb[key];
26
+ }
27
+ return void 0;
28
+ }
29
+ function resolve(loc, key, params) {
30
+ const template = lookup(loc, key);
31
+ return template === void 0 ? key : interpolate(template, params);
32
+ }
33
+ function pluralKey(loc, key, count) {
34
+ const category = new Intl.PluralRules(loc).select(count);
35
+ if (lookup(loc, `${key}.${category}`) !== void 0) return `${key}.${category}`;
36
+ return `${key}.other`;
37
+ }
38
+ return {
39
+ locale: localeSignal,
40
+ locales,
41
+ setLocale(loc) {
42
+ localeSignal.set(loc);
43
+ },
44
+ t(key, params) {
45
+ return derived(() => resolve(localeSignal.get(), key, params));
46
+ },
47
+ translate(key, params) {
48
+ return resolve(localeSignal.peek(), key, params);
49
+ },
50
+ plural(key, count, params) {
51
+ const merged = { count, ...params ?? {} };
52
+ return derived(() => {
53
+ const loc = localeSignal.get();
54
+ return resolve(loc, pluralKey(loc, key, count), merged);
55
+ });
56
+ },
57
+ has(key) {
58
+ return lookup(localeSignal.peek(), key) !== void 0;
59
+ }
60
+ };
61
+ }
62
+ export {
63
+ createI18n,
64
+ interpolate
65
+ };
66
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/i18n.ts"],"sourcesContent":["/**\n * Minimal, framework-native internationalization for StreetUI.\n *\n * Built entirely on `@streetui/state` signals — there is no second reactive\n * system. The active locale is a writable signal; `t()` returns a derived\n * signal that recomputes when the locale changes, so translations plug\n * straight into the DSL's reactive text bindings (`text(() => i18n.t(...).get())`\n * or `text(i18n.t(...))`).\n *\n * Translation is deterministic: a missing key resolves to the key itself, and\n * the same (locale, key, params) always produces the same string on the server\n * and the client. That determinism is what keeps `renderToString()` and\n * `hydrate()` in agreement — provided the app boots the client with the same\n * initial locale it rendered with on the server.\n */\n\nimport {\n signal,\n derived,\n type ReadonlySignal,\n type DerivedSignal,\n} from '@streetui/state';\n\n/** A flat dictionary of message templates for a single locale. */\nexport type MessageMap = Record<string, string>;\n\n/** Values allowed in interpolation params. */\nexport type InterpolationParams = Record<string, string | number>;\n\nexport interface I18nConfig<M extends MessageMap> {\n /** The initial (and server-rendered) locale. */\n readonly locale: string;\n /** Messages keyed by locale, e.g. `{ en: {...}, fr: {...} }`. */\n readonly messages: Readonly<Record<string, M>>;\n /** Locale consulted when a key is absent from the active locale. */\n readonly fallbackLocale?: string;\n}\n\nexport interface I18n<M extends MessageMap> {\n /** The active locale as a reactive, read-only signal. */\n readonly locale: ReadonlySignal<string>;\n /** Switch the active locale; all `t()`/`plural()` signals recompute. */\n setLocale(locale: string): void;\n /** The locales that have a message map, in declaration order. */\n readonly locales: ReadonlyArray<string>;\n /** Reactive translation. Returns a derived signal — call `.get()` to read. */\n t(key: keyof M & string, params?: InterpolationParams): ReadonlySignal<string>;\n /** Non-reactive translation for the current locale (a one-shot read). */\n translate(key: keyof M & string, params?: InterpolationParams): string;\n /**\n * Reactive pluralization via `Intl.PluralRules`. Selects the message whose\n * key is `${key}.${category}` (e.g. `items.one`), falling back to\n * `${key}.other`. `count` is available to interpolation as `{count}`.\n */\n plural(key: string, count: number, params?: InterpolationParams): ReadonlySignal<string>;\n /** True when the active (or fallback) locale defines `key`. */\n has(key: string): boolean;\n}\n\nconst INTERPOLATION = /\\{(\\w+)\\}/g;\n\n/** Replace `{name}` placeholders using `params`; unknown names are left intact. */\nexport function interpolate(template: string, params?: InterpolationParams): string {\n if (params === undefined) return template;\n return template.replace(INTERPOLATION, (whole, name: string) => {\n const value = params[name];\n return value === undefined ? whole : String(value);\n });\n}\n\nexport function createI18n<M extends MessageMap>(config: I18nConfig<M>): I18n<M> {\n const messages = config.messages;\n const fallback = config.fallbackLocale;\n const localeSignal = signal<string>(config.locale);\n const locales = Object.keys(messages);\n\n /** Resolve a raw template for `key` in `loc`, then the fallback locale. */\n function lookup(loc: string, key: string): string | undefined {\n const active = messages[loc];\n const hit = active === undefined ? undefined : active[key];\n if (hit !== undefined) return hit;\n if (fallback !== undefined && fallback !== loc) {\n const fb = messages[fallback];\n if (fb !== undefined) return fb[key];\n }\n return undefined;\n }\n\n function resolve(loc: string, key: string, params?: InterpolationParams): string {\n const template = lookup(loc, key);\n // Deterministic miss: echo the key so output is stable server/client.\n return template === undefined ? key : interpolate(template, params);\n }\n\n function pluralKey(loc: string, key: string, count: number): string {\n const category = new Intl.PluralRules(loc).select(count);\n if (lookup(loc, `${key}.${category}`) !== undefined) return `${key}.${category}`;\n return `${key}.other`;\n }\n\n return {\n locale: localeSignal,\n locales,\n setLocale(loc: string): void {\n localeSignal.set(loc);\n },\n t(key, params): DerivedSignal<string> {\n return derived<string>(() => resolve(localeSignal.get(), key, params));\n },\n translate(key, params): string {\n return resolve(localeSignal.peek(), key, params);\n },\n plural(key, count, params): DerivedSignal<string> {\n const merged: InterpolationParams = { count, ...(params ?? {}) };\n return derived<string>(() => {\n const loc = localeSignal.get();\n return resolve(loc, pluralKey(loc, key, count), merged);\n });\n },\n has(key: string): boolean {\n return lookup(localeSignal.peek(), key) !== undefined;\n },\n };\n}\n"],"mappings":";AAgBA;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAsCP,IAAM,gBAAgB;AAGf,SAAS,YAAY,UAAkB,QAAsC;AAClF,MAAI,WAAW,OAAW,QAAO;AACjC,SAAO,SAAS,QAAQ,eAAe,CAAC,OAAO,SAAiB;AAC9D,UAAM,QAAQ,OAAO,IAAI;AACzB,WAAO,UAAU,SAAY,QAAQ,OAAO,KAAK;AAAA,EACnD,CAAC;AACH;AAEO,SAAS,WAAiC,QAAgC;AAC/E,QAAM,WAAW,OAAO;AACxB,QAAM,WAAW,OAAO;AACxB,QAAM,eAAe,OAAe,OAAO,MAAM;AACjD,QAAM,UAAU,OAAO,KAAK,QAAQ;AAGpC,WAAS,OAAO,KAAa,KAAiC;AAC5D,UAAM,SAAS,SAAS,GAAG;AAC3B,UAAM,MAAM,WAAW,SAAY,SAAY,OAAO,GAAG;AACzD,QAAI,QAAQ,OAAW,QAAO;AAC9B,QAAI,aAAa,UAAa,aAAa,KAAK;AAC9C,YAAM,KAAK,SAAS,QAAQ;AAC5B,UAAI,OAAO,OAAW,QAAO,GAAG,GAAG;AAAA,IACrC;AACA,WAAO;AAAA,EACT;AAEA,WAAS,QAAQ,KAAa,KAAa,QAAsC;AAC/E,UAAM,WAAW,OAAO,KAAK,GAAG;AAEhC,WAAO,aAAa,SAAY,MAAM,YAAY,UAAU,MAAM;AAAA,EACpE;AAEA,WAAS,UAAU,KAAa,KAAa,OAAuB;AAClE,UAAM,WAAW,IAAI,KAAK,YAAY,GAAG,EAAE,OAAO,KAAK;AACvD,QAAI,OAAO,KAAK,GAAG,GAAG,IAAI,QAAQ,EAAE,MAAM,OAAW,QAAO,GAAG,GAAG,IAAI,QAAQ;AAC9E,WAAO,GAAG,GAAG;AAAA,EACf;AAEA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,UAAU,KAAmB;AAC3B,mBAAa,IAAI,GAAG;AAAA,IACtB;AAAA,IACA,EAAE,KAAK,QAA+B;AACpC,aAAO,QAAgB,MAAM,QAAQ,aAAa,IAAI,GAAG,KAAK,MAAM,CAAC;AAAA,IACvE;AAAA,IACA,UAAU,KAAK,QAAgB;AAC7B,aAAO,QAAQ,aAAa,KAAK,GAAG,KAAK,MAAM;AAAA,IACjD;AAAA,IACA,OAAO,KAAK,OAAO,QAA+B;AAChD,YAAM,SAA8B,EAAE,OAAO,GAAI,UAAU,CAAC,EAAG;AAC/D,aAAO,QAAgB,MAAM;AAC3B,cAAM,MAAM,aAAa,IAAI;AAC7B,eAAO,QAAQ,KAAK,UAAU,KAAK,KAAK,KAAK,GAAG,MAAM;AAAA,MACxD,CAAC;AAAA,IACH;AAAA,IACA,IAAI,KAAsB;AACxB,aAAO,OAAO,aAAa,KAAK,GAAG,GAAG,MAAM;AAAA,IAC9C;AAAA,EACF;AACF;","names":[]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@streetui/i18n",
3
+ "version": "1.0.0",
4
+ "description": "StreetUI internationalization — reactive locale, typed translation, interpolation and pluralization",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "clean": "rm -rf dist"
26
+ },
27
+ "dependencies": {
28
+ "@streetui/state": "1.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "*",
32
+ "tsup": "*",
33
+ "vitest": "*"
34
+ },
35
+ "license": "MIT",
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md",
43
+ "LICENSE"
44
+ ]
45
+ }