diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 265e8f0cdb02..7033ca61bb08 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -12,6 +12,10 @@ export default defineConfig({ lastUpdated: true, cleanUrls: true, + sitemap: { + hostname: 'https://vitepress.dev' + }, + head: [ ['meta', { name: 'theme-color', content: '#3c8772' }], [ @@ -131,6 +135,10 @@ function sidebarGuide() { { text: 'MPA Mode', link: '/guide/mpa-mode' + }, + { + text: 'Sitemap Generation', + link: '/guide/sitemap-generation' } ] }, diff --git a/docs/guide/sitemap-generation.md b/docs/guide/sitemap-generation.md new file mode 100644 index 000000000000..7d8914287f5d --- /dev/null +++ b/docs/guide/sitemap-generation.md @@ -0,0 +1,53 @@ +# Sitemap Generation + +VitePress comes with out-of-the-box support for generating a `sitemap.xml` file for your site. To enable it, add the following to your `.vitepress/config.js`: + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + sitemap: { + hostname: 'https://example.com' + } +}) +``` + +To have `` tags in your `sitemap.xml`, you can enable the [`lastUpdated`](../reference/default-theme-last-updated) option. + +## Options + +Sitemap support is powered by the [`sitemap`](https://www.npmjs.com/package/sitemap) module. You can pass any options supported by it to the `sitemap` option in your config file. These will be passed directly to the `SitemapStream` constructor. Refer to the [`sitemap` documentation](https://www.npmjs.com/package/sitemap#options-you-can-pass) for more details. Example: + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + sitemap: { + hostname: 'https://example.com', + lastmodDateOnly: false + } +}) +``` + +## `transformItems` Hook + +You can use the `sitemap.transformItems` hook to modify the sitemap items before they are written to the `sitemap.xml` file. This hook is called with an array of sitemap items and expects an array of sitemap items to be returned. Example: + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + sitemap: { + hostname: 'https://example.com', + transformItems: (items) => { + // add new items or modify/filter existing items + items.push({ + url: '/extra-page', + changefreq: 'monthly', + priority: 0.8 + }) + return items + } + } +}) +``` diff --git a/package.json b/package.json index 0c14c6759e51..5416945cb34d 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "shiki-processor": "^0.1.3", "simple-git-hooks": "^2.9.0", "sirv": "^2.0.3", + "sitemap": "^7.1.1", "supports-color": "^9.4.0", "typescript": "^5.1.6", "vitest": "^0.33.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d85eb2f3003..4d1f377dcc33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: sirv: specifier: ^2.0.3 version: 2.0.3 + sitemap: + specifier: ^7.1.1 + version: 7.1.1 supports-color: specifier: ^9.4.0 version: 9.4.0 @@ -1101,6 +1104,10 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true + /@types/node@17.0.45: + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + dev: true + /@types/node@20.4.5: resolution: {integrity: sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==} @@ -1127,6 +1134,12 @@ packages: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} dev: true + /@types/sax@1.2.4: + resolution: {integrity: sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==} + dependencies: + '@types/node': 20.4.5 + dev: true + /@types/send@0.17.1: resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} dependencies: @@ -1513,6 +1526,10 @@ packages: picomatch: 2.3.1 dev: true + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + /argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: @@ -3907,6 +3924,10 @@ packages: is-regex: 1.1.4 dev: true + /sax@1.2.4: + resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} + dev: true + /search-insights@2.7.0: resolution: {integrity: sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==} engines: {node: '>=8.16.0'} @@ -4022,6 +4043,17 @@ packages: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true + /sitemap@7.1.1: + resolution: {integrity: sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg==} + engines: {node: '>=12.0.0', npm: '>=5.6.0'} + hasBin: true + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.4 + arg: 5.0.2 + sax: 1.2.4 + dev: true + /slash@4.0.0: resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} engines: {node: '>=12'} diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 1626452e25ab..255995b6c412 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -1,7 +1,6 @@ import { createHash } from 'crypto' import fs from 'fs-extra' import { createRequire } from 'module' -import ora from 'ora' import path from 'path' import { packageDirectorySync } from 'pkg-dir' import { rimraf } from 'rimraf' @@ -11,7 +10,9 @@ import type { BuildOptions } from 'vite' import { resolveConfig, type SiteConfig } from '../config' import { slash, type HeadConfig } from '../shared' import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' -import { bundle, failMark, okMark } from './bundle' +import { task } from '../utils/task' +import { bundle } from './bundle' +import { generateSitemap } from './generateSitemap' import { renderPage } from './render' export async function build( @@ -43,10 +44,7 @@ export async function build( const entryPath = path.join(siteConfig.tempDir, 'app.js') const { render } = await import(pathToFileURL(entryPath).toString()) - const spinner = ora({ discardStdin: false }) - spinner.start('rendering pages...') - - try { + await task('rendering pages', async () => { const appChunk = clientResult && (clientResult.output.find( @@ -118,14 +116,6 @@ export async function build( ) ) ) - } catch (e) { - spinner.stopAndPersist({ - symbol: failMark - }) - throw e - } - spinner.stopAndPersist({ - symbol: okMark }) // emit page hash map for the case where a user session is open @@ -139,6 +129,7 @@ export async function build( if (!process.env.DEBUG) await rimraf(siteConfig.tempDir) } + await generateSitemap(siteConfig) await siteConfig.buildEnd?.(siteConfig) siteConfig.logger.info( diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index d9c3d474fd2f..2d8e8dfd7377 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -1,22 +1,19 @@ -import ora from 'ora' -import path from 'path' import fs from 'fs-extra' +import path from 'path' +import type { GetModuleInfo, RollupOutput } from 'rollup' +import { fileURLToPath } from 'url' import { build, + normalizePath, type BuildOptions, type UserConfig as ViteUserConfig } from 'vite' -import type { GetModuleInfo, RollupOutput } from 'rollup' -import type { SiteConfig } from '../config' import { APP_PATH } from '../alias' +import type { SiteConfig } from '../config' import { createVitePressPlugin } from '../plugin' import { sanitizeFileName, slash } from '../shared' +import { task } from '../utils/task' import { buildMPAClient } from './buildMPAClient' -import { fileURLToPath } from 'url' -import { normalizePath } from 'vite' - -export const okMark = '\x1b[32m✓\x1b[0m' -export const failMark = '\x1b[31m✖\x1b[0m' // A list of default theme components that should only be loaded on demand. const lazyDefaultThemeComponentsRE = @@ -142,24 +139,14 @@ export async function bundle( } }) - let clientResult: RollupOutput | null - let serverResult: RollupOutput + let clientResult!: RollupOutput | null + let serverResult!: RollupOutput - const spinner = ora({ discardStdin: false }) - spinner.start('building client + server bundles...') - try { + await task('building client + server bundles', async () => { clientResult = config.mpa ? null : ((await build(await resolveViteConfig(false))) as RollupOutput) serverResult = (await build(await resolveViteConfig(true))) as RollupOutput - } catch (e) { - spinner.stopAndPersist({ - symbol: failMark - }) - throw e - } - spinner.stopAndPersist({ - symbol: okMark }) if (config.mpa) { diff --git a/src/node/build/generateSitemap.ts b/src/node/build/generateSitemap.ts new file mode 100644 index 000000000000..e04bd729e5a6 --- /dev/null +++ b/src/node/build/generateSitemap.ts @@ -0,0 +1,60 @@ +import fs from 'fs-extra' +import path from 'path' +import { + SitemapStream, + type EnumChangefreq, + type Img, + type LinkItem, + type NewsItem +} from 'sitemap' +import type { SiteConfig } from '../config' +import { getGitTimestamp } from '../utils/getGitTimestamp' +import { task } from '../utils/task' + +export async function generateSitemap(siteConfig: SiteConfig) { + if (!siteConfig.sitemap?.hostname) return + + await task('generating sitemap', async () => { + let items: SitemapItem[] = await Promise.all( + siteConfig.pages.map(async (page) => { + // + let url = siteConfig.rewrites.map[page] || page + url = url.replace(/(^|\/)?index.md$/, '$1') + url = url.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html') + + const lastmod = siteConfig.lastUpdated && (await getGitTimestamp(page)) + return lastmod ? { url, lastmod } : { url } + }) + ) + items = items.sort((a, b) => a.url.localeCompare(b.url)) + items = (await siteConfig.sitemap?.transformItems?.(items)) || items + + const sitemapStream = new SitemapStream(siteConfig.sitemap) + const sitemapPath = path.join(siteConfig.outDir, 'sitemap.xml') + const writeStream = fs.createWriteStream(sitemapPath) + + sitemapStream.pipe(writeStream) + items.forEach((item) => sitemapStream.write(item)) + sitemapStream.end() + }) +} + +// ============================== Patched Types =============================== + +export interface SitemapItem { + lastmod?: string | number | Date + changefreq?: `${EnumChangefreq}` + fullPrecisionPriority?: boolean + priority?: number + news?: NewsItem + expires?: string + androidLink?: string + ampLink?: string + url: string + video?: any + img?: string | Img | (string | Img)[] + links?: LinkItem[] + lastmodfile?: string | Buffer | URL + lastmodISO?: string + lastmodrealtime?: boolean +} diff --git a/src/node/config.ts b/src/node/config.ts index 6144d8763b82..b39250cdf99f 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -127,7 +127,8 @@ export async function resolveConfig( transformHtml: userConfig.transformHtml, transformPageData: userConfig.transformPageData, rewrites, - userConfig + userConfig, + sitemap: userConfig.sitemap } // to be shared with content loaders diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 6a1cea00922c..c2f58d76e991 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -1,15 +1,17 @@ -import { - type Awaitable, - type HeadConfig, - type LocaleConfig, - type LocaleSpecificConfig, - type PageData, - type SiteData, - type SSGContext -} from './shared' -import type { MarkdownOptions } from './markdown' import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' -import { type Logger, type UserConfig as ViteConfig } from 'vite' +import type { SitemapStreamOptions } from 'sitemap' +import type { Logger, UserConfig as ViteConfig } from 'vite' +import type { SitemapItem } from './build/generateSitemap' +import type { MarkdownOptions } from './markdown' +import type { + Awaitable, + HeadConfig, + LocaleConfig, + LocaleSpecificConfig, + PageData, + SSGContext, + SiteData +} from './shared' export type RawConfigExports = | Awaitable> @@ -138,6 +140,14 @@ export interface UserConfig */ rewrites?: Record + /** + * @experimental + */ + sitemap?: SitemapStreamOptions & { + hostname: string + transformItems?: (items: SitemapItem[]) => Awaitable + } + /** * Build end hook: called when SSG finish. * @param siteConfig The resolved configuration. @@ -192,6 +202,7 @@ export interface SiteConfig | 'transformHead' | 'transformHtml' | 'transformPageData' + | 'sitemap' > { root: string srcDir: string diff --git a/src/node/utils/getGitTimestamp.ts b/src/node/utils/getGitTimestamp.ts index 5404c5124c08..51d01df793f9 100644 --- a/src/node/utils/getGitTimestamp.ts +++ b/src/node/utils/getGitTimestamp.ts @@ -1,7 +1,12 @@ import { spawn } from 'cross-spawn' import { basename, dirname } from 'path' +const cache = new Map() + export function getGitTimestamp(file: string) { + const cached = cache.get(file) + if (cached) return cached + return new Promise((resolve, reject) => { const cwd = dirname(file) const fileName = basename(file) @@ -11,7 +16,9 @@ export function getGitTimestamp(file: string) { let output = '' child.stdout.on('data', (d) => (output += String(d))) child.on('close', () => { - resolve(+new Date(output)) + const timestamp = +new Date(output) + cache.set(file, timestamp) + resolve(timestamp) }) child.on('error', reject) }) diff --git a/src/node/utils/task.ts b/src/node/utils/task.ts new file mode 100644 index 000000000000..1d8a3322cfed --- /dev/null +++ b/src/node/utils/task.ts @@ -0,0 +1,18 @@ +import ora from 'ora' + +export const okMark = '\x1b[32m✓\x1b[0m' +export const failMark = '\x1b[31m✖\x1b[0m' + +export async function task(taskName: string, task: () => Promise) { + const spinner = ora({ discardStdin: false }) + spinner.start(taskName + '...') + + try { + await task() + } catch (e) { + spinner.stopAndPersist({ symbol: failMark }) + throw e + } + + spinner.stopAndPersist({ symbol: okMark }) +}