@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 +10 -3
- package/scripts/i18n/README.md +117 -0
- package/scripts/i18n/bundle.ts +250 -0
- package/scripts/i18n/cli.ts +281 -0
- package/scripts/i18n/commands.ts +370 -0
- package/scripts/i18n/config.ts +178 -0
- package/scripts/i18n/index.ts +63 -0
- package/scripts/i18n/servedTree.ts +270 -0
- package/scripts/i18n/shipped.ts +99 -0
- package/scripts/i18n/types.ts +140 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `i18n.config.json` parsing, and loading a shell's adapter module.
|
|
3
|
+
*
|
|
4
|
+
* A config that does not validate is a hard error (exit 2): a gate that
|
|
5
|
+
* silently degrades to "checked nothing" is the failure this toolkit removes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import type {
|
|
12
|
+
AdapterBuild,
|
|
13
|
+
DigestOrder,
|
|
14
|
+
I18nAdapter,
|
|
15
|
+
ResolvedI18nToolsConfig,
|
|
16
|
+
ShippedKey,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_CONFIG_FILE = 'i18n.config.json';
|
|
20
|
+
export const DEFAULT_SCOPE = '@burdenoff';
|
|
21
|
+
export const DEFAULT_PACKAGES = ['microfe-*'];
|
|
22
|
+
|
|
23
|
+
export class I18nConfigError extends Error {
|
|
24
|
+
readonly issues: string[];
|
|
25
|
+
|
|
26
|
+
constructor(issues: string[]) {
|
|
27
|
+
super(`Invalid i18n config:\n - ${issues.join('\n - ')}`);
|
|
28
|
+
this.name = 'I18nConfigError';
|
|
29
|
+
this.issues = issues;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const KNOWN_KEYS = new Set([
|
|
38
|
+
'$schema',
|
|
39
|
+
'$comment',
|
|
40
|
+
'product',
|
|
41
|
+
'servedUrl',
|
|
42
|
+
'bundleFile',
|
|
43
|
+
'adapter',
|
|
44
|
+
'shipped',
|
|
45
|
+
'readsLeafSlot',
|
|
46
|
+
'digestOrder',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function optionalString(
|
|
50
|
+
raw: Record<string, unknown>,
|
|
51
|
+
key: string,
|
|
52
|
+
issues: string[]
|
|
53
|
+
): string | undefined {
|
|
54
|
+
const value = raw[key];
|
|
55
|
+
if (value === undefined) return undefined;
|
|
56
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
57
|
+
issues.push(`"${key}" must be a non-empty string`);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Validate a parsed config and apply defaults; relative paths resolve against `configDir`. */
|
|
64
|
+
export function resolveI18nConfig(raw: unknown, configDir: string): ResolvedI18nToolsConfig {
|
|
65
|
+
const issues: string[] = [];
|
|
66
|
+
if (!isRecord(raw)) throw new I18nConfigError(['config must be a JSON object']);
|
|
67
|
+
|
|
68
|
+
for (const key of Object.keys(raw)) {
|
|
69
|
+
if (!KNOWN_KEYS.has(key)) issues.push(`unknown key "${key}"`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const product = optionalString(raw, 'product', issues);
|
|
73
|
+
if (product === undefined && !issues.some((i) => i.startsWith('"product"'))) {
|
|
74
|
+
issues.push('"product" is required');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const servedUrl = optionalString(raw, 'servedUrl', issues);
|
|
78
|
+
if (servedUrl !== undefined && !servedUrl.startsWith('https://')) {
|
|
79
|
+
issues.push('"servedUrl" must be an https:// URL');
|
|
80
|
+
}
|
|
81
|
+
const bundleFile = optionalString(raw, 'bundleFile', issues);
|
|
82
|
+
const adapter = optionalString(raw, 'adapter', issues);
|
|
83
|
+
|
|
84
|
+
let scope = DEFAULT_SCOPE;
|
|
85
|
+
let packages = DEFAULT_PACKAGES;
|
|
86
|
+
if (raw.shipped !== undefined) {
|
|
87
|
+
if (!isRecord(raw.shipped)) issues.push('"shipped" must be an object');
|
|
88
|
+
else {
|
|
89
|
+
const s = raw.shipped;
|
|
90
|
+
for (const key of Object.keys(s)) {
|
|
91
|
+
if (key !== 'scope' && key !== 'packages') issues.push(`unknown key "shipped.${key}"`);
|
|
92
|
+
}
|
|
93
|
+
if (s.scope !== undefined) {
|
|
94
|
+
if (typeof s.scope !== 'string' || !/^@[a-z0-9][a-z0-9._-]*$/.test(s.scope)) {
|
|
95
|
+
issues.push('"shipped.scope" must be an npm scope such as "@burdenoff"');
|
|
96
|
+
} else scope = s.scope;
|
|
97
|
+
}
|
|
98
|
+
if (s.packages !== undefined) {
|
|
99
|
+
if (
|
|
100
|
+
!Array.isArray(s.packages) ||
|
|
101
|
+
s.packages.length === 0 ||
|
|
102
|
+
!s.packages.every((p: unknown) => typeof p === 'string' && p !== '' && p !== '*')
|
|
103
|
+
) {
|
|
104
|
+
issues.push(
|
|
105
|
+
'"shipped.packages" must be a non-empty array of package names (a trailing * is a prefix; a bare * is refused)'
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
// Narrowed by the `every` string check just above.
|
|
109
|
+
packages = s.packages as string[];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let readsLeafSlot = true;
|
|
116
|
+
if (raw.readsLeafSlot !== undefined) {
|
|
117
|
+
if (typeof raw.readsLeafSlot !== 'boolean') issues.push('"readsLeafSlot" must be a boolean');
|
|
118
|
+
else readsLeafSlot = raw.readsLeafSlot;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let digestOrder: DigestOrder = 'utf8';
|
|
122
|
+
if (raw.digestOrder !== undefined) {
|
|
123
|
+
if (raw.digestOrder !== 'utf8' && raw.digestOrder !== 'utf16') {
|
|
124
|
+
issues.push('"digestOrder" must be "utf8" or "utf16"');
|
|
125
|
+
} else digestOrder = raw.digestOrder;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (issues.length > 0) throw new I18nConfigError(issues);
|
|
129
|
+
|
|
130
|
+
const abs = (path: string | undefined): string | undefined =>
|
|
131
|
+
path === undefined ? undefined : isAbsolute(path) ? path : resolve(configDir, path);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
// `issues` is empty, so the required "product" check above has passed.
|
|
135
|
+
product: product as string,
|
|
136
|
+
configDir,
|
|
137
|
+
...(servedUrl !== undefined ? { servedUrl } : {}),
|
|
138
|
+
...(bundleFile !== undefined ? { bundleFile: abs(bundleFile) } : {}),
|
|
139
|
+
...(adapter !== undefined ? { adapter: abs(adapter) } : {}),
|
|
140
|
+
shipped: { scope, packages },
|
|
141
|
+
readsLeafSlot,
|
|
142
|
+
digestOrder,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Narrow an imported module to the adapter surface, refusing members of the wrong type. */
|
|
147
|
+
export function toAdapter(mod: unknown, label: string): I18nAdapter {
|
|
148
|
+
if (!isRecord(mod)) throw new I18nConfigError([`${label} did not load as a module`]);
|
|
149
|
+
const source = isRecord(mod.default) ? mod.default : mod;
|
|
150
|
+
const issues: string[] = [];
|
|
151
|
+
const adapter: I18nAdapter = {};
|
|
152
|
+
const build = source.buildBundle;
|
|
153
|
+
if (build !== undefined) {
|
|
154
|
+
if (typeof build !== 'function') issues.push(`${label}: buildBundle must be a function`);
|
|
155
|
+
else adapter.buildBundle = build as () => Promise<AdapterBuild> | AdapterBuild;
|
|
156
|
+
}
|
|
157
|
+
const requested = source.requestedKeys;
|
|
158
|
+
if (requested !== undefined) {
|
|
159
|
+
if (typeof requested !== 'function') issues.push(`${label}: requestedKeys must be a function`);
|
|
160
|
+
else {
|
|
161
|
+
adapter.requestedKeys = requested as () =>
|
|
162
|
+
| Promise<Readonly<Record<string, ShippedKey>>>
|
|
163
|
+
| Readonly<Record<string, ShippedKey>>;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (adapter.buildBundle === undefined && adapter.requestedKeys === undefined) {
|
|
167
|
+
issues.push(`${label}: exports neither buildBundle nor requestedKeys`);
|
|
168
|
+
}
|
|
169
|
+
if (issues.length > 0) throw new I18nConfigError(issues);
|
|
170
|
+
return adapter;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Import the configured adapter module (TypeScript is fine under Bun). */
|
|
174
|
+
export async function loadAdapter(path: string): Promise<I18nAdapter> {
|
|
175
|
+
if (!existsSync(path)) throw new I18nConfigError([`adapter not found: ${path}`]);
|
|
176
|
+
const mod: unknown = await import(pathToFileURL(path).href);
|
|
177
|
+
return toAdapter(mod, path);
|
|
178
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic entry point for the fleet i18n toolkit, for shell scripts and
|
|
3
|
+
* tests that import the shared pieces rather than shelling out to the CLI:
|
|
4
|
+
*
|
|
5
|
+
* import { digestBundle, resolvableKeys } from '@burdenoff/fe-libs/scripts/i18n';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
BundleError,
|
|
10
|
+
MAX_BUNDLE_BYTES,
|
|
11
|
+
bundleLocales,
|
|
12
|
+
bundleRows,
|
|
13
|
+
diffBundles,
|
|
14
|
+
digestBundle,
|
|
15
|
+
loadBundleSource,
|
|
16
|
+
localeRows,
|
|
17
|
+
parseBundle,
|
|
18
|
+
rowLine,
|
|
19
|
+
setDigest,
|
|
20
|
+
utf16Order,
|
|
21
|
+
utf8Order,
|
|
22
|
+
} from './bundle';
|
|
23
|
+
export {
|
|
24
|
+
DEFAULT_CONFIG_FILE,
|
|
25
|
+
DEFAULT_PACKAGES,
|
|
26
|
+
DEFAULT_SCOPE,
|
|
27
|
+
I18nConfigError,
|
|
28
|
+
loadAdapter,
|
|
29
|
+
resolveI18nConfig,
|
|
30
|
+
toAdapter,
|
|
31
|
+
} from './config';
|
|
32
|
+
export {
|
|
33
|
+
CommandError,
|
|
34
|
+
resolveSource,
|
|
35
|
+
runDiff,
|
|
36
|
+
runDigest,
|
|
37
|
+
runServedTree,
|
|
38
|
+
runVerifyShipped,
|
|
39
|
+
} from './commands';
|
|
40
|
+
export {
|
|
41
|
+
LEAF_SLOT,
|
|
42
|
+
LEAF_SLOT_FE_LIBS,
|
|
43
|
+
UNSAFE_SEGMENTS,
|
|
44
|
+
assertExportableKey,
|
|
45
|
+
buildNestedDictionary,
|
|
46
|
+
compareServed,
|
|
47
|
+
flattenServed,
|
|
48
|
+
hidingPrefix,
|
|
49
|
+
keyPath,
|
|
50
|
+
nestingPrefixes,
|
|
51
|
+
resolvableKeys,
|
|
52
|
+
resolveInApp,
|
|
53
|
+
} from './servedTree';
|
|
54
|
+
export type { NestedDoc, ServedComparison } from './servedTree';
|
|
55
|
+
export {
|
|
56
|
+
CALL_PAIR,
|
|
57
|
+
collectShippedKeys,
|
|
58
|
+
matchingPackages,
|
|
59
|
+
missingFromBundle,
|
|
60
|
+
packageMatches,
|
|
61
|
+
shippedKeys,
|
|
62
|
+
} from './shipped';
|
|
63
|
+
export type * from './types';
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app shell is actually SERVED: the nested dictionary global-i18n-svc's
|
|
3
|
+
* `exportTranslations(format: JSON)` builds from a product's rows, and what
|
|
4
|
+
* fe-libs' runtime can resolve from it.
|
|
5
|
+
*
|
|
6
|
+
* VibeControls (`scripts/i18n-nested-export.ts`) and FluidGrids
|
|
7
|
+
* (`scripts/i18n-served-tree.ts`) each carried their own port of the same
|
|
8
|
+
* service algorithm. This is the one shared port, so a change to the service
|
|
9
|
+
* is modelled once.
|
|
10
|
+
*
|
|
11
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* THE MODEL (global-i18n-svc src/modules/i18n/nested-keys.ts, 2026.920.1)
|
|
13
|
+
*
|
|
14
|
+
* A nested document holds a string OR an object at each path, so a key that is
|
|
15
|
+
* also a prefix of other keys (`channel.status` and `channel.status.active`)
|
|
16
|
+
* cannot keep its value at its own path:
|
|
17
|
+
*
|
|
18
|
+
* - the SUBTREE keeps the path, whatever order the rows come back in
|
|
19
|
+
* (2026.919.1, BOFF-7458);
|
|
20
|
+
* - the displaced LEAF is served from its branch's `_` member (2026.920.1,
|
|
21
|
+
* BOFF-7459): `channel.status._ = "Status"`. A key stored literally as
|
|
22
|
+
* `<path>._` already fills that slot and keeps it.
|
|
23
|
+
*
|
|
24
|
+
* fe-libs' `resolveTranslationValue` walks `key.split('.')` and returns the
|
|
25
|
+
* string it lands on; from fe-libs 2026.919.1 it falls back to a branch's `_`
|
|
26
|
+
* string (`readsLeafSlot`). A shell that wants the conservative pre-slot model
|
|
27
|
+
* passes `readsLeafSlot: false` explicitly.
|
|
28
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A nested export document: every leaf is a served value. */
|
|
32
|
+
export type NestedDoc = { [segment: string]: string | NestedDoc };
|
|
33
|
+
|
|
34
|
+
/** Segments the export refuses: a key containing one is skipped. */
|
|
35
|
+
export const UNSAFE_SEGMENTS: ReadonlySet<string> = new Set([
|
|
36
|
+
'__proto__',
|
|
37
|
+
'prototype',
|
|
38
|
+
'constructor',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** The member a branch serves its own displaced leaf from. */
|
|
42
|
+
export const LEAF_SLOT = '_';
|
|
43
|
+
|
|
44
|
+
/** The first fe-libs release whose runtime reads `LEAF_SLOT` (fe-libs#239). */
|
|
45
|
+
export const LEAF_SLOT_FE_LIBS = '2026.919.1';
|
|
46
|
+
|
|
47
|
+
function isBranch(value: unknown): value is NestedDoc {
|
|
48
|
+
return typeof value === 'object' && value !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ownValue(node: NestedDoc, segment: string): string | NestedDoc | undefined {
|
|
52
|
+
return Object.hasOwn(node, segment) ? node[segment] : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The path the export stores a key at, or `null` when it stores it nowhere:
|
|
57
|
+
* an empty segment is skipped (`a..b` is stored at `a.b`), an unsafe segment
|
|
58
|
+
* skips the key, and an empty LAST segment (`a.b.`) skips the key whole.
|
|
59
|
+
*/
|
|
60
|
+
export function keyPath(key: string): string[] | null {
|
|
61
|
+
const segments = key.split('.');
|
|
62
|
+
if (!segments[segments.length - 1]) return null;
|
|
63
|
+
if (segments.some((segment) => UNSAFE_SEGMENTS.has(segment))) return null;
|
|
64
|
+
return segments.filter((segment) => segment !== '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Throw for a key the export cannot serve as itself: one with an empty
|
|
69
|
+
* segment (served under another path or not at all) or an unsafe segment
|
|
70
|
+
* (not served). Builders call this before emitting a key.
|
|
71
|
+
*/
|
|
72
|
+
export function assertExportableKey(key: string): void {
|
|
73
|
+
const parts = key.split('.');
|
|
74
|
+
if (parts.some((part) => part === '')) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`key ${JSON.stringify(key)} has an empty segment; the nested export would serve it under another path`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const unsafe = parts.find((part) => UNSAFE_SEGMENTS.has(part));
|
|
80
|
+
if (unsafe !== undefined) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`key ${JSON.stringify(key)} has a ${unsafe} segment, which the nested export skips`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function branchAt(root: NestedDoc, path: readonly string[]): NestedDoc | undefined {
|
|
88
|
+
let current: string | NestedDoc | undefined = root;
|
|
89
|
+
for (const segment of path) {
|
|
90
|
+
if (!isBranch(current)) return undefined;
|
|
91
|
+
current = ownValue(current, segment);
|
|
92
|
+
}
|
|
93
|
+
return isBranch(current) ? current : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The document global-i18n-svc builds from one locale's rows (its
|
|
98
|
+
* `buildNestedDictionary`). Pass 1 is the shape: the subtree wins over a
|
|
99
|
+
* colliding leaf in either row order, recording each displaced leaf. Pass 2
|
|
100
|
+
* writes each displaced leaf into its branch's `_` slot unless a literal
|
|
101
|
+
* `<path>._` key already owns it. The result depends only on the SET of rows,
|
|
102
|
+
* except where two rows name one path (a repeated key, or `a.b` and `a..b`),
|
|
103
|
+
* where the last read wins exactly as in the service.
|
|
104
|
+
*/
|
|
105
|
+
export function buildNestedDictionary(rows: Iterable<readonly [string, string]>): NestedDoc {
|
|
106
|
+
const root: NestedDoc = {};
|
|
107
|
+
const displaced = new Map<string, { path: string[]; value: string }>();
|
|
108
|
+
|
|
109
|
+
for (const [key, value] of rows) {
|
|
110
|
+
const path = keyPath(key);
|
|
111
|
+
if (!path) continue;
|
|
112
|
+
const last = path.at(-1);
|
|
113
|
+
if (last === undefined) continue;
|
|
114
|
+
|
|
115
|
+
let cursor: NestedDoc = root;
|
|
116
|
+
for (const [depth, segment] of path.slice(0, -1).entries()) {
|
|
117
|
+
const existing = ownValue(cursor, segment);
|
|
118
|
+
if (isBranch(existing)) {
|
|
119
|
+
cursor = existing;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (typeof existing === 'string') {
|
|
123
|
+
const leafPath = path.slice(0, depth + 1);
|
|
124
|
+
displaced.set(leafPath.join('.'), { path: leafPath, value: existing });
|
|
125
|
+
}
|
|
126
|
+
const branch: NestedDoc = {};
|
|
127
|
+
cursor[segment] = branch;
|
|
128
|
+
cursor = branch;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isBranch(ownValue(cursor, last))) {
|
|
132
|
+
displaced.set(path.join('.'), { path, value });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
cursor[last] = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const { path, value } of displaced.values()) {
|
|
139
|
+
const branch = branchAt(root, path);
|
|
140
|
+
if (branch && !Object.hasOwn(branch, LEAF_SLOT)) branch[LEAF_SLOT] = value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return root;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every string path of a nested document, `_` members included. */
|
|
147
|
+
export function flattenServed(doc: NestedDoc): Map<string, string> {
|
|
148
|
+
const out = new Map<string, string>();
|
|
149
|
+
const walk = (node: NestedDoc, prefix: string): void => {
|
|
150
|
+
for (const [segment, value] of Object.entries(node)) {
|
|
151
|
+
const path = prefix ? `${prefix}.${segment}` : segment;
|
|
152
|
+
if (typeof value === 'string') out.set(path, value);
|
|
153
|
+
else walk(value, path);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
walk(doc, '');
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What fe-libs resolves for `key` in `doc`: walk the RAW segments (so `a..b`
|
|
162
|
+
* resolves nothing even though the export stores it at `a.b`) and take the
|
|
163
|
+
* string landed on; with `readsLeafSlot`, a branch falls back to its `_`
|
|
164
|
+
* string.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveInApp(
|
|
167
|
+
doc: NestedDoc,
|
|
168
|
+
key: string,
|
|
169
|
+
readsLeafSlot = true
|
|
170
|
+
): string | undefined {
|
|
171
|
+
let current: string | NestedDoc | undefined = doc;
|
|
172
|
+
for (const segment of key.split('.')) {
|
|
173
|
+
if (!isBranch(current)) return undefined;
|
|
174
|
+
current = ownValue(current, segment);
|
|
175
|
+
}
|
|
176
|
+
if (typeof current === 'string') return current;
|
|
177
|
+
if (!readsLeafSlot || !isBranch(current)) return undefined;
|
|
178
|
+
const leaf = ownValue(current, LEAF_SLOT);
|
|
179
|
+
return typeof leaf === 'string' ? leaf : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* key -> value for every row key the runtime can resolve from the document
|
|
184
|
+
* those rows produce. A row is counted under the path it is stored at, which is
|
|
185
|
+
* the key a call site looks up; the synthetic `_` a displaced leaf occupies
|
|
186
|
+
* belongs to no row and is not counted.
|
|
187
|
+
*/
|
|
188
|
+
export function resolvableKeys(
|
|
189
|
+
rows: Iterable<readonly [string, string]>,
|
|
190
|
+
readsLeafSlot = true
|
|
191
|
+
): Map<string, string> {
|
|
192
|
+
const materialised = [...rows];
|
|
193
|
+
const doc = buildNestedDictionary(materialised);
|
|
194
|
+
const out = new Map<string, string>();
|
|
195
|
+
for (const [key] of materialised) {
|
|
196
|
+
const lookup = keyPath(key)?.join('.');
|
|
197
|
+
if (lookup === undefined) continue;
|
|
198
|
+
const value = resolveInApp(doc, lookup, readsLeafSlot);
|
|
199
|
+
if (value !== undefined) out.set(lookup, value);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The paths a value for `key` turns into branches: every non-empty segment but
|
|
206
|
+
* the last (`a.b.c` walks `a` and `a.b`). A key with an unsafe segment walks
|
|
207
|
+
* nothing, because the export skips it.
|
|
208
|
+
*/
|
|
209
|
+
export function nestingPrefixes(key: string): string[] {
|
|
210
|
+
const all = key.split('.');
|
|
211
|
+
if (all.some((part) => UNSAFE_SEGMENTS.has(part))) return [];
|
|
212
|
+
const parents = all.slice(0, -1).filter(Boolean);
|
|
213
|
+
return parents.map((_, n) => parents.slice(0, n + 1).join('.'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The key resolved today that a NEW value for `key` would stop serving (or
|
|
218
|
+
* serve differently), or undefined when the value is safe to add.
|
|
219
|
+
*
|
|
220
|
+
* Without the slot, every resolved prefix the new key walks through is lost.
|
|
221
|
+
* With it, a displaced prefix P still resolves from `P._`, unless the new key
|
|
222
|
+
* runs through that very slot (`P._` takes it, `P._.x` makes it a branch).
|
|
223
|
+
*/
|
|
224
|
+
export function hidingPrefix(
|
|
225
|
+
key: string,
|
|
226
|
+
resolved: ReadonlySet<string> | ReadonlyMap<string, string>,
|
|
227
|
+
readsLeafSlot = true
|
|
228
|
+
): string | undefined {
|
|
229
|
+
const prefixes = nestingPrefixes(key);
|
|
230
|
+
const parts = key.split('.').filter(Boolean);
|
|
231
|
+
return prefixes.find(
|
|
232
|
+
(prefix, i) => resolved.has(prefix) && (!readsLeafSlot || parts[i + 1] === LEAF_SLOT)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What one locale resolves before vs after a change. */
|
|
237
|
+
export interface ServedComparison {
|
|
238
|
+
before: number;
|
|
239
|
+
after: number;
|
|
240
|
+
/** Keys resolved before and not after, sorted. */
|
|
241
|
+
lost: string[];
|
|
242
|
+
/** Keys resolved both times whose value changed, sorted. */
|
|
243
|
+
changed: string[];
|
|
244
|
+
/** Keys resolved after and not before. */
|
|
245
|
+
gained: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Compare two `resolvableKeys` results: every key resolved before must still be. */
|
|
249
|
+
export function compareServed(
|
|
250
|
+
before: ReadonlyMap<string, string>,
|
|
251
|
+
after: ReadonlyMap<string, string>
|
|
252
|
+
): ServedComparison {
|
|
253
|
+
const lost: string[] = [];
|
|
254
|
+
const changed: string[] = [];
|
|
255
|
+
for (const [key, value] of before) {
|
|
256
|
+
const next = after.get(key);
|
|
257
|
+
if (next === undefined) lost.push(key);
|
|
258
|
+
else if (next !== value) changed.push(key);
|
|
259
|
+
}
|
|
260
|
+
let gained = 0;
|
|
261
|
+
for (const key of after.keys()) if (!before.has(key)) gained++;
|
|
262
|
+
const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
263
|
+
return {
|
|
264
|
+
before: before.size,
|
|
265
|
+
after: after.size,
|
|
266
|
+
lost: lost.sort(byCodePoint),
|
|
267
|
+
changed: changed.sort(byCodePoint),
|
|
268
|
+
gained,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The i18n keys the code that ACTUALLY SHIPS asks for.
|
|
3
|
+
*
|
|
4
|
+
* A shell's extractor reads microfrontend sources at origin/main, but the app
|
|
5
|
+
* ships the PINNED published packages, and the two can disagree. If main has
|
|
6
|
+
* renamed a key the pinned build still calls, the bundle omits it and users
|
|
7
|
+
* read English. Compiled bundles keep the call shape — `tr(key, fallback)`
|
|
8
|
+
* survives minification as `x("auth.login.welcomeBack", "Welcome back")` — so
|
|
9
|
+
* matching the (key, fallback) PAIR in `dist` recovers what the pinned code
|
|
10
|
+
* requests. Matching lone dotted strings would sweep up analytics event names,
|
|
11
|
+
* route ids and config paths; requiring an adjacent prose fallback keeps those
|
|
12
|
+
* out.
|
|
13
|
+
*
|
|
14
|
+
* Lifted from vibecontrols-app and fluidgrids-app `scripts/verify-shipped-i18n.ts`
|
|
15
|
+
* (BOFF-7300); the package filter is configuration instead of a constant.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import type { ShippedKey } from './types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `<fn>("some.dotted.key", "Some English fallback")`: both literals, adjacent,
|
|
24
|
+
* as the first two arguments. The key shape is the extractors' (any depth).
|
|
25
|
+
*/
|
|
26
|
+
export const CALL_PAIR =
|
|
27
|
+
/\w+\(\s*"([a-z][a-zA-Z0-9]*(?:\.[A-Za-z0-9_-]+)+)"\s*,\s*"([^"\\]{2,300})"\s*[),]/g;
|
|
28
|
+
|
|
29
|
+
/** Add every key -> first (package, fallback) pair found in one compiled source. */
|
|
30
|
+
export function collectShippedKeys(
|
|
31
|
+
pkg: string,
|
|
32
|
+
source: string,
|
|
33
|
+
into: Map<string, ShippedKey> = new Map()
|
|
34
|
+
): Map<string, ShippedKey> {
|
|
35
|
+
for (const match of source.matchAll(CALL_PAIR)) {
|
|
36
|
+
const key = match[1];
|
|
37
|
+
const fallback = match[2];
|
|
38
|
+
if (key === undefined || fallback === undefined) continue;
|
|
39
|
+
// A real UI fallback reads like prose; identifiers and numbers do not.
|
|
40
|
+
if (!/[a-zA-Z]/.test(fallback)) continue;
|
|
41
|
+
if (!into.has(key)) into.set(key, { pkg, fallback });
|
|
42
|
+
}
|
|
43
|
+
return into;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `name` (unscoped) matches one of `patterns`; a trailing `*` is a prefix match. */
|
|
47
|
+
export function packageMatches(name: string, patterns: readonly string[]): boolean {
|
|
48
|
+
return patterns.some((pattern) =>
|
|
49
|
+
pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walkJs(dir: string): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
if (!existsSync(dir)) return out;
|
|
56
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
57
|
+
const path = join(dir, entry);
|
|
58
|
+
if (statSync(path).isDirectory()) {
|
|
59
|
+
if (entry !== 'node_modules') out.push(...walkJs(path));
|
|
60
|
+
} else if (entry.endsWith('.js') || entry.endsWith('.mjs')) out.push(path);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The packages under `scopeDir` that match `patterns`, sorted. */
|
|
66
|
+
export function matchingPackages(scopeDir: string, patterns: readonly string[]): string[] {
|
|
67
|
+
if (!existsSync(scopeDir)) return [];
|
|
68
|
+
return readdirSync(scopeDir)
|
|
69
|
+
.filter((name) => packageMatches(name, patterns))
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every key the installed, compiled packages request. Scans each matching
|
|
75
|
+
* package's `dist`; the first package (alphabetically) to request a key names it.
|
|
76
|
+
*/
|
|
77
|
+
export function shippedKeys(
|
|
78
|
+
scopeDir: string,
|
|
79
|
+
patterns: readonly string[]
|
|
80
|
+
): Map<string, ShippedKey> {
|
|
81
|
+
const found = new Map<string, ShippedKey>();
|
|
82
|
+
for (const pkg of matchingPackages(scopeDir, patterns)) {
|
|
83
|
+
for (const file of walkJs(join(scopeDir, pkg, 'dist'))) {
|
|
84
|
+
collectShippedKeys(pkg, readFileSync(file, 'utf8'), found);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shipped keys the bundle does not define, sorted by key. */
|
|
91
|
+
export function missingFromBundle(
|
|
92
|
+
shipped: ReadonlyMap<string, ShippedKey>,
|
|
93
|
+
bundleKeys: Iterable<string>
|
|
94
|
+
): Array<[string, ShippedKey]> {
|
|
95
|
+
const have = new Set(bundleKeys);
|
|
96
|
+
return [...shipped.entries()]
|
|
97
|
+
.filter(([key]) => !have.has(key))
|
|
98
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
99
|
+
}
|