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.
@@ -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;
@@ -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
- : ['app', 'src', 'components'];
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);
@@ -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: [...] }) so the loader can import those keys.`);
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()'),
@@ -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
- const outDir = resolve(cwd, config.outDir);
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
- const CODEGEN_VERSION = 7;
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
- await rm(outDir, { recursive: true, force: true });
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, config.outDir, sourcePayloads);
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(hashPath, hash, 'utf8');
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
- /** Safe JS identifier from a message key. */
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
- const cleaned = key.replace(/[^A-Za-z0-9_]/g, '_');
398
+ let cleaned = key.replace(/[^A-Za-z0-9_]/g, '_');
399
+ if (!cleaned)
400
+ cleaned = '_';
348
401
  if (/^[0-9]/.test(cleaned))
349
- return `_${cleaned}`;
350
- return cleaned || '_';
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 = ['app', 'src', 'components']) {
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 = ['app', 'src', 'components']) {
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 translators = new Map(); // var → ns
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+)?(?:useT|getT)\s*\(\s*(?:[^'"\n,]+,\s*)?(['"])([^'"]*)\2\s*\)/g;
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], m[3] || '_root');
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: ['a', 'b'] }) — only `from` keys become refs
156
- const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
157
- let dm;
158
- while ((dm = dynRe.exec(code)) !== null) {
159
- const tail = code.slice(dm.index, dm.index + 400);
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 only.
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
- if (nonLitRe.test(code)) {
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 references. */
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
- /** unused + undefined checks against scanned source references. */
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 { refs, dynamicNamespaces } = await scanKeys(cwd);
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;
@@ -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;
@@ -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',
@@ -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: [...] })` to a map of those imports.
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
  */
@@ -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: [...] })` to a map of those imports.
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: ['a', 'b'] }, values?)
48
- const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
49
- let dm;
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 keys = [...fromMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
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: [...] }, values?)
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 dynCallRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(\\s*([^,]+?)\\s*,\\s*\\{[^}]*?from\\s*:\\s*\\[([^\\]]*)][^}]*\\}\\s*(?:,\\s*([^)]*?))?\\)`, 'g');
80
- code = code.replace(dynCallRe, (_full, keyExpr, fromBody, values) => {
81
- const keys = [...fromBody.matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
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 = values != null && String(values).trim() !== ''
90
- ? `, ${String(values).trim()}`
88
+ const valuesArg = call.valuesExpr != null && call.valuesExpr.trim() !== ''
89
+ ? `, ${call.valuesExpr.trim()}`
91
90
  : '';
92
- return `((__tongue_dyn) => { const __tongue_fn = __tongue_dyn[${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} })`;
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
- const cleaned = key.replace(/[^A-Za-z0-9_]/g, '_');
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",