feat: sitemap generation (#2691)

pull/2701/head
Divyansh Singh 1 year ago committed by GitHub
parent b61f36d853
commit 5563695b15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -12,6 +12,10 @@ export default defineConfig({
lastUpdated: true, lastUpdated: true,
cleanUrls: true, cleanUrls: true,
sitemap: {
hostname: 'https://vitepress.dev'
},
head: [ head: [
['meta', { name: 'theme-color', content: '#3c8772' }], ['meta', { name: 'theme-color', content: '#3c8772' }],
[ [
@ -131,6 +135,10 @@ function sidebarGuide() {
{ {
text: 'MPA Mode', text: 'MPA Mode',
link: '/guide/mpa-mode' link: '/guide/mpa-mode'
},
{
text: 'Sitemap Generation',
link: '/guide/sitemap-generation'
} }
] ]
}, },

@ -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 `<lastmod>` 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
}
}
})
```

@ -176,6 +176,7 @@
"shiki-processor": "^0.1.3", "shiki-processor": "^0.1.3",
"simple-git-hooks": "^2.9.0", "simple-git-hooks": "^2.9.0",
"sirv": "^2.0.3", "sirv": "^2.0.3",
"sitemap": "^7.1.1",
"supports-color": "^9.4.0", "supports-color": "^9.4.0",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"vitest": "^0.33.0", "vitest": "^0.33.0",

@ -258,6 +258,9 @@ importers:
sirv: sirv:
specifier: ^2.0.3 specifier: ^2.0.3
version: 2.0.3 version: 2.0.3
sitemap:
specifier: ^7.1.1
version: 7.1.1
supports-color: supports-color:
specifier: ^9.4.0 specifier: ^9.4.0
version: 9.4.0 version: 9.4.0
@ -1101,6 +1104,10 @@ packages:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
dev: true dev: true
/@types/node@17.0.45:
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
dev: true
/@types/node@20.4.5: /@types/node@20.4.5:
resolution: {integrity: sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==} resolution: {integrity: sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==}
@ -1127,6 +1134,12 @@ packages:
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
dev: true 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: /@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
dependencies: dependencies:
@ -1513,6 +1526,10 @@ packages:
picomatch: 2.3.1 picomatch: 2.3.1
dev: true dev: true
/arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
dev: true
/argparse@1.0.10: /argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
dependencies: dependencies:
@ -3907,6 +3924,10 @@ packages:
is-regex: 1.1.4 is-regex: 1.1.4
dev: true dev: true
/sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
dev: true
/search-insights@2.7.0: /search-insights@2.7.0:
resolution: {integrity: sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==} resolution: {integrity: sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==}
engines: {node: '>=8.16.0'} engines: {node: '>=8.16.0'}
@ -4022,6 +4043,17 @@ packages:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
dev: true 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: /slash@4.0.0:
resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==}
engines: {node: '>=12'} engines: {node: '>=12'}

@ -1,7 +1,6 @@
import { createHash } from 'crypto' import { createHash } from 'crypto'
import fs from 'fs-extra' import fs from 'fs-extra'
import { createRequire } from 'module' import { createRequire } from 'module'
import ora from 'ora'
import path from 'path' import path from 'path'
import { packageDirectorySync } from 'pkg-dir' import { packageDirectorySync } from 'pkg-dir'
import { rimraf } from 'rimraf' import { rimraf } from 'rimraf'
@ -11,7 +10,9 @@ import type { BuildOptions } from 'vite'
import { resolveConfig, type SiteConfig } from '../config' import { resolveConfig, type SiteConfig } from '../config'
import { slash, type HeadConfig } from '../shared' import { slash, type HeadConfig } from '../shared'
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' 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' import { renderPage } from './render'
export async function build( export async function build(
@ -43,10 +44,7 @@ export async function build(
const entryPath = path.join(siteConfig.tempDir, 'app.js') const entryPath = path.join(siteConfig.tempDir, 'app.js')
const { render } = await import(pathToFileURL(entryPath).toString()) const { render } = await import(pathToFileURL(entryPath).toString())
const spinner = ora({ discardStdin: false }) await task('rendering pages', async () => {
spinner.start('rendering pages...')
try {
const appChunk = const appChunk =
clientResult && clientResult &&
(clientResult.output.find( (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 // 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) if (!process.env.DEBUG) await rimraf(siteConfig.tempDir)
} }
await generateSitemap(siteConfig)
await siteConfig.buildEnd?.(siteConfig) await siteConfig.buildEnd?.(siteConfig)
siteConfig.logger.info( siteConfig.logger.info(

@ -1,22 +1,19 @@
import ora from 'ora'
import path from 'path'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path'
import type { GetModuleInfo, RollupOutput } from 'rollup'
import { fileURLToPath } from 'url'
import { import {
build, build,
normalizePath,
type BuildOptions, type BuildOptions,
type UserConfig as ViteUserConfig type UserConfig as ViteUserConfig
} from 'vite' } from 'vite'
import type { GetModuleInfo, RollupOutput } from 'rollup'
import type { SiteConfig } from '../config'
import { APP_PATH } from '../alias' import { APP_PATH } from '../alias'
import type { SiteConfig } from '../config'
import { createVitePressPlugin } from '../plugin' import { createVitePressPlugin } from '../plugin'
import { sanitizeFileName, slash } from '../shared' import { sanitizeFileName, slash } from '../shared'
import { task } from '../utils/task'
import { buildMPAClient } from './buildMPAClient' 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. // A list of default theme components that should only be loaded on demand.
const lazyDefaultThemeComponentsRE = const lazyDefaultThemeComponentsRE =
@ -142,24 +139,14 @@ export async function bundle(
} }
}) })
let clientResult: RollupOutput | null let clientResult!: RollupOutput | null
let serverResult: RollupOutput let serverResult!: RollupOutput
const spinner = ora({ discardStdin: false }) await task('building client + server bundles', async () => {
spinner.start('building client + server bundles...')
try {
clientResult = config.mpa clientResult = config.mpa
? null ? null
: ((await build(await resolveViteConfig(false))) as RollupOutput) : ((await build(await resolveViteConfig(false))) as RollupOutput)
serverResult = (await build(await resolveViteConfig(true))) 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) { if (config.mpa) {

@ -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
}

@ -127,7 +127,8 @@ export async function resolveConfig(
transformHtml: userConfig.transformHtml, transformHtml: userConfig.transformHtml,
transformPageData: userConfig.transformPageData, transformPageData: userConfig.transformPageData,
rewrites, rewrites,
userConfig userConfig,
sitemap: userConfig.sitemap
} }
// to be shared with content loaders // to be shared with content loaders

@ -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 { 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<ThemeConfig = any> = export type RawConfigExports<ThemeConfig = any> =
| Awaitable<UserConfig<ThemeConfig>> | Awaitable<UserConfig<ThemeConfig>>
@ -138,6 +140,14 @@ export interface UserConfig<ThemeConfig = any>
*/ */
rewrites?: Record<string, string> rewrites?: Record<string, string>
/**
* @experimental
*/
sitemap?: SitemapStreamOptions & {
hostname: string
transformItems?: (items: SitemapItem[]) => Awaitable<SitemapItem[]>
}
/** /**
* Build end hook: called when SSG finish. * Build end hook: called when SSG finish.
* @param siteConfig The resolved configuration. * @param siteConfig The resolved configuration.
@ -192,6 +202,7 @@ export interface SiteConfig<ThemeConfig = any>
| 'transformHead' | 'transformHead'
| 'transformHtml' | 'transformHtml'
| 'transformPageData' | 'transformPageData'
| 'sitemap'
> { > {
root: string root: string
srcDir: string srcDir: string

@ -1,7 +1,12 @@
import { spawn } from 'cross-spawn' import { spawn } from 'cross-spawn'
import { basename, dirname } from 'path' import { basename, dirname } from 'path'
const cache = new Map<string, number>()
export function getGitTimestamp(file: string) { export function getGitTimestamp(file: string) {
const cached = cache.get(file)
if (cached) return cached
return new Promise<number>((resolve, reject) => { return new Promise<number>((resolve, reject) => {
const cwd = dirname(file) const cwd = dirname(file)
const fileName = basename(file) const fileName = basename(file)
@ -11,7 +16,9 @@ export function getGitTimestamp(file: string) {
let output = '' let output = ''
child.stdout.on('data', (d) => (output += String(d))) child.stdout.on('data', (d) => (output += String(d)))
child.on('close', () => { child.on('close', () => {
resolve(+new Date(output)) const timestamp = +new Date(output)
cache.set(file, timestamp)
resolve(timestamp)
}) })
child.on('error', reject) child.on('error', reject)
}) })

@ -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<void>) {
const spinner = ora({ discardStdin: false })
spinner.start(taskName + '...')
try {
await task()
} catch (e) {
spinner.stopAndPersist({ symbol: failMark })
throw e
}
spinner.stopAndPersist({ symbol: okMark })
}
Loading…
Cancel
Save