@ultimat3/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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/package.json +35 -0
  4. package/src/catalog.d.ts +28 -0
  5. package/src/catalog.d.ts.map +1 -0
  6. package/src/catalog.js +81 -0
  7. package/src/catalog.js.map +1 -0
  8. package/src/catalog.ts +132 -0
  9. package/src/catalogs/en.json +212 -0
  10. package/src/context.d.ts +64 -0
  11. package/src/context.d.ts.map +1 -0
  12. package/src/context.js +132 -0
  13. package/src/context.js.map +1 -0
  14. package/src/context.ts +195 -0
  15. package/src/define-catalogs.ts +56 -0
  16. package/src/errors.d.ts +22 -0
  17. package/src/errors.d.ts.map +1 -0
  18. package/src/errors.js +52 -0
  19. package/src/errors.js.map +1 -0
  20. package/src/errors.ts +73 -0
  21. package/src/extract.d.ts +67 -0
  22. package/src/extract.d.ts.map +1 -0
  23. package/src/extract.js +106 -0
  24. package/src/extract.js.map +1 -0
  25. package/src/extract.ts +184 -0
  26. package/src/framework.d.ts +12 -0
  27. package/src/framework.d.ts.map +1 -0
  28. package/src/framework.js +16 -0
  29. package/src/framework.js.map +1 -0
  30. package/src/framework.ts +19 -0
  31. package/src/index.d.ts +10 -0
  32. package/src/index.d.ts.map +1 -0
  33. package/src/index.js +10 -0
  34. package/src/index.js.map +1 -0
  35. package/src/index.ts +101 -0
  36. package/src/interpolate.d.ts +36 -0
  37. package/src/interpolate.d.ts.map +1 -0
  38. package/src/interpolate.js +96 -0
  39. package/src/interpolate.js.map +1 -0
  40. package/src/interpolate.ts +103 -0
  41. package/src/locales.d.ts +36 -0
  42. package/src/locales.d.ts.map +1 -0
  43. package/src/locales.js +141 -0
  44. package/src/locales.js.map +1 -0
  45. package/src/locales.ts +166 -0
  46. package/src/translator.d.ts +25 -0
  47. package/src/translator.d.ts.map +1 -0
  48. package/src/translator.js +37 -0
  49. package/src/translator.js.map +1 -0
  50. package/src/translator.ts +87 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
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,116 @@
1
+ # 🌍 @ultimat3/i18n
2
+
3
+ **Golden rule: no user-facing string is written twice, and a missing one is loud.**
4
+ Every string goes through `t()`. A key with no translation renders `⟦key⟧` — never blank,
5
+ never the English fallback, never silence. Numbers, dates and money are **not** i18n
6
+ strings: format those with `Intl` via [@ultimat3/money](../money) and [@ultimat3/time](../time).
7
+
8
+ | Concern | Owner | Rule |
9
+ |---|---|---|
10
+ | Registration | `define-catalogs.ts` | `defineCatalogs()` — one call, at boot, per app |
11
+ | Lookup + interpolation | `translator.ts` | `t(key, vars?)`, miss → `⟦key⟧` |
12
+ | Catalog shape | `catalog.ts` | nested authoring → flat dot-keys |
13
+ | Placeholders + plurals | `interpolate.ts` | `{var}`, CLDR categories via `Intl.PluralRules` |
14
+ | Locale resolution | `locales.ts` | supported set, `normalizeLocale`, `negotiateLocale` |
15
+ | Request locale | `context.ts` | resolved once, read from ALS — never passed by hand |
16
+ | Enforcement | `extract.ts` | `x verify` fails on a missing key in a shipped locale |
17
+
18
+ ## Declare the catalogs, once
19
+
20
+ `defineCatalogs` is the only way an app registers strings. Never call `registerCatalog` by hand.
21
+
22
+ ```ts
23
+ // the app's own packages/i18n/src/index.ts — its whole i18n module
24
+ import { defineCatalogs, type TranslationKey, type Translator, useI18n } from '@ultimat3/i18n';
25
+ import en from '../catalogs/en.json';
26
+ import es from '../catalogs/es.json';
27
+
28
+ export const catalogs = defineCatalogs({ default: 'en', locales: { en, es } });
29
+
30
+ export type AppCatalog = typeof en;
31
+ export type AppKey = TranslationKey<AppCatalog>; // 'nav.home' | 'posts.likes' | …
32
+ export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
33
+ ```
34
+
35
+ | One call | Because |
36
+ |---|---|
37
+ | validates + flattens every locale before registering any | a malformed catalog fails the boot whole, never half |
38
+ | registers the framework catalog first, the app's second | later wins, so app strings override framework strings |
39
+ | `configureLocales({ supported, fallback })` | the locale set is declared once, not twice |
40
+ | returns `{ default, locales, catalogs, keys() }` | the app's key space, for tests and tooling |
41
+
42
+ A `default` outside `locales` is `X_LOCALE_UNSUPPORTED` — and a compile error before that.
43
+
44
+ ## Read a string
45
+
46
+ ```ts
47
+ import { negotiateLocale, t } from '@ultimat3/i18n';
48
+
49
+ // per-request: the HTTP layer resolves the locale once
50
+ const locale = negotiateLocale(request.headers.get('accept-language')); // 'de'
51
+
52
+ // anywhere downstream — no locale argument, ever
53
+ t('pagination.showing', { from: 1, to: 20, total: 137 });
54
+ t('pagination.result', { count: 1 }); // "1 result"
55
+ t('pagination.result', { count: 9 }); // "9 results"
56
+ t('nav.settings'); // "⟦nav.settings⟧" — fix it or ship it broken, visibly
57
+ ```
58
+
59
+ | Call | Returns | Use for |
60
+ |---|---|---|
61
+ | `t(key, vars?)` | `string` | one string, framework or app, untyped keys |
62
+ | `useI18n<AppCatalog>()` | `Translator<AppCatalog>` | a component rendering several — unknown key is a build error |
63
+ | `translatorFor(locale)` | `Translator` | an explicit locale: mail, a worker tick, a preview |
64
+
65
+ `Translator` with no type argument keeps taking any `string`, which is what `@ultimat3/ui` and
66
+ `@ultimat3/mail` are built against. `has()` and `raw()` stay permissive on purpose — a probe for
67
+ a key that may not exist is what they are for.
68
+
69
+ ## Plurals are CLDR, not `n === 1`
70
+
71
+ Author two forms with `key` / `key_plural`, or all forms a locale needs with
72
+ `key_<category>` where category is `zero | one | two | few | many | other`.
73
+
74
+ ```json
75
+ { "files": { "n_one": "{count} plik", "n_few": "{count} pliki", "n_many": "{count} plików" } }
76
+ ```
77
+
78
+ `t('files.n', { count: 3 })` → `3 pliki`, `{ count: 5 }` → `5 plików`. Selection runs
79
+ through `Intl.PluralRules`, so Polish, Russian and Arabic work without a special case.
80
+
81
+ Suffixes are underscores on the leaf, never a nested `{ one, other }` branch: the runtime probes
82
+ `files.n_few`, and `TranslationKey` admits the stem `files.n` for exactly the same reason.
83
+
84
+ ## Catalogs
85
+
86
+ Author nested and feature-namespaced; `defineCatalogs` flattens to dot-keys and rejects a
87
+ non-string leaf with `X_CATALOG_INVALID`. Registration order is framework then app — later
88
+ wins, which is how an app overrides `errors.notFound.title` without forking the framework.
89
+
90
+ `src/catalogs/en.json` ships the framework's own strings: `errors.*`, `auth.*`,
91
+ `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.cron.*`.
92
+
93
+ ## Enforcement
94
+
95
+ `extractKeys()` scans source for `t('...')` calls; `auditCatalogs()` reports keys used,
96
+ keys missing per locale, and keys defined but never used; `assertCatalogsComplete()` is
97
+ the `x verify` gate.
98
+
99
+ ```
100
+ X_CATALOG_MISSING_KEYS: catalog is incomplete
101
+ cause: packages/i18n/catalogs/es.json is missing 2 key(s) used in source: admin.nav.jobs, common.save
102
+ fix: x i18n sync es
103
+ ```
104
+
105
+ ## Errors
106
+
107
+ | Code | When |
108
+ |---|---|
109
+ | `X_LOCALE_UNSUPPORTED` | a tag outside the supported set was asserted, or a `defineCatalogs` default that is not one of its locales |
110
+ | `X_CATALOG_MISSING_KEYS` | a shipped locale lacks a key the source uses |
111
+ | `X_CATALOG_INVALID` | non-string leaf, bad key segment, or a dotted/nested collision |
112
+
113
+ ## Why it exists
114
+
115
+ Retrofitting i18n means touching every string in the app. Structuring for many locales on
116
+ day one costs nothing; the loud-miss rule is what keeps it honest once there are two.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ultimat3/i18n",
3
+ "version": "1.0.0",
4
+ "description": "Dependency-free translator, catalog flattening, locale negotiation and loud missing-key rendering",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/i18n"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "1.0.0"
34
+ }
35
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Catalog shape only: nested authoring (`{ nav: { home: 'Home' } }`) → flat dot-key
3
+ * lookup (`nav.home`), plus merging framework strings under app strings.
4
+ */
5
+ /** What authors write, and what `catalogs/<locale>.json` contains. */
6
+ export type NestedCatalog = {
7
+ readonly [key: string]: string | NestedCatalog;
8
+ };
9
+ /** What the translator consumes: flat dot-keys → template strings. */
10
+ export type Catalog = Readonly<Record<string, string>>;
11
+ /**
12
+ * Depth-first flatten. Throws `X_CATALOG_INVALID` on a non-string leaf (arrays and
13
+ * numbers are the two mistakes translators actually make) or a duplicate flat key.
14
+ */
15
+ export declare function flattenCatalog(source: NestedCatalog, prefix?: string): Catalog;
16
+ /** Validate an untrusted value (a parsed JSON file) as a nested catalog. */
17
+ export declare function parseNestedCatalog(value: unknown, path?: string): NestedCatalog;
18
+ /** Parse + flatten in one step — the loader every catalog file goes through. */
19
+ export declare function loadCatalog(value: unknown, prefix?: string): Catalog;
20
+ /**
21
+ * Later catalogs win. Call order is framework strings first, app strings last, so an
22
+ * app can override `errors.notFound.title` without forking the framework catalog.
23
+ */
24
+ export declare function mergeCatalogs(...catalogs: readonly Catalog[]): Catalog;
25
+ export declare function catalogKeys(catalog: Catalog): string[];
26
+ /** Keys present in `base` but absent from `target` — the extractor's diff primitive. */
27
+ export declare function missingFrom(base: Catalog, target: Catalog): string[];
28
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,sEAAsE;AACtE,MAAM,MAAM,aAAa,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,aAAa,CAAA;CAAE,CAAC;AAE/E,sEAAsE;AACtE,MAAM,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAIvD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,SAAK,GAAG,OAAO,CAI1E;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,SAAK,GAAG,aAAa,CAa3E;AAED,gFAAgF;AAChF,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,SAAK,GAAG,OAAO,CAEhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAStE;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAEtD;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,EAAE,CAEpE"}
package/src/catalog.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Catalog shape only: nested authoring (`{ nav: { home: 'Home' } }`) → flat dot-key
3
+ * lookup (`nav.home`), plus merging framework strings under app strings.
4
+ */
5
+ import { catalogInvalid } from './errors';
6
+ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
7
+ /**
8
+ * Depth-first flatten. Throws `X_CATALOG_INVALID` on a non-string leaf (arrays and
9
+ * numbers are the two mistakes translators actually make) or a duplicate flat key.
10
+ */
11
+ export function flattenCatalog(source, prefix = '') {
12
+ const flat = {};
13
+ walk(source, prefix, flat);
14
+ return flat;
15
+ }
16
+ /** Validate an untrusted value (a parsed JSON file) as a nested catalog. */
17
+ export function parseNestedCatalog(value, path = '') {
18
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
19
+ throw catalogInvalid(path || '<root>', `expected an object, got ${describe(value)}`);
20
+ }
21
+ for (const [key, child] of Object.entries(value)) {
22
+ const childPath = path === '' ? key : `${path}.${key}`;
23
+ if (!KEY_SEGMENT.test(key)) {
24
+ throw catalogInvalid(childPath, 'key segments must match /^[A-Za-z0-9_-]+$/');
25
+ }
26
+ if (typeof child === 'string')
27
+ continue;
28
+ parseNestedCatalog(child, childPath);
29
+ }
30
+ return value;
31
+ }
32
+ /** Parse + flatten in one step — the loader every catalog file goes through. */
33
+ export function loadCatalog(value, prefix = '') {
34
+ return flattenCatalog(parseNestedCatalog(value), prefix);
35
+ }
36
+ /**
37
+ * Later catalogs win. Call order is framework strings first, app strings last, so an
38
+ * app can override `errors.notFound.title` without forking the framework catalog.
39
+ */
40
+ export function mergeCatalogs(...catalogs) {
41
+ const merged = {};
42
+ for (const catalog of catalogs) {
43
+ for (const key of Object.keys(catalog)) {
44
+ const value = catalog[key];
45
+ if (value !== undefined)
46
+ merged[key] = value;
47
+ }
48
+ }
49
+ return merged;
50
+ }
51
+ export function catalogKeys(catalog) {
52
+ return Object.keys(catalog).sort();
53
+ }
54
+ /** Keys present in `base` but absent from `target` — the extractor's diff primitive. */
55
+ export function missingFrom(base, target) {
56
+ return catalogKeys(base).filter((key) => !Object.hasOwn(target, key));
57
+ }
58
+ function walk(node, prefix, out) {
59
+ for (const [key, value] of Object.entries(node)) {
60
+ const path = prefix === '' ? key : `${prefix}.${key}`;
61
+ if (typeof value === 'string') {
62
+ if (Object.hasOwn(out, path)) {
63
+ throw catalogInvalid(path, 'duplicate key — a nested branch and a dotted key collide');
64
+ }
65
+ out[path] = value;
66
+ continue;
67
+ }
68
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
69
+ throw catalogInvalid(path, `expected a string or an object, got ${describe(value)}`);
70
+ }
71
+ walk(value, path, out);
72
+ }
73
+ }
74
+ function describe(value) {
75
+ if (value === null)
76
+ return 'null';
77
+ if (Array.isArray(value))
78
+ return 'an array';
79
+ return `a ${typeof value}`;
80
+ }
81
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.js","sourceRoot":"","sources":["catalog.ts"],"names":[],"mappings":"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"}
package/src/catalog.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Catalog shape only: nested authoring (`{ nav: { home: 'Home' } }`) → flat dot-key
3
+ * lookup (`nav.home`), plus merging framework strings under app strings.
4
+ */
5
+
6
+ import { catalogInvalid } from './errors';
7
+
8
+ /** What authors write, and what `catalogs/<locale>.json` contains. */
9
+ export type NestedCatalog = { readonly [key: string]: string | NestedCatalog };
10
+
11
+ /** What the translator consumes: flat dot-keys → template strings. */
12
+ export type Catalog = Readonly<Record<string, string>>;
13
+
14
+ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
15
+
16
+ /**
17
+ * Depth-first flatten. Throws `X_CATALOG_INVALID` on a non-string leaf (arrays and
18
+ * numbers are the two mistakes translators actually make) or a duplicate flat key.
19
+ */
20
+ export function flattenCatalog(source: NestedCatalog, prefix = ''): Catalog {
21
+ const flat: Record<string, string> = {};
22
+ walk(source, prefix, flat);
23
+ return flat;
24
+ }
25
+
26
+ /** Validate an untrusted value (a parsed JSON file) as a nested catalog. */
27
+ export function parseNestedCatalog(value: unknown, path = ''): NestedCatalog {
28
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
29
+ throw catalogInvalid(path || '<root>', `expected an object, got ${describe(value)}`);
30
+ }
31
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
32
+ const childPath = path === '' ? key : `${path}.${key}`;
33
+ if (!KEY_SEGMENT.test(key)) {
34
+ throw catalogInvalid(childPath, 'key segments must match /^[A-Za-z0-9_-]+$/');
35
+ }
36
+ if (typeof child === 'string') continue;
37
+ parseNestedCatalog(child, childPath);
38
+ }
39
+ return value as NestedCatalog;
40
+ }
41
+
42
+ /** Parse + flatten in one step — the loader every catalog file goes through. */
43
+ export function loadCatalog(value: unknown, prefix = ''): Catalog {
44
+ return flattenCatalog(parseNestedCatalog(value), prefix);
45
+ }
46
+
47
+ /**
48
+ * `flattenCatalog`'s inverse: the authored shape a flat catalog is written back to disk as.
49
+ * A tool that writes `{ "nav.home": "Home" }` produces a file `parseNestedCatalog` then refuses —
50
+ * a dot is not a key segment — so anything that reads a catalog, edits it and writes it back has
51
+ * to come through here or it round-trips into `X_CATALOG_INVALID`.
52
+ *
53
+ * A key that would nest under a leaf (`nav` and `nav.home` in the same catalog) is the same
54
+ * branch/leaf collision `flattenCatalog` refuses in the other direction, reported as such.
55
+ *
56
+ * Every node is `Object.create(null)`, never `{}`, because a catalog is untrusted input read off
57
+ * disk: on a normal object `node['__proto__']` resolves to `Object.prototype` instead of reading
58
+ * as absent, so the key `__proto__.polluted` would walk into the prototype and write to it. A
59
+ * null-prototype node makes `__proto__` an ordinary segment — it nests and serializes like any
60
+ * other, and nothing outside this catalog can be reached from a key.
61
+ */
62
+ export function nestCatalog(catalog: Catalog): NestedCatalog {
63
+ const root = Object.create(null) as Record<string, unknown>;
64
+ for (const key of catalogKeys(catalog)) {
65
+ const value = catalog[key];
66
+ if (value === undefined) continue;
67
+ const segments = key.split('.');
68
+ const leaf = segments.pop();
69
+ if (leaf === undefined) continue;
70
+ let node = root;
71
+ for (const segment of segments) {
72
+ const child = node[segment];
73
+ if (child === undefined) node[segment] = Object.create(null);
74
+ else if (typeof child !== 'object') {
75
+ throw catalogInvalid(key, 'duplicate key — a nested branch and a dotted key collide');
76
+ }
77
+ node = node[segment] as Record<string, unknown>;
78
+ }
79
+ if (typeof node[leaf] === 'object') {
80
+ throw catalogInvalid(key, 'duplicate key — a nested branch and a dotted key collide');
81
+ }
82
+ node[leaf] = value;
83
+ }
84
+ return root as NestedCatalog;
85
+ }
86
+
87
+ /**
88
+ * Later catalogs win. Call order is framework strings first, app strings last, so an
89
+ * app can override `errors.notFound.title` without forking the framework catalog.
90
+ */
91
+ export function mergeCatalogs(...catalogs: readonly Catalog[]): Catalog {
92
+ const merged: Record<string, string> = {};
93
+ for (const catalog of catalogs) {
94
+ for (const key of Object.keys(catalog)) {
95
+ const value = catalog[key];
96
+ if (value !== undefined) merged[key] = value;
97
+ }
98
+ }
99
+ return merged;
100
+ }
101
+
102
+ export function catalogKeys(catalog: Catalog): string[] {
103
+ return Object.keys(catalog).sort();
104
+ }
105
+
106
+ /** Keys present in `base` but absent from `target` — the extractor's diff primitive. */
107
+ export function missingFrom(base: Catalog, target: Catalog): string[] {
108
+ return catalogKeys(base).filter((key) => !Object.hasOwn(target, key));
109
+ }
110
+
111
+ function walk(node: NestedCatalog, prefix: string, out: Record<string, string>): void {
112
+ for (const [key, value] of Object.entries(node)) {
113
+ const path = prefix === '' ? key : `${prefix}.${key}`;
114
+ if (typeof value === 'string') {
115
+ if (Object.hasOwn(out, path)) {
116
+ throw catalogInvalid(path, 'duplicate key — a nested branch and a dotted key collide');
117
+ }
118
+ out[path] = value;
119
+ continue;
120
+ }
121
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
122
+ throw catalogInvalid(path, `expected a string or an object, got ${describe(value)}`);
123
+ }
124
+ walk(value, path, out);
125
+ }
126
+ }
127
+
128
+ function describe(value: unknown): string {
129
+ if (value === null) return 'null';
130
+ if (Array.isArray(value)) return 'an array';
131
+ return `a ${typeof value}`;
132
+ }
@@ -0,0 +1,212 @@
1
+ {
2
+ "common": {
3
+ "save": "Save",
4
+ "cancel": "Cancel",
5
+ "delete": "Delete",
6
+ "edit": "Edit",
7
+ "create": "Create",
8
+ "search": "Search",
9
+ "filter": "Filter",
10
+ "close": "Close",
11
+ "retry": "Try again",
12
+ "loading": "Loading…",
13
+ "empty": "Nothing here yet.",
14
+ "confirm": "Are you sure?",
15
+ "yes": "Yes",
16
+ "no": "No",
17
+ "copy": "Copy",
18
+ "copied": "Copied",
19
+ "signedInAs": "Signed in as {email}"
20
+ },
21
+ "errors": {
22
+ "notFound": {
23
+ "title": "Page not found",
24
+ "body": "We looked for {path} and came up empty.",
25
+ "action": "Back to safety"
26
+ },
27
+ "forbidden": {
28
+ "title": "Not allowed",
29
+ "body": "Your account can't do this. Ask an admin for the {permission} permission.",
30
+ "action": "Back"
31
+ },
32
+ "unauthorized": {
33
+ "title": "Please sign in",
34
+ "body": "This page needs an account.",
35
+ "action": "Sign in"
36
+ },
37
+ "serverError": {
38
+ "title": "Something broke on our side",
39
+ "body": "The error was recorded as {traceId}.",
40
+ "action": "Reload"
41
+ },
42
+ "offline": {
43
+ "title": "You're offline",
44
+ "body": "Changes are saved locally and will sync when you reconnect.",
45
+ "action": "Retry now"
46
+ },
47
+ "rateLimited": {
48
+ "title": "Too many requests",
49
+ "body": "Try again in {seconds} seconds.",
50
+ "action": "Wait"
51
+ },
52
+ "validation": {
53
+ "title": "Check the highlighted fields",
54
+ "count": "{count} field needs attention",
55
+ "count_plural": "{count} fields need attention"
56
+ }
57
+ },
58
+ "validation": {
59
+ "required": "Required",
60
+ "tooShort": "Use at least {min} characters",
61
+ "tooLong": "Use at most {max} characters",
62
+ "notEmail": "Enter a valid email address",
63
+ "notUrl": "Enter a valid URL",
64
+ "notNumber": "Enter a number",
65
+ "outOfRange": "Enter a value between {min} and {max}",
66
+ "mismatch": "Values don't match"
67
+ },
68
+ "auth": {
69
+ "signIn": {
70
+ "title": "Sign in",
71
+ "submit": "Sign in",
72
+ "email": "Email",
73
+ "password": "Password",
74
+ "forgot": "Forgot your password?",
75
+ "noAccount": "Don't have an account?",
76
+ "createAccount": "Create one",
77
+ "magicLink": "Email me a sign-in link",
78
+ "failed": "That email and password don't match."
79
+ },
80
+ "signUp": {
81
+ "title": "Create your account",
82
+ "submit": "Create account",
83
+ "name": "Name",
84
+ "haveAccount": "Already have an account?",
85
+ "terms": "By continuing you agree to the {termsLink}.",
86
+ "taken": "That email is already registered."
87
+ },
88
+ "resetPassword": {
89
+ "title": "Reset your password",
90
+ "submit": "Send reset link",
91
+ "sent": "Check {email} for a reset link.",
92
+ "newPassword": "New password",
93
+ "expired": "That link expired. Request a new one."
94
+ },
95
+ "verifyEmail": {
96
+ "title": "Verify your email",
97
+ "body": "We sent a code to {email}.",
98
+ "submit": "Verify",
99
+ "resend": "Resend code"
100
+ },
101
+ "twoFactor": {
102
+ "title": "Two-factor code",
103
+ "body": "Enter the 6-digit code from your authenticator app.",
104
+ "submit": "Verify",
105
+ "recovery": "Use a recovery code"
106
+ },
107
+ "signOut": "Sign out",
108
+ "session": {
109
+ "expired": "Your session expired. Sign in again.",
110
+ "current": "This device"
111
+ }
112
+ },
113
+ "pagination": {
114
+ "previous": "Previous",
115
+ "next": "Next",
116
+ "first": "First",
117
+ "last": "Last",
118
+ "page": "Page {page} of {pages}",
119
+ "showing": "Showing {from}–{to} of {total}",
120
+ "perPage": "{count} per page",
121
+ "result": "{count} result",
122
+ "result_plural": "{count} results",
123
+ "loadMore": "Load more",
124
+ "noMore": "That's everything."
125
+ },
126
+ "admin": {
127
+ "nav": {
128
+ "dashboard": "Dashboard",
129
+ "entities": "Data",
130
+ "actions": "Actions",
131
+ "jobs": "Jobs",
132
+ "tasks": "Schedules",
133
+ "queries": "Queries",
134
+ "policies": "Policies",
135
+ "users": "Users",
136
+ "settings": "Settings",
137
+ "logs": "Logs",
138
+ "traces": "Traces",
139
+ "migrations": "Migrations"
140
+ },
141
+ "dashboard": {
142
+ "title": "Dashboard",
143
+ "health": "Health",
144
+ "throughput": "Requests per minute",
145
+ "errorRate": "Error rate",
146
+ "latency": "p95 latency",
147
+ "queueDepth": "Queue depth",
148
+ "activeUsers": "Active users",
149
+ "since": "since {when}"
150
+ },
151
+ "jobs": {
152
+ "title": "Jobs",
153
+ "queued": "Queued",
154
+ "running": "Running",
155
+ "succeeded": "Succeeded",
156
+ "failed": "Failed",
157
+ "retrying": "Retrying",
158
+ "cancelled": "Cancelled",
159
+ "attempt": "Attempt {attempt} of {attempts}",
160
+ "nextRun": "Next run {when}",
161
+ "retryNow": "Retry now",
162
+ "cancel": "Cancel job",
163
+ "step": "Step {name}",
164
+ "pending": "{count} job waiting",
165
+ "pending_plural": "{count} jobs waiting"
166
+ },
167
+ "tasks": {
168
+ "title": "Schedules",
169
+ "cron": "Cron",
170
+ "timezone": "Timezone",
171
+ "lastRun": "Last run",
172
+ "nextRun": "Next run",
173
+ "runNow": "Run now",
174
+ "pause": "Pause",
175
+ "resume": "Resume"
176
+ },
177
+ "entities": {
178
+ "title": "Data",
179
+ "rows": "{count} row",
180
+ "rows_plural": "{count} rows",
181
+ "newRecord": "New {entity}",
182
+ "deleteRecord": "Delete this {entity}?",
183
+ "noResults": "No {entity} matched that filter."
184
+ },
185
+ "table": {
186
+ "sortAscending": "Sort ascending",
187
+ "sortDescending": "Sort descending",
188
+ "columns": "Columns",
189
+ "selected": "{count} selected",
190
+ "selectAll": "Select all",
191
+ "clearSelection": "Clear selection"
192
+ }
193
+ },
194
+ "time": {
195
+ "cron": {
196
+ "everyMinute": "every minute",
197
+ "everyNMinutes": "every {n} minutes",
198
+ "everyHour": "every hour",
199
+ "everyNHours": "every {n} hours",
200
+ "at": "at {time}",
201
+ "onDaysOfMonth": "on day {days}",
202
+ "onWeekdays": "on {days}",
203
+ "inMonths": "in {months}",
204
+ "everyDay": "every day",
205
+ "and": "and",
206
+ "invalid": "not a valid cron expression"
207
+ },
208
+ "relative": {
209
+ "now": "just now"
210
+ }
211
+ }
212
+ }