next-tongue 0.9.6 → 0.9.8
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/cli/check-types.d.ts +1 -1
- package/dist/cli/extract.js +4 -2
- package/dist/client/index.js +1 -1
- package/dist/compiler/build.js +49 -6
- package/dist/compiler/codegen/emit-js.d.ts +1 -1
- package/dist/compiler/codegen/emit-js.js +59 -4
- package/dist/compiler/dynamic-from.d.ts +31 -0
- package/dist/compiler/dynamic-from.js +240 -0
- package/dist/compiler/scan-keys.d.ts +16 -2
- package/dist/compiler/scan-keys.js +45 -20
- package/dist/compiler/validate/catalog.d.ts +2 -0
- package/dist/compiler/validate/catalog.js +23 -0
- package/dist/compiler/validate/usage.d.ts +1 -1
- package/dist/compiler/validate/usage.js +35 -3
- package/dist/config/types.d.ts +13 -0
- package/dist/config/validate.js +4 -0
- package/dist/next/loader.d.ts +2 -1
- package/dist/next/loader.js +19 -20
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type CheckKind = 'missing' | 'unused' | 'undefined' | 'mismatch' | 'syntax' | 'stale' | 'a11y' | 'bidi' | 'length' | 'duplicate';
|
|
1
|
+
export type CheckKind = 'missing' | 'unused' | 'undefined' | 'dynamic' | 'mismatch' | 'syntax' | 'stale' | 'a11y' | 'bidi' | 'length' | 'duplicate' | 'icu';
|
|
2
2
|
export type CheckSeverity = 'error' | 'warn';
|
|
3
3
|
export type CheckIssue = {
|
|
4
4
|
check: CheckKind;
|
package/dist/cli/extract.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as readline from 'node:readline';
|
|
|
3
3
|
import { loadConfig } from '../config/load-config.js';
|
|
4
4
|
import { buildMessages } from '../compiler/build.js';
|
|
5
5
|
import { loadLocaleMessages, resolveMessagePath } from '../compiler/load-messages.js';
|
|
6
|
-
import { scanReferencedKeys } from '../compiler/scan-keys.js';
|
|
6
|
+
import { DEFAULT_SCAN_ROOTS, scanReferencedKeys, } from '../compiler/scan-keys.js';
|
|
7
7
|
import { TongueError } from '../errors.js';
|
|
8
8
|
export async function cmdExtract(opts) {
|
|
9
9
|
const { config, cwd } = await loadConfig({
|
|
@@ -15,7 +15,9 @@ export async function cmdExtract(opts) {
|
|
|
15
15
|
}
|
|
16
16
|
const roots = opts.include
|
|
17
17
|
? opts.include.split(',').map((s) => s.trim()).filter(Boolean)
|
|
18
|
-
:
|
|
18
|
+
: config.scan.include.length > 0
|
|
19
|
+
? [...config.scan.include]
|
|
20
|
+
: [...DEFAULT_SCAN_ROOTS];
|
|
19
21
|
const refs = await scanReferencedKeys(cwd, roots);
|
|
20
22
|
const used = new Set(refs.map((r) => r.fullKey));
|
|
21
23
|
const catalogs = await loadLocaleMessages(config, cwd);
|
package/dist/client/index.js
CHANGED
|
@@ -88,7 +88,7 @@ export function useT(namespace) {
|
|
|
88
88
|
*/
|
|
89
89
|
function createLoaderStubT(locale, ns) {
|
|
90
90
|
const fail = (kind) => {
|
|
91
|
-
throw new TongueError('TONGUE_E_NO_PROVIDER', `useT('${ns}') ${kind} was not rewritten to a per-key import. Ensure withTongue() is applied (loader on SSR + client). Prefer getT on the server for static copy; for dynamic keys use t.dynamic(key, { from: [...] })
|
|
91
|
+
throw new TongueError('TONGUE_E_NO_PROVIDER', `useT('${ns}') ${kind} was not rewritten to a per-key import. Ensure withTongue() is applied (loader on SSR + client). Prefer getT on the server for static copy; for dynamic keys use t.dynamic(key, { from: [...] }) with an inline array or a same-file const (imported from arrays are not rewritten).`);
|
|
92
92
|
};
|
|
93
93
|
const t = Object.assign((..._args) => fail('t()'), {
|
|
94
94
|
rich: () => fail('t.rich()'),
|
package/dist/compiler/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile, rm } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { TongueError } from '../errors.js';
|
|
5
5
|
import { collectArgs } from './ast.js';
|
|
@@ -16,7 +16,9 @@ import { scanAppRoutes } from './scan-routes.js';
|
|
|
16
16
|
import { loadAndMergeSources, writeSourcesCache } from './sources.js';
|
|
17
17
|
export async function buildMessages(options) {
|
|
18
18
|
const { config, cwd } = options;
|
|
19
|
-
|
|
19
|
+
/** Final `.tongue` path; may briefly point at a staging dir while writing. */
|
|
20
|
+
let outDir = resolve(cwd, config.outDir);
|
|
21
|
+
const publishedOutDir = outDir;
|
|
20
22
|
const fileCatalogs = await loadLocaleMessages(config, cwd);
|
|
21
23
|
const { catalogs, payloads: sourcePayloads } = await loadAndMergeSources(config, cwd, fileCatalogs);
|
|
22
24
|
const defaultCatalog = catalogs[config.defaultLocale];
|
|
@@ -111,7 +113,8 @@ export async function buildMessages(options) {
|
|
|
111
113
|
const defaultMsgRel = config.messages.replaceAll('{locale}', config.defaultLocale);
|
|
112
114
|
const emitLocales = localesForEmit(config);
|
|
113
115
|
// Content hash for idempotency (bump CODEGEN_VERSION when emit format changes)
|
|
114
|
-
|
|
116
|
+
// Bump when emit shape changes (incl. reserved-key ident sanitization).
|
|
117
|
+
const CODEGEN_VERSION = 8;
|
|
115
118
|
const hash = createHash('sha256')
|
|
116
119
|
.update(JSON.stringify({
|
|
117
120
|
codegen: CODEGEN_VERSION,
|
|
@@ -138,11 +141,15 @@ export async function buildMessages(options) {
|
|
|
138
141
|
/* rebuild */
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
|
-
|
|
144
|
+
// Write into a staging dir, then atomically replace `.tongue` (avoids ENOTEMPTY
|
|
145
|
+
// when the previous tree is partially locked by the bundler / another build).
|
|
146
|
+
const staging = `${publishedOutDir}.tmp-${process.pid}`;
|
|
147
|
+
await clearOutputDir(staging);
|
|
148
|
+
outDir = staging;
|
|
142
149
|
await mkdir(join(outDir, 'msg'), { recursive: true });
|
|
143
150
|
await mkdir(join(outDir, 'ns'), { recursive: true });
|
|
144
151
|
if (sourcePayloads.length) {
|
|
145
|
-
await writeSourcesCache(cwd,
|
|
152
|
+
await writeSourcesCache(cwd, staging, sourcePayloads);
|
|
146
153
|
}
|
|
147
154
|
// Shared rich-text helpers (imported by per-key modules that contain tags)
|
|
148
155
|
await writeFile(join(outDir, 'rich.js'), [
|
|
@@ -398,9 +405,17 @@ export async function buildMessages(options) {
|
|
|
398
405
|
])),
|
|
399
406
|
};
|
|
400
407
|
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
401
|
-
await writeFile(
|
|
408
|
+
await writeFile(join(outDir, '.hash'), hash, 'utf8');
|
|
402
409
|
// package.json imports helper note — apps map "#tongue/*" themselves / via withTongue
|
|
403
410
|
await writeFile(join(outDir, 'README.md'), 'Generated by next-tongue. Do not edit. Map `#tongue/*` → this directory.\nImport `#tongue/runtime` and call `ensureTongue()` once per process.\n', 'utf8');
|
|
411
|
+
await clearOutputDir(publishedOutDir);
|
|
412
|
+
try {
|
|
413
|
+
await rename(outDir, publishedOutDir);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
throw new TongueError('TONGUE_E_IO', `Cannot publish ${publishedOutDir} from staging (${err instanceof Error ? err.message : err}). Delete ${publishedOutDir} and any \`${config.outDir}.tmp-*\` dirs, then re-run tongue build.`, { file: publishedOutDir });
|
|
417
|
+
}
|
|
418
|
+
outDir = publishedOutDir;
|
|
404
419
|
return {
|
|
405
420
|
outDir,
|
|
406
421
|
locales: emitLocales,
|
|
@@ -409,6 +424,34 @@ export async function buildMessages(options) {
|
|
|
409
424
|
hash,
|
|
410
425
|
};
|
|
411
426
|
}
|
|
427
|
+
/** Remove a directory tree; on ENOTEMPTY/EBUSY, retry then rename-aside. */
|
|
428
|
+
async function clearOutputDir(path) {
|
|
429
|
+
for (let i = 0; i < 6; i++) {
|
|
430
|
+
try {
|
|
431
|
+
await rm(path, { recursive: true, force: true });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
const code = err?.code;
|
|
436
|
+
if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') {
|
|
437
|
+
if (code === 'ENOENT')
|
|
438
|
+
return;
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
await new Promise((r) => setTimeout(r, 40 * (i + 1)));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const trash = `${path}.trash-${process.pid}-${Date.now()}`;
|
|
445
|
+
try {
|
|
446
|
+
await rename(path, trash);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (err?.code === 'ENOENT')
|
|
450
|
+
return;
|
|
451
|
+
throw new TongueError('TONGUE_E_IO', `Cannot clear ${path} (${err instanceof Error ? err.message : err}). Delete it manually and re-run tongue build.`, { file: path });
|
|
452
|
+
}
|
|
453
|
+
void rm(trash, { recursive: true, force: true }).catch(() => { });
|
|
454
|
+
}
|
|
412
455
|
/** Locales written into message modules / types (includes pseudo when enabled). */
|
|
413
456
|
export function localesForEmit(config) {
|
|
414
457
|
if (!config.pseudo || config.pseudo.enabled === false)
|
|
@@ -26,5 +26,5 @@ export declare function emitRichMessageFunction(exportName: string, ast: Message
|
|
|
26
26
|
}): string;
|
|
27
27
|
/** True when the AST contains at least one rich tag. */
|
|
28
28
|
export declare function hasTags(ast: MessageAst): boolean;
|
|
29
|
-
/** Safe JS identifier from a message key. */
|
|
29
|
+
/** Safe JS identifier from a message key (e.g. `continue` → `continue_`). */
|
|
30
30
|
export declare function safeIdent(key: string): string;
|
|
@@ -342,10 +342,65 @@ function stringifyAstAsConstant(ast) {
|
|
|
342
342
|
}
|
|
343
343
|
return out;
|
|
344
344
|
}
|
|
345
|
-
/**
|
|
345
|
+
/** ECMAScript reserved words / strict-mode reserved — unsafe as bare identifiers. */
|
|
346
|
+
const RESERVED_IDENTS = new Set([
|
|
347
|
+
'arguments',
|
|
348
|
+
'await',
|
|
349
|
+
'break',
|
|
350
|
+
'case',
|
|
351
|
+
'catch',
|
|
352
|
+
'class',
|
|
353
|
+
'const',
|
|
354
|
+
'continue',
|
|
355
|
+
'debugger',
|
|
356
|
+
'default',
|
|
357
|
+
'delete',
|
|
358
|
+
'do',
|
|
359
|
+
'else',
|
|
360
|
+
'enum',
|
|
361
|
+
'eval',
|
|
362
|
+
'export',
|
|
363
|
+
'extends',
|
|
364
|
+
'false',
|
|
365
|
+
'finally',
|
|
366
|
+
'for',
|
|
367
|
+
'function',
|
|
368
|
+
'if',
|
|
369
|
+
'implements',
|
|
370
|
+
'import',
|
|
371
|
+
'in',
|
|
372
|
+
'instanceof',
|
|
373
|
+
'interface',
|
|
374
|
+
'let',
|
|
375
|
+
'new',
|
|
376
|
+
'null',
|
|
377
|
+
'package',
|
|
378
|
+
'private',
|
|
379
|
+
'protected',
|
|
380
|
+
'public',
|
|
381
|
+
'return',
|
|
382
|
+
'static',
|
|
383
|
+
'super',
|
|
384
|
+
'switch',
|
|
385
|
+
'this',
|
|
386
|
+
'throw',
|
|
387
|
+
'true',
|
|
388
|
+
'try',
|
|
389
|
+
'typeof',
|
|
390
|
+
'var',
|
|
391
|
+
'void',
|
|
392
|
+
'while',
|
|
393
|
+
'with',
|
|
394
|
+
'yield',
|
|
395
|
+
]);
|
|
396
|
+
/** Safe JS identifier from a message key (e.g. `continue` → `continue_`). */
|
|
346
397
|
export function safeIdent(key) {
|
|
347
|
-
|
|
398
|
+
let cleaned = key.replace(/[^A-Za-z0-9_]/g, '_');
|
|
399
|
+
if (!cleaned)
|
|
400
|
+
cleaned = '_';
|
|
348
401
|
if (/^[0-9]/.test(cleaned))
|
|
349
|
-
|
|
350
|
-
|
|
402
|
+
cleaned = `_${cleaned}`;
|
|
403
|
+
if (RESERVED_IDENTS.has(cleaned))
|
|
404
|
+
cleaned = `${cleaned}_`;
|
|
405
|
+
return cleaned;
|
|
351
406
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `t.dynamic(key, { from })` key lists for the call-site loader and
|
|
3
|
+
* `tongue check` scan. Supports:
|
|
4
|
+
* - inline arrays: `{ from: ['a', 'b'] }`
|
|
5
|
+
* - same-file const arrays: `const X = ['a', 'b']; { from: X }`
|
|
6
|
+
* - Object.values of same-file const objects: `{ from: Object.values(BTN_KEYS) }`
|
|
7
|
+
*
|
|
8
|
+
* Imported / computed `from` expressions are not resolved (left as stubs).
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractStringLiterals(body: string): string[];
|
|
11
|
+
/** Take a balanced `[...]` / `{...}` / `(...)` starting at `source[start]`. */
|
|
12
|
+
export declare function takeBalanced(source: string, start: number, open: string, close: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve keys from a `from` expression text (already sliced out of the call).
|
|
15
|
+
* Returns null when the expression cannot be resolved statically in-file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveFromKeys(fileSource: string, fromExpr: string): string[] | null;
|
|
18
|
+
export type DynamicCallSite = {
|
|
19
|
+
/** Start of `t.dynamic` */
|
|
20
|
+
start: number;
|
|
21
|
+
/** End after closing `)` */
|
|
22
|
+
end: number;
|
|
23
|
+
keyExpr: string;
|
|
24
|
+
fromExpr: string;
|
|
25
|
+
valuesExpr: string | null;
|
|
26
|
+
keys: string[] | null;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Find `varName.dynamic(...)` call sites and resolve their `from` keys.
|
|
30
|
+
*/
|
|
31
|
+
export declare function findDynamicCalls(source: string, varName: string): DynamicCallSite[];
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `t.dynamic(key, { from })` key lists for the call-site loader and
|
|
3
|
+
* `tongue check` scan. Supports:
|
|
4
|
+
* - inline arrays: `{ from: ['a', 'b'] }`
|
|
5
|
+
* - same-file const arrays: `const X = ['a', 'b']; { from: X }`
|
|
6
|
+
* - Object.values of same-file const objects: `{ from: Object.values(BTN_KEYS) }`
|
|
7
|
+
*
|
|
8
|
+
* Imported / computed `from` expressions are not resolved (left as stubs).
|
|
9
|
+
*/
|
|
10
|
+
export function extractStringLiterals(body) {
|
|
11
|
+
return [...body.matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
12
|
+
}
|
|
13
|
+
/** Take a balanced `[...]` / `{...}` / `(...)` starting at `source[start]`. */
|
|
14
|
+
export function takeBalanced(source, start, open, close) {
|
|
15
|
+
if (source[start] !== open)
|
|
16
|
+
return null;
|
|
17
|
+
let depth = 0;
|
|
18
|
+
let inStr = null;
|
|
19
|
+
let escape = false;
|
|
20
|
+
for (let i = start; i < source.length; i++) {
|
|
21
|
+
const c = source[i];
|
|
22
|
+
if (inStr) {
|
|
23
|
+
if (escape) {
|
|
24
|
+
escape = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (c === '\\' && inStr !== '`') {
|
|
28
|
+
escape = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === inStr)
|
|
32
|
+
inStr = null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
36
|
+
inStr = c;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (c === open)
|
|
40
|
+
depth++;
|
|
41
|
+
else if (c === close) {
|
|
42
|
+
depth--;
|
|
43
|
+
if (depth === 0)
|
|
44
|
+
return source.slice(start, i + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function escapeRegExp(s) {
|
|
50
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
}
|
|
52
|
+
/** Index of `ident`'s `const|let|var` initializer expression, or -1. */
|
|
53
|
+
function findDeclInit(source, ident) {
|
|
54
|
+
const re = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(ident)}\\s*=\\s*`, 'g');
|
|
55
|
+
const m = re.exec(source);
|
|
56
|
+
return m ? m.index + m[0].length : -1;
|
|
57
|
+
}
|
|
58
|
+
function skipWs(source, i) {
|
|
59
|
+
while (/\s/.test(source[i] ?? ''))
|
|
60
|
+
i++;
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
function resolveConstArrayKeys(source, ident) {
|
|
64
|
+
const init = findDeclInit(source, ident);
|
|
65
|
+
if (init < 0)
|
|
66
|
+
return null;
|
|
67
|
+
const arr = takeBalanced(source, skipWs(source, init), '[', ']');
|
|
68
|
+
if (!arr)
|
|
69
|
+
return null;
|
|
70
|
+
return extractStringLiterals(arr.slice(1, -1));
|
|
71
|
+
}
|
|
72
|
+
function resolveObjectValueKeys(source, ident) {
|
|
73
|
+
const init = findDeclInit(source, ident);
|
|
74
|
+
if (init < 0)
|
|
75
|
+
return null;
|
|
76
|
+
// Type assertions (`as const`) sit after the literal; object is first.
|
|
77
|
+
const obj = takeBalanced(source, skipWs(source, init), '{', '}');
|
|
78
|
+
if (!obj)
|
|
79
|
+
return null;
|
|
80
|
+
return [...obj.matchAll(/:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve keys from a `from` expression text (already sliced out of the call).
|
|
84
|
+
* Returns null when the expression cannot be resolved statically in-file.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveFromKeys(fileSource, fromExpr) {
|
|
87
|
+
const trimmed = fromExpr.trim();
|
|
88
|
+
if (!trimmed)
|
|
89
|
+
return null;
|
|
90
|
+
if (trimmed.startsWith('[')) {
|
|
91
|
+
const arr = takeBalanced(trimmed, 0, '[', ']');
|
|
92
|
+
if (!arr)
|
|
93
|
+
return null;
|
|
94
|
+
return extractStringLiterals(arr.slice(1, -1));
|
|
95
|
+
}
|
|
96
|
+
const ov = /^Object\.values\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(trimmed);
|
|
97
|
+
if (ov) {
|
|
98
|
+
const keys = resolveObjectValueKeys(fileSource, ov[1]);
|
|
99
|
+
return keys && keys.length > 0 ? keys : null;
|
|
100
|
+
}
|
|
101
|
+
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
|
|
102
|
+
const keys = resolveConstArrayKeys(fileSource, trimmed);
|
|
103
|
+
return keys && keys.length > 0 ? keys : null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Find `varName.dynamic(...)` call sites and resolve their `from` keys.
|
|
109
|
+
*/
|
|
110
|
+
export function findDynamicCalls(source, varName) {
|
|
111
|
+
const re = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
|
|
112
|
+
const out = [];
|
|
113
|
+
let m;
|
|
114
|
+
while ((m = re.exec(source)) !== null) {
|
|
115
|
+
const openParen = m.index + m[0].length - 1;
|
|
116
|
+
const parsed = parseDynamicArgs(source, openParen);
|
|
117
|
+
if (!parsed)
|
|
118
|
+
continue;
|
|
119
|
+
const keys = resolveFromKeys(source, parsed.fromExpr);
|
|
120
|
+
out.push({
|
|
121
|
+
start: m.index,
|
|
122
|
+
end: parsed.closeParen + 1,
|
|
123
|
+
keyExpr: parsed.keyExpr,
|
|
124
|
+
fromExpr: parsed.fromExpr,
|
|
125
|
+
valuesExpr: parsed.valuesExpr,
|
|
126
|
+
keys,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function parseDynamicArgs(source, openParen) {
|
|
132
|
+
let i = openParen + 1;
|
|
133
|
+
while (/\s/.test(source[i] ?? ''))
|
|
134
|
+
i++;
|
|
135
|
+
const keyStart = i;
|
|
136
|
+
i = scanArgEnd(source, i);
|
|
137
|
+
if (i < 0 || source[i] !== ',')
|
|
138
|
+
return null;
|
|
139
|
+
const keyExpr = source.slice(keyStart, i).trim();
|
|
140
|
+
i++;
|
|
141
|
+
while (/\s/.test(source[i] ?? ''))
|
|
142
|
+
i++;
|
|
143
|
+
if (source[i] !== '{')
|
|
144
|
+
return null;
|
|
145
|
+
const opts = takeBalanced(source, i, '{', '}');
|
|
146
|
+
if (!opts)
|
|
147
|
+
return null;
|
|
148
|
+
const optsEnd = i + opts.length;
|
|
149
|
+
const fromExpr = extractFromProp(opts);
|
|
150
|
+
if (fromExpr == null)
|
|
151
|
+
return null;
|
|
152
|
+
i = optsEnd;
|
|
153
|
+
while (/\s/.test(source[i] ?? ''))
|
|
154
|
+
i++;
|
|
155
|
+
let valuesExpr = null;
|
|
156
|
+
if (source[i] === ',') {
|
|
157
|
+
i++;
|
|
158
|
+
while (/\s/.test(source[i] ?? ''))
|
|
159
|
+
i++;
|
|
160
|
+
const valuesStart = i;
|
|
161
|
+
i = scanArgEnd(source, i);
|
|
162
|
+
if (i < 0)
|
|
163
|
+
return null;
|
|
164
|
+
valuesExpr = source.slice(valuesStart, i).trim() || null;
|
|
165
|
+
}
|
|
166
|
+
while (/\s/.test(source[i] ?? ''))
|
|
167
|
+
i++;
|
|
168
|
+
if (source[i] !== ')')
|
|
169
|
+
return null;
|
|
170
|
+
return { keyExpr, fromExpr, valuesExpr, closeParen: i };
|
|
171
|
+
}
|
|
172
|
+
/** Scan to next top-level `,` or `)` (not inside strings/brackets). */
|
|
173
|
+
function scanArgEnd(source, start) {
|
|
174
|
+
let depthParen = 0;
|
|
175
|
+
let depthBracket = 0;
|
|
176
|
+
let depthBrace = 0;
|
|
177
|
+
let inStr = null;
|
|
178
|
+
let escape = false;
|
|
179
|
+
for (let i = start; i < source.length; i++) {
|
|
180
|
+
const c = source[i];
|
|
181
|
+
if (inStr) {
|
|
182
|
+
if (escape) {
|
|
183
|
+
escape = false;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (c === '\\' && inStr !== '`') {
|
|
187
|
+
escape = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (c === inStr)
|
|
191
|
+
inStr = null;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
195
|
+
inStr = c;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (c === '(')
|
|
199
|
+
depthParen++;
|
|
200
|
+
else if (c === ')') {
|
|
201
|
+
if (depthParen === 0 && depthBracket === 0 && depthBrace === 0)
|
|
202
|
+
return i;
|
|
203
|
+
depthParen--;
|
|
204
|
+
}
|
|
205
|
+
else if (c === '[')
|
|
206
|
+
depthBracket++;
|
|
207
|
+
else if (c === ']')
|
|
208
|
+
depthBracket--;
|
|
209
|
+
else if (c === '{')
|
|
210
|
+
depthBrace++;
|
|
211
|
+
else if (c === '}')
|
|
212
|
+
depthBrace--;
|
|
213
|
+
else if (c === ',' &&
|
|
214
|
+
depthParen === 0 &&
|
|
215
|
+
depthBracket === 0 &&
|
|
216
|
+
depthBrace === 0) {
|
|
217
|
+
return i;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
function extractFromProp(optsLiteral) {
|
|
223
|
+
// optsLiteral includes surrounding braces
|
|
224
|
+
const inner = optsLiteral.slice(1, -1);
|
|
225
|
+
const fromIdx = /\bfrom\s*:/.exec(inner);
|
|
226
|
+
if (!fromIdx)
|
|
227
|
+
return null;
|
|
228
|
+
let i = fromIdx.index + fromIdx[0].length;
|
|
229
|
+
while (/\s/.test(inner[i] ?? ''))
|
|
230
|
+
i++;
|
|
231
|
+
if (inner[i] === '[') {
|
|
232
|
+
const arr = takeBalanced(inner, i, '[', ']');
|
|
233
|
+
return arr;
|
|
234
|
+
}
|
|
235
|
+
const ov = /^Object\.values\(\s*[A-Za-z_$][\w$]*\s*\)/.exec(inner.slice(i));
|
|
236
|
+
if (ov)
|
|
237
|
+
return ov[0];
|
|
238
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(inner.slice(i));
|
|
239
|
+
return id ? id[0] : null;
|
|
240
|
+
}
|
|
@@ -5,6 +5,13 @@ export type ReferencedKey = {
|
|
|
5
5
|
file: string;
|
|
6
6
|
line: number;
|
|
7
7
|
};
|
|
8
|
+
export type UndeclaredDynamic = {
|
|
9
|
+
file: string;
|
|
10
|
+
line: number;
|
|
11
|
+
/** Translator binding, e.g. `t` */
|
|
12
|
+
name: string;
|
|
13
|
+
namespace: string;
|
|
14
|
+
};
|
|
8
15
|
export type ScanResult = {
|
|
9
16
|
refs: ReferencedKey[];
|
|
10
17
|
/**
|
|
@@ -12,13 +19,20 @@ export type ScanResult = {
|
|
|
12
19
|
* unused-key warnings for these namespaces are suppressed.
|
|
13
20
|
*/
|
|
14
21
|
dynamicNamespaces: Set<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Client `useT` + `t(variable)` without `t.dynamic` — runtime stub will throw.
|
|
24
|
+
* Server `getT` non-literals are OK (whole namespace is loaded).
|
|
25
|
+
*/
|
|
26
|
+
undeclaredDynamics: UndeclaredDynamic[];
|
|
15
27
|
};
|
|
28
|
+
/** Default directories scanned by `tongue check` / `extract`. */
|
|
29
|
+
export declare const DEFAULT_SCAN_ROOTS: readonly ["app", "src", "components", "lib", "server", "actions"];
|
|
16
30
|
/**
|
|
17
31
|
* Find `t('key')` / `t.rich('key')` under a known `useT`/`getT` namespace,
|
|
18
32
|
* plus `t.dynamic(..., { from })` declared keys and dynamic `t(var)` markers.
|
|
19
33
|
*/
|
|
20
|
-
export declare function scanReferencedKeys(cwd: string, roots?: string[]): Promise<ReferencedKey[]>;
|
|
21
|
-
export declare function scanKeys(cwd: string, roots?: string[]): Promise<ScanResult>;
|
|
34
|
+
export declare function scanReferencedKeys(cwd: string, roots?: readonly string[]): Promise<ReferencedKey[]>;
|
|
35
|
+
export declare function scanKeys(cwd: string, roots?: readonly string[]): Promise<ScanResult>;
|
|
22
36
|
/** Strip // and /* *\/ comments; preserve newlines for line numbers. */
|
|
23
37
|
export declare function stripComments(source: string): string;
|
|
24
38
|
export declare function extractFromSource(source: string, file: string): ScanResult;
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
+
import { findDynamicCalls } from './dynamic-from.js';
|
|
4
|
+
/** Default directories scanned by `tongue check` / `extract`. */
|
|
5
|
+
export const DEFAULT_SCAN_ROOTS = [
|
|
6
|
+
'app',
|
|
7
|
+
'src',
|
|
8
|
+
'components',
|
|
9
|
+
'lib',
|
|
10
|
+
'server',
|
|
11
|
+
'actions',
|
|
12
|
+
];
|
|
3
13
|
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mdx']);
|
|
4
14
|
const SKIP_DIRS = new Set([
|
|
5
15
|
'node_modules',
|
|
@@ -14,10 +24,10 @@ const SKIP_DIRS = new Set([
|
|
|
14
24
|
* Find `t('key')` / `t.rich('key')` under a known `useT`/`getT` namespace,
|
|
15
25
|
* plus `t.dynamic(..., { from })` declared keys and dynamic `t(var)` markers.
|
|
16
26
|
*/
|
|
17
|
-
export async function scanReferencedKeys(cwd, roots =
|
|
27
|
+
export async function scanReferencedKeys(cwd, roots = DEFAULT_SCAN_ROOTS) {
|
|
18
28
|
return (await scanKeys(cwd, roots)).refs;
|
|
19
29
|
}
|
|
20
|
-
export async function scanKeys(cwd, roots =
|
|
30
|
+
export async function scanKeys(cwd, roots = DEFAULT_SCAN_ROOTS) {
|
|
21
31
|
const files = [];
|
|
22
32
|
for (const root of roots) {
|
|
23
33
|
const abs = join(cwd, root);
|
|
@@ -32,6 +42,7 @@ export async function scanKeys(cwd, roots = ['app', 'src', 'components']) {
|
|
|
32
42
|
}
|
|
33
43
|
const refs = [];
|
|
34
44
|
const dynamicNamespaces = new Set();
|
|
45
|
+
const undeclaredDynamics = [];
|
|
35
46
|
for (const file of files) {
|
|
36
47
|
const src = await readFile(file, 'utf8');
|
|
37
48
|
const rel = relative(cwd, file);
|
|
@@ -39,8 +50,9 @@ export async function scanKeys(cwd, roots = ['app', 'src', 'components']) {
|
|
|
39
50
|
refs.push(...part.refs);
|
|
40
51
|
for (const ns of part.dynamicNamespaces)
|
|
41
52
|
dynamicNamespaces.add(ns);
|
|
53
|
+
undeclaredDynamics.push(...part.undeclaredDynamics);
|
|
42
54
|
}
|
|
43
|
-
return { refs, dynamicNamespaces };
|
|
55
|
+
return { refs, dynamicNamespaces, undeclaredDynamics };
|
|
44
56
|
}
|
|
45
57
|
async function walk(dir, out) {
|
|
46
58
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -128,12 +140,16 @@ export function extractFromSource(source, file) {
|
|
|
128
140
|
const code = stripComments(source);
|
|
129
141
|
const out = [];
|
|
130
142
|
const dynamicNamespaces = new Set();
|
|
131
|
-
const
|
|
143
|
+
const undeclaredDynamics = [];
|
|
144
|
+
const translators = new Map();
|
|
132
145
|
// useT('ns') | getT('ns') | getT(locale, 'ns')
|
|
133
|
-
const declRe = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(
|
|
146
|
+
const declRe = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(useT|getT)\s*\(\s*(?:[^'"\n,]+,\s*)?(['"])([^'"]*)\3\s*\)/g;
|
|
134
147
|
let m;
|
|
135
148
|
while ((m = declRe.exec(code)) !== null) {
|
|
136
|
-
translators.set(m[1],
|
|
149
|
+
translators.set(m[1], {
|
|
150
|
+
ns: m[4] || '_root',
|
|
151
|
+
kind: m[2],
|
|
152
|
+
});
|
|
137
153
|
}
|
|
138
154
|
const push = (ns, key, index) => {
|
|
139
155
|
const fullKey = ns === '_root' || ns === '' ? key : `${ns}.${key}`;
|
|
@@ -145,23 +161,18 @@ export function extractFromSource(source, file) {
|
|
|
145
161
|
line: lineAt(code, index),
|
|
146
162
|
});
|
|
147
163
|
};
|
|
148
|
-
for (const [varName, ns] of translators) {
|
|
164
|
+
for (const [varName, { ns, kind }] of translators) {
|
|
149
165
|
// Literal t('key') / t.rich('key')
|
|
150
166
|
const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
|
|
151
167
|
let cm;
|
|
152
168
|
while ((cm = callRe.exec(code)) !== null) {
|
|
153
169
|
push(ns, cm[2], cm.index);
|
|
154
170
|
}
|
|
155
|
-
// t.dynamic(any, { from
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
|
|
161
|
-
if (fromMatch) {
|
|
162
|
-
const keys = fromMatch[1].match(/['"]([^'"]+)['"]/g) ?? [];
|
|
163
|
-
for (const raw of keys) {
|
|
164
|
-
push(ns, raw.slice(1, -1), dm.index);
|
|
171
|
+
// t.dynamic(any, { from }) — only resolvable `from` keys become refs
|
|
172
|
+
for (const call of findDynamicCalls(code, varName)) {
|
|
173
|
+
if (call.keys) {
|
|
174
|
+
for (const key of call.keys) {
|
|
175
|
+
push(ns, key, call.start);
|
|
165
176
|
}
|
|
166
177
|
}
|
|
167
178
|
dynamicNamespaces.add(ns || '_root');
|
|
@@ -172,14 +183,28 @@ export function extractFromSource(source, file) {
|
|
|
172
183
|
while ((nm = nsRe.exec(code)) !== null) {
|
|
173
184
|
dynamicNamespaces.add(nm[2] || '_root');
|
|
174
185
|
}
|
|
175
|
-
// Non-literal t(var) — suppress unused for the namespace
|
|
186
|
+
// Non-literal t(var) — suppress unused for the namespace.
|
|
187
|
+
// Client useT stubs throw — report each site. Server getT is fine.
|
|
176
188
|
// Do NOT harvest unrelated string arrays (cache tags, sort keys, …).
|
|
177
189
|
const nonLitRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(?!['"])([A-Za-z_$][\\w$]*)`, 'g');
|
|
178
|
-
|
|
190
|
+
let nl;
|
|
191
|
+
let sawNonLit = false;
|
|
192
|
+
while ((nl = nonLitRe.exec(code)) !== null) {
|
|
193
|
+
sawNonLit = true;
|
|
194
|
+
if (kind === 'useT') {
|
|
195
|
+
undeclaredDynamics.push({
|
|
196
|
+
file,
|
|
197
|
+
line: lineAt(code, nl.index),
|
|
198
|
+
name: varName,
|
|
199
|
+
namespace: ns || '_root',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (sawNonLit) {
|
|
179
204
|
dynamicNamespaces.add(ns || '_root');
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
|
-
return { refs: out, dynamicNamespaces };
|
|
207
|
+
return { refs: out, dynamicNamespaces, undeclaredDynamics };
|
|
183
208
|
}
|
|
184
209
|
function lineAt(source, index) {
|
|
185
210
|
let line = 1;
|
|
@@ -20,3 +20,5 @@ export type TongueLock = {
|
|
|
20
20
|
export declare function validateStale(config: ResolvedTongueConfig, cwd: string, opts?: {
|
|
21
21
|
locales?: readonly string[];
|
|
22
22
|
}): Promise<CheckIssue[]>;
|
|
23
|
+
/** Letter + single quote + letter — usually meant as prose apostrophe, not ICU quote. */
|
|
24
|
+
export declare function likelyUnescapedApostrophe(icu: string): boolean;
|
|
@@ -69,6 +69,17 @@ export async function validateCatalog(config, cwd, opts) {
|
|
|
69
69
|
});
|
|
70
70
|
continue;
|
|
71
71
|
}
|
|
72
|
+
if (likelyUnescapedApostrophe(source)) {
|
|
73
|
+
issues.push({
|
|
74
|
+
check: 'icu',
|
|
75
|
+
severity: 'warn',
|
|
76
|
+
message: `Key "${fullKey}" looks like a natural-language apostrophe (e.g. French). In ICU, double it: "l''été" → l'été`,
|
|
77
|
+
key: fullKey,
|
|
78
|
+
locale: config.defaultLocale,
|
|
79
|
+
file,
|
|
80
|
+
...(line != null ? { line } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
72
83
|
const defaultArgs = collectArgs(defaultAst);
|
|
73
84
|
for (const locale of locales) {
|
|
74
85
|
if (locale === config.defaultLocale)
|
|
@@ -224,3 +235,15 @@ export async function validateStale(config, cwd, opts) {
|
|
|
224
235
|
}
|
|
225
236
|
return issues;
|
|
226
237
|
}
|
|
238
|
+
/** Letter + single quote + letter — usually meant as prose apostrophe, not ICU quote. */
|
|
239
|
+
export function likelyUnescapedApostrophe(icu) {
|
|
240
|
+
for (let i = 0; i < icu.length - 2; i++) {
|
|
241
|
+
const a = icu[i];
|
|
242
|
+
const b = icu[i + 1];
|
|
243
|
+
const c = icu[i + 2];
|
|
244
|
+
if (b === "'" && /[A-Za-zÀ-ÿ]/.test(a) && /[A-Za-zÀ-ÿ]/.test(c)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ResolvedTongueConfig } from '../../config/types.js';
|
|
2
2
|
import type { CheckIssue } from '../../cli/check-types.js';
|
|
3
|
-
/** unused + undefined checks against scanned source
|
|
3
|
+
/** unused + undefined + undeclared client dynamic checks against scanned source. */
|
|
4
4
|
export declare function validateUsage(config: ResolvedTongueConfig, cwd: string): Promise<CheckIssue[]>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadLocaleMessages } from '../load-messages.js';
|
|
2
|
-
import { scanKeys } from '../scan-keys.js';
|
|
2
|
+
import { DEFAULT_SCAN_ROOTS, scanKeys } from '../scan-keys.js';
|
|
3
3
|
function severityFor(level, alwaysError = false) {
|
|
4
4
|
if (alwaysError)
|
|
5
5
|
return 'error';
|
|
@@ -11,7 +11,26 @@ function namespaceOf(fullKey) {
|
|
|
11
11
|
const i = fullKey.indexOf('.');
|
|
12
12
|
return i === -1 ? '_root' : fullKey.slice(0, i);
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/** Glob-lite: `*` matches any substring (e.g. `common.*`, `*.seeAll`). */
|
|
15
|
+
function matchesIgnore(fullKey, patterns) {
|
|
16
|
+
for (const raw of patterns) {
|
|
17
|
+
const p = raw.trim();
|
|
18
|
+
if (!p)
|
|
19
|
+
continue;
|
|
20
|
+
if (p === fullKey)
|
|
21
|
+
return true;
|
|
22
|
+
if (!p.includes('*'))
|
|
23
|
+
continue;
|
|
24
|
+
const re = new RegExp(`^${p
|
|
25
|
+
.split('*')
|
|
26
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
27
|
+
.join('.*')}$`);
|
|
28
|
+
if (re.test(fullKey))
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
/** unused + undefined + undeclared client dynamic checks against scanned source. */
|
|
15
34
|
export async function validateUsage(config, cwd) {
|
|
16
35
|
const issues = [];
|
|
17
36
|
const catalogs = await loadLocaleMessages(config, cwd);
|
|
@@ -24,7 +43,8 @@ export async function validateUsage(config, cwd) {
|
|
|
24
43
|
defined.add(ns === '_root' ? key : `${ns}.${key}`);
|
|
25
44
|
}
|
|
26
45
|
}
|
|
27
|
-
const
|
|
46
|
+
const roots = config.scan.include.length > 0 ? config.scan.include : DEFAULT_SCAN_ROOTS;
|
|
47
|
+
const { refs, dynamicNamespaces, undeclaredDynamics } = await scanKeys(cwd, roots);
|
|
28
48
|
const used = new Set();
|
|
29
49
|
for (const ref of refs) {
|
|
30
50
|
used.add(ref.fullKey);
|
|
@@ -39,11 +59,23 @@ export async function validateUsage(config, cwd) {
|
|
|
39
59
|
});
|
|
40
60
|
}
|
|
41
61
|
}
|
|
62
|
+
for (const site of undeclaredDynamics) {
|
|
63
|
+
issues.push({
|
|
64
|
+
check: 'dynamic',
|
|
65
|
+
severity: 'error',
|
|
66
|
+
message: `useT('${site.namespace}') ${site.name}(variable) is not a string literal — use ${site.name}.dynamic(key, { from: [...] }) so the loader can import keys (plain ${site.name}(var) throws at runtime on the client)`,
|
|
67
|
+
file: site.file,
|
|
68
|
+
line: site.line,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
42
71
|
const unusedSev = severityFor(config.strict.unused);
|
|
43
72
|
if (unusedSev) {
|
|
73
|
+
const ignore = config.strict.unusedIgnore;
|
|
44
74
|
for (const fullKey of defined) {
|
|
45
75
|
if (used.has(fullKey))
|
|
46
76
|
continue;
|
|
77
|
+
if (matchesIgnore(fullKey, ignore))
|
|
78
|
+
continue;
|
|
47
79
|
// Dynamic t(var) / t.ns / t.dynamic — do not false-flag catalog keys as unused
|
|
48
80
|
if (dynamicNamespaces.has(namespaceOf(fullKey)))
|
|
49
81
|
continue;
|
package/dist/config/types.d.ts
CHANGED
|
@@ -55,9 +55,18 @@ export type TongueConfig = {
|
|
|
55
55
|
list?: Record<string, Intl.ListFormatOptions>;
|
|
56
56
|
};
|
|
57
57
|
outDir?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Directories scanned by `tongue check` / `extract` for `getT`/`useT` call sites.
|
|
60
|
+
* Defaults to app, src, components, lib, server, actions.
|
|
61
|
+
*/
|
|
62
|
+
scan?: {
|
|
63
|
+
include?: string[];
|
|
64
|
+
};
|
|
58
65
|
strict?: {
|
|
59
66
|
missing?: StrictLevel;
|
|
60
67
|
unused?: StrictLevel;
|
|
68
|
+
/** Full keys or globs (`common.*`) excluded from unused warnings. */
|
|
69
|
+
unusedIgnore?: string[];
|
|
61
70
|
mismatch?: StrictLevel;
|
|
62
71
|
a11y?: StrictLevel;
|
|
63
72
|
bidi?: StrictLevel;
|
|
@@ -102,9 +111,13 @@ export type ResolvedTongueConfig = {
|
|
|
102
111
|
list: Record<string, Intl.ListFormatOptions>;
|
|
103
112
|
};
|
|
104
113
|
outDir: string;
|
|
114
|
+
scan: {
|
|
115
|
+
include: readonly string[];
|
|
116
|
+
};
|
|
105
117
|
strict: {
|
|
106
118
|
missing: StrictLevel;
|
|
107
119
|
unused: StrictLevel;
|
|
120
|
+
unusedIgnore: readonly string[];
|
|
108
121
|
mismatch: StrictLevel;
|
|
109
122
|
a11y: StrictLevel;
|
|
110
123
|
bidi: StrictLevel;
|
package/dist/config/validate.js
CHANGED
|
@@ -127,9 +127,13 @@ export function resolveConfig(input) {
|
|
|
127
127
|
list: input.formats?.list ?? {},
|
|
128
128
|
},
|
|
129
129
|
outDir: input.outDir ?? '.tongue',
|
|
130
|
+
scan: {
|
|
131
|
+
include: input.scan?.include ?? [],
|
|
132
|
+
},
|
|
130
133
|
strict: {
|
|
131
134
|
missing: input.strict?.missing ?? 'error',
|
|
132
135
|
unused: input.strict?.unused ?? 'warn',
|
|
136
|
+
unusedIgnore: input.strict?.unusedIgnore ?? [],
|
|
133
137
|
mismatch: input.strict?.mismatch ?? 'error',
|
|
134
138
|
a11y: input.strict?.a11y ?? 'error',
|
|
135
139
|
bidi: input.strict?.bidi ?? 'warn',
|
package/dist/next/loader.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* const t = useT('home'); t('inbox', { count })
|
|
7
7
|
* into a static import of the per-key module when the key is a string literal.
|
|
8
8
|
*
|
|
9
|
-
* Also rewrites `t.dynamic(key, { from
|
|
9
|
+
* Also rewrites `t.dynamic(key, { from })` to a map of those imports when `from`
|
|
10
|
+
* is an inline array, a same-file const array, or `Object.values(sameFileConst)`.
|
|
10
11
|
*
|
|
11
12
|
* Escape analysis: if `t` is passed around, falls back to namespace import + warning.
|
|
12
13
|
*/
|
package/dist/next/loader.js
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* const t = useT('home'); t('inbox', { count })
|
|
7
7
|
* into a static import of the per-key module when the key is a string literal.
|
|
8
8
|
*
|
|
9
|
-
* Also rewrites `t.dynamic(key, { from
|
|
9
|
+
* Also rewrites `t.dynamic(key, { from })` to a map of those imports when `from`
|
|
10
|
+
* is an inline array, a same-file const array, or `Object.values(sameFileConst)`.
|
|
10
11
|
*
|
|
11
12
|
* Escape analysis: if `t` is passed around, falls back to namespace import + warning.
|
|
12
13
|
*/
|
|
14
|
+
import { findDynamicCalls } from '../compiler/dynamic-from.js';
|
|
15
|
+
import { safeIdent } from '../compiler/codegen/emit-js.js';
|
|
13
16
|
const USE_T_DECL = /(?:const|let|var)\s+(\w+)\s*=\s*useT\s*\(\s*(['"])([^'"]*)\2\s*\)/g;
|
|
14
17
|
export function transformCallSites(source, resourcePath, _options = {}) {
|
|
15
18
|
const warnings = [];
|
|
@@ -44,16 +47,11 @@ export function transformCallSites(source, resourcePath, _options = {}) {
|
|
|
44
47
|
imports.set(`${ns}:${key}`, { ns, key, ident });
|
|
45
48
|
return full;
|
|
46
49
|
});
|
|
47
|
-
// t.dynamic(expr, { from
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
while ((dm = dynRe.exec(withoutDirs)) !== null) {
|
|
51
|
-
const tail = withoutDirs.slice(dm.index, dm.index + 500);
|
|
52
|
-
const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
|
|
53
|
-
if (!fromMatch)
|
|
50
|
+
// t.dynamic(expr, { from }, values?) — collect resolvable keys
|
|
51
|
+
for (const call of findDynamicCalls(withoutDirs, varName)) {
|
|
52
|
+
if (!call.keys)
|
|
54
53
|
continue;
|
|
55
|
-
const
|
|
56
|
-
for (const key of keys) {
|
|
54
|
+
for (const key of call.keys) {
|
|
57
55
|
const ident = safeImportIdent(ns, key);
|
|
58
56
|
imports.set(`${ns}:${key}`, { ns, key, ident });
|
|
59
57
|
}
|
|
@@ -72,13 +70,14 @@ export function transformCallSites(source, resourcePath, _options = {}) {
|
|
|
72
70
|
const localeVar = '__tongue_locale';
|
|
73
71
|
// Inject locale once after the first useT declaration
|
|
74
72
|
code = code.replace(/((?:const|let|var)\s+\w+\s*=\s*useT\s*\([^)]*\))/, `$1\n const ${localeVar} = useLocale();`);
|
|
75
|
-
// Replace t.dynamic(expr, { from
|
|
73
|
+
// Replace t.dynamic(expr, { from }, values?) — rewrite from the end so indices stay valid
|
|
76
74
|
for (const [varName, ns] of translators) {
|
|
77
75
|
if (escapes(withoutDirs, varName))
|
|
78
76
|
continue;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
77
|
+
const calls = findDynamicCalls(code, varName).filter((c) => c.keys && c.keys.length > 0);
|
|
78
|
+
for (let ci = calls.length - 1; ci >= 0; ci--) {
|
|
79
|
+
const call = calls[ci];
|
|
80
|
+
const keys = call.keys;
|
|
82
81
|
const mapEntries = keys
|
|
83
82
|
.map((key) => {
|
|
84
83
|
const ident = safeImportIdent(ns, key);
|
|
@@ -86,11 +85,12 @@ export function transformCallSites(source, resourcePath, _options = {}) {
|
|
|
86
85
|
return `${JSON.stringify(key)}: ${ident}`;
|
|
87
86
|
})
|
|
88
87
|
.join(', ');
|
|
89
|
-
const valuesArg =
|
|
90
|
-
? `, ${
|
|
88
|
+
const valuesArg = call.valuesExpr != null && call.valuesExpr.trim() !== ''
|
|
89
|
+
? `, ${call.valuesExpr.trim()}`
|
|
91
90
|
: '';
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
const replacement = `((__tongue_dyn) => { const __tongue_fn = __tongue_dyn[${call.keyExpr.trim()}]; if (!__tongue_fn) throw new Error(${JSON.stringify(`[next-tongue] t.dynamic key not in from: [${keys.join(', ')}]`)}); return __tongue_fn(${localeVar}${valuesArg}); })({ ${mapEntries} })`;
|
|
92
|
+
code = code.slice(0, call.start) + replacement + code.slice(call.end);
|
|
93
|
+
}
|
|
94
94
|
}
|
|
95
95
|
// Replace t('key', values) / t.rich('key', values) with per-key calls
|
|
96
96
|
for (const [varName, ns] of translators) {
|
|
@@ -182,8 +182,7 @@ function safeImportIdent(ns, key) {
|
|
|
182
182
|
return `__tongue_${(ns + '_' + key).replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
183
183
|
}
|
|
184
184
|
function safeExport(key) {
|
|
185
|
-
|
|
186
|
-
return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned || '_';
|
|
185
|
+
return safeIdent(key);
|
|
187
186
|
}
|
|
188
187
|
/** Webpack/Turbopack loader entry. */
|
|
189
188
|
export default function tongueLoader(source) {
|