Skip to content
This repository was archived by the owner on Jan 28, 2025. It is now read-only.

Commit a8765d1

Browse files
authored
fix(core): allow lowercase locale prefixes by standardizing locale casing on server-side, update locale redirect casing (#1496)
1 parent 9a07c65 commit a8765d1

File tree

6 files changed

+75
-40
lines changed

6 files changed

+75
-40
lines changed

packages/e2e-tests/next-app-with-locales/cypress/integration/pages.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ describe("Pages Tests", () => {
77
[
88
{ path: "/ssr-page" },
99
{ path: "/en/ssr-page" },
10-
{ path: "/fr/ssr-page" }
10+
{ path: "/fr/ssr-page" },
11+
{ path: "/en-GB/ssr-page" },
12+
{ path: "/en-gb/ssr-page" }
1113
].forEach(({ path }) => {
1214
it(`serves but does not cache page ${path}`, () => {
1315
cy.ensureRouteNotCached(path);
@@ -37,7 +39,9 @@ describe("Pages Tests", () => {
3739
[
3840
{ path: "/ssr-page-2", locale: "en" },
3941
{ path: "/en/ssr-page-2", locale: "en" },
40-
{ path: "/fr/ssr-page-2", locale: "fr" }
42+
{ path: "/fr/ssr-page-2", locale: "fr" },
43+
{ path: "/en-GB/ssr-page-2", locale: "en-GB" },
44+
{ path: "/en-gb/ssr-page-2", locale: "en-GB" }
4145
].forEach(({ locale, path }) => {
4246
it(`serves but does not cache page ${path}`, () => {
4347
if (path === "/") {
@@ -75,10 +79,11 @@ describe("Pages Tests", () => {
7579

7680
describe("SSG pages", () => {
7781
[
78-
{ path: "/ssg-page" },
79-
{ path: "/en/ssg-page" },
80-
{ path: "/fr/ssg-page" }
81-
].forEach(({ path }) => {
82+
{ path: "/ssg-page", locale: "en" },
83+
{ path: "/en/ssg-page", locale: "en" },
84+
{ path: "/fr/ssg-page", locale: "fr" },
85+
{ path: "/en-GB/ssg-page", locale: "en-GB" }
86+
].forEach(({ path, locale }) => {
8287
it(`serves and caches page ${path}`, () => {
8388
cy.visit(path);
8489

@@ -92,6 +97,8 @@ describe("Pages Tests", () => {
9297

9398
cy.ensureRouteCached(path);
9499
cy.visit(path);
100+
101+
cy.get("[data-cy=locale]").contains(locale);
95102
});
96103

97104
it(`supports preview mode ${path}`, () => {

packages/e2e-tests/next-app-with-locales/next.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ module.exports = {
200200
];
201201
},
202202
i18n: {
203-
locales: ["en", "fr"],
203+
locales: ["en", "en-GB", "fr"],
204204
defaultLocale: "en"
205205
}
206206
};

packages/e2e-tests/next-app-with-locales/pages/index.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import React from "react";
2-
import { NextPageContext } from "next";
2+
import { useRouter } from "next/router";
33

44
type IndexPageProps = {
55
name: string;
66
};
77

88
export default function IndexPage(props: IndexPageProps): JSX.Element {
9+
const {
10+
query: { segments = [] },
11+
locale
12+
} = useRouter();
913
return (
1014
<React.Fragment>
1115
<div>
12-
{`Hello ${props.name}. This is an SSG page using getStaticProps(). It also has an image.`}
16+
<p>
17+
{`Hello ${props.name}. This is an SSG page using getStaticProps(). It also has an image.`}
18+
</p>
19+
<p data-cy="locale">{locale}</p>
20+
<p data-cy="segments">{segments}</p>
1321
</div>
1422
<img src={"/app-store-badge.png"} alt={"An image"} />
1523
</React.Fragment>
1624
);
1725
}
1826

19-
export async function getStaticProps(
20-
ctx: NextPageContext
21-
): Promise<{ props: IndexPageProps }> {
27+
export function getStaticProps(): { props: IndexPageProps } {
2228
return {
2329
props: { name: "serverless-next.js" }
2430
};

packages/e2e-tests/next-app-with-locales/pages/ssg-page.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,32 @@
11
import React from "react";
22
import { GetStaticPropsContext } from "next";
3+
import { useRouter } from "next/router";
34

45
type SSGPageProps = {
56
name: string;
67
preview: boolean;
78
};
89

910
export default function SSGPage(props: any): JSX.Element {
11+
const {
12+
query: { segments = [] },
13+
locale
14+
} = useRouter();
1015
return (
1116
<React.Fragment>
1217
{`Hello ${props.name}! This is an SSG Page using getStaticProps().`}
1318
<div>
1419
<p data-cy="preview-mode">{String(props.preview)}</p>
1520
</div>
21+
<p data-cy="locale">{locale}</p>
22+
<p data-cy="segments">{segments}</p>
1623
</React.Fragment>
1724
);
1825
}
1926

20-
export async function getStaticProps(
21-
ctx: GetStaticPropsContext
22-
): Promise<{ props: SSGPageProps }> {
27+
export function getStaticProps(ctx: GetStaticPropsContext): {
28+
props: SSGPageProps;
29+
} {
2330
return {
2431
props: {
2532
name: "serverless-next.js",

packages/libs/core/src/route/locale.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@ export function addDefaultLocaleToPath(
3434
? routesManifest.basePath
3535
: "";
3636

37-
// If prefixed with a locale, return that path
37+
// If prefixed with a locale, return that path with normalized locale
38+
const pathLowerCase = path.toLowerCase();
3839
for (const locale of locales) {
3940
if (
40-
path === `${basePath}/${locale}` ||
41-
path.startsWith(`${basePath}/${locale}/`)
41+
pathLowerCase === `${basePath}/${locale}`.toLowerCase() ||
42+
pathLowerCase.startsWith(`${basePath}/${locale}/`.toLowerCase())
4243
) {
43-
return typeof forceLocale === "string"
44-
? path.replace(`${locale}/`, `${forceLocale}/`)
45-
: path;
44+
return path.replace(
45+
new RegExp(`${basePath}/${locale}`, "i"),
46+
`${basePath}/${forceLocale ?? locale}`
47+
);
4648
}
4749
}
4850

@@ -62,16 +64,17 @@ export function dropLocaleFromPath(
6264
routesManifest: RoutesManifest
6365
): string {
6466
if (routesManifest.i18n) {
67+
const pathLowerCase = path.toLowerCase();
6568
const locales = routesManifest.i18n.locales;
6669

6770
// If prefixed with a locale, return path without
6871
for (const locale of locales) {
69-
const prefix = `/${locale}`;
70-
if (path === prefix) {
72+
const prefixLowerCase = `/${locale.toLowerCase()}`;
73+
if (pathLowerCase === prefixLowerCase) {
7174
return "/";
7275
}
73-
if (path.startsWith(`${prefix}/`)) {
74-
return `${path.slice(prefix.length)}`;
76+
if (pathLowerCase.startsWith(`${prefixLowerCase}/`)) {
77+
return `${pathLowerCase.slice(prefixLowerCase.length)}`;
7578
}
7679
}
7780
}
@@ -87,9 +90,10 @@ export const getAcceptLanguageLocale = async (
8790
if (routesManifest.i18n) {
8891
const defaultLocaleLowerCase =
8992
routesManifest.i18n.defaultLocale?.toLowerCase();
90-
const locales = new Set(
91-
routesManifest.i18n.locales.map((locale) => locale.toLowerCase())
92-
);
93+
const localeMap: { [key: string]: string } = {};
94+
for (const locale of routesManifest.i18n.locales) {
95+
localeMap[locale.toLowerCase()] = locale;
96+
}
9397

9498
// Accept.language(header, locales) prefers the locales order,
9599
// so we ask for all to find the order preferred by user.
@@ -99,8 +103,8 @@ export const getAcceptLanguageLocale = async (
99103
if (localeLowerCase === defaultLocaleLowerCase) {
100104
break;
101105
}
102-
if (locales.has(localeLowerCase)) {
103-
return `${routesManifest.basePath}/${language}${
106+
if (localeMap[localeLowerCase]) {
107+
return `${routesManifest.basePath}/${localeMap[localeLowerCase]}${
104108
manifest.trailingSlash ? "/" : ""
105109
}`;
106110
}
@@ -117,8 +121,13 @@ export function getLocalePrefixFromUri(
117121
}
118122

119123
if (routesManifest.i18n) {
124+
const uriLowerCase = uri.toLowerCase();
120125
for (const locale of routesManifest.i18n.locales) {
121-
if (uri === `/${locale}` || uri.startsWith(`/${locale}/`)) {
126+
const localeLowerCase = locale.toLowerCase();
127+
if (
128+
uriLowerCase === `/${localeLowerCase}` ||
129+
uriLowerCase.startsWith(`/${localeLowerCase}/`)
130+
) {
122131
return `/${locale}`;
123132
}
124133
}

packages/libs/core/tests/route/locale.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Headers, Manifest, RoutesManifest } from "../../src";
1+
import { Manifest, RoutesManifest } from "../../src";
22
import {
33
addDefaultLocaleToPath,
44
dropLocaleFromPath,
@@ -19,19 +19,21 @@ describe("Locale Utils Tests", () => {
1919
redirects: [],
2020
rewrites: [],
2121
i18n: {
22-
locales: ["en", "fr", "nl"],
22+
locales: ["en", "fr", "nl", "en-GB"],
2323
defaultLocale: "en",
2424
localeDetection: true
2525
}
2626
};
2727
});
2828

2929
it.each`
30-
path | forceLocale | expectedPath
31-
${"/a"} | ${null} | ${"/en/a"}
32-
${"/en/a"} | ${null} | ${"/en/a"}
33-
${"/fr/a"} | ${null} | ${"/fr/a"}
34-
${"/nl/a"} | ${"en"} | ${"/en/a"}
30+
path | forceLocale | expectedPath
31+
${"/a"} | ${null} | ${"/en/a"}
32+
${"/en/a"} | ${null} | ${"/en/a"}
33+
${"/fr/a"} | ${null} | ${"/fr/a"}
34+
${"/en-GB/a"} | ${null} | ${"/en-GB/a"}
35+
${"/en-gb/a"} | ${null} | ${"/en-GB/a"}
36+
${"/nl/a"} | ${"en"} | ${"/en/a"}
3537
`(
3638
"changes path $path to $expectedPath",
3739
({ path, forceLocale, expectedPath }) => {
@@ -157,7 +159,7 @@ describe("Locale Utils Tests", () => {
157159
redirects: [],
158160
rewrites: [],
159161
i18n: {
160-
locales: ["en", "fr"],
162+
locales: ["en", "fr", "en-GB"],
161163
defaultLocale: "en",
162164
localeDetection: true
163165
}
@@ -168,6 +170,7 @@ describe("Locale Utils Tests", () => {
168170
path | expectedPath
169171
${"/en"} | ${"/"}
170172
${"/en/test"} | ${"/test"}
173+
${"/en-GB/test"} | ${"/test"}
171174
${"/fr/api/foo"} | ${"/api/foo"}
172175
`("changes path $path to $expectedPath", ({ path, expectedPath }) => {
173176
const newPath = dropLocaleFromPath(path, routesManifest);
@@ -179,6 +182,7 @@ describe("Locale Utils Tests", () => {
179182
path
180183
${"/base/en"} | ${"/base"}
181184
${"/base/en/test"} | ${"/base/test"}
185+
${"/base/en-GB/test"} | ${"/base/test"}
182186
${"/base/fr/api/foo"} | ${"/base/api/foo"}
183187
`("keeps path $path unchanged", ({ path }) => {
184188
const newPath = dropLocaleFromPath(path, routesManifest);
@@ -201,7 +205,7 @@ describe("Locale Utils Tests", () => {
201205
redirects: [],
202206
rewrites: [],
203207
i18n: {
204-
locales: ["en", "fr", "nl"],
208+
locales: ["en", "en-GB", "fr", "nl"],
205209
defaultLocale: "en",
206210
localeDetection: true
207211
}
@@ -214,6 +218,8 @@ describe("Locale Utils Tests", () => {
214218
${"nl"} | ${"/nl/"}
215219
${"de, fr"} | ${"/fr/"}
216220
${"fr;q=0.7, nl;q=0.9"} | ${"/nl/"}
221+
${"en-GB"} | ${"/en-GB/"}
222+
${"en-gb"} | ${"/en-GB/"}
217223
`(
218224
"returns $expectedPath for $acceptLang",
219225
async ({ acceptLang, expectedPath }) => {

0 commit comments

Comments
 (0)