diff --git a/.changeset/pink-frogs-poke.md b/.changeset/pink-frogs-poke.md new file mode 100644 index 000000000000..c3a1528fb62d --- /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 `format()` option is a function that accepts the current URL of the page, and returns a new URL. + +For example, when your host only supports URLs using the `.html` extension, you can use `format()` to add it to the generated URLs: + + +```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; +--- +``` 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({