From 9961b523d0d1f6b7e524cffc99762cace6517c03 Mon Sep 17 00:00:00 2001 From: Filip Holm Date: Fri, 8 May 2026 14:57:15 +0200 Subject: [PATCH 1/2] feat: add sitemap filtering option --- docs/start/framework/react/guide/seo.md | 2 + docs/start/framework/solid/guide/seo.md | 2 + .../start-plugin-core/src/build-sitemap.ts | 9 +- packages/start-plugin-core/src/schema.ts | 1 + .../tests/build-sitemap.test.ts | 294 ++++++++++++++++++ 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 packages/start-plugin-core/tests/build-sitemap.test.ts diff --git a/docs/start/framework/react/guide/seo.md b/docs/start/framework/react/guide/seo.md index a4f580d446..8d4f726490 100644 --- a/docs/start/framework/react/guide/seo.md +++ b/docs/start/framework/react/guide/seo.md @@ -217,6 +217,8 @@ export default defineConfig({ sitemap: { enabled: true, host: 'https://myapp.com', + // Filter function takes the page object and returns whether it should be included in the sitemap + filter: ({ path }) => !path.startsWith('/do-not-include-me'), }, }), ], diff --git a/docs/start/framework/solid/guide/seo.md b/docs/start/framework/solid/guide/seo.md index 617c492182..b77b84d0e7 100644 --- a/docs/start/framework/solid/guide/seo.md +++ b/docs/start/framework/solid/guide/seo.md @@ -217,6 +217,8 @@ export default defineConfig({ sitemap: { enabled: true, host: 'https://myapp.com', + // Filter function takes the page object and returns whether it should be included in the sitemap + filter: ({ path }) => !path.startsWith('/do-not-include-me'), }, }), ], diff --git a/packages/start-plugin-core/src/build-sitemap.ts b/packages/start-plugin-core/src/build-sitemap.ts index 3eae17ca02..1621aaa76a 100644 --- a/packages/start-plugin-core/src/build-sitemap.ts +++ b/packages/start-plugin-core/src/build-sitemap.ts @@ -43,12 +43,15 @@ export type SitemapData = { function buildSitemapJson( pages: TanStackStartOutputConfig['pages'], host: string, + filter: NonNullable['filter'] ): SitemapData { const slash = checkSlash(host) const urls: Array = pages .filter((page) => { - return page.sitemap?.exclude !== true + if (page.sitemap?.exclude === true) return false + if (filter && !filter(page)) return false + return true }) .map((page) => ({ loc: `${host}${slash}${page.path.replace(/^\/+/g, '')}`, @@ -141,7 +144,7 @@ export function buildSitemap({ throw new Error('Sitemap is not enabled') } - const { host, outputPath } = sitemapOptions + const { host, outputPath, filter } = sitemapOptions if (!host) { if (!startConfig.sitemap) { @@ -169,7 +172,7 @@ export function buildSitemap({ logger.info('Building Sitemap...') // Build the sitemap data - const sitemapData = buildSitemapJson(pages, host) + const sitemapData = buildSitemapJson(pages, host, filter) // Generate output paths const xmlOutputPath = path.join(publicDir, outputPath) diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index e0287858e6..a7ca5d0e92 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -243,6 +243,7 @@ export const tanstackStartOptionsObjectSchema = z.object({ enabled: z.boolean().optional().default(true), host: z.string().optional(), outputPath: z.string().optional().default('sitemap.xml'), + filter: z.function().args(pageSchema).returns(z.any()).optional(), }) .optional(), prerender: z diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts new file mode 100644 index 0000000000..9260da8020 --- /dev/null +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSitemap } from '../src/build-sitemap' + +vi.mock('../src/utils', async () => { + const actual = await vi.importActual('../src/utils') + return { + ...actual, + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + } +}) + +const writtenFiles: Record = {} + +vi.mock('node:fs', () => { + return { + writeFileSync: (filePath: string, content: string) => { + writtenFiles[filePath] = content + }, + } +}) + +function makeStartConfig( + overrides: Partial<{ + pages: Array<{ path: string; sitemap?: Record }> + sitemap: Record | null + }> = {}, +): any { + const pages = overrides.pages ?? [{ path: '/about' }, { path: '/contact' }] + const sitemap = + overrides.sitemap !== undefined + ? overrides.sitemap + : { enabled: true, host: 'https://example.com', outputPath: 'sitemap.xml' } + + const config: any = { + pages, + } + if (sitemap !== null) { + config.sitemap = sitemap + } + return config +} + +beforeEach(() => { + for (const key of Object.keys(writtenFiles)) { + delete writtenFiles[key] + } +}) + +describe('buildSitemap', () => { + describe('error cases', () => { + it('throws when sitemap is not enabled', () => { + const startConfig = makeStartConfig({ + sitemap: { enabled: false, host: 'https://example.com', outputPath: 'sitemap.xml' }, + }) + expect(() => buildSitemap({ startConfig, publicDir: '/dist' })).toThrow( + 'Sitemap is not enabled', + ) + }) + + it('throws when host is missing and sitemap is configured explicitly', () => { + const startConfig = makeStartConfig({ + sitemap: { enabled: true, outputPath: 'sitemap.xml' }, + }) + expect(() => buildSitemap({ startConfig, publicDir: '/dist' })).toThrow( + 'Sitemap host is not set and required to build the sitemap.', + ) + }) + + it('throws when outputPath is missing', () => { + const startConfig = makeStartConfig({ + sitemap: { enabled: true, host: 'https://example.com' }, + }) + expect(() => buildSitemap({ startConfig, publicDir: '/dist' })).toThrow( + 'Sitemap output path is not set', + ) + }) + }) + + describe('URL construction', () => { + it('constructs URLs by joining host and page path', () => { + const startConfig = makeStartConfig() + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/about') + expect(xml).toContain('https://example.com/contact') + }) + + it('handles host with trailing slash without doubling', () => { + const startConfig = makeStartConfig({ + sitemap: { + enabled: true, + host: 'https://example.com/', + outputPath: 'sitemap.xml', + }, + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/about') + expect(xml).not.toContain('https://example.com//about') + }) + + it('strips leading slashes from page.path to avoid double slashes', () => { + const startConfig = makeStartConfig({ + pages: [{ path: '/blog/post' }], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/blog/post') + expect(xml).not.toContain('https://example.com//blog/post') + }) + }) + + describe('sitemap filtering', () => { + it('excludes pages with sitemap.exclude = true', () => { + const startConfig = makeStartConfig({ + pages: [ + { path: '/public' }, + { path: '/private', sitemap: { exclude: true } }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/public') + expect(xml).not.toContain('https://example.com/private') + }) + + it('applies custom filter function', () => { + const startConfig = makeStartConfig({ + sitemap: { + enabled: true, + host: 'https://example.com', + outputPath: 'sitemap.xml', + filter: (page: any) => page.path !== '/hidden', + }, + }) + startConfig.pages = [{ path: '/visible' }, { path: '/hidden' }] + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/visible') + expect(xml).not.toContain('https://example.com/hidden') + }) + }) + + describe('page metadata', () => { + it('includes priority and changefreq when provided', () => { + const startConfig = makeStartConfig({ + pages: [ + { + path: '/home', + sitemap: { priority: 0.8, changefreq: 'weekly' as const }, + }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('0.8') + expect(xml).toContain('weekly') + }) + + it('uses provided lastmod date', () => { + const startConfig = makeStartConfig({ + pages: [ + { + path: '/page', + sitemap: { lastmod: '2024-06-15' }, + }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('2024-06-15') + }) + + it('includes alternate refs with hreflang', () => { + const startConfig = makeStartConfig({ + pages: [ + { + path: '/page', + sitemap: { + alternateRefs: [ + { href: 'https://example.fr/page', hreflang: 'fr' }, + ], + }, + }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('hreflang="fr"') + expect(xml).toContain('https://example.fr/page') + }) + + it('includes image metadata', () => { + const startConfig = makeStartConfig({ + pages: [ + { + path: '/gallery', + sitemap: { + images: [ + { loc: 'https://example.com/img.jpg', title: 'My Image', caption: 'A caption' }, + ], + }, + }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('https://example.com/img.jpg') + expect(xml).toContain('My Image') + expect(xml).toContain('A caption') + }) + + it('includes news metadata', () => { + const startConfig = makeStartConfig({ + pages: [ + { + path: '/news/article', + sitemap: { + news: { + publication: { name: 'My Blog', language: 'en' }, + publicationDate: '2024-06-01', + title: 'Breaking News', + }, + }, + }, + ], + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('My Blog') + expect(xml).toContain('en') + expect(xml).toContain('Breaking News') + expect(xml).toContain('2024-06-01') + }) + }) + + describe('output files', () => { + it('writes XML sitemap and pages.json to publicDir', () => { + const startConfig = makeStartConfig() + buildSitemap({ startConfig, publicDir: '/output' }) + expect(writtenFiles['/output/sitemap.xml']).toBeDefined() + expect(writtenFiles['/output/pages.json']).toBeDefined() + }) + + it('writes well-formed XML with urlset root element', () => { + const startConfig = makeStartConfig() + buildSitemap({ startConfig, publicDir: '/dist' }) + const xml = writtenFiles['/dist/sitemap.xml'] + expect(xml).toContain('') + }) + + it('pages.json contains host, pages, and lastBuilt', () => { + const startConfig = makeStartConfig() + buildSitemap({ startConfig, publicDir: '/dist' }) + const pagesData = JSON.parse(writtenFiles['/dist/pages.json']!) + expect(pagesData).toHaveProperty('host', 'https://example.com') + expect(pagesData).toHaveProperty('pages') + expect(pagesData).toHaveProperty('lastBuilt') + }) + + it('respects custom outputPath for sitemap XML', () => { + const startConfig = makeStartConfig({ + sitemap: { + enabled: true, + host: 'https://example.com', + outputPath: 'custom-sitemap.xml', + }, + }) + buildSitemap({ startConfig, publicDir: '/dist' }) + expect(writtenFiles['/dist/custom-sitemap.xml']).toBeDefined() + expect(writtenFiles['/dist/sitemap.xml']).toBeUndefined() + }) + }) + + describe('no pages', () => { + it('skips generation when pages array is empty', () => { + const startConfig = makeStartConfig({ pages: [] }) + buildSitemap({ startConfig, publicDir: '/dist' }) + expect(writtenFiles['/dist/sitemap.xml']).toBeUndefined() + }) + }) + + describe('auto-defaults', () => { + it('defaults to sitemap enabled when pages present and sitemap config is absent', () => { + // When sitemap is not configured and pages exist, buildSitemap sets defaults + // but still requires a host - so it logs info and returns without writing + const startConfig = makeStartConfig({ sitemap: null }) + // Should not throw, just exits early due to missing host + expect(() => buildSitemap({ startConfig, publicDir: '/dist' })).not.toThrow() + expect(writtenFiles['/dist/sitemap.xml']).toBeUndefined() + }) + }) +}) From a19efe8ff35224fde290b533dac93b64ba6c9e57 Mon Sep 17 00:00:00 2001 From: Filip Holm Date: Mon, 11 May 2026 08:05:55 +0200 Subject: [PATCH 2/2] consolidate page setup --- packages/start-plugin-core/tests/build-sitemap.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts index 9260da8020..5ea8e4c037 100644 --- a/packages/start-plugin-core/tests/build-sitemap.test.ts +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -126,6 +126,7 @@ describe('buildSitemap', () => { it('applies custom filter function', () => { const startConfig = makeStartConfig({ + pages: [{ path: '/visible' }, { path: '/hidden' }], sitemap: { enabled: true, host: 'https://example.com', @@ -133,7 +134,6 @@ describe('buildSitemap', () => { filter: (page: any) => page.path !== '/hidden', }, }) - startConfig.pages = [{ path: '/visible' }, { path: '/hidden' }] buildSitemap({ startConfig, publicDir: '/dist' }) const xml = writtenFiles['/dist/sitemap.xml'] expect(xml).toContain('https://example.com/visible')