diff --git a/examples/react/basic/.gitignore b/examples/react/basic/.gitignore index 8354e4d50d..4205052dd4 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/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/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/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..a6b5a0553d --- /dev/null +++ b/examples/react/start-basic/src/routes/sitemap[.]xml.ts @@ -0,0 +1,48 @@ +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', + priority: 0.5, + changefreq: 'weekly', + routes: [ + '/', + '/posts', + '/route-a', + '/route-b', + '/deferred', + [ + '/posts/$postId', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}`, + priority: 0.8, + changefreq: 'daily', + })) + }, + ], + [ + '/posts/$postId/deep', + async () => { + const posts = await fetchPosts() + return posts.map((post) => ({ + path: `/posts/${post.id}/deep`, + priority: 0.7, + changefreq: 'weekly', + })) + }, + ], + ], + }) + + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + }, + }) + }, +}) 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/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/README.md b/packages/router-sitemap/README.md new file mode 100644 index 0000000000..3cff8b377a --- /dev/null +++ b/packages/router-sitemap/README.md @@ -0,0 +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). diff --git a/packages/router-sitemap/eslint.config.js b/packages/router-sitemap/eslint.config.js new file mode 100644 index 0000000000..ab7211d69d --- /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, + }, + }, + }, +] diff --git a/packages/router-sitemap/package.json b/packages/router-sitemap/package.json new file mode 100644 index 0000000000..69f35c29d6 --- /dev/null +++ b/packages/router-sitemap/package.json @@ -0,0 +1,81 @@ +{ + "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: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", + "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" + } + }, + "./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, + "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/generateSitemap.ts b/packages/router-sitemap/src/generateSitemap.ts new file mode 100644 index 0000000000..9a3d3829bb --- /dev/null +++ b/packages/router-sitemap/src/generateSitemap.ts @@ -0,0 +1,233 @@ +import { XMLBuilder } from 'fast-xml-parser' +import type { RegisteredRouter, RoutePaths } from '@tanstack/router-core' + +export type ChangeFreq = (typeof CHANGEFREQ)[number] + +export interface StaticEntryOptions { + lastmod?: string | Date + changefreq?: ChangeFreq + priority?: number +} + +export interface DynamicEntryOptions extends StaticEntryOptions { + path?: string +} + +export interface SitemapEntry extends StaticEntryOptions { + loc: string +} + +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 = + | StaticEntryOptions + | (() => StaticEntryOptions | Promise) + +export type DynamicRouteValue = + | Array + | (() => Array | Promise>) + +type RouteValue = + RouteIsDynamic extends true ? DynamicRouteValue : StaticRouteValue + +export interface SitemapConfig< + TRouter extends RegisteredRouter = RegisteredRouter, +> { + siteUrl: string + priority?: number + changefreq?: ChangeFreq + routes: Array< + | RoutePaths + | [ + RoutePaths, + RouteValue>, + ] + > +} + +const CHANGEFREQ = [ + 'always', + 'hourly', + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'never', +] as const + +function isDefined(value: T | undefined): value is T { + return value !== undefined +} + +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) && + priority >= 0 && + priority <= 1 + ) +} + +function isValidChangeFreq(changefreq: string): changefreq is ChangeFreq { + return ( + typeof changefreq === 'string' && + CHANGEFREQ.includes(changefreq as ChangeFreq) + ) +} + +function isValidLastMod(lastmod: string | Date): boolean { + if (lastmod instanceof Date) { + return !isNaN(lastmod.getTime()) + } + + if (typeof lastmod === 'string') { + const date = new Date(lastmod) + return !isNaN(date.getTime()) + } + + return false +} + +/** Throws if sitemap entry value is invalid. */ +function validateEntry( + route: string, + entry: StaticEntryOptions | DynamicEntryOptions, +): asserts entry is StaticEntryOptions | DynamicEntryOptions { + if (!route.startsWith('/')) { + 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`) + } + + 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 */ +export async function generateSitemap< + TRouter extends RegisteredRouter = RegisteredRouter, +>(config: SitemapConfig): Promise { + const urls: Array = [] + const { routes, priority, changefreq } = config + + const siteUrl = parseBaseUrl(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 => { + validateEntry(route, entry) + + return { + 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 { + urls.push(createEntry(route, routeValue)) + } + } + } else { + // Simple route string without configuration + urls.push(createEntry(routeItem)) + } + } + + 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, + processEntities: true, + }) + + return builder.build(xmlObject) +} diff --git a/packages/router-sitemap/src/index.ts b/packages/router-sitemap/src/index.ts new file mode 100644 index 0000000000..939682a035 --- /dev/null +++ b/packages/router-sitemap/src/index.ts @@ -0,0 +1 @@ +export * from './generateSitemap' diff --git a/packages/router-sitemap/src/sitemapPlugin.ts b/packages/router-sitemap/src/sitemapPlugin.ts new file mode 100644 index 0000000000..b8fd403d0d --- /dev/null +++ b/packages/router-sitemap/src/sitemapPlugin.ts @@ -0,0 +1,50 @@ +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' +import type { Plugin } from 'vite' + +export interface SitemapPluginOptions< + TRouter extends RegisteredRouter = RegisteredRouter, +> { + sitemap: SitemapConfig + path?: string +} + +export function sitemapPlugin< + TRouter extends RegisteredRouter = RegisteredRouter, +>(options: SitemapPluginOptions): Plugin { + 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) { + throw new Error('Failed to write sitemap file.', { cause: error }) + } + } + + return { + name: 'sitemap', + + configResolved(cfg) { + publicDir = cfg.publicDir + }, + + async buildStart() { + await generateAndWrite() + }, + + async configureServer() { + await generateAndWrite() + }, + } +} diff --git a/packages/router-sitemap/tests/generateSitemap.test.ts b/packages/router-sitemap/tests/generateSitemap.test.ts new file mode 100644 index 0000000000..c1122ccb82 --- /dev/null +++ b/packages/router-sitemap/tests/generateSitemap.test.ts @@ -0,0 +1,571 @@ +import { describe, expect, test } from 'vitest' +import { generateSitemap } from '../src/index' +import type { SitemapConfig, DynamicEntryOptions } from '../src/index' + +describe('generateSitemap', () => { + test('generates 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 + 2023-12-01 + 0.8 + monthly + `) + expect(result).toContain('') + }) + + test('handles 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') + }) + + test('returns empty urlset when no routes are provided', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [], + } + + const result = await generateSitemap(config) + expect(result).toContain('') + expect(result).toContain( + '', + ) + }) + + test('throws if siteUrl is invalid', async () => { + await expect( + generateSitemap({ + siteUrl: '', + routes: [], + }), + ).rejects.toThrow() + await expect( + generateSitemap({ + siteUrl: 'not-a-valid-url', + 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() + }) + + test('handles 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 + 2023-12-01 + 0.9 + `) + }) + + test('handles 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 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/2 + 2023-12-02 + `) + }) + + test('handles 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 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/array-2 + weekly + `) + }) + + test('handles 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 + 1 + `) + expect(result).toContain(` + https://example.com/about + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/1 + daily + `) + }) + + test('handles 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') + }) + + test('handles 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', + ) + }) + + test('handles 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') + }) + + test('handles 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') + }) + + test('handles 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') + }) + + test('ignores 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('') + }) + + test('handles 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 + 2023-12-01 + 0.8 + weekly + `) + }) + + test('handles 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 + 2023-12-01 + `) + expect(result).toContain(` + https://example.com/posts/async-2 + 2023-12-02 + `) + }) + + test('applies 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 + 0.5 + `) + expect(result).toContain(` + https://example.com/about + 0.9 + `) + }) + + test('applies 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 + weekly + `) + expect(result).toContain(` + https://example.com/about + daily + `) + }) + + test('applies 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) + + 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 + `) + }) + + test('handles 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') + }) + + test('validates invalid priority values', async () => { + const negativeConfig: SitemapConfig = { + siteUrl: 'https://example.com', + 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 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', + ) + }) + + test('validates invalid changefreq values', async () => { + const config: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [['/invalid-changefreq', { changefreq: 'invalid' as any }]], + } + + await expect(generateSitemap(config)).rejects.toThrow( + 'Invalid entry /invalid-changefreq: changefreq must be one of always, hourly, daily, weekly, monthly, yearly, never', + ) + }) + + test('escapes 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'', + ) + }) + + test('handles invalid route paths', async () => { + const emptyConfig: SitemapConfig = { + siteUrl: 'https://example.com', + routes: [''], // Empty string + } + await expect(generateSitemap(emptyConfig)).rejects.toThrow() + + 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() + }) + + 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') + }) + + test('path property should not appear in XML output', async () => { + const result = await generateSitemap({ + siteUrl: 'https://example.com', + routes: [ + [ + '/posts/$postId', + () => [ + { path: '/posts/1', priority: 0.8 }, + { path: '/posts/2', priority: 0.8 }, + ], + ] as any, + ], + }) + + // 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('') + }) +}) 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..4aea0863c5 --- /dev/null +++ b/packages/router-sitemap/vite.config.ts @@ -0,0 +1,21 @@ +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', './src/sitemapPlugin.ts'], + srcDir: './src', + externalDeps: ['vite', 'node:fs/promises', 'node:path'], + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb1c7eaeba..d1b8c0db38 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: @@ -2536,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) @@ -3672,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 @@ -4394,6 +4398,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 +6422,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.20.3)(yaml@2.7.0) + packages/router-utils: dependencies: '@babel/core': @@ -11996,6 +12019,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 +14529,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==} @@ -19619,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) @@ -21231,6 +21261,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: @@ -21668,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 @@ -23933,6 +23967,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 @@ -23966,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 @@ -24038,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 @@ -24686,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 @@ -24702,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 @@ -24740,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) @@ -24815,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: