diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 90e625614..ddc634c3f 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -128,11 +128,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "3.235 KB" + "limit": "3.355 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "17.84 KB" + "limit": "17.975 KB" }, { "path": "dist/production/server.react-client.js", diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 829cefec7..2ac28b46c 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -6,6 +6,7 @@ import { } from '../routing/types'; import { getLocalePrefix, + getSortedPathnames, matchesPathname, prefixPathname, templateToRegex @@ -15,65 +16,6 @@ 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) { - const sortedPathnames = pathnames.sort(comparePathnamePairs); - return sortedPathnames; -} - export function getInternalTemplate< AppLocales extends Locales, AppPathnames extends Pathnames @@ -112,7 +54,7 @@ export function getInternalTemplate< } // Try to find an internal pathname that matches (this can be the case - // if all localized pathnames are different from the internal pathnames). + // if all localized pathnames are different from the internal pathnames) for (const internalPathname of Object.keys(pathnames)) { if (matchesPathname(internalPathname, pathname)) { return [undefined, internalPathname]; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index ad05b8626..ff452f253 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -149,9 +149,7 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. - return pathname - ? getRoute({pathname, locale, pathnames: config.pathnames}) - : pathname; + return pathname ? getRoute(locale, pathname, config.pathnames) : pathname; } function getPathname({ diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index ecbf98a26..d862ca16f 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,7 +1,7 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import {Locales, Pathnames} from '../../routing/types'; -import {matchesPathname} from '../../shared/utils'; +import {matchesPathname, getSortedPathnames} from '../../shared/utils'; import StrictParams from './StrictParams'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -152,28 +152,29 @@ export function compileLocalizedPathname({ } } -export function getRoute({ - locale, - pathname, - pathnames -}: { - locale: AppLocales[number]; - pathname: string; - pathnames: Pathnames; -}) { +export function getRoute( + locale: AppLocales[number], + pathname: string, + pathnames: Pathnames +): keyof Pathnames { + const sortedPathnames = getSortedPathnames(Object.keys(pathnames)); const decoded = decodeURI(pathname); - let template = Object.entries(pathnames).find(([, routePath]) => { - const routePathname = - typeof routePath !== 'string' ? routePath[locale] : routePath; - return matchesPathname(routePathname, decoded); - })?.[0]; - - if (!template) { - template = pathname; + for (const internalPathname of sortedPathnames) { + const localizedPathnamesOrPathname = pathnames[internalPathname]; + if (typeof localizedPathnamesOrPathname === 'string') { + const localizedPathname = localizedPathnamesOrPathname; + if (matchesPathname(localizedPathname, decoded)) { + return internalPathname; + } + } else { + if (matchesPathname(localizedPathnamesOrPathname[locale], decoded)) { + return internalPathname; + } + } } - return template as keyof Pathnames; + return pathname as keyof Pathnames; } export function getBasePath( diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index ba362dd40..589e60457 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -132,3 +132,61 @@ export function templateToRegex(template: string): RegExp { return new RegExp(`^${regexPattern}$`); } + +function isOptionalCatchAllSegment(pathname: string) { + return pathname.includes('[[...'); +} + +function isCatchAllSegment(pathname: string) { + return pathname.includes('[...'); +} + +function isDynamicSegment(pathname: string) { + return pathname.includes('['); +} + +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) { + return pathnames.sort(comparePathnamePairs); +} diff --git a/packages/next-intl/test/middleware/utils.test.tsx b/packages/next-intl/test/middleware/utils.test.tsx index 41b4c1aa6..a11ba349e 100644 --- a/packages/next-intl/test/middleware/utils.test.tsx +++ b/packages/next-intl/test/middleware/utils.test.tsx @@ -3,8 +3,7 @@ import { formatPathnameTemplate, getInternalTemplate, getNormalizedPathname, - getRouteParams, - getSortedPathnames + getRouteParams } from '../../src/middleware/utils'; describe('getNormalizedPathname', () => { @@ -169,61 +168,3 @@ describe('getInternalTemplate', () => { ]); }); }); - -describe('getSortedPathnames', () => { - it('works for static routes that include the root', () => { - expect(getSortedPathnames(['/', '/foo', '/test'])).toEqual([ - '/', - '/foo', - '/test' - ]); - }); - - it('should prioritize non-catch-all routes over catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[...slug]', '/categories/new']) - ).toEqual(['/categories/new', '/categories/[...slug]']); - }); - - it('should prioritize static routes over optional catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[[...slug]]', '/categories']) - ).toEqual(['/categories', '/categories/[[...slug]]']); - }); - - it('should prioritize more specific routes over dynamic routes', () => { - expect( - getSortedPathnames(['/categories/[slug]', '/categories/new']) - ).toEqual(['/categories/new', '/categories/[slug]']); - }); - - it('should prioritize dynamic routes over catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[...slug]', '/categories/[slug]']) - ).toEqual(['/categories/[slug]', '/categories/[...slug]']); - }); - - it('should prioritize more specific nested routes over dynamic routes', () => { - expect( - getSortedPathnames([ - '/articles/[category]/[articleSlug]', - '/articles/[category]/new' - ]) - ).toEqual([ - '/articles/[category]/new', - '/articles/[category]/[articleSlug]' - ]); - }); - - it('should prioritize more specific nested routes over catch-all routes', () => { - expect( - getSortedPathnames([ - '/articles/[category]/[...articleSlug]', - '/articles/[category]/new' - ]) - ).toEqual([ - '/articles/[category]/new', - '/articles/[category]/[...articleSlug]' - ]); - }); -}); diff --git a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx index 57806bbc2..41d46435d 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -11,20 +11,28 @@ import {Pathnames} from '../../../src/routing'; vi.mock('next/navigation'); -const locales = ['en', 'de'] as const; +const locales = ['en', 'de', 'ja'] as const; const pathnames = { '/': '/', '/about': { en: '/about', - de: '/ueber-uns' + de: '/ueber-uns', + ja: '/約' }, '/news/[articleSlug]-[articleId]': { en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' + de: '/neuigkeiten/[articleSlug]-[articleId]', + ja: '/ニュース/[articleSlug]-[articleId]' }, '/categories/[...parts]': { en: '/categories/[...parts]', - de: '/kategorien/[...parts]' + de: '/kategorien/[...parts]', + ja: '/カテゴリ/[...parts]' + }, + '/categories/new': { + en: '/categories/new', + de: '/kategorien/neu', + ja: '/カテゴリ/新規' }, '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' } satisfies Pathnames; @@ -83,6 +91,28 @@ describe("localePrefix: 'as-needed'", () => { screen.getByText('/news/[articleSlug]-[articleId]'); }); + it('returns the internal pathname for a more specific pathname that overlaps with another pathname', () => { + function Component() { + const pathname = usePathname(); + return <>{pathname}; + } + + vi.mocked(useNextPathname).mockImplementation(() => '/en/categories/new'); + render(); + screen.getByText('/categories/new'); + }); + + it('returns an encoded pathname correctly', () => { + function Component() { + const pathname = usePathname(); + return <>{pathname}; + } + vi.mocked(useParams).mockImplementation(() => ({locale: 'ja'})); + vi.mocked(useNextPathname).mockImplementation(() => '/ja/%E7%B4%84'); + render(); + screen.getByText('/about'); + }); + it('returns the internal pathname a non-default locale', () => { vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx index 284ba65a3..0d5b2e7bc 100644 --- a/packages/next-intl/test/shared/utils.test.tsx +++ b/packages/next-intl/test/shared/utils.test.tsx @@ -3,7 +3,8 @@ import { hasPathnamePrefixed, unprefixPathname, matchesPathname, - prefixPathname + prefixPathname, + getSortedPathnames } from '../../src/shared/utils'; describe('prefixPathname', () => { @@ -114,3 +115,61 @@ describe('matchesPathname', () => { ).toBe(false); }); }); + +describe('getSortedPathnames', () => { + it('works for static routes that include the root', () => { + expect(getSortedPathnames(['/', '/foo', '/test'])).toEqual([ + '/', + '/foo', + '/test' + ]); + }); + + it('should prioritize non-catch-all routes over catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[...slug]', '/categories/new']) + ).toEqual(['/categories/new', '/categories/[...slug]']); + }); + + it('should prioritize static routes over optional catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[[...slug]]', '/categories']) + ).toEqual(['/categories', '/categories/[[...slug]]']); + }); + + it('should prioritize more specific routes over dynamic routes', () => { + expect( + getSortedPathnames(['/categories/[slug]', '/categories/new']) + ).toEqual(['/categories/new', '/categories/[slug]']); + }); + + it('should prioritize dynamic routes over catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[...slug]', '/categories/[slug]']) + ).toEqual(['/categories/[slug]', '/categories/[...slug]']); + }); + + it('should prioritize more specific nested routes over dynamic routes', () => { + expect( + getSortedPathnames([ + '/articles/[category]/[articleSlug]', + '/articles/[category]/new' + ]) + ).toEqual([ + '/articles/[category]/new', + '/articles/[category]/[articleSlug]' + ]); + }); + + it('should prioritize more specific nested routes over catch-all routes', () => { + expect( + getSortedPathnames([ + '/articles/[category]/[...articleSlug]', + '/articles/[category]/new' + ]) + ).toEqual([ + '/articles/[category]/new', + '/articles/[category]/[...articleSlug]' + ]); + }); +});