diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 68625f4c6..6100e892e 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.475 KB" + "limit": "3.55 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "18.325 KB" + "limit": "18.355 KB" }, { "path": "dist/production/server.react-client.js", diff --git a/packages/next-intl/src/navigation/shared/utils.test.tsx b/packages/next-intl/src/navigation/shared/utils.test.tsx index 7638bbb94..39a294557 100644 --- a/packages/next-intl/src/navigation/shared/utils.test.tsx +++ b/packages/next-intl/src/navigation/shared/utils.test.tsx @@ -24,14 +24,98 @@ describe('serializeSearchParams', () => { }); describe('compileLocalizedPathname', () => { + const locales: ReadonlyArray = ['en', 'de']; + const pathnames = { + '/about/[param]': { + en: '/about/[param]', + de: '/ueber-uns/[param]' + }, + '/test/[one]/[two]': '/test/[one]/[two]', + '/test/[one]/[one]': '/test/[one]/[one]', + '/test/[...params]': '/test/[...params]', + '/test/[[...params]]': '/test/[[...params]]' + } as const; + + it('compiles a pathname that differs by locale', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/about/[param]', + params: {param: 'value'}, + pathnames + }) + ).toBe('/about/value'); + expect( + compileLocalizedPathname({ + locale: 'de', + pathname: '/about/[param]', + params: {param: 'wert'}, + pathnames + }) + ).toBe('/ueber-uns/wert'); + }); + + it('compiles a pathname that is equal for all locales', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/test/[one]/[two]', + params: {one: '1', two: '2'}, + pathnames + }) + ).toBe('/test/1/2'); + }); + + it('compiles a pathname where a param appears twice', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/test/[one]/[one]', + params: {one: '1'}, + pathnames + }) + ).toBe('/test/1/1'); + }); + + it('compiles a pathname with a catch-all segment', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/test/[...params]', + params: {params: ['a', 'b']}, + pathnames + }) + ).toBe('/test/a/b'); + }); + + it('compiles a pathname with an optional catch-all segment if the segment is provided', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/test/[[...params]]', + params: {params: ['a', 'b']}, + pathnames + }) + ).toBe('/test/a/b'); + }); + + it('compiles a pathname with an optional catch-all segment if the segment is absent', () => { + expect( + compileLocalizedPathname({ + locale: 'en', + pathname: '/test/[[...params]]', + pathnames + }) + ).toBe('/test'); + }); + it('throws when params were not resolved', () => { - const locales: ReadonlyArray = ['en']; expect(() => - // @ts-expect-error -- Purposefully miss a param compileLocalizedPathname({ locale: 'en', pathname: '/test/[one]/[two]', - pathnames: '/test/[one]/[two]', + pathnames, + // @ts-expect-error -- Purposefully miss a param params: {one: '1'} }) ).toThrow( diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index d862ca16f..b5cb714da 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,7 +1,11 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import {Locales, Pathnames} from '../../routing/types'; -import {matchesPathname, getSortedPathnames} from '../../shared/utils'; +import { + matchesPathname, + getSortedPathnames, + normalizeTrailingSlash +} from '../../shared/utils'; import StrictParams from './StrictParams'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -112,17 +116,24 @@ export function compileLocalizedPathname({ if (params) { Object.entries(params).forEach(([key, value]) => { + let regexp: string, replacer: string; + if (Array.isArray(value)) { - compiled = compiled.replace( - new RegExp(`(\\[)?\\[...${key}\\](\\])?`, 'g'), - value.map((v) => String(v)).join('/') - ); + regexp = `(\\[)?\\[...${key}\\](\\])?`; + replacer = value.map((v) => String(v)).join('/'); } else { - compiled = compiled.replace(`[${key}]`, String(value)); + regexp = `\\[${key}\\]`; + replacer = String(value); } + + compiled = compiled.replace(new RegExp(regexp, 'g'), replacer); }); } + // Clean up optional catch-all segments that were not replaced + compiled = compiled.replace(/\[\[\.\.\..+\]\]/g, ''); + compiled = normalizeTrailingSlash(compiled); + if (process.env.NODE_ENV !== 'production' && compiled.includes('[')) { // Next.js throws anyway, therefore better provide a more helpful error message throw new Error(