next-tongue 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/a11y.js +5 -1
- package/dist/compiler/validate/catalog.d.ts +7 -0
- package/dist/compiler/validate/catalog.js +42 -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/navigation/index.d.ts +8 -0
- package/dist/navigation/index.js +14 -0
- package/dist/next/loader.js +2 -2
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +3 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -1
- package/dist/server/request-locale.d.ts +27 -0
- package/dist/server/request-locale.js +22 -0
- 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;
|
|
@@ -75,6 +75,8 @@ function hasLangHint(src) {
|
|
|
75
75
|
/<html[^>]*\blang=["']/.test(src) ||
|
|
76
76
|
/\blang=\{locale\}/.test(src) ||
|
|
77
77
|
/document\.documentElement\.lang/.test(src) ||
|
|
78
|
+
/setAttribute\(\s*['"]lang['"]/.test(src) ||
|
|
79
|
+
/\bHtmlLang\b/.test(src) ||
|
|
78
80
|
/\.lang\s*=/.test(src));
|
|
79
81
|
}
|
|
80
82
|
function hasDirHint(src) {
|
|
@@ -82,7 +84,9 @@ function hasDirHint(src) {
|
|
|
82
84
|
/<html[^>]*\bdir=["']/.test(src) ||
|
|
83
85
|
/\bdir=\{getDir\(/.test(src) ||
|
|
84
86
|
/\bdir=\{.*getDir/.test(src) ||
|
|
85
|
-
/document\.documentElement\.dir/.test(src)
|
|
87
|
+
/document\.documentElement\.dir/.test(src) ||
|
|
88
|
+
/setAttribute\(\s*['"]dir['"]/.test(src) ||
|
|
89
|
+
/\bHtmlLang\b/.test(src));
|
|
86
90
|
}
|
|
87
91
|
async function findLocaleLayouts(cwd) {
|
|
88
92
|
return findLayoutsUnder(cwd, ['app/[locale]', 'src/app/[locale]']);
|
|
@@ -20,3 +20,10 @@ 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;
|
|
25
|
+
/**
|
|
26
|
+
* Apostrophe-in-words is common in fr/it/…; English `don't` floods CI with noise.
|
|
27
|
+
* Only hint for locales that regularly need ICU `''` in prose.
|
|
28
|
+
*/
|
|
29
|
+
export declare function shouldHintApostrophe(locale: string): boolean;
|
|
@@ -69,6 +69,18 @@ export async function validateCatalog(config, cwd, opts) {
|
|
|
69
69
|
});
|
|
70
70
|
continue;
|
|
71
71
|
}
|
|
72
|
+
if (shouldHintApostrophe(config.defaultLocale) &&
|
|
73
|
+
likelyUnescapedApostrophe(source)) {
|
|
74
|
+
issues.push({
|
|
75
|
+
check: 'icu',
|
|
76
|
+
severity: 'warn',
|
|
77
|
+
message: `Key "${fullKey}" looks like a natural-language apostrophe. In ICU, double it: "l''été" → l'été`,
|
|
78
|
+
key: fullKey,
|
|
79
|
+
locale: config.defaultLocale,
|
|
80
|
+
file,
|
|
81
|
+
...(line != null ? { line } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
72
84
|
const defaultArgs = collectArgs(defaultAst);
|
|
73
85
|
for (const locale of locales) {
|
|
74
86
|
if (locale === config.defaultLocale)
|
|
@@ -131,6 +143,17 @@ export async function validateCatalog(config, cwd, opts) {
|
|
|
131
143
|
});
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
146
|
+
if (shouldHintApostrophe(locale) &&
|
|
147
|
+
likelyUnescapedApostrophe(resolved)) {
|
|
148
|
+
issues.push({
|
|
149
|
+
check: 'icu',
|
|
150
|
+
severity: 'warn',
|
|
151
|
+
message: `Key "${fullKey}" looks like a natural-language apostrophe. In ICU, double it: "l''été" → l'été`,
|
|
152
|
+
key: fullKey,
|
|
153
|
+
locale,
|
|
154
|
+
file: localeFile,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
134
157
|
const mismatchSev = severityFor(config.strict.mismatch);
|
|
135
158
|
if (mismatchSev) {
|
|
136
159
|
const otherArgs = collectArgs(otherAst);
|
|
@@ -224,3 +247,22 @@ export async function validateStale(config, cwd, opts) {
|
|
|
224
247
|
}
|
|
225
248
|
return issues;
|
|
226
249
|
}
|
|
250
|
+
/** Letter + single quote + letter — usually meant as prose apostrophe, not ICU quote. */
|
|
251
|
+
export function likelyUnescapedApostrophe(icu) {
|
|
252
|
+
for (let i = 0; i < icu.length - 2; i++) {
|
|
253
|
+
const a = icu[i];
|
|
254
|
+
const b = icu[i + 1];
|
|
255
|
+
const c = icu[i + 2];
|
|
256
|
+
if (b === "'" && /[A-Za-zÀ-ÿ]/.test(a) && /[A-Za-zÀ-ÿ]/.test(c)) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Apostrophe-in-words is common in fr/it/…; English `don't` floods CI with noise.
|
|
264
|
+
* Only hint for locales that regularly need ICU `''` in prose.
|
|
265
|
+
*/
|
|
266
|
+
export function shouldHintApostrophe(locale) {
|
|
267
|
+
return /^(fr|it|ca|oc|ht|lb|rm|ga|gd|mt)(-|$)/i.test(locale);
|
|
268
|
+
}
|
|
@@ -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',
|
|
@@ -48,6 +48,7 @@ export declare function createNavigation(config: NavigationConfig): {
|
|
|
48
48
|
refresh: () => void;
|
|
49
49
|
};
|
|
50
50
|
usePathname: () => string;
|
|
51
|
+
usePathnameWithoutLocale: () => string;
|
|
51
52
|
getPathname: (args: {
|
|
52
53
|
href: Href;
|
|
53
54
|
locale: Locale;
|
|
@@ -57,3 +58,10 @@ export declare function createNavigation(config: NavigationConfig): {
|
|
|
57
58
|
children: (options: import("./locale-switcher.js").UseLocaleSwitcherResult) => ReactNode;
|
|
58
59
|
}) => import("react").JSX.Element;
|
|
59
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Strip a leading locale segment from `next/navigation`'s pathname.
|
|
63
|
+
* Prefer `createNavigation(…).usePathname` / `usePathnameWithoutLocale` when
|
|
64
|
+
* you already use tongue navigation; this unbound helper is for apps that keep
|
|
65
|
+
* plain `next/link` + proxy for as-needed paths.
|
|
66
|
+
*/
|
|
67
|
+
export declare function usePathnameWithoutLocale(locales: readonly string[]): string;
|
package/dist/navigation/index.js
CHANGED
|
@@ -77,6 +77,10 @@ export function createNavigation(config) {
|
|
|
77
77
|
const locale = useLocale();
|
|
78
78
|
return toInternalPathname(stripped, locale, bound.pathnames);
|
|
79
79
|
}
|
|
80
|
+
/** Same as `usePathname` when pathnames are unset — locale prefix stripped. */
|
|
81
|
+
function usePathnameWithoutLocale() {
|
|
82
|
+
return stripLocalePrefix(useNextPathname(), bound.locales);
|
|
83
|
+
}
|
|
80
84
|
const { useLocaleSwitcher, LocaleSwitcher } = createLocaleSwitcher(config);
|
|
81
85
|
return {
|
|
82
86
|
Link,
|
|
@@ -84,8 +88,18 @@ export function createNavigation(config) {
|
|
|
84
88
|
permanentRedirect,
|
|
85
89
|
useRouter,
|
|
86
90
|
usePathname,
|
|
91
|
+
usePathnameWithoutLocale,
|
|
87
92
|
getPathname: (args) => resolve(args.href, args.locale),
|
|
88
93
|
useLocaleSwitcher,
|
|
89
94
|
LocaleSwitcher,
|
|
90
95
|
};
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Strip a leading locale segment from `next/navigation`'s pathname.
|
|
99
|
+
* Prefer `createNavigation(…).usePathname` / `usePathnameWithoutLocale` when
|
|
100
|
+
* you already use tongue navigation; this unbound helper is for apps that keep
|
|
101
|
+
* plain `next/link` + proxy for as-needed paths.
|
|
102
|
+
*/
|
|
103
|
+
export function usePathnameWithoutLocale(locales) {
|
|
104
|
+
return stripLocalePrefix(useNextPathname(), locales);
|
|
105
|
+
}
|
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) {
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export type CreateProxyOptions = {
|
|
|
48
48
|
resolveLocale?: (req: TongueProxyRequest) => string | undefined | Promise<string | undefined>;
|
|
49
49
|
onLocale?: (locale: string, req: TongueProxyRequest) => void;
|
|
50
50
|
};
|
|
51
|
+
/** Default locale cookie name (`SameSite=Lax`). Use with `requestLocale` in API routes. */
|
|
52
|
+
export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
|
|
51
53
|
/**
|
|
52
54
|
* Next.js 16 `proxy.ts` handler for locale prefix routing.
|
|
53
55
|
* Returns standard `Response` objects (compatible with Next proxy/middleware).
|
package/dist/proxy/index.js
CHANGED
|
@@ -2,8 +2,10 @@ import { toInternalPathname } from '../navigation/pathname.js';
|
|
|
2
2
|
import { createLocaleMatcher, } from './locale-matcher.js';
|
|
3
3
|
export { createLocaleMatcher, buildLocaleMatcherTables, emitLocaleMatcherModule, localeParentChain, matchTagAgainstTables, } from './locale-matcher.js';
|
|
4
4
|
export { verifyLocalePreference, signLocalePreference, timingSafeEqual, DEFAULT_PREF_COOKIE, } from '../runtime/locale-preference.js';
|
|
5
|
+
/** Default locale cookie name (`SameSite=Lax`). Use with `requestLocale` in API routes. */
|
|
6
|
+
export const LOCALE_COOKIE_NAME = 'TONGUE_LOCALE';
|
|
5
7
|
const DEFAULT_COOKIE = {
|
|
6
|
-
name:
|
|
8
|
+
name: LOCALE_COOKIE_NAME,
|
|
7
9
|
maxAge: 60 * 60 * 24 * 365,
|
|
8
10
|
sameSite: 'lax',
|
|
9
11
|
path: '/',
|
package/dist/server/index.d.ts
CHANGED
|
@@ -61,3 +61,5 @@ export type { Format, SanctionedUnit, SimpleUnit, DisplayNameType, } from '../ru
|
|
|
61
61
|
export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
|
|
62
62
|
export { setLocalePreference, } from './locale-preference.js';
|
|
63
63
|
export type { SetLocalePreferenceOptions, CookieStoreLike, } from './locale-preference.js';
|
|
64
|
+
export { requestLocale, LOCALE_COOKIE_NAME, LOCALE_HEADER_NAME, } from './request-locale.js';
|
|
65
|
+
export type { RequestLocaleOptions, RequestLocaleSource, } from './request-locale.js';
|
package/dist/server/index.js
CHANGED
|
@@ -56,7 +56,7 @@ export async function getT(localeOrNs, namespace) {
|
|
|
56
56
|
async function getTWithLocale(locale, ns) {
|
|
57
57
|
assertLocale(locale);
|
|
58
58
|
if (!namespaceLoader) {
|
|
59
|
-
throw new TongueError('TONGUE_E_TYPES_MISSING', 'Message modules not loaded.
|
|
59
|
+
throw new TongueError('TONGUE_E_TYPES_MISSING', 'Message modules not loaded. Import ensureTongue from `#tongue/runtime` and call it once (layout/page), or wrap next.config with withTongue() and run `tongue build && next build` so `.tongue/` is fresh.');
|
|
60
60
|
}
|
|
61
61
|
const messages = await namespaceLoader(ns);
|
|
62
62
|
return createT({
|
|
@@ -141,6 +141,7 @@ export { generateAlternates, createGenerateAlternates, } from './alternates.js';
|
|
|
141
141
|
export { translateIssues, defaultFormatPath, } from '../runtime/translate-issues.js';
|
|
142
142
|
export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
|
|
143
143
|
export { setLocalePreference, } from './locale-preference.js';
|
|
144
|
+
export { requestLocale, LOCALE_COOKIE_NAME, LOCALE_HEADER_NAME, } from './request-locale.js';
|
|
144
145
|
function assertLocale(locale) {
|
|
145
146
|
if (runtimeLocales.length && !runtimeLocales.includes(locale)) {
|
|
146
147
|
throw new TongueError('TONGUE_E_LOCALE', `Locale "${locale}" is not in configured locales [${runtimeLocales.join(', ')}]`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Default cookie name set by `createProxy` (`SameSite=Lax`). */
|
|
2
|
+
export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
|
|
3
|
+
/** Common header for client → API locale (wizard / fetch). */
|
|
4
|
+
export declare const LOCALE_HEADER_NAME = "x-locale";
|
|
5
|
+
export type RequestLocaleSource = {
|
|
6
|
+
headers: {
|
|
7
|
+
get: (name: string) => string | null;
|
|
8
|
+
};
|
|
9
|
+
cookies?: {
|
|
10
|
+
get: (name: string) => {
|
|
11
|
+
value: string;
|
|
12
|
+
} | undefined;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type RequestLocaleOptions = {
|
|
16
|
+
locales: readonly string[];
|
|
17
|
+
defaultLocale: string;
|
|
18
|
+
/** Defaults to `TONGUE_LOCALE`. */
|
|
19
|
+
cookieName?: string;
|
|
20
|
+
/** Defaults to `x-locale`. */
|
|
21
|
+
headerName?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Resolve locale in Route Handlers / Server Actions (no `params.locale`).
|
|
25
|
+
* Order: header → cookie → defaultLocale.
|
|
26
|
+
*/
|
|
27
|
+
export declare function requestLocale(req: RequestLocaleSource, opts: RequestLocaleOptions): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isLocale as isLocaleValue } from '../runtime/locale.js';
|
|
2
|
+
/** Default cookie name set by `createProxy` (`SameSite=Lax`). */
|
|
3
|
+
export const LOCALE_COOKIE_NAME = 'TONGUE_LOCALE';
|
|
4
|
+
/** Common header for client → API locale (wizard / fetch). */
|
|
5
|
+
export const LOCALE_HEADER_NAME = 'x-locale';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve locale in Route Handlers / Server Actions (no `params.locale`).
|
|
8
|
+
* Order: header → cookie → defaultLocale.
|
|
9
|
+
*/
|
|
10
|
+
export function requestLocale(req, opts) {
|
|
11
|
+
const headerName = opts.headerName ?? LOCALE_HEADER_NAME;
|
|
12
|
+
const cookieName = opts.cookieName ?? LOCALE_COOKIE_NAME;
|
|
13
|
+
const fromHeader = req.headers.get(headerName)?.trim();
|
|
14
|
+
if (fromHeader && isLocaleValue(fromHeader, opts.locales)) {
|
|
15
|
+
return fromHeader;
|
|
16
|
+
}
|
|
17
|
+
const fromCookie = req.cookies?.get(cookieName)?.value?.trim();
|
|
18
|
+
if (fromCookie && isLocaleValue(fromCookie, opts.locales)) {
|
|
19
|
+
return fromCookie;
|
|
20
|
+
}
|
|
21
|
+
return opts.defaultLocale;
|
|
22
|
+
}
|