next-tongue 0.9.12 → 0.9.14
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/extract.js +9 -2
- package/dist/cli/index.js +17 -1
- package/dist/cli/init.d.ts +6 -1
- package/dist/cli/init.js +53 -19
- package/dist/cli/merge-proxy.d.ts +27 -0
- package/dist/cli/merge-proxy.js +145 -0
- package/dist/compiler/unused-meta.d.ts +23 -0
- package/dist/compiler/unused-meta.js +86 -0
- package/dist/compiler/validate/usage.js +18 -21
- package/package.json +1 -1
package/dist/cli/extract.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { DEFAULT_SCAN_ROOTS, scanReferencedKeys, } from '../compiler/scan-keys.js';
|
|
7
|
+
import { collectUnusedIgnores, isUnusedIgnored, matchesUnusedIgnoreGlob, } from '../compiler/unused-meta.js';
|
|
7
8
|
import { TongueError } from '../errors.js';
|
|
8
9
|
export async function cmdExtract(opts) {
|
|
9
10
|
const { config, cwd } = await loadConfig({
|
|
@@ -28,8 +29,6 @@ export async function cmdExtract(opts) {
|
|
|
28
29
|
defined.add(ns === '_root' ? key : `${ns}.${key}`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
|
-
const missing = [...used].filter((k) => !defined.has(k)).sort();
|
|
32
|
-
const unused = [...defined].filter((k) => !used.has(k)).sort();
|
|
33
32
|
const placeholder = opts.placeholder ?? null; // null → use key as value
|
|
34
33
|
const abs = resolveMessagePath(cwd, config.messages, config.defaultLocale);
|
|
35
34
|
let raw;
|
|
@@ -46,6 +45,14 @@ export async function cmdExtract(opts) {
|
|
|
46
45
|
catch (err) {
|
|
47
46
|
throw new TongueError('TONGUE_E_PARSE', `Invalid JSON: ${err instanceof Error ? err.message : err}`, { file: abs });
|
|
48
47
|
}
|
|
48
|
+
const metaIgnores = collectUnusedIgnores(json);
|
|
49
|
+
const configIgnores = config.strict.unusedIgnore;
|
|
50
|
+
const missing = [...used].filter((k) => !defined.has(k)).sort();
|
|
51
|
+
const unused = [...defined]
|
|
52
|
+
.filter((k) => !used.has(k))
|
|
53
|
+
.filter((k) => !isUnusedIgnored(k, metaIgnores))
|
|
54
|
+
.filter((k) => !matchesUnusedIgnoreGlob(k, configIgnores))
|
|
55
|
+
.sort();
|
|
49
56
|
let added = 0;
|
|
50
57
|
let pruned = 0;
|
|
51
58
|
for (const fullKey of missing) {
|
package/dist/cli/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function runCli(argv) {
|
|
|
32
32
|
prefix: { type: 'string' },
|
|
33
33
|
export: { type: 'boolean', default: false },
|
|
34
34
|
force: { type: 'boolean', default: false },
|
|
35
|
+
proxy: { type: 'string' },
|
|
35
36
|
format: { type: 'string' },
|
|
36
37
|
out: { type: 'string' },
|
|
37
38
|
'missing-only': { type: 'boolean', default: false },
|
|
@@ -116,11 +117,26 @@ export async function runCli(argv) {
|
|
|
116
117
|
process.exitCode = 2;
|
|
117
118
|
break;
|
|
118
119
|
}
|
|
120
|
+
const proxyRaw = typeof values.proxy === 'string' ? values.proxy : undefined;
|
|
121
|
+
const proxyMode = proxyRaw === 'merge' ||
|
|
122
|
+
proxyRaw === 'skip' ||
|
|
123
|
+
proxyRaw === 'overwrite'
|
|
124
|
+
? proxyRaw
|
|
125
|
+
: undefined;
|
|
126
|
+
if (proxyRaw != null &&
|
|
127
|
+
proxyRaw !== 'merge' &&
|
|
128
|
+
proxyRaw !== 'skip' &&
|
|
129
|
+
proxyRaw !== 'overwrite') {
|
|
130
|
+
console.error(`Invalid --proxy ${JSON.stringify(proxyRaw)} (expected merge|skip|overwrite)`);
|
|
131
|
+
process.exitCode = 2;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
119
134
|
await cmdInit({
|
|
120
135
|
...(typeof values.cwd === 'string' ? { cwd: values.cwd } : {}),
|
|
121
136
|
...(typeof values.locales === 'string' ? { locales: values.locales } : {}),
|
|
122
137
|
...(typeof values.default === 'string' ? { default: values.default } : {}),
|
|
123
138
|
...(prefix ? { prefix } : {}),
|
|
139
|
+
...(proxyMode ? { proxyMode } : {}),
|
|
124
140
|
export: values.export === true,
|
|
125
141
|
yes: values.yes === true,
|
|
126
142
|
force: values.force === true,
|
|
@@ -270,7 +286,7 @@ Usage:
|
|
|
270
286
|
tongue build [--cwd <dir>] [--config <path>] [--json] [--quiet]
|
|
271
287
|
tongue check [--cwd <dir>] [--config <path>] [--locale de] [--reporter pretty|json|github] [--max-warnings N]
|
|
272
288
|
tongue extract [--include app,src] [--placeholder ''] [--prune] [--yes] [--dry-run]
|
|
273
|
-
tongue init [--locales en,de] [--default en] [--prefix as-needed] [--export] [--yes] [--force] [--dry-run]
|
|
289
|
+
tongue init [--locales en,de] [--default en] [--prefix as-needed] [--proxy merge|skip|overwrite] [--export] [--yes] [--force] [--dry-run]
|
|
274
290
|
tongue export --format xliff|csv [--locale de] [--out ./translations] [--missing-only]
|
|
275
291
|
tongue import <file.xlf|file.csv> [--dry-run]
|
|
276
292
|
tongue report [--json] [--max-bytes 4kb]
|
package/dist/cli/init.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
export type ProxyMode = 'merge' | 'skip' | 'overwrite';
|
|
1
2
|
export type InitCliOptions = {
|
|
2
3
|
cwd?: string;
|
|
3
4
|
locales?: string;
|
|
4
5
|
default?: string;
|
|
5
6
|
prefix?: 'always' | 'as-needed' | 'never';
|
|
7
|
+
/** How to treat an existing proxy.ts / middleware.ts. Default: skip. */
|
|
8
|
+
proxyMode?: ProxyMode;
|
|
6
9
|
export?: boolean;
|
|
7
10
|
yes?: boolean;
|
|
8
11
|
force?: boolean;
|
|
@@ -12,8 +15,10 @@ export type InitCliOptions = {
|
|
|
12
15
|
export declare function cmdInit(opts: InitCliOptions): Promise<void>;
|
|
13
16
|
/** Prefer src/app when present; otherwise app/. */
|
|
14
17
|
export declare function detectAppRoot(cwd: string): Promise<'app' | 'src/app'>;
|
|
18
|
+
/** Where to write a new proxy.ts when none exists. */
|
|
19
|
+
export declare function detectProxyWritePath(appRoot: 'app' | 'src/app'): string;
|
|
15
20
|
/**
|
|
16
21
|
* Where to write proxy.ts. Returns null when the project already has proxy/middleware
|
|
17
|
-
* (caller should compose
|
|
22
|
+
* (caller should compose or pass `--proxy merge|overwrite`).
|
|
18
23
|
*/
|
|
19
24
|
export declare function detectProxyPath(cwd: string, appRoot: 'app' | 'src/app'): Promise<string | null>;
|
package/dist/cli/init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative } from 'node:path';
|
|
3
3
|
import * as readline from 'node:readline';
|
|
4
|
+
import { findExistingProxyPath, mergeProxySource, PROXY_CANDIDATES, } from './merge-proxy.js';
|
|
4
5
|
import { resolveInsideProject } from './safe-path.js';
|
|
5
6
|
export async function cmdInit(opts) {
|
|
6
7
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -36,7 +37,7 @@ export async function cmdInit(opts) {
|
|
|
36
37
|
console.log(`tongue init: using ${appRoot}/ for App Router files`);
|
|
37
38
|
}
|
|
38
39
|
const hasRootLayout = await hasFile(cwd, `${appRoot}/layout.tsx`, `${appRoot}/layout.ts`, `${appRoot}/layout.jsx`, `${appRoot}/layout.js`);
|
|
39
|
-
const
|
|
40
|
+
const existingProxy = await findExistingProxyPath(cwd, (rel) => exists(join(cwd, rel)));
|
|
40
41
|
const plans = [];
|
|
41
42
|
plans.push({
|
|
42
43
|
path: 'tongue.config.ts',
|
|
@@ -64,16 +65,55 @@ export default defineConfig({
|
|
|
64
65
|
content: `${JSON.stringify(sample, null, 2)}\n`,
|
|
65
66
|
});
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
let proxyMode = opts.proxyMode ?? 'skip';
|
|
69
|
+
if (existingProxy && opts.proxyMode == null && interactive) {
|
|
70
|
+
const answer = await ask(`Existing ${existingProxy} — merge|skip|overwrite [skip]: `, 'skip');
|
|
71
|
+
if (answer === 'merge' || answer === 'skip' || answer === 'overwrite') {
|
|
72
|
+
proxyMode = answer;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const scaffold = proxyScaffold(locales, defaultLocale, prefix);
|
|
76
|
+
if (!existingProxy) {
|
|
68
77
|
plans.push({
|
|
69
|
-
path:
|
|
78
|
+
path: detectProxyWritePath(appRoot),
|
|
70
79
|
kind: 'create',
|
|
71
|
-
content:
|
|
80
|
+
content: scaffold,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else if (proxyMode === 'overwrite') {
|
|
84
|
+
plans.push({
|
|
85
|
+
path: existingProxy,
|
|
86
|
+
kind: 'merge',
|
|
87
|
+
content: scaffold,
|
|
72
88
|
});
|
|
73
89
|
}
|
|
90
|
+
else if (proxyMode === 'merge') {
|
|
91
|
+
const abs = join(cwd, existingProxy);
|
|
92
|
+
const src = await readFile(abs, 'utf8');
|
|
93
|
+
const merged = mergeProxySource(src, {
|
|
94
|
+
locales,
|
|
95
|
+
defaultLocale,
|
|
96
|
+
prefix,
|
|
97
|
+
});
|
|
98
|
+
if (merged.status === 'merged') {
|
|
99
|
+
plans.push({
|
|
100
|
+
path: existingProxy,
|
|
101
|
+
kind: 'merge',
|
|
102
|
+
content: merged.code,
|
|
103
|
+
});
|
|
104
|
+
if (!opts.quiet && merged.note) {
|
|
105
|
+
console.log(`tongue init: ${merged.note}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (!opts.quiet) {
|
|
109
|
+
console.log(`tongue init: could not merge ${existingProxy} (${merged.reason}). Compose like:\n\n` +
|
|
110
|
+
scaffold +
|
|
111
|
+
'\nSee docs/routing-and-proxy.md#composing-with-your-own-proxy-logic.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
74
114
|
else if (!opts.quiet) {
|
|
75
|
-
console.log(
|
|
76
|
-
|
|
115
|
+
console.log(`tongue init: existing ${existingProxy} — skipped (pass --proxy merge to wire createProxy, or --proxy overwrite). Compose like:\n\n` +
|
|
116
|
+
scaffold +
|
|
77
117
|
'\nSee docs/routing-and-proxy.md#composing-with-your-own-proxy-logic / hybrid routing.');
|
|
78
118
|
}
|
|
79
119
|
const layoutPath = `${appRoot}/[locale]/layout.tsx`;
|
|
@@ -311,26 +351,20 @@ export async function detectAppRoot(cwd) {
|
|
|
311
351
|
return 'src/app';
|
|
312
352
|
return 'app';
|
|
313
353
|
}
|
|
354
|
+
/** Where to write a new proxy.ts when none exists. */
|
|
355
|
+
export function detectProxyWritePath(appRoot) {
|
|
356
|
+
return appRoot.startsWith('src/') ? 'src/proxy.ts' : 'proxy.ts';
|
|
357
|
+
}
|
|
314
358
|
/**
|
|
315
359
|
* Where to write proxy.ts. Returns null when the project already has proxy/middleware
|
|
316
|
-
* (caller should compose
|
|
360
|
+
* (caller should compose or pass `--proxy merge|overwrite`).
|
|
317
361
|
*/
|
|
318
362
|
export async function detectProxyPath(cwd, appRoot) {
|
|
319
|
-
const
|
|
320
|
-
'proxy.ts',
|
|
321
|
-
'proxy.js',
|
|
322
|
-
'src/proxy.ts',
|
|
323
|
-
'src/proxy.js',
|
|
324
|
-
'middleware.ts',
|
|
325
|
-
'middleware.js',
|
|
326
|
-
'src/middleware.ts',
|
|
327
|
-
'src/middleware.js',
|
|
328
|
-
];
|
|
329
|
-
for (const p of existing) {
|
|
363
|
+
for (const p of PROXY_CANDIDATES) {
|
|
330
364
|
if (await exists(join(cwd, p)))
|
|
331
365
|
return null;
|
|
332
366
|
}
|
|
333
|
-
return appRoot
|
|
367
|
+
return detectProxyWritePath(appRoot);
|
|
334
368
|
}
|
|
335
369
|
function wrapNextConfig(src, staticExport) {
|
|
336
370
|
let out = src;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MergeProxyOptions = {
|
|
2
|
+
locales: readonly string[];
|
|
3
|
+
defaultLocale: string;
|
|
4
|
+
prefix: string;
|
|
5
|
+
};
|
|
6
|
+
export type MergeProxyResult = {
|
|
7
|
+
status: 'unchanged';
|
|
8
|
+
reason: string;
|
|
9
|
+
code: string;
|
|
10
|
+
} | {
|
|
11
|
+
status: 'merged';
|
|
12
|
+
code: string;
|
|
13
|
+
note?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Merge `createProxy({ locales, defaultLocale, prefix })` into an existing
|
|
17
|
+
* proxy.ts / middleware.ts without wiping user logic.
|
|
18
|
+
*
|
|
19
|
+
* - Adds the import + `const tongue = createProxy(...)`.
|
|
20
|
+
* - Replaces bare `return NextResponse.next()` with `return tongue(<req>)`
|
|
21
|
+
* using the handler's first parameter name.
|
|
22
|
+
* - If already using `createProxy`, leaves the file alone.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mergeProxySource(source: string, opts: MergeProxyOptions): MergeProxyResult;
|
|
25
|
+
export declare const PROXY_CANDIDATES: readonly ["proxy.ts", "proxy.js", "src/proxy.ts", "src/proxy.js", "middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"];
|
|
26
|
+
/** Relative path of an existing proxy/middleware file, or null. */
|
|
27
|
+
export declare function findExistingProxyPath(cwd: string, exists: (rel: string) => Promise<boolean>): Promise<string | null>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const CREATE_PROXY_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]next-tongue\/proxy['"]/;
|
|
2
|
+
/**
|
|
3
|
+
* Merge `createProxy({ locales, defaultLocale, prefix })` into an existing
|
|
4
|
+
* proxy.ts / middleware.ts without wiping user logic.
|
|
5
|
+
*
|
|
6
|
+
* - Adds the import + `const tongue = createProxy(...)`.
|
|
7
|
+
* - Replaces bare `return NextResponse.next()` with `return tongue(<req>)`
|
|
8
|
+
* using the handler's first parameter name.
|
|
9
|
+
* - If already using `createProxy`, leaves the file alone.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeProxySource(source, opts) {
|
|
12
|
+
if (/\bcreateProxy\s*\(/.test(source)) {
|
|
13
|
+
return {
|
|
14
|
+
status: 'unchanged',
|
|
15
|
+
reason: 'createProxy already present',
|
|
16
|
+
code: source,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
let code = source.replace(/^\uFEFF/, '');
|
|
20
|
+
const reqIdent = detectRequestIdent(code) ?? 'req';
|
|
21
|
+
const tongueIdent = /\btongue\b/.test(code) ? '__tongueProxy' : 'tongue';
|
|
22
|
+
code = ensureCreateProxyImport(code);
|
|
23
|
+
code = insertTongueConst(code, tongueIdent, opts);
|
|
24
|
+
// Only the *last* NextResponse.next() — early returns for /api etc. stay passthrough.
|
|
25
|
+
const replaced = replaceLastNextReturn(code, tongueIdent, reqIdent);
|
|
26
|
+
if (replaced) {
|
|
27
|
+
return {
|
|
28
|
+
status: 'merged',
|
|
29
|
+
code: ensureTrailingNewline(replaced),
|
|
30
|
+
note: `Replaced final NextResponse.next() with ${tongueIdent}(${reqIdent}). Early next() returns (allow-lists) are kept.`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// No next() — wrap exported handler so user logic runs first, then tongue.
|
|
34
|
+
const wrapped = wrapExportedHandler(code, tongueIdent, reqIdent);
|
|
35
|
+
if (wrapped) {
|
|
36
|
+
return {
|
|
37
|
+
status: 'merged',
|
|
38
|
+
code: ensureTrailingNewline(wrapped),
|
|
39
|
+
note: `Wrapped existing handler: user Response wins unless it is a passthrough next; otherwise ${tongueIdent} runs.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
status: 'unchanged',
|
|
44
|
+
reason: 'could not find NextResponse.next() or an exportable proxy/middleware handler to wrap',
|
|
45
|
+
code: source,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function ensureTrailingNewline(s) {
|
|
49
|
+
return s.endsWith('\n') ? s : `${s}\n`;
|
|
50
|
+
}
|
|
51
|
+
function ensureCreateProxyImport(code) {
|
|
52
|
+
if (CREATE_PROXY_IMPORT.test(code)) {
|
|
53
|
+
return code.replace(CREATE_PROXY_IMPORT, (full, specs) => {
|
|
54
|
+
if (/\bcreateProxy\b/.test(specs))
|
|
55
|
+
return full;
|
|
56
|
+
const parts = specs
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((s) => s.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
parts.push('createProxy');
|
|
61
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const importBlock = `import { createProxy } from 'next-tongue/proxy';\n`;
|
|
65
|
+
// After last import, or at top
|
|
66
|
+
const lastImport = [...code.matchAll(/^import\s.+;?\s*$/gm)].pop();
|
|
67
|
+
if (lastImport && lastImport.index != null) {
|
|
68
|
+
const end = lastImport.index + lastImport[0].length;
|
|
69
|
+
return code.slice(0, end) + '\n' + importBlock + code.slice(end);
|
|
70
|
+
}
|
|
71
|
+
return importBlock + code;
|
|
72
|
+
}
|
|
73
|
+
function insertTongueConst(code, tongueIdent, opts) {
|
|
74
|
+
if (new RegExp(`\\b(?:const|let|var)\\s+${tongueIdent}\\s*=`).test(code)) {
|
|
75
|
+
return code;
|
|
76
|
+
}
|
|
77
|
+
const decl = `const ${tongueIdent} = createProxy({\n` +
|
|
78
|
+
` locales: ${JSON.stringify([...opts.locales])},\n` +
|
|
79
|
+
` defaultLocale: ${JSON.stringify(opts.defaultLocale)},\n` +
|
|
80
|
+
` prefix: ${JSON.stringify(opts.prefix)},\n` +
|
|
81
|
+
`});\n`;
|
|
82
|
+
// Prefer after imports / 'use …' directives
|
|
83
|
+
const lastImport = [...code.matchAll(/^import\s.+;?\s*$/gm)].pop();
|
|
84
|
+
if (lastImport && lastImport.index != null) {
|
|
85
|
+
const end = lastImport.index + lastImport[0].length;
|
|
86
|
+
return code.slice(0, end) + '\n\n' + decl + code.slice(end);
|
|
87
|
+
}
|
|
88
|
+
return decl + '\n' + code;
|
|
89
|
+
}
|
|
90
|
+
function replaceLastNextReturn(code, tongueIdent, reqIdent) {
|
|
91
|
+
const re = /\breturn\s+NextResponse\.next\s*\(\s*\)\s*;?/g;
|
|
92
|
+
let last = null;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = re.exec(code)) !== null)
|
|
95
|
+
last = m;
|
|
96
|
+
if (!last)
|
|
97
|
+
return null;
|
|
98
|
+
return (code.slice(0, last.index) +
|
|
99
|
+
`return ${tongueIdent}(${reqIdent});` +
|
|
100
|
+
code.slice(last.index + last[0].length));
|
|
101
|
+
}
|
|
102
|
+
function detectRequestIdent(code) {
|
|
103
|
+
const m = /export\s+(?:async\s+)?function\s+(?:proxy|middleware)\s*\(\s*([A-Za-z_$][\w$]*)/.exec(code) ??
|
|
104
|
+
/export\s+const\s+(?:proxy|middleware)\s*=\s*(?:async\s*)?\(\s*([A-Za-z_$][\w$]*)/.exec(code);
|
|
105
|
+
return m?.[1] ?? null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Rename `export function proxy/middleware` → internal, add wrapper that
|
|
109
|
+
* prefers a non-passthrough user Response then falls back to tongue.
|
|
110
|
+
*/
|
|
111
|
+
function wrapExportedHandler(code, tongueIdent, reqIdent) {
|
|
112
|
+
const fnRe = /export\s+(async\s+)?function\s+(proxy|middleware)\s*\(/;
|
|
113
|
+
const m = fnRe.exec(code);
|
|
114
|
+
if (!m || m.index == null)
|
|
115
|
+
return null;
|
|
116
|
+
const asyncKw = m[1] ?? '';
|
|
117
|
+
const name = m[2];
|
|
118
|
+
const inner = `__tongue_user_${name}`;
|
|
119
|
+
let out = code.slice(0, m.index) + `${asyncKw}function ${inner}(` + code.slice(m.index + m[0].length);
|
|
120
|
+
out +=
|
|
121
|
+
`\n\nexport ${asyncKw}function ${name}(${reqIdent}: Parameters<typeof ${inner}>[0]) {\n` +
|
|
122
|
+
` const __user = ${asyncKw ? 'await ' : ''}${inner}(${reqIdent});\n` +
|
|
123
|
+
` if (__user && __user.headers?.get?.('x-middleware-next') !== '1') return __user;\n` +
|
|
124
|
+
` return ${tongueIdent}(${reqIdent});\n` +
|
|
125
|
+
`}\n`;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
export const PROXY_CANDIDATES = [
|
|
129
|
+
'proxy.ts',
|
|
130
|
+
'proxy.js',
|
|
131
|
+
'src/proxy.ts',
|
|
132
|
+
'src/proxy.js',
|
|
133
|
+
'middleware.ts',
|
|
134
|
+
'middleware.js',
|
|
135
|
+
'src/middleware.ts',
|
|
136
|
+
'src/middleware.js',
|
|
137
|
+
];
|
|
138
|
+
/** Relative path of an existing proxy/middleware file, or null. */
|
|
139
|
+
export async function findExistingProxyPath(cwd, exists) {
|
|
140
|
+
for (const p of PROXY_CANDIDATES) {
|
|
141
|
+
if (await exists(p))
|
|
142
|
+
return p;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog `$meta` flags that suppress `unused` (and extract `--prune`).
|
|
3
|
+
*
|
|
4
|
+
* Per key (sibling `key$meta`):
|
|
5
|
+
* { "planned": true }
|
|
6
|
+
* { "ignoreUnused": true }
|
|
7
|
+
* { "@tongue-ignore": true }
|
|
8
|
+
*
|
|
9
|
+
* Per namespace (child `$meta` on the namespace object):
|
|
10
|
+
* "common": { "$meta": { "planned": true }, "seeAll": "…" }
|
|
11
|
+
*/
|
|
12
|
+
export type Nested = Record<string, unknown>;
|
|
13
|
+
export type UnusedIgnoreIndex = {
|
|
14
|
+
/** Exact full keys (e.g. `common.seeAll`). */
|
|
15
|
+
keys: Set<string>;
|
|
16
|
+
/** Namespace prefixes whose unused keys are ignored (`common` → `common.*`). */
|
|
17
|
+
namespaces: Set<string>;
|
|
18
|
+
};
|
|
19
|
+
/** Walk default-locale JSON and collect planned / @tongue-ignore markers. */
|
|
20
|
+
export declare function collectUnusedIgnores(root: Nested): UnusedIgnoreIndex;
|
|
21
|
+
export declare function isUnusedIgnored(fullKey: string, index: UnusedIgnoreIndex): boolean;
|
|
22
|
+
/** Glob-lite for `strict.unusedIgnore` (`common.*`, `*.seeAll`). */
|
|
23
|
+
export declare function matchesUnusedIgnoreGlob(fullKey: string, patterns: readonly string[]): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog `$meta` flags that suppress `unused` (and extract `--prune`).
|
|
3
|
+
*
|
|
4
|
+
* Per key (sibling `key$meta`):
|
|
5
|
+
* { "planned": true }
|
|
6
|
+
* { "ignoreUnused": true }
|
|
7
|
+
* { "@tongue-ignore": true }
|
|
8
|
+
*
|
|
9
|
+
* Per namespace (child `$meta` on the namespace object):
|
|
10
|
+
* "common": { "$meta": { "planned": true }, "seeAll": "…" }
|
|
11
|
+
*/
|
|
12
|
+
function metaIgnoresUnused(meta) {
|
|
13
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
|
|
14
|
+
return false;
|
|
15
|
+
const m = meta;
|
|
16
|
+
if (m.planned === true)
|
|
17
|
+
return true;
|
|
18
|
+
if (m.ignoreUnused === true)
|
|
19
|
+
return true;
|
|
20
|
+
if (m['@tongue-ignore'] === true)
|
|
21
|
+
return true;
|
|
22
|
+
// Allow string form: "@tongue-ignore" or "planned"
|
|
23
|
+
if (typeof m.unused === 'string') {
|
|
24
|
+
const u = m.unused.toLowerCase();
|
|
25
|
+
if (u === 'ignore' || u === 'planned' || u === '@tongue-ignore')
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/** Walk default-locale JSON and collect planned / @tongue-ignore markers. */
|
|
31
|
+
export function collectUnusedIgnores(root) {
|
|
32
|
+
const keys = new Set();
|
|
33
|
+
const namespaces = new Set();
|
|
34
|
+
walk(root, '', keys, namespaces);
|
|
35
|
+
return { keys, namespaces };
|
|
36
|
+
}
|
|
37
|
+
function walk(node, prefix, keys, namespaces) {
|
|
38
|
+
const nsMeta = node['$meta'];
|
|
39
|
+
if (prefix && metaIgnoresUnused(nsMeta)) {
|
|
40
|
+
namespaces.add(prefix);
|
|
41
|
+
}
|
|
42
|
+
for (const [key, value] of Object.entries(node)) {
|
|
43
|
+
if (key === '$meta' || key.endsWith('$meta') || key.endsWith('$description')) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const full = prefix ? `${prefix}.${key}` : key;
|
|
47
|
+
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
48
|
+
walk(value, full, keys, namespaces);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
continue;
|
|
53
|
+
const meta = node[`${key}$meta`];
|
|
54
|
+
if (metaIgnoresUnused(meta)) {
|
|
55
|
+
keys.add(full);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function isUnusedIgnored(fullKey, index) {
|
|
60
|
+
if (index.keys.has(fullKey))
|
|
61
|
+
return true;
|
|
62
|
+
for (const ns of index.namespaces) {
|
|
63
|
+
if (fullKey === ns || fullKey.startsWith(`${ns}.`))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/** Glob-lite for `strict.unusedIgnore` (`common.*`, `*.seeAll`). */
|
|
69
|
+
export function matchesUnusedIgnoreGlob(fullKey, patterns) {
|
|
70
|
+
for (const raw of patterns) {
|
|
71
|
+
const p = raw.trim();
|
|
72
|
+
if (!p)
|
|
73
|
+
continue;
|
|
74
|
+
if (p === fullKey)
|
|
75
|
+
return true;
|
|
76
|
+
if (!p.includes('*'))
|
|
77
|
+
continue;
|
|
78
|
+
const re = new RegExp(`^${p
|
|
79
|
+
.split('*')
|
|
80
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
81
|
+
.join('.*')}$`);
|
|
82
|
+
if (re.test(fullKey))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { loadLocaleMessages, resolveMessagePath, } from '../load-messages.js';
|
|
2
3
|
import { DEFAULT_SCAN_ROOTS, scanKeys } from '../scan-keys.js';
|
|
4
|
+
import { collectUnusedIgnores, isUnusedIgnored, matchesUnusedIgnoreGlob, } from '../unused-meta.js';
|
|
3
5
|
function severityFor(level, alwaysError = false) {
|
|
4
6
|
if (alwaysError)
|
|
5
7
|
return 'error';
|
|
@@ -11,25 +13,6 @@ function namespaceOf(fullKey) {
|
|
|
11
13
|
const i = fullKey.indexOf('.');
|
|
12
14
|
return i === -1 ? '_root' : fullKey.slice(0, i);
|
|
13
15
|
}
|
|
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
16
|
/** unused + undefined + undeclared client dynamic checks against scanned source. */
|
|
34
17
|
export async function validateUsage(config, cwd) {
|
|
35
18
|
const issues = [];
|
|
@@ -71,10 +54,13 @@ export async function validateUsage(config, cwd) {
|
|
|
71
54
|
const unusedSev = severityFor(config.strict.unused);
|
|
72
55
|
if (unusedSev) {
|
|
73
56
|
const ignore = config.strict.unusedIgnore;
|
|
57
|
+
const metaIgnores = await loadUnusedMetaIgnores(config, cwd);
|
|
74
58
|
for (const fullKey of defined) {
|
|
75
59
|
if (used.has(fullKey))
|
|
76
60
|
continue;
|
|
77
|
-
if (
|
|
61
|
+
if (matchesUnusedIgnoreGlob(fullKey, ignore))
|
|
62
|
+
continue;
|
|
63
|
+
if (isUnusedIgnored(fullKey, metaIgnores))
|
|
78
64
|
continue;
|
|
79
65
|
// Dynamic t(var) / t.ns / t.dynamic — do not false-flag catalog keys as unused
|
|
80
66
|
if (dynamicNamespaces.has(namespaceOf(fullKey)))
|
|
@@ -90,3 +76,14 @@ export async function validateUsage(config, cwd) {
|
|
|
90
76
|
}
|
|
91
77
|
return issues;
|
|
92
78
|
}
|
|
79
|
+
async function loadUnusedMetaIgnores(config, cwd) {
|
|
80
|
+
const abs = resolveMessagePath(cwd, config.messages, config.defaultLocale);
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFile(abs, 'utf8');
|
|
83
|
+
const json = JSON.parse(raw);
|
|
84
|
+
return collectUnusedIgnores(json);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return collectUnusedIgnores({});
|
|
88
|
+
}
|
|
89
|
+
}
|