Skip to content

Commit

Permalink
feat: Support trailingSlash: true in Next.js config (#1190)
Browse files Browse the repository at this point in the history
Closes #1184
Closes #668

---------

Co-authored-by: amannn <amannn@users.noreply.github.com>
  • Loading branch information
amannn and amannn authored Jul 11, 2024
1 parent 077a0ca commit cfbdee9
Show file tree
Hide file tree
Showing 16 changed files with 470 additions and 65 deletions.
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

0 comments on commit cfbdee9

Please sign in to comment.