@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.
- package/dist/audit/glob.d.ts +7 -0
- package/dist/audit/glob.js +19 -0
- package/dist/audit/index.d.ts +26 -0
- package/dist/audit/index.js +24 -0
- package/dist/audit/missing-key-collector.d.ts +39 -0
- package/dist/audit/missing-key-collector.js +46 -0
- package/dist/audit/scan/hardcoded.d.ts +29 -0
- package/dist/audit/scan/hardcoded.js +86 -0
- package/dist/audit/scan/recognize.d.ts +25 -0
- package/dist/audit/scan/recognize.js +110 -0
- package/dist/audit/scan/scanner.d.ts +21 -0
- package/dist/audit/scan/scanner.js +26 -0
- package/dist/audit/scan/svelte-ast.d.ts +21 -0
- package/dist/audit/scan/svelte-ast.js +61 -0
- package/dist/audit/scan/svelte-walker.d.ts +11 -0
- package/dist/audit/scan/svelte-walker.js +208 -0
- package/dist/audit/scan/ts-walker.d.ts +10 -0
- package/dist/audit/scan/ts-walker.js +183 -0
- package/dist/audit/scan/types.d.ts +48 -0
- package/dist/audit/scan/types.js +9 -0
- package/dist/audit/translations.d.ts +59 -0
- package/dist/audit/translations.js +207 -0
- package/dist/audit/unused.d.ts +46 -0
- package/dist/audit/unused.js +65 -0
- package/dist/i18n/context.svelte.d.ts +10 -1
- package/dist/i18n/context.svelte.js +3 -1
- package/dist/i18n/registry.svelte.d.ts +11 -1
- package/dist/i18n/registry.svelte.js +33 -1
- package/dist/i18n/types.d.ts +25 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +10 -1
- package/package.json +12 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny key-glob matcher shared by the translation audit (`ignoreKeys`) and the
|
|
3
|
+
* unused-key reconciler (`dynamicKeys`/`ignoreKeys`). Supports exact keys and a
|
|
4
|
+
* trailing `*` wildcard, so `errors.*` matches `errors.timeout` but not `errorsX`
|
|
5
|
+
* is intentional — `errors.*` slices to the prefix `errors.` (the dot is kept).
|
|
6
|
+
*/
|
|
7
|
+
export declare function makeGlobMatcher(patterns: string[] | undefined): (key: string) => boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny key-glob matcher shared by the translation audit (`ignoreKeys`) and the
|
|
3
|
+
* unused-key reconciler (`dynamicKeys`/`ignoreKeys`). Supports exact keys and a
|
|
4
|
+
* trailing `*` wildcard, so `errors.*` matches `errors.timeout` but not `errorsX`
|
|
5
|
+
* is intentional — `errors.*` slices to the prefix `errors.` (the dot is kept).
|
|
6
|
+
*/
|
|
7
|
+
export function makeGlobMatcher(patterns) {
|
|
8
|
+
if (!patterns || patterns.length === 0)
|
|
9
|
+
return () => false;
|
|
10
|
+
const exact = new Set();
|
|
11
|
+
const prefixes = [];
|
|
12
|
+
for (const pattern of patterns) {
|
|
13
|
+
if (pattern.endsWith('*'))
|
|
14
|
+
prefixes.push(pattern.slice(0, -1));
|
|
15
|
+
else
|
|
16
|
+
exact.add(pattern);
|
|
17
|
+
}
|
|
18
|
+
return (key) => exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix));
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@urbicon-ui/i18n/audit` — the dependency-free data-level audit (Feature A,
|
|
3
|
+
* also on the main entry) plus the source-scanning unused-key detector
|
|
4
|
+
* (Feature B). The scanner lazily imports `typescript` and `svelte/compiler`
|
|
5
|
+
* (optional peers), so this subpath stays out of the i18n runtime entry and the
|
|
6
|
+
* heavy deps load only when a scan actually runs.
|
|
7
|
+
*
|
|
8
|
+
* The CLI (`urbicon i18n`) is the filesystem front end over this pure core: it
|
|
9
|
+
* globs sources, reads bundles, then calls `scanSources` + `findUnusedKeys` +
|
|
10
|
+
* `auditTranslations` here.
|
|
11
|
+
*/
|
|
12
|
+
export type { I18nMissingKey, Locale } from '../i18n/types.js';
|
|
13
|
+
export { isLocaleSupported, SUPPORTED_LOCALES } from '../i18n/types.js';
|
|
14
|
+
export { collectDeepKeys } from '../utils/deep-keys.js';
|
|
15
|
+
export { makeGlobMatcher } from './glob.js';
|
|
16
|
+
export type { MissingKeyCollector, MissingKeyRecord } from './missing-key-collector.js';
|
|
17
|
+
export { createMissingKeyCollector } from './missing-key-collector.js';
|
|
18
|
+
export type { FindHardcodedOptions, HardcodedFinding } from './scan/hardcoded.js';
|
|
19
|
+
export { findHardcodedStrings } from './scan/hardcoded.js';
|
|
20
|
+
export type { ScanSourcesResult } from './scan/scanner.js';
|
|
21
|
+
export { scanSource, scanSources } from './scan/scanner.js';
|
|
22
|
+
export type { KeyUsageSite, ScanOptions, UsageScan } from './scan/types.js';
|
|
23
|
+
export type { AuditTranslationsOptions, TranslationAuditReport, TranslationFinding, TranslationFindingCode, TranslationFindingSeverity } from './translations.js';
|
|
24
|
+
export { auditTranslations } from './translations.js';
|
|
25
|
+
export type { DynamicPrefixCoverage, FindUnusedOptions, UnusedKeyFinding, UnusedReport, UsedButUndefinedFinding } from './unused.js';
|
|
26
|
+
export { findUnusedKeys } from './unused.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@urbicon-ui/i18n/audit` — the dependency-free data-level audit (Feature A,
|
|
3
|
+
* also on the main entry) plus the source-scanning unused-key detector
|
|
4
|
+
* (Feature B). The scanner lazily imports `typescript` and `svelte/compiler`
|
|
5
|
+
* (optional peers), so this subpath stays out of the i18n runtime entry and the
|
|
6
|
+
* heavy deps load only when a scan actually runs.
|
|
7
|
+
*
|
|
8
|
+
* The CLI (`urbicon i18n`) is the filesystem front end over this pure core: it
|
|
9
|
+
* globs sources, reads bundles, then calls `scanSources` + `findUnusedKeys` +
|
|
10
|
+
* `auditTranslations` here.
|
|
11
|
+
*/
|
|
12
|
+
export { isLocaleSupported, SUPPORTED_LOCALES } from '../i18n/types.js';
|
|
13
|
+
// Re-exported so the CLI can derive defined keys from a bundle and validate
|
|
14
|
+
// locale tags without importing the main (Svelte-bearing) entry — keeps the node
|
|
15
|
+
// CLI free of Svelte runtime.
|
|
16
|
+
export { collectDeepKeys } from '../utils/deep-keys.js';
|
|
17
|
+
export { makeGlobMatcher } from './glob.js';
|
|
18
|
+
export { createMissingKeyCollector } from './missing-key-collector.js';
|
|
19
|
+
export { findHardcodedStrings } from './scan/hardcoded.js';
|
|
20
|
+
// Usage scanner (Feature B) + hardcoded-string lint (Feature C).
|
|
21
|
+
export { scanSource, scanSources } from './scan/scanner.js';
|
|
22
|
+
// Data-level translation audit (Feature A).
|
|
23
|
+
export { auditTranslations } from './translations.js';
|
|
24
|
+
export { findUnusedKeys } from './unused.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createMissingKeyCollector` — a ready-made `onMissingKey` sink that records
|
|
3
|
+
* every resolved-nowhere key so a test or E2E run can assert "no missing keys
|
|
4
|
+
* were hit", or feed observed dynamic keys into the unused-key scanner.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const misses = createMissingKeyCollector();
|
|
8
|
+
* configureI18n({ onMissingKey: misses.onMissingKey });
|
|
9
|
+
* // … render / exercise the app …
|
|
10
|
+
* expect(misses.isClean()).toBe(true); // fail-loud on any raw-key render
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* This is the runtime counterpart to {@link auditTranslations}: the audit catches
|
|
14
|
+
* keys missing *between bundles* statically; the collector catches keys a code
|
|
15
|
+
* path actually *requested* but no bundle defined — including dynamically built
|
|
16
|
+
* ones a static scan cannot see.
|
|
17
|
+
*/
|
|
18
|
+
import type { I18nMissingKey, Locale } from '../i18n/types.js';
|
|
19
|
+
export interface MissingKeyRecord {
|
|
20
|
+
/** The unresolved key. */
|
|
21
|
+
key: string;
|
|
22
|
+
/** Active locale when the miss occurred. */
|
|
23
|
+
locale: Locale;
|
|
24
|
+
/** Package scope, if the call was package-scoped. */
|
|
25
|
+
packageName?: string;
|
|
26
|
+
/** How many times this (key, locale, package) miss was observed. */
|
|
27
|
+
count: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MissingKeyCollector {
|
|
30
|
+
/** Wire this into `configureI18n({ onMissingKey })`. */
|
|
31
|
+
onMissingKey: (info: I18nMissingKey) => void;
|
|
32
|
+
/** Distinct misses observed, sorted by key then locale, with hit counts. */
|
|
33
|
+
report(): MissingKeyRecord[];
|
|
34
|
+
/** Whether no miss has been observed (convenience for assertions). */
|
|
35
|
+
isClean(): boolean;
|
|
36
|
+
/** Forget everything observed so far (e.g. in a `beforeEach`). */
|
|
37
|
+
reset(): void;
|
|
38
|
+
}
|
|
39
|
+
export declare function createMissingKeyCollector(): MissingKeyCollector;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createMissingKeyCollector` — a ready-made `onMissingKey` sink that records
|
|
3
|
+
* every resolved-nowhere key so a test or E2E run can assert "no missing keys
|
|
4
|
+
* were hit", or feed observed dynamic keys into the unused-key scanner.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const misses = createMissingKeyCollector();
|
|
8
|
+
* configureI18n({ onMissingKey: misses.onMissingKey });
|
|
9
|
+
* // … render / exercise the app …
|
|
10
|
+
* expect(misses.isClean()).toBe(true); // fail-loud on any raw-key render
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* This is the runtime counterpart to {@link auditTranslations}: the audit catches
|
|
14
|
+
* keys missing *between bundles* statically; the collector catches keys a code
|
|
15
|
+
* path actually *requested* but no bundle defined — including dynamically built
|
|
16
|
+
* ones a static scan cannot see.
|
|
17
|
+
*/
|
|
18
|
+
export function createMissingKeyCollector() {
|
|
19
|
+
const records = new Map();
|
|
20
|
+
const idOf = (info) => `${info.packageName ?? ''}::${info.locale}::${info.key}`;
|
|
21
|
+
return {
|
|
22
|
+
onMissingKey(info) {
|
|
23
|
+
const id = idOf(info);
|
|
24
|
+
const existing = records.get(id);
|
|
25
|
+
if (existing) {
|
|
26
|
+
existing.count += 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
records.set(id, {
|
|
30
|
+
key: info.key,
|
|
31
|
+
locale: info.locale,
|
|
32
|
+
packageName: info.packageName,
|
|
33
|
+
count: 1
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
report() {
|
|
37
|
+
return [...records.values()].sort((a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale));
|
|
38
|
+
},
|
|
39
|
+
isClean() {
|
|
40
|
+
return records.size === 0;
|
|
41
|
+
},
|
|
42
|
+
reset() {
|
|
43
|
+
records.clear();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded-string lint (Feature C) — surface literal UI copy in `.svelte` markup
|
|
3
|
+
* that was never routed through i18n. Heuristic and therefore ADVISORY by default
|
|
4
|
+
* (the CLI gates it only on opt-in): it flags plain markup text and a small set of
|
|
5
|
+
* human-readable attributes (`aria-label`, `title`, `placeholder`, `alt`), skips
|
|
6
|
+
* code-shaped strings, and never looks inside `<script>`/`<style>` (those hold no
|
|
7
|
+
* Text/Attribute AST nodes) or a `<T>` component (already translated).
|
|
8
|
+
*/
|
|
9
|
+
export interface HardcodedFinding {
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
/** The offending literal, trimmed. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** Markup text content, or a flagged attribute value. */
|
|
15
|
+
kind: 'text' | 'attribute';
|
|
16
|
+
/** The attribute name when `kind === 'attribute'`. */
|
|
17
|
+
attribute?: string;
|
|
18
|
+
context: string;
|
|
19
|
+
}
|
|
20
|
+
export interface FindHardcodedOptions {
|
|
21
|
+
/** Exact strings or `prefix*` globs to never flag. */
|
|
22
|
+
ignoreStrings?: string[];
|
|
23
|
+
/** Attribute names to check (default: aria-label, title, placeholder, alt). */
|
|
24
|
+
attributes?: string[];
|
|
25
|
+
/** Length window for a candidate (default 3–80). */
|
|
26
|
+
minLength?: number;
|
|
27
|
+
maxLength?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function findHardcodedStrings(code: string, file: string, options?: FindHardcodedOptions): Promise<HardcodedFinding[]>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded-string lint (Feature C) — surface literal UI copy in `.svelte` markup
|
|
3
|
+
* that was never routed through i18n. Heuristic and therefore ADVISORY by default
|
|
4
|
+
* (the CLI gates it only on opt-in): it flags plain markup text and a small set of
|
|
5
|
+
* human-readable attributes (`aria-label`, `title`, `placeholder`, `alt`), skips
|
|
6
|
+
* code-shaped strings, and never looks inside `<script>`/`<style>` (those hold no
|
|
7
|
+
* Text/Attribute AST nodes) or a `<T>` component (already translated).
|
|
8
|
+
*/
|
|
9
|
+
import { makeGlobMatcher } from '../glob.js';
|
|
10
|
+
import { makeContextAt, makeLineAt } from './recognize.js';
|
|
11
|
+
import { asNodes, asString, loadParse, walkAst } from './svelte-ast.js';
|
|
12
|
+
const DEFAULT_ATTRIBUTES = ['aria-label', 'title', 'placeholder', 'alt'];
|
|
13
|
+
/** Does a trimmed string read like human UI copy rather than code/identifiers/data? */
|
|
14
|
+
function looksLikeCopy(text, min, max) {
|
|
15
|
+
if (text.length < min || text.length > max)
|
|
16
|
+
return false;
|
|
17
|
+
if (!/[a-zA-Z]/.test(text))
|
|
18
|
+
return false; // needs a letter
|
|
19
|
+
if (/^https?:\/\//.test(text))
|
|
20
|
+
return false; // URL
|
|
21
|
+
if (/^[\w.-]+@[\w.-]+$/.test(text))
|
|
22
|
+
return false; // email
|
|
23
|
+
if (/^[A-Z0-9_]+$/.test(text))
|
|
24
|
+
return false; // CONST_CASE
|
|
25
|
+
if (/^\w+(\.\w+)+$/.test(text))
|
|
26
|
+
return false; // dotted key / filename
|
|
27
|
+
if (/^[a-z][a-zA-Z0-9]*$/.test(text))
|
|
28
|
+
return false; // single camelCase token (variable-ish)
|
|
29
|
+
if (/^[\d\s.,:;/–—-]+$/.test(text))
|
|
30
|
+
return false; // numbers / dates / separators
|
|
31
|
+
if (/[{}<>=]/.test(text))
|
|
32
|
+
return false; // markup/code fragments
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
export async function findHardcodedStrings(code, file, options = {}) {
|
|
36
|
+
const parse = await loadParse();
|
|
37
|
+
const ast = parse(code, { modern: true });
|
|
38
|
+
const lineAt = makeLineAt(code);
|
|
39
|
+
const contextAt = makeContextAt(code);
|
|
40
|
+
const checked = new Set((options.attributes ?? DEFAULT_ATTRIBUTES).map((name) => name.toLowerCase()));
|
|
41
|
+
const isIgnored = makeGlobMatcher(options.ignoreStrings);
|
|
42
|
+
const min = options.minLength ?? 3;
|
|
43
|
+
const max = options.maxLength ?? 80;
|
|
44
|
+
const findings = [];
|
|
45
|
+
const seen = new Set(); // dedupe identical text@line
|
|
46
|
+
const skipText = new WeakSet(); // attribute-value Text + <T> subtree Text
|
|
47
|
+
const consider = (text, line, kind, attribute) => {
|
|
48
|
+
const trimmed = text?.trim();
|
|
49
|
+
if (!trimmed || isIgnored(trimmed) || !looksLikeCopy(trimmed, min, max))
|
|
50
|
+
return;
|
|
51
|
+
const id = `${line}:${trimmed}`;
|
|
52
|
+
if (seen.has(id))
|
|
53
|
+
return;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
findings.push({ file, line, text: trimmed, kind, attribute, context: contextAt(line) });
|
|
56
|
+
};
|
|
57
|
+
// Pass 1 — attributes (and collect Text to exclude from the markup pass). Text/
|
|
58
|
+
// Attribute nodes exist only in the template, so walking the whole Root is safe.
|
|
59
|
+
walkAst(ast, (node) => {
|
|
60
|
+
if (node.type === 'Attribute') {
|
|
61
|
+
const values = asNodes(node.value);
|
|
62
|
+
for (const value of values)
|
|
63
|
+
if (value.type === 'Text')
|
|
64
|
+
skipText.add(value);
|
|
65
|
+
const name = asString(node.name)?.toLowerCase();
|
|
66
|
+
const single = values.length === 1 ? values[0] : undefined;
|
|
67
|
+
if (name && checked.has(name) && single?.type === 'Text') {
|
|
68
|
+
consider(asString(single.data), lineAt(node.start ?? 0), 'attribute', name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (node.type === 'Component' && asString(node.name) === 'T') {
|
|
72
|
+
walkAst(node, (inner) => {
|
|
73
|
+
if (inner.type === 'Text')
|
|
74
|
+
skipText.add(inner);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
// Pass 2 — markup text not already claimed by an attribute or a <T> subtree.
|
|
79
|
+
walkAst(ast, (node) => {
|
|
80
|
+
if (node.type === 'Text' && !skipText.has(node)) {
|
|
81
|
+
consider(asString(node.data), lineAt(node.start ?? 0), 'text');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
findings.sort((a, b) => a.line - b.line || a.text.localeCompare(b.text));
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-agnostic recognition heuristics + small source helpers shared by both
|
|
3
|
+
* walkers. The crux of B1 (which calls are translation calls) lives here so the
|
|
4
|
+
* TypeScript and Svelte walkers stay thin.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtractedKey, KeyUsageSite, UsageScan } from './types.js';
|
|
7
|
+
export declare function isFactoryName(name: string): boolean;
|
|
8
|
+
export declare function isRenderMethod(name: string): boolean;
|
|
9
|
+
export declare function isProbeMethod(name: string): boolean;
|
|
10
|
+
export declare function isKeyMethod(name: string): boolean;
|
|
11
|
+
/** File-local identifiers bound to a translate function, split render vs probe. */
|
|
12
|
+
export interface Bindings {
|
|
13
|
+
render: Set<string>;
|
|
14
|
+
probe: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
export declare function createBindings(extra?: string[]): Bindings;
|
|
17
|
+
export declare function createScan(): UsageScan;
|
|
18
|
+
/** Route one call's extracted keys into the scan; an empty extraction is an opaque site. */
|
|
19
|
+
export declare function recordKeyCall(scan: UsageScan, extractions: ExtractedKey[], site: KeyUsageSite, isProbe: boolean): void;
|
|
20
|
+
/** Combine per-file scans into one. */
|
|
21
|
+
export declare function mergeScans(scans: UsageScan[]): UsageScan;
|
|
22
|
+
/** A fast 1-based line lookup from a character offset, precomputed once per file. */
|
|
23
|
+
export declare function makeLineAt(code: string): (offset: number) => number;
|
|
24
|
+
/** The trimmed text of a 1-based line, for report context. */
|
|
25
|
+
export declare function makeContextAt(code: string): (line: number) => string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-agnostic recognition heuristics + small source helpers shared by both
|
|
3
|
+
* walkers. The crux of B1 (which calls are translation calls) lives here so the
|
|
4
|
+
* TypeScript and Svelte walkers stay thin.
|
|
5
|
+
*/
|
|
6
|
+
// A factory hook whose result is (or yields) a translate function: the global
|
|
7
|
+
// `useI18n` / `useTranslate`, plus the per-package re-exports consumers make
|
|
8
|
+
// (`useBlocksI18n`, `useTableI18n`, `useFooTranslate`). Matching by this naming
|
|
9
|
+
// convention is what lets the scanner discover arbitrarily-named local aliases.
|
|
10
|
+
const FACTORY_RE = /^use([A-Z]\w*)?(I18n|Translate)$/;
|
|
11
|
+
export function isFactoryName(name) {
|
|
12
|
+
return FACTORY_RE.test(name);
|
|
13
|
+
}
|
|
14
|
+
const RENDER_METHODS = new Set(['t', 'plural', 'translate']);
|
|
15
|
+
const PROBE_METHODS = new Set(['exists']);
|
|
16
|
+
export function isRenderMethod(name) {
|
|
17
|
+
return RENDER_METHODS.has(name);
|
|
18
|
+
}
|
|
19
|
+
export function isProbeMethod(name) {
|
|
20
|
+
return PROBE_METHODS.has(name);
|
|
21
|
+
}
|
|
22
|
+
export function isKeyMethod(name) {
|
|
23
|
+
return RENDER_METHODS.has(name) || PROBE_METHODS.has(name);
|
|
24
|
+
}
|
|
25
|
+
/** Bare identifiers that are always translation render-calls, even without a binding. */
|
|
26
|
+
const DEFAULT_RENDER_IDENTIFIERS = ['t', '$t'];
|
|
27
|
+
export function createBindings(extra = []) {
|
|
28
|
+
return { render: new Set([...DEFAULT_RENDER_IDENTIFIERS, ...extra]), probe: new Set() };
|
|
29
|
+
}
|
|
30
|
+
export function createScan() {
|
|
31
|
+
return {
|
|
32
|
+
staticKeys: new Map(),
|
|
33
|
+
probeKeys: new Set(),
|
|
34
|
+
dynamicPrefixes: [],
|
|
35
|
+
opaqueSites: [],
|
|
36
|
+
literalPool: new Set()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Route one call's extracted keys into the scan; an empty extraction is an opaque site. */
|
|
40
|
+
export function recordKeyCall(scan, extractions, site, isProbe) {
|
|
41
|
+
if (extractions.length === 0) {
|
|
42
|
+
scan.opaqueSites.push(site);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const extracted of extractions) {
|
|
46
|
+
if (extracted.kind === 'static') {
|
|
47
|
+
if (isProbe) {
|
|
48
|
+
scan.probeKeys.add(extracted.value);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const sites = scan.staticKeys.get(extracted.value);
|
|
52
|
+
if (sites)
|
|
53
|
+
sites.push(site);
|
|
54
|
+
else
|
|
55
|
+
scan.staticKeys.set(extracted.value, [site]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (extracted.kind === 'prefix') {
|
|
59
|
+
scan.dynamicPrefixes.push({ prefix: extracted.prefix, site });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
scan.opaqueSites.push(site);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Combine per-file scans into one. */
|
|
67
|
+
export function mergeScans(scans) {
|
|
68
|
+
const merged = createScan();
|
|
69
|
+
for (const scan of scans) {
|
|
70
|
+
for (const [key, sites] of scan.staticKeys) {
|
|
71
|
+
const existing = merged.staticKeys.get(key);
|
|
72
|
+
if (existing)
|
|
73
|
+
existing.push(...sites);
|
|
74
|
+
else
|
|
75
|
+
merged.staticKeys.set(key, [...sites]);
|
|
76
|
+
}
|
|
77
|
+
for (const key of scan.probeKeys)
|
|
78
|
+
merged.probeKeys.add(key);
|
|
79
|
+
merged.dynamicPrefixes.push(...scan.dynamicPrefixes);
|
|
80
|
+
merged.opaqueSites.push(...scan.opaqueSites);
|
|
81
|
+
for (const literal of scan.literalPool)
|
|
82
|
+
merged.literalPool.add(literal);
|
|
83
|
+
}
|
|
84
|
+
return merged;
|
|
85
|
+
}
|
|
86
|
+
/** A fast 1-based line lookup from a character offset, precomputed once per file. */
|
|
87
|
+
export function makeLineAt(code) {
|
|
88
|
+
const starts = [0];
|
|
89
|
+
for (let i = 0; i < code.length; i++) {
|
|
90
|
+
if (code[i] === '\n')
|
|
91
|
+
starts.push(i + 1);
|
|
92
|
+
}
|
|
93
|
+
return (offset) => {
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = starts.length - 1;
|
|
96
|
+
while (lo < hi) {
|
|
97
|
+
const mid = (lo + hi + 1) >> 1;
|
|
98
|
+
if (starts[mid] <= offset)
|
|
99
|
+
lo = mid;
|
|
100
|
+
else
|
|
101
|
+
hi = mid - 1;
|
|
102
|
+
}
|
|
103
|
+
return lo + 1;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The trimmed text of a 1-based line, for report context. */
|
|
107
|
+
export function makeContextAt(code) {
|
|
108
|
+
const lines = code.split('\n');
|
|
109
|
+
return (line) => (lines[line - 1] ?? '').trim();
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-scanner entry: dispatch by extension to the Svelte or TypeScript walker,
|
|
3
|
+
* and a multi-file helper that merges per-file scans while surfacing (never
|
|
4
|
+
* swallowing) files that failed to parse.
|
|
5
|
+
*/
|
|
6
|
+
import type { ScanOptions, UsageScan } from './types.js';
|
|
7
|
+
/** Scan one source's text for translation-key usage. */
|
|
8
|
+
export declare function scanSource(code: string, file: string, options?: ScanOptions): Promise<UsageScan>;
|
|
9
|
+
export interface ScanSourcesResult {
|
|
10
|
+
scan: UsageScan;
|
|
11
|
+
/** Files that could not be parsed — reported, not silently dropped. */
|
|
12
|
+
errors: Array<{
|
|
13
|
+
file: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** Scan many sources concurrently and merge them; parse failures become `errors`. */
|
|
18
|
+
export declare function scanSources(files: Array<{
|
|
19
|
+
file: string;
|
|
20
|
+
code: string;
|
|
21
|
+
}>, options?: ScanOptions): Promise<ScanSourcesResult>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-scanner entry: dispatch by extension to the Svelte or TypeScript walker,
|
|
3
|
+
* and a multi-file helper that merges per-file scans while surfacing (never
|
|
4
|
+
* swallowing) files that failed to parse.
|
|
5
|
+
*/
|
|
6
|
+
import { mergeScans } from './recognize.js';
|
|
7
|
+
import { scanSvelte } from './svelte-walker.js';
|
|
8
|
+
import { scanTs } from './ts-walker.js';
|
|
9
|
+
/** Scan one source's text for translation-key usage. */
|
|
10
|
+
export function scanSource(code, file, options = {}) {
|
|
11
|
+
return file.endsWith('.svelte') ? scanSvelte(code, file, options) : scanTs(code, file, options);
|
|
12
|
+
}
|
|
13
|
+
/** Scan many sources concurrently and merge them; parse failures become `errors`. */
|
|
14
|
+
export async function scanSources(files, options = {}) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const scans = [];
|
|
17
|
+
await Promise.all(files.map(async ({ file, code }) => {
|
|
18
|
+
try {
|
|
19
|
+
scans.push(await scanSource(code, file, options));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
errors.push({ file, message: error instanceof Error ? error.message : String(error) });
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
return { scan: mergeScans(scans), errors };
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Svelte-AST plumbing for the usage walker and the hardcoded-string lint:
|
|
3
|
+
* the lazily-imported `parse`, a permissive `unknown`-based node accessor set, and
|
|
4
|
+
* one iterative cycle-safe DFS. Kept dependency-light — `svelte/compiler` loads
|
|
5
|
+
* only when a `.svelte` source is actually scanned.
|
|
6
|
+
*/
|
|
7
|
+
type SvelteParse = typeof import('svelte/compiler')['parse'];
|
|
8
|
+
export declare function loadParse(): Promise<SvelteParse>;
|
|
9
|
+
export interface AstNode {
|
|
10
|
+
type: string;
|
|
11
|
+
start?: number;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
export declare const asNode: (value: unknown) => AstNode | undefined;
|
|
15
|
+
export declare const asNodes: (value: unknown) => AstNode[];
|
|
16
|
+
export declare const asString: (value: unknown) => string | undefined;
|
|
17
|
+
/** A TemplateElement's text. `.value` is a plain `{ raw, cooked }` (no `.type`). */
|
|
18
|
+
export declare const cookedOf: (quasi: AstNode | undefined) => string | undefined;
|
|
19
|
+
/** Iterative DFS over the whole AST; skips non-structural keys and guards cycles. */
|
|
20
|
+
export declare function walkAst(root: unknown, visit: (node: AstNode) => void): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Svelte-AST plumbing for the usage walker and the hardcoded-string lint:
|
|
3
|
+
* the lazily-imported `parse`, a permissive `unknown`-based node accessor set, and
|
|
4
|
+
* one iterative cycle-safe DFS. Kept dependency-light — `svelte/compiler` loads
|
|
5
|
+
* only when a `.svelte` source is actually scanned.
|
|
6
|
+
*/
|
|
7
|
+
let parsePromise;
|
|
8
|
+
export async function loadParse() {
|
|
9
|
+
if (!parsePromise) {
|
|
10
|
+
parsePromise = (async () => {
|
|
11
|
+
try {
|
|
12
|
+
return (await import('svelte/compiler')).parse;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error('@urbicon-ui/i18n/audit needs the "svelte" peer to scan .svelte sources — install it.');
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
}
|
|
19
|
+
return parsePromise;
|
|
20
|
+
}
|
|
21
|
+
export const asNode = (value) => value && typeof value === 'object' && typeof value.type === 'string'
|
|
22
|
+
? value
|
|
23
|
+
: undefined;
|
|
24
|
+
export const asNodes = (value) => Array.isArray(value) ? value.flatMap((item) => asNode(item) ?? []) : [];
|
|
25
|
+
export const asString = (value) => typeof value === 'string' ? value : undefined;
|
|
26
|
+
/** A TemplateElement's text. `.value` is a plain `{ raw, cooked }` (no `.type`). */
|
|
27
|
+
export const cookedOf = (quasi) => {
|
|
28
|
+
const value = quasi?.value;
|
|
29
|
+
return asString(value?.cooked) ?? asString(value?.raw);
|
|
30
|
+
};
|
|
31
|
+
/** Iterative DFS over the whole AST; skips non-structural keys and guards cycles. */
|
|
32
|
+
export function walkAst(root, visit) {
|
|
33
|
+
const seen = new WeakSet();
|
|
34
|
+
const stack = [root];
|
|
35
|
+
while (stack.length) {
|
|
36
|
+
const current = stack.pop();
|
|
37
|
+
if (!current || typeof current !== 'object' || seen.has(current))
|
|
38
|
+
continue;
|
|
39
|
+
seen.add(current);
|
|
40
|
+
if (Array.isArray(current)) {
|
|
41
|
+
for (const item of current)
|
|
42
|
+
stack.push(item);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const node = current;
|
|
46
|
+
if (typeof node.type === 'string')
|
|
47
|
+
visit(node);
|
|
48
|
+
for (const key in node) {
|
|
49
|
+
if (key === 'type' ||
|
|
50
|
+
key === 'loc' ||
|
|
51
|
+
key === 'parent' ||
|
|
52
|
+
key === 'metadata' ||
|
|
53
|
+
key === 'name_loc') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const child = node[key];
|
|
57
|
+
if (child && typeof child === 'object')
|
|
58
|
+
stack.push(child);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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 type { ScanOptions, UsageScan } from './types.js';
|
|
11
|
+
export declare function scanSvelte(code: string, file: string, options?: ScanOptions): Promise<UsageScan>;
|