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 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]
@@ -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 manually).
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 proxyPath = await detectProxyPath(cwd, appRoot);
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
- if (proxyPath) {
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: proxyPath,
78
+ path: detectProxyWritePath(appRoot),
70
79
  kind: 'create',
71
- content: proxyScaffold(locales, defaultLocale, prefix),
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('tongue init: existing proxy/middleware detected — do not overwrite. Compose like:\n\n' +
76
- proxyScaffold(locales, defaultLocale, prefix) +
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 manually).
360
+ * (caller should compose or pass `--proxy merge|overwrite`).
317
361
  */
318
362
  export async function detectProxyPath(cwd, appRoot) {
319
- const existing = [
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.startsWith('src/') ? 'src/proxy.ts' : 'proxy.ts';
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
+ }
@@ -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';
@@ -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)}: () => unknown`
50
- : `${JSON.stringify(name)}: (chunks: unknown) => unknown`;
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
- let path = resolveHref(href);
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
- return `/${locale}${path.startsWith('/') ? path : `/${path}`}`;
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
- let path = href.pathname.startsWith('/') ? href.pathname : `/${href.pathname}`;
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
- if (s)
62
- path += `?${s}`;
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
  }
@@ -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`. */
@@ -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 { pathname } = req.nextUrl;
42
- const https = isHttpsRequest(req);
43
- if (isUnsafePath(pathname)) {
44
- return passthrough(cookieCfg, null, https);
45
- }
46
- const domain = matchDomain(req, domains);
47
- const activeLocales = domain?.locales ? [...domain.locales] : locales;
48
- const activeDefault = domain?.defaultLocale ?? defaultLocale;
49
- const activeSet = new Set(activeLocales);
50
- const activeMatcher = activeLocales.length === locales.length
51
- ? detectionMatcher
52
- : createLocaleMatcher(activeLocales.filter((l) => !excludeDetection.has(l)));
53
- const matched = matchLocalePrefix(pathname, localeSet);
54
- if (matched && (!domain || activeSet.has(matched.locale))) {
55
- const { locale, rest } = matched;
56
- const publicRest = rest === '' ? '/' : rest;
57
- const internal = toInternalPathname(publicRest, locale, pathnames);
58
- if (prefix === 'as-needed' && locale === activeDefault) {
59
- // Canonicalize default-locale URLs without the prefix.
60
- const dest = publicPathForLocale(internal, locale, pathnames);
61
- const redirectUrl = req.nextUrl.clone();
62
- redirectUrl.pathname = dest;
63
- options.onLocale?.(locale, req);
64
- return redirectResponse(redirectUrl.toString(), 308, cookieCfg, locale, https);
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
- if (internal !== publicRest) {
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
- const internal = toInternalPathname(pathname, locale, pathnames);
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
- if (prefix === 'as-needed' && locale === activeDefault) {
92
- // Serve default locale without prefix; rewrite to internal app path.
93
- if (pathname !== publicPath && pathnames) {
94
- // User visited internal `/about` but public is also `/about` — fine.
95
- // If they somehow differ for default locale, redirect to public.
96
- if (publicPath !== internal && pathname === internal) {
97
- const redirectUrl = req.nextUrl.clone();
98
- redirectUrl.pathname = publicPath;
99
- return redirectResponse(redirectUrl.toString(), 308, cookieCfg, locale, https);
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(locale, destPublic);
110
- return redirectResponse(url.toString(), 307, cookieCfg, locale, https);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",