From b1ab0acae7458ccce7ee506088dc1974cba12d0b Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Wed, 25 Jun 2025 21:00:55 -0400 Subject: [PATCH 01/16] Add @tanstack/router-sitemap package and example integration Introduces the new @tanstack/router-sitemap package for sitemap generation, including its source, config, and dependencies. Integrates sitemap generation into the React Start Basic example with a new /sitemap.xml server route and updates relevant package.json and lock files to include the new package. --- examples/react/start-basic/package.json | 1 + .../react/start-basic/src/routeTree.gen.ts | 33 +- .../start-basic/src/routes/sitemap[.]xml.ts | 30 ++ package.json | 3 +- packages/router-sitemap/eslint.config.js | 16 + packages/router-sitemap/package.json | 70 ++++ packages/router-sitemap/src/index.ts | 336 ++++++++++++++++++ packages/router-sitemap/tsconfig.json | 5 + packages/router-sitemap/vite.config.ts | 20 ++ pnpm-lock.yaml | 33 ++ 10 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 examples/react/start-basic/src/routes/sitemap[.]xml.ts create mode 100644 packages/router-sitemap/eslint.config.js create mode 100644 packages/router-sitemap/package.json create mode 100644 packages/router-sitemap/src/index.ts create mode 100644 packages/router-sitemap/tsconfig.json create mode 100644 packages/router-sitemap/vite.config.ts diff --git a/examples/react/start-basic/package.json b/examples/react/start-basic/package.json index 20676992e6..27625edd30 100644 --- a/examples/react/start-basic/package.json +++ b/examples/react/start-basic/package.json @@ -12,6 +12,7 @@ "@tanstack/react-router": "^1.123.2", "@tanstack/react-router-devtools": "^1.123.2", "@tanstack/react-start": "^1.123.2", + "@tanstack/router-sitemap": "^1.121.37", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", diff --git a/examples/react/start-basic/src/routeTree.gen.ts b/examples/react/start-basic/src/routeTree.gen.ts index 9f7ccda018..d450861e76 100644 --- a/examples/react/start-basic/src/routeTree.gen.ts +++ b/examples/react/start-basic/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathle import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' +import { ServerRoute as SitemapDotxmlServerRouteImport } from './routes/sitemap[.]xml' import { ServerRoute as CustomScriptDotjsServerRouteImport } from './routes/customScript[.]js' import { ServerRoute as ApiUsersServerRouteImport } from './routes/api/users' import { ServerRoute as ApiUsersUserIdServerRouteImport } from './routes/api/users.$userId' @@ -102,6 +103,11 @@ const PathlessLayoutNestedLayoutRouteARoute = path: '/route-a', getParentRoute: () => PathlessLayoutNestedLayoutRoute, } as any) +const SitemapDotxmlServerRoute = SitemapDotxmlServerRouteImport.update({ + id: '/sitemap.xml', + path: '/sitemap.xml', + getParentRoute: () => rootServerRouteImport, +} as any) const CustomScriptDotjsServerRoute = CustomScriptDotjsServerRouteImport.update({ id: '/customScript.js', path: '/customScript.js', @@ -217,30 +223,43 @@ export interface RootRouteChildren { } export interface FileServerRoutesByFullPath { '/customScript.js': typeof CustomScriptDotjsServerRoute + '/sitemap.xml': typeof SitemapDotxmlServerRoute '/api/users': typeof ApiUsersServerRouteWithChildren '/api/users/$userId': typeof ApiUsersUserIdServerRoute } export interface FileServerRoutesByTo { '/customScript.js': typeof CustomScriptDotjsServerRoute + '/sitemap.xml': typeof SitemapDotxmlServerRoute '/api/users': typeof ApiUsersServerRouteWithChildren '/api/users/$userId': typeof ApiUsersUserIdServerRoute } export interface FileServerRoutesById { __root__: typeof rootServerRouteImport '/customScript.js': typeof CustomScriptDotjsServerRoute + '/sitemap.xml': typeof SitemapDotxmlServerRoute '/api/users': typeof ApiUsersServerRouteWithChildren '/api/users/$userId': typeof ApiUsersUserIdServerRoute } export interface FileServerRouteTypes { fileServerRoutesByFullPath: FileServerRoutesByFullPath - fullPaths: '/customScript.js' | '/api/users' | '/api/users/$userId' + fullPaths: + | '/customScript.js' + | '/sitemap.xml' + | '/api/users' + | '/api/users/$userId' fileServerRoutesByTo: FileServerRoutesByTo - to: '/customScript.js' | '/api/users' | '/api/users/$userId' - id: '__root__' | '/customScript.js' | '/api/users' | '/api/users/$userId' + to: '/customScript.js' | '/sitemap.xml' | '/api/users' | '/api/users/$userId' + id: + | '__root__' + | '/customScript.js' + | '/sitemap.xml' + | '/api/users' + | '/api/users/$userId' fileServerRoutesById: FileServerRoutesById } export interface RootServerRouteChildren { CustomScriptDotjsServerRoute: typeof CustomScriptDotjsServerRoute + SitemapDotxmlServerRoute: typeof SitemapDotxmlServerRoute ApiUsersServerRoute: typeof ApiUsersServerRouteWithChildren } @@ -348,6 +367,13 @@ declare module '@tanstack/react-router' { } declare module '@tanstack/react-start/server' { interface ServerFileRoutesByPath { + '/sitemap.xml': { + id: '/sitemap.xml' + path: '/sitemap.xml' + fullPath: '/sitemap.xml' + preLoaderRoute: typeof SitemapDotxmlServerRouteImport + parentRoute: typeof rootServerRouteImport + } '/customScript.js': { id: '/customScript.js' path: '/customScript.js' @@ -452,6 +478,7 @@ export const routeTree = rootRouteImport ._addFileTypes() const rootServerRouteChildren: RootServerRouteChildren = { CustomScriptDotjsServerRoute: CustomScriptDotjsServerRoute, + SitemapDotxmlServerRoute: SitemapDotxmlServerRoute, ApiUsersServerRoute: ApiUsersServerRouteWithChildren, } export const serverRouteTree = rootServerRouteImport diff --git a/examples/react/start-basic/src/routes/sitemap[.]xml.ts b/examples/react/start-basic/src/routes/sitemap[.]xml.ts new file mode 100644 index 0000000000..a8d73584e6 --- /dev/null +++ b/examples/react/start-basic/src/routes/sitemap[.]xml.ts @@ -0,0 +1,30 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { generateSitemap } from '@tanstack/router-sitemap' +import { fetchPosts } from '~/utils/posts' + +export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ + GET: async () => { + const sitemap = await generateSitemap({ + siteUrl: 'http://localhost:3000', + defaultPriority: 0.5, + defaultChangeFreq: 'weekly', + routes: { + '/': {}, + '/posts/$postId': async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + priority: 0.8, + changeFrequency: 'daily', + })) + }, + }, + }) + + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + }, + }) + }, +}) diff --git a/package.json b/package.json index f95ea9c054..05f2197f3a 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,8 @@ "@tanstack/eslint-plugin-router": "workspace:*", "@tanstack/server-functions-plugin": "workspace:*", "@tanstack/directive-functions-plugin": "workspace:*", - "@tanstack/router-utils": "workspace:*" + "@tanstack/router-utils": "workspace:*", + "@tanstack/router-sitemap": "workspace:*" } } } diff --git a/packages/router-sitemap/eslint.config.js b/packages/router-sitemap/eslint.config.js new file mode 100644 index 0000000000..b31092bbb4 --- /dev/null +++ b/packages/router-sitemap/eslint.config.js @@ -0,0 +1,16 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] \ No newline at end of file diff --git a/packages/router-sitemap/package.json b/packages/router-sitemap/package.json new file mode 100644 index 0000000000..8b2fbfbd06 --- /dev/null +++ b/packages/router-sitemap/package.json @@ -0,0 +1,70 @@ +{ + "name": "@tanstack/router-sitemap", + "version": "1.121.37", + "description": "Sitemap generation utilities for TanStack Router", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/router.git", + "directory": "packages/router-sitemap" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "react", + "router", + "routing", + "sitemap", + "seo", + "typescript" + ], + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test": "pnpm test:eslint && pnpm test:types && pnpm test:build", + "test:eslint": "eslint ./src", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", + "test:types:ts58": "tsc", + "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", + "build": "vite build" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=12" + }, + "dependencies": { + "@tanstack/router-core": "workspace:^", + "fast-xml-parser": "^5.2.5" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.3.5" + } +} diff --git a/packages/router-sitemap/src/index.ts b/packages/router-sitemap/src/index.ts new file mode 100644 index 0000000000..8170aaa54e --- /dev/null +++ b/packages/router-sitemap/src/index.ts @@ -0,0 +1,336 @@ +import { XMLBuilder } from 'fast-xml-parser' +import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' + +// Utility types for route param detection +type SplitPath = + TSegment extends `${infer Segment}/${infer Rest}` + ? Segment | SplitPath + : TSegment + +type ExtractParams = { + [K in SplitPath as K extends `$${infer Param}` + ? Param + : never]: string +} + +type RouteIsDynamic = + keyof ExtractParams extends never ? false : true + +/** Image sitemap extension */ +export interface ImageEntry { + loc: string +} + +/** News sitemap extension */ +export interface NewsEntry { + publication: { + name: string + language: string + } + publication_date: string + title: string +} + +/** Video sitemap extension */ +export interface VideoEntry { + thumbnail_loc: string + title: string + description: string + /** Either content_loc or player_loc is required */ + content_loc?: string + player_loc?: string + duration?: number + expiration_date?: string + rating?: number + view_count?: number + publication_date?: string + family_friendly?: 'yes' | 'no' + restriction?: { + country: string + relationship: 'allow' | 'deny' + } + platform?: { + relationship: 'allow' | 'deny' + content: string + } + requires_subscription?: 'yes' | 'no' + uploader?: { + info?: string + content: string + } + live?: 'yes' | 'no' + tag?: Array +} + +/** Sitemap entry with optional extensions */ +export interface SitemapEntry { + lastmod?: string | Date + changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' + priority?: number + images?: Array + news?: NewsEntry + videos?: Array +} + +export type StaticRouteValue = + | SitemapEntry + | (() => SitemapEntry | Promise) + +export type DynamicRouteEntry = SitemapEntry & { path: string } +export type DynamicRouteValue = + | Array + | (() => Array | Promise>) + +/** + * Pick which shape to use based on whether `TRoute` is dynamic or static. + */ +type RouteValue = + RouteIsDynamic extends true ? DynamicRouteValue : StaticRouteValue + +/** Sitemap configuration */ +export interface SitemapConfig { + siteUrl: string + routes: { + [TRoute in RoutePaths]?: RouteValue + } +} + +type FinalSitemapEntry = { + url: string + lastmod?: string + changefreq?: SitemapEntry['changefreq'] + priority?: number + images?: Array + news?: NewsEntry + videos?: Array +} + +/** + * Generate sitemap XML from configuration + */ +export async function generateSitemap< + TRouter extends RegisteredRouter = RegisteredRouter, +>(config: SitemapConfig): Promise { + const finalEntries: Array = [] + const { siteUrl, routes } = config + + // Validate siteUrl + if (!siteUrl || typeof siteUrl !== 'string') { + throw new Error('siteUrl is required and must be a string') + } + + const createEntry = (path: string, entry: SitemapEntry): FinalSitemapEntry => { + return { + url: `${siteUrl}${path}`, + lastmod: + entry.lastmod instanceof Date + ? entry.lastmod.toISOString() + : entry.lastmod, + changefreq: entry.changefreq, + priority: entry.priority, + images: entry.images, + news: entry.news, + videos: entry.videos, + } + } + + for (const route in routes) { + const routeValue = routes[route as keyof typeof routes] + + if (typeof routeValue === 'function') { + const resolvedValue = await routeValue() + if (Array.isArray(resolvedValue)) { + finalEntries.push( + ...resolvedValue.map((entry) => createEntry(entry.path, entry)), + ) + } else { + finalEntries.push(createEntry(route, resolvedValue)) + } + } else if (Array.isArray(routeValue)) { + finalEntries.push( + ...routeValue.map((entry) => createEntry(entry.path, entry)), + ) + } else if (routeValue) { + finalEntries.push(createEntry(route, routeValue)) + } + } + + // Generate XML structure + const hasImages = finalEntries.some(entry => entry.images?.length) + const hasNews = finalEntries.some(entry => entry.news) + const hasVideos = finalEntries.some(entry => entry.videos?.length) + + // Build namespace attributes + const namespaces: Record = { + '@_xmlns': 'http://www.sitemaps.org/schemas/sitemap/0.9' + } + + if (hasImages) { + namespaces['@_xmlns:image'] = 'http://www.google.com/schemas/sitemap-image/1.1' + } + if (hasNews) { + namespaces['@_xmlns:news'] = 'http://www.google.com/schemas/sitemap-news/0.9' + } + if (hasVideos) { + namespaces['@_xmlns:video'] = 'http://www.google.com/schemas/sitemap-video/1.1' + } + + // Convert entries to XML structure + const urls = finalEntries.map(entry => { + const urlEntry: any = { + loc: entry.url + } + + // Add standard sitemap fields + if (entry.lastmod) { + urlEntry.lastmod = entry.lastmod + } + if (entry.changefreq) { + urlEntry.changefreq = entry.changefreq + } + if (entry.priority !== undefined) { + urlEntry.priority = entry.priority + } + + // Add image extensions + if (entry.images?.length) { + urlEntry['image:image'] = entry.images.map(image => ({ + 'image:loc': image.loc + })) + } + + // Add news extension + if (entry.news) { + // Validate required news fields + if (!entry.news.publication.name) { + throw new Error('News entries must have publication.name') + } + if (!entry.news.publication.language) { + throw new Error('News entries must have publication.language') + } + if (!entry.news.publication_date) { + throw new Error('News entries must have publication_date') + } + if (!entry.news.title) { + throw new Error('News entries must have title') + } + + urlEntry['news:news'] = { + 'news:publication': { + 'news:name': entry.news.publication.name, + 'news:language': entry.news.publication.language + }, + 'news:publication_date': entry.news.publication_date, + 'news:title': entry.news.title + } + } + + // Add video extensions + if (entry.videos?.length) { + urlEntry['video:video'] = entry.videos.map(video => { + // Validate required video fields + if (!video.thumbnail_loc) { + throw new Error('Video entries must have thumbnail_loc') + } + if (!video.title) { + throw new Error('Video entries must have title') + } + if (!video.description) { + throw new Error('Video entries must have description') + } + if (!video.content_loc && !video.player_loc) { + throw new Error('Video entries must have either content_loc or player_loc') + } + + const videoEntry: any = { + 'video:thumbnail_loc': video.thumbnail_loc, + 'video:title': video.title, + 'video:description': video.description + } + + // Add required content_loc or player_loc + if (video.content_loc) { + videoEntry['video:content_loc'] = video.content_loc + } + if (video.player_loc) { + videoEntry['video:player_loc'] = video.player_loc + } + + // Add optional video fields + if (video.duration !== undefined) { + videoEntry['video:duration'] = video.duration + } + if (video.expiration_date) { + videoEntry['video:expiration_date'] = video.expiration_date + } + if (video.rating !== undefined) { + videoEntry['video:rating'] = video.rating + } + if (video.view_count !== undefined) { + videoEntry['video:view_count'] = video.view_count + } + if (video.publication_date) { + videoEntry['video:publication_date'] = video.publication_date + } + if (video.family_friendly) { + videoEntry['video:family_friendly'] = video.family_friendly + } + if (video.restriction) { + videoEntry['video:restriction'] = { + '@_relationship': video.restriction.relationship, + '#text': video.restriction.country + } + } + if (video.platform) { + videoEntry['video:platform'] = { + '@_relationship': video.platform.relationship, + '#text': video.platform.content + } + } + if (video.requires_subscription) { + videoEntry['video:requires_subscription'] = video.requires_subscription + } + if (video.uploader) { + videoEntry['video:uploader'] = { + ...(video.uploader.info && { '@_info': video.uploader.info }), + '#text': video.uploader.content + } + } + if (video.live) { + videoEntry['video:live'] = video.live + } + if (video.tag?.length) { + videoEntry['video:tag'] = video.tag + } + + return videoEntry + }) + } + + return urlEntry + }) + + // Build final XML structure + const xmlObject = { + '?xml': { + '@_version': '1.0', + '@_encoding': 'UTF-8' + }, + urlset: { + ...namespaces, + url: urls + } + } + + // Generate XML + const builder = new XMLBuilder({ + attributeNamePrefix: '@_', + textNodeName: '#text', + ignoreAttributes: false, + format: true, + indentBy: ' ', + suppressEmptyNode: false + }) + + return builder.build(xmlObject) +} diff --git a/packages/router-sitemap/tsconfig.json b/packages/router-sitemap/tsconfig.json new file mode 100644 index 0000000000..604fde5556 --- /dev/null +++ b/packages/router-sitemap/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "vite.config.ts", "tests"], + "exclude": ["tests/generator/**/**"] +} diff --git a/packages/router-sitemap/vite.config.ts b/packages/router-sitemap/vite.config.ts new file mode 100644 index 0000000000..5389f0f739 --- /dev/null +++ b/packages/router-sitemap/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './tests', + watch: false, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.ts', + srcDir: './src', + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb1c7eaeba..87795e4496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,7 @@ overrides: '@tanstack/server-functions-plugin': workspace:* '@tanstack/directive-functions-plugin': workspace:* '@tanstack/router-utils': workspace:* + '@tanstack/router-sitemap': workspace:* importers: @@ -4394,6 +4395,9 @@ importers: '@tanstack/react-start': specifier: workspace:* version: link:../../../packages/react-start + '@tanstack/router-sitemap': + specifier: workspace:* + version: link:../../../packages/router-sitemap react: specifier: ^19.0.0 version: 19.0.0 @@ -6415,6 +6419,22 @@ importers: specifier: ^7.20.7 version: 7.20.7 + packages/router-sitemap: + dependencies: + '@tanstack/router-core': + specifier: workspace:* + version: link:../router-core + fast-xml-parser: + specifier: ^5.2.5 + version: 5.2.5 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: 6.3.5 + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + packages/router-utils: dependencies: '@babel/core': @@ -11996,6 +12016,10 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -14502,6 +14526,9 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + style-to-object@1.0.8: resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} @@ -21231,6 +21258,10 @@ snapshots: fast-uri@3.0.6: {} + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + fastest-levenshtein@1.0.16: {} fastq@1.19.0: @@ -23933,6 +23964,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.1.1: {} + style-to-object@1.0.8: dependencies: inline-style-parser: 0.2.4 From ab6ba15fc236d7d08df7fe803d8ddf5806868f64 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Fri, 27 Jun 2025 08:13:14 -0400 Subject: [PATCH 02/16] add vite plugin make simpler for now --- .../start-basic/src/routes/sitemap[.]xml.ts | 2 +- packages/router-sitemap/package.json | 10 + .../router-sitemap/src/generateSitemap.ts | 179 ++++++ packages/router-sitemap/src/index.ts | 337 +--------- packages/router-sitemap/src/sitemapPlugin.ts | 45 ++ .../tests/generateSitemap.test.ts | 588 ++++++++++++++++++ packages/router-sitemap/vite.config.ts | 2 +- 7 files changed, 825 insertions(+), 338 deletions(-) create mode 100644 packages/router-sitemap/src/generateSitemap.ts create mode 100644 packages/router-sitemap/src/sitemapPlugin.ts create mode 100644 packages/router-sitemap/tests/generateSitemap.test.ts diff --git a/examples/react/start-basic/src/routes/sitemap[.]xml.ts b/examples/react/start-basic/src/routes/sitemap[.]xml.ts index a8d73584e6..66b938efac 100644 --- a/examples/react/start-basic/src/routes/sitemap[.]xml.ts +++ b/examples/react/start-basic/src/routes/sitemap[.]xml.ts @@ -15,7 +15,7 @@ export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ return posts.map((post) => ({ path: `/posts/${post.id}`, priority: 0.8, - changeFrequency: 'daily', + changefreq: 'daily', })) }, }, diff --git a/packages/router-sitemap/package.json b/packages/router-sitemap/package.json index 8b2fbfbd06..aa1201deca 100644 --- a/packages/router-sitemap/package.json +++ b/packages/router-sitemap/package.json @@ -49,6 +49,16 @@ "default": "./dist/cjs/index.cjs" } }, + "./vite-plugin": { + "import": { + "types": "./dist/esm/sitemapPlugin.d.ts", + "default": "./dist/esm/sitemapPlugin.js" + }, + "require": { + "types": "./dist/cjs/sitemapPlugin.d.cts", + "default": "./dist/cjs/sitemapPlugin.cjs" + } + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts new file mode 100644 index 0000000000..f1d7c4d0c6 --- /dev/null +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -0,0 +1,179 @@ +import { XMLBuilder } from 'fast-xml-parser' +import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' + +// Utility types for route param detection +type SplitPath = + TSegment extends `${infer Segment}/${infer Rest}` + ? Segment | SplitPath + : TSegment + +type ExtractParams = { + [K in SplitPath as K extends `$${infer Param}` + ? Param + : never]: string +} + +type RouteIsDynamic = + keyof ExtractParams extends never ? false : true + +export type ChangeFreq = + | 'always' + | 'hourly' + | 'daily' + | 'weekly' + | 'monthly' + | 'yearly' + | 'never' + +export interface SitemapEntryOptions { + lastmod?: string | Date + changefreq?: ChangeFreq + priority?: number +} + +export interface SitemapEntry { + url: string + lastmod?: string + changefreq?: ChangeFreq + priority?: number +} + +export type StaticRouteValue = + | SitemapEntryOptions + | (() => SitemapEntryOptions | Promise) + +export type DynamicRouteEntry = SitemapEntryOptions & { path: string } +export type DynamicRouteValue = + | Array + | (() => Array | Promise>) + +/** + * Pick which shape to use based on whether `TRoute` is dynamic or static. + */ +type RouteValue = + RouteIsDynamic extends true ? DynamicRouteValue : StaticRouteValue + +/** Sitemap configuration */ +export interface SitemapConfig< + TRouter extends RegisteredRouter = RegisteredRouter, +> { + siteUrl: string + defaultPriority?: number + defaultChangeFreq?: ChangeFreq + routes: { + [TRoute in RoutePaths]?: RouteValue + } +} + +function createSitemapEntry( + entry: SitemapEntryOptions & { path?: string }, + siteUrl: string, + route?: string, +): SitemapEntry { + return { + url: entry.path ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, + lastmod: + entry.lastmod instanceof Date + ? entry.lastmod.toISOString() + : entry.lastmod, + changefreq: entry.changefreq, + priority: entry.priority, + } +} + +/** + * Generate sitemap XML from configuration + */ +export async function generateSitemap< + TRouter extends RegisteredRouter = RegisteredRouter, +>(config: SitemapConfig): Promise { + const finalEntries: Array = [] + const { siteUrl, routes, defaultPriority, defaultChangeFreq } = config + + if (!siteUrl || typeof siteUrl !== 'string') { + throw new Error('siteUrl is required and must be a string') + } + + try { + new URL(siteUrl) + } catch { + throw new Error(`Invalid siteUrl: ${siteUrl}.`) + } + + for (const route in routes) { + const routeValue = routes[route as keyof typeof routes] + + if (typeof routeValue === 'function') { + const resolvedValue = await routeValue() + if (Array.isArray(resolvedValue)) { + finalEntries.push( + ...resolvedValue.map((entry) => createSitemapEntry({ + ...entry, + priority: entry.priority ?? defaultPriority, + changefreq: entry.changefreq ?? defaultChangeFreq, + }, siteUrl)), + ) + } else { + finalEntries.push(createSitemapEntry({ + ...resolvedValue, + priority: resolvedValue.priority ?? defaultPriority, + changefreq: resolvedValue.changefreq ?? defaultChangeFreq, + }, siteUrl, route)) + } + } else if (Array.isArray(routeValue)) { + finalEntries.push( + ...routeValue.map((entry) => createSitemapEntry({ + ...entry, + priority: entry.priority ?? defaultPriority, + changefreq: entry.changefreq ?? defaultChangeFreq, + }, siteUrl)), + ) + } else if (routeValue) { + finalEntries.push(createSitemapEntry({ + ...routeValue, + priority: routeValue.priority ?? defaultPriority, + changefreq: routeValue.changefreq ?? defaultChangeFreq, + }, siteUrl, route)) + } + } + + const urls = finalEntries.map((entry) => { + const xml: any = { + loc: entry.url, + } + + if (entry.lastmod) { + xml.lastmod = entry.lastmod + } + if (entry.changefreq) { + xml.changefreq = entry.changefreq + } + if (entry.priority !== undefined) { + xml.priority = entry.priority + } + + return xml + }) + + const xmlObject = { + '?xml': { + '@_version': '1.0', + '@_encoding': 'UTF-8', + }, + urlset: { + '@_xmlns': 'http://www.sitemaps.org/schemas/sitemap/0.9', + url: urls, + }, + } + + const builder = new XMLBuilder({ + attributeNamePrefix: '@_', + textNodeName: '#text', + ignoreAttributes: false, + format: true, + indentBy: ' ', + suppressEmptyNode: false, + }) + + return builder.build(xmlObject) +} diff --git a/packages/router-sitemap/src/index.ts b/packages/router-sitemap/src/index.ts index 8170aaa54e..79990a5b06 100644 --- a/packages/router-sitemap/src/index.ts +++ b/packages/router-sitemap/src/index.ts @@ -1,336 +1 @@ -import { XMLBuilder } from 'fast-xml-parser' -import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' - -// Utility types for route param detection -type SplitPath = - TSegment extends `${infer Segment}/${infer Rest}` - ? Segment | SplitPath - : TSegment - -type ExtractParams = { - [K in SplitPath as K extends `$${infer Param}` - ? Param - : never]: string -} - -type RouteIsDynamic = - keyof ExtractParams extends never ? false : true - -/** Image sitemap extension */ -export interface ImageEntry { - loc: string -} - -/** News sitemap extension */ -export interface NewsEntry { - publication: { - name: string - language: string - } - publication_date: string - title: string -} - -/** Video sitemap extension */ -export interface VideoEntry { - thumbnail_loc: string - title: string - description: string - /** Either content_loc or player_loc is required */ - content_loc?: string - player_loc?: string - duration?: number - expiration_date?: string - rating?: number - view_count?: number - publication_date?: string - family_friendly?: 'yes' | 'no' - restriction?: { - country: string - relationship: 'allow' | 'deny' - } - platform?: { - relationship: 'allow' | 'deny' - content: string - } - requires_subscription?: 'yes' | 'no' - uploader?: { - info?: string - content: string - } - live?: 'yes' | 'no' - tag?: Array -} - -/** Sitemap entry with optional extensions */ -export interface SitemapEntry { - lastmod?: string | Date - changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' - priority?: number - images?: Array - news?: NewsEntry - videos?: Array -} - -export type StaticRouteValue = - | SitemapEntry - | (() => SitemapEntry | Promise) - -export type DynamicRouteEntry = SitemapEntry & { path: string } -export type DynamicRouteValue = - | Array - | (() => Array | Promise>) - -/** - * Pick which shape to use based on whether `TRoute` is dynamic or static. - */ -type RouteValue = - RouteIsDynamic extends true ? DynamicRouteValue : StaticRouteValue - -/** Sitemap configuration */ -export interface SitemapConfig { - siteUrl: string - routes: { - [TRoute in RoutePaths]?: RouteValue - } -} - -type FinalSitemapEntry = { - url: string - lastmod?: string - changefreq?: SitemapEntry['changefreq'] - priority?: number - images?: Array - news?: NewsEntry - videos?: Array -} - -/** - * Generate sitemap XML from configuration - */ -export async function generateSitemap< - TRouter extends RegisteredRouter = RegisteredRouter, ->(config: SitemapConfig): Promise { - const finalEntries: Array = [] - const { siteUrl, routes } = config - - // Validate siteUrl - if (!siteUrl || typeof siteUrl !== 'string') { - throw new Error('siteUrl is required and must be a string') - } - - const createEntry = (path: string, entry: SitemapEntry): FinalSitemapEntry => { - return { - url: `${siteUrl}${path}`, - lastmod: - entry.lastmod instanceof Date - ? entry.lastmod.toISOString() - : entry.lastmod, - changefreq: entry.changefreq, - priority: entry.priority, - images: entry.images, - news: entry.news, - videos: entry.videos, - } - } - - for (const route in routes) { - const routeValue = routes[route as keyof typeof routes] - - if (typeof routeValue === 'function') { - const resolvedValue = await routeValue() - if (Array.isArray(resolvedValue)) { - finalEntries.push( - ...resolvedValue.map((entry) => createEntry(entry.path, entry)), - ) - } else { - finalEntries.push(createEntry(route, resolvedValue)) - } - } else if (Array.isArray(routeValue)) { - finalEntries.push( - ...routeValue.map((entry) => createEntry(entry.path, entry)), - ) - } else if (routeValue) { - finalEntries.push(createEntry(route, routeValue)) - } - } - - // Generate XML structure - const hasImages = finalEntries.some(entry => entry.images?.length) - const hasNews = finalEntries.some(entry => entry.news) - const hasVideos = finalEntries.some(entry => entry.videos?.length) - - // Build namespace attributes - const namespaces: Record = { - '@_xmlns': 'http://www.sitemaps.org/schemas/sitemap/0.9' - } - - if (hasImages) { - namespaces['@_xmlns:image'] = 'http://www.google.com/schemas/sitemap-image/1.1' - } - if (hasNews) { - namespaces['@_xmlns:news'] = 'http://www.google.com/schemas/sitemap-news/0.9' - } - if (hasVideos) { - namespaces['@_xmlns:video'] = 'http://www.google.com/schemas/sitemap-video/1.1' - } - - // Convert entries to XML structure - const urls = finalEntries.map(entry => { - const urlEntry: any = { - loc: entry.url - } - - // Add standard sitemap fields - if (entry.lastmod) { - urlEntry.lastmod = entry.lastmod - } - if (entry.changefreq) { - urlEntry.changefreq = entry.changefreq - } - if (entry.priority !== undefined) { - urlEntry.priority = entry.priority - } - - // Add image extensions - if (entry.images?.length) { - urlEntry['image:image'] = entry.images.map(image => ({ - 'image:loc': image.loc - })) - } - - // Add news extension - if (entry.news) { - // Validate required news fields - if (!entry.news.publication.name) { - throw new Error('News entries must have publication.name') - } - if (!entry.news.publication.language) { - throw new Error('News entries must have publication.language') - } - if (!entry.news.publication_date) { - throw new Error('News entries must have publication_date') - } - if (!entry.news.title) { - throw new Error('News entries must have title') - } - - urlEntry['news:news'] = { - 'news:publication': { - 'news:name': entry.news.publication.name, - 'news:language': entry.news.publication.language - }, - 'news:publication_date': entry.news.publication_date, - 'news:title': entry.news.title - } - } - - // Add video extensions - if (entry.videos?.length) { - urlEntry['video:video'] = entry.videos.map(video => { - // Validate required video fields - if (!video.thumbnail_loc) { - throw new Error('Video entries must have thumbnail_loc') - } - if (!video.title) { - throw new Error('Video entries must have title') - } - if (!video.description) { - throw new Error('Video entries must have description') - } - if (!video.content_loc && !video.player_loc) { - throw new Error('Video entries must have either content_loc or player_loc') - } - - const videoEntry: any = { - 'video:thumbnail_loc': video.thumbnail_loc, - 'video:title': video.title, - 'video:description': video.description - } - - // Add required content_loc or player_loc - if (video.content_loc) { - videoEntry['video:content_loc'] = video.content_loc - } - if (video.player_loc) { - videoEntry['video:player_loc'] = video.player_loc - } - - // Add optional video fields - if (video.duration !== undefined) { - videoEntry['video:duration'] = video.duration - } - if (video.expiration_date) { - videoEntry['video:expiration_date'] = video.expiration_date - } - if (video.rating !== undefined) { - videoEntry['video:rating'] = video.rating - } - if (video.view_count !== undefined) { - videoEntry['video:view_count'] = video.view_count - } - if (video.publication_date) { - videoEntry['video:publication_date'] = video.publication_date - } - if (video.family_friendly) { - videoEntry['video:family_friendly'] = video.family_friendly - } - if (video.restriction) { - videoEntry['video:restriction'] = { - '@_relationship': video.restriction.relationship, - '#text': video.restriction.country - } - } - if (video.platform) { - videoEntry['video:platform'] = { - '@_relationship': video.platform.relationship, - '#text': video.platform.content - } - } - if (video.requires_subscription) { - videoEntry['video:requires_subscription'] = video.requires_subscription - } - if (video.uploader) { - videoEntry['video:uploader'] = { - ...(video.uploader.info && { '@_info': video.uploader.info }), - '#text': video.uploader.content - } - } - if (video.live) { - videoEntry['video:live'] = video.live - } - if (video.tag?.length) { - videoEntry['video:tag'] = video.tag - } - - return videoEntry - }) - } - - return urlEntry - }) - - // Build final XML structure - const xmlObject = { - '?xml': { - '@_version': '1.0', - '@_encoding': 'UTF-8' - }, - urlset: { - ...namespaces, - url: urls - } - } - - // Generate XML - const builder = new XMLBuilder({ - attributeNamePrefix: '@_', - textNodeName: '#text', - ignoreAttributes: false, - format: true, - indentBy: ' ', - suppressEmptyNode: false - }) - - return builder.build(xmlObject) -} +export * from './generateSitemap' \ No newline at end of file diff --git a/packages/router-sitemap/src/sitemapPlugin.ts b/packages/router-sitemap/src/sitemapPlugin.ts new file mode 100644 index 0000000000..b2c31855ed --- /dev/null +++ b/packages/router-sitemap/src/sitemapPlugin.ts @@ -0,0 +1,45 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { generateSitemap } from './generateSitemap' +import type { SitemapConfig } from './generateSitemap' +import type { RegisteredRouter } from '@tanstack/router-core' +import type { Plugin } from 'vite' + +export interface SitemapPluginOptions< + TRouter extends RegisteredRouter = RegisteredRouter, +> { + sitemap: SitemapConfig + outputDir?: string + filename?: string +} + +export function sitemapPlugin< + TRouter extends RegisteredRouter = RegisteredRouter, +>(options: SitemapPluginOptions): Plugin { + const { sitemap, outputDir = 'public', filename = 'sitemap.xml' } = options + + return { + name: 'sitemap', + async buildEnd() { + try { + const sitemapXml = await generateSitemap(sitemap) + + const outputPath = join(outputDir, filename) + const dirPath = dirname(outputPath) + + try { + mkdirSync(dirPath, { recursive: true }) + } catch { + // Directory might already exist + } + + writeFileSync(outputPath, sitemapXml, 'utf8') + + console.log(`Sitemap generated: ${outputPath}`) + } catch (error) { + console.error('Failed to generate sitemap:', error) + throw error + } + }, + } +} diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts new file mode 100644 index 0000000000..1b6d0a35fd --- /dev/null +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it } from 'vitest' +import { generateSitemap, type SitemapConfig } from '../src/index' + +describe('generateSitemap', () => { + describe('basic functionality', () => { + it('should generate basic sitemap XML', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': {}, + '/about': { + lastmod: '2023-12-01', + changefreq: 'monthly', + priority: 0.8, + }, + }, + } + + const result = await generateSitemap(config) + + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('https://example.com/home') + expect(result).toContain('https://example.com/about') + expect(result).toContain('2023-12-01') + expect(result).toContain('monthly') + expect(result).toContain('0.8') + expect(result).toContain('') + }) + + it('should handle Date objects for lastmod', async () => { + const date = new Date('2023-12-01T10:00:00Z') + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': { + lastmod: date, + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('2023-12-01T10:00:00.000Z') + }) + + it('should handle empty routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: {}, + } + + const result = await generateSitemap(config) + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('') + }) + + it('should validate siteUrl is required', async () => { + const config = { + siteUrl: '', + routes: {}, + } as SitemapConfig + + await expect(generateSitemap(config)).rejects.toThrow( + 'siteUrl is required and must be a string' + ) + }) + + it('should validate siteUrl is a string', async () => { + const config = { + siteUrl: null, + routes: {}, + } as any + + await expect(generateSitemap(config)).rejects.toThrow( + 'siteUrl is required and must be a string' + ) + }) + + it('should validate siteUrl is a valid URL', async () => { + const config: SitemapConfig = { + siteUrl: 'not-a-valid-url', + routes: {}, + } + + await expect(generateSitemap(config)).rejects.toThrow( + 'Invalid siteUrl: not-a-valid-url.' + ) + }) + }) + + describe('function-based routes', () => { + it('should handle sync function for static routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': () => ({ + lastmod: '2023-12-01', + priority: 0.9, + }), + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('2023-12-01') + expect(result).toContain('0.9') + }) + + it('should handle async function for static routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': async () => ({ + lastmod: '2023-12-01', + changefreq: 'daily' as const, + }), + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('2023-12-01') + expect(result).toContain('daily') + }) + + it('should handle function returning array for dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': () => [ + { path: '/posts/1', lastmod: '2023-12-01' }, + { path: '/posts/2', lastmod: '2023-12-02' }, + ], + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('https://example.com/posts/2') + expect(result).toContain('2023-12-01') + expect(result).toContain('2023-12-02') + }) + + it('should handle async function returning array for dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': async () => [ + { path: '/posts/async-1', priority: 0.8 }, + { path: '/posts/async-2', priority: 0.7 }, + ], + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/async-1') + expect(result).toContain('https://example.com/posts/async-2') + expect(result).toContain('0.8') + expect(result).toContain('0.7') + }) + }) + + describe('array-based dynamic routes', () => { + it('should handle array of dynamic entries', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': [ + { path: '/posts/array-1', lastmod: '2023-12-01' }, + { path: '/posts/array-2', changefreq: 'weekly' as const }, + ], + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/array-1') + expect(result).toContain('https://example.com/posts/array-2') + expect(result).toContain('2023-12-01') + expect(result).toContain('weekly') + }) + }) + + describe('mixed configurations', () => { + it('should handle mix of static and dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': { priority: 1.0 }, + '/about': () => ({ lastmod: '2023-12-01' }), + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': [ + { path: '/posts/1', changefreq: 'daily' as const }, + ], + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('1') + expect(result).toContain('https://example.com/about') + expect(result).toContain('2023-12-01') + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('daily') + }) + }) + + describe('XML structure validation', () => { + it('should generate proper XML declaration', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': {}, + }, + } + + const result = await generateSitemap(config) + + expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) + }) + + it('should have proper XML structure with closing tags', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': {}, + }, + } + + const result = await generateSitemap(config) + + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('') + }) + + it('should format XML with proper indentation', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/home': {}, + }, + } + + const result = await generateSitemap(config) + + // Should have proper indentation + expect(result).toContain(' ') + expect(result).toContain(' ') + }) + }) + + describe('URL handling edge cases', () => { + it('should handle siteUrl with trailing slash', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com/', + routes: { + '/home': {}, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com//home') + }) + + it('should handle special characters in URLs', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/special-chars?param=value&other=123': {}, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/special-chars?param=value&other=123') + }) + + it('should handle unicode characters in URLs', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': [ + { path: '/posts/héllo-wörld', lastmod: '2023-12-01' }, + ], + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/héllo-wörld') + }) + }) + + describe('priority and numeric values', () => { + it('should handle priority value 0', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/low-priority': { + priority: 0, + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('0') + }) + + it('should handle priority value 1', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/high-priority': { + priority: 1, + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('1') + }) + + it('should handle decimal priority values', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/decimal-priority': { + priority: 0.85, + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('0.85') + }) + }) + + describe('empty and undefined values', () => { + it('should skip undefined optional fields', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/minimal': { + lastmod: undefined, + changefreq: undefined, + priority: undefined, + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/minimal') + expect(result).not.toContain('') + expect(result).not.toContain('') + expect(result).not.toContain('') + }) + }) + + describe('large datasets', () => { + it('should handle many static routes', async () => { + const routes: Record = {} + for (let i = 0; i < 100; i++) { + routes[`/page-${i}`] = { priority: i / 100 } + } + + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/page-0') + expect(result).toContain('https://example.com/page-99') + expect(result).toContain('0') + expect(result).toContain('0.99') + }) + + it('should handle many dynamic routes', async () => { + const dynamicEntries = [] + for (let i = 0; i < 50; i++) { + dynamicEntries.push({ + path: `/post-${i}`, + lastmod: `2023-12-${String(i + 1).padStart(2, '0')}`, + }) + } + + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': dynamicEntries, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/post-0') + expect(result).toContain('https://example.com/post-49') + expect(result).toContain('2023-12-01') + expect(result).toContain('2023-12-50') + }) + }) + + describe('async function handling', () => { + it('should handle async static route function', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + '/async-static': async () => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)) + return { + lastmod: '2023-12-01', + changefreq: 'weekly' as const, + priority: 0.8, + } + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/async-static') + expect(result).toContain('2023-12-01') + expect(result).toContain('weekly') + expect(result).toContain('0.8') + }) + + it('should handle async dynamic route function', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': async () => { + // Simulate async operation (e.g., database query) + await new Promise(resolve => setTimeout(resolve, 10)) + return [ + { path: '/posts/async-1', lastmod: '2023-12-01' }, + { path: '/posts/async-2', lastmod: '2023-12-02' }, + ] + }, + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/async-1') + expect(result).toContain('https://example.com/posts/async-2') + expect(result).toContain('2023-12-01') + expect(result).toContain('2023-12-02') + }) + }) + + describe('default values', () => { + it('should apply defaultPriority to routes without explicit priority', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + defaultPriority: 0.5, + routes: { + '/home': {}, // No priority specified, should get default + '/about': { priority: 0.9 }, // Explicit priority, should override default + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('0.5') // Default priority + expect(result).toContain('https://example.com/about') + expect(result).toContain('0.9') // Explicit priority + }) + + it('should apply defaultChangeFreq to routes without explicit changefreq', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + defaultChangeFreq: 'weekly', + routes: { + '/home': {}, // No changefreq specified, should get default + '/about': { changefreq: 'daily' as const }, // Explicit changefreq, should override default + }, + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('weekly') // Default changefreq + expect(result).toContain('https://example.com/about') + expect(result).toContain('daily') // Explicit changefreq + }) + + it('should apply both default values together', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + defaultPriority: 0.7, + defaultChangeFreq: 'monthly', + routes: { + '/home': {}, // Should get both defaults + '/about': { priority: 0.9 }, // Should get default changefreq but explicit priority + '/contact': { changefreq: 'yearly' as const }, // Should get default priority but explicit changefreq + }, + } + + const result = await generateSitemap(config) + + // /home should have both defaults + expect(result).toContain('https://example.com/home') + expect(result).toContain('0.7') + expect(result).toContain('monthly') + + // /about should have explicit priority, default changefreq + expect(result).toContain('https://example.com/about') + expect(result).toContain('0.9') + expect(result).toContain('monthly') + + // /contact should have default priority, explicit changefreq + expect(result).toContain('https://example.com/contact') + expect(result).toContain('0.7') + expect(result).toContain('yearly') + }) + + it('should apply defaults to dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + defaultPriority: 0.6, + defaultChangeFreq: 'weekly', + routes: { + // @ts-expect-error - Testing dynamic routes + '/posts/$postId': [ + { path: '/posts/1' }, // Should get defaults + { path: '/posts/2', priority: 0.9 }, // Should get default changefreq, explicit priority + { path: '/posts/3', changefreq: 'daily' as const }, // Should get default priority, explicit changefreq + ], + }, + } + + const result = await generateSitemap(config) + + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('0.6') + expect(result).toContain('weekly') + + expect(result).toContain('https://example.com/posts/2') + expect(result).toContain('0.9') + expect(result).toContain('weekly') + + expect(result).toContain('https://example.com/posts/3') + expect(result).toContain('0.6') + expect(result).toContain('daily') + }) + + it('should apply defaults to function-based routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + defaultPriority: 0.4, + defaultChangeFreq: 'monthly', + routes: { + '/static-func': () => ({}), // Function returning empty object, should get defaults + '/static-func-partial': () => ({ priority: 0.8 }), // Should get default changefreq + // @ts-expect-error - Testing dynamic routes + '/dynamic-func': () => [ + { path: '/dynamic/1' }, // Should get defaults + { path: '/dynamic/2', changefreq: 'hourly' as const }, // Should get default priority + ], + }, + } + + const result = await generateSitemap(config) + + // Static function with defaults + expect(result).toContain('https://example.com/static-func') + expect(result).toContain('0.4') + expect(result).toContain('monthly') + + // Static function with partial defaults + expect(result).toContain('https://example.com/static-func-partial') + expect(result).toContain('0.8') + expect(result).toContain('monthly') + + // Dynamic function with defaults + expect(result).toContain('https://example.com/dynamic/1') + expect(result).toContain('0.4') + expect(result).toContain('monthly') + + expect(result).toContain('https://example.com/dynamic/2') + expect(result).toContain('0.4') + expect(result).toContain('hourly') + }) + }) +}) \ No newline at end of file diff --git a/packages/router-sitemap/vite.config.ts b/packages/router-sitemap/vite.config.ts index 5389f0f739..e346aba9f1 100644 --- a/packages/router-sitemap/vite.config.ts +++ b/packages/router-sitemap/vite.config.ts @@ -14,7 +14,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: './src/index.ts', + entry: ['./src/index.ts', './src/sitemapPlugin.ts'], srcDir: './src', }), ) From 32c2d76dca394bfb82b10b12a378dc709fa00ea9 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sat, 28 Jun 2025 07:38:35 -0400 Subject: [PATCH 03/16] updated api --- .../start-basic/src/routes/sitemap[.]xml.ts | 29 +- .../router-sitemap/src/generateSitemap.ts | 168 ++- .../tests/generateSitemap.test.ts | 1054 +++++++++-------- 3 files changed, 614 insertions(+), 637 deletions(-) diff --git a/examples/react/start-basic/src/routes/sitemap[.]xml.ts b/examples/react/start-basic/src/routes/sitemap[.]xml.ts index 66b938efac..2ee05e7fd9 100644 --- a/examples/react/start-basic/src/routes/sitemap[.]xml.ts +++ b/examples/react/start-basic/src/routes/sitemap[.]xml.ts @@ -6,19 +6,22 @@ export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ GET: async () => { const sitemap = await generateSitemap({ siteUrl: 'http://localhost:3000', - defaultPriority: 0.5, - defaultChangeFreq: 'weekly', - routes: { - '/': {}, - '/posts/$postId': async () => { - const posts = await fetchPosts() - return posts.map((post) => ({ - path: `/posts/${post.id}`, - priority: 0.8, - changefreq: 'daily', - })) - }, - }, + priority: 0.5, + changefreq: 'weekly', + routes: [ + '/', + [ + '/posts/$postId', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + priority: 0.8, + changefreq: 'daily', + })) + }, + ], + ], }) return new Response(sitemap, { diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts index f1d7c4d0c6..2eb5ef9c3a 100644 --- a/packages/router-sitemap/src/generateSitemap.ts +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -1,21 +1,6 @@ import { XMLBuilder } from 'fast-xml-parser' import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' -// Utility types for route param detection -type SplitPath = - TSegment extends `${infer Segment}/${infer Rest}` - ? Segment | SplitPath - : TSegment - -type ExtractParams = { - [K in SplitPath as K extends `$${infer Param}` - ? Param - : never]: string -} - -type RouteIsDynamic = - keyof ExtractParams extends never ? false : true - export type ChangeFreq = | 'always' | 'hourly' @@ -25,27 +10,42 @@ export type ChangeFreq = | 'yearly' | 'never' -export interface SitemapEntryOptions { +export interface StaticEntryOptions { lastmod?: string | Date changefreq?: ChangeFreq priority?: number } -export interface SitemapEntry { - url: string - lastmod?: string - changefreq?: ChangeFreq - priority?: number +export interface DynamicEntryOptions extends StaticEntryOptions { + path?: string +} + +export interface SitemapEntry extends StaticEntryOptions { + loc: string +} + +// Utility types for route param detection +type SplitPath = + TSegment extends `${infer Segment}/${infer Rest}` + ? Segment | SplitPath + : TSegment + +type ExtractParams = { + [K in SplitPath as K extends `$${infer Param}` + ? Param + : never]: string } +type RouteIsDynamic = + keyof ExtractParams extends never ? false : true + export type StaticRouteValue = - | SitemapEntryOptions - | (() => SitemapEntryOptions | Promise) + | StaticEntryOptions + | (() => StaticEntryOptions | Promise) -export type DynamicRouteEntry = SitemapEntryOptions & { path: string } export type DynamicRouteValue = - | Array - | (() => Array | Promise>) + | Array + | (() => Array | Promise>) /** * Pick which shape to use based on whether `TRoute` is dynamic or static. @@ -58,27 +58,15 @@ export interface SitemapConfig< TRouter extends RegisteredRouter = RegisteredRouter, > { siteUrl: string - defaultPriority?: number - defaultChangeFreq?: ChangeFreq - routes: { - [TRoute in RoutePaths]?: RouteValue - } -} - -function createSitemapEntry( - entry: SitemapEntryOptions & { path?: string }, - siteUrl: string, - route?: string, -): SitemapEntry { - return { - url: entry.path ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, - lastmod: - entry.lastmod instanceof Date - ? entry.lastmod.toISOString() - : entry.lastmod, - changefreq: entry.changefreq, - priority: entry.priority, - } + priority?: number + changefreq?: ChangeFreq + routes: Array< + | RoutePaths + | [ + RoutePaths, + RouteValue>, + ] + > } /** @@ -87,8 +75,8 @@ function createSitemapEntry( export async function generateSitemap< TRouter extends RegisteredRouter = RegisteredRouter, >(config: SitemapConfig): Promise { - const finalEntries: Array = [] - const { siteUrl, routes, defaultPriority, defaultChangeFreq } = config + const urls: Array = [] + const { siteUrl, routes, priority, changefreq } = config if (!siteUrl || typeof siteUrl !== 'string') { throw new Error('siteUrl is required and must be a string') @@ -100,61 +88,43 @@ export async function generateSitemap< throw new Error(`Invalid siteUrl: ${siteUrl}.`) } - for (const route in routes) { - const routeValue = routes[route as keyof typeof routes] - - if (typeof routeValue === 'function') { - const resolvedValue = await routeValue() - if (Array.isArray(resolvedValue)) { - finalEntries.push( - ...resolvedValue.map((entry) => createSitemapEntry({ - ...entry, - priority: entry.priority ?? defaultPriority, - changefreq: entry.changefreq ?? defaultChangeFreq, - }, siteUrl)), - ) + const createEntry = ( + route: string, + entry: DynamicEntryOptions | StaticEntryOptions, + ): SitemapEntry => ({ + ...entry, + loc: 'path' in entry ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, + lastmod: + entry.lastmod instanceof Date + ? entry.lastmod.toISOString() + : entry.lastmod, + priority: entry.priority ?? priority, + changefreq: entry.changefreq ?? changefreq, + }) + + for (const routeItem of routes) { + if (Array.isArray(routeItem)) { + // Tuple with route and configuration + const [route, routeValue] = routeItem + + if (typeof routeValue === 'function') { + const resolvedValue = await routeValue() + if (Array.isArray(resolvedValue)) { + urls.push(...resolvedValue.map((entry) => createEntry(route, entry))) + } else { + urls.push(createEntry(route, resolvedValue)) + } + } else if (Array.isArray(routeValue)) { + urls.push(...routeValue.map((entry) => createEntry(route, entry))) } else { - finalEntries.push(createSitemapEntry({ - ...resolvedValue, - priority: resolvedValue.priority ?? defaultPriority, - changefreq: resolvedValue.changefreq ?? defaultChangeFreq, - }, siteUrl, route)) + urls.push(createEntry(route, routeValue)) } - } else if (Array.isArray(routeValue)) { - finalEntries.push( - ...routeValue.map((entry) => createSitemapEntry({ - ...entry, - priority: entry.priority ?? defaultPriority, - changefreq: entry.changefreq ?? defaultChangeFreq, - }, siteUrl)), - ) - } else if (routeValue) { - finalEntries.push(createSitemapEntry({ - ...routeValue, - priority: routeValue.priority ?? defaultPriority, - changefreq: routeValue.changefreq ?? defaultChangeFreq, - }, siteUrl, route)) + } else { + // Simple route string without configuration + urls.push(createEntry(routeItem, {})) } } - const urls = finalEntries.map((entry) => { - const xml: any = { - loc: entry.url, - } - - if (entry.lastmod) { - xml.lastmod = entry.lastmod - } - if (entry.changefreq) { - xml.changefreq = entry.changefreq - } - if (entry.priority !== undefined) { - xml.priority = entry.priority - } - - return xml - }) - const xmlObject = { '?xml': { '@_version': '1.0', diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index 1b6d0a35fd..94f3bdb629 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -1,588 +1,592 @@ import { describe, expect, it } from 'vitest' -import { generateSitemap, type SitemapConfig } from '../src/index' +import { generateSitemap } from '../src/index' +import type { SitemapConfig } from '../src/index' describe('generateSitemap', () => { - describe('basic functionality', () => { - it('should generate basic sitemap XML', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': {}, - '/about': { + it('should generate basic sitemap XML', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + '/home', + [ + '/about', + { lastmod: '2023-12-01', changefreq: 'monthly', priority: 0.8, }, - }, - } - - const result = await generateSitemap(config) - - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('https://example.com/home') - expect(result).toContain('https://example.com/about') - expect(result).toContain('2023-12-01') - expect(result).toContain('monthly') - expect(result).toContain('0.8') - expect(result).toContain('') - }) - - it('should handle Date objects for lastmod', async () => { - const date = new Date('2023-12-01T10:00:00Z') - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': { + ], + ], + } + + const result = await generateSitemap(config) + + expect(result).toContain('') + expect(result).toContain( + '', + ) + expect(result).toContain('https://example.com/home') + expect(result).toContain('https://example.com/about') + expect(result).toContain('2023-12-01') + expect(result).toContain('monthly') + expect(result).toContain('0.8') + expect(result).toContain('') + }) + + it('should handle Date objects for lastmod', async () => { + const date = new Date('2023-12-01T10:00:00Z') + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/home', + { lastmod: date, }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('2023-12-01T10:00:00.000Z') - }) - - it('should handle empty routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: {}, - } - - const result = await generateSitemap(config) - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('') - }) - - it('should validate siteUrl is required', async () => { - const config = { - siteUrl: '', - routes: {}, - } as SitemapConfig - - await expect(generateSitemap(config)).rejects.toThrow( - 'siteUrl is required and must be a string' - ) - }) - - it('should validate siteUrl is a string', async () => { - const config = { - siteUrl: null, - routes: {}, - } as any - - await expect(generateSitemap(config)).rejects.toThrow( - 'siteUrl is required and must be a string' - ) - }) - - it('should validate siteUrl is a valid URL', async () => { - const config: SitemapConfig = { - siteUrl: 'not-a-valid-url', - routes: {}, - } - - await expect(generateSitemap(config)).rejects.toThrow( - 'Invalid siteUrl: not-a-valid-url.' - ) - }) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('2023-12-01T10:00:00.000Z') + }) + + it('should handle empty routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [], + } + + const result = await generateSitemap(config) + expect(result).toContain('') + expect(result).toContain( + '', + ) + expect(result).toContain('') }) - describe('function-based routes', () => { - it('should handle sync function for static routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': () => ({ + it('should validate siteUrl is required', async () => { + const config = { + siteUrl: '', + routes: [], + } as SitemapConfig + + await expect(generateSitemap(config)).rejects.toThrow( + 'siteUrl is required and must be a string', + ) + }) + + it('should validate siteUrl is a string', async () => { + const config = { + siteUrl: null, + routes: [], + } as any + + await expect(generateSitemap(config)).rejects.toThrow( + 'siteUrl is required and must be a string', + ) + }) + + it('should validate siteUrl is a valid URL', async () => { + const config: SitemapConfig = { + siteUrl: 'not-a-valid-url', + routes: [], + } + + await expect(generateSitemap(config)).rejects.toThrow( + 'Invalid siteUrl: not-a-valid-url.', + ) + }) + + it('should handle sync function for static routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/home', + () => ({ lastmod: '2023-12-01', priority: 0.9, }), - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('2023-12-01') - expect(result).toContain('0.9') - }) - - it('should handle async function for static routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': async () => ({ - lastmod: '2023-12-01', - changefreq: 'daily' as const, - }), - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('2023-12-01') - expect(result).toContain('daily') - }) - - it('should handle function returning array for dynamic routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': () => [ + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('2023-12-01') + expect(result).toContain('0.9') + }) + + + it('should handle function returning array for dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + () => [ { path: '/posts/1', lastmod: '2023-12-01' }, { path: '/posts/2', lastmod: '2023-12-02' }, ], - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('https://example.com/posts/2') - expect(result).toContain('2023-12-01') - expect(result).toContain('2023-12-02') - }) - - it('should handle async function returning array for dynamic routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': async () => [ - { path: '/posts/async-1', priority: 0.8 }, - { path: '/posts/async-2', priority: 0.7 }, - ], - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/async-1') - expect(result).toContain('https://example.com/posts/async-2') - expect(result).toContain('0.8') - expect(result).toContain('0.7') - }) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('https://example.com/posts/2') + expect(result).toContain('2023-12-01') + expect(result).toContain('2023-12-02') }) - describe('array-based dynamic routes', () => { - it('should handle array of dynamic entries', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': [ + + it('should handle array of dynamic entries', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + [ { path: '/posts/array-1', lastmod: '2023-12-01' }, { path: '/posts/array-2', changefreq: 'weekly' as const }, ], - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/array-1') - expect(result).toContain('https://example.com/posts/array-2') - expect(result).toContain('2023-12-01') - expect(result).toContain('weekly') - }) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/array-1') + expect(result).toContain('https://example.com/posts/array-2') + expect(result).toContain('2023-12-01') + expect(result).toContain('weekly') }) - describe('mixed configurations', () => { - it('should handle mix of static and dynamic routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': { priority: 1.0 }, - '/about': () => ({ lastmod: '2023-12-01' }), - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': [ - { path: '/posts/1', changefreq: 'daily' as const }, - ], - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('1') - expect(result).toContain('https://example.com/about') - expect(result).toContain('2023-12-01') - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('daily') - }) + it('should handle mix of static and dynamic routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + ['/home', { priority: 1.0 }], + ['/about', () => ({ lastmod: '2023-12-01' })], + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + [{ path: '/posts/1', changefreq: 'daily' as const }], + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('1') + expect(result).toContain('https://example.com/about') + expect(result).toContain('2023-12-01') + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('daily') }) - describe('XML structure validation', () => { - it('should generate proper XML declaration', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': {}, - }, - } - - const result = await generateSitemap(config) - - expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) - }) - - it('should have proper XML structure with closing tags', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': {}, - }, - } - - const result = await generateSitemap(config) - - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('') - }) - - it('should format XML with proper indentation', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/home': {}, - }, - } - - const result = await generateSitemap(config) - - // Should have proper indentation - expect(result).toContain(' ') - expect(result).toContain(' ') - }) + it('should generate proper XML declaration', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: ['/home'], + } + + const result = await generateSitemap(config) + + expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) }) - describe('URL handling edge cases', () => { - it('should handle siteUrl with trailing slash', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com/', - routes: { - '/home': {}, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com//home') - }) - - it('should handle special characters in URLs', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/special-chars?param=value&other=123': {}, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/special-chars?param=value&other=123') - }) - - it('should handle unicode characters in URLs', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': [ - { path: '/posts/héllo-wörld', lastmod: '2023-12-01' }, - ], - }, - } - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/héllo-wörld') - }) + + it('should handle siteUrl with trailing slash', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com/', + routes: ['/home'], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com//home') + }) + + it('should handle special characters in URLs', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: ['/special-chars?param=value&other=123'], + } + + const result = await generateSitemap(config) + expect(result).toContain( + 'https://example.com/special-chars?param=value&other=123', + ) }) - describe('priority and numeric values', () => { - it('should handle priority value 0', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/low-priority': { + it('should handle unicode characters in URLs', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + [{ path: '/posts/héllo-wörld', lastmod: '2023-12-01' }], + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain( + 'https://example.com/posts/héllo-wörld', + ) + }) + + it('should handle priority value 0', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/low-priority', + { priority: 0, }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('0') - }) - - it('should handle priority value 1', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/high-priority': { - priority: 1, - }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('1') - }) - - it('should handle decimal priority values', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/decimal-priority': { + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('0') + }) + + + it('should handle decimal priority values', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/decimal-priority', + { priority: 0.85, }, - }, - } + ], + ], + } - const result = await generateSitemap(config) - expect(result).toContain('0.85') - }) + const result = await generateSitemap(config) + expect(result).toContain('0.85') }) - describe('empty and undefined values', () => { - it('should skip undefined optional fields', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/minimal': { + it('should skip undefined optional fields', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/minimal', + { lastmod: undefined, changefreq: undefined, priority: undefined, }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/minimal') - expect(result).not.toContain('') - expect(result).not.toContain('') - expect(result).not.toContain('') - }) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/minimal') + expect(result).not.toContain('') + expect(result).not.toContain('') + expect(result).not.toContain('') }) - describe('large datasets', () => { - it('should handle many static routes', async () => { - const routes: Record = {} - for (let i = 0; i < 100; i++) { - routes[`/page-${i}`] = { priority: i / 100 } - } - - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/page-0') - expect(result).toContain('https://example.com/page-99') - expect(result).toContain('0') - expect(result).toContain('0.99') - }) - - it('should handle many dynamic routes', async () => { - const dynamicEntries = [] - for (let i = 0; i < 50; i++) { - dynamicEntries.push({ - path: `/post-${i}`, - lastmod: `2023-12-${String(i + 1).padStart(2, '0')}`, - }) - } - - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': dynamicEntries, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/post-0') - expect(result).toContain('https://example.com/post-49') - expect(result).toContain('2023-12-01') - expect(result).toContain('2023-12-50') - }) - }) - describe('async function handling', () => { - it('should handle async static route function', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - '/async-static': async () => { - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)) + + it('should handle async static route function', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/async-static', + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) return { lastmod: '2023-12-01', changefreq: 'weekly' as const, priority: 0.8, } }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/async-static') - expect(result).toContain('2023-12-01') - expect(result).toContain('weekly') - expect(result).toContain('0.8') - }) - - it('should handle async dynamic route function', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': async () => { - // Simulate async operation (e.g., database query) - await new Promise(resolve => setTimeout(resolve, 10)) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/async-static') + expect(result).toContain('2023-12-01') + expect(result).toContain('weekly') + expect(result).toContain('0.8') + }) + + it('should handle async dynamic route function', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) return [ { path: '/posts/async-1', lastmod: '2023-12-01' }, { path: '/posts/async-2', lastmod: '2023-12-02' }, ] }, - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/async-1') - expect(result).toContain('https://example.com/posts/async-2') - expect(result).toContain('2023-12-01') - expect(result).toContain('2023-12-02') - }) + ], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/posts/async-1') + expect(result).toContain('https://example.com/posts/async-2') + expect(result).toContain('2023-12-01') + expect(result).toContain('2023-12-02') + }) + + it('should apply priority to routes without explicit priority', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + priority: 0.5, + routes: [ + '/home', // No priority specified, should get default + ['/about', { priority: 0.9 }], // Explicit priority, should override default + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('0.5') // Default priority + expect(result).toContain('https://example.com/about') + expect(result).toContain('0.9') // Explicit priority }) - describe('default values', () => { - it('should apply defaultPriority to routes without explicit priority', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - defaultPriority: 0.5, - routes: { - '/home': {}, // No priority specified, should get default - '/about': { priority: 0.9 }, // Explicit priority, should override default - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('0.5') // Default priority - expect(result).toContain('https://example.com/about') - expect(result).toContain('0.9') // Explicit priority - }) - - it('should apply defaultChangeFreq to routes without explicit changefreq', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - defaultChangeFreq: 'weekly', - routes: { - '/home': {}, // No changefreq specified, should get default - '/about': { changefreq: 'daily' as const }, // Explicit changefreq, should override default - }, - } - - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('weekly') // Default changefreq - expect(result).toContain('https://example.com/about') - expect(result).toContain('daily') // Explicit changefreq - }) - - it('should apply both default values together', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - defaultPriority: 0.7, - defaultChangeFreq: 'monthly', - routes: { - '/home': {}, // Should get both defaults - '/about': { priority: 0.9 }, // Should get default changefreq but explicit priority - '/contact': { changefreq: 'yearly' as const }, // Should get default priority but explicit changefreq - }, - } - - const result = await generateSitemap(config) - - // /home should have both defaults - expect(result).toContain('https://example.com/home') - expect(result).toContain('0.7') - expect(result).toContain('monthly') - - // /about should have explicit priority, default changefreq - expect(result).toContain('https://example.com/about') - expect(result).toContain('0.9') - expect(result).toContain('monthly') - - // /contact should have default priority, explicit changefreq - expect(result).toContain('https://example.com/contact') - expect(result).toContain('0.7') - expect(result).toContain('yearly') - }) - - it('should apply defaults to dynamic routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - defaultPriority: 0.6, - defaultChangeFreq: 'weekly', - routes: { - // @ts-expect-error - Testing dynamic routes - '/posts/$postId': [ + it('should apply changefreq to routes without explicit changefreq', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + changefreq: 'weekly', + routes: [ + '/home', // No changefreq specified, should get default + ['/about', { changefreq: 'daily' as const }], // Explicit changefreq, should override default + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/home') + expect(result).toContain('weekly') // Default changefreq + expect(result).toContain('https://example.com/about') + expect(result).toContain('daily') // Explicit changefreq + }) + + it('should apply both default values together', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + priority: 0.7, + changefreq: 'monthly', + routes: [ + '/home', // Should get both defaults + ['/about', { priority: 0.9 }], // Should get default changefreq but explicit priority + ['/contact', { changefreq: 'yearly' as const }], // Should get default priority but explicit changefreq + ], + } + + const result = await generateSitemap(config) + + // /home should have both defaults + expect(result).toContain('https://example.com/home') + expect(result).toContain('0.7') + expect(result).toContain('monthly') + + // /about should have explicit priority, default changefreq + expect(result).toContain('https://example.com/about') + expect(result).toContain('0.9') + expect(result).toContain('monthly') + + // /contact should have default priority, explicit changefreq + expect(result).toContain('https://example.com/contact') + expect(result).toContain('0.7') + expect(result).toContain('yearly') + }) + + it('should apply defaults to dynamic routes', async () => { + // @ts-ignore - Test configuration + const config: SitemapConfig = { + siteUrl: 'https://example.com', + priority: 0.6, + changefreq: 'weekly', + routes: [ + [ + '/posts/$postId', + // @ts-ignore - Dynamic route for testing + [ { path: '/posts/1' }, // Should get defaults { path: '/posts/2', priority: 0.9 }, // Should get default changefreq, explicit priority { path: '/posts/3', changefreq: 'daily' as const }, // Should get default priority, explicit changefreq ], - }, - } - - const result = await generateSitemap(config) - - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('0.6') - expect(result).toContain('weekly') - - expect(result).toContain('https://example.com/posts/2') - expect(result).toContain('0.9') - expect(result).toContain('weekly') - - expect(result).toContain('https://example.com/posts/3') - expect(result).toContain('0.6') - expect(result).toContain('daily') - }) - - it('should apply defaults to function-based routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - defaultPriority: 0.4, - defaultChangeFreq: 'monthly', - routes: { - '/static-func': () => ({}), // Function returning empty object, should get defaults - '/static-func-partial': () => ({ priority: 0.8 }), // Should get default changefreq - // @ts-expect-error - Testing dynamic routes - '/dynamic-func': () => [ - { path: '/dynamic/1' }, // Should get defaults - { path: '/dynamic/2', changefreq: 'hourly' as const }, // Should get default priority - ], - }, - } - - const result = await generateSitemap(config) - - // Static function with defaults - expect(result).toContain('https://example.com/static-func') - expect(result).toContain('0.4') - expect(result).toContain('monthly') - - // Static function with partial defaults - expect(result).toContain('https://example.com/static-func-partial') - expect(result).toContain('0.8') - expect(result).toContain('monthly') - - // Dynamic function with defaults - expect(result).toContain('https://example.com/dynamic/1') - expect(result).toContain('0.4') - expect(result).toContain('monthly') - - expect(result).toContain('https://example.com/dynamic/2') - expect(result).toContain('0.4') - expect(result).toContain('hourly') - }) + ], + ], + } + + const result = await generateSitemap(config) + + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('0.6') + expect(result).toContain('weekly') + + expect(result).toContain('https://example.com/posts/2') + expect(result).toContain('0.9') + expect(result).toContain('weekly') + + expect(result).toContain('https://example.com/posts/3') + expect(result).toContain('0.6') + expect(result).toContain('daily') + }) + + it('should apply defaults to function-based routes', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + priority: 0.4, + changefreq: 'monthly', + routes: [ + ['/static-func', () => ({})], // Function returning empty object, should get defaults + ['/static-func-partial', () => ({ priority: 0.8 })], // Should get default changefreq + // @ts-ignore - Dynamic route for testing + [ + '/dynamic-func', + () => + [ + { path: '/dynamic/1' }, // Should get defaults + { path: '/dynamic/2', changefreq: 'hourly' as const }, // Should get default priority + ] as any, + ], + ], + } + + const result = await generateSitemap(config) + + // Static function with defaults + expect(result).toContain('https://example.com/static-func') + expect(result).toContain('0.4') + expect(result).toContain('monthly') + + // Static function with partial defaults + expect(result).toContain( + 'https://example.com/static-func-partial', + ) + expect(result).toContain('0.8') + expect(result).toContain('monthly') + + // Dynamic function with defaults + expect(result).toContain('https://example.com/dynamic/1') + expect(result).toContain('0.4') + expect(result).toContain('monthly') + + expect(result).toContain('https://example.com/dynamic/2') + expect(result).toContain('0.4') + expect(result).toContain('hourly') + }) + + it('should handle function errors gracefully', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + [ + '/error-route', + () => { + throw new Error('Test function error') + }, + ], + ], + } + + await expect(generateSitemap(config)).rejects.toThrow('Test function error') + }) + + it('should handle invalid priority values', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + ['/negative-priority', { priority: -0.5 }], + ['/high-priority', { priority: 1.5 }], + ['/nan-priority', { priority: NaN }], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/negative-priority') + expect(result).toContain('https://example.com/high-priority') + expect(result).toContain('https://example.com/nan-priority') + // Invalid priority values should either be omitted or handled gracefully + }) + + it('should handle invalid changefreq values', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + ['/invalid-changefreq', { changefreq: 'invalid' as any }], + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/invalid-changefreq') + // Invalid changefreq should either be omitted or handled gracefully + }) + + it('should escape XML special characters in URLs', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + '/path-with-', + '/path-with-"quotes"', + "/path-with-'apostrophes'", + ], + } + + const result = await generateSitemap(config) + expect(result).toContain('https://example.com/path-with-<brackets>') + expect(result).toContain('https://example.com/path-with-"quotes"') + expect(result).toContain("https://example.com/path-with-'apostrophes'") + }) + + it('should handle invalid route paths', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + '', // Empty string + 'no-leading-slash', + null as any, + undefined as any, + ], + } + + // Should either handle gracefully or throw appropriate errors + const result = await generateSitemap(config) + // At minimum, should not crash and should produce valid XML + expect(result).toContain('') + expect(result).toContain('') + }) + + it('should handle functions returning invalid data', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [ + ['/null-return', () => null], + ['/undefined-return', () => undefined], + ['/invalid-object', () => ({ invalidProp: 'test' })], + ['/string-return', () => 'not-an-object' as any], + ], + } + + const result = await generateSitemap(config) + // Should handle gracefully and produce valid XML + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('https://example.com/null-return') + expect(result).toContain('https://example.com/undefined-return') }) -}) \ No newline at end of file +}) From f68aea24de23cddc476ba50ff001139fbe749ff6 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sat, 28 Jun 2025 09:05:13 -0400 Subject: [PATCH 04/16] fix test cases --- packages/router-sitemap/package.json | 1 + .../router-sitemap/src/generateSitemap.ts | 124 ++++++++++++---- .../tests/generateSitemap.test.ts | 135 ++++++++++-------- 3 files changed, 175 insertions(+), 85 deletions(-) diff --git a/packages/router-sitemap/package.json b/packages/router-sitemap/package.json index aa1201deca..69f35c29d6 100644 --- a/packages/router-sitemap/package.json +++ b/packages/router-sitemap/package.json @@ -25,6 +25,7 @@ "scripts": { "clean": "rimraf ./dist && rimraf ./coverage", "test": "pnpm test:eslint && pnpm test:types && pnpm test:build", + "test:unit": "vitest run", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js", diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts index 2eb5ef9c3a..e5fbc071e5 100644 --- a/packages/router-sitemap/src/generateSitemap.ts +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -1,14 +1,7 @@ import { XMLBuilder } from 'fast-xml-parser' import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' -export type ChangeFreq = - | 'always' - | 'hourly' - | 'daily' - | 'weekly' - | 'monthly' - | 'yearly' - | 'never' +export type ChangeFreq = (typeof CHANGEFREQ)[number] export interface StaticEntryOptions { lastmod?: string | Date @@ -69,6 +62,75 @@ export interface SitemapConfig< > } +const CHANGEFREQ = [ + 'always', + 'hourly', + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'never', +] as const + +function isDefined(value: T | undefined): value is T { + return value !== undefined +} + +function isValidPriority(priority: unknown): priority is number { + return ( + typeof priority === 'number' && + !Number.isNaN(priority) && + priority >= 0 && + priority <= 1 + ) +} + +function isValidChangeFreq(changefreq: unknown): changefreq is ChangeFreq { + return ( + typeof changefreq === 'string' && + CHANGEFREQ.includes(changefreq as ChangeFreq) + ) +} + +function isValidLastMod(lastmod: unknown): lastmod is string | Date { + if (lastmod instanceof Date) { + return !isNaN(lastmod.getTime()) + } + + if (typeof lastmod === 'string') { + const date = new Date(lastmod) + return !isNaN(date.getTime()) + } + + return false +} + +function validateEntry( + route: string, + entry: StaticEntryOptions | DynamicEntryOptions, +): asserts entry is StaticEntryOptions | DynamicEntryOptions { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!entry || typeof entry !== 'object') { + throw new Error(`Invalid entry for route "${route}": must be an object.`) + } + + if (isDefined(entry.lastmod) && !isValidLastMod(entry.lastmod)) { + throw new Error(`Invalid entry ${route}: lastmod must be a string or Date`) + } + + if (isDefined(entry.priority) && !isValidPriority(entry.priority)) { + throw new Error( + `Invalid entry ${route}: priority must be a number between 0 and 1`, + ) + } + + if (isDefined(entry.changefreq) && !isValidChangeFreq(entry.changefreq)) { + throw new Error( + `Invalid entry ${route}: changefreq must be one of ${CHANGEFREQ.join(', ')}`, + ) + } +} + /** * Generate sitemap XML from configuration */ @@ -76,31 +138,42 @@ export async function generateSitemap< TRouter extends RegisteredRouter = RegisteredRouter, >(config: SitemapConfig): Promise { const urls: Array = [] - const { siteUrl, routes, priority, changefreq } = config - - if (!siteUrl || typeof siteUrl !== 'string') { - throw new Error('siteUrl is required and must be a string') - } + const { routes, priority, changefreq } = config + let siteUrl: string try { - new URL(siteUrl) + siteUrl = new URL(config.siteUrl).toString() } catch { - throw new Error(`Invalid siteUrl: ${siteUrl}.`) + throw new Error(`Invalid siteUrl: ${config.siteUrl}.`) + } + + if (isDefined(priority) && !isValidPriority(priority)) { + throw new Error(`Invalid priority: ${priority}. Must be between 0 and 1.`) + } + + if (isDefined(changefreq) && !isValidChangeFreq(changefreq)) { + throw new Error( + `Invalid changefreq: ${changefreq}. Must be one of ${CHANGEFREQ.join(', ')}.`, + ) } const createEntry = ( route: string, entry: DynamicEntryOptions | StaticEntryOptions, - ): SitemapEntry => ({ - ...entry, - loc: 'path' in entry ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, - lastmod: - entry.lastmod instanceof Date - ? entry.lastmod.toISOString() - : entry.lastmod, - priority: entry.priority ?? priority, - changefreq: entry.changefreq ?? changefreq, - }) + ): SitemapEntry => { + validateEntry(route, entry) + + return { + ...entry, + loc: 'path' in entry ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, + lastmod: + entry.lastmod instanceof Date + ? entry.lastmod.toISOString() + : entry.lastmod, + priority: entry.priority ?? priority, + changefreq: entry.changefreq ?? changefreq, + } + } for (const routeItem of routes) { if (Array.isArray(routeItem)) { @@ -143,6 +216,7 @@ export async function generateSitemap< format: true, indentBy: ' ', suppressEmptyNode: false, + processEntities: true, }) return builder.build(xmlObject) diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index 94f3bdb629..16e201b53a 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -72,7 +72,7 @@ describe('generateSitemap', () => { } as SitemapConfig await expect(generateSitemap(config)).rejects.toThrow( - 'siteUrl is required and must be a string', + 'Invalid siteUrl: . Must be a valid URL.', ) }) @@ -83,7 +83,7 @@ describe('generateSitemap', () => { } as any await expect(generateSitemap(config)).rejects.toThrow( - 'siteUrl is required and must be a string', + 'Invalid siteUrl: null. Must be a valid URL.', ) }) @@ -118,7 +118,6 @@ describe('generateSitemap', () => { expect(result).toContain('0.9') }) - it('should handle function returning array for dynamic routes', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', @@ -141,7 +140,6 @@ describe('generateSitemap', () => { expect(result).toContain('2023-12-02') }) - it('should handle array of dynamic entries', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', @@ -198,8 +196,6 @@ describe('generateSitemap', () => { expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) }) - - it('should handle siteUrl with trailing slash', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com/', @@ -207,7 +203,7 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com//home') + expect(result).toContain('https://example.com/home') }) it('should handle special characters in URLs', async () => { @@ -235,9 +231,7 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain( - 'https://example.com/posts/héllo-wörld', - ) + expect(result).toContain('https://example.com/posts/héllo-wörld') }) it('should handle priority value 0', async () => { @@ -257,7 +251,6 @@ describe('generateSitemap', () => { expect(result).toContain('0') }) - it('should handle decimal priority values', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', @@ -297,8 +290,6 @@ describe('generateSitemap', () => { expect(result).not.toContain('') }) - - it('should handle async static route function', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', @@ -507,34 +498,41 @@ describe('generateSitemap', () => { await expect(generateSitemap(config)).rejects.toThrow('Test function error') }) - it('should handle invalid priority values', async () => { - const config: SitemapConfig = { + it('should validate invalid priority values', async () => { + const negativeConfig: SitemapConfig = { siteUrl: 'https://example.com', - routes: [ - ['/negative-priority', { priority: -0.5 }], - ['/high-priority', { priority: 1.5 }], - ['/nan-priority', { priority: NaN }], - ], + routes: [['/negative-priority', { priority: -0.5 }]], } + await expect(generateSitemap(negativeConfig)).rejects.toThrow( + 'Invalid entry /negative-priority: priority must be a number between 0 and 1', + ) - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/negative-priority') - expect(result).toContain('https://example.com/high-priority') - expect(result).toContain('https://example.com/nan-priority') - // Invalid priority values should either be omitted or handled gracefully + const highConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [['/high-priority', { priority: 1.5 }]], + } + await expect(generateSitemap(highConfig)).rejects.toThrow( + 'Invalid entry /high-priority: priority must be a number between 0 and 1', + ) + + const nanConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [['/nan-priority', { priority: NaN }]], + } + await expect(generateSitemap(nanConfig)).rejects.toThrow( + 'Invalid entry /nan-priority: priority must be a number between 0 and 1', + ) }) - it('should handle invalid changefreq values', async () => { + it('should validate invalid changefreq values', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', - routes: [ - ['/invalid-changefreq', { changefreq: 'invalid' as any }], - ], + routes: [['/invalid-changefreq', { changefreq: 'invalid' as any }]], } - const result = await generateSitemap(config) - expect(result).toContain('https://example.com/invalid-changefreq') - // Invalid changefreq should either be omitted or handled gracefully + await expect(generateSitemap(config)).rejects.toThrow( + 'Invalid entry /invalid-changefreq: changefreq must be one of always, hourly, daily, weekly, monthly, yearly, never', + ) }) it('should escape XML special characters in URLs', async () => { @@ -548,45 +546,62 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/path-with-<brackets>') - expect(result).toContain('https://example.com/path-with-"quotes"') - expect(result).toContain("https://example.com/path-with-'apostrophes'") + expect(result).toContain( + 'https://example.com/path-with-<brackets>', + ) + expect(result).toContain( + 'https://example.com/path-with-"quotes"', + ) + expect(result).toContain( + 'https://example.com/path-with-'apostrophes'', + ) }) it('should handle invalid route paths', async () => { - const config: SitemapConfig = { + const emptyConfig: SitemapConfig = { siteUrl: 'https://example.com', - routes: [ - '', // Empty string - 'no-leading-slash', - null as any, - undefined as any, - ], + routes: [''], // Empty string } + await expect(generateSitemap(emptyConfig)).rejects.toThrow() - // Should either handle gracefully or throw appropriate errors - const result = await generateSitemap(config) - // At minimum, should not crash and should produce valid XML - expect(result).toContain('') - expect(result).toContain('') + const nullConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [null as any], + } + await expect(generateSitemap(nullConfig)).rejects.toThrow() + + const undefinedConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [undefined as any], + } + await expect(generateSitemap(undefinedConfig)).rejects.toThrow() }) it('should handle functions returning invalid data', async () => { - const config: SitemapConfig = { + const nullConfig: SitemapConfig = { siteUrl: 'https://example.com', - routes: [ - ['/null-return', () => null], - ['/undefined-return', () => undefined], - ['/invalid-object', () => ({ invalidProp: 'test' })], - ['/string-return', () => 'not-an-object' as any], - ], + // @ts-ignore - Invalid configuration + routes: [['/null-return', () => null]], } + await expect(generateSitemap(nullConfig)).rejects.toThrow( + 'Invalid entry for route "/null-return"', + ) - const result = await generateSitemap(config) - // Should handle gracefully and produce valid XML - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('https://example.com/null-return') - expect(result).toContain('https://example.com/undefined-return') + const undefinedConfig: SitemapConfig = { + siteUrl: 'https://example.com', + // @ts-ignore - Invalid configuration + routes: [['/undefined-return', () => undefined]], + } + await expect(generateSitemap(undefinedConfig)).rejects.toThrow( + 'Invalid entry for route "/undefined-return"', + ) + + const stringConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [['/string-return', () => 'not-an-object' as any]], + } + await expect(generateSitemap(stringConfig)).rejects.toThrow( + 'Invalid entry for route "/string-return"', + ) }) }) From d110f3f2d349220b41442b409887aa50f320284f Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sun, 29 Jun 2025 09:15:36 -0400 Subject: [PATCH 05/16] small cleanup and fixes --- .../router-sitemap/src/generateSitemap.ts | 47 +++++++---- .../tests/generateSitemap.test.ts | 77 ++++--------------- 2 files changed, 46 insertions(+), 78 deletions(-) diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts index e5fbc071e5..8394efd0cd 100644 --- a/packages/router-sitemap/src/generateSitemap.ts +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -76,7 +76,22 @@ function isDefined(value: T | undefined): value is T { return value !== undefined } -function isValidPriority(priority: unknown): priority is number { +function parseBaseUrl(url: string) { + const parsed = new URL(url) + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error( + `Invalid URL protocol: ${parsed.protocol}. Must be http or https.`, + ) + } + // Remove trailing slash if present + let normalized = parsed.origin + parsed.pathname + if (normalized.endsWith('/') && normalized.length > parsed.origin.length) { + normalized = normalized.slice(0, -1) + } + return normalized +} + +function isValidPriority(priority: number): boolean { return ( typeof priority === 'number' && !Number.isNaN(priority) && @@ -85,14 +100,14 @@ function isValidPriority(priority: unknown): priority is number { ) } -function isValidChangeFreq(changefreq: unknown): changefreq is ChangeFreq { +function isValidChangeFreq(changefreq: string): changefreq is ChangeFreq { return ( typeof changefreq === 'string' && CHANGEFREQ.includes(changefreq as ChangeFreq) ) } -function isValidLastMod(lastmod: unknown): lastmod is string | Date { +function isValidLastMod(lastmod: string | Date): boolean { if (lastmod instanceof Date) { return !isNaN(lastmod.getTime()) } @@ -105,13 +120,13 @@ function isValidLastMod(lastmod: unknown): lastmod is string | Date { return false } +/** Throw if sitemap entry value is invalid. */ function validateEntry( route: string, entry: StaticEntryOptions | DynamicEntryOptions, ): asserts entry is StaticEntryOptions | DynamicEntryOptions { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!entry || typeof entry !== 'object') { - throw new Error(`Invalid entry for route "${route}": must be an object.`) + if (!route.startsWith('/')) { + throw new Error(`Invalid entry ${route}: route must start with '/'`) } if (isDefined(entry.lastmod) && !isValidLastMod(entry.lastmod)) { @@ -140,12 +155,7 @@ export async function generateSitemap< const urls: Array = [] const { routes, priority, changefreq } = config - let siteUrl: string - try { - siteUrl = new URL(config.siteUrl).toString() - } catch { - throw new Error(`Invalid siteUrl: ${config.siteUrl}.`) - } + const siteUrl = parseBaseUrl(config.siteUrl) if (isDefined(priority) && !isValidPriority(priority)) { throw new Error(`Invalid priority: ${priority}. Must be between 0 and 1.`) @@ -159,7 +169,7 @@ export async function generateSitemap< const createEntry = ( route: string, - entry: DynamicEntryOptions | StaticEntryOptions, + entry: DynamicEntryOptions | StaticEntryOptions = {}, ): SitemapEntry => { validateEntry(route, entry) @@ -182,19 +192,22 @@ export async function generateSitemap< if (typeof routeValue === 'function') { const resolvedValue = await routeValue() + if (Array.isArray(resolvedValue)) { urls.push(...resolvedValue.map((entry) => createEntry(route, entry))) } else { urls.push(createEntry(route, resolvedValue)) } - } else if (Array.isArray(routeValue)) { - urls.push(...routeValue.map((entry) => createEntry(route, entry))) } else { - urls.push(createEntry(route, routeValue)) + if (Array.isArray(routeValue)) { + urls.push(...routeValue.map((entry) => createEntry(route, entry))) + } else { + urls.push(createEntry(route, routeValue)) + } } } else { // Simple route string without configuration - urls.push(createEntry(routeItem, {})) + urls.push(createEntry(routeItem)) } } diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index 16e201b53a..ba6b7fb55d 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -51,7 +51,7 @@ describe('generateSitemap', () => { expect(result).toContain('2023-12-01T10:00:00.000Z') }) - it('should handle empty routes', async () => { + it('returns empty urlset when no routes are provided', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [], @@ -63,39 +63,22 @@ describe('generateSitemap', () => { '', ) expect(result).toContain('') + expect(result).not.toContain('') }) - it('should validate siteUrl is required', async () => { - const config = { - siteUrl: '', - routes: [], - } as SitemapConfig - - await expect(generateSitemap(config)).rejects.toThrow( - 'Invalid siteUrl: . Must be a valid URL.', - ) - }) - - it('should validate siteUrl is a string', async () => { - const config = { - siteUrl: null, - routes: [], - } as any - - await expect(generateSitemap(config)).rejects.toThrow( - 'Invalid siteUrl: null. Must be a valid URL.', - ) - }) - - it('should validate siteUrl is a valid URL', async () => { - const config: SitemapConfig = { - siteUrl: 'not-a-valid-url', - routes: [], - } - - await expect(generateSitemap(config)).rejects.toThrow( - 'Invalid siteUrl: not-a-valid-url.', - ) + it('throws if siteUrl is invalid', async () => { + await expect( + generateSitemap({ + siteUrl: '', + routes: [], + }), + ).rejects.toThrow() + await expect( + generateSitemap({ + siteUrl: 'not-a-valid-url', + routes: [], + }), + ).rejects.toThrow() }) it('should handle sync function for static routes', async () => { @@ -268,7 +251,7 @@ describe('generateSitemap', () => { expect(result).toContain('0.85') }) - it('should skip undefined optional fields', async () => { + it('should ignore undefined optional fields', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -576,32 +559,4 @@ describe('generateSitemap', () => { } await expect(generateSitemap(undefinedConfig)).rejects.toThrow() }) - - it('should handle functions returning invalid data', async () => { - const nullConfig: SitemapConfig = { - siteUrl: 'https://example.com', - // @ts-ignore - Invalid configuration - routes: [['/null-return', () => null]], - } - await expect(generateSitemap(nullConfig)).rejects.toThrow( - 'Invalid entry for route "/null-return"', - ) - - const undefinedConfig: SitemapConfig = { - siteUrl: 'https://example.com', - // @ts-ignore - Invalid configuration - routes: [['/undefined-return', () => undefined]], - } - await expect(generateSitemap(undefinedConfig)).rejects.toThrow( - 'Invalid entry for route "/undefined-return"', - ) - - const stringConfig: SitemapConfig = { - siteUrl: 'https://example.com', - routes: [['/string-return', () => 'not-an-object' as any]], - } - await expect(generateSitemap(stringConfig)).rejects.toThrow( - 'Invalid entry for route "/string-return"', - ) - }) }) From f527a5c1c3117b71fc980a5c3865c461152dbee6 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sun, 29 Jun 2025 10:07:31 -0400 Subject: [PATCH 06/16] add sitemap vite plugin to router basic example --- examples/react/basic/package.json | 1 + examples/react/basic/public/sitemap.xml | 83 +++++++++++++++++++++++++ examples/react/basic/vite.config.js | 30 ++++++++- pnpm-lock.yaml | 3 + 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 examples/react/basic/public/sitemap.xml diff --git a/examples/react/basic/package.json b/examples/react/basic/package.json index eee2783a3b..e50d0c673b 100644 --- a/examples/react/basic/package.json +++ b/examples/react/basic/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tanstack/react-router": "^1.123.2", "@tanstack/react-router-devtools": "^1.123.2", + "@tanstack/router-sitemap": "^1.121.37", "react": "^19.0.0", "react-dom": "^19.0.0", "redaxios": "^0.5.1", diff --git a/examples/react/basic/public/sitemap.xml b/examples/react/basic/public/sitemap.xml new file mode 100644 index 0000000000..a177c415f4 --- /dev/null +++ b/examples/react/basic/public/sitemap.xml @@ -0,0 +1,83 @@ + + + + http://localhost:3000/ + 0.5 + weekly + + + http://localhost:3000/posts + 0.5 + weekly + + + /posts/1 + 0.8 + daily + http://localhost:3000/posts/1 + + + /posts/2 + 0.8 + daily + http://localhost:3000/posts/2 + + + /posts/3 + 0.8 + daily + http://localhost:3000/posts/3 + + + /posts/4 + 0.8 + daily + http://localhost:3000/posts/4 + + + /posts/5 + 0.8 + daily + http://localhost:3000/posts/5 + + + /posts/6 + 0.8 + daily + http://localhost:3000/posts/6 + + + /posts/7 + 0.8 + daily + http://localhost:3000/posts/7 + + + /posts/8 + 0.8 + daily + http://localhost:3000/posts/8 + + + /posts/9 + 0.8 + daily + http://localhost:3000/posts/9 + + + /posts/10 + 0.8 + daily + http://localhost:3000/posts/10 + + + http://localhost:3000/route-a + 0.5 + weekly + + + http://localhost:3000/route-b + 0.5 + weekly + + diff --git a/examples/react/basic/vite.config.js b/examples/react/basic/vite.config.js index 5a33944a9b..53841e8975 100644 --- a/examples/react/basic/vite.config.js +++ b/examples/react/basic/vite.config.js @@ -1,7 +1,35 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { sitemapPlugin } from '@tanstack/router-sitemap/vite-plugin' +import { fetchPosts } from './src/posts' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + sitemapPlugin({ + sitemap: { + siteUrl: 'http://localhost:3000', + priority: 0.5, + changefreq: 'weekly', + routes: [ + '/', + '/posts', + [ + '/posts/$postId', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + priority: 0.8, + changefreq: 'daily', + })) + }, + ], + '/route-a', + '/route-b', + ], + }, + }), + ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87795e4496..2dcbc054ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2537,6 +2537,9 @@ importers: '@tanstack/react-router-devtools': specifier: workspace:^ version: link:../../../packages/react-router-devtools + '@tanstack/router-sitemap': + specifier: workspace:* + version: link:../../../packages/router-sitemap autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.5.3) From e8b20d2a17a56fe49ed7cef51575131c2ce51bd8 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sun, 29 Jun 2025 12:32:01 -0400 Subject: [PATCH 07/16] sitemap vite plugin --- examples/react/basic/.gitignore | 3 +- examples/react/basic/public/sitemap.xml | 83 -------------------- packages/router-sitemap/README.md | 1 + packages/router-sitemap/src/sitemapPlugin.ts | 48 ++++++----- packages/router-sitemap/vite.config.ts | 1 + 5 files changed, 31 insertions(+), 105 deletions(-) delete mode 100644 examples/react/basic/public/sitemap.xml create mode 100644 packages/router-sitemap/README.md diff --git a/examples/react/basic/.gitignore b/examples/react/basic/.gitignore index 8354e4d50d..c8aca7e3a7 100644 --- a/examples/react/basic/.gitignore +++ b/examples/react/basic/.gitignore @@ -7,4 +7,5 @@ dist-ssr /test-results/ /playwright-report/ /blob-report/ -/playwright/.cache/ \ No newline at end of file +/playwright/.cache/ +/public/sitemap.xml \ No newline at end of file diff --git a/examples/react/basic/public/sitemap.xml b/examples/react/basic/public/sitemap.xml deleted file mode 100644 index a177c415f4..0000000000 --- a/examples/react/basic/public/sitemap.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - http://localhost:3000/ - 0.5 - weekly - - - http://localhost:3000/posts - 0.5 - weekly - - - /posts/1 - 0.8 - daily - http://localhost:3000/posts/1 - - - /posts/2 - 0.8 - daily - http://localhost:3000/posts/2 - - - /posts/3 - 0.8 - daily - http://localhost:3000/posts/3 - - - /posts/4 - 0.8 - daily - http://localhost:3000/posts/4 - - - /posts/5 - 0.8 - daily - http://localhost:3000/posts/5 - - - /posts/6 - 0.8 - daily - http://localhost:3000/posts/6 - - - /posts/7 - 0.8 - daily - http://localhost:3000/posts/7 - - - /posts/8 - 0.8 - daily - http://localhost:3000/posts/8 - - - /posts/9 - 0.8 - daily - http://localhost:3000/posts/9 - - - /posts/10 - 0.8 - daily - http://localhost:3000/posts/10 - - - http://localhost:3000/route-a - 0.5 - weekly - - - http://localhost:3000/route-b - 0.5 - weekly - - diff --git a/packages/router-sitemap/README.md b/packages/router-sitemap/README.md new file mode 100644 index 0000000000..bc3914751d --- /dev/null +++ b/packages/router-sitemap/README.md @@ -0,0 +1 @@ +# TanStack Router Sitemap Generator diff --git a/packages/router-sitemap/src/sitemapPlugin.ts b/packages/router-sitemap/src/sitemapPlugin.ts index b2c31855ed..983c73bd56 100644 --- a/packages/router-sitemap/src/sitemapPlugin.ts +++ b/packages/router-sitemap/src/sitemapPlugin.ts @@ -1,5 +1,6 @@ -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import { createLogger } from 'vite' import { generateSitemap } from './generateSitemap' import type { SitemapConfig } from './generateSitemap' import type { RegisteredRouter } from '@tanstack/router-core' @@ -9,37 +10,42 @@ export interface SitemapPluginOptions< TRouter extends RegisteredRouter = RegisteredRouter, > { sitemap: SitemapConfig - outputDir?: string - filename?: string + path?: string } export function sitemapPlugin< TRouter extends RegisteredRouter = RegisteredRouter, >(options: SitemapPluginOptions): Plugin { - const { sitemap, outputDir = 'public', filename = 'sitemap.xml' } = options + const { sitemap, path = 'sitemap.xml' } = options + const logger = createLogger('info', { prefix: '[sitemap]' }) + let publicDir = 'public' + + const generateAndWrite = async () => { + try { + const sitemapXml = await generateSitemap(sitemap) + const outputPath = join(publicDir, path) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, sitemapXml, 'utf8') + logger.info(`Sitemap generated: ${outputPath}`) + } catch (error) { + logger.error(`Failed to generate sitemap: ${error}`) + throw error + } + } return { name: 'sitemap', - async buildEnd() { - try { - const sitemapXml = await generateSitemap(sitemap) - - const outputPath = join(outputDir, filename) - const dirPath = dirname(outputPath) - try { - mkdirSync(dirPath, { recursive: true }) - } catch { - // Directory might already exist - } + configResolved(cfg) { + publicDir = cfg.publicDir + }, - writeFileSync(outputPath, sitemapXml, 'utf8') + async buildStart() { + await generateAndWrite() + }, - console.log(`Sitemap generated: ${outputPath}`) - } catch (error) { - console.error('Failed to generate sitemap:', error) - throw error - } + async configureServer() { + await generateAndWrite() }, } } diff --git a/packages/router-sitemap/vite.config.ts b/packages/router-sitemap/vite.config.ts index e346aba9f1..4aea0863c5 100644 --- a/packages/router-sitemap/vite.config.ts +++ b/packages/router-sitemap/vite.config.ts @@ -16,5 +16,6 @@ export default mergeConfig( tanstackViteConfig({ entry: ['./src/index.ts', './src/sitemapPlugin.ts'], srcDir: './src', + externalDeps: ['vite', 'node:fs/promises', 'node:path'], }), ) From 8a1aa95c6e1ebadfe4e9e0adac3ede4003929e3d Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Sun, 29 Jun 2025 12:32:26 -0400 Subject: [PATCH 08/16] fix ignore --- examples/react/basic/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/react/basic/.gitignore b/examples/react/basic/.gitignore index c8aca7e3a7..4205052dd4 100644 --- a/examples/react/basic/.gitignore +++ b/examples/react/basic/.gitignore @@ -8,4 +8,4 @@ dist-ssr /playwright-report/ /blob-report/ /playwright/.cache/ -/public/sitemap.xml \ No newline at end of file +public/sitemap.xml \ No newline at end of file From 421d376621aa0f1e2f83534ae726919043c6799d Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 08:27:29 -0400 Subject: [PATCH 09/16] small fixes, cleanup, and improving test cases --- packages/router-sitemap/eslint.config.js | 2 +- .../router-sitemap/src/generateSitemap.ts | 6 +- packages/router-sitemap/src/sitemapPlugin.ts | 3 +- .../tests/generateSitemap.test.ts | 323 +++++++++--------- 4 files changed, 161 insertions(+), 173 deletions(-) diff --git a/packages/router-sitemap/eslint.config.js b/packages/router-sitemap/eslint.config.js index b31092bbb4..ab7211d69d 100644 --- a/packages/router-sitemap/eslint.config.js +++ b/packages/router-sitemap/eslint.config.js @@ -13,4 +13,4 @@ export default [ }, }, }, -] \ No newline at end of file +] diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts index 8394efd0cd..4f1f471070 100644 --- a/packages/router-sitemap/src/generateSitemap.ts +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -129,6 +129,11 @@ function validateEntry( throw new Error(`Invalid entry ${route}: route must start with '/'`) } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof entry !== 'object' && entry !== null) { + throw new Error(`Invalid entry ${route}: entry must be an object`) + } + if (isDefined(entry.lastmod) && !isValidLastMod(entry.lastmod)) { throw new Error(`Invalid entry ${route}: lastmod must be a string or Date`) } @@ -174,7 +179,6 @@ export async function generateSitemap< validateEntry(route, entry) return { - ...entry, loc: 'path' in entry ? `${siteUrl}${entry.path}` : `${siteUrl}${route}`, lastmod: entry.lastmod instanceof Date diff --git a/packages/router-sitemap/src/sitemapPlugin.ts b/packages/router-sitemap/src/sitemapPlugin.ts index 983c73bd56..b8fd403d0d 100644 --- a/packages/router-sitemap/src/sitemapPlugin.ts +++ b/packages/router-sitemap/src/sitemapPlugin.ts @@ -28,8 +28,7 @@ export function sitemapPlugin< await writeFile(outputPath, sitemapXml, 'utf8') logger.info(`Sitemap generated: ${outputPath}`) } catch (error) { - logger.error(`Failed to generate sitemap: ${error}`) - throw error + throw new Error('Failed to write sitemap file.', { cause: error }) } } diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index ba6b7fb55d..1e2df8a3a2 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, test } from 'vitest' import { generateSitemap } from '../src/index' import type { SitemapConfig } from '../src/index' describe('generateSitemap', () => { - it('should generate basic sitemap XML', async () => { + test('generates basic sitemap XML', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -25,15 +25,19 @@ describe('generateSitemap', () => { expect(result).toContain( '', ) - expect(result).toContain('https://example.com/home') - expect(result).toContain('https://example.com/about') - expect(result).toContain('2023-12-01') - expect(result).toContain('monthly') - expect(result).toContain('0.8') + expect(result).toContain(` + https://example.com/home + `) + expect(result).toContain(` + https://example.com/about + 2023-12-01 + 0.8 + monthly + `) expect(result).toContain('') }) - it('should handle Date objects for lastmod', async () => { + test('handles Date objects for lastmod', async () => { const date = new Date('2023-12-01T10:00:00Z') const config: SitemapConfig = { siteUrl: 'https://example.com', @@ -51,7 +55,7 @@ describe('generateSitemap', () => { expect(result).toContain('2023-12-01T10:00:00.000Z') }) - it('returns empty urlset when no routes are provided', async () => { + test('returns empty urlset when no routes are provided', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [], @@ -60,13 +64,11 @@ describe('generateSitemap', () => { const result = await generateSitemap(config) expect(result).toContain('') expect(result).toContain( - '', + '', ) - expect(result).toContain('') - expect(result).not.toContain('') }) - it('throws if siteUrl is invalid', async () => { + test('throws if siteUrl is invalid', async () => { await expect( generateSitemap({ siteUrl: '', @@ -79,9 +81,29 @@ describe('generateSitemap', () => { routes: [], }), ).rejects.toThrow() + await expect( + // @ts-ignore - Invalid type for siteUrl + generateSitemap({ + routes: [], + }), + ).rejects.toThrow() + await expect( + generateSitemap({ + // @ts-ignore - Invalid type for siteUrl + siteUrl: 123, + routes: [], + }), + ).rejects.toThrow() + await expect( + generateSitemap({ + // @ts-ignore - Invalid type for siteUrl + siteUrl: null, + routes: [], + }), + ).rejects.toThrow() }) - it('should handle sync function for static routes', async () => { + test('handles sync function for static routes', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -96,12 +118,14 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('2023-12-01') - expect(result).toContain('0.9') + expect(result).toContain(` + https://example.com/home + 2023-12-01 + 0.9 + `) }) - it('should handle function returning array for dynamic routes', async () => { + test('handles function returning array for dynamic routes', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -117,13 +141,17 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('https://example.com/posts/2') - expect(result).toContain('2023-12-01') - expect(result).toContain('2023-12-02') + expect(result).toContain(` + https://example.com/posts/1 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/2 + 2023-12-02 + `) }) - it('should handle array of dynamic entries', async () => { + test('handles array of dynamic entries', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -139,13 +167,17 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/array-1') - expect(result).toContain('https://example.com/posts/array-2') - expect(result).toContain('2023-12-01') - expect(result).toContain('weekly') + expect(result).toContain(` + https://example.com/posts/array-1 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/array-2 + weekly + `) }) - it('should handle mix of static and dynamic routes', async () => { + test('handles mix of static and dynamic routes', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -160,26 +192,21 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('1') - expect(result).toContain('https://example.com/about') - expect(result).toContain('2023-12-01') - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('daily') - }) - - it('should generate proper XML declaration', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - routes: ['/home'], - } - - const result = await generateSitemap(config) - - expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) + expect(result).toContain(` + https://example.com/home + 1 + `) + expect(result).toContain(` + https://example.com/about + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/1 + daily + `) }) - it('should handle siteUrl with trailing slash', async () => { + test('handles siteUrl with trailing slash', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com/', routes: ['/home'], @@ -189,7 +216,7 @@ describe('generateSitemap', () => { expect(result).toContain('https://example.com/home') }) - it('should handle special characters in URLs', async () => { + test('handles special characters in URLs', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: ['/special-chars?param=value&other=123'], @@ -201,7 +228,7 @@ describe('generateSitemap', () => { ) }) - it('should handle unicode characters in URLs', async () => { + test('handles unicode characters in URLs', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -217,7 +244,7 @@ describe('generateSitemap', () => { expect(result).toContain('https://example.com/posts/héllo-wörld') }) - it('should handle priority value 0', async () => { + test('handles priority value 0', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -234,7 +261,7 @@ describe('generateSitemap', () => { expect(result).toContain('0') }) - it('should handle decimal priority values', async () => { + test('handles decimal priority values', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -251,7 +278,7 @@ describe('generateSitemap', () => { expect(result).toContain('0.85') }) - it('should ignore undefined optional fields', async () => { + test('ignores undefined optional fields', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -273,7 +300,7 @@ describe('generateSitemap', () => { expect(result).not.toContain('') }) - it('should handle async static route function', async () => { + test('handles async static route function', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -292,13 +319,15 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/async-static') - expect(result).toContain('2023-12-01') - expect(result).toContain('weekly') - expect(result).toContain('0.8') + expect(result).toContain(` + https://example.com/async-static + 2023-12-01 + 0.8 + weekly + `) }) - it('should handle async dynamic route function', async () => { + test('handles async dynamic route function', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -317,13 +346,17 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/posts/async-1') - expect(result).toContain('https://example.com/posts/async-2') - expect(result).toContain('2023-12-01') - expect(result).toContain('2023-12-02') + expect(result).toContain(` + https://example.com/posts/async-1 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/async-2 + 2023-12-02 + `) }) - it('should apply priority to routes without explicit priority', async () => { + test('applies priority to routes without explicit priority', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', priority: 0.5, @@ -334,13 +367,17 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('0.5') // Default priority - expect(result).toContain('https://example.com/about') - expect(result).toContain('0.9') // Explicit priority + expect(result).toContain(` + https://example.com/home + 0.5 + `) + expect(result).toContain(` + https://example.com/about + 0.9 + `) }) - it('should apply changefreq to routes without explicit changefreq', async () => { + test('applies changefreq to routes without explicit changefreq', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', changefreq: 'weekly', @@ -351,13 +388,17 @@ describe('generateSitemap', () => { } const result = await generateSitemap(config) - expect(result).toContain('https://example.com/home') - expect(result).toContain('weekly') // Default changefreq - expect(result).toContain('https://example.com/about') - expect(result).toContain('daily') // Explicit changefreq + expect(result).toContain(` + https://example.com/home + weekly + `) + expect(result).toContain(` + https://example.com/about + daily + `) }) - it('should apply both default values together', async () => { + test('applies both default values together', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', priority: 0.7, @@ -371,101 +412,24 @@ describe('generateSitemap', () => { const result = await generateSitemap(config) - // /home should have both defaults - expect(result).toContain('https://example.com/home') - expect(result).toContain('0.7') - expect(result).toContain('monthly') - - // /about should have explicit priority, default changefreq - expect(result).toContain('https://example.com/about') - expect(result).toContain('0.9') - expect(result).toContain('monthly') - - // /contact should have default priority, explicit changefreq - expect(result).toContain('https://example.com/contact') - expect(result).toContain('0.7') - expect(result).toContain('yearly') - }) - - it('should apply defaults to dynamic routes', async () => { - // @ts-ignore - Test configuration - const config: SitemapConfig = { - siteUrl: 'https://example.com', - priority: 0.6, - changefreq: 'weekly', - routes: [ - [ - '/posts/$postId', - // @ts-ignore - Dynamic route for testing - [ - { path: '/posts/1' }, // Should get defaults - { path: '/posts/2', priority: 0.9 }, // Should get default changefreq, explicit priority - { path: '/posts/3', changefreq: 'daily' as const }, // Should get default priority, explicit changefreq - ], - ], - ], - } - - const result = await generateSitemap(config) - - expect(result).toContain('https://example.com/posts/1') - expect(result).toContain('0.6') - expect(result).toContain('weekly') - - expect(result).toContain('https://example.com/posts/2') - expect(result).toContain('0.9') - expect(result).toContain('weekly') - - expect(result).toContain('https://example.com/posts/3') - expect(result).toContain('0.6') - expect(result).toContain('daily') - }) - - it('should apply defaults to function-based routes', async () => { - const config: SitemapConfig = { - siteUrl: 'https://example.com', - priority: 0.4, - changefreq: 'monthly', - routes: [ - ['/static-func', () => ({})], // Function returning empty object, should get defaults - ['/static-func-partial', () => ({ priority: 0.8 })], // Should get default changefreq - // @ts-ignore - Dynamic route for testing - [ - '/dynamic-func', - () => - [ - { path: '/dynamic/1' }, // Should get defaults - { path: '/dynamic/2', changefreq: 'hourly' as const }, // Should get default priority - ] as any, - ], - ], - } - - const result = await generateSitemap(config) - - // Static function with defaults - expect(result).toContain('https://example.com/static-func') - expect(result).toContain('0.4') - expect(result).toContain('monthly') - - // Static function with partial defaults - expect(result).toContain( - 'https://example.com/static-func-partial', - ) - expect(result).toContain('0.8') - expect(result).toContain('monthly') - - // Dynamic function with defaults - expect(result).toContain('https://example.com/dynamic/1') - expect(result).toContain('0.4') - expect(result).toContain('monthly') - - expect(result).toContain('https://example.com/dynamic/2') - expect(result).toContain('0.4') - expect(result).toContain('hourly') + expect(result).toContain(` + https://example.com/home + 0.7 + monthly + `) + expect(result).toContain(` + https://example.com/about + 0.9 + monthly + `) + expect(result).toContain(` + https://example.com/contact + 0.7 + yearly + `) }) - it('should handle function errors gracefully', async () => { + test('handles function errors gracefully', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -481,7 +445,7 @@ describe('generateSitemap', () => { await expect(generateSitemap(config)).rejects.toThrow('Test function error') }) - it('should validate invalid priority values', async () => { + test('validates invalid priority values', async () => { const negativeConfig: SitemapConfig = { siteUrl: 'https://example.com', routes: [['/negative-priority', { priority: -0.5 }]], @@ -507,7 +471,7 @@ describe('generateSitemap', () => { ) }) - it('should validate invalid changefreq values', async () => { + test('validates invalid changefreq values', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [['/invalid-changefreq', { changefreq: 'invalid' as any }]], @@ -518,7 +482,7 @@ describe('generateSitemap', () => { ) }) - it('should escape XML special characters in URLs', async () => { + test('escapes XML special characters in URLs', async () => { const config: SitemapConfig = { siteUrl: 'https://example.com', routes: [ @@ -540,7 +504,7 @@ describe('generateSitemap', () => { ) }) - it('should handle invalid route paths', async () => { + test('handles invalid route paths', async () => { const emptyConfig: SitemapConfig = { siteUrl: 'https://example.com', routes: [''], // Empty string @@ -559,4 +523,25 @@ describe('generateSitemap', () => { } await expect(generateSitemap(undefinedConfig)).rejects.toThrow() }) + + test('handles functions returning invalid data', async () => { + await expect( + generateSitemap({ + siteUrl: 'https://example.com', + routes: [['/string-return', () => 'not-an-object' as any]], + }), + ).rejects.toThrow('Invalid entry /string-return: entry must be an object') + await expect( + generateSitemap({ + siteUrl: 'https://example.com', + routes: [['/number-return', () => 123 as any]], + }), + ).rejects.toThrow('Invalid entry /number-return: entry must be an object') + await expect( + generateSitemap({ + siteUrl: 'https://example.com', + routes: [['/array-return', () => [1, 2, 3] as any]], + }), + ).rejects.toThrow('Invalid entry /array-return: entry must be an object') + }) }) From d0ab52cff743f1cf79343e650ca18726f0e6345f Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 08:29:51 -0400 Subject: [PATCH 10/16] cleanup --- packages/router-sitemap/src/generateSitemap.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/router-sitemap/src/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts index 4f1f471070..9a3d3829bb 100644 --- a/packages/router-sitemap/src/generateSitemap.ts +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -17,7 +17,6 @@ export interface SitemapEntry extends StaticEntryOptions { loc: string } -// Utility types for route param detection type SplitPath = TSegment extends `${infer Segment}/${infer Rest}` ? Segment | SplitPath @@ -40,13 +39,9 @@ export type DynamicRouteValue = | Array | (() => Array | Promise>) -/** - * Pick which shape to use based on whether `TRoute` is dynamic or static. - */ type RouteValue = RouteIsDynamic extends true ? DynamicRouteValue : StaticRouteValue -/** Sitemap configuration */ export interface SitemapConfig< TRouter extends RegisteredRouter = RegisteredRouter, > { @@ -120,7 +115,7 @@ function isValidLastMod(lastmod: string | Date): boolean { return false } -/** Throw if sitemap entry value is invalid. */ +/** Throws if sitemap entry value is invalid. */ function validateEntry( route: string, entry: StaticEntryOptions | DynamicEntryOptions, @@ -151,9 +146,7 @@ function validateEntry( } } -/** - * Generate sitemap XML from configuration - */ +/** Generate sitemap XML from configuration */ export async function generateSitemap< TRouter extends RegisteredRouter = RegisteredRouter, >(config: SitemapConfig): Promise { From a3ed725aba0d88cafbec3369841efeb6003ac57e Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 20:20:03 -0400 Subject: [PATCH 11/16] document the sitemap package --- packages/router-sitemap/README.md | 146 ++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/packages/router-sitemap/README.md b/packages/router-sitemap/README.md index bc3914751d..3cff8b377a 100644 --- a/packages/router-sitemap/README.md +++ b/packages/router-sitemap/README.md @@ -1 +1,147 @@ # TanStack Router Sitemap Generator + +Create an XML sitemap for your TanStack Router/Start based website. + +This package provides a `generateSitemap` function and a `sitemapPlugin` vite plugin. + +- Use the `sitemapPlugin` if you want to generate a static XML file during your Vite build. +- Use the `generateSitemap` function to generate a sitemap at request time in a Server Function/API Route using TanStack Start. + +See the [configuration section](#sitemap-configuration) for more details on how to declare your sitemap. + +## Installation + +```bash +npm install @tanstack/router-sitemap +``` + +## Vite Plugin + +The `examples/react/basic` example includes a sitemap generated using the Vite plugin. + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { sitemapPlugin } from '@tanstack/router-sitemap/vite-plugin' + +export default defineConfig({ + plugins: [ + sitemapPlugin({ + sitemap: { + siteUrl: 'https://tanstack.com', + routes: ['/', '/posts'], + }, + }), + ], +}) +``` + +### Plugin Configuration + +| Property | Type | Default | Description | +| --------- | --------------- | --------------- | --------------------------------------------- | +| `sitemap` | `SitemapConfig` | Required | Sitemap configuration object | +| `path` | `string` | `'sitemap.xml'` | Output file path relative to public directory | + +## Start API Route + +The `examples/react/start-basic` example includes a `sitemap.xml` API route. + +```ts +// routes/sitemap[.]xml.ts +import { createServerFileRoute } from '@tanstack/react-start/server' +import { generateSitemap } from '@tanstack/router-sitemap' +import { fetchPosts } from '~/utils/posts' + +export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ + GET: async () => { + const sitemap = await generateSitemap({ + siteUrl: 'https://tanstack.com', + routes: [ + '/', + [ + '/posts/$postId', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + priority: 0.8, + changefreq: 'daily', + })) + }, + ], + ], + }) + + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + }, + }) + }, +}) +``` + +## Sitemap Configuration + +### Configuration Options + +| Property | Type | Required | Description | +| ------------ | ------------------ | -------- | -------------------------------------------------------- | +| `siteUrl` | `string` | Yes | Base URL of your website (e.g., `'https://example.com'`) | +| `routes` | `Array` | Yes | Array of routes to include in the sitemap | +| `priority` | `number` | No | Default priority for all routes (0.0-1.0) | +| `changefreq` | `ChangeFreq` | No | Default change frequency for all routes | + +### Route Configuration + +Route strings are inferred from your router similar to a `Link`'s `to` prop. + +Routes can be configured as: + +1. **Simple strings**: `'/'`, `'/about'` +2. **Configuration tuples**: `['/route-path', options]` + +### Route Options + +| Property | Type | Description | +| ------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `path` | `string` | Custom path for dynamic routes | +| `lastmod` | `string \| Date` | Last modification date | +| `changefreq` | `'always' \| 'hourly' \| 'daily' \| 'weekly' \| 'monthly' \| 'yearly' \| 'never'` | How frequently the page changes | +| `priority` | `number` | Priority of this URL relative to other URLs (0.0-1.0) | + +### Static Route Configuration + +```ts +routes: [ + '/', // Simple string + ['/about', { priority: 0.9, changefreq: 'monthly' }], // With options + ['/dynamic', async () => ({ lastmod: await getLastModified() })], // Async function +] +``` + +### Dynamic Route Configuration + +For routes with parameters, use functions that return arrays: + +```ts +routes: [ + [ + '/posts/$postId', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + lastmod: post.updatedAt, + priority: 0.8, + changefreq: 'daily', + })) + }, + ], +] +``` + +## Credit + +This package is partly based on an existing plugin from the community: [tanstack-router-sitemap](https://github.com/Ryanjso/tanstack-router-sitemap). From 1299e9c19b39caf924c55aa9d09d9951a6389aec Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 20:39:51 -0400 Subject: [PATCH 12/16] add additional routes to react-start example sitemap --- .../react/start-basic/src/routes/sitemap[.]xml.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/react/start-basic/src/routes/sitemap[.]xml.ts b/examples/react/start-basic/src/routes/sitemap[.]xml.ts index 2ee05e7fd9..a6b5a0553d 100644 --- a/examples/react/start-basic/src/routes/sitemap[.]xml.ts +++ b/examples/react/start-basic/src/routes/sitemap[.]xml.ts @@ -10,6 +10,10 @@ export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ changefreq: 'weekly', routes: [ '/', + '/posts', + '/route-a', + '/route-b', + '/deferred', [ '/posts/$postId', async () => { @@ -21,6 +25,17 @@ export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({ })) }, ], + [ + '/posts/$postId/deep', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}/deep`, + priority: 0.7, + changefreq: 'weekly', + })) + }, + ], ], }) From a3674e3b9a5ced0f4624b805892faacc12b8a695 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 20:40:08 -0400 Subject: [PATCH 13/16] add test check for path in xml output --- .../tests/generateSitemap.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index 1e2df8a3a2..9a21c06fbb 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -544,4 +544,28 @@ describe('generateSitemap', () => { }), ).rejects.toThrow('Invalid entry /array-return: entry must be an object') }) + + test('path property should not appear in XML output', async () => { + const result = await generateSitemap({ + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId' as any, + () => [ + { path: '/posts/1', priority: 0.8 }, + { path: '/posts/2', priority: 0.8 }, + ], + ], + ], + }) + + // Should contain the correct loc elements + expect(result).toContain('https://example.com/posts/1') + expect(result).toContain('https://example.com/posts/2') + + // Should NOT contain path elements in XML + expect(result).not.toContain('/posts/1') + expect(result).not.toContain('/posts/2') + expect(result).not.toContain('') + }) }) From 95ce910a23d25d71b6889621ee20f2f9c4234187 Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 20:51:38 -0400 Subject: [PATCH 14/16] fix: lockfile --- pnpm-lock.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dcbc054ed..d1b8c0db38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3676,10 +3676,10 @@ importers: version: 19.0.3(@types/react@19.0.8) html-webpack-plugin: specifier: ^5.6.3 - version: 5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + version: 5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + version: 0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1) typescript: specifier: ^5.7.2 version: 5.8.2 @@ -6436,7 +6436,7 @@ importers: version: 5.8.2 vite: specifier: 6.3.5 - version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) packages/router-utils: dependencies: @@ -19649,17 +19649,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack-dev-server@5.2.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4))': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.0)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1) @@ -21702,7 +21702,7 @@ snapshots: html-tags@3.3.1: {} - html-webpack-plugin@5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): + html-webpack-plugin@5.6.3(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -24002,7 +24002,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swc-loader@0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): + swc-loader@0.2.6(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) '@swc/counter': 0.1.3 @@ -24074,26 +24074,26 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 - terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): + terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) + webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4) optionalDependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) esbuild: 0.25.4 - terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)): + terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4) + webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.10.15(@swc/helpers@0.5.15) esbuild: 0.25.4 @@ -24722,9 +24722,9 @@ snapshots: webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.2.0)(webpack@5.97.1))(webpack-dev-server@5.2.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.0)(webpack@5.97.1) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -24738,7 +24738,7 @@ snapshots: optionalDependencies: webpack-dev-server: 5.2.0(webpack-cli@5.1.4)(webpack@5.97.1) - webpack-dev-middleware@7.4.2(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)): + webpack-dev-middleware@7.4.2(webpack@5.97.1): dependencies: colorette: 2.0.20 memfs: 4.17.0 @@ -24776,7 +24776,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + webpack-dev-middleware: 7.4.2(webpack@5.97.1) ws: 8.18.0 optionalDependencies: webpack: 5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4) @@ -24851,7 +24851,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack-cli@5.1.4)) + terser-webpack-plugin: 5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(esbuild@0.25.4)(webpack@5.97.1) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: From b96097faebbe996fe5309840d09c417c6f003a1f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 00:52:43 +0000 Subject: [PATCH 15/16] ci: apply automated fixes --- labeler-config.yml | 3 +++ packages/router-sitemap/src/index.ts | 2 +- packages/router-sitemap/tests/generateSitemap.test.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/labeler-config.yml b/labeler-config.yml index 40fd47941c..629755312f 100644 --- a/labeler-config.yml +++ b/labeler-config.yml @@ -49,6 +49,9 @@ 'package: router-plugin': - changed-files: - any-glob-to-any-file: 'packages/router-plugin/**/*' +'package: router-sitemap': + - changed-files: + - any-glob-to-any-file: 'packages/router-sitemap/**/*' 'package: router-utils': - changed-files: - any-glob-to-any-file: 'packages/router-utils/**/*' diff --git a/packages/router-sitemap/src/index.ts b/packages/router-sitemap/src/index.ts index 79990a5b06..939682a035 100644 --- a/packages/router-sitemap/src/index.ts +++ b/packages/router-sitemap/src/index.ts @@ -1 +1 @@ -export * from './generateSitemap' \ No newline at end of file +export * from './generateSitemap' diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index 9a21c06fbb..b5505ecb97 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -562,7 +562,7 @@ describe('generateSitemap', () => { // Should contain the correct loc elements expect(result).toContain('https://example.com/posts/1') expect(result).toContain('https://example.com/posts/2') - + // Should NOT contain path elements in XML expect(result).not.toContain('/posts/1') expect(result).not.toContain('/posts/2') From a3eb3f8f1ff8b49854641f16e63051cbe46a7cef Mon Sep 17 00:00:00 2001 From: Dane Grant Date: Mon, 30 Jun 2025 21:07:00 -0400 Subject: [PATCH 16/16] more anys make ts no mad --- packages/router-sitemap/tests/generateSitemap.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts index b5505ecb97..c1122ccb82 100644 --- a/packages/router-sitemap/tests/generateSitemap.test.ts +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' import { generateSitemap } from '../src/index' -import type { SitemapConfig } from '../src/index' +import type { SitemapConfig, DynamicEntryOptions } from '../src/index' describe('generateSitemap', () => { test('generates basic sitemap XML', async () => { @@ -550,12 +550,12 @@ describe('generateSitemap', () => { siteUrl: 'https://example.com', routes: [ [ - '/posts/$postId' as any, + '/posts/$postId', () => [ { path: '/posts/1', priority: 0.8 }, { path: '/posts/2', priority: 0.8 }, ], - ], + ] as any, ], })