mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b06a1a1d04
* fix(i18n): strip locale prefix for API routes Mirror Next.js's behaviour where a locale-prefixed request path like `/fr/api/ok` is matched against `pages/api/ok` after stripping the locale segment. Previously the locale prefix was kept through the `/api/` startsWith check, so any locale-prefixed API URL 404'd. Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in `server/pages-i18n.ts` that mirrors Next.js's `normalizeLocalePath().pathname` (see packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it into all three Pages Router request pipelines so dev/prod parity holds: - dev plugin: `packages/vinext/src/index.ts` - prod (Node): `packages/vinext/src/server/prod-server.ts` - prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts` App Router is intentionally unchanged: Next.js does not support the `i18n` config field with App Router, so App Router route handlers under `app/*/route.ts` never see a locale prefix to strip. Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and 4 (default-locale path normalisation) are being handled in parallel PRs. Tests: - Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional (`nl-NL`), query-preserving, unconfigured-prefix, and null-config cases (`tests/pages-i18n.test.ts`). - Dev integration tests (`tests/features.test.ts`) and prod integration tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and assert a 200 "ok" response against the existing `pages-i18n-domains` fixture, ported from Next.js's `test/e2e/middleware-redirects/test/index.test.ts` ("should redirect to api route with locale"). - Updates to `tests/deploy.test.ts` assertions for the generated worker-entry template strings. * test(after-deploy): update worker-entry assertion after locale strip The Pages Router worker entry now calls handleApiRoute(request, apiLookupUrl, ctx) instead of using resolvedUrl directly, so this generated-code assertion needs the new variable name. Refs #1336 (item 3).
183 lines
7.1 KiB
TypeScript
183 lines
7.1 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vite-plus/test";
|
|
|
|
describe("Pages i18n domain helpers", () => {
|
|
let addLocalePrefix: typeof import("../packages/vinext/src/utils/domain-locale.js").addLocalePrefix;
|
|
let detectDomainLocale: typeof import("../packages/vinext/src/server/pages-i18n.js").detectDomainLocale;
|
|
let getLocaleRedirect: typeof import("../packages/vinext/src/server/pages-i18n.js").getLocaleRedirect;
|
|
let resolvePagesI18nRequest: typeof import("../packages/vinext/src/server/pages-i18n.js").resolvePagesI18nRequest;
|
|
|
|
beforeAll(async () => {
|
|
const mod = await import("../packages/vinext/src/server/pages-i18n.js");
|
|
({ addLocalePrefix } = await import("../packages/vinext/src/utils/domain-locale.js"));
|
|
detectDomainLocale = mod.detectDomainLocale;
|
|
getLocaleRedirect = mod.getLocaleRedirect;
|
|
resolvePagesI18nRequest = mod.resolvePagesI18nRequest;
|
|
});
|
|
|
|
const i18n = {
|
|
locales: ["en", "fr", "nl-NL", "nl-BE"],
|
|
defaultLocale: "en",
|
|
localeDetection: true,
|
|
domains: [
|
|
{ domain: "example.com", defaultLocale: "en" },
|
|
{ domain: "example.fr", defaultLocale: "fr", http: true },
|
|
{ domain: "example.nl", defaultLocale: "nl-NL", locales: ["nl-BE"] },
|
|
],
|
|
};
|
|
|
|
it("matches configured domains ignoring port and case", () => {
|
|
expect(detectDomainLocale(i18n.domains, "EXAMPLE.FR:3000")).toEqual(i18n.domains[1]);
|
|
});
|
|
|
|
it("matches a domain by locale aliases when switching locales", () => {
|
|
expect(detectDomainLocale(i18n.domains, undefined, "nl-BE")).toEqual(i18n.domains[2]);
|
|
});
|
|
|
|
it("treats default locale comparisons as case-insensitive when prefixing paths", () => {
|
|
expect(addLocalePrefix("/about", "en-us", "en-US")).toBe("/about");
|
|
});
|
|
|
|
it("does not double-prefix paths that already include the locale with different casing", () => {
|
|
expect(addLocalePrefix("/FR/about", "fr", "en")).toBe("/FR/about");
|
|
});
|
|
|
|
it("does not let NEXT_LOCALE override the current domain default locale", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: { cookie: "NEXT_LOCALE=fr" },
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.com", pathname: "/" },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not redirect same-domain locale aliases when the domain default already matches", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: { cookie: "NEXT_LOCALE=nl-BE" },
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.nl", pathname: "/" },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("redirects same-domain locale aliases detected from Accept-Language", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: { "accept-language": "nl-BE,nl;q=0.9,en;q=0.8" },
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.nl", pathname: "/" },
|
|
}),
|
|
).toBe("https://example.nl/nl-BE");
|
|
});
|
|
|
|
it("uses Accept-Language rather than NEXT_LOCALE to choose the preferred domain", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: {
|
|
cookie: "NEXT_LOCALE=en",
|
|
"accept-language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
},
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.com", pathname: "/" },
|
|
}),
|
|
).toBe("http://example.fr/");
|
|
});
|
|
|
|
it("redirects root requests to the preferred locale domain", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" },
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.com", pathname: "/" },
|
|
}),
|
|
).toBe("http://example.fr/");
|
|
});
|
|
|
|
it("does not redirect non-root requests for locale detection", () => {
|
|
expect(
|
|
getLocaleRedirect({
|
|
headers: { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" },
|
|
nextConfig: { i18n, basePath: "", trailingSlash: false },
|
|
pathLocale: undefined,
|
|
urlParsed: { hostname: "example.com", pathname: "/about" },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("preserves the search string on root locale redirects", () => {
|
|
expect(
|
|
resolvePagesI18nRequest(
|
|
"/?utm=campaign&next=%2Fcheckout",
|
|
i18n,
|
|
{ "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" },
|
|
"example.com",
|
|
).redirectUrl,
|
|
).toBe("http://example.fr/?utm=campaign&next=%2Fcheckout");
|
|
});
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/middleware-redirects/test/index.test.ts
|
|
// (the "should redirect to api route with locale" case)
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-redirects/test/index.test.ts
|
|
//
|
|
// Reference Next.js implementation:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/i18n/normalize-locale-path.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts
|
|
describe("stripI18nLocaleForApiRoute (issue #1336 item 3)", () => {
|
|
const i18n = {
|
|
locales: ["en", "fr", "nl-NL"],
|
|
defaultLocale: "en",
|
|
localeDetection: true,
|
|
};
|
|
let stripI18nLocaleForApiRoute: typeof import("../packages/vinext/src/server/pages-i18n.js").stripI18nLocaleForApiRoute;
|
|
|
|
beforeAll(async () => {
|
|
({ stripI18nLocaleForApiRoute } = await import("../packages/vinext/src/server/pages-i18n.js"));
|
|
});
|
|
|
|
it("strips a single-segment locale prefix from an API path", () => {
|
|
expect(stripI18nLocaleForApiRoute("/fr/api/ok", i18n)).toBe("/api/ok");
|
|
});
|
|
|
|
it("strips a region-tagged locale prefix from an API path", () => {
|
|
expect(stripI18nLocaleForApiRoute("/nl-NL/api/ok", i18n)).toBe("/api/ok");
|
|
});
|
|
|
|
it("preserves the query string when stripping the locale prefix", () => {
|
|
expect(stripI18nLocaleForApiRoute("/fr/api/ok?id=1&tag=a&tag=b", i18n)).toBe(
|
|
"/api/ok?id=1&tag=a&tag=b",
|
|
);
|
|
});
|
|
|
|
it("returns the URL unchanged when no locale prefix is present", () => {
|
|
expect(stripI18nLocaleForApiRoute("/api/ok", i18n)).toBe("/api/ok");
|
|
});
|
|
|
|
it("returns the URL unchanged when the first segment is not a configured locale", () => {
|
|
// "es" is NOT in locales; this must NOT be treated as a locale prefix.
|
|
expect(stripI18nLocaleForApiRoute("/es/api/ok", i18n)).toBe("/es/api/ok");
|
|
});
|
|
|
|
it("returns the URL unchanged when i18nConfig is null/undefined", () => {
|
|
expect(stripI18nLocaleForApiRoute("/fr/api/ok", null)).toBe("/fr/api/ok");
|
|
expect(stripI18nLocaleForApiRoute("/fr/api/ok", undefined)).toBe("/fr/api/ok");
|
|
});
|
|
|
|
it("strips locale even on non-API paths (caller decides what to do with the result)", () => {
|
|
// The helper is locale-strip only; callers branch on the resulting
|
|
// pathname's /api/ prefix. This isolates the helper's behaviour from
|
|
// any caller-specific routing decisions.
|
|
expect(stripI18nLocaleForApiRoute("/fr/about", i18n)).toBe("/about");
|
|
});
|
|
|
|
it("leaves the root path untouched", () => {
|
|
expect(stripI18nLocaleForApiRoute("/", i18n)).toBe("/");
|
|
});
|
|
});
|