From daa9ae8bdb7112d428d2bb99d5a7c5c5875931ad Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Wed, 24 Jun 2026 19:34:35 +0000 Subject: [PATCH] feat(paginate): add format option to customize pagination URLs Add a `format` option to `PaginateOptions` that accepts a callback `(url: string) => string` to transform pagination URLs (current, next, prev, first, last) before they are set on the page result. This allows users deploying to static file servers that don't support clean URLs to add file extensions like `.html`: paginate(items, { format: (url) => `${url}.html`, }) Fixes #13604 --- packages/astro/src/core/render/paginate.ts | 39 +++++---- packages/astro/src/types/public/common.ts | 13 +++ .../astro/test/units/render/paginate.test.ts | 84 +++++++++++++++++++ pnpm-lock.yaml | 2 +- 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/packages/astro/src/core/render/paginate.ts b/packages/astro/src/core/render/paginate.ts index 11f5ec2c2a06..f1ba5a29db35 100644 --- a/packages/astro/src/core/render/paginate.ts +++ b/packages/astro/src/core/render/paginate.ts @@ -21,11 +21,12 @@ export function generatePaginateFunction( args: PaginateOptions = {}, ): ReturnType { const generate = getRouteGenerator(routeMatch.segments, trailingSlash); - let { pageSize: _pageSize, params: _params, props: _props } = args; + let { pageSize: _pageSize, params: _params, props: _props, format: _format } = args; const pageSize = _pageSize || 10; const paramName = 'page'; const additionalParams = _params || {}; const additionalProps = _props || {}; + const formatUrl = _format || ((url: string) => url); let includesFirstPageNumber: boolean; if (routeMatch.params.includes(`...${paramName}`)) { includesFirstPageNumber = false; @@ -47,36 +48,40 @@ export function generatePaginateFunction( ...additionalParams, [paramName]: includesFirstPageNumber || pageNum > 1 ? String(pageNum) : undefined, }; - const current = addRouteBase(generate({ ...params }), base); + const current = formatUrl(addRouteBase(generate({ ...params }), base)); const next = pageNum === lastPage ? undefined - : addRouteBase(generate({ ...params, page: String(pageNum + 1) }), base); + : formatUrl(addRouteBase(generate({ ...params, page: String(pageNum + 1) }), base)); const prev = pageNum === 1 ? undefined - : addRouteBase( - generate({ - ...params, - page: - !includesFirstPageNumber && pageNum - 1 === 1 ? undefined : String(pageNum - 1), - }), - base, + : formatUrl( + addRouteBase( + generate({ + ...params, + page: + !includesFirstPageNumber && pageNum - 1 === 1 ? undefined : String(pageNum - 1), + }), + base, + ), ); const first = pageNum === 1 ? undefined - : addRouteBase( - generate({ - ...params, - page: includesFirstPageNumber ? '1' : undefined, - }), - base, + : formatUrl( + addRouteBase( + generate({ + ...params, + page: includesFirstPageNumber ? '1' : undefined, + }), + base, + ), ); const last = pageNum === lastPage ? undefined - : addRouteBase(generate({ ...params, page: String(lastPage) }), base); + : formatUrl(addRouteBase(generate({ ...params, page: String(lastPage) }), base)); return { params, props: { diff --git a/packages/astro/src/types/public/common.ts b/packages/astro/src/types/public/common.ts index 9f70d6644449..b4188a36d73c 100644 --- a/packages/astro/src/types/public/common.ts +++ b/packages/astro/src/types/public/common.ts @@ -42,6 +42,19 @@ export interface PaginateOptions `${url}.html`, + * }) + * ``` + */ + format?: (url: string) => string; } /** diff --git a/packages/astro/test/units/render/paginate.test.ts b/packages/astro/test/units/render/paginate.test.ts index d8b73eb49d1e..b129351d692e 100644 --- a/packages/astro/test/units/render/paginate.test.ts +++ b/packages/astro/test/units/render/paginate.test.ts @@ -133,6 +133,90 @@ describe('Pagination — multiple params (color + page)', () => { }); }); +describe('Pagination — format option transforms URLs', () => { + const route = createRouteData({ + route: '/blog/[...page]', + segments: [ + [{ content: 'blog', dynamic: false, spread: false }], + [{ content: '...page', dynamic: true, spread: true }], + ], + }); + const paginate = generatePaginateFunction(route, '/', 'ignore'); + const pages = paginate(items, { + pageSize: 10, + format: (url) => `${url}.html`, + }); + + it('applies format to current URL', () => { + assert.equal(pages[0].props.page.url.current, '/blog.html'); + assert.equal(pages[1].props.page.url.current, '/blog/2.html'); + }); + + it('applies format to next URL', () => { + assert.equal(pages[0].props.page.url.next, '/blog/2.html'); + }); + + it('applies format to prev URL', () => { + assert.equal(pages[1].props.page.url.prev, '/blog.html'); + }); + + it('applies format to first URL', () => { + assert.equal(pages[2].props.page.url.first, '/blog.html'); + }); + + it('applies format to last URL', () => { + assert.equal(pages[0].props.page.url.last, '/blog/3.html'); + }); + + it('does not apply format to undefined URLs', () => { + assert.equal(pages[0].props.page.url.prev, undefined); + assert.equal(pages[2].props.page.url.next, undefined); + assert.equal(pages[0].props.page.url.first, undefined); + assert.equal(pages[2].props.page.url.last, undefined); + }); +}); + +describe('Pagination — format option with base path', () => { + const route = createRouteData({ + route: '/posts/[...page]', + segments: [ + [{ content: 'posts', dynamic: false, spread: false }], + [{ content: '...page', dynamic: true, spread: true }], + ], + }); + const paginate = generatePaginateFunction(route, '/site', 'ignore'); + const pages = paginate(items, { + pageSize: 10, + format: (url) => `${url}.html`, + }); + + it('applies format after base is prepended', () => { + assert.equal(pages[0].props.page.url.current, '/site/posts.html'); + assert.equal(pages[1].props.page.url.current, '/site/posts/2.html'); + assert.equal(pages[0].props.page.url.next, '/site/posts/2.html'); + assert.equal(pages[1].props.page.url.prev, '/site/posts.html'); + }); +}); + +describe('Pagination — without format option (default behavior unchanged)', () => { + const route = createRouteData({ + route: '/blog/[...page]', + segments: [ + [{ content: 'blog', dynamic: false, spread: false }], + [{ content: '...page', dynamic: true, spread: true }], + ], + }); + const paginate = generatePaginateFunction(route, '/', 'ignore'); + const pages = paginate(items, { pageSize: 10 }); + + it('URLs have no file extension by default', () => { + assert.equal(pages[0].props.page.url.current, '/blog'); + assert.equal(pages[1].props.page.url.current, '/blog/2'); + assert.equal(pages[0].props.page.url.next, '/blog/2'); + assert.equal(pages[1].props.page.url.prev, '/blog'); + }); +}); + describe('Pagination — root spread, correct prev URL — Migrated from astro-pagination-root-spread.test.js', () => { // 4 items, pageSize 1 → 4 pages; root spread means page 1 has no number in URL. const route = createRouteData({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37286f3661ed..f9393cc0ab02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6736,7 +6736,7 @@ importers: specifier: ^4.22.0 version: 4.22.3 - triage/17120: + triage/13604: dependencies: astro: specifier: workspace:*