next-tongue 0.9.7 → 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/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/scan-keys.d.ts +16 -2
- package/dist/compiler/scan-keys.js +39 -10
- 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.js +2 -2
- 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/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
|
}
|
|
@@ -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,6 +1,15 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
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
|
+
];
|
|
4
13
|
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mdx']);
|
|
5
14
|
const SKIP_DIRS = new Set([
|
|
6
15
|
'node_modules',
|
|
@@ -15,10 +24,10 @@ const SKIP_DIRS = new Set([
|
|
|
15
24
|
* Find `t('key')` / `t.rich('key')` under a known `useT`/`getT` namespace,
|
|
16
25
|
* plus `t.dynamic(..., { from })` declared keys and dynamic `t(var)` markers.
|
|
17
26
|
*/
|
|
18
|
-
export async function scanReferencedKeys(cwd, roots =
|
|
27
|
+
export async function scanReferencedKeys(cwd, roots = DEFAULT_SCAN_ROOTS) {
|
|
19
28
|
return (await scanKeys(cwd, roots)).refs;
|
|
20
29
|
}
|
|
21
|
-
export async function scanKeys(cwd, roots =
|
|
30
|
+
export async function scanKeys(cwd, roots = DEFAULT_SCAN_ROOTS) {
|
|
22
31
|
const files = [];
|
|
23
32
|
for (const root of roots) {
|
|
24
33
|
const abs = join(cwd, root);
|
|
@@ -33,6 +42,7 @@ export async function scanKeys(cwd, roots = ['app', 'src', 'components']) {
|
|
|
33
42
|
}
|
|
34
43
|
const refs = [];
|
|
35
44
|
const dynamicNamespaces = new Set();
|
|
45
|
+
const undeclaredDynamics = [];
|
|
36
46
|
for (const file of files) {
|
|
37
47
|
const src = await readFile(file, 'utf8');
|
|
38
48
|
const rel = relative(cwd, file);
|
|
@@ -40,8 +50,9 @@ export async function scanKeys(cwd, roots = ['app', 'src', 'components']) {
|
|
|
40
50
|
refs.push(...part.refs);
|
|
41
51
|
for (const ns of part.dynamicNamespaces)
|
|
42
52
|
dynamicNamespaces.add(ns);
|
|
53
|
+
undeclaredDynamics.push(...part.undeclaredDynamics);
|
|
43
54
|
}
|
|
44
|
-
return { refs, dynamicNamespaces };
|
|
55
|
+
return { refs, dynamicNamespaces, undeclaredDynamics };
|
|
45
56
|
}
|
|
46
57
|
async function walk(dir, out) {
|
|
47
58
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -129,12 +140,16 @@ export function extractFromSource(source, file) {
|
|
|
129
140
|
const code = stripComments(source);
|
|
130
141
|
const out = [];
|
|
131
142
|
const dynamicNamespaces = new Set();
|
|
132
|
-
const
|
|
143
|
+
const undeclaredDynamics = [];
|
|
144
|
+
const translators = new Map();
|
|
133
145
|
// useT('ns') | getT('ns') | getT(locale, 'ns')
|
|
134
|
-
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;
|
|
135
147
|
let m;
|
|
136
148
|
while ((m = declRe.exec(code)) !== null) {
|
|
137
|
-
translators.set(m[1],
|
|
149
|
+
translators.set(m[1], {
|
|
150
|
+
ns: m[4] || '_root',
|
|
151
|
+
kind: m[2],
|
|
152
|
+
});
|
|
138
153
|
}
|
|
139
154
|
const push = (ns, key, index) => {
|
|
140
155
|
const fullKey = ns === '_root' || ns === '' ? key : `${ns}.${key}`;
|
|
@@ -146,7 +161,7 @@ export function extractFromSource(source, file) {
|
|
|
146
161
|
line: lineAt(code, index),
|
|
147
162
|
});
|
|
148
163
|
};
|
|
149
|
-
for (const [varName, ns] of translators) {
|
|
164
|
+
for (const [varName, { ns, kind }] of translators) {
|
|
150
165
|
// Literal t('key') / t.rich('key')
|
|
151
166
|
const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
|
|
152
167
|
let cm;
|
|
@@ -168,14 +183,28 @@ export function extractFromSource(source, file) {
|
|
|
168
183
|
while ((nm = nsRe.exec(code)) !== null) {
|
|
169
184
|
dynamicNamespaces.add(nm[2] || '_root');
|
|
170
185
|
}
|
|
171
|
-
// 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.
|
|
172
188
|
// Do NOT harvest unrelated string arrays (cache tags, sort keys, …).
|
|
173
189
|
const nonLitRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(?!['"])([A-Za-z_$][\\w$]*)`, 'g');
|
|
174
|
-
|
|
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) {
|
|
175
204
|
dynamicNamespaces.add(ns || '_root');
|
|
176
205
|
}
|
|
177
206
|
}
|
|
178
|
-
return { refs: out, dynamicNamespaces };
|
|
207
|
+
return { refs: out, dynamicNamespaces, undeclaredDynamics };
|
|
179
208
|
}
|
|
180
209
|
function lineAt(source, index) {
|
|
181
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.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Escape analysis: if `t` is passed around, falls back to namespace import + warning.
|
|
13
13
|
*/
|
|
14
14
|
import { findDynamicCalls } from '../compiler/dynamic-from.js';
|
|
15
|
+
import { safeIdent } from '../compiler/codegen/emit-js.js';
|
|
15
16
|
const USE_T_DECL = /(?:const|let|var)\s+(\w+)\s*=\s*useT\s*\(\s*(['"])([^'"]*)\2\s*\)/g;
|
|
16
17
|
export function transformCallSites(source, resourcePath, _options = {}) {
|
|
17
18
|
const warnings = [];
|
|
@@ -181,8 +182,7 @@ function safeImportIdent(ns, key) {
|
|
|
181
182
|
return `__tongue_${(ns + '_' + key).replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
182
183
|
}
|
|
183
184
|
function safeExport(key) {
|
|
184
|
-
|
|
185
|
-
return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned || '_';
|
|
185
|
+
return safeIdent(key);
|
|
186
186
|
}
|
|
187
187
|
/** Webpack/Turbopack loader entry. */
|
|
188
188
|
export default function tongueLoader(source) {
|