@urbicon-ui/i18n 6.3.7 → 6.3.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Svelte usage walker. `svelte/compiler.parse` (lazily imported) yields one AST
3
+ * whose Root holds the module/instance scripts (estree) *and* the template, so a
4
+ * single iterative walk reaches every translate call — script-level and inside
5
+ * markup expressions — with original-source offsets for accurate line numbers.
6
+ *
7
+ * On top of the estree handling (mirroring the TypeScript walker) it understands
8
+ * the `<T key="…">` component, the markup form of a translation render-call.
9
+ */
10
+ import { createBindings, createScan, isFactoryName, isKeyMethod, isProbeMethod, isRenderMethod, makeContextAt, makeLineAt, recordKeyCall } from './recognize.js';
11
+ import { asNode, asNodes, asString, cookedOf, loadParse, walkAst } from './svelte-ast.js';
12
+ /** Peel `as`/`!`/`satisfies` (TS nodes present when `lang="ts"`) off an expression. */
13
+ function unwrap(node) {
14
+ let current = node;
15
+ while (current &&
16
+ /^TS\w+(Expression|Assertion)$/.test(current.type) &&
17
+ asNode(current.expression)) {
18
+ current = asNode(current.expression);
19
+ }
20
+ return current;
21
+ }
22
+ function extractFromArg(raw) {
23
+ const node = unwrap(raw);
24
+ if (!node)
25
+ return [{ kind: 'opaque' }];
26
+ if (node.type === 'Literal') {
27
+ const value = asString(node.value);
28
+ return value !== undefined ? [{ kind: 'static', value }] : [{ kind: 'opaque' }];
29
+ }
30
+ if (node.type === 'TemplateLiteral') {
31
+ const head = cookedOf(asNodes(node.quasis)[0]) ?? '';
32
+ if (asNodes(node.expressions).length === 0)
33
+ return [{ kind: 'static', value: head }];
34
+ return head ? [{ kind: 'prefix', prefix: head }] : [{ kind: 'opaque' }];
35
+ }
36
+ if (node.type === 'ConditionalExpression') {
37
+ return [...extractFromArg(asNode(node.consequent)), ...extractFromArg(asNode(node.alternate))];
38
+ }
39
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
40
+ const left = unwrap(asNode(node.left));
41
+ const value = left?.type === 'Literal' ? asString(left.value) : undefined;
42
+ // An empty static part (`'' + x`) is no prefix — `''` would shield every key.
43
+ return value ? [{ kind: 'prefix', prefix: value }] : [{ kind: 'opaque' }];
44
+ }
45
+ return [{ kind: 'opaque' }];
46
+ }
47
+ /** The rightmost callee name of a call (`x.useTranslate()` → `useTranslate`). */
48
+ function calleeName(call) {
49
+ const callee = asNode(call.callee);
50
+ if (!callee)
51
+ return undefined;
52
+ if (callee.type === 'Identifier')
53
+ return asString(callee.name);
54
+ if (callee.type === 'MemberExpression' && callee.computed !== true) {
55
+ return asString(asNode(callee.property)?.name);
56
+ }
57
+ return undefined;
58
+ }
59
+ function factoryCallName(node) {
60
+ if (node?.type !== 'CallExpression')
61
+ return undefined;
62
+ const name = calleeName(node);
63
+ return name && isFactoryName(name) ? name : undefined;
64
+ }
65
+ function bindDeclarator(node, bindings) {
66
+ const init = asNode(node.init);
67
+ if (!init)
68
+ return;
69
+ const id = asNode(node.id);
70
+ if (factoryCallName(init)) {
71
+ if (id?.type === 'Identifier') {
72
+ const name = asString(id.name);
73
+ if (name)
74
+ bindings.render.add(name);
75
+ }
76
+ else if (id?.type === 'ObjectPattern') {
77
+ for (const prop of asNodes(id.properties)) {
78
+ if (prop.type !== 'Property')
79
+ continue;
80
+ const method = asString(asNode(prop.key)?.name);
81
+ const local = asString(asNode(prop.value)?.name);
82
+ if (!method || !local)
83
+ continue;
84
+ if (isProbeMethod(method))
85
+ bindings.probe.add(local);
86
+ else if (isRenderMethod(method))
87
+ bindings.render.add(local);
88
+ }
89
+ }
90
+ return;
91
+ }
92
+ // `const t = useI18n().t`
93
+ if (init.type === 'MemberExpression' && init.computed !== true && id?.type === 'Identifier') {
94
+ const method = asString(asNode(init.property)?.name);
95
+ const local = asString(id.name);
96
+ if (method && local && isKeyMethod(method) && factoryCallName(asNode(init.object))) {
97
+ if (isProbeMethod(method))
98
+ bindings.probe.add(local);
99
+ else
100
+ bindings.render.add(local);
101
+ }
102
+ }
103
+ }
104
+ function classifyCall(call, bindings) {
105
+ const callee = asNode(call.callee);
106
+ if (!callee)
107
+ return null;
108
+ if (callee.type === 'Identifier') {
109
+ const name = asString(callee.name);
110
+ if (!name)
111
+ return null;
112
+ if (bindings.render.has(name))
113
+ return { isProbe: false };
114
+ if (bindings.probe.has(name))
115
+ return { isProbe: true };
116
+ return null;
117
+ }
118
+ if (callee.type === 'MemberExpression' && callee.computed !== true) {
119
+ const name = asString(asNode(callee.property)?.name);
120
+ if (name && isRenderMethod(name))
121
+ return { isProbe: false };
122
+ if (name && isProbeMethod(name))
123
+ return { isProbe: true };
124
+ }
125
+ return null;
126
+ }
127
+ /** Resolve the key of a `<T key="…">` / `<T key={'…'}>` component usage. */
128
+ function extractTComponentKey(component) {
129
+ const keyAttr = asNodes(component.attributes).find((attr) => attr.type === 'Attribute' && asString(attr.name) === 'key');
130
+ if (!keyAttr)
131
+ return [];
132
+ const values = Array.isArray(keyAttr.value)
133
+ ? asNodes(keyAttr.value)
134
+ : asNode(keyAttr.value)
135
+ ? [asNode(keyAttr.value)]
136
+ : [];
137
+ const out = [];
138
+ for (const value of values) {
139
+ if (value.type === 'Text') {
140
+ const text = asString(value.data);
141
+ if (text !== undefined)
142
+ out.push({ kind: 'static', value: text });
143
+ }
144
+ else if (value.type === 'ExpressionTag') {
145
+ out.push(...extractFromArg(asNode(value.expression)));
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ export async function scanSvelte(code, file, options = {}) {
151
+ const parse = await loadParse();
152
+ const ast = parse(code, { modern: true });
153
+ const bindings = createBindings(options.functionNames);
154
+ const lineAt = makeLineAt(code);
155
+ const contextAt = makeContextAt(code);
156
+ const siteAt = (offset) => {
157
+ const line = lineAt(offset ?? 0);
158
+ return { file, line, context: contextAt(line) };
159
+ };
160
+ // Pass 1 — bindings.
161
+ walkAst(ast, (node) => {
162
+ if (node.type === 'VariableDeclarator')
163
+ bindDeclarator(node, bindings);
164
+ });
165
+ // Pass 2 — usage + literal harvest.
166
+ const scan = createScan();
167
+ walkAst(ast, (node) => {
168
+ if (node.type === 'Literal') {
169
+ const value = asString(node.value);
170
+ if (value !== undefined)
171
+ scan.literalPool.add(value);
172
+ }
173
+ else if (node.type === 'Text') {
174
+ // Plain markup text and quoted attribute values (`<Foo labelKey="user.name"/>`,
175
+ // `<code>a.b.c</code>`) are Text, not Literal nodes — harvest for the loose layer.
176
+ const text = asString(node.data)?.trim();
177
+ if (text)
178
+ scan.literalPool.add(text);
179
+ }
180
+ else if (node.type === 'TemplateLiteral') {
181
+ const quasis = asNodes(node.quasis);
182
+ if (asNodes(node.expressions).length === 0) {
183
+ const cooked = cookedOf(quasis[0]);
184
+ if (cooked !== undefined)
185
+ scan.literalPool.add(cooked);
186
+ }
187
+ else {
188
+ // Static head of any template literal → a dynamic prefix, so config-built
189
+ // keys (`col.${id}.label`) rendered elsewhere are shielded from "unused".
190
+ const head = cookedOf(quasis[0]);
191
+ if (head)
192
+ scan.dynamicPrefixes.push({ prefix: head, site: siteAt(node.start) });
193
+ }
194
+ }
195
+ else if (node.type === 'CallExpression') {
196
+ const classification = classifyCall(node, bindings);
197
+ if (classification) {
198
+ const args = asNodes(node.arguments);
199
+ const extractions = args.length > 0 ? extractFromArg(args[0]) : [];
200
+ recordKeyCall(scan, extractions, siteAt(node.start), classification.isProbe);
201
+ }
202
+ }
203
+ else if (node.type === 'Component' && asString(node.name) === 'T') {
204
+ recordKeyCall(scan, extractTComponentKey(node), siteAt(node.start), false);
205
+ }
206
+ });
207
+ return scan;
208
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * TypeScript/JavaScript usage walker. Uses the TypeScript compiler API (the repo
3
+ * precedent — docs-gen extracts props the same way), lazily imported so the
4
+ * dependency never touches the i18n runtime entry, only `@urbicon-ui/i18n/audit`.
5
+ *
6
+ * Two passes: collect the file-local translate-function bindings (B1), then walk
7
+ * calls + string literals (B2), emitting into a {@link UsageScan}.
8
+ */
9
+ import type { ScanOptions, UsageScan } from './types.js';
10
+ export declare function scanTs(code: string, file: string, options?: ScanOptions): Promise<UsageScan>;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TypeScript/JavaScript usage walker. Uses the TypeScript compiler API (the repo
3
+ * precedent — docs-gen extracts props the same way), lazily imported so the
4
+ * dependency never touches the i18n runtime entry, only `@urbicon-ui/i18n/audit`.
5
+ *
6
+ * Two passes: collect the file-local translate-function bindings (B1), then walk
7
+ * calls + string literals (B2), emitting into a {@link UsageScan}.
8
+ */
9
+ import { createBindings, createScan, isFactoryName, isKeyMethod, isProbeMethod, isRenderMethod, makeContextAt, recordKeyCall } from './recognize.js';
10
+ let tsPromise;
11
+ async function loadTs() {
12
+ if (!tsPromise) {
13
+ tsPromise = (async () => {
14
+ let mod;
15
+ try {
16
+ mod = await import('typescript');
17
+ }
18
+ catch {
19
+ throw new Error('@urbicon-ui/i18n/audit needs the optional peer "typescript" to scan .ts/.js sources — install it (it ships with any SvelteKit/TS project).');
20
+ }
21
+ // typescript ships CJS; interop puts the namespace on `.default` or spreads it.
22
+ const candidate = mod;
23
+ return typeof candidate.createSourceFile === 'function'
24
+ ? candidate
25
+ : candidate.default;
26
+ })();
27
+ }
28
+ return tsPromise;
29
+ }
30
+ function scriptKindFor(ts, file) {
31
+ if (file.endsWith('.tsx'))
32
+ return ts.ScriptKind.TSX;
33
+ if (file.endsWith('.jsx'))
34
+ return ts.ScriptKind.JSX;
35
+ if (/\.(m|c)?js$/.test(file))
36
+ return ts.ScriptKind.JS;
37
+ return ts.ScriptKind.TS;
38
+ }
39
+ function calleeName(ts, expr) {
40
+ if (ts.isIdentifier(expr))
41
+ return expr.text;
42
+ if (ts.isPropertyAccessExpression(expr))
43
+ return expr.name.text;
44
+ return undefined;
45
+ }
46
+ /** The factory-hook name when `node` is a call to one (`useTableI18n()` → `useTableI18n`). */
47
+ function factoryCallName(ts, node) {
48
+ if (!ts.isCallExpression(node))
49
+ return undefined;
50
+ const name = calleeName(ts, node.expression);
51
+ return name && isFactoryName(name) ? name : undefined;
52
+ }
53
+ function bindDeclarationName(ts, name, bindings) {
54
+ if (ts.isIdentifier(name)) {
55
+ // `const bt = useTableI18n()` — the alias is a translate function.
56
+ bindings.render.add(name.text);
57
+ return;
58
+ }
59
+ if (ts.isObjectBindingPattern(name)) {
60
+ // `const { t, exists } = useI18n()` — only the known methods are key-calls.
61
+ for (const element of name.elements) {
62
+ const source = element.propertyName ?? element.name;
63
+ const method = ts.isIdentifier(source) ? source.text : undefined;
64
+ const local = ts.isIdentifier(element.name) ? element.name.text : undefined;
65
+ if (!method || !local)
66
+ continue;
67
+ if (isProbeMethod(method))
68
+ bindings.probe.add(local);
69
+ else if (isRenderMethod(method))
70
+ bindings.render.add(local);
71
+ }
72
+ }
73
+ }
74
+ function collectBindings(ts, sf, bindings) {
75
+ const visit = (node) => {
76
+ if (ts.isVariableDeclaration(node) && node.initializer) {
77
+ const init = node.initializer;
78
+ if (factoryCallName(ts, init)) {
79
+ bindDeclarationName(ts, node.name, bindings);
80
+ }
81
+ else if (ts.isPropertyAccessExpression(init) &&
82
+ isKeyMethod(init.name.text) &&
83
+ factoryCallName(ts, init.expression) &&
84
+ ts.isIdentifier(node.name)) {
85
+ // `const t = useI18n().t`
86
+ if (isProbeMethod(init.name.text))
87
+ bindings.probe.add(node.name.text);
88
+ else
89
+ bindings.render.add(node.name.text);
90
+ }
91
+ }
92
+ ts.forEachChild(node, visit);
93
+ };
94
+ ts.forEachChild(sf, visit);
95
+ }
96
+ function unwrap(ts, node) {
97
+ let current = node;
98
+ for (;;) {
99
+ // Per-guard so TypeScript narrows `current` to a node that has `.expression`.
100
+ if (ts.isParenthesizedExpression(current))
101
+ current = current.expression;
102
+ else if (ts.isAsExpression(current))
103
+ current = current.expression;
104
+ else if (ts.isNonNullExpression(current))
105
+ current = current.expression;
106
+ else if (ts.isSatisfiesExpression(current))
107
+ current = current.expression;
108
+ else
109
+ return current;
110
+ }
111
+ }
112
+ function extractFromArg(ts, raw) {
113
+ const node = unwrap(ts, raw);
114
+ if (ts.isStringLiteralLike(node))
115
+ return [{ kind: 'static', value: node.text }];
116
+ if (ts.isTemplateExpression(node)) {
117
+ // `` `filter.op.${x}` `` → prefix `filter.op.`; an empty head is unresolvable.
118
+ return node.head.text ? [{ kind: 'prefix', prefix: node.head.text }] : [{ kind: 'opaque' }];
119
+ }
120
+ if (ts.isConditionalExpression(node)) {
121
+ return [...extractFromArg(ts, node.whenTrue), ...extractFromArg(ts, node.whenFalse)];
122
+ }
123
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
124
+ const left = unwrap(ts, node.left);
125
+ // An empty static part (`'' + x`) is no prefix — `''` would shield every key.
126
+ if (ts.isStringLiteralLike(left) && left.text)
127
+ return [{ kind: 'prefix', prefix: left.text }];
128
+ return [{ kind: 'opaque' }];
129
+ }
130
+ return [{ kind: 'opaque' }];
131
+ }
132
+ function classifyCall(ts, bindings, call) {
133
+ const callee = call.expression;
134
+ if (ts.isIdentifier(callee)) {
135
+ if (bindings.render.has(callee.text))
136
+ return { isProbe: false };
137
+ if (bindings.probe.has(callee.text))
138
+ return { isProbe: true };
139
+ return null;
140
+ }
141
+ if (ts.isPropertyAccessExpression(callee)) {
142
+ const name = callee.name.text;
143
+ if (isRenderMethod(name))
144
+ return { isProbe: false };
145
+ if (isProbeMethod(name))
146
+ return { isProbe: true };
147
+ }
148
+ return null;
149
+ }
150
+ export async function scanTs(code, file, options = {}) {
151
+ const ts = await loadTs();
152
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, file));
153
+ const bindings = createBindings(options.functionNames);
154
+ collectBindings(ts, sf, bindings);
155
+ const scan = createScan();
156
+ const contextAt = makeContextAt(code);
157
+ const visit = (node) => {
158
+ if (ts.isStringLiteralLike(node))
159
+ scan.literalPool.add(node.text);
160
+ // Harvest the static head of EVERY template literal (not only those in a t()
161
+ // call) as a dynamic prefix — keys built in a config (`col.${id}.label`) and
162
+ // rendered elsewhere are then shielded from the unused list.
163
+ if (ts.isTemplateExpression(node) && node.head.text) {
164
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
165
+ scan.dynamicPrefixes.push({
166
+ prefix: node.head.text,
167
+ site: { file, line, context: contextAt(line) }
168
+ });
169
+ }
170
+ if (ts.isCallExpression(node)) {
171
+ const classification = classifyCall(ts, bindings, node);
172
+ if (classification) {
173
+ const arg = node.arguments[0];
174
+ const extractions = arg ? extractFromArg(ts, arg) : [];
175
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
176
+ recordKeyCall(scan, extractions, { file, line, context: contextAt(line) }, classification.isProbe);
177
+ }
178
+ }
179
+ ts.forEachChild(node, visit);
180
+ };
181
+ ts.forEachChild(sf, visit);
182
+ return scan;
183
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared types for the usage scanner (Feature B/C of the i18n audit).
3
+ *
4
+ * A scan walks source files for translation-key *usage* — every way a key is
5
+ * referenced — so the reconciler can decide which defined keys are unused. The
6
+ * shapes here are AST-agnostic: the TypeScript walker and the Svelte walker both
7
+ * emit into a {@link UsageScan}.
8
+ */
9
+ export interface KeyUsageSite {
10
+ /** Source file (as passed to the scanner — the caller decides absolute vs relative). */
11
+ file: string;
12
+ /** 1-based line of the call/usage. */
13
+ line: number;
14
+ /** Trimmed source line, for human-readable reports. */
15
+ context: string;
16
+ }
17
+ export interface UsageScan {
18
+ /** Static literal keys in render calls (`t`/`plural`/`translate`, `<T key>`), with their sites. */
19
+ staticKeys: Map<string, KeyUsageSite[]>;
20
+ /** Keys seen only in `exists()` probes — they count as *used* but are excluded from used-but-undefined. */
21
+ probeKeys: Set<string>;
22
+ /** Static prefixes from template-literal keys, e.g. `` `filter.op.${x}` `` → `filter.op.` (trailing dot kept). */
23
+ dynamicPrefixes: Array<{
24
+ prefix: string;
25
+ site: KeyUsageSite;
26
+ }>;
27
+ /** Translation calls whose key could not be resolved statically (`t(variable)`). */
28
+ opaqueSites: KeyUsageSite[];
29
+ /** Every string literal seen anywhere — the loose-literal harvest layer. */
30
+ literalPool: Set<string>;
31
+ }
32
+ export interface ScanOptions {
33
+ /** Extra bare-identifier names to treat as translation render-calls (the escape hatch). */
34
+ functionNames?: string[];
35
+ }
36
+ /**
37
+ * The classification of a translation call's first argument. A ternary expands to
38
+ * several; an unresolvable argument yields `opaque`.
39
+ */
40
+ export type ExtractedKey = {
41
+ kind: 'static';
42
+ value: string;
43
+ } | {
44
+ kind: 'prefix';
45
+ prefix: string;
46
+ } | {
47
+ kind: 'opaque';
48
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Shared types for the usage scanner (Feature B/C of the i18n audit).
3
+ *
4
+ * A scan walks source files for translation-key *usage* — every way a key is
5
+ * referenced — so the reconciler can decide which defined keys are unused. The
6
+ * shapes here are AST-agnostic: the TypeScript walker and the Svelte walker both
7
+ * emit into a {@link UsageScan}.
8
+ */
9
+ export {};
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `auditTranslations` — data-level translation quality & parity audit.
3
+ *
4
+ * The richer successor to {@link validatePackageTranslations}: a pure function
5
+ * over a package's locale bundles (no source scan, no I/O, deterministic, zero
6
+ * false positives) that reports structured findings instead of opaque strings.
7
+ *
8
+ * Consumers run it in a vitest test to fail CI on drift —
9
+ * `expect(auditTranslations('app', bundles).ok).toBe(true)` — or via the
10
+ * `urbicon i18n parity` CLI, which formats the same findings. Key parity is the
11
+ * baseline (missing/extra keys); on top of it sit the checks a structural diff
12
+ * can't see: empty values, interpolation-param drift between locales, malformed
13
+ * or CLDR-incomplete `_plural` objects, placeholder leftovers, and (opt-in)
14
+ * not-yet-translated strings identical to the base locale.
15
+ */
16
+ import { type Locale } from '../i18n/types.js';
17
+ /** A single audit check. Stable identifiers — safe to switch on in tooling/CI. */
18
+ export type TranslationFindingCode = 'missing-key' | 'extra-key' | 'empty-value' | 'wrong-type' | 'param-mismatch' | 'plural-shape-invalid' | 'plural-category-incomplete' | 'value-equals-key' | 'same-as-base' | 'invalid-locale' | 'no-translations';
19
+ export type TranslationFindingSeverity = 'error' | 'warning';
20
+ export interface TranslationFinding {
21
+ /** Which check produced this finding. */
22
+ code: TranslationFindingCode;
23
+ severity: TranslationFindingSeverity;
24
+ /** The locale the finding belongs to (the base locale for base-only checks). */
25
+ locale: Locale;
26
+ /** Dotted leaf-key path, e.g. `dialog.close`. Empty for whole-bundle findings. */
27
+ key: string;
28
+ /** Human-readable message, prefixed with `[packageName]`. */
29
+ detail: string;
30
+ }
31
+ export interface AuditTranslationsOptions {
32
+ /** Base locale every other locale is diffed against. Default: `en` if present, else the first. */
33
+ baseLocale?: Locale;
34
+ /**
35
+ * Per-check toggles. Unlisted checks keep their default — all on EXCEPT
36
+ * `same-as-base`, which is FP-prone (brand names, "OK", shared tokens) and
37
+ * opt-in. `missing-key` cannot be disabled (it is the parity floor).
38
+ */
39
+ checks?: Partial<Record<TranslationFindingCode, boolean>>;
40
+ /** Leaf-key paths to skip across all checks. Exact, or a `prefix.*` glob. */
41
+ ignoreKeys?: string[];
42
+ }
43
+ export interface TranslationAuditReport {
44
+ /** True when there are no `error`-severity findings (warnings do not fail). */
45
+ ok: boolean;
46
+ /** All findings, sorted deterministically by locale → key → code. */
47
+ findings: TranslationFinding[];
48
+ /** The `error` subset, for a quick `expect(report.errors).toEqual([])`. */
49
+ errors: TranslationFinding[];
50
+ /** The `warning` subset. */
51
+ warnings: TranslationFinding[];
52
+ }
53
+ /**
54
+ * Audit a package's locale bundles for parity and translation-quality issues.
55
+ *
56
+ * @param packageName Used only to prefix `detail` messages (e.g. `[blocks]`).
57
+ * @param translations Per-locale bundles, exactly as passed to `createPackageI18n`.
58
+ */
59
+ export declare function auditTranslations(packageName: string, translations: Partial<Record<Locale, Record<string, unknown>>>, options?: AuditTranslationsOptions): TranslationAuditReport;