next-tongue 0.9.14 → 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.
@@ -27,7 +27,9 @@ export function mergeProxySource(source, opts) {
27
27
  return {
28
28
  status: 'merged',
29
29
  code: ensureTrailingNewline(replaced),
30
- note: `Replaced final NextResponse.next() with ${tongueIdent}(${reqIdent}). Early next() returns (allow-lists) are kept.`,
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.`,
31
33
  };
32
34
  }
33
35
  // No next() — wrap exported handler so user logic runs first, then tongue.
@@ -36,7 +38,9 @@ export function mergeProxySource(source, opts) {
36
38
  return {
37
39
  status: 'merged',
38
40
  code: ensureTrailingNewline(wrapped),
39
- note: `Wrapped existing handler: user Response wins unless it is a passthrough next; otherwise ${tongueIdent} runs.`,
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.`,
40
44
  };
41
45
  }
42
46
  return {
@@ -117,14 +121,50 @@ function wrapExportedHandler(code, tongueIdent, reqIdent) {
117
121
  const name = m[2];
118
122
  const inner = `__tongue_user_${name}`;
119
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.
120
129
  out +=
121
- `\n\nexport ${asyncKw}function ${name}(${reqIdent}: Parameters<typeof ${inner}>[0]) {\n` +
130
+ `\n\nexport async function ${name}(${reqIdent}: Parameters<typeof ${inner}>[0]) {\n` +
122
131
  ` const __user = ${asyncKw ? 'await ' : ''}${inner}(${reqIdent});\n` +
123
132
  ` if (__user && __user.headers?.get?.('x-middleware-next') !== '1') return __user;\n` +
124
- ` return ${tongueIdent}(${reqIdent});\n` +
133
+ ` const __tongue = await ${tongueIdent}(${reqIdent});\n` +
134
+ ` return __user ? mergeProxyResponse(__user, __tongue) : __tongue;\n` +
125
135
  `}\n`;
126
136
  return out;
127
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
+ }
128
168
  export const PROXY_CANDIDATES = [
129
169
  'proxy.ts',
130
170
  'proxy.js',
@@ -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.14",
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",