Skip to content

Commit 5563695

Browse files
authored
feat: sitemap generation (#2691)
1 parent b61f36d commit 5563695

File tree

11 files changed

+218
-49
lines changed

11 files changed

+218
-49
lines changed

docs/.vitepress/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export default defineConfig({
1212
lastUpdated: true,
1313
cleanUrls: true,
1414

15+
sitemap: {
16+
hostname: 'https://vitepress.dev'
17+
},
18+
1519
head: [
1620
['meta', { name: 'theme-color', content: '#3c8772' }],
1721
[
@@ -131,6 +135,10 @@ function sidebarGuide() {
131135
{
132136
text: 'MPA Mode',
133137
link: '/guide/mpa-mode'
138+
},
139+
{
140+
text: 'Sitemap Generation',
141+
link: '/guide/sitemap-generation'
134142
}
135143
]
136144
},

docs/guide/sitemap-generation.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Sitemap Generation
2+
3+
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`:
4+
5+
```ts
6+
import { defineConfig } from 'vitepress'
7+
8+
export default defineConfig({
9+
sitemap: {
10+
hostname: 'https://example.com'
11+
}
12+
})
13+
```
14+
15+
To have `<lastmod>` tags in your `sitemap.xml`, you can enable the [`lastUpdated`](../reference/default-theme-last-updated) option.
16+
17+
## Options
18+
19+
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:
20+
21+
```ts
22+
import { defineConfig } from 'vitepress'
23+
24+
export default defineConfig({
25+
sitemap: {
26+
hostname: 'https://example.com',
27+
lastmodDateOnly: false
28+
}
29+
})
30+
```
31+
32+
## `transformItems` Hook
33+
34+
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:
35+
36+
```ts
37+
import { defineConfig } from 'vitepress'
38+
39+
export default defineConfig({
40+
sitemap: {
41+
hostname: 'https://example.com',
42+
transformItems: (items) => {
43+
// add new items or modify/filter existing items
44+
items.push({
45+
url: '/extra-page',
46+
changefreq: 'monthly',
47+
priority: 0.8
48+
})
49+
return items
50+
}
51+
}
52+
})
53+
```

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@
176176
"shiki-processor": "^0.1.3",
177177
"simple-git-hooks": "^2.9.0",
178178
"sirv": "^2.0.3",
179+
"sitemap": "^7.1.1",
179180
"supports-color": "^9.4.0",
180181
"typescript": "^5.1.6",
181182
"vitest": "^0.33.0",

pnpm-lock.yaml

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/node/build/build.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createHash } from 'crypto'
22
import fs from 'fs-extra'
33
import { createRequire } from 'module'
4-
import ora from 'ora'
54
import path from 'path'
65
import { packageDirectorySync } from 'pkg-dir'
76
import { rimraf } from 'rimraf'
@@ -11,7 +10,9 @@ import type { BuildOptions } from 'vite'
1110
import { resolveConfig, type SiteConfig } from '../config'
1211
import { slash, type HeadConfig } from '../shared'
1312
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
14-
import { bundle, failMark, okMark } from './bundle'
13+
import { task } from '../utils/task'
14+
import { bundle } from './bundle'
15+
import { generateSitemap } from './generateSitemap'
1516
import { renderPage } from './render'
1617

1718
export async function build(
@@ -43,10 +44,7 @@ export async function build(
4344
const entryPath = path.join(siteConfig.tempDir, 'app.js')
4445
const { render } = await import(pathToFileURL(entryPath).toString())
4546

46-
const spinner = ora({ discardStdin: false })
47-
spinner.start('rendering pages...')
48-
49-
try {
47+
await task('rendering pages', async () => {
5048
const appChunk =
5149
clientResult &&
5250
(clientResult.output.find(
@@ -118,14 +116,6 @@ export async function build(
118116
)
119117
)
120118
)
121-
} catch (e) {
122-
spinner.stopAndPersist({
123-
symbol: failMark
124-
})
125-
throw e
126-
}
127-
spinner.stopAndPersist({
128-
symbol: okMark
129119
})
130120

131121
// emit page hash map for the case where a user session is open
@@ -139,6 +129,7 @@ export async function build(
139129
if (!process.env.DEBUG) await rimraf(siteConfig.tempDir)
140130
}
141131

132+
await generateSitemap(siteConfig)
142133
await siteConfig.buildEnd?.(siteConfig)
143134

144135
siteConfig.logger.info(

src/node/build/bundle.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
import ora from 'ora'
2-
import path from 'path'
31
import fs from 'fs-extra'
2+
import path from 'path'
3+
import type { GetModuleInfo, RollupOutput } from 'rollup'
4+
import { fileURLToPath } from 'url'
45
import {
56
build,
7+
normalizePath,
68
type BuildOptions,
79
type UserConfig as ViteUserConfig
810
} from 'vite'
9-
import type { GetModuleInfo, RollupOutput } from 'rollup'
10-
import type { SiteConfig } from '../config'
1111
import { APP_PATH } from '../alias'
12+
import type { SiteConfig } from '../config'
1213
import { createVitePressPlugin } from '../plugin'
1314
import { sanitizeFileName, slash } from '../shared'
15+
import { task } from '../utils/task'
1416
import { buildMPAClient } from './buildMPAClient'
15-
import { fileURLToPath } from 'url'
16-
import { normalizePath } from 'vite'
17-
18-
export const okMark = '\x1b[32m✓\x1b[0m'
19-
export const failMark = '\x1b[31m✖\x1b[0m'
2017

2118
// A list of default theme components that should only be loaded on demand.
2219
const lazyDefaultThemeComponentsRE =
@@ -142,24 +139,14 @@ export async function bundle(
142139
}
143140
})
144141

145-
let clientResult: RollupOutput | null
146-
let serverResult: RollupOutput
142+
let clientResult!: RollupOutput | null
143+
let serverResult!: RollupOutput
147144

148-
const spinner = ora({ discardStdin: false })
149-
spinner.start('building client + server bundles...')
150-
try {
145+
await task('building client + server bundles', async () => {
151146
clientResult = config.mpa
152147
? null
153148
: ((await build(await resolveViteConfig(false))) as RollupOutput)
154149
serverResult = (await build(await resolveViteConfig(true))) as RollupOutput
155-
} catch (e) {
156-
spinner.stopAndPersist({
157-
symbol: failMark
158-
})
159-
throw e
160-
}
161-
spinner.stopAndPersist({
162-
symbol: okMark
163150
})
164151

165152
if (config.mpa) {

src/node/build/generateSitemap.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from 'fs-extra'
2+
import path from 'path'
3+
import {
4+
SitemapStream,
5+
type EnumChangefreq,
6+
type Img,
7+
type LinkItem,
8+
type NewsItem
9+
} from 'sitemap'
10+
import type { SiteConfig } from '../config'
11+
import { getGitTimestamp } from '../utils/getGitTimestamp'
12+
import { task } from '../utils/task'
13+
14+
export async function generateSitemap(siteConfig: SiteConfig) {
15+
if (!siteConfig.sitemap?.hostname) return
16+
17+
await task('generating sitemap', async () => {
18+
let items: SitemapItem[] = await Promise.all(
19+
siteConfig.pages.map(async (page) => {
20+
//
21+
let url = siteConfig.rewrites.map[page] || page
22+
url = url.replace(/(^|\/)?index.md$/, '$1')
23+
url = url.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html')
24+
25+
const lastmod = siteConfig.lastUpdated && (await getGitTimestamp(page))
26+
return lastmod ? { url, lastmod } : { url }
27+
})
28+
)
29+
items = items.sort((a, b) => a.url.localeCompare(b.url))
30+
items = (await siteConfig.sitemap?.transformItems?.(items)) || items
31+
32+
const sitemapStream = new SitemapStream(siteConfig.sitemap)
33+
const sitemapPath = path.join(siteConfig.outDir, 'sitemap.xml')
34+
const writeStream = fs.createWriteStream(sitemapPath)
35+
36+
sitemapStream.pipe(writeStream)
37+
items.forEach((item) => sitemapStream.write(item))
38+
sitemapStream.end()
39+
})
40+
}
41+
42+
// ============================== Patched Types ===============================
43+
44+
export interface SitemapItem {
45+
lastmod?: string | number | Date
46+
changefreq?: `${EnumChangefreq}`
47+
fullPrecisionPriority?: boolean
48+
priority?: number
49+
news?: NewsItem
50+
expires?: string
51+
androidLink?: string
52+
ampLink?: string
53+
url: string
54+
video?: any
55+
img?: string | Img | (string | Img)[]
56+
links?: LinkItem[]
57+
lastmodfile?: string | Buffer | URL
58+
lastmodISO?: string
59+
lastmodrealtime?: boolean
60+
}

src/node/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export async function resolveConfig(
127127
transformHtml: userConfig.transformHtml,
128128
transformPageData: userConfig.transformPageData,
129129
rewrites,
130-
userConfig
130+
userConfig,
131+
sitemap: userConfig.sitemap
131132
}
132133

133134
// to be shared with content loaders

src/node/siteConfig.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import {
2-
type Awaitable,
3-
type HeadConfig,
4-
type LocaleConfig,
5-
type LocaleSpecificConfig,
6-
type PageData,
7-
type SiteData,
8-
type SSGContext
9-
} from './shared'
10-
import type { MarkdownOptions } from './markdown'
111
import type { Options as VuePluginOptions } from '@vitejs/plugin-vue'
12-
import { type Logger, type UserConfig as ViteConfig } from 'vite'
2+
import type { SitemapStreamOptions } from 'sitemap'
3+
import type { Logger, UserConfig as ViteConfig } from 'vite'
4+
import type { SitemapItem } from './build/generateSitemap'
5+
import type { MarkdownOptions } from './markdown'
6+
import type {
7+
Awaitable,
8+
HeadConfig,
9+
LocaleConfig,
10+
LocaleSpecificConfig,
11+
PageData,
12+
SSGContext,
13+
SiteData
14+
} from './shared'
1315

1416
export type RawConfigExports<ThemeConfig = any> =
1517
| Awaitable<UserConfig<ThemeConfig>>
@@ -138,6 +140,14 @@ export interface UserConfig<ThemeConfig = any>
138140
*/
139141
rewrites?: Record<string, string>
140142

143+
/**
144+
* @experimental
145+
*/
146+
sitemap?: SitemapStreamOptions & {
147+
hostname: string
148+
transformItems?: (items: SitemapItem[]) => Awaitable<SitemapItem[]>
149+
}
150+
141151
/**
142152
* Build end hook: called when SSG finish.
143153
* @param siteConfig The resolved configuration.
@@ -192,6 +202,7 @@ export interface SiteConfig<ThemeConfig = any>
192202
| 'transformHead'
193203
| 'transformHtml'
194204
| 'transformPageData'
205+
| 'sitemap'
195206
> {
196207
root: string
197208
srcDir: string

0 commit comments

Comments
 (0)