@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 +21 -0
- package/README.md +129 -0
- package/dist/index.cjs +91 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +55 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
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":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|