Skip to content

Commit

Permalink
feat: Automatically prefer localized pathnames that are more specific (
Browse files Browse the repository at this point in the history
  • Loading branch information
fkapsahili authored Apr 26, 2024
1 parent 9c54d62 commit 88a9b7a
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 14 deletions.
15 changes: 7 additions & 8 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -321,20 +321,19 @@ export default createMiddleware({
de: '/ueber-uns'
},

// Pathnames that overlap with dynamic segments should
// be listed first in the object, as the middleware will
// pick the first matching entry.
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell'
},

// Dynamic params are supported via square brackets
'/news/[articleSlug]-[articleId]': {
en: '/news/[articleSlug]-[articleId]',
de: '/neuigkeiten/[articleSlug]-[articleId]'
},

// Static pathnames that overlap with dynamic segments
// will be prioritized over the dynamic segment
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell'
},

// Also (optional) catch-all segments are supported
'/categories/[...slug]': {
en: '/categories/[...slug]',
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "News-Artikel #{articleId}"
},
"JustIn": {
"title": "Gerade eingetroffen"
},
"NotFound": {
"title": "Diese Seite wurde nicht gefunden (404)"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "News article #{articleId}"
},
"JustIn": {
"title": "Just in"
},
"NotFound": {
"title": "This page was not found (404)"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "Noticias #{articleId}"
},
"JustIn": {
"title": "Recién llegado"
},
"NotFound": {
"title": "Esta página no se encontró (404)"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {useTranslations} from 'next-intl';

export default function NewsArticle() {
const t = useTranslations('JustIn');
return <h1>{t('title')}</h1>;
}
6 changes: 6 additions & 0 deletions examples/example-app-router-playground/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const pathnames = {
de: '/neuigkeiten/[articleId]',
es: '/noticias/[articleId]',
ja: '/ニュース/[articleId]'
},
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell',
es: '/noticias/justo-en',
ja: '/ニュース/現在'
}
} satisfies Pathnames<typeof locales>;

Expand Down
18 changes: 18 additions & 0 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ it('redirects unprefixed paths for non-default locales', async ({browser}) => {
page.getByRole('heading', {name: 'Verschachtelt'});
});

it('prioritizes static routes over dynamic routes for the default locale', async ({
page
}) => {
await page.goto('/news/just-in');
await expect(page).toHaveURL('/news/just-in');
await expect(page.getByRole('heading', {name: 'Just In'})).toBeVisible();
});

it('prioritizes static routes over dynamic routes for non-default locales', async ({
page
}) => {
await page.goto('/de/neuigkeiten/aktuell');
await expect(page).toHaveURL('/de/neuigkeiten/aktuell');
await expect(
page.getByRole('heading', {name: 'Gerade eingetroffen'})
).toBeVisible();
});

it('sets the `path` for the cookie', async ({page}) => {
await page.goto('/de/client');

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "6 KB"
"limit": "6.19 KB"
}
]
}
66 changes: 63 additions & 3 deletions packages/next-intl/src/middleware/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,65 @@ export function getFirstPathnameSegment(pathname: string) {
return pathname.split('/')[1];
}

function isOptionalCatchAllSegment(pathname: string) {
return pathname.includes('[[...');
}

function isCatchAllSegment(pathname: string) {
return pathname.includes('[...');
}

function isDynamicSegment(pathname: string) {
return pathname.includes('[');
}

export function comparePathnamePairs(a: string, b: string): number {
const pathA = a.split('/');
const pathB = b.split('/');

const maxLength = Math.max(pathA.length, pathB.length);
for (let i = 0; i < maxLength; i++) {
const segmentA = pathA[i];
const segmentB = pathB[i];

// If one of the paths ends, prioritize the shorter path
if (!segmentA && segmentB) return -1;
if (segmentA && !segmentB) return 1;

// Prioritize static segments over dynamic segments
if (!isDynamicSegment(segmentA) && isDynamicSegment(segmentB)) return -1;
if (isDynamicSegment(segmentA) && !isDynamicSegment(segmentB)) return 1;

// Prioritize non-catch-all segments over catch-all segments
if (!isCatchAllSegment(segmentA) && isCatchAllSegment(segmentB)) return -1;
if (isCatchAllSegment(segmentA) && !isCatchAllSegment(segmentB)) return 1;

// Prioritize non-optional catch-all segments over optional catch-all segments
if (
!isOptionalCatchAllSegment(segmentA) &&
isOptionalCatchAllSegment(segmentB)
) {
return -1;
}
if (
isOptionalCatchAllSegment(segmentA) &&
!isOptionalCatchAllSegment(segmentB)
) {
return 1;
}

if (segmentA === segmentB) continue;
}

// Both pathnames are completely static
return 0;
}

export function getSortedPathnames(pathnames: Array<string>) {
const sortedPathnames = pathnames.sort(comparePathnamePairs);
return sortedPathnames;
}

export function getInternalTemplate<
Locales extends AllLocales,
Pathnames extends NonNullable<
Expand All @@ -19,10 +78,11 @@ export function getInternalTemplate<
pathname: string,
locale: Locales[number]
): [Locales[number] | undefined, keyof Pathnames | undefined] {
const sortedPathnames = getSortedPathnames(Object.keys(pathnames));

// Try to find a localized pathname that matches
for (const [internalPathname, localizedPathnamesOrPathname] of Object.entries(
pathnames
)) {
for (const internalPathname of sortedPathnames) {
const localizedPathnamesOrPathname = pathnames[internalPathname];
if (typeof localizedPathnamesOrPathname === 'string') {
const localizedPathname = localizedPathnamesOrPathname;
if (matchesPathname(localizedPathname, pathname)) {
Expand Down
Loading

0 comments on commit 88a9b7a

Please sign in to comment.