Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b73586f
fix: Follow-up for #1573 to also handle the case when a non-default l…
amannn Nov 26, 2024
b44c8ef
Merge branch 'main' into canary
amannn Jan 21, 2025
b4de1a0
Merge remote-tracking branch 'origin/main' into canary
amannn Feb 17, 2025
84fe6d0
fix: Add workaround for OpenTelemetry/Zone.js (#1718)
amannn Feb 17, 2025
eade8d9
Merge remote-tracking branch 'origin/main' into canary
amannn Mar 12, 2025
19e339e
fix: Handle default exports correctly for `moduleResolution: 'node'` …
amannn Mar 12, 2025
ad6f306
fix: downgrade deps
amannn Mar 12, 2025
fdaddc5
fix: Patch version
amannn Mar 12, 2025
ad8d7c5
feat: Support stable turbo config in Next.js 15.3
amannn Apr 17, 2025
5cb0f4d
Upgrade to Next.js 15.3
amannn Apr 17, 2025
ac88b91
Merge remote-tracking branch 'origin/main' into canary
amannn Apr 17, 2025
b2e2aa5
fix lint
amannn Apr 17, 2025
bac7310
Merge remote-tracking branch 'origin/canary' into feat/1838-stable-tu…
amannn Apr 17, 2025
58b4a57
fix: Support stable Turbopack config (#1849)
amannn Apr 17, 2025
79fdcdc
Handle case if using experimental turbo config
amannn Apr 22, 2025
6918a47
Merge branch 'feat/1838-stable-turbo-config' into canary
amannn Apr 22, 2025
d5ffd72
Merge remote-tracking branch 'origin/main' into canary
amannn Apr 23, 2025
09b34ea
feat: Add `forcePrefix` option for `redirect` and `getPathname` (#1864)
amannn Apr 23, 2025
e629aa8
Update packages/next-intl/src/navigation/shared/createSharedNavigatio…
amannn Apr 23, 2025
69ae8e7
Wording
amannn Apr 24, 2025
0b3a5c6
Merge remote-tracking branch 'origin/main' into canary
amannn Jun 11, 2025
282196c
feat: Encode non-ASCII characters in pathnames returned from navigati…
amannn Jun 11, 2025
69cdf47
Add test for alternate links
amannn Jun 11, 2025
8172ea2
fix: Don't encode hashes in unknown pathnames
amannn Jun 24, 2025
c596c85
fix: Handle hashes in pathnames correctly when using `trailingSlash: …
amannn Jun 24, 2025
3f78906
Merge branch 'main' into canary
amannn Jun 24, 2025
025af7b
Merge branch 'canary' into fix/unknown-pathnames-encoding
amannn Jun 24, 2025
4ada87c
fix: Release
amannn Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/next-intl/.size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ const config: SizeLimitConfig = [
name: "import {createNavigation} from 'next-intl/navigation' (react-client)",
path: 'dist/esm/production/navigation.react-client.js',
import: '{createNavigation}',
limit: '2.325 KB'
limit: '2.335 KB'
},
{
name: "import {createNavigation} from 'next-intl/navigation' (react-server)",
path: 'dist/esm/production/navigation.react-server.js',
import: '{createNavigation}',
limit: '3.095 KB'
limit: '3.115 KB'
},
{
name: "import * from 'next-intl/server' (react-client)",
Expand All @@ -42,7 +42,7 @@ const config: SizeLimitConfig = [
{
name: "import * from 'next-intl/middleware'",
path: 'dist/esm/production/middleware.js',
limit: '9.645 KB'
limit: '9.695 KB'
},
{
name: "import * from 'next-intl/routing'",
Expand Down
6 changes: 6 additions & 0 deletions packages/next-intl/src/navigation/createNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,12 @@ describe.each([
expect(markup).toContain('href="/en/test"');
});

it('handles unknown pathnames with a hash', () => {
// @ts-expect-error -- Validation is still on
const markup = renderToString(<Link href="/test#foo">Test</Link>);
expect(markup).toContain('href="/en/test#foo"');
});

it('handles external links correctly', () => {
const markup = renderToString(
// @ts-expect-error -- Validation is still on
Expand Down
78 changes: 36 additions & 42 deletions packages/next-intl/src/navigation/shared/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,51 +123,48 @@ export function compileLocalizedPathname<AppLocales extends Locales, Pathname>({
pathnames: Pathnames<AppLocales>;
query?: Record<string, SearchParamValue>;
}) {
function getNamedPath(value: keyof typeof pathnames) {
let namedPath = pathnames[value];
if (!namedPath) {
// Unknown pathnames
namedPath = value;
}
return namedPath;
}
function compilePath(value: string) {
const pathnameConfig = pathnames[value];

function compilePath(
namedPath: Pathnames<AppLocales>[keyof Pathnames<AppLocales>],
internalPathname: string
) {
const template = getLocalizedTemplate(namedPath, locale, internalPathname);
let compiled = template;
let compiled: string;
if (pathnameConfig) {
const template = getLocalizedTemplate(pathnameConfig, locale, value);
compiled = template;

if (params) {
Object.entries(params).forEach(([key, value]) => {
let regexp: string, replacer: string;
if (params) {
Object.entries(params).forEach(([key, paramValue]) => {
let regexp: string, replacer: string;

if (Array.isArray(value)) {
regexp = `(\\[)?\\[...${key}\\](\\])?`;
replacer = value.map((v) => String(v)).join('/');
} else {
regexp = `\\[${key}\\]`;
replacer = String(value);
}
if (Array.isArray(paramValue)) {
regexp = `(\\[)?\\[...${key}\\](\\])?`;
replacer = paramValue.map((v) => String(v)).join('/');
} else {
regexp = `\\[${key}\\]`;
replacer = String(paramValue);
}

compiled = compiled.replace(new RegExp(regexp, 'g'), replacer);
});
}
compiled = compiled.replace(new RegExp(regexp, 'g'), replacer);
});
}

// Clean up optional catch-all segments that were not replaced
compiled = compiled.replace(/\[\[\.\.\..+\]\]/g, '');
if (process.env.NODE_ENV !== 'production' && compiled.includes('[')) {
// Next.js throws anyway, therefore better provide a more helpful error message
throw new Error(
`Insufficient params provided for localized pathname.\nTemplate: ${template}\nParams: ${JSON.stringify(
params
)}`
);
}

// Clean up optional catch-all segments that were not replaced
compiled = compiled.replace(/\[\[\.\.\..+\]\]/g, '');
if (process.env.NODE_ENV !== 'production' && compiled.includes('[')) {
// Next.js throws anyway, therefore better provide a more helpful error message
throw new Error(
`Insufficient params provided for localized pathname.\nTemplate: ${template}\nParams: ${JSON.stringify(
params
)}`
);
compiled = encodePathname(compiled);
} else {
// Unknown pathnames
compiled = value;
}

compiled = normalizeTrailingSlash(compiled);
compiled = encodePathname(compiled);

if (query) {
// This also encodes non-ASCII characters by
Expand All @@ -179,13 +176,10 @@ export function compileLocalizedPathname<AppLocales extends Locales, Pathname>({
}

if (typeof pathname === 'string') {
const namedPath = getNamedPath(pathname);
const compiled = compilePath(namedPath, pathname);
return compiled;
return compilePath(pathname);
} else {
const {pathname: internalPathname, ...rest} = pathname;
const namedPath = getNamedPath(internalPathname);
const compiled = compilePath(namedPath, internalPathname);
const compiled = compilePath(internalPathname);
const result: UrlObject = {...rest, pathname: compiled};
return result;
}
Expand Down
60 changes: 59 additions & 1 deletion packages/next-intl/src/shared/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {
getSortedPathnames,
hasPathnamePrefixed,
matchesPathname,
normalizeTrailingSlash,
prefixPathname,
unprefixPathname
} from './utils.js';
Expand Down Expand Up @@ -173,3 +174,60 @@ describe('getSortedPathnames', () => {
]);
});
});

describe('normalizeTrailingSlash', () => {
describe('with trailing slash enabled', () => {
beforeEach(() => {
process.env._next_intl_trailing_slash = 'true';
return () => {
delete process.env._next_intl_trailing_slash;
};
});

it('should add trailing slash when pathname does not end with slash', () => {
expect(normalizeTrailingSlash('/about')).toBe('/about/');
});

it('should not modify pathname when it already ends with slash', () => {
expect(normalizeTrailingSlash('/about/')).toBe('/about/');
});

it('should not modify root path', () => {
expect(normalizeTrailingSlash('/')).toBe('/');
});

it('should handle pathnames with multiple segments', () => {
expect(
normalizeTrailingSlash('/categories/development/programming/')
).toBe('/categories/development/programming/');
});

it('handles pathnames that contain a hash', () => {
expect(normalizeTrailingSlash('/about#section')).toBe('/about/#section');
});
});

describe('with trailing slash disabled', () => {
it('should remove trailing slash when pathname ends with slash', () => {
expect(normalizeTrailingSlash('/about/')).toBe('/about');
});

it('should not modify pathname when it already has no trailing slash', () => {
expect(normalizeTrailingSlash('/about')).toBe('/about');
});

it('should not modify root path', () => {
expect(normalizeTrailingSlash('/')).toBe('/');
});

it('should handle pathnames with multiple segments', () => {
expect(
normalizeTrailingSlash('/categories/development/programming/')
).toBe('/categories/development/programming');
});

it('handles pathnames that contain a hash', () => {
expect(normalizeTrailingSlash('/about#section')).toBe('/about#section');
});
});
});
18 changes: 13 additions & 5 deletions packages/next-intl/src/shared/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,24 @@ export function getLocalizedTemplate<AppLocales extends Locales>(
export function normalizeTrailingSlash(pathname: string) {
const trailingSlash = hasTrailingSlash();

if (pathname !== '/') {
const pathnameEndsWithSlash = pathname.endsWith('/');
const [path, ...hashParts] = pathname.split('#');
const hash = hashParts.join('#');

let normalizedPath = path;
if (normalizedPath !== '/') {
const pathnameEndsWithSlash = normalizedPath.endsWith('/');
if (trailingSlash && !pathnameEndsWithSlash) {
pathname += '/';
normalizedPath += '/';
} else if (!trailingSlash && pathnameEndsWithSlash) {
pathname = pathname.slice(0, -1);
normalizedPath = normalizedPath.slice(0, -1);
}
}

return pathname;
if (hash) {
normalizedPath += '#' + hash;
}

return normalizedPath;
}

export function matchesPathname(
Expand Down