Skip to content

Commit 220be99

Browse files
authored
fix rewrite/redirect with i18n (#469)
* fix rewrite/redirect with i18n * Create cuddly-waves-smash.md
1 parent 59ff2ee commit 220be99

File tree

7 files changed

+235
-10
lines changed

7 files changed

+235
-10
lines changed

.changeset/cuddly-waves-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"open-next": patch
3+
---
4+
5+
fix rewrite/redirect with i18n

examples/pages-router/next.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ const nextConfig = {
3535
basePath: false,
3636
locale: false,
3737
},
38+
{
39+
source: "/redirect-with-locale/",
40+
destination: "/ssr/",
41+
permanent: false,
42+
},
3843
],
3944
trailingSlash: true,
4045
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copied from Next.js source code
2+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/accept-header.ts
3+
4+
interface Selection {
5+
pos: number;
6+
pref?: number;
7+
q: number;
8+
token: string;
9+
}
10+
11+
interface Options {
12+
prefixMatch?: boolean;
13+
type: "accept-language";
14+
}
15+
16+
function parse(
17+
raw: string,
18+
preferences: string[] | undefined,
19+
options: Options,
20+
) {
21+
const lowers = new Map<string, { orig: string; pos: number }>();
22+
const header = raw.replace(/[ \t]/g, "");
23+
24+
if (preferences) {
25+
let pos = 0;
26+
for (const preference of preferences) {
27+
const lower = preference.toLowerCase();
28+
lowers.set(lower, { orig: preference, pos: pos++ });
29+
if (options.prefixMatch) {
30+
const parts = lower.split("-");
31+
while ((parts.pop(), parts.length > 0)) {
32+
const joined = parts.join("-");
33+
if (!lowers.has(joined)) {
34+
lowers.set(joined, { orig: preference, pos: pos++ });
35+
}
36+
}
37+
}
38+
}
39+
}
40+
41+
const parts = header.split(",");
42+
const selections: Selection[] = [];
43+
const map = new Set<string>();
44+
45+
for (let i = 0; i < parts.length; ++i) {
46+
const part = parts[i];
47+
if (!part) {
48+
continue;
49+
}
50+
51+
const params = part.split(";");
52+
if (params.length > 2) {
53+
throw new Error(`Invalid ${options.type} header`);
54+
}
55+
56+
let token = params[0].toLowerCase();
57+
if (!token) {
58+
throw new Error(`Invalid ${options.type} header`);
59+
}
60+
61+
const selection: Selection = { token, pos: i, q: 1 };
62+
if (preferences && lowers.has(token)) {
63+
selection.pref = lowers.get(token)!.pos;
64+
}
65+
66+
map.add(selection.token);
67+
68+
if (params.length === 2) {
69+
const q = params[1];
70+
const [key, value] = q.split("=");
71+
72+
if (!value || (key !== "q" && key !== "Q")) {
73+
throw new Error(`Invalid ${options.type} header`);
74+
}
75+
76+
const score = parseFloat(value);
77+
if (score === 0) {
78+
continue;
79+
}
80+
81+
if (Number.isFinite(score) && score <= 1 && score >= 0.001) {
82+
selection.q = score;
83+
}
84+
}
85+
86+
selections.push(selection);
87+
}
88+
89+
selections.sort((a, b) => {
90+
if (b.q !== a.q) {
91+
return b.q - a.q;
92+
}
93+
94+
if (b.pref !== a.pref) {
95+
if (a.pref === undefined) {
96+
return 1;
97+
}
98+
99+
if (b.pref === undefined) {
100+
return -1;
101+
}
102+
103+
return a.pref - b.pref;
104+
}
105+
106+
return a.pos - b.pos;
107+
});
108+
109+
const values = selections.map((selection) => selection.token);
110+
if (!preferences || !preferences.length) {
111+
return values;
112+
}
113+
114+
const preferred: string[] = [];
115+
for (const selection of values) {
116+
if (selection === "*") {
117+
for (const [preference, value] of lowers) {
118+
if (!map.has(preference)) {
119+
preferred.push(value.orig);
120+
}
121+
}
122+
} else {
123+
const lower = selection.toLowerCase();
124+
if (lowers.has(lower)) {
125+
preferred.push(lowers.get(lower)!.orig);
126+
}
127+
}
128+
}
129+
130+
return preferred;
131+
}
132+
133+
export function acceptLanguage(header = "", preferences?: string[]) {
134+
return (
135+
parse(header, preferences, {
136+
type: "accept-language",
137+
prefixMatch: true,
138+
})[0] || undefined
139+
);
140+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { NextConfig } from "config/index.js";
2+
import type { i18nConfig } from "types/next-types";
3+
import { InternalEvent } from "types/open-next";
4+
5+
import { debug } from "../../../adapters/logger.js";
6+
import { acceptLanguage } from "./accept-header";
7+
8+
function isLocalizedPath(path: string): boolean {
9+
return (
10+
NextConfig.i18n?.locales.includes(path.split("/")[1].toLowerCase()) ?? false
11+
);
12+
}
13+
14+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/i18n/get-locale-redirect.ts
15+
function getLocaleFromCookie(cookies: Record<string, string>) {
16+
const i18n = NextConfig.i18n;
17+
const nextLocale = cookies.NEXT_LOCALE?.toLowerCase();
18+
return nextLocale
19+
? i18n?.locales.find((locale) => nextLocale === locale.toLowerCase())
20+
: undefined;
21+
}
22+
23+
function detectLocale(internalEvent: InternalEvent, i18n: i18nConfig): string {
24+
const cookiesLocale = getLocaleFromCookie(internalEvent.cookies);
25+
const preferredLocale = acceptLanguage(
26+
internalEvent.headers["accept-language"],
27+
i18n?.locales,
28+
);
29+
debug({
30+
cookiesLocale,
31+
preferredLocale,
32+
defaultLocale: i18n.defaultLocale,
33+
});
34+
35+
return cookiesLocale ?? preferredLocale ?? i18n.defaultLocale;
36+
37+
// TODO: handle domain based locale detection
38+
}
39+
40+
export function localizePath(internalEvent: InternalEvent): string {
41+
const i18n = NextConfig.i18n;
42+
if (!i18n) {
43+
return internalEvent.rawPath;
44+
}
45+
if (isLocalizedPath(internalEvent.rawPath)) {
46+
return internalEvent.rawPath;
47+
}
48+
const detectedLocale = detectLocale(internalEvent, i18n);
49+
return `/${detectedLocale}${internalEvent.rawPath}`;
50+
}

packages/open-next/src/core/routing/matcher.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import { InternalEvent, InternalResult } from "types/open-next";
1717

1818
import { debug } from "../../adapters/logger";
19+
import { localizePath } from "./i18n";
1920
import {
2021
convertFromQueryString,
2122
convertToQueryString,
@@ -170,14 +171,17 @@ export function handleRewrites<T extends RewriteDefinition>(
170171
rewrites: T[],
171172
) {
172173
const { rawPath, headers, query, cookies } = event;
174+
const localizedRawPath = localizePath(event);
173175
const matcher = routeHasMatcher(headers, cookies, query);
174176
const computeHas = computeParamHas(headers, cookies, query);
175-
const rewrite = rewrites.find(
176-
(route) =>
177-
new RegExp(route.regex).test(rawPath) &&
177+
const rewrite = rewrites.find((route) => {
178+
const path = route.locale === false ? rawPath : localizedRawPath;
179+
return (
180+
new RegExp(route.regex).test(path) &&
178181
checkHas(matcher, route.has) &&
179-
checkHas(matcher, route.missing, true),
180-
);
182+
checkHas(matcher, route.missing, true)
183+
);
184+
});
181185
let finalQuery = query;
182186

183187
let rewrittenUrl = rawPath;
@@ -188,14 +192,16 @@ export function handleRewrites<T extends RewriteDefinition>(
188192
rewrite.destination,
189193
isExternalRewrite,
190194
);
195+
// We need to use a localized path if the rewrite is not locale specific
196+
const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath;
191197
debug("urlParts", { pathname, protocol, hostname, queryString });
192198
const toDestinationPath = compile(escapeRegex(pathname ?? "") ?? "");
193199
const toDestinationHost = compile(escapeRegex(hostname ?? "") ?? "");
194200
const toDestinationQuery = compile(escapeRegex(queryString ?? "") ?? "");
195201
let params = {
196202
// params for the source
197203
...getParamsFromSource(match(escapeRegex(rewrite?.source) ?? ""))(
198-
rawPath,
204+
pathToUse,
199205
),
200206
// params for the has
201207
...rewrite.has?.reduce((acc, cur) => {

packages/open-next/src/types/next-types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@ export type Header = {
6060
has?: RouteHas[];
6161
missing?: RouteHas[];
6262
};
63+
64+
export interface i18nConfig {
65+
locales: string[];
66+
defaultLocale: string;
67+
}
6368
export interface NextConfig {
6469
basePath?: string;
6570
trailingSlash?: string;
6671
skipTrailingSlashRedirect?: boolean;
67-
i18n?: {
68-
locales: string[];
69-
defaultLocale: string;
70-
};
72+
i18n?: i18nConfig;
7173
experimental: {
7274
serverActions?: boolean;
7375
appDir?: boolean;
@@ -92,6 +94,7 @@ export interface RewriteDefinition {
9294
has?: RouteHas[];
9395
missing?: RouteHas[];
9496
regex: string;
97+
locale?: false;
9598
}
9699

97100
export interface RedirectDefinition extends RewriteDefinition {

packages/tests-e2e/tests/pagesRouter/redirect.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,19 @@ test("Single redirect", async ({ page }) => {
77
let el = page.getByRole("heading", { name: "Open source Next.js adapter" });
88
await expect(el).toBeVisible();
99
});
10+
11+
test("Redirect with default locale support", async ({ page }) => {
12+
await page.goto("/redirect-with-locale/");
13+
14+
await page.waitForURL("/ssr/");
15+
let el = page.getByText("SSR");
16+
await expect(el).toBeVisible();
17+
});
18+
19+
test("Redirect with locale support", async ({ page }) => {
20+
await page.goto("/nl/redirect-with-locale/");
21+
22+
await page.waitForURL("/nl/ssr/");
23+
let el = page.getByText("SSR");
24+
await expect(el).toBeVisible();
25+
});

0 commit comments

Comments
 (0)