Skip to content

Commit dea867b

Browse files
authored
feat: Support partial pathnames (#1743)
[`pathnames`](https://next-intl.dev/docs/routing#pathnames) can now be declared partially for convenience: ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['en-US', 'en-GB', 'de'], defaultLocale: 'en-US', pathnames: { '/': '/', '/about': { // ("/about" is used for en-US and en-UK) de: '/ueber-uns' } } }); ``` Resolves #990
1 parent 021e874 commit dea867b

File tree

13 files changed

+163
-70
lines changed

13 files changed

+163
-70
lines changed

docs/src/pages/docs/routing.mdx

+6-10
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ Since you typically want to define these routes only once internally, you can us
171171
import {defineRouting} from 'next-intl/routing';
172172

173173
export const routing = defineRouting({
174-
locales: ['en', 'de'],
175-
defaultLocale: 'en',
174+
locales: ['en-US', 'en-UK', 'de'],
175+
defaultLocale: 'en-US',
176176

177177
// The `pathnames` object holds pairs of internal and
178178
// external paths. Based on the locale, the external
@@ -183,29 +183,25 @@ export const routing = defineRouting({
183183
'/': '/',
184184
'/blog': '/blog',
185185

186-
// If locales use different paths, you can
187-
// specify each external path per locale
186+
// If some locales use different paths, you can
187+
// specify the relevant external pathnames
188188
'/about': {
189-
en: '/about',
190189
de: '/ueber-uns'
191190
},
192191

193192
// Dynamic params are supported via square brackets
194-
'/news/[articleSlug]-[articleId]': {
195-
en: '/news/[articleSlug]-[articleId]',
196-
de: '/neuigkeiten/[articleSlug]-[articleId]'
193+
'/news/[articleSlug]': {
194+
de: '/neuigkeiten/[articleSlug]'
197195
},
198196

199197
// Static pathnames that overlap with dynamic segments
200198
// will be prioritized over the dynamic segment
201199
'/news/just-in': {
202-
en: '/news/just-in',
203200
de: '/neuigkeiten/aktuell'
204201
},
205202

206203
// Also (optional) catch-all segments are supported
207204
'/categories/[...slug]': {
208-
en: '/categories/[...slug]',
209205
de: '/kategorien/[...slug]'
210206
}
211207
}

docs/src/pages/docs/routing/navigation.mdx

+2-2
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,8 @@ Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, th
262262
// When the user is on `/de/ueber-uns`, this will be `/about`
263263
const pathname = usePathname();
264264

265-
// When the user is on `/de/neuigkeiten/produktneuheit-94812`,
266-
// this will be `/news/[articleSlug]-[articleId]`
265+
// When the user is on `/de/neuigkeiten/produktneuheit`,
266+
// this will be `/news/[articleSlug]`
267267
const pathname = usePathname();
268268
```
269269

examples/example-app-router/src/i18n/routing.ts

-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export const routing = defineRouting({
77
pathnames: {
88
'/': '/',
99
'/pathnames': {
10-
en: '/pathnames',
1110
de: '/pfadnamen'
1211
}
1312
}

packages/next-intl/.size-limit.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ const config: SizeLimitConfig = [
2121
name: "import {createNavigation} from 'next-intl/navigation' (react-client)",
2222
path: 'dist/esm/production/navigation.react-client.js',
2323
import: '{createNavigation}',
24-
limit: '2.285 KB'
24+
limit: '2.305 KB'
2525
},
2626
{
2727
name: "import {createNavigation} from 'next-intl/navigation' (react-server)",
2828
path: 'dist/esm/production/navigation.react-server.js',
2929
import: '{createNavigation}',
30-
limit: '3.055 KB'
30+
limit: '3.075 KB'
3131
},
3232
{
3333
name: "import * from 'next-intl/server' (react-client)",
@@ -42,7 +42,7 @@ const config: SizeLimitConfig = [
4242
{
4343
name: "import * from 'next-intl/middleware'",
4444
path: 'dist/esm/production/middleware.js',
45-
limit: '9.355 KB'
45+
limit: '9.505 KB'
4646
},
4747
{
4848
name: "import * from 'next-intl/routing'",

packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx

+57-14
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
103103
routing,
104104
request: getMockRequest('https://example.com/'),
105105
resolvedLocale: 'en',
106-
localizedPathnames: pathnames['/']
106+
localizedPathnames: pathnames['/'],
107+
internalTemplateName: '/'
107108
}).split(', ')
108109
).toEqual([
109110
`<https://example.com${
@@ -120,7 +121,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
120121
routing,
121122
request: getMockRequest('https://example.com/about'),
122123
resolvedLocale: 'en',
123-
localizedPathnames: pathnames['/about']
124+
localizedPathnames: pathnames['/about'],
125+
internalTemplateName: '/about'
124126
}).split(', ')
125127
).toEqual([
126128
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="en"`,
@@ -133,7 +135,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
133135
routing,
134136
request: getMockRequest('https://example.com/de/ueber'),
135137
resolvedLocale: 'de',
136-
localizedPathnames: pathnames['/about']
138+
localizedPathnames: pathnames['/about'],
139+
internalTemplateName: '/about'
137140
}).split(', ')
138141
).toEqual([
139142
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="en"`,
@@ -146,7 +149,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
146149
routing,
147150
request: getMockRequest('https://example.com/users/2'),
148151
resolvedLocale: 'en',
149-
localizedPathnames: pathnames['/users/[userId]']
152+
localizedPathnames: pathnames['/users/[userId]'],
153+
internalTemplateName: '/users/[userId]'
150154
}).split(', ')
151155
).toEqual([
152156
`<https://example.com${basePath}/users/2>; rel="alternate"; hreflang="en"`,
@@ -155,6 +159,35 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
155159
]);
156160
});
157161

162+
it('works for partial pathnames with undefined entries', () => {
163+
const routing = receiveRoutingConfig({
164+
defaultLocale: 'en',
165+
locales: ['en', 'de', 'ja'],
166+
localePrefix: 'as-needed'
167+
});
168+
const pathnames = {
169+
'/': '/',
170+
'/about': {
171+
de: '/ueber'
172+
}
173+
};
174+
175+
expect(
176+
getAlternateLinksHeaderValue({
177+
routing,
178+
request: getMockRequest('https://example.com/about'),
179+
resolvedLocale: 'en',
180+
localizedPathnames: pathnames['/about'],
181+
internalTemplateName: '/about'
182+
}).split(', ')
183+
).toEqual([
184+
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="en"`,
185+
`<https://example.com${basePath}/de/ueber>; rel="alternate"; hreflang="de"`,
186+
`<https://example.com${basePath}/ja/about>; rel="alternate"; hreflang="ja"`,
187+
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="x-default"`
188+
]);
189+
});
190+
158191
it('works for prefixed routing (always)', () => {
159192
const routing = receiveRoutingConfig({
160193
defaultLocale: 'en',
@@ -404,25 +437,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
404437
routing,
405438
request: getMockRequest('https://en.example.com/about'),
406439
resolvedLocale: 'en',
407-
localizedPathnames: routing.pathnames!['/about']
440+
localizedPathnames: routing.pathnames!['/about'],
441+
internalTemplateName: '/about'
408442
}),
409443
getAlternateLinksHeaderValue({
410444
routing,
411445
request: getMockRequest('https://ca.example.com/about'),
412446
resolvedLocale: 'en',
413-
localizedPathnames: routing.pathnames!['/about']
447+
localizedPathnames: routing.pathnames!['/about'],
448+
internalTemplateName: '/about'
414449
}),
415450
getAlternateLinksHeaderValue({
416451
routing,
417452
request: getMockRequest('https://ca.example.com/fr/a-propos'),
418453
resolvedLocale: 'fr',
419-
localizedPathnames: routing.pathnames!['/about']
454+
localizedPathnames: routing.pathnames!['/about'],
455+
internalTemplateName: '/about'
420456
}),
421457
getAlternateLinksHeaderValue({
422458
routing,
423459
request: getMockRequest('https://fr.example.com/a-propos'),
424460
resolvedLocale: 'fr',
425-
localizedPathnames: routing.pathnames!['/about']
461+
localizedPathnames: routing.pathnames!['/about'],
462+
internalTemplateName: '/about'
426463
})
427464
]
428465
.map((links) => links.split(', '))
@@ -440,25 +477,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
440477
routing,
441478
request: getMockRequest('https://en.example.com/users/42'),
442479
resolvedLocale: 'en',
443-
localizedPathnames: routing.pathnames!['/users/[userId]']
480+
localizedPathnames: routing.pathnames!['/users/[userId]'],
481+
internalTemplateName: '/users/[userId]'
444482
}),
445483
getAlternateLinksHeaderValue({
446484
routing,
447485
request: getMockRequest('https://ca.example.com/users/42'),
448486
resolvedLocale: 'en',
449-
localizedPathnames: routing.pathnames!['/users/[userId]']
487+
localizedPathnames: routing.pathnames!['/users/[userId]'],
488+
internalTemplateName: '/users/[userId]'
450489
}),
451490
getAlternateLinksHeaderValue({
452491
routing,
453492
request: getMockRequest('https://ca.example.com/fr/utilisateurs/42'),
454493
resolvedLocale: 'fr',
455-
localizedPathnames: routing.pathnames!['/users/[userId]']
494+
localizedPathnames: routing.pathnames!['/users/[userId]'],
495+
internalTemplateName: '/users/[userId]'
456496
}),
457497
getAlternateLinksHeaderValue({
458498
routing,
459499
request: getMockRequest('https://fr.example.com/utilisateurs/42'),
460500
resolvedLocale: 'fr',
461-
localizedPathnames: routing.pathnames!['/users/[userId]']
501+
localizedPathnames: routing.pathnames!['/users/[userId]'],
502+
internalTemplateName: '/users/[userId]'
462503
})
463504
]
464505
.map((links) => links.split(', '))
@@ -601,7 +642,8 @@ describe('trailingSlash: true', () => {
601642
routing,
602643
request: new NextRequest(new URL('https://example.com' + pathname)),
603644
resolvedLocale: 'en',
604-
localizedPathnames: pathnames['/about']
645+
localizedPathnames: pathnames['/about'],
646+
internalTemplateName: '/about'
605647
}).split(', ')
606648
).toEqual([
607649
`<https://example.com/about/>; rel="alternate"; hreflang="en"`,
@@ -618,7 +660,8 @@ describe('trailingSlash: true', () => {
618660
routing,
619661
request: new NextRequest(new URL('https://example.com' + pathname)),
620662
resolvedLocale: 'en',
621-
localizedPathnames: pathnames['/']
663+
localizedPathnames: pathnames['/'],
664+
internalTemplateName: '/'
622665
}).split(', ')
623666
).toEqual([
624667
`<https://example.com/>; rel="alternate"; hreflang="en"`,

packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx

+13-6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default function getAlternateLinksHeaderValue<
2525
AppPathnames extends Pathnames<AppLocales> | undefined,
2626
AppDomains extends DomainsConfig<AppLocales> | undefined
2727
>({
28+
internalTemplateName,
2829
localizedPathnames,
2930
request,
3031
resolvedLocale,
@@ -42,6 +43,7 @@ export default function getAlternateLinksHeaderValue<
4243
request: NextRequest;
4344
resolvedLocale: AppLocales[number];
4445
localizedPathnames?: Pathnames<AppLocales>[string];
46+
internalTemplateName?: string;
4547
}) {
4648
const normalizedUrl = request.nextUrl.clone();
4749

@@ -72,10 +74,12 @@ export default function getAlternateLinksHeaderValue<
7274

7375
function getLocalizedPathname(pathname: string, locale: AppLocales[number]) {
7476
if (localizedPathnames && typeof localizedPathnames === 'object') {
77+
const sourceTemplate = localizedPathnames[resolvedLocale];
78+
7579
return formatTemplatePathname(
7680
pathname,
77-
localizedPathnames[resolvedLocale],
78-
localizedPathnames[locale]
81+
sourceTemplate ?? internalTemplateName!,
82+
localizedPathnames[locale] ?? internalTemplateName!
7983
);
8084
} else {
8185
return pathname;
@@ -145,11 +149,14 @@ export default function getAlternateLinksHeaderValue<
145149
// For domain-based routing there is no reasonable x-default
146150
!routing.domains || routing.domains.length === 0;
147151
if (shouldAddXDefault) {
148-
const url = new URL(
149-
getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale),
150-
normalizedUrl
152+
const localizedPathname = getLocalizedPathname(
153+
normalizedUrl.pathname,
154+
routing.defaultLocale
151155
);
152-
links.push(getAlternateEntry(url, 'x-default'));
156+
if (localizedPathname) {
157+
const url = new URL(localizedPathname, normalizedUrl);
158+
links.push(getAlternateEntry(url, 'x-default'));
159+
}
153160
}
154161

155162
return links.join(', ');

0 commit comments

Comments
 (0)