Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support trailingSlash: true in Next.js config #1188

Merged
merged 6 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ export const config = {
};
```

### Trailing slash

If you have [`trailingSlash`](https://nextjs.org/docs/app/api-reference/next-config-js/trailingSlash) set to `true` in your Next.js config, this setting will be taken into account when the middleware generates pathnames, e.g. for redirects.

Note that if you're using [localized pathnames](/docs/routing#pathnames), your internal and external pathnames can be defined either with or without a trailing slash as they will be normalized internally.

## Composing other middlewares

By calling `createMiddleware`, you'll receive a function of the following type:
Expand Down
1 change: 1 addition & 0 deletions examples/example-app-router-playground/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./src/i18n.tsx');
export default withNextIntl({
trailingSlash: process.env.TRAILING_SLASH === 'true',
experimental: {
staleTimes: {
// Next.js 14.2 broke `locale-prefix-never.spec.ts`.
Expand Down
7 changes: 4 additions & 3 deletions examples/example-app-router-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
"scripts": {
"dev": "next dev",
"lint": "eslint src && tsc",
"test": "pnpm run test:playwright && pnpm run test:playwright:locale-prefix-never && pnpm run test:jest",
"test:playwright": "playwright test",
"test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && NEXT_PUBLIC_LOCALE_PREFIX=never playwright test",
"test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest",
"test:playwright:main": "TEST_MATCH=main.spec.ts playwright test",
"test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test",
"test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test",
"test:jest": "jest",
"build": "next build",
"start": "next start"
Expand Down
5 changes: 1 addition & 4 deletions examples/example-app-router-playground/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ const PORT = process.env.CI ? 3004 : 3000;

const config: PlaywrightTestConfig = {
retries: process.env.CI ? 1 : 0,
testMatch:
process.env.NEXT_PUBLIC_LOCALE_PREFIX === 'never'
? 'locale-prefix-never.spec.ts'
: 'main.spec.ts',
testMatch: process.env.TEST_MATCH || 'main.spec.ts',
testDir: './tests',
projects: [
{
Expand Down
13 changes: 13 additions & 0 deletions examples/example-app-router-playground/tests/getAlternateLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {APIResponse} from '@playwright/test';

export default async function getAlternateLinks(response: APIResponse) {
return (
response
.headers()
.link.split(', ')
// On CI, Playwright uses a different host somehow
.map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost'))
// Normalize ports
.map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000'))
);
}
11 changes: 2 additions & 9 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {test as it, expect, Page, BrowserContext} from '@playwright/test';
import getAlternateLinks from './getAlternateLinks';

const describe = it.describe;

Expand Down Expand Up @@ -541,15 +542,7 @@ it('keeps search params for redirects', async ({browser}) => {

it('sets alternate links', async ({request}) => {
async function getLinks(pathname: string) {
return (
(await request.get(pathname))
.headers()
.link.split(', ')
// On CI, Playwright uses a different host somehow
.map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost'))
// Normalize ports
.map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000'))
);
return getAlternateLinks(await request.get(pathname));
}

for (const pathname of ['/', '/en', '/de']) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {test as it, expect} from '@playwright/test';
import getAlternateLinks from './getAlternateLinks';

it('redirects to a locale prefix correctly', async ({request}) => {
const response = await request.get('/', {
maxRedirects: 0,
headers: {
'Accept-Language': 'de'
}
});
expect(response.status()).toBe(307);
expect(response.headers().location).toBe('/de/');
});

it('redirects a localized pathname correctly', async ({request}) => {
const response = await request.get('/de/nested/', {maxRedirects: 0});
expect(response.status()).toBe(307);
expect(response.headers().location).toBe('/de/verschachtelt/');
});

it('redirects a page with a missing trailing slash', async ({request}) => {
expect((await request.get('/de', {maxRedirects: 0})).headers().location).toBe(
'/de/'
);
expect(
(await request.get('/de/client', {maxRedirects: 0})).headers().location
).toBe('/de/client/');
});

it('renders page content', async ({page}) => {
await page.goto('/');
await page.getByRole('heading', {name: 'Home'}).waitFor();

await page.goto('/de/');
await page.getByRole('heading', {name: 'Start'}).waitFor();
});

it('renders links correctly', async ({page}) => {
await page.goto('/de/');
await expect(page.getByRole('link', {name: 'Client-Seite'})).toHaveAttribute(
'href',
'/de/client/'
);
await expect(
page.getByRole('link', {name: 'Verschachtelte Seite'})
).toHaveAttribute('href', '/de/verschachtelt/');
});

it('returns alternate links correctly', async ({request}) => {
async function getLinks(pathname: string) {
return getAlternateLinks(await request.get(pathname));
}

for (const pathname of ['/', '/en', '/de']) {
expect(await getLinks(pathname)).toEqual([
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/spain/>; rel="alternate"; hreflang="es"',
'<http://localhost:3000/ja/>; rel="alternate"; hreflang="ja"',
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
]);
}

for (const pathname of ['/nested', '/en/nested', '/de/nested']) {
expect(await getLinks(pathname)).toEqual([
'<http://localhost:3000/nested/>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/verschachtelt/>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/spain/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"'
]);
}
});

it('can handle dynamic params', async ({page}) => {
await page.goto('/news/3');
await page.getByRole('heading', {name: 'News article #3'}).waitFor();

await page.goto('/de/neuigkeiten/3');
await page.getByRole('heading', {name: 'News-Artikel #3'}).waitFor();
});
6 changes: 3 additions & 3 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@
},
{
"path": "dist/production/navigation.react-client.js",
"limit": "3.355 KB"
"limit": "3.465 KB"
},
{
"path": "dist/production/navigation.react-server.js",
"limit": "17.975 KB"
"limit": "18.075 KB"
},
{
"path": "dist/production/server.react-client.js",
Expand All @@ -144,7 +144,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "6.42 KB"
"limit": "6.485 KB"
},
{
"path": "dist/production/routing.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment edge-runtime

import {NextRequest} from 'next/server';
import {it, expect, describe} from 'vitest';
import {it, expect, describe, beforeEach, afterEach} from 'vitest';
import {Pathnames} from '../routing';
import {receiveConfig} from './config';
import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue';
Expand Down Expand Up @@ -552,3 +552,81 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
});
}
);

describe('trailingSlash: true', () => {
beforeEach(() => {
process.env._next_intl_trailing_slash = 'true';
});
afterEach(() => {
delete process.env._next_intl_trailing_slash;
});

it('adds a trailing slash to pathnames', () => {
const config = receiveConfig({
defaultLocale: 'en',
locales: ['en', 'es'],
localePrefix: 'as-needed'
});

expect(
getAlternateLinksHeaderValue({
config,
request: new NextRequest(new URL('https://example.com/about')),
resolvedLocale: 'en'
}).split(', ')
).toEqual([
`<https://example.com/about/>; rel="alternate"; hreflang="en"`,
`<https://example.com/es/about/>; rel="alternate"; hreflang="es"`,
`<https://example.com/about/>; rel="alternate"; hreflang="x-default"`
]);
});

describe('localized pathnames', () => {
const config = receiveConfig({
defaultLocale: 'en',
locales: ['en', 'es'],
localePrefix: 'as-needed'
});
const pathnames = {
'/': '/',
'/about': {
en: '/about',
es: '/acerca'
}
};

it('adds a trailing slash to nested pathnames when localized pathnames are used', () => {
['/about', '/about/'].forEach((pathname) => {
expect(
getAlternateLinksHeaderValue({
config,
request: new NextRequest(new URL('https://example.com' + pathname)),
resolvedLocale: 'en',
localizedPathnames: pathnames['/about']
}).split(', ')
).toEqual([
`<https://example.com/about/>; rel="alternate"; hreflang="en"`,
`<https://example.com/es/acerca/>; rel="alternate"; hreflang="es"`,
`<https://example.com/about/>; rel="alternate"; hreflang="x-default"`
]);
});
});

it('adds a trailing slash to the root pathname when localized pathnames are used', () => {
['', '/'].forEach((pathname) => {
expect(
getAlternateLinksHeaderValue({
config,
request: new NextRequest(new URL('https://example.com' + pathname)),
resolvedLocale: 'en',
localizedPathnames: pathnames['/']
}).split(', ')
).toEqual([
`<https://example.com/>; rel="alternate"; hreflang="en"`,
`<https://example.com/es/>; rel="alternate"; hreflang="es"`,
`<https://example.com/>; rel="alternate"; hreflang="x-default"`
]);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {NextRequest} from 'next/server';
import {Locales, Pathnames} from '../routing/types';
import {normalizeTrailingSlash} from '../shared/utils';
import {MiddlewareRoutingConfig} from './config';
import {
applyBasePath,
Expand Down Expand Up @@ -44,6 +45,8 @@ export default function getAlternateLinksHeaderValue<
);

function getAlternateEntry(url: URL, locale: string) {
url.pathname = normalizeTrailingSlash(url.pathname);

if (request.nextUrl.basePath) {
url = new URL(url);
url.pathname = applyBasePath(url.pathname, request.nextUrl.basePath);
Expand Down
Loading
Loading