Skip to content

Commit

Permalink
fix rewrite/redirect with i18n (#469)
Browse files Browse the repository at this point in the history
* fix rewrite/redirect with i18n

* Create cuddly-waves-smash.md
  • Loading branch information
conico974 authored Jul 20, 2024
1 parent 59ff2ee commit 220be99
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-waves-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"open-next": patch
---

fix rewrite/redirect with i18n
5 changes: 5 additions & 0 deletions examples/pages-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const nextConfig = {
basePath: false,
locale: false,
},
{
source: "/redirect-with-locale/",
destination: "/ssr/",
permanent: false,
},
],
trailingSlash: true,
};
Expand Down
140 changes: 140 additions & 0 deletions packages/open-next/src/core/routing/i18n/accept-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copied from Next.js source code
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/accept-header.ts

interface Selection {
pos: number;
pref?: number;
q: number;
token: string;
}

interface Options {
prefixMatch?: boolean;
type: "accept-language";
}

function parse(
raw: string,
preferences: string[] | undefined,
options: Options,
) {
const lowers = new Map<string, { orig: string; pos: number }>();
const header = raw.replace(/[ \t]/g, "");

if (preferences) {
let pos = 0;
for (const preference of preferences) {
const lower = preference.toLowerCase();
lowers.set(lower, { orig: preference, pos: pos++ });
if (options.prefixMatch) {
const parts = lower.split("-");
while ((parts.pop(), parts.length > 0)) {
const joined = parts.join("-");
if (!lowers.has(joined)) {
lowers.set(joined, { orig: preference, pos: pos++ });
}
}
}
}
}

const parts = header.split(",");
const selections: Selection[] = [];
const map = new Set<string>();

for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
if (!part) {
continue;
}

const params = part.split(";");
if (params.length > 2) {
throw new Error(`Invalid ${options.type} header`);
}

let token = params[0].toLowerCase();
if (!token) {
throw new Error(`Invalid ${options.type} header`);
}

const selection: Selection = { token, pos: i, q: 1 };
if (preferences && lowers.has(token)) {
selection.pref = lowers.get(token)!.pos;
}

map.add(selection.token);

if (params.length === 2) {
const q = params[1];
const [key, value] = q.split("=");

if (!value || (key !== "q" && key !== "Q")) {
throw new Error(`Invalid ${options.type} header`);
}

const score = parseFloat(value);
if (score === 0) {
continue;
}

if (Number.isFinite(score) && score <= 1 && score >= 0.001) {
selection.q = score;
}
}

selections.push(selection);
}

selections.sort((a, b) => {
if (b.q !== a.q) {
return b.q - a.q;
}

if (b.pref !== a.pref) {
if (a.pref === undefined) {
return 1;
}

if (b.pref === undefined) {
return -1;
}

return a.pref - b.pref;
}

return a.pos - b.pos;
});

const values = selections.map((selection) => selection.token);
if (!preferences || !preferences.length) {
return values;
}

const preferred: string[] = [];
for (const selection of values) {
if (selection === "*") {
for (const [preference, value] of lowers) {
if (!map.has(preference)) {
preferred.push(value.orig);
}
}
} else {
const lower = selection.toLowerCase();
if (lowers.has(lower)) {
preferred.push(lowers.get(lower)!.orig);
}
}
}

return preferred;
}

export function acceptLanguage(header = "", preferences?: string[]) {
return (
parse(header, preferences, {
type: "accept-language",
prefixMatch: true,
})[0] || undefined
);
}
50 changes: 50 additions & 0 deletions packages/open-next/src/core/routing/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextConfig } from "config/index.js";
import type { i18nConfig } from "types/next-types";
import { InternalEvent } from "types/open-next";

import { debug } from "../../../adapters/logger.js";
import { acceptLanguage } from "./accept-header";

function isLocalizedPath(path: string): boolean {
return (
NextConfig.i18n?.locales.includes(path.split("/")[1].toLowerCase()) ?? false
);
}

// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/i18n/get-locale-redirect.ts
function getLocaleFromCookie(cookies: Record<string, string>) {
const i18n = NextConfig.i18n;
const nextLocale = cookies.NEXT_LOCALE?.toLowerCase();
return nextLocale
? i18n?.locales.find((locale) => nextLocale === locale.toLowerCase())
: undefined;
}

function detectLocale(internalEvent: InternalEvent, i18n: i18nConfig): string {
const cookiesLocale = getLocaleFromCookie(internalEvent.cookies);
const preferredLocale = acceptLanguage(
internalEvent.headers["accept-language"],
i18n?.locales,
);
debug({
cookiesLocale,
preferredLocale,
defaultLocale: i18n.defaultLocale,
});

return cookiesLocale ?? preferredLocale ?? i18n.defaultLocale;

// TODO: handle domain based locale detection
}

export function localizePath(internalEvent: InternalEvent): string {
const i18n = NextConfig.i18n;
if (!i18n) {
return internalEvent.rawPath;
}
if (isLocalizedPath(internalEvent.rawPath)) {
return internalEvent.rawPath;
}
const detectedLocale = detectLocale(internalEvent, i18n);
return `/${detectedLocale}${internalEvent.rawPath}`;
}
18 changes: 12 additions & 6 deletions packages/open-next/src/core/routing/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import { InternalEvent, InternalResult } from "types/open-next";

import { debug } from "../../adapters/logger";
import { localizePath } from "./i18n";
import {
convertFromQueryString,
convertToQueryString,
Expand Down Expand Up @@ -170,14 +171,17 @@ export function handleRewrites<T extends RewriteDefinition>(
rewrites: T[],
) {
const { rawPath, headers, query, cookies } = event;
const localizedRawPath = localizePath(event);
const matcher = routeHasMatcher(headers, cookies, query);
const computeHas = computeParamHas(headers, cookies, query);
const rewrite = rewrites.find(
(route) =>
new RegExp(route.regex).test(rawPath) &&
const rewrite = rewrites.find((route) => {
const path = route.locale === false ? rawPath : localizedRawPath;
return (
new RegExp(route.regex).test(path) &&
checkHas(matcher, route.has) &&
checkHas(matcher, route.missing, true),
);
checkHas(matcher, route.missing, true)
);
});
let finalQuery = query;

let rewrittenUrl = rawPath;
Expand All @@ -188,14 +192,16 @@ export function handleRewrites<T extends RewriteDefinition>(
rewrite.destination,
isExternalRewrite,
);
// We need to use a localized path if the rewrite is not locale specific
const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath;
debug("urlParts", { pathname, protocol, hostname, queryString });
const toDestinationPath = compile(escapeRegex(pathname ?? "") ?? "");
const toDestinationHost = compile(escapeRegex(hostname ?? "") ?? "");
const toDestinationQuery = compile(escapeRegex(queryString ?? "") ?? "");
let params = {
// params for the source
...getParamsFromSource(match(escapeRegex(rewrite?.source) ?? ""))(
rawPath,
pathToUse,
),
// params for the has
...rewrite.has?.reduce((acc, cur) => {
Expand Down
11 changes: 7 additions & 4 deletions packages/open-next/src/types/next-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ export type Header = {
has?: RouteHas[];
missing?: RouteHas[];
};

export interface i18nConfig {
locales: string[];
defaultLocale: string;
}
export interface NextConfig {
basePath?: string;
trailingSlash?: string;
skipTrailingSlashRedirect?: boolean;
i18n?: {
locales: string[];
defaultLocale: string;
};
i18n?: i18nConfig;
experimental: {
serverActions?: boolean;
appDir?: boolean;
Expand All @@ -92,6 +94,7 @@ export interface RewriteDefinition {
has?: RouteHas[];
missing?: RouteHas[];
regex: string;
locale?: false;
}

export interface RedirectDefinition extends RewriteDefinition {
Expand Down
16 changes: 16 additions & 0 deletions packages/tests-e2e/tests/pagesRouter/redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,19 @@ test("Single redirect", async ({ page }) => {
let el = page.getByRole("heading", { name: "Open source Next.js adapter" });
await expect(el).toBeVisible();
});

test("Redirect with default locale support", async ({ page }) => {
await page.goto("/redirect-with-locale/");

await page.waitForURL("/ssr/");
let el = page.getByText("SSR");
await expect(el).toBeVisible();
});

test("Redirect with locale support", async ({ page }) => {
await page.goto("/nl/redirect-with-locale/");

await page.waitForURL("/nl/ssr/");
let el = page.getByText("SSR");
await expect(el).toBeVisible();
});

0 comments on commit 220be99

Please sign in to comment.