next-tongue 0.9.13 → 0.9.15
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/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 +185 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/compiler/codegen/emit-types.js +3 -2
- package/dist/navigation/pathname.d.ts +11 -0
- package/dist/navigation/pathname.js +46 -13
- package/dist/proxy/index.d.ts +15 -0
- package/dist/proxy/index.js +108 -61
- package/package.json +1 -1
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,185 @@
|
|
|
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}). ` +
|
|
31
|
+
`Early next() returns (allow-lists) are kept. ` +
|
|
32
|
+
`If you stamp CSP/nonce on next(), use createProxy({ wrapResponse }) or mergeProxyResponse — see routing docs.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// No next() — wrap exported handler so user logic runs first, then tongue.
|
|
36
|
+
const wrapped = wrapExportedHandler(code, tongueIdent, reqIdent);
|
|
37
|
+
if (wrapped) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'merged',
|
|
40
|
+
code: ensureTrailingNewline(wrapped),
|
|
41
|
+
note: `Wrapped existing handler: non-passthrough user Response wins (auth/maintenance); ` +
|
|
42
|
+
`passthrough next() → ${tongueIdent} with mergeProxyResponse (keeps CSP/nonce). ` +
|
|
43
|
+
`Rewrites to paths under [locale] must include the locale segment.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
status: 'unchanged',
|
|
48
|
+
reason: 'could not find NextResponse.next() or an exportable proxy/middleware handler to wrap',
|
|
49
|
+
code: source,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function ensureTrailingNewline(s) {
|
|
53
|
+
return s.endsWith('\n') ? s : `${s}\n`;
|
|
54
|
+
}
|
|
55
|
+
function ensureCreateProxyImport(code) {
|
|
56
|
+
if (CREATE_PROXY_IMPORT.test(code)) {
|
|
57
|
+
return code.replace(CREATE_PROXY_IMPORT, (full, specs) => {
|
|
58
|
+
if (/\bcreateProxy\b/.test(specs))
|
|
59
|
+
return full;
|
|
60
|
+
const parts = specs
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
parts.push('createProxy');
|
|
65
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const importBlock = `import { createProxy } from 'next-tongue/proxy';\n`;
|
|
69
|
+
// After last import, or at top
|
|
70
|
+
const lastImport = [...code.matchAll(/^import\s.+;?\s*$/gm)].pop();
|
|
71
|
+
if (lastImport && lastImport.index != null) {
|
|
72
|
+
const end = lastImport.index + lastImport[0].length;
|
|
73
|
+
return code.slice(0, end) + '\n' + importBlock + code.slice(end);
|
|
74
|
+
}
|
|
75
|
+
return importBlock + code;
|
|
76
|
+
}
|
|
77
|
+
function insertTongueConst(code, tongueIdent, opts) {
|
|
78
|
+
if (new RegExp(`\\b(?:const|let|var)\\s+${tongueIdent}\\s*=`).test(code)) {
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
81
|
+
const decl = `const ${tongueIdent} = createProxy({\n` +
|
|
82
|
+
` locales: ${JSON.stringify([...opts.locales])},\n` +
|
|
83
|
+
` defaultLocale: ${JSON.stringify(opts.defaultLocale)},\n` +
|
|
84
|
+
` prefix: ${JSON.stringify(opts.prefix)},\n` +
|
|
85
|
+
`});\n`;
|
|
86
|
+
// Prefer after imports / 'use …' directives
|
|
87
|
+
const lastImport = [...code.matchAll(/^import\s.+;?\s*$/gm)].pop();
|
|
88
|
+
if (lastImport && lastImport.index != null) {
|
|
89
|
+
const end = lastImport.index + lastImport[0].length;
|
|
90
|
+
return code.slice(0, end) + '\n\n' + decl + code.slice(end);
|
|
91
|
+
}
|
|
92
|
+
return decl + '\n' + code;
|
|
93
|
+
}
|
|
94
|
+
function replaceLastNextReturn(code, tongueIdent, reqIdent) {
|
|
95
|
+
const re = /\breturn\s+NextResponse\.next\s*\(\s*\)\s*;?/g;
|
|
96
|
+
let last = null;
|
|
97
|
+
let m;
|
|
98
|
+
while ((m = re.exec(code)) !== null)
|
|
99
|
+
last = m;
|
|
100
|
+
if (!last)
|
|
101
|
+
return null;
|
|
102
|
+
return (code.slice(0, last.index) +
|
|
103
|
+
`return ${tongueIdent}(${reqIdent});` +
|
|
104
|
+
code.slice(last.index + last[0].length));
|
|
105
|
+
}
|
|
106
|
+
function detectRequestIdent(code) {
|
|
107
|
+
const m = /export\s+(?:async\s+)?function\s+(?:proxy|middleware)\s*\(\s*([A-Za-z_$][\w$]*)/.exec(code) ??
|
|
108
|
+
/export\s+const\s+(?:proxy|middleware)\s*=\s*(?:async\s*)?\(\s*([A-Za-z_$][\w$]*)/.exec(code);
|
|
109
|
+
return m?.[1] ?? null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rename `export function proxy/middleware` → internal, add wrapper that
|
|
113
|
+
* prefers a non-passthrough user Response then falls back to tongue.
|
|
114
|
+
*/
|
|
115
|
+
function wrapExportedHandler(code, tongueIdent, reqIdent) {
|
|
116
|
+
const fnRe = /export\s+(async\s+)?function\s+(proxy|middleware)\s*\(/;
|
|
117
|
+
const m = fnRe.exec(code);
|
|
118
|
+
if (!m || m.index == null)
|
|
119
|
+
return null;
|
|
120
|
+
const asyncKw = m[1] ?? '';
|
|
121
|
+
const name = m[2];
|
|
122
|
+
const inner = `__tongue_user_${name}`;
|
|
123
|
+
let out = code.slice(0, m.index) + `${asyncKw}function ${inner}(` + code.slice(m.index + m[0].length);
|
|
124
|
+
// Prefer non-passthrough user Response (auth redirect, maintenance rewrite).
|
|
125
|
+
// Passthrough next() → tongue, then mergeProxyResponse so CSP/nonce on the
|
|
126
|
+
// user next() are not dropped.
|
|
127
|
+
out = ensureMergeProxyResponseImport(out);
|
|
128
|
+
// Outer wrapper is always async — createProxy returns a Promise.
|
|
129
|
+
out +=
|
|
130
|
+
`\n\nexport async function ${name}(${reqIdent}: Parameters<typeof ${inner}>[0]) {\n` +
|
|
131
|
+
` const __user = ${asyncKw ? 'await ' : ''}${inner}(${reqIdent});\n` +
|
|
132
|
+
` if (__user && __user.headers?.get?.('x-middleware-next') !== '1') return __user;\n` +
|
|
133
|
+
` const __tongue = await ${tongueIdent}(${reqIdent});\n` +
|
|
134
|
+
` return __user ? mergeProxyResponse(__user, __tongue) : __tongue;\n` +
|
|
135
|
+
`}\n`;
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function ensureMergeProxyResponseImport(code) {
|
|
139
|
+
if (/\bmergeProxyResponse\b/.test(code) && CREATE_PROXY_IMPORT.test(code)) {
|
|
140
|
+
return code.replace(CREATE_PROXY_IMPORT, (full, specs) => {
|
|
141
|
+
if (/\bmergeProxyResponse\b/.test(specs))
|
|
142
|
+
return full;
|
|
143
|
+
const parts = specs
|
|
144
|
+
.split(',')
|
|
145
|
+
.map((s) => s.trim())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
parts.push('mergeProxyResponse');
|
|
148
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// ensureCreateProxyImport already ran — add mergeProxyResponse to that import
|
|
152
|
+
if (CREATE_PROXY_IMPORT.test(code)) {
|
|
153
|
+
return code.replace(CREATE_PROXY_IMPORT, (full, specs) => {
|
|
154
|
+
if (/\bmergeProxyResponse\b/.test(specs))
|
|
155
|
+
return full;
|
|
156
|
+
const parts = specs
|
|
157
|
+
.split(',')
|
|
158
|
+
.map((s) => s.trim())
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
if (!parts.includes('createProxy'))
|
|
161
|
+
parts.unshift('createProxy');
|
|
162
|
+
parts.push('mergeProxyResponse');
|
|
163
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return code;
|
|
167
|
+
}
|
|
168
|
+
export const PROXY_CANDIDATES = [
|
|
169
|
+
'proxy.ts',
|
|
170
|
+
'proxy.js',
|
|
171
|
+
'src/proxy.ts',
|
|
172
|
+
'src/proxy.js',
|
|
173
|
+
'middleware.ts',
|
|
174
|
+
'middleware.js',
|
|
175
|
+
'src/middleware.ts',
|
|
176
|
+
'src/middleware.js',
|
|
177
|
+
];
|
|
178
|
+
/** Relative path of an existing proxy/middleware file, or null. */
|
|
179
|
+
export async function findExistingProxyPath(cwd, exists) {
|
|
180
|
+
for (const p of PROXY_CANDIDATES) {
|
|
181
|
+
if (await exists(p))
|
|
182
|
+
return p;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -39,5 +39,7 @@ export declare const defaultLocale: {
|
|
|
39
39
|
export type { TFunction, Format };
|
|
40
40
|
export type { SanctionedUnit, SimpleUnit, DisplayNameType, } from '../runtime/format.js';
|
|
41
41
|
export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
|
|
42
|
+
/** Isomorphic — safe in client HtmlLang helpers. */
|
|
43
|
+
export { getDir } from '../runtime/locale.js';
|
|
42
44
|
export { translateIssues, defaultFormatPath, } from '../runtime/translate-issues.js';
|
|
43
45
|
export type { TranslateIssue, TranslateIssuesOptions, IssuePathSegment, } from '../runtime/translate-issues.js';
|
package/dist/client/index.js
CHANGED
|
@@ -116,4 +116,6 @@ export const defaultLocale = {
|
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
118
|
export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
|
|
119
|
+
/** Isomorphic — safe in client HtmlLang helpers. */
|
|
120
|
+
export { getDir } from '../runtime/locale.js';
|
|
119
121
|
export { translateIssues, defaultFormatPath, } from '../runtime/translate-issues.js';
|
|
@@ -14,6 +14,7 @@ export function emitTypesFile(opts) {
|
|
|
14
14
|
: 'string';
|
|
15
15
|
return `/* eslint-disable */
|
|
16
16
|
// Generated by next-tongue. Do not edit.
|
|
17
|
+
import type { ReactNode } from 'react';
|
|
17
18
|
declare module 'next-tongue' {
|
|
18
19
|
interface TongueRegister {
|
|
19
20
|
locales: ${localeUnion};
|
|
@@ -46,8 +47,8 @@ function argsToType(args) {
|
|
|
46
47
|
}
|
|
47
48
|
case 'rich':
|
|
48
49
|
return kind.selfClosing
|
|
49
|
-
? `${JSON.stringify(name)}: () =>
|
|
50
|
-
: `${JSON.stringify(name)}: (chunks:
|
|
50
|
+
? `${JSON.stringify(name)}: () => ReactNode`
|
|
51
|
+
: `${JSON.stringify(name)}: (chunks: ReactNode) => ReactNode`;
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
54
|
return `{ ${fields.join('; ')} }`;
|
|
@@ -2,6 +2,8 @@ export type PathnameInput = string | {
|
|
|
2
2
|
pathname: string;
|
|
3
3
|
params?: Record<string, string | number>;
|
|
4
4
|
query?: Record<string, string | number | undefined>;
|
|
5
|
+
/** Fragment without requiring `#` (e.g. `'preferences'` or `'#preferences'`). */
|
|
6
|
+
hash?: string;
|
|
5
7
|
};
|
|
6
8
|
export type GetPathnameArgs = {
|
|
7
9
|
href: PathnameInput;
|
|
@@ -15,6 +17,9 @@ export type GetPathnameArgs = {
|
|
|
15
17
|
};
|
|
16
18
|
/**
|
|
17
19
|
* Build a localized public pathname for links, redirects, and sitemaps.
|
|
20
|
+
* Query strings and hashes are preserved (stripped before pathnames/prefix,
|
|
21
|
+
* reattached after). Prefer the object form for typed `pathnames` unions:
|
|
22
|
+
* `{ pathname: '/dashboard', query: { demo: 1 }, hash: 'prefs' }`.
|
|
18
23
|
*/
|
|
19
24
|
export declare function getPathname(args: GetPathnameArgs): string;
|
|
20
25
|
/** Strip a leading locale segment from a pathname. */
|
|
@@ -24,3 +29,9 @@ export declare function stripLocalePrefix(pathname: string, locales: readonly st
|
|
|
24
29
|
* used in `routing.pathnames` (e.g. `/ueber-uns` → `/about` for `de`).
|
|
25
30
|
*/
|
|
26
31
|
export declare function toInternalPathname(pathname: string, locale: string, pathnames?: Record<string, Partial<Record<string, string>>>): string;
|
|
32
|
+
/** Split `/path?q=1#hash` into parts (pathnames lookup needs the path alone). */
|
|
33
|
+
export declare function splitPathSearchHash(full: string): {
|
|
34
|
+
pathname: string;
|
|
35
|
+
search: string;
|
|
36
|
+
hash: string;
|
|
37
|
+
};
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build a localized public pathname for links, redirects, and sitemaps.
|
|
3
|
+
* Query strings and hashes are preserved (stripped before pathnames/prefix,
|
|
4
|
+
* reattached after). Prefer the object form for typed `pathnames` unions:
|
|
5
|
+
* `{ pathname: '/dashboard', query: { demo: 1 }, hash: 'prefs' }`.
|
|
3
6
|
*/
|
|
4
7
|
export function getPathname(args) {
|
|
5
8
|
const { href, locale, prefix = 'as-needed', defaultLocale = 'en', pathnames, } = args;
|
|
6
|
-
|
|
9
|
+
const { pathname: rawPath, search, hash } = resolveHref(href);
|
|
10
|
+
let path = rawPath;
|
|
7
11
|
if (pathnames) {
|
|
8
12
|
const localized = pathnames[path]?.[locale];
|
|
9
13
|
if (localized)
|
|
10
14
|
path = localized;
|
|
11
15
|
}
|
|
12
|
-
if (prefix === 'never')
|
|
13
|
-
return path;
|
|
16
|
+
if (prefix === 'never') {
|
|
17
|
+
return path + search + hash;
|
|
18
|
+
}
|
|
14
19
|
if (prefix === 'as-needed' && locale === defaultLocale) {
|
|
15
|
-
return path;
|
|
20
|
+
return path + search + hash;
|
|
16
21
|
}
|
|
17
22
|
// always, or as-needed for non-default
|
|
18
|
-
if (path === '/')
|
|
19
|
-
return `/${locale}
|
|
20
|
-
|
|
23
|
+
if (path === '/') {
|
|
24
|
+
return `/${locale}` + search + hash;
|
|
25
|
+
}
|
|
26
|
+
return (`/${locale}${path.startsWith('/') ? path : `/${path}`}` + search + hash);
|
|
21
27
|
}
|
|
22
28
|
/** Strip a leading locale segment from a pathname. */
|
|
23
29
|
export function stripLocalePrefix(pathname, locales) {
|
|
@@ -43,14 +49,21 @@ export function toInternalPathname(pathname, locale, pathnames) {
|
|
|
43
49
|
return pathname;
|
|
44
50
|
}
|
|
45
51
|
function resolveHref(href) {
|
|
46
|
-
if (typeof href === 'string')
|
|
47
|
-
return href.startsWith('/') ? href : `/${href}
|
|
48
|
-
|
|
52
|
+
if (typeof href === 'string') {
|
|
53
|
+
return splitPathSearchHash(href.startsWith('/') ? href : `/${href}`);
|
|
54
|
+
}
|
|
55
|
+
let path = href.pathname.startsWith('/')
|
|
56
|
+
? href.pathname
|
|
57
|
+
: `/${href.pathname}`;
|
|
58
|
+
// Object pathname should be path-only; tolerate accidental ?/#.
|
|
59
|
+
const split = splitPathSearchHash(path);
|
|
60
|
+
path = split.pathname;
|
|
49
61
|
if (href.params) {
|
|
50
62
|
for (const [key, value] of Object.entries(href.params)) {
|
|
51
63
|
path = path.replace(`[${key}]`, encodeURIComponent(String(value)));
|
|
52
64
|
}
|
|
53
65
|
}
|
|
66
|
+
let search = split.search;
|
|
54
67
|
if (href.query) {
|
|
55
68
|
const qs = new URLSearchParams();
|
|
56
69
|
for (const [key, value] of Object.entries(href.query)) {
|
|
@@ -58,8 +71,28 @@ function resolveHref(href) {
|
|
|
58
71
|
qs.set(key, String(value));
|
|
59
72
|
}
|
|
60
73
|
const s = qs.toString();
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
search = s ? `?${s}` : '';
|
|
75
|
+
}
|
|
76
|
+
let hash = split.hash;
|
|
77
|
+
if (href.hash != null && href.hash !== '') {
|
|
78
|
+
hash = href.hash.startsWith('#') ? href.hash : `#${href.hash}`;
|
|
79
|
+
}
|
|
80
|
+
return { pathname: path, search, hash };
|
|
81
|
+
}
|
|
82
|
+
/** Split `/path?q=1#hash` into parts (pathnames lookup needs the path alone). */
|
|
83
|
+
export function splitPathSearchHash(full) {
|
|
84
|
+
let path = full;
|
|
85
|
+
let hash = '';
|
|
86
|
+
let search = '';
|
|
87
|
+
const hashIdx = path.indexOf('#');
|
|
88
|
+
if (hashIdx >= 0) {
|
|
89
|
+
hash = path.slice(hashIdx);
|
|
90
|
+
path = path.slice(0, hashIdx);
|
|
91
|
+
}
|
|
92
|
+
const qIdx = path.indexOf('?');
|
|
93
|
+
if (qIdx >= 0) {
|
|
94
|
+
search = path.slice(qIdx);
|
|
95
|
+
path = path.slice(0, qIdx);
|
|
63
96
|
}
|
|
64
|
-
return path;
|
|
97
|
+
return { pathname: path || '/', search, hash };
|
|
65
98
|
}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -47,6 +47,12 @@ export type CreateProxyOptions = {
|
|
|
47
47
|
/** Override detection (domain → cookie → Accept-Language → default). */
|
|
48
48
|
resolveLocale?: (req: TongueProxyRequest) => string | undefined | Promise<string | undefined>;
|
|
49
49
|
onLocale?: (locale: string, req: TongueProxyRequest) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Post-process every tongue response (redirect / rewrite / next).
|
|
52
|
+
* Use to re-stamp CSP / nonce headers after locale routing — tongue does
|
|
53
|
+
* not preserve headers from a prior `NextResponse.next()`.
|
|
54
|
+
*/
|
|
55
|
+
wrapResponse?: (res: Response, req: TongueProxyRequest) => Response | Promise<Response>;
|
|
50
56
|
};
|
|
51
57
|
/** Default locale cookie name (`SameSite=Lax`). Use with `requestLocale` in API routes. */
|
|
52
58
|
export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
|
|
@@ -57,6 +63,15 @@ export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
|
|
|
57
63
|
* @see docs/routing-and-proxy.md
|
|
58
64
|
*/
|
|
59
65
|
export declare function createProxy(options: CreateProxyOptions): (req: TongueProxyRequest) => Response | Promise<Response>;
|
|
66
|
+
/**
|
|
67
|
+
* Copy response headers from a prior `NextResponse.next()` (CSP, nonce, …)
|
|
68
|
+
* onto a tongue rewrite/redirect/next response. Skips middleware control
|
|
69
|
+
* headers (`x-middleware-*`, `Location` when tongue already set one).
|
|
70
|
+
*
|
|
71
|
+
* Use when you stamp headers on `next()` then call tongue — tongue alone
|
|
72
|
+
* cannot see those headers.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mergeProxyResponse(prior: Response, tongue: Response): Response;
|
|
60
75
|
/** Map internal path → localized public path for a locale. */
|
|
61
76
|
export declare function publicPathForLocale(internal: string, locale: string, pathnames?: Record<string, Partial<Record<string, string>>>): string;
|
|
62
77
|
/** Resolve `Host` / `x-forwarded-host` against `routing.domains`. */
|
package/dist/proxy/index.js
CHANGED
|
@@ -37,78 +37,125 @@ export function createProxy(options) {
|
|
|
37
37
|
if (!localeSet.has(defaultLocale)) {
|
|
38
38
|
throw new Error(`[next-tongue] createProxy: defaultLocale "${defaultLocale}" is not in locales`);
|
|
39
39
|
}
|
|
40
|
+
const wrap = options.wrapResponse;
|
|
40
41
|
return async (req) => {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
42
|
+
const res = await handleProxyRequest(req, {
|
|
43
|
+
locales,
|
|
44
|
+
defaultLocale,
|
|
45
|
+
prefix,
|
|
46
|
+
localeDetection,
|
|
47
|
+
pathnames,
|
|
48
|
+
domains,
|
|
49
|
+
cookieCfg,
|
|
50
|
+
excludeDetection,
|
|
51
|
+
localeSet,
|
|
52
|
+
detectionMatcher,
|
|
53
|
+
options,
|
|
54
|
+
});
|
|
55
|
+
return wrap ? await wrap(res, req) : res;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function handleProxyRequest(req, ctx) {
|
|
59
|
+
const { locales, defaultLocale, prefix, localeDetection, pathnames, domains, cookieCfg, excludeDetection, localeSet, detectionMatcher, options, } = ctx;
|
|
60
|
+
const { pathname } = req.nextUrl;
|
|
61
|
+
const https = isHttpsRequest(req);
|
|
62
|
+
if (isUnsafePath(pathname)) {
|
|
63
|
+
return passthrough(cookieCfg, null, https);
|
|
64
|
+
}
|
|
65
|
+
const domain = matchDomain(req, domains);
|
|
66
|
+
const activeLocales = domain?.locales ? [...domain.locales] : locales;
|
|
67
|
+
const activeDefault = domain?.defaultLocale ?? defaultLocale;
|
|
68
|
+
const activeSet = new Set(activeLocales);
|
|
69
|
+
const activeMatcher = activeLocales.length === locales.length
|
|
70
|
+
? detectionMatcher
|
|
71
|
+
: createLocaleMatcher(activeLocales.filter((l) => !excludeDetection.has(l)));
|
|
72
|
+
const matched = matchLocalePrefix(pathname, localeSet);
|
|
73
|
+
if (matched && (!domain || activeSet.has(matched.locale))) {
|
|
74
|
+
const { locale, rest } = matched;
|
|
75
|
+
const publicRest = rest === '' ? '/' : rest;
|
|
76
|
+
const internal = toInternalPathname(publicRest, locale, pathnames);
|
|
77
|
+
if (prefix === 'as-needed' && locale === activeDefault) {
|
|
78
|
+
// Canonicalize default-locale URLs without the prefix.
|
|
79
|
+
const dest = publicPathForLocale(internal, locale, pathnames);
|
|
80
|
+
const redirectUrl = req.nextUrl.clone();
|
|
81
|
+
redirectUrl.pathname = dest;
|
|
66
82
|
options.onLocale?.(locale, req);
|
|
67
|
-
|
|
68
|
-
const url = req.nextUrl.clone();
|
|
69
|
-
url.pathname = joinLocalePath(locale, internal);
|
|
70
|
-
return rewriteResponse(url.toString(), cookieCfg, locale, https);
|
|
71
|
-
}
|
|
72
|
-
return passthrough(cookieCfg, locale, https);
|
|
83
|
+
return redirectResponse(redirectUrl.toString(), 308, cookieCfg, locale, https);
|
|
73
84
|
}
|
|
74
|
-
const resolved = options.resolveLocale
|
|
75
|
-
? await options.resolveLocale(req)
|
|
76
|
-
: undefined;
|
|
77
|
-
let locale = sanitizeLocale(resolved, activeLocales) ??
|
|
78
|
-
(localeDetection
|
|
79
|
-
? detectLocale(req, activeLocales, activeDefault, cookieCfg, excludeDetection, activeMatcher, domain)
|
|
80
|
-
: activeDefault);
|
|
81
|
-
locale = sanitizeLocale(locale, activeLocales) ?? activeDefault;
|
|
82
85
|
options.onLocale?.(locale, req);
|
|
83
|
-
|
|
84
|
-
// Prefer localized public path when the user hit the internal key.
|
|
85
|
-
const publicPath = publicPathForLocale(internal, locale, pathnames);
|
|
86
|
-
if (prefix === 'never') {
|
|
86
|
+
if (internal !== publicRest) {
|
|
87
87
|
const url = req.nextUrl.clone();
|
|
88
88
|
url.pathname = joinLocalePath(locale, internal);
|
|
89
89
|
return rewriteResponse(url.toString(), cookieCfg, locale, https);
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
return passthrough(cookieCfg, locale, https);
|
|
92
|
+
}
|
|
93
|
+
const resolved = options.resolveLocale
|
|
94
|
+
? await options.resolveLocale(req)
|
|
95
|
+
: undefined;
|
|
96
|
+
let locale = sanitizeLocale(resolved, activeLocales) ??
|
|
97
|
+
(localeDetection
|
|
98
|
+
? detectLocale(req, activeLocales, activeDefault, cookieCfg, excludeDetection, activeMatcher, domain)
|
|
99
|
+
: activeDefault);
|
|
100
|
+
locale = sanitizeLocale(locale, activeLocales) ?? activeDefault;
|
|
101
|
+
options.onLocale?.(locale, req);
|
|
102
|
+
const internal = toInternalPathname(pathname, locale, pathnames);
|
|
103
|
+
// Prefer localized public path when the user hit the internal key.
|
|
104
|
+
const publicPath = publicPathForLocale(internal, locale, pathnames);
|
|
105
|
+
if (prefix === 'never') {
|
|
106
|
+
const url = req.nextUrl.clone();
|
|
107
|
+
url.pathname = joinLocalePath(locale, internal);
|
|
108
|
+
return rewriteResponse(url.toString(), cookieCfg, locale, https);
|
|
109
|
+
}
|
|
110
|
+
if (prefix === 'as-needed' && locale === activeDefault) {
|
|
111
|
+
// Serve default locale without prefix; rewrite to internal app path.
|
|
112
|
+
if (pathname !== publicPath && pathnames) {
|
|
113
|
+
// User visited internal `/about` but public is also `/about` — fine.
|
|
114
|
+
// If they somehow differ for default locale, redirect to public.
|
|
115
|
+
if (publicPath !== internal && pathname === internal) {
|
|
116
|
+
const redirectUrl = req.nextUrl.clone();
|
|
117
|
+
redirectUrl.pathname = publicPath;
|
|
118
|
+
return redirectResponse(redirectUrl.toString(), 308, cookieCfg, locale, https);
|
|
101
119
|
}
|
|
102
|
-
const url = req.nextUrl.clone();
|
|
103
|
-
url.pathname = joinLocalePath(activeDefault, internal);
|
|
104
|
-
return rewriteResponse(url.toString(), cookieCfg, locale, https);
|
|
105
120
|
}
|
|
106
|
-
// Non-default (or always): redirect to prefixed localized public URL.
|
|
107
|
-
const destPublic = publicPathForLocale(internal, locale, pathnames);
|
|
108
121
|
const url = req.nextUrl.clone();
|
|
109
|
-
url.pathname = joinLocalePath(
|
|
110
|
-
return
|
|
111
|
-
}
|
|
122
|
+
url.pathname = joinLocalePath(activeDefault, internal);
|
|
123
|
+
return rewriteResponse(url.toString(), cookieCfg, locale, https);
|
|
124
|
+
}
|
|
125
|
+
// Non-default (or always): redirect to prefixed localized public URL.
|
|
126
|
+
const destPublic = publicPathForLocale(internal, locale, pathnames);
|
|
127
|
+
const url = req.nextUrl.clone();
|
|
128
|
+
url.pathname = joinLocalePath(locale, destPublic);
|
|
129
|
+
return redirectResponse(url.toString(), 307, cookieCfg, locale, https);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Copy response headers from a prior `NextResponse.next()` (CSP, nonce, …)
|
|
133
|
+
* onto a tongue rewrite/redirect/next response. Skips middleware control
|
|
134
|
+
* headers (`x-middleware-*`, `Location` when tongue already set one).
|
|
135
|
+
*
|
|
136
|
+
* Use when you stamp headers on `next()` then call tongue — tongue alone
|
|
137
|
+
* cannot see those headers.
|
|
138
|
+
*/
|
|
139
|
+
export function mergeProxyResponse(prior, tongue) {
|
|
140
|
+
const headers = new Headers(tongue.headers);
|
|
141
|
+
prior.headers.forEach((value, key) => {
|
|
142
|
+
const k = key.toLowerCase();
|
|
143
|
+
if (k.startsWith('x-middleware-'))
|
|
144
|
+
return;
|
|
145
|
+
if (k === 'location' && headers.has('location'))
|
|
146
|
+
return;
|
|
147
|
+
if (k === 'set-cookie') {
|
|
148
|
+
headers.append(key, value);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!headers.has(key))
|
|
152
|
+
headers.set(key, value);
|
|
153
|
+
});
|
|
154
|
+
return new Response(tongue.body, {
|
|
155
|
+
status: tongue.status,
|
|
156
|
+
statusText: tongue.statusText,
|
|
157
|
+
headers,
|
|
158
|
+
});
|
|
112
159
|
}
|
|
113
160
|
/** Map internal path → localized public path for a locale. */
|
|
114
161
|
export function publicPathForLocale(internal, locale, pathnames) {
|