@burdenoff/fe-libs 2026.1021.8 → 2026.1021.9

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@burdenoff/fe-libs",
3
- "version": "2026.1021.8",
3
+ "version": "2026.1021.9",
4
4
  "description": "Burdenoff frontend primitives and domain libraries",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts",
8
- "a11y-audit": "./scripts/a11y-audit/cli.ts"
8
+ "a11y-audit": "./scripts/a11y-audit/cli.ts",
9
+ "burdenoff-i18n": "./scripts/i18n/cli.ts"
9
10
  },
10
11
  "imports": {
11
12
  "#lib/*": "./src/lib/*",
@@ -159,6 +160,10 @@
159
160
  },
160
161
  "./shared/tokens/tokens.css": "./src/shared/tokens/tokens.css",
161
162
  "./shared/styles": "./src/shared/styles/index.css",
163
+ "./scripts/i18n": {
164
+ "types": "./scripts/i18n/index.ts",
165
+ "import": "./scripts/i18n/index.ts"
166
+ },
162
167
  "./vite": {
163
168
  "types": "./src/vite/index.ts",
164
169
  "import": "./src/vite/index.ts"
@@ -301,6 +306,8 @@
301
306
  "scripts/graphql-schema-drift/README.md",
302
307
  "scripts/a11y-audit/*.ts",
303
308
  "scripts/a11y-audit/README.md",
309
+ "scripts/i18n/*.ts",
310
+ "scripts/i18n/README.md",
304
311
  "README.md",
305
312
  "LICENSE"
306
313
  ],
@@ -315,7 +322,7 @@
315
322
  "test:package-livekit-optional": "node scripts/validate-livekit-optional.mjs",
316
323
  "verify:package": "node scripts/verify-package-contents.mjs",
317
324
  "test:handsfree-browser": "node scripts/handsfree-browser-run.mjs",
318
- "test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ src/credentials src/connections src/storage src/shared/assistant src/shared/components src/shared/graphql/links/sse-link.test.ts src/shared/graphql/fetch.test.ts src/shared/graphql/prepareWorkspaceRequest.test.ts src/shared/graphql/client.test.ts src/shared/utils/apollo.test.ts src/shared/utils/canvasTokens.test.ts src/shared/graphql/tourWorkspaceAuth.test.ts src/shared/config/gatewayUrls.test.ts src/shared/config/authBridgeUrls.test.ts src/shared/config/shellRoutes.test.ts src/shared/config/gatewayFallback.test.ts scripts/__tests__ src/shared/config/products.test.ts src/shared/config/productSlug.test.ts src/shared/hooks/shell/useProducts.test.ts src/shared/providers/shell/brandAttribute.test.ts src/chrome/ProductSwitcher.test.ts src/shared/utils/gatewayFetchRewrite.test.ts src/shared/utils/authCookie.test.ts src/shared/utils/redirectAllowlist.test.ts src/shared/providers/shell/ProfileStorageManager.test.ts src/shared/providers/AppShellProvider.test.ts src/shared/providers/shell/interpolate.test.ts src/shared/providers/shell/resolveTranslation.test.ts src/shared/providers/shell/I18nProvider.render.test.tsx src/shared/providers/shell/I18nProvider.client.test.tsx src/shared/providers/shell/translationStore.test.ts src/shared/providers/shell/translationLayers.test.ts src/shared/providers/shell/directionMode.test.ts src/shared/providers/shell/authProvider/ssoRelay.test.ts src/shared/providers/shell/authProvider/useAuthActions.signOut.test.tsx src/shared/providers/shell/personaSurfaceRemote.test.ts src/shared/providers/shell/workspaceSwitch.client.test.tsx src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/shared/navigation src/shared/tours src/chrome/__tests__ src/ui/__tests__",
325
+ "test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ scripts/i18n/__tests__ src/credentials src/connections src/storage src/shared/assistant src/shared/components src/shared/graphql/links/sse-link.test.ts src/shared/graphql/fetch.test.ts src/shared/graphql/prepareWorkspaceRequest.test.ts src/shared/graphql/client.test.ts src/shared/utils/apollo.test.ts src/shared/utils/canvasTokens.test.ts src/shared/graphql/tourWorkspaceAuth.test.ts src/shared/config/gatewayUrls.test.ts src/shared/config/authBridgeUrls.test.ts src/shared/config/shellRoutes.test.ts src/shared/config/gatewayFallback.test.ts scripts/__tests__ src/shared/config/products.test.ts src/shared/config/productSlug.test.ts src/shared/hooks/shell/useProducts.test.ts src/shared/providers/shell/brandAttribute.test.ts src/chrome/ProductSwitcher.test.ts src/shared/utils/gatewayFetchRewrite.test.ts src/shared/utils/authCookie.test.ts src/shared/utils/redirectAllowlist.test.ts src/shared/providers/shell/ProfileStorageManager.test.ts src/shared/providers/AppShellProvider.test.ts src/shared/providers/shell/interpolate.test.ts src/shared/providers/shell/resolveTranslation.test.ts src/shared/providers/shell/I18nProvider.render.test.tsx src/shared/providers/shell/I18nProvider.client.test.tsx src/shared/providers/shell/translationStore.test.ts src/shared/providers/shell/translationLayers.test.ts src/shared/providers/shell/directionMode.test.ts src/shared/providers/shell/authProvider/ssoRelay.test.ts src/shared/providers/shell/authProvider/useAuthActions.signOut.test.tsx src/shared/providers/shell/personaSurfaceRemote.test.ts src/shared/providers/shell/workspaceSwitch.client.test.tsx src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/shared/navigation src/shared/tours src/chrome/__tests__ src/ui/__tests__",
319
326
  "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run lint:sanity && bun run format && bun run type:check && bun run test && bun run build && bun run verify:package'",
320
327
  "switch:status": "bun scripts/deps-mode.ts status",
321
328
  "switch:remote": "bun scripts/deps-mode.ts remote",
@@ -0,0 +1,117 @@
1
+ # burdenoff-i18n — shared i18n bundle gates
2
+
3
+ Every app shell publishes `/i18n-bundle.json`, and the gitops seed jobs
4
+ (`seed:translations --bundle-url=https://app.<domain>/i18n-bundle.json`) write
5
+ it into global-i18n-svc. The seeder replaces rows (DELETE, then INSERT), so a
6
+ bundle that drops a key, or changes a value, changes what production serves.
7
+
8
+ VibeControls and FluidGrids each kept about 20 i18n scripts to guard this, and
9
+ the copies had drifted apart. The scripts that do not depend on a product live
10
+ here, as one CLI. A shell keeps only the part that reads its own translation
11
+ sources: the extractor and the bundle builder. The commands reach that part
12
+ through a small adapter module.
13
+
14
+ > **Keep publishing `/i18n-bundle.json`.** It is the seed input. The gitops seed
15
+ > jobs download it, and `served` below reads it. Leave it out only of native
16
+ > IPA/APK packaging.
17
+
18
+ ## Commands
19
+
20
+ | Command | Fails (exit 1) when |
21
+ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
22
+ | `verify-shipped [--bundle=<src>] [--warn]` | the installed, compiled packages request a key that the bundle does not define, and the builder did not hold that key back on purpose |
23
+ | `digest [<src>] [--order=utf8\|utf16]` | never; prints the key and row counts plus the set digests as JSON |
24
+ | `diff <base> <proposed> [--allow-updates] [--allow-deletions] [--json=PATH] [--limit=N]` | seeding `<proposed>` over `<base>` would change or delete a row that is served today |
25
+ | `served-tree [<src>] [--baseline=<src>] [--reads-leaf-slot=true\|false]` | the nested export would serve a key differently from the bundle. With `--baseline`, it fails only when a key that resolves today would be hidden or shadowed |
26
+
27
+ Exit `2` means the check could not run. That covers a bad config or argument,
28
+ a bundle that is unreadable or malformed, and a check with nothing to look at,
29
+ such as no installed packages. A check that could not run is never reported as
30
+ a pass.
31
+
32
+ `<src>` can be one of:
33
+
34
+ - `built`: the adapter's `buildBundle()`, or else `bundleFile`. This is the default.
35
+ - `served`: `servedUrl`.
36
+ - a file path.
37
+ - an `https://` URL. `http://` is refused.
38
+
39
+ ## Install in a shell
40
+
41
+ `@burdenoff/fe-libs` ships the CLI as the package `bin` `burdenoff-i18n`:
42
+
43
+ ```json
44
+ "i18n:verify-shipped": "burdenoff-i18n verify-shipped",
45
+ "i18n:digest": "burdenoff-i18n digest",
46
+ "i18n:diff-served": "burdenoff-i18n diff served built",
47
+ "i18n:served-tree": "burdenoff-i18n served-tree built --baseline=served"
48
+ ```
49
+
50
+ `i18n.config.json` at the shell root:
51
+
52
+ ```json
53
+ {
54
+ "product": "fluidgrids",
55
+ "servedUrl": "https://app.fluidgrids.ai/i18n-bundle.json",
56
+ "adapter": "scripts/i18n-adapter.ts",
57
+ "shipped": { "scope": "@burdenoff", "packages": ["microfe-*", "fluidgrids-fe-sdk"] },
58
+ "readsLeafSlot": true,
59
+ "digestOrder": "utf8"
60
+ }
61
+ ```
62
+
63
+ Unknown keys are rejected. The defaults are scope `@burdenoff`, packages
64
+ `["microfe-*"]`, `readsLeafSlot: true` (fe-libs ≥ 2026.919.1 reads a branch's
65
+ `_` slot) and `digestOrder: "utf8"`.
66
+
67
+ The adapter is a TypeScript module that Bun imports. Both exports are optional:
68
+
69
+ ```ts
70
+ import type { I18nAdapter } from '@burdenoff/fe-libs/scripts/i18n';
71
+ import { buildBundle, heldBackKeys } from './build-i18n-bundle'; // the shell's own builder
72
+ import { loadAssistantSeedCopy } from './i18n-assistant-keys';
73
+
74
+ export const adapter: I18nAdapter = {
75
+ // The bundle exactly as the build emits it, plus keys it holds back on purpose.
76
+ async buildBundle() {
77
+ const built = await buildBundle({ quiet: true });
78
+ return { bundle: built.bundle, heldBack: heldBackKeys(built) };
79
+ },
80
+ // Keys that the shipped code requests but a compiled-call scan cannot see,
81
+ // e.g. runtime-prefixed assistant keys.
82
+ async requestedKeys() {
83
+ const copy = await loadAssistantSeedCopy();
84
+ return Object.fromEntries(
85
+ Object.entries(copy).map(([key, fallback]) => [key, { pkg: 'fe-libs assistant', fallback }])
86
+ );
87
+ },
88
+ };
89
+ export default adapter;
90
+ ```
91
+
92
+ A shell that writes its bundle to disk can skip the adapter and set
93
+ `"bundleFile": "dist/i18n-bundle.json"`.
94
+
95
+ ## Digests
96
+
97
+ `digest` hashes each row as `JSON.stringify([key, locale, value])`. It sorts the
98
+ lines in `order`, joins them with `\n` and takes the sha256. Two bundles with
99
+ the same rows therefore have the same digest, whatever order the rows are in.
100
+ `utf8` sorts by UTF-8 bytes (code points), which is how FluidGrids'
101
+ served-bundle fixture was generated. `utf16` sorts by JavaScript's default
102
+ comparison, which is how VibeControls' seed-plan fixtures were generated. The
103
+ two orders differ only for characters outside the Basic Multilingual Plane.
104
+
105
+ ## Programmatic use
106
+
107
+ `@burdenoff/fe-libs/scripts/i18n` exports the pieces for shell scripts and
108
+ tests:
109
+
110
+ - bundles: `parseBundle`, `loadBundleSource`, `digestBundle`, `diffBundles`, `setDigest`
111
+ - the nested-export model: `buildNestedDictionary`, `resolvableKeys`, `hidingPrefix`, `compareServed`
112
+ - the compiled-call scan: `shippedKeys`, `collectShippedKeys`
113
+
114
+ The nested-export model is the single port of global-i18n-svc's
115
+ `buildNestedDictionary` (2026.920.1, BOFF-7458/7459). It replaces the separate
116
+ copies in `vibecontrols-app/scripts/i18n-nested-export.ts` and
117
+ `fluidgrids-app/scripts/i18n-served-tree.ts`.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Reading, digesting and diffing emitted i18n bundles.
3
+ *
4
+ * Every function here is pure except `loadBundleSource`, so the seed contract
5
+ * (what a bundle would insert, change or delete in production) is unit-testable
6
+ * without a network or a database.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { readFileSync } from 'node:fs';
11
+ import type {
12
+ BundleDiff,
13
+ BundleDigest,
14
+ BundleKey,
15
+ BundleRow,
16
+ DigestOrder,
17
+ I18nBundle,
18
+ RowChange,
19
+ } from './types';
20
+
21
+ export class BundleError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = 'BundleError';
25
+ }
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /**
33
+ * Validate an emitted bundle. A malformed bundle is an error, never an empty
34
+ * one: a digest or diff of "no rows" would read as "seeds nothing" and pass.
35
+ * Duplicate keys are refused because the seeded row set would then depend on
36
+ * which entry the seeder reads last.
37
+ */
38
+ export function parseBundle(raw: unknown, label = 'bundle'): I18nBundle {
39
+ if (!isRecord(raw) || !Array.isArray(raw.keys)) {
40
+ throw new BundleError(`${label}: expected an object with a "keys" array`);
41
+ }
42
+ const seen = new Set<string>();
43
+ const keys: BundleKey[] = [];
44
+ raw.keys.forEach((entry: unknown, index: number) => {
45
+ if (!isRecord(entry) || typeof entry.key !== 'string' || entry.key === '') {
46
+ throw new BundleError(`${label}: keys[${index}] has no "key" string`);
47
+ }
48
+ if (!isRecord(entry.values)) {
49
+ throw new BundleError(`${label}: keys[${index}] (${entry.key}) has no "values" object`);
50
+ }
51
+ if (seen.has(entry.key)) {
52
+ throw new BundleError(`${label}: key "${entry.key}" appears more than once`);
53
+ }
54
+ seen.add(entry.key);
55
+ const values: Record<string, string> = {};
56
+ for (const [locale, value] of Object.entries(entry.values)) {
57
+ if (typeof value !== 'string') {
58
+ throw new BundleError(`${label}: ${entry.key}|${locale} is not a string`);
59
+ }
60
+ values[locale] = value;
61
+ }
62
+ keys.push({
63
+ key: entry.key,
64
+ ...(typeof entry.namespace === 'string' ? { namespace: entry.namespace } : {}),
65
+ values,
66
+ });
67
+ });
68
+ // The header fields are passed through untouched and never read here, so
69
+ // the cast only names them; `keys`, the one field read, was validated above.
70
+ return { ...(raw as Omit<I18nBundle, 'keys'>), keys };
71
+ }
72
+
73
+ /** Default ceiling for a downloaded bundle; the largest served today is ~12 MB. */
74
+ export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
75
+
76
+ export interface LoadOptions {
77
+ fetchImpl?: typeof fetch;
78
+ timeoutMs?: number;
79
+ maxBytes?: number;
80
+ }
81
+
82
+ /**
83
+ * Load a bundle from a file path or an `https://` URL. Plain `http://` is
84
+ * refused: the served bundle is the production seed input, and a digest of a
85
+ * tampered copy would be worse than no digest.
86
+ */
87
+ export async function loadBundleSource(
88
+ source: string,
89
+ options: LoadOptions = {}
90
+ ): Promise<I18nBundle> {
91
+ const maxBytes = options.maxBytes ?? MAX_BUNDLE_BYTES;
92
+ if (/^[a-z]+:\/\//i.test(source)) {
93
+ if (!source.toLowerCase().startsWith('https://')) {
94
+ throw new BundleError(`${source}: only https:// URLs are accepted`);
95
+ }
96
+ const doFetch = options.fetchImpl ?? fetch;
97
+ const response = await doFetch(source, {
98
+ signal: AbortSignal.timeout(options.timeoutMs ?? 60_000),
99
+ headers: { accept: 'application/json' },
100
+ });
101
+ if (!response.ok) {
102
+ throw new BundleError(`${source}: HTTP ${response.status}`);
103
+ }
104
+ const declared = Number(response.headers.get('content-length') ?? '0');
105
+ if (declared > maxBytes) {
106
+ throw new BundleError(`${source}: larger than ${maxBytes} bytes`);
107
+ }
108
+ const text = await response.text();
109
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
110
+ throw new BundleError(`${source}: larger than ${maxBytes} bytes`);
111
+ }
112
+ return parseBundle(parseJson(text, source), source);
113
+ }
114
+ const text = readFileSync(source, 'utf8');
115
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
116
+ throw new BundleError(`${source}: larger than ${maxBytes} bytes`);
117
+ }
118
+ return parseBundle(parseJson(text, source), source);
119
+ }
120
+
121
+ function parseJson(text: string, label: string): unknown {
122
+ try {
123
+ return JSON.parse(text) as unknown;
124
+ } catch (error) {
125
+ throw new BundleError(`${label}: not valid JSON (${(error as Error).message})`);
126
+ }
127
+ }
128
+
129
+ /** Every `[key, locale, value]` row a bundle seeds, in bundle order. */
130
+ export function bundleRows(bundle: I18nBundle): BundleRow[] {
131
+ const rows: BundleRow[] = [];
132
+ for (const { key, values } of bundle.keys) {
133
+ for (const [locale, value] of Object.entries(values)) rows.push([key, locale, value]);
134
+ }
135
+ return rows;
136
+ }
137
+
138
+ /** The line a row is hashed as: `JSON.stringify([key, locale, value])`. */
139
+ export const rowLine = (row: BundleRow): string => JSON.stringify(row);
140
+
141
+ /** Compare by UTF-8 bytes, i.e. Unicode code points. */
142
+ export const utf8Order = (a: string, b: string): number =>
143
+ Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
144
+
145
+ /** Compare by UTF-16 code units (JavaScript's default `<`). */
146
+ export const utf16Order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
147
+
148
+ export const ORDERS: Readonly<Record<DigestOrder, (a: string, b: string) => number>> = {
149
+ utf8: utf8Order,
150
+ utf16: utf16Order,
151
+ };
152
+
153
+ /** sha256 of `lines` sorted in `order` and joined with `\n`: equal sets give equal digests. */
154
+ export function setDigest(lines: Iterable<string>, order: DigestOrder = 'utf8'): string {
155
+ const sorted = [...lines].sort(ORDERS[order]);
156
+ return createHash('sha256').update(sorted.join('\n'), 'utf8').digest('hex');
157
+ }
158
+
159
+ /** Counts and set digests of a bundle. */
160
+ export function digestBundle(bundle: I18nBundle, order: DigestOrder = 'utf8'): BundleDigest {
161
+ const rows = bundleRows(bundle);
162
+ const rowsByLocale: Record<string, number> = {};
163
+ for (const [, locale] of rows) rowsByLocale[locale] = (rowsByLocale[locale] ?? 0) + 1;
164
+ return {
165
+ keys: bundle.keys.length,
166
+ rows: rows.length,
167
+ order,
168
+ keySetSha256: setDigest(
169
+ bundle.keys.map((k) => k.key),
170
+ order
171
+ ),
172
+ rowSetSha256: setDigest(rows.map(rowLine), order),
173
+ rowsByLocale: sortRecord(rowsByLocale),
174
+ };
175
+ }
176
+
177
+ function sortRecord(record: Record<string, number>): Record<string, number> {
178
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => utf8Order(a, b)));
179
+ }
180
+
181
+ /** An unambiguous id for a (key, locale) pair, whatever characters either holds. */
182
+ const pair = (key: string, locale: string): string => JSON.stringify([key, locale]);
183
+
184
+ function rowMap(bundle: I18nBundle): Map<string, RowChange> {
185
+ const out = new Map<string, RowChange>();
186
+ for (const [key, locale, value] of bundleRows(bundle)) {
187
+ out.set(pair(key, locale), { key, locale, before: value, after: value });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ const byRow = (a: RowChange, b: RowChange): number =>
193
+ utf8Order(a.key, b.key) || utf8Order(a.locale, b.locale);
194
+
195
+ /**
196
+ * What seeding `proposed` over `base` (normally the served bundle) changes:
197
+ * rows inserted, rows whose value changes, and rows the proposed bundle no
198
+ * longer carries. The seeder is DELETE-then-INSERT over the bundle, so a
199
+ * "deletion" here is a translation production would stop serving.
200
+ */
201
+ export function diffBundles(base: I18nBundle, proposed: I18nBundle): BundleDiff {
202
+ const before = rowMap(base);
203
+ const after = rowMap(proposed);
204
+ const inserts: RowChange[] = [];
205
+ const updates: RowChange[] = [];
206
+ const deletions: RowChange[] = [];
207
+ for (const [id, row] of after) {
208
+ const old = before.get(id);
209
+ if (!old) inserts.push({ key: row.key, locale: row.locale, before: null, after: row.after });
210
+ else if (old.before !== row.after) {
211
+ updates.push({ key: row.key, locale: row.locale, before: old.before, after: row.after });
212
+ }
213
+ }
214
+ for (const [id, row] of before) {
215
+ if (!after.has(id)) {
216
+ deletions.push({ key: row.key, locale: row.locale, before: row.before, after: null });
217
+ }
218
+ }
219
+ const baseKeys = new Set(base.keys.map((k) => k.key));
220
+ const proposedKeys = new Set(proposed.keys.map((k) => k.key));
221
+ const insertsByLocale: Record<string, number> = {};
222
+ for (const row of inserts) insertsByLocale[row.locale] = (insertsByLocale[row.locale] ?? 0) + 1;
223
+ return {
224
+ base: { keys: baseKeys.size, rows: before.size },
225
+ proposed: { keys: proposedKeys.size, rows: after.size },
226
+ inserts: inserts.sort(byRow),
227
+ updates: updates.sort(byRow),
228
+ deletions: deletions.sort(byRow),
229
+ newKeys: [...proposedKeys].filter((k) => !baseKeys.has(k)).sort(utf8Order),
230
+ removedKeys: [...baseKeys].filter((k) => !proposedKeys.has(k)).sort(utf8Order),
231
+ insertsByLocale: sortRecord(insertsByLocale),
232
+ };
233
+ }
234
+
235
+ /** One locale's `[key, value]` rows from a bundle, in bundle order. */
236
+ export function localeRows(bundle: I18nBundle, locale: string): Array<[string, string]> {
237
+ const rows: Array<[string, string]> = [];
238
+ for (const { key, values } of bundle.keys) {
239
+ const value = values[locale];
240
+ if (value !== undefined) rows.push([key, value]);
241
+ }
242
+ return rows;
243
+ }
244
+
245
+ /** Every locale any key of the bundle carries, sorted. */
246
+ export function bundleLocales(bundle: I18nBundle): string[] {
247
+ const out = new Set<string>();
248
+ for (const { values } of bundle.keys) for (const locale of Object.keys(values)) out.add(locale);
249
+ return [...out].sort(utf8Order);
250
+ }