Skip to content

Commit

Permalink
feat: Return alternate language links as headers from middleware (#195)
Browse files Browse the repository at this point in the history
  • Loading branch information
amannn authored Feb 19, 2023
1 parent e0bd177 commit f55424a
Show file tree
Hide file tree
Showing 13 changed files with 173 additions and 18 deletions.
42 changes: 42 additions & 0 deletions packages/example-next-13-advanced/messages/es.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"Index": {
"title": "Inicio",
"description": "Esta es la página de inicio.",
"rich": "Este es un texto <important>importante</important>.",
"globalDefaults": "<highlight>{globalString}</highlight>"
},
"LocaleLayout": {
"title": "Ejemplo next-intl",
"description": "Este es un ejemplo de cómo usar next-intl en el directorio app."
},
"Client": {
"title": "Cliente",
"description": "Esta página se hidrata en el lado del cliente."
},
"Nested": {
"title": "Anidado",
"description": "Esta es una página anidada."
},
"Navigation": {
"home": "Inicio",
"client": "Página del cliente",
"nested": "Página anidada"
},
"NotFound": {
"title": "Esta página no se encontró (404)"
},
"LocaleSwitcher": {
"switchLocale": "Cambiar a {locale, select, de {Alemán} en {Inglés} other {Desconocido}}"
},
"Counter": {
"count": "Conteo actual:",
"increment": "Incrementar"
},
"ClientCounter": {
"count": "Conteo actual: {count}",
"increment": "Incrementar"
},
"ApiRoute": {
"hello": "¡Hola {name}!"
}
}
2 changes: 1 addition & 1 deletion packages/example-next-13-advanced/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import createIntlMiddleware from 'next-intl/middleware';

export default createIntlMiddleware({
locales: ['en', 'de'],
locales: ['en', 'de', 'es'],
defaultLocale: 'en',
domains: [
{
Expand Down
26 changes: 26 additions & 0 deletions packages/example-next-13-advanced/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,32 @@ it('keeps search params for redirects', async ({browser}) => {
);
});

it('sets alternate links', async ({request}) => {
for (const pathname of ['/', '/en', '/de']) {
expect((await request.get(pathname)).headers().link).toBe(
[
'<http://localhost:3000/en>; rel="alternate"; hreflang="en"',
'<http://example.de:3000/>; rel="alternate"; hreflang="de"',
'<http://de.example.com:3000/>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/es>; rel="alternate"; hreflang="es"',
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
].join(', ')
);
}

for (const pathname of ['/nested', '/en/nested', '/de/nested']) {
expect((await request.get(pathname)).headers().link).toBe(
[
'<http://localhost:3000/en/nested>; rel="alternate"; hreflang="en"',
'<http://example.de:3000/nested>; rel="alternate"; hreflang="de"',
'<http://de.example.com:3000/nested>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/es/nested>; rel="alternate"; hreflang="es"',
'<http://localhost:3000/nested>; rel="alternate"; hreflang="x-default"'
].join(', ')
);
}
});

it.skip('can localize route handlers', async ({request}) => {
// Default
{
Expand Down
1 change: 0 additions & 1 deletion packages/next-intl/client.js

This file was deleted.

4 changes: 1 addition & 3 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,14 @@
"./server": "./dist/src/server/index.js",
"./client": "./dist/src/client/index.js",
"./config": "./config.js",
"./middleware": "./dist/src/middleware.js",
"./middleware": "./dist/src/middleware/index.js",
"./withNextIntl": "./withNextIntl.js",
"./plugin": "./plugin.js"
},
"files": [
"dist",
"src",
"server.js",
"server.d.ts",
"client.js",
"client.d.ts",
"config.js",
"middleware.d.ts",
Expand Down
1 change: 0 additions & 1 deletion packages/next-intl/server.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ type NextIntlMiddlewareConfig = {

/** Configure a list of domains where the `defaultLocale` is changed (e.g. `es.example.com/about`, `example.fr/about`). Note that the `x-forwarded-host` or alternatively the `host` header will be used to determine the requested domain. */
domains?: Array<{domain: string; defaultLocale: string}>;

/** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */
alternateLinks?: boolean;
};

export default NextIntlMiddlewareConfig;
7 changes: 7 additions & 0 deletions packages/next-intl/src/middleware/getHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function getHost(requestHeaders: Headers) {
return (
requestHeaders.get('x-forwarded-host') ??
requestHeaders.get('host') ??
undefined
);
}
1 change: 1 addition & 0 deletions packages/next-intl/src/middleware/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {default} from './middleware';
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {NextRequest, NextResponse} from 'next/server';
import NextIntlMiddlewareConfig from './server/NextIntlMiddlewareConfig';
import resolveLocale from './server/resolveLocale';
import {COOKIE_LOCALE_NAME, HEADER_LOCALE_NAME} from './shared/constants';
import {COOKIE_LOCALE_NAME, HEADER_LOCALE_NAME} from '../shared/constants';
import NextIntlMiddlewareConfig from './NextIntlMiddlewareConfig';
import resolveLocale from './resolveLocale';
import setAlternateLinksHeader from './setAlternateLinksHeader';

const ROOT_URL = '/';

export default function createIntlMiddleware(config: NextIntlMiddlewareConfig) {
return function middleware(request: NextRequest) {
// Ideally we could use the `headers()` and `cookies()` API here
// as well, but they are currently not available in middleware.
const {domain, locale} = resolveLocale(
config,
request.headers,
Expand Down Expand Up @@ -95,6 +94,10 @@ export default function createIntlMiddleware(config: NextIntlMiddlewareConfig) {
response.cookies.set(COOKIE_LOCALE_NAME, locale);
}

if ((config.alternateLinks ?? true) && config.locales.length > 1) {
setAlternateLinksHeader(config, request, response);
}

return response;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import Negotiator from 'negotiator';
import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies';
import {COOKIE_LOCALE_NAME} from '../shared/constants';
import NextIntlMiddlewareConfig from './NextIntlMiddlewareConfig';
import getHost from './getHost';

function findLocaleDomain(
requestHeaders: Headers,
i18n: NextIntlMiddlewareConfig
) {
let host =
requestHeaders.get('x-forwarded-host') ??
requestHeaders.get('host') ??
undefined;

let host = getHost(requestHeaders);
// Remove port
host = host?.replace(/:\d+$/, '');

Expand Down
74 changes: 74 additions & 0 deletions packages/next-intl/src/middleware/setAlternateLinksHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {NextRequest, NextResponse} from 'next/server';
import NextIntlMiddlewareConfig from './NextIntlMiddlewareConfig';

function getUnprefixedPathname(
config: NextIntlMiddlewareConfig,
request: NextRequest
) {
const url = new URL(request.url);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}

url.pathname = url.pathname.replace(
new RegExp(`^/(${config.locales.join('|')})/`),
'/'
);

// Remove trailing slash
if (url.pathname !== '/') {
url.pathname = url.pathname.slice(0, -1);
}

return url.toString();
}

function getAlternateEntry(url: string, locale: string) {
return `<${url}>; rel="alternate"; hreflang="${locale}"`;
}

/**
* See https://developers.google.com/search/docs/specialty/international/localized-versions
*/
export default function setAlternateLinksHeader(
config: NextIntlMiddlewareConfig,
request: NextRequest,
response: NextResponse
) {
const unprefixedPathname = getUnprefixedPathname(config, request);

const links = config.locales.flatMap((locale) => {
function localizePathname(url: URL) {
if (url.pathname === '/') {
url.pathname = `/${locale}`;
} else {
url.pathname = `/${locale}${url.pathname}`;
}
return url;
}

let url;

const domainConfigs =
config.domains?.filter((cur) => cur.defaultLocale === locale) || [];

if (domainConfigs.length > 1) {
// Prio 1: Configured domain(s)
return domainConfigs.map((domainConfig) => {
url = new URL(unprefixedPathname);
url.host = domainConfig.domain;
return getAlternateEntry(url.toString(), locale);
});
} else {
// Prio 2: Prefixed route
url = new URL(unprefixedPathname);
localizePathname(url);
}

return getAlternateEntry(url.toString(), locale);
});

links.push(getAlternateEntry(unprefixedPathname, 'x-default'));

response.headers.set('Link', links.join(', '));
}
10 changes: 8 additions & 2 deletions packages/website/pages/docs/next-13/server-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ module.exports = withNextIntl({
```tsx
import createIntlMiddleware from 'next-intl/middleware';

// The middleware matches a locale for the user request
// and handles redirects and rewrites accordingly.
export default createIntlMiddleware({
// A list of all locales that are supported
locales: ['en', 'de'],
Expand All @@ -98,14 +100,18 @@ export default createIntlMiddleware({
domain: 'example.de',
defaultLocale: 'de'
}
]
],

// Sets the `Link` response header to notify search engines about
// links to the content in other languages (defaults to `true`).
alternateLinks: true
});

export const config = {
// Skip all paths that aren't pages that you'd like to internationalize.
// If you use the `public` folder, make sure your static assets are ignored
// (e.g. by moving them to a shared folder that is referenced here).
matcher: ['/((?!api|_next|favicon.ico).*)']
matcher: ['/((?!api|_next|favicon.ico|assets).*)']
};
```

Expand Down

1 comment on commit f55424a

@vercel
Copy link

@vercel vercel bot commented on f55424a Feb 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-intl – ./

next-intl-amann.vercel.app
next-intl-docs.vercel.app
next-intl-git-feat-next-13-rsc-amann.vercel.app

Please sign in to comment.