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.
@@ -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);
@@ -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
  }
@@ -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 = ['app', 'src', 'components']) {
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 = ['app', 'src', 'components']) {
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 translators = new Map(); // var → ns
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+)?(?: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;
135
147
  let m;
136
148
  while ((m = declRe.exec(code)) !== null) {
137
- translators.set(m[1], m[3] || '_root');
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 only.
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
- 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) {
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 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',
@@ -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
- const cleaned = key.replace(/[^A-Za-z0-9_]/g, '_');
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.7",
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",