Skip to content

Commit 07e93ed

Browse files
committed
feat: Support partial pathnames
1 parent 021e874 commit 07e93ed

File tree

11 files changed

+221
-99
lines changed

11 files changed

+221
-99
lines changed

Diff for: examples/example-app-router/src/i18n/routing.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const routing = defineRouting({
77
pathnames: {
88
'/': '/',
99
'/pathnames': {
10-
en: '/pathnames',
10+
en: null,
1111
de: '/pfadnamen'
1212
}
1313
}

Diff for: 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'",

Diff for: 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 and null 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+
ja: null
173+
}
174+
};
175+
176+
expect(
177+
getAlternateLinksHeaderValue({
178+
routing,
179+
request: getMockRequest('https://example.com/about'),
180+
resolvedLocale: 'en',
181+
localizedPathnames: pathnames['/about'],
182+
internalTemplateName: '/about'
183+
}).split(', ')
184+
).toEqual([
185+
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="en"`,
186+
`<https://example.com${basePath}/de/ueber>; rel="alternate"; hreflang="de"`,
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"`,

Diff for: packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx

+65-47
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,13 @@ export default function getAlternateLinksHeaderValue<
7274

7375
function getLocalizedPathname(pathname: string, locale: AppLocales[number]) {
7476
if (localizedPathnames && typeof localizedPathnames === 'object') {
77+
if (localizedPathnames[locale] === null) return;
78+
const sourceTemplate = localizedPathnames[resolvedLocale];
79+
7580
return formatTemplatePathname(
7681
pathname,
77-
localizedPathnames[resolvedLocale],
78-
localizedPathnames[locale]
82+
sourceTemplate ?? internalTemplateName ?? pathname,
83+
localizedPathnames[locale] ?? internalTemplateName ?? pathname
7984
);
8085
} else {
8186
return pathname;
@@ -86,70 +91,83 @@ export default function getAlternateLinksHeaderValue<
8691
routing.locales as AppLocales,
8792
routing.localePrefix,
8893
false
89-
).flatMap(([locale, prefix]) => {
90-
function prefixPathname(pathname: string) {
91-
if (pathname === '/') {
92-
return prefix;
93-
} else {
94-
return prefix + pathname;
94+
)
95+
.flatMap(([locale, prefix]) => {
96+
function prefixPathname(pathname: string) {
97+
if (pathname === '/') {
98+
return prefix;
99+
} else {
100+
return prefix + pathname;
101+
}
95102
}
96-
}
97103

98-
let url: URL;
104+
let url: URL;
99105

100-
if (routing.domains) {
101-
const domainConfigs = routing.domains.filter((cur) =>
102-
isLocaleSupportedOnDomain(locale, cur)
103-
);
106+
if (routing.domains) {
107+
const domainConfigs = routing.domains.filter((cur) =>
108+
isLocaleSupportedOnDomain(locale, cur)
109+
);
104110

105-
return domainConfigs.map((domainConfig) => {
106-
url = new URL(normalizedUrl);
107-
url.port = '';
108-
url.host = domainConfig.domain;
111+
return domainConfigs.map((domainConfig) => {
112+
const pathname = getLocalizedPathname(normalizedUrl.pathname, locale);
113+
if (!pathname) return undefined;
109114

110-
// Important: Use `normalizedUrl` here, as `url` potentially uses
111-
// a `basePath` that automatically gets applied to the pathname
112-
url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale);
115+
url = new URL(normalizedUrl);
116+
url.port = '';
117+
url.host = domainConfig.domain;
118+
119+
// Important: Use `normalizedUrl` here, as `url` potentially uses
120+
// a `basePath` that automatically gets applied to the pathname
121+
url.pathname = pathname;
122+
123+
if (
124+
locale !== domainConfig.defaultLocale ||
125+
routing.localePrefix.mode === 'always'
126+
) {
127+
url.pathname = prefixPathname(url.pathname);
128+
}
129+
130+
return getAlternateEntry(url, locale);
131+
});
132+
} else {
133+
let pathname: string;
134+
if (localizedPathnames && typeof localizedPathnames === 'object') {
135+
const candidate = getLocalizedPathname(
136+
normalizedUrl.pathname,
137+
locale
138+
);
139+
if (!candidate) return undefined;
140+
pathname = candidate;
141+
} else {
142+
pathname = normalizedUrl.pathname;
143+
}
113144

114145
if (
115-
locale !== domainConfig.defaultLocale ||
146+
locale !== routing.defaultLocale ||
116147
routing.localePrefix.mode === 'always'
117148
) {
118-
url.pathname = prefixPathname(url.pathname);
149+
pathname = prefixPathname(pathname);
119150
}
120-
121-
return getAlternateEntry(url, locale);
122-
});
123-
} else {
124-
let pathname: string;
125-
if (localizedPathnames && typeof localizedPathnames === 'object') {
126-
pathname = getLocalizedPathname(normalizedUrl.pathname, locale);
127-
} else {
128-
pathname = normalizedUrl.pathname;
151+
url = new URL(pathname, normalizedUrl);
129152
}
130153

131-
if (
132-
locale !== routing.defaultLocale ||
133-
routing.localePrefix.mode === 'always'
134-
) {
135-
pathname = prefixPathname(pathname);
136-
}
137-
url = new URL(pathname, normalizedUrl);
138-
}
139-
140-
return getAlternateEntry(url, locale);
141-
});
154+
return getAlternateEntry(url, locale);
155+
})
156+
.filter((link) => link != null);
142157

143158
// Add x-default entry
144159
const shouldAddXDefault =
145160
// For domain-based routing there is no reasonable x-default
146161
!routing.domains || routing.domains.length === 0;
147162
if (shouldAddXDefault) {
148-
const url = new URL(
149-
getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale),
150-
normalizedUrl
163+
const localizedPathname = getLocalizedPathname(
164+
normalizedUrl.pathname,
165+
routing.defaultLocale
151166
);
152-
links.push(getAlternateEntry(url, 'x-default'));
167+
if (localizedPathname) {
168+
const url = new URL(localizedPathname, normalizedUrl);
169+
links.push(getAlternateEntry(url, 'x-default'));
170+
}
153171
}
154172

155173
return links.join(', ');

0 commit comments

Comments
 (0)