Skip to content

Commit

Permalink
feat: Support symbols in localized pathnames that require URL encoding (
Browse files Browse the repository at this point in the history
#959)

Fixes #607

**Prerelease:**

```
[email protected]
```

**Example:**

```js
export const pathnames = {
  '/': '/',
  '/test': {
    en: '/test',
    ja: '/テスト'
  }
} satisfies Pathnames<typeof locales>
```

Previously, a request to `/ja/テスト` would be a 404 since the pathname
wasn't matched correctly. Now, the internal `/test` route is properly
used for rendering.

If you've previously used [a workaround to encode
pathnames](#607 (comment)),
please remove it when upgrading to the latest version of `next-intl`.
  • Loading branch information
amannn authored Apr 3, 2024
1 parent 5018991 commit b6e66f4
Show file tree
Hide file tree
Showing 10 changed files with 160 additions and 26 deletions.
2 changes: 2 additions & 0 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ In this case, the localized slug can either be provided by the backend or genera

A good practice is to include the ID in the URL, allowing you to retrieve the article based on this information from the backend. The ID can be further used to implement [self-healing URLs](https://mikebifulco.com/posts/self-healing-urls-nextjs-seo), where a redirect is added if the `articleSlug` doesn't match.

If you localize the values for dynamic segments, you might want to turn off [alternate links](#alternate-links) and provide your own implementation that considers localized values for dynamic segments.

</details>

### Matcher config
Expand Down
12 changes: 12 additions & 0 deletions examples/example-app-router-playground/messages/ja.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Index": {
"title": "Home (ja)",
"description": "This is the home page (ja).",
"rich": "This is a <important>rich</important> text (ja).",
"globalDefaults": "<highlight>{globalString}</highlight> (ja)"
},
"Nested": {
"title": "ネステッド",
"description": "これはネストされたページです。"
}
}
4 changes: 3 additions & 1 deletion examples/example-app-router-playground/src/i18n.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {headers} from 'next/headers';
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import defaultMessages from '../messages/en.json';
import {locales} from './navigation';

export default getRequestConfig(async ({locale}) => {
Expand All @@ -9,7 +10,8 @@ export default getRequestConfig(async ({locale}) => {

const now = headers().get('x-now');
const timeZone = headers().get('x-time-zone') ?? 'Europe/Vienna';
const messages = (await import(`../messages/${locale}.json`)).default;
const localeMessages = (await import(`../messages/${locale}.json`)).default;
const messages = {...defaultMessages, ...localeMessages};

return {
now: now ? new Date(now) : undefined,
Expand Down
8 changes: 5 additions & 3 deletions examples/example-app-router-playground/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {

export const defaultLocale = 'en';

export const locales = ['en', 'de', 'es'] as const;
export const locales = ['en', 'de', 'es', 'ja'] as const;

export const localePrefix =
process.env.NEXT_PUBLIC_LOCALE_PREFIX === 'never' ? 'never' : 'as-needed';
Expand All @@ -17,13 +17,15 @@ export const pathnames = {
'/nested': {
en: '/nested',
de: '/verschachtelt',
es: '/anidada'
es: '/anidada',
ja: '/ネスト'
},
'/redirect': '/redirect',
'/news/[articleId]': {
en: '/news/[articleId]',
de: '/neuigkeiten/[articleId]',
es: '/noticias/[articleId]'
es: '/noticias/[articleId]',
ja: '/ニュース/[articleId]'
}
} satisfies Pathnames<typeof locales>;

Expand Down
47 changes: 43 additions & 4 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {test as it, expect, Page, BrowserContext} from '@playwright/test';

it.describe.configure({mode: 'parallel'});

const describe = it.describe;

async function assertLocaleCookieValue(
page: Page,
value: string,
Expand Down Expand Up @@ -59,7 +61,7 @@ it('redirects to a matched locale at the root for non-default locales', async ({

it('redirects to a matched locale for an invalid cased non-default locale', async ({
browser
}) => {
}) => {
const context = await browser.newContext({locale: 'de'});
const page = await context.newPage();

Expand All @@ -70,7 +72,7 @@ it('redirects to a matched locale for an invalid cased non-default locale', asyn

it('redirects to a matched locale for an invalid cased non-default locale in a nested path', async ({
browser
}) => {
}) => {
const context = await browser.newContext({locale: 'de'});
const page = await context.newPage();

Expand All @@ -81,7 +83,7 @@ it('redirects to a matched locale for an invalid cased non-default locale in a n

it('redirects to a matched locale for an invalid cased default locale', async ({
browser
}) => {
}) => {
const context = await browser.newContext({locale: 'en'});
const page = await context.newPage();

Expand All @@ -92,7 +94,7 @@ it('redirects to a matched locale for an invalid cased default locale', async ({

it('redirects to a matched locale for an invalid cased default locale in a nested path', async ({
browser
}) => {
}) => {
const context = await browser.newContext({locale: 'en'});
const page = await context.newPage();

Expand Down Expand Up @@ -419,6 +421,9 @@ it('can use `usePathname` to get internal pathnames', async ({page}) => {

await page.goto('/en/nested');
await expect(page.getByTestId('UnlocalizedPathname')).toHaveText('/nested');

await page.goto('/ja//ネスト');
await expect(page.getByTestId('UnlocalizedPathname')).toHaveText('/nested');
});

it('returns the correct value from `usePathname` in the initial render', async ({
Expand Down Expand Up @@ -536,6 +541,7 @@ it('sets alternate links', async ({request}) => {
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/es>; rel="alternate"; hreflang="es"',
'<http://localhost:3000/ja>; rel="alternate"; hreflang="ja"',
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
]);
}
Expand All @@ -545,6 +551,7 @@ it('sets alternate links', async ({request}) => {
'<http://localhost:3000/nested>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/verschachtelt>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/es/anidada>; rel="alternate"; hreflang="es"',
'<http://localhost:3000/ja/%E3%83%8D%E3%82%B9%E3%83%88>; rel="alternate"; hreflang="ja"',
'<http://localhost:3000/nested>; rel="alternate"; hreflang="x-default"'
]);
}
Expand Down Expand Up @@ -655,3 +662,35 @@ it('can use async APIs in async components', async ({page}) => {
.getByTestId('AsyncComponentWithoutNamespaceAndLocale')
.getByText('AsyncComponent');
});

describe('handling of foreign characters', () => {
it('handles encoded search params', async ({page}) => {
await page.goto('/ja?param=テスト');
await expect(page).toHaveURL('/ja?param=テスト');
await expect(page.getByTestId('SearchParams')).toHaveText(
'{ "param": "テスト" }'
);
});

it('handles decoded search params', async ({page}) => {
await page.goto('/ja?param=%E3%83%86%E3%82%B9%E3%83%88');
await expect(page).toHaveURL('/ja?param=テスト');
await expect(page.getByTestId('SearchParams')).toHaveText(
'{ "param": "テスト" }'
);
});

it('handles encoded localized pathnames', async ({page}) => {
await page.goto('/ja/ネスト');
await expect(page).toHaveURL('/ja/ネスト');
page.getByRole('heading', {name: 'ネステッド'});
await expect(page.getByTestId('UnlocalizedPathname')).toHaveText('/nested');
});

it('handles decoded localized pathnames', async ({page}) => {
await page.goto('/ja/%E3%83%8D%E3%82%B9%E3%83%88');
await expect(page).toHaveURL('/ja/ネスト');
page.getByRole('heading', {name: 'ネステッド'});
await expect(page.getByTestId('UnlocalizedPathname')).toHaveText('/nested');
});
});
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "5.9 KB"
"limit": "5.95 KB"
}
]
}
11 changes: 7 additions & 4 deletions packages/next-intl/src/middleware/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@ export default function createMiddleware<Locales extends AllLocales>(
const configWithDefaults = receiveConfig(config);

return function middleware(request: NextRequest) {
// Resolve potential foreign symbols (e.g. /ja/%E7%B4%84 → /ja/約))
const nextPathname = decodeURI(request.nextUrl.pathname);

const {domain, locale} = resolveLocale(
configWithDefaults,
request.headers,
request.cookies,
request.nextUrl.pathname
nextPathname
);

const hasOutdatedCookie =
Expand Down Expand Up @@ -130,20 +133,20 @@ export default function createMiddleware<Locales extends AllLocales>(
}

const normalizedPathname = getNormalizedPathname(
request.nextUrl.pathname,
nextPathname,
configWithDefaults.locales
);

const pathLocale = getPathnameLocale(
request.nextUrl.pathname,
nextPathname,
configWithDefaults.locales
);
const hasLocalePrefix = pathLocale != null;

let response;
let internalTemplateName: string | undefined;

let pathname = request.nextUrl.pathname;
let pathname = nextPathname;
if (configWithDefaults.pathnames) {
let resolvedTemplateLocale: Locales[number] | undefined;
[resolvedTemplateLocale, internalTemplateName] = getInternalTemplate(
Expand Down
6 changes: 5 additions & 1 deletion packages/next-intl/src/navigation/shared/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ export function getRoute<Locales extends AllLocales>({
pathname: string;
pathnames: Pathnames<Locales>;
}) {
const unlocalizedPathname = unlocalizePathname(pathname, locale);
const unlocalizedPathname = unlocalizePathname(
// Potentially handle foreign symbols
decodeURI(pathname),
locale
);

let template = Object.entries(pathnames).find(([, routePath]) => {
const routePathname =
Expand Down
Loading

0 comments on commit b6e66f4

Please sign in to comment.