@tapestry-ui/i18n 0.0.0-stage → 0.2.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 +109 -2
- package/dist/format.d.ts +18 -0
- package/dist/format.js +58 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.js +142 -0
- package/package.json +45 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon Minton
|
|
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
CHANGED
|
@@ -1,3 +1,110 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tapestry-ui/i18n
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small i18n core for Preact apps. **The app sets keys, the DOM resolves.**
|
|
4
|
+
|
|
5
|
+
A **utility**, not a component — it renders nothing. Part of
|
|
6
|
+
[Tapestry UI](https://github.com/ryurage/brandonminton), lifted from
|
|
7
|
+
Brandskviða, which has run this shape across eleven locales since 2026-09.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @tapestry-ui/i18n
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// en.ts — the source of truth. Keys name WHERE a string lives, never what it says.
|
|
15
|
+
export const en = {
|
|
16
|
+
'gaps.title': 'Intel gaps',
|
|
17
|
+
'gaps.toggle.open': '{n, plural, one {# open gap} other {# open gaps}}',
|
|
18
|
+
'greeting.partner': 'Evening, {who}.',
|
|
19
|
+
} as const;
|
|
20
|
+
export type MsgKey = keyof typeof en;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// i18n.ts — the three app-specific facts, and nothing else
|
|
25
|
+
export const { translate, textOf, message, locale, setLocale } = createI18n({
|
|
26
|
+
source: en,
|
|
27
|
+
catalogs: { es },
|
|
28
|
+
registry: [{ code: 'en', endonym: 'English' }, { code: 'es', endonym: 'Español' }],
|
|
29
|
+
storageKey: 'my-app-locale',
|
|
30
|
+
sourceCode: 'en',
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
<h2>{translate('gaps.title')}</h2>
|
|
36
|
+
<p>{translate('gaps.toggle.open', { n: 12 })}</p>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`locale` is a signal, so `setLocale('es')` re-renders every visible string. No
|
|
40
|
+
reload, no provider threaded through the tree.
|
|
41
|
+
|
|
42
|
+
## Three rules it exists to enforce
|
|
43
|
+
|
|
44
|
+
**Per-key fallback.** A locale file is `Partial<Record<MsgKey, string>>` on
|
|
45
|
+
purpose. Ship forty keys today and four hundred next month; anything absent
|
|
46
|
+
falls through to the source **key by key**, never to a blank string. An empty
|
|
47
|
+
stub is a valid locale.
|
|
48
|
+
|
|
49
|
+
**Keys say WHERE, not what.** `tile.cash.title`, not `cashAndRunway`. A key that
|
|
50
|
+
describes the text has to be renamed when the text changes, which nobody does.
|
|
51
|
+
|
|
52
|
+
**Your data is not copy.** A person's name, a property, something a user typed —
|
|
53
|
+
those are content. They must not be in the catalog, because a translator must
|
|
54
|
+
not be able to reach them. They arrive as `{vars}`.
|
|
55
|
+
|
|
56
|
+
## The pseudo-locale is the point
|
|
57
|
+
|
|
58
|
+
`setLocale('pseudo')` generates a catalog from the source: accented look-alikes,
|
|
59
|
+
~35% padding, `[brackets]`.
|
|
60
|
+
|
|
61
|
+
> **Unbracketed text on screen is a hardcoded string your extraction missed.**
|
|
62
|
+
> Overflowing text is a layout that assumed English lengths.
|
|
63
|
+
|
|
64
|
+
A grep can only see literals it recognises. The pseudo-locale sees everything
|
|
65
|
+
that reaches the screen, which is why it is the real meter.
|
|
66
|
+
|
|
67
|
+
It mangles the words inside a plural's branches but leaves the syntax alone —
|
|
68
|
+
`other {…}` has to survive, or the parser stops finding the branch. (That was a
|
|
69
|
+
real bug, found by the pseudo-locale catching itself.)
|
|
70
|
+
|
|
71
|
+
## Plurals
|
|
72
|
+
|
|
73
|
+
`{n, plural, one {# thing} other {# things}}`, backed by `Intl.PluralRules`,
|
|
74
|
+
with `#` as the count. **Any CLDR category** is accepted, so a language needing
|
|
75
|
+
`few` and `many` writes them, and a language that never does never has to. A
|
|
76
|
+
missing category falls back to `other`.
|
|
77
|
+
|
|
78
|
+
Never build a plural by concatenation, and never write `N item(s)` — those are
|
|
79
|
+
English rules wearing a disguise.
|
|
80
|
+
|
|
81
|
+
## Tokens your catalog must not contain
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
createI18n({ ..., tokens: { name: (id, tag) => NAMES[locale][id][tag ?? 'nom'] } })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`{name.jon}` and `{name.jon:gen}` are handed to your resolver. This is how
|
|
88
|
+
Brandskviða transliterates Norse names into Cyrillic and declines them, without
|
|
89
|
+
the library ever learning what a Norse name is.
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
| | |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `createI18n(options)` | the factory; everything it returns is typed to **your** keys |
|
|
96
|
+
| `translate(key, vars?)` | resolve now |
|
|
97
|
+
| `textOf(msg)` | resolve a `Msg`; a raw string passes through verbatim |
|
|
98
|
+
| `message(key, vars?)` | build a `Msg` for a call site that carries one |
|
|
99
|
+
| `keyOf(msg)` | compare **keys**, never rendered text |
|
|
100
|
+
| `locale` / `setLocale` | the signal, and the setter that persists + stamps `<html lang>` |
|
|
101
|
+
| `pseudoize`, `resolveLocale`, `resolveMessage` | pure, exported, and where the logic lives |
|
|
102
|
+
|
|
103
|
+
Locale resolution: `?lang=` → saved → `navigator.language` → source.
|
|
104
|
+
|
|
105
|
+
Names read like English by house law: **translate · textOf · message.** Never a
|
|
106
|
+
lazy `t()`.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT © Brandon Minton
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Vars = Record<string, string | number>;
|
|
2
|
+
/** Resolvers for `{group.id}` tokens, by group name. */
|
|
3
|
+
export type Tokens = Record<string, (id: string, tag?: string) => string>;
|
|
4
|
+
export interface FormatOptions {
|
|
5
|
+
/** Locale whose CLDR plural rules pick the branch. Defaults to English. */
|
|
6
|
+
plurals?: string;
|
|
7
|
+
tokens?: Tokens;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One message, with its plurals chosen, its tokens resolved and its vars filled.
|
|
11
|
+
*
|
|
12
|
+
* Order matters and is not arbitrary: plurals first, because a branch can itself
|
|
13
|
+
* contain `{vars}`; then tokens, whose `{group.id}` shape would otherwise be
|
|
14
|
+
* mistaken for a var; then the vars. A var with no value is LEFT AS ITS
|
|
15
|
+
* PLACEHOLDER rather than blanked — a visible `{who}` is a bug report, and an
|
|
16
|
+
* empty space is a mystery.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatMessage(template: string, vars?: Vars, options?: FormatOptions): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Filling one message in. The pure half of the library, and deliberately its own
|
|
2
|
+
// module: this file imports NOTHING.
|
|
3
|
+
//
|
|
4
|
+
// Why that matters. createI18n is built on a signal, so importing it drags in
|
|
5
|
+
// @preact/signals and preact — correct in a browser, wrong in a server that only
|
|
6
|
+
// needs to render the source catalog. A Cloudflare Worker that refuses a bad edit
|
|
7
|
+
// has to say why in words, and the words belong in the same catalog the browser
|
|
8
|
+
// reads. So the formatting lives here, both halves use it, and there is exactly
|
|
9
|
+
// one implementation of "what does {n, plural, …} mean".
|
|
10
|
+
//
|
|
11
|
+
// Reachable as @tapestry-ui/i18n/format.
|
|
12
|
+
/**
|
|
13
|
+
* `{count, plural, one {…} other {…}}` — the var name, then the branch list.
|
|
14
|
+
*
|
|
15
|
+
* A branch may itself hold `{vars}`, so its content is "anything but braces, or
|
|
16
|
+
* one braced run" rather than `[^}]*`. The simpler version stopped at the first
|
|
17
|
+
* `}` it met, which for "one {# story for {who}}" meant the whole plural failed
|
|
18
|
+
* to match and the reader got the template. No catalog had nested one yet;
|
|
19
|
+
* pseudoize() in i18n.ts already knew better, and now both halves agree.
|
|
20
|
+
*/
|
|
21
|
+
const BRANCH_BODY = "(?:[^{}]|\\{[^{}]*\\})*";
|
|
22
|
+
const PLURAL = new RegExp(`\\{(\\w+), plural,((?: \\w+ \\{${BRANCH_BODY}\\})+)\\}`, "g");
|
|
23
|
+
/** One ` name {text}` branch inside a plural. */
|
|
24
|
+
const PLURAL_BRANCH = new RegExp(` (\\w+) \\{(${BRANCH_BODY})\\}`, "g");
|
|
25
|
+
/** `{var}`. */
|
|
26
|
+
const PLACEHOLDER = /\{(\w+)\}/g;
|
|
27
|
+
/** `{group.id}` or `{group.id:tag}` — a token an app resolves for itself. */
|
|
28
|
+
const TOKEN = /\{(\w+)\.(\w+)(?::(\w+))?\}/g;
|
|
29
|
+
/**
|
|
30
|
+
* One message, with its plurals chosen, its tokens resolved and its vars filled.
|
|
31
|
+
*
|
|
32
|
+
* Order matters and is not arbitrary: plurals first, because a branch can itself
|
|
33
|
+
* contain `{vars}`; then tokens, whose `{group.id}` shape would otherwise be
|
|
34
|
+
* mistaken for a var; then the vars. A var with no value is LEFT AS ITS
|
|
35
|
+
* PLACEHOLDER rather than blanked — a visible `{who}` is a bug report, and an
|
|
36
|
+
* empty space is a mystery.
|
|
37
|
+
*/
|
|
38
|
+
export function formatMessage(template, vars, options = {}) {
|
|
39
|
+
const pluralRules = new Intl.PluralRules(options.plurals ?? "en");
|
|
40
|
+
let out = template.replace(PLURAL, (_whole, varName, branchSource) => {
|
|
41
|
+
const count = Number(vars?.[varName] ?? 0);
|
|
42
|
+
const branches = {};
|
|
43
|
+
for (const found of branchSource.matchAll(PLURAL_BRANCH))
|
|
44
|
+
branches[found[1]] = found[2];
|
|
45
|
+
// `#` is the count, which is what spares a catalog "1 story"/"2 stories" twice
|
|
46
|
+
return (branches[pluralRules.select(count)] ?? branches.other ?? "").replace(/#/g, String(count));
|
|
47
|
+
});
|
|
48
|
+
if (options.tokens) {
|
|
49
|
+
out = out.replace(TOKEN, (whole, group, id, tag) => {
|
|
50
|
+
const resolve = options.tokens?.[group];
|
|
51
|
+
return resolve ? resolve(id, tag) : whole;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (vars) {
|
|
55
|
+
out = out.replace(PLACEHOLDER, (whole, varName) => (varName in vars ? String(vars[varName]) : whole));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Signal } from "@preact/signals";
|
|
2
|
+
import { type Tokens, type Vars } from "./format";
|
|
3
|
+
export { formatMessage } from "./format";
|
|
4
|
+
export type { FormatOptions, Tokens } from "./format";
|
|
5
|
+
/** A catalog is a flat map of key to source text. Keys name WHERE a string
|
|
6
|
+
* lives, never what it says — `tile.cash.title`, not `cashAndRunway`. */
|
|
7
|
+
export type Catalog = Record<string, string>;
|
|
8
|
+
/** What a call site carries. A raw string is LEGACY during an extraction:
|
|
9
|
+
* rendered verbatim, which is exactly what lets the pseudo-locale's bracket
|
|
10
|
+
* test find text nobody has migrated yet. */
|
|
11
|
+
export type Msg<K extends string> = string | {
|
|
12
|
+
k: K;
|
|
13
|
+
vars?: Vars;
|
|
14
|
+
};
|
|
15
|
+
export type { Vars } from "./format";
|
|
16
|
+
export interface LocaleInfo<Code extends string> {
|
|
17
|
+
readonly code: Code;
|
|
18
|
+
/** The language's own name for itself, deliberately UNTRANSLATED — a Spanish
|
|
19
|
+
* speaker must find "Español" whatever locale happens to be active. */
|
|
20
|
+
readonly endonym: string;
|
|
21
|
+
}
|
|
22
|
+
export interface I18nOptions<Source extends Catalog, Code extends string> {
|
|
23
|
+
/** The source of truth. Its keys become the key type for everything else. */
|
|
24
|
+
source: Source;
|
|
25
|
+
/**
|
|
26
|
+
* Every other locale, keyed by code. DELIBERATELY PARTIAL: a translation can
|
|
27
|
+
* ship forty keys today and four hundred next month and never fail typecheck
|
|
28
|
+
* or blank a render — resolveMessage falls through to the source key by key.
|
|
29
|
+
*/
|
|
30
|
+
catalogs?: Partial<Record<Code, Partial<Record<keyof Source & string, string>>>>;
|
|
31
|
+
/** The locales a language menu may offer, in presentation order. The source
|
|
32
|
+
* locale belongs here; the pseudo-locale never does. */
|
|
33
|
+
registry: readonly LocaleInfo<Code>[];
|
|
34
|
+
/** Where the choice is remembered. One per app, or they fight. */
|
|
35
|
+
storageKey: string;
|
|
36
|
+
/** The code the source catalog is written in. */
|
|
37
|
+
sourceCode: Code;
|
|
38
|
+
/** The tester-only generated locale. Reachable by ?lang=, never listed. */
|
|
39
|
+
pseudoCode?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Token resolvers for `{group.id}` and `{group.id:tag}` placeholders, so an
|
|
42
|
+
* app can interpolate things a catalog must never contain — a proper name
|
|
43
|
+
* that crosses alphabets, say. The library stays ignorant of what they are.
|
|
44
|
+
*/
|
|
45
|
+
tokens?: Tokens;
|
|
46
|
+
}
|
|
47
|
+
export declare function pseudoize(source: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Resolution order: an explicit `?lang=` wins, then what was saved, then the
|
|
50
|
+
* browser's own preference, then the source language. Pure over its inputs so a
|
|
51
|
+
* test can lock it without a DOM.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveLocale<Code extends string>(urlLang: string | null, saved: string | null, navigatorLanguage: string, supported: readonly string[], fallback: Code): Code;
|
|
54
|
+
/**
|
|
55
|
+
* THE PER-KEY FALLBACK, which is what makes a partial catalog safe: a locale
|
|
56
|
+
* that ships a key uses it; anything else — a key it never reached, or a wholly
|
|
57
|
+
* empty stub — falls through to the source, never to a blank string. Pure, so
|
|
58
|
+
* the contract is directly testable with a synthetic catalog.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveMessage<Source extends Catalog>(catalog: Partial<Record<keyof Source & string, string>>, source: Source, key: keyof Source & string): string;
|
|
61
|
+
export interface I18n<Source extends Catalog, Code extends string> {
|
|
62
|
+
/** The key union, for typing a consumer's own helpers. Value is never read. */
|
|
63
|
+
readonly keyType: keyof Source & string;
|
|
64
|
+
readonly locale: Signal<Code | string>;
|
|
65
|
+
readonly registry: readonly LocaleInfo<Code>[];
|
|
66
|
+
setLocale(next: Code | string): void;
|
|
67
|
+
message(k: keyof Source & string, vars?: Vars): Msg<keyof Source & string>;
|
|
68
|
+
translate(k: keyof Source & string, vars?: Vars): string;
|
|
69
|
+
textOf(value: Msg<keyof Source & string> | null | undefined): string;
|
|
70
|
+
keyOf(value: Msg<keyof Source & string> | null | undefined): string | null;
|
|
71
|
+
/** Codes with a registered catalog, for a test's registry-agreement check. */
|
|
72
|
+
readonly catalogCodes: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
export declare function createI18n<Source extends Catalog, Code extends string>(options: I18nOptions<Source, Code>): I18n<Source, Code>;
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @tapestry-ui/i18n — the loom's tongues, as a library.
|
|
2
|
+
//
|
|
3
|
+
// A UTILITY, not a component (Brandon's naming, 2026-10-08). The scope now
|
|
4
|
+
// holds two kinds of thing: components that render — accordion, modal, tabs,
|
|
5
|
+
// ask, combobox, tour — and utilities that do not. sortable is the other one:
|
|
6
|
+
// a hook with no markup of its own. This has no markup either; it is a factory
|
|
7
|
+
// and five functions.
|
|
8
|
+
//
|
|
9
|
+
// Lifted from Brandskviða, which has been running this shape across eleven
|
|
10
|
+
// locales since 2026-09. The game is the pattern of record; this is the part of
|
|
11
|
+
// it that knows nothing about any one app.
|
|
12
|
+
//
|
|
13
|
+
// THE SHAPE: THE APP SETS KEYS, THE DOM RESOLVES. A call site produces a key
|
|
14
|
+
// (plus vars); the render resolves it against the active locale; `locale` is a
|
|
15
|
+
// signal, so switching it re-renders every visible string with no reload and no
|
|
16
|
+
// provider to thread through the tree.
|
|
17
|
+
//
|
|
18
|
+
// NAMES READ LIKE ENGLISH, by house law: translate · textOf · message. Never a
|
|
19
|
+
// lazy `t()`.
|
|
20
|
+
//
|
|
21
|
+
// WHAT THIS DELIBERATELY DOES NOT DO: own your catalog, know your locale codes,
|
|
22
|
+
// or understand your domain. createI18n() is a factory you hand a source
|
|
23
|
+
// catalog; everything it returns is typed to YOUR keys, so a typo is a compile
|
|
24
|
+
// error and a translation file that drifts fails the build.
|
|
25
|
+
import { signal } from "@preact/signals";
|
|
26
|
+
import { formatMessage } from "./format";
|
|
27
|
+
export { formatMessage } from "./format";
|
|
28
|
+
const PSEUDO_MAP = {
|
|
29
|
+
a: "å", e: "é", i: "ï", o: "ö", u: "û", y: "ý", c: "ç", n: "ñ", s: "š", z: "ž",
|
|
30
|
+
A: "Å", E: "É", I: "Ï", O: "Ö", U: "Û", C: "Ç", N: "Ñ", S: "Š", Z: "Ž", T: "Ŧ", L: "Ŀ",
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Accented look-alikes, ~35% padding, and [brackets], with `{…}` placeholders
|
|
34
|
+
* left intact. Its whole job: UNBRACKETED TEXT ON SCREEN IS A HARDCODED STRING,
|
|
35
|
+
* and overflow is a layout that assumed English lengths.
|
|
36
|
+
*/
|
|
37
|
+
// A placeholder, INCLUDING a one-level-nested one. The obvious `\{[^}]*\}`
|
|
38
|
+
// stops at the first inner brace, so it chops a plural in half and the
|
|
39
|
+
// mangling then eats the word `other` — the branch the parser needs. Found by
|
|
40
|
+
// the pseudo-locale catching itself, 2026-10-08.
|
|
41
|
+
const PLACEHOLDER = /(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/;
|
|
42
|
+
/** The shape of a plural, so its BRANCHES can be mangled while its SYNTAX is
|
|
43
|
+
* left alone. Protecting the whole expression would keep `other` readable but
|
|
44
|
+
* leave the human words inside it in plain English — half a pseudo-locale. */
|
|
45
|
+
const PLURAL_SHAPE = /^\{(\w+), plural,((?: \w+ \{[^{}]*\})+)\}$/;
|
|
46
|
+
const PLURAL_BRANCH = / (\w+) \{([^{}]*)\}/g;
|
|
47
|
+
const mangleWords = (text) => text.replace(/[a-zA-Z]/g, ch => PSEUDO_MAP[ch] ?? ch);
|
|
48
|
+
export function pseudoize(source) {
|
|
49
|
+
const mangled = source
|
|
50
|
+
.split(PLACEHOLDER)
|
|
51
|
+
.map(part => {
|
|
52
|
+
if (!part.startsWith("{"))
|
|
53
|
+
return mangleWords(part);
|
|
54
|
+
const plural = part.match(PLURAL_SHAPE);
|
|
55
|
+
if (!plural)
|
|
56
|
+
return part; // a plain {var} or {name.x} passes through whole
|
|
57
|
+
const branches = plural[2].replace(PLURAL_BRANCH, (_whole, category, body) => ` ${category} {${mangleWords(body)}}`);
|
|
58
|
+
return `{${plural[1]}, plural,${branches}}`;
|
|
59
|
+
})
|
|
60
|
+
.join("");
|
|
61
|
+
return `[${mangled}${"·".repeat(Math.max(0, Math.ceil(source.length * 0.35)))}]`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolution order: an explicit `?lang=` wins, then what was saved, then the
|
|
65
|
+
* browser's own preference, then the source language. Pure over its inputs so a
|
|
66
|
+
* test can lock it without a DOM.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveLocale(urlLang, saved, navigatorLanguage, supported, fallback) {
|
|
69
|
+
for (const candidate of [urlLang, saved, navigatorLanguage.split("-")[0]]) {
|
|
70
|
+
if (candidate && supported.includes(candidate))
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* THE PER-KEY FALLBACK, which is what makes a partial catalog safe: a locale
|
|
77
|
+
* that ships a key uses it; anything else — a key it never reached, or a wholly
|
|
78
|
+
* empty stub — falls through to the source, never to a blank string. Pure, so
|
|
79
|
+
* the contract is directly testable with a synthetic catalog.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveMessage(catalog, source, key) {
|
|
82
|
+
return catalog[key] ?? source[key] ?? key;
|
|
83
|
+
}
|
|
84
|
+
export function createI18n(options) {
|
|
85
|
+
const { source, registry, storageKey, sourceCode, pseudoCode = "pseudo" } = options;
|
|
86
|
+
const catalogs = (options.catalogs ?? {});
|
|
87
|
+
const supported = [sourceCode, pseudoCode, ...Object.keys(catalogs)];
|
|
88
|
+
let pseudoCache = null;
|
|
89
|
+
const pseudoCatalog = () => {
|
|
90
|
+
pseudoCache ?? (pseudoCache = Object.fromEntries(Object.entries(source).map(([k, v]) => [k, pseudoize(v)])));
|
|
91
|
+
return pseudoCache;
|
|
92
|
+
};
|
|
93
|
+
const startup = () => {
|
|
94
|
+
try {
|
|
95
|
+
const url = new URLSearchParams(window.location.search).get("lang");
|
|
96
|
+
const saved = window.localStorage.getItem(storageKey);
|
|
97
|
+
return resolveLocale(url, saved, navigator.language || sourceCode, supported, sourceCode);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return sourceCode;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const locale = signal(typeof window === "undefined" ? sourceCode : startup());
|
|
104
|
+
const stampHtmlLang = (code) => {
|
|
105
|
+
try {
|
|
106
|
+
document.documentElement.lang = code === pseudoCode ? `${sourceCode}-x-pseudo` : code;
|
|
107
|
+
}
|
|
108
|
+
catch { /* no DOM, as in a test */ }
|
|
109
|
+
};
|
|
110
|
+
stampHtmlLang(locale.value);
|
|
111
|
+
const setLocale = (next) => {
|
|
112
|
+
locale.value = next;
|
|
113
|
+
try {
|
|
114
|
+
window.localStorage.setItem(storageKey, next);
|
|
115
|
+
}
|
|
116
|
+
catch { /* private mode */ }
|
|
117
|
+
stampHtmlLang(next);
|
|
118
|
+
};
|
|
119
|
+
const catalogFor = (code) => {
|
|
120
|
+
if (code === sourceCode)
|
|
121
|
+
return source;
|
|
122
|
+
if (code === pseudoCode)
|
|
123
|
+
return pseudoCatalog();
|
|
124
|
+
return catalogs[code] ?? {};
|
|
125
|
+
};
|
|
126
|
+
const translate = (key, vars) => formatMessage(resolveMessage(catalogFor(locale.value), source, key), vars, {
|
|
127
|
+
// the pseudo-locale is English wearing a costume; its plural rules are English's
|
|
128
|
+
plurals: locale.value === pseudoCode ? sourceCode : locale.value,
|
|
129
|
+
tokens: options.tokens,
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
keyType: undefined,
|
|
133
|
+
locale,
|
|
134
|
+
registry,
|
|
135
|
+
setLocale,
|
|
136
|
+
catalogCodes: Object.keys(catalogs),
|
|
137
|
+
message: (k, vars) => (vars ? { k, vars } : { k }),
|
|
138
|
+
translate,
|
|
139
|
+
textOf: value => (value == null ? "" : typeof value === "string" ? value : translate(value.k, value.vars)),
|
|
140
|
+
keyOf: value => (value != null && typeof value === "object" ? value.k : null),
|
|
141
|
+
};
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tapestry-ui/i18n",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A small i18n core for Preact apps — a typed catalog, per-key fallback, CLDR plurals, and a pseudo-locale that makes untranslated text visible. The app sets keys, the DOM resolves.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Brandon Minton",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/i18n.d.ts",
|
|
11
|
+
"default": "./dist/i18n.js"
|
|
12
|
+
},
|
|
13
|
+
"./format": {
|
|
14
|
+
"types": "./dist/format.d.ts",
|
|
15
|
+
"default": "./dist/format.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@preact/signals": ">=1.3.0",
|
|
32
|
+
"preact": ">=10.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@preact/signals": "^2.0.0",
|
|
36
|
+
"jsdom": "^25.0.1",
|
|
37
|
+
"preact": "^10.29.4",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"vitest": "^4.1.10"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/ryurage/brandonminton.git",
|
|
44
|
+
"directory": "packages/tapestry-ui/i18n"
|
|
45
|
+
},
|
|
46
|
+
"keywords": ["preact", "i18n", "l10n", "translation", "locale", "pseudo-locale", "signals", "tapestry-ui"]
|
|
47
|
+
}
|