@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 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
- # Temporary Holding Version
1
+ # @tapestry-ui/i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }