From 6ae93baf5b4a3c2cad2e6e687efa01986c1d1ffc Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Wed, 24 Jun 2026 19:34:35 +0000 Subject: [PATCH 1/3] 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 +++++++++++++++++++ 3 files changed, 119 insertions(+), 17 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 8ea2f3160cc1..78770ebbdc73 100644 --- a/packages/astro/test/units/render/paginate.test.ts +++ b/packages/astro/test/units/render/paginate.test.ts @@ -157,6 +157,90 @@ describe('Pagination — param value of 0 (e.g. an "uncategorized" id)', () => { }); }); +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({ From 7a94fdf419bfeb2bc8a044b75be4446d00c52474 Mon Sep 17 00:00:00 2001 From: ematipico Date: Mon, 6 Jul 2026 11:49:46 +0100 Subject: [PATCH 2/3] chore: add changeset --- .changeset/pink-frogs-poke.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/pink-frogs-poke.md diff --git a/.changeset/pink-frogs-poke.md b/.changeset/pink-frogs-poke.md new file mode 100644 index 000000000000..dacdc43f7ccc --- /dev/null +++ b/.changeset/pink-frogs-poke.md @@ -0,0 +1,27 @@ +--- +'astro': minor +--- + +Adds a new `format` option to the [`paginate`](https://docs.astro.build/en/reference/routing-reference/#paginate) utility. The option `format` is a function that accepts the current URL of the page, and returns a new URL. + +For example, you can use `format` to append `.html`; useful for those websites where the URLs contain the `.html` extension. + + +```astro +--- +export async function getStaticPaths({ paginate }) { + // Load your data with fetch(), getCollection(), etc. + const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`); + const result = await response.json(); + const allPokemon = result.results; + + // Return a paginated collection of paths for all items + return paginate(allPokemon, { + pageSize: 10, + format: (url) => `${url}.html` + }); +} + +const { page } = Astro.props; +--- +``` From 4c1e197cc448afe5d730295a2f3070eb05c52466 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 6 Jul 2026 14:18:30 +0100 Subject: [PATCH 3/3] Update .changeset/pink-frogs-poke.md Co-authored-by: Armand Philippot --- .changeset/pink-frogs-poke.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/pink-frogs-poke.md b/.changeset/pink-frogs-poke.md index dacdc43f7ccc..c3a1528fb62d 100644 --- a/.changeset/pink-frogs-poke.md +++ b/.changeset/pink-frogs-poke.md @@ -2,9 +2,9 @@ 'astro': minor --- -Adds a new `format` option to the [`paginate`](https://docs.astro.build/en/reference/routing-reference/#paginate) utility. The option `format` is a function that accepts the current URL of the page, and returns a new URL. +Adds a new `format()` option to the [`paginate`](https://docs.astro.build/en/reference/routing-reference/#paginate) utility. The `format()` option is a function that accepts the current URL of the page, and returns a new URL. -For example, you can use `format` to append `.html`; useful for those websites where the URLs contain the `.html` extension. +For example, when your host only supports URLs using the `.html` extension, you can use `format()` to add it to the generated URLs: ```astro