pull/5417/merge
Divyansh Singh 4 days ago committed by GitHub
commit 2e4c614e6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -91,6 +91,14 @@ describe('relative base emit', () => {
expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
}) })
test('locale 404 renders at its own depth', () => {
const html = read('relative', 'zh/404.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("../",location).href'
)
expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
})
test('no sentinel leaks into emitted html or css', () => { test('no sentinel leaks into emitted html or css', () => {
for (const file of walk(dist('relative'))) { for (const file of walk(dist('relative'))) {
if (!/\.(html|css)$/.test(file)) continue if (!/\.(html|css)$/.test(file)) continue
@ -208,3 +216,44 @@ describe('plain base emit is unchanged', () => {
expect(html).not.toContain('crossorigin>') expect(html).not.toContain('crossorigin>')
}) })
}) })
describe('not-found emit', () => {
for (const mode of ['plain', 'relative', 'cdn', 'mpa']) {
test(`${mode}: the theme page stands in for a missing root 404.md`, () => {
const html = read(mode, '404.html')
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('class="NotFound"')
expect(html).toContain('<title>404 | Base Fixture</title>')
expect(html).toContain('<meta name="robots" content="noindex">')
expect(html).toContain('<html lang="en"')
})
test(`${mode}: a locale 404.md is emitted for its locale`, () => {
const html = read(mode, 'zh/404.html')
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('页面未找到')
expect(html).toContain('<title>页面未找到 | Base Fixture</title>')
expect(html).toContain('<meta name="robots" content="noindex">')
expect(html).toContain('<html lang="zh-CN"')
expect(html).not.toContain('class="NotFound"')
})
test(`${mode}: not-found pages stay out of the sitemap`, () => {
const sitemap = read(mode, 'sitemap.xml')
expect(sitemap).toContain('<loc>https://example.com/zh/</loc>')
expect(sitemap).toContain('<loc>https://example.com/sub/page.html</loc>')
expect(sitemap).not.toContain('404.html')
})
}
test('mpa: the not-found page needs no script', () => {
const html = read('mpa', '404.html')
expect(html).not.toContain('<script type="module"')
})
test('the not-found page is pre-rendered with the site chrome', () => {
const html = read('plain', '404.html')
expect(html).toContain('class="VPNav"')
expect(html).not.toContain('class="VPSidebar"')
})
})

@ -12,6 +12,15 @@ export default defineConfig({
outDir: `.vitepress/dist-${mode}`, outDir: `.vitepress/dist-${mode}`,
cleanUrls: false, cleanUrls: false,
rewrites: { 'src-moved.md': 'moved/target.md' }, rewrites: { 'src-moved.md': 'moved/target.md' },
sitemap: { hostname: 'https://example.com' },
locales: {
root: { label: 'English', lang: 'en' },
zh: {
label: '中文',
lang: 'zh-CN',
themeConfig: { notFound: { title: '页面未找到' } }
}
},
vite: { vite: {
logLevel: 'error', logLevel: 'error',
// keep the tiny fixture images as real emitted assets // keep the tiny fixture images as real emitted assets

@ -0,0 +1,7 @@
---
title: 页面未找到
---
# 页面未找到
这个页面不存在。[回到首页](/zh/)

@ -0,0 +1,3 @@
# 首页
中文首页。

@ -11,14 +11,20 @@ export async function newPage(): Promise<TestPage> {
const page = await browser.newPage() const page = await browser.newPage()
const errors: string[] = [] const errors: string[] = []
page.on('console', (msg) => { page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text()) if (msg.type() !== 'error') return
// a failed resource is only identified by where it came from
const url = msg.location()?.url
errors.push(url ? `${msg.text()} <${url}>` : msg.text())
}) })
page.on('pageerror', (err) => errors.push(String(err))) page.on('pageerror', (err) => errors.push(String(err)))
return { browser, page, errors } return { browser, page, errors }
} }
export function realErrors(errors: string[]): string[] { export function realErrors(errors: string[], ignore: string[] = []): string[] {
return errors.filter((e) => !e.includes('favicon')) return errors.filter(
(e) =>
!e.includes('favicon') && !ignore.some((url) => e.includes(`<${url}>`))
)
} }
export async function waitForHydration(page: Page): Promise<void> { export async function waitForHydration(page: Page): Promise<void> {

@ -0,0 +1,152 @@
import { SUB_PREFIX } from './constants'
import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
// the cdn build, served at the root by a host that picks the nearest 404.html
const nearest = () => `http://localhost:${process.env['PAGES_PORT']}`
// the plain build, served by a host that only knows the root 404.html
const rootOnly = () => `http://localhost:${process.env['PLAIN_PORT']}`
// the relative build, mounted under a prefix
const sub = () => `http://localhost:${process.env['SUB_PORT']}${SUB_PREFIX}`
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
beforeEach(() => {
t.errors.length = 0
})
const h1 = () => t.page.textContent('h1')
// the document of a miss is a 404 by design; anything else is a real error
const errors = () => realErrors(t.errors, [t.page.url()])
const lang = () => t.page.evaluate(() => document.documentElement.lang)
const mark = () => t.page.evaluate(() => ((window as any).__spa_marker = 1))
const marked = () => t.page.evaluate(() => (window as any).__spa_marker === 1)
describe('not-found page on a host serving the nearest 404.html', () => {
test('a miss shows the theme page when the site has no 404.md', async () => {
const res = await t.page.goto(`${nearest()}/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await t.page.title()).toBe('404 | Base Fixture')
expect(await t.page.getAttribute('.NotFound .link', 'href')).toBe('/')
expect(new URL(t.page.url()).pathname).toBe('/nowhere.html')
expect(errors()).toEqual([])
})
test("a miss under a locale shows that locale's 404.md", async () => {
const res = await t.page.goto(`${nearest()}/zh/guide/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await lang()).toBe('zh-CN')
expect(await t.page.title()).toBe('页面未找到 | Base Fixture')
expect(errors()).toEqual([])
})
test('the theme page uses the locale text of the miss', async () => {
// the root build has no zh text of its own: only the client knows the
// locale, from the url
await t.page.goto(`${nearest()}/nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await lang()).toBe('en')
})
test('a 404 served for a url that has a page renders that page', async () => {
// the static host has no `index` -> `index.html` rule, so this is a miss
// for the server and a real page for the client
const res = await t.page.goto(`${nearest()}/sub/index`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('Sub index')
expect(errors()).toEqual([])
})
test('the not-found page renders at its own url', async () => {
const res = await t.page.goto(`${nearest()}/zh/404.html`)
expect(res?.status()).toBe(200)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(errors()).toEqual([])
})
test('client-side navigation to a miss keeps the url and recovers', async () => {
await t.page.goto(`${nearest()}/`)
await waitForHydration(t.page)
await mark()
await t.page.evaluate(() => {
const a = document.createElement('a')
a.href = '/zh/nowhere.html'
a.id = 'to-nowhere'
a.textContent = 'nowhere'
// above the fixed nav and sidebar, so the click reaches it
a.style.cssText = 'position:fixed;right:0;bottom:0;z-index:1000'
document.body.appendChild(a)
})
await t.page.click('#to-nowhere')
await t.page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('页面未找到')
)
expect(new URL(t.page.url()).pathname).toBe('/zh/nowhere.html')
expect(await lang()).toBe('zh-CN')
expect(await marked()).toBe(true)
await t.page.click('.vp-doc a[href="/zh/"]')
await t.page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('首页')
)
expect(await marked()).toBe(true)
expect(errors()).toEqual([])
})
})
describe('not-found page on a host serving only the root 404.html', () => {
test("a miss under a locale still shows that locale's page", async () => {
const res = await t.page.goto(`${rootOnly()}/zh/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await lang()).toBe('zh-CN')
expect(await t.page.title()).toBe('页面未找到 | Base Fixture')
expect(errors()).toEqual([])
})
test('a miss outside any locale shows the theme page', async () => {
await t.page.goto(`${rootOnly()}/deep/nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(errors()).toEqual([])
})
})
describe('not-found page with a relative base', () => {
test('a miss under a locale is styled and localized', async () => {
const res = await t.page.goto(`${sub()}zh/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__)).toBe(
sub()
)
expect(errors()).toEqual([])
})
test('a root-level miss shows the theme page', async () => {
await t.page.goto(`${sub()}nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await t.page.getAttribute('.NotFound .link', 'href')).toBe(
SUB_PREFIX
)
expect(errors()).toEqual([])
})
})

@ -23,11 +23,17 @@ const types: Record<string, string> = {
'.zip': 'application/zip' '.zip': 'application/zip'
} }
// how a miss is answered: with the nearest 404.html up the directory tree
// (cloudflare pages, gitlab pages), with the root one only (github pages,
// netlify, vercel), or with nothing (an asset cdn)
type NotFoundMode = 'nearest' | 'root' | 'none'
// listens on an os-assigned port (the other suites run in parallel on CI, // listens on an os-assigned port (the other suites run in parallel on CI,
// so a pre-picked "free" port can be taken before we bind it) // so a pre-picked "free" port can be taken before we bind it)
function serveStatic( function serveStatic(
mounts: [prefix: string, root: string][], mounts: [prefix: string, root: string][],
cors: boolean cors: boolean,
notFound: NotFoundMode = 'none'
): Promise<Server> { ): Promise<Server> {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname) const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname)
@ -45,6 +51,18 @@ function serveStatic(
res.end(data) res.end(data)
return return
} catch {} } catch {}
if (notFound === 'none') break
const dirs = notFound === 'nearest' ? file.split('/').slice(0, -1) : []
for (let depth = dirs.length; depth >= 0; depth--) {
try {
const data = await readFile(
join(root, ...dirs.slice(0, depth), '404.html')
)
res.writeHead(404, { 'content-type': 'text/html' })
res.end(data)
return
} catch {}
}
} }
res.writeHead(404) res.writeHead(404)
res.end('not found') res.end('not found')
@ -88,10 +106,13 @@ export async function setup() {
[SUB_PREFIX, dist('relative')], [SUB_PREFIX, dist('relative')],
[ALT_PREFIX, dist('relative')] [ALT_PREFIX, dist('relative')]
], ],
false false,
'nearest'
), ),
await serveStatic([['/', dist('cdn')]], false), await serveStatic([['/', dist('cdn')]], false, 'nearest'),
cdnServer cdnServer,
// a host that only knows the root 404.html
await serveStatic([['/', dist('plain')]], false, 'root')
] ]
browserServer = await chromium.launchServer({ browserServer = await chromium.launchServer({
@ -105,6 +126,7 @@ export async function setup() {
process.env['SUB_PORT'] = String(portOf(servers[0]!)) process.env['SUB_PORT'] = String(portOf(servers[0]!))
process.env['PAGES_PORT'] = String(portOf(servers[1]!)) process.env['PAGES_PORT'] = String(portOf(servers[1]!))
process.env['VP_CDN_PORT'] = String(cdnPort) process.env['VP_CDN_PORT'] = String(cdnPort)
process.env['PLAIN_PORT'] = String(portOf(servers[3]!))
} }
export async function teardown() { export async function teardown() {

@ -197,6 +197,11 @@ const sidebar: DefaultTheme.Config['sidebar'] = {
export default defineConfig({ export default defineConfig({
title: 'Example', title: 'Example',
description: 'An example app using VitePress.', description: 'An example app using VitePress.',
// a locale without a 404.md of its own inherits the root one
locales: {
root: { label: 'English', lang: 'en' },
fr: { label: 'Français', lang: 'fr' }
},
srcExclude: ['**/parts/**'], srcExclude: ['**/parts/**'],
markdown: { markdown: {
image: { lazyLoad: true } image: { lazyLoad: true }

@ -0,0 +1,7 @@
---
title: Not found
---
# Custom not found
This page does not exist. [Go home](/)

@ -0,0 +1,3 @@
# Accueil
Page française.

@ -0,0 +1,118 @@
const origin = () => `http://localhost:${process.env['PORT']}`
const status = (path: string) =>
page.evaluate(
async (path) =>
(await fetch(path, { headers: { accept: 'text/html' } })).status,
path
)
describe('not found page', () => {
test('a missing url renders the site 404.md with a 404 status', async () => {
const res = await page.goto(`${origin()}/missing`)
expect(res?.status()).toBe(404)
await page.waitForSelector('#app .Layout')
expect(await page.textContent('h1')).toContain('Custom not found')
expect(await page.title()).toBe('Not found | Example')
// the address bar keeps what the visitor typed
expect(new URL(page.url()).pathname).toBe('/missing')
// a doc page without the doc chrome
expect(
await page.locator('.VPContent').getAttribute('class')
).not.toContain('has-sidebar')
expect(await page.locator('.VPDocFooter .pager-link').count()).toBe(0)
expect(await page.locator('.VPDocFooter .edit-link').count()).toBe(0)
})
test('a locale without its own 404.md inherits the root one', async () => {
const res = await page.goto(`${origin()}/fr/guide/missing`)
expect(res?.status()).toBe(404)
await page.waitForSelector('#app .Layout')
expect(await page.textContent('h1')).toContain('Custom not found')
expect(await page.title()).toBe('Not found | Example')
expect(await page.evaluate(() => document.documentElement.lang)).toBe('fr')
// the locale menu marks the locale of the miss
expect(
await page.locator('.VPNavBarTranslations .items a').first().textContent()
).toContain('English')
})
test('the not-found page also renders at its own url', async () => {
await goto('/404')
expect(await page.textContent('h1')).toContain('Custom not found')
})
test('client-side navigation to a missing url keeps the url', async () => {
await goto('/')
await page.evaluate(() => {
;(window as any).__spa_marker = 1
const a = document.createElement('a')
a.href = '/nested/nowhere'
a.id = 'to-nowhere'
a.textContent = 'nowhere'
// above the fixed nav and sidebar, so the click reaches it
a.style.cssText = 'position:fixed;right:0;bottom:0;z-index:1000'
document.body.appendChild(a)
})
await page.click('#to-nowhere')
await page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Custom not found')
)
expect(new URL(page.url()).pathname).toBe('/nested/nowhere.html')
expect(await page.evaluate(() => (window as any).__spa_marker)).toBe(1)
// and back to a real page
await page.click('.vp-doc a[href="/"]')
await page.waitForSelector('.VPHome')
expect(new URL(page.url()).pathname).toBe('/')
expect(await page.evaluate(() => (window as any).__spa_marker)).toBe(1)
})
test('the server answers with the right status codes', async () => {
await goto('/')
expect(await status('/missing')).toBe(404)
expect(await status('/missing.html')).toBe(404)
expect(await status('/nested/nowhere/')).toBe(404)
expect(await status('/')).toBe(200)
expect(await status('/home.html')).toBe(200)
expect(await status('/404.html')).toBe(200)
})
test.runIf(process.env['VITE_TEST_BUILD'])(
'the emitted page is pre-rendered, marked and not indexed',
async () => {
await goto('/')
const html = await page.evaluate(async () =>
(await fetch('/missing', { headers: { accept: 'text/html' } })).text()
)
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('Custom not found')
expect(html).toContain('<meta name="robots" content="noindex">')
const frHtml = await page.evaluate(async () =>
(await fetch('/fr/404.html')).text()
)
expect(frHtml).toContain('<html lang="fr"')
expect(frHtml).toContain('Custom not found')
expect(frHtml).toContain('<title>Not found | Example</title>')
}
)
test.runIf(process.env['VITE_TEST_BUILD'])(
'a pre-rendered miss loads the page chunk once',
async () => {
await page.goto(`${origin()}/missing`)
await page.waitForSelector('#app .Layout')
const chunks = await page.evaluate(() =>
performance
.getEntriesByType('resource')
.map((e) => e.name)
.filter((name) => /\/404\.md\./.test(name))
)
expect(chunks).toHaveLength(1)
expect(chunks[0]).not.toContain('.lean.js')
}
)
})

@ -0,0 +1,136 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveConfig } from 'node/config'
import { notFoundPlugin } from 'node/plugins/notFoundPlugin'
import { normalizePath } from 'vite'
const locales = `locales: { root: { label: 'English', lang: 'en' }, zh: { label: '中文', lang: 'zh-CN' } }`
async function site(files: Record<string, string>, config = '') {
const root = mkdtempSync(join(tmpdir(), 'vp-not-found-'))
mkdirSync(join(root, '.vitepress'), { recursive: true })
writeFileSync(
join(root, '.vitepress/config.mjs'),
`export default { ${locales}, ${config} }`
)
for (const [file, content] of Object.entries(files)) {
mkdirSync(join(root, file, '..'), { recursive: true })
writeFileSync(join(root, file), content)
}
const siteConfig = await resolveConfig(root, 'build', 'production')
const plugin = notFoundPlugin(siteConfig)
const hooks = {
resolveId: (id: string, importer?: string) =>
(plugin.resolveId as any).handler.call(undefined, id, importer, {}),
load: (id: string) => (plugin.load as any).handler.call(undefined, id)
}
const file = (page: string) => normalizePath(join(siteConfig.srcDir, page))
return {
siteConfig,
...hooks,
file,
cleanup: () => rmSync(root, { recursive: true })
}
}
describe('node/plugins/notFoundPlugin', () => {
test('synthesizes the pages nobody wrote', async () => {
const s = await site({ 'index.md': '# Home' })
try {
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: null },
{ path: 'zh/404.md', source: null }
])
expect(s.resolveId('/zh/404.md')).toBe(s.file('zh/404.md'))
expect(s.resolveId('/zh/404.md?t=123')).toBe(s.file('zh/404.md'))
// the bundler hands entries over as native paths
expect(s.resolveId(join(s.siteConfig.srcDir, 'zh', '404.md'))).toBe(
s.file('zh/404.md')
)
expect(s.resolveId('./zh/404.md', s.file('index.md'))).toBe(
s.file('zh/404.md')
)
expect(s.load(s.file('404.md'))).toContain('<NotFound />')
expect(s.load(s.file('zh/404.md'))).toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('a locale without its own page re-exports the root one', async () => {
const s = await site({ 'index.md': '# Home', '404.md': '# Lost' })
try {
expect(s.siteConfig.pages).not.toContain('404.md')
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: '404.md' },
{ path: 'zh/404.md', source: null }
])
// the authored page loads as a file
expect(s.resolveId(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('404.md'))).toBeUndefined()
// the inherited one is a virtual js module around it
const id = s.resolveId('/zh/404.md')
expect(id).toBe('\0' + s.file('zh/404.md'))
const code = s.load(id)
expect(code).toContain(`from ${JSON.stringify(s.file('404.md'))}`)
expect(code).toContain(`relativePath: "zh/404.md"`)
expect(code).not.toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('a rewrite can move a page onto the not-found path', async () => {
const s = await site(
{ 'index.md': '# Home', 'errors/lost.md': '# Lost' },
`rewrites: { 'errors/lost.md': '404.md' }`
)
try {
expect(s.siteConfig.pages).not.toContain('errors/lost.md')
expect(s.siteConfig.notFoundPages[0]).toEqual({
path: '404.md',
source: 'errors/lost.md'
})
expect(s.load(s.file('zh/404.md'))).toContain(
`from ${JSON.stringify(s.file('errors/lost.md'))}`
)
} finally {
s.cleanup()
}
})
test('a rewrite moving 404.md away leaves the file a regular page', async () => {
const s = await site(
{ 'index.md': '# Home', '404.md': '# Moved' },
`rewrites: { '404.md': 'moved/404.md' }`
)
try {
expect(s.siteConfig.pages).toContain('404.md')
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: null },
{ path: 'zh/404.md', source: null }
])
// the file keeps its own content; the not-found page is synthesized
// from the theme instead
expect(s.resolveId(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('zh/404.md'))).toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('leaves sub-requests to the module that owns them', async () => {
const s = await site({ 'index.md': '# Home', '404.md': '# Lost' })
try {
expect(
s.resolveId('/zh/404.md?vue&type=style&index=0&lang.css')
).toBeUndefined()
expect(s.load(s.file('zh/404.md') + '?vue&type=style')).toBeUndefined()
} finally {
s.cleanup()
}
})
})

@ -1,12 +1,61 @@
import { import {
createNotFoundPageData,
isRelativeBase, isRelativeBase,
joinPath, joinPath,
mergeHead, mergeHead,
relativePathToRoot, relativePathToRoot,
type HeadConfig resolveNotFoundPage,
type HeadConfig,
type SiteData
} from 'shared/shared' } from 'shared/shared'
describe('shared/shared', () => { describe('shared/shared', () => {
describe('resolveNotFoundPage', () => {
const site = {
locales: {
root: { label: 'English', lang: 'en' },
zh: { label: '中文', lang: 'zh-CN' },
'fr-FR': { label: 'Français', lang: 'fr-FR' },
'https://example.com/': { label: 'External' }
}
} as unknown as SiteData
test('picks the not-found page of the path locale', () => {
expect(resolveNotFoundPage(site, 'zh/guide/missing.md')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'zh/guide/missing')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'zh/')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'fr-FR/missing')).toBe('fr-FR/404.md')
})
test('falls back to the root page', () => {
expect(resolveNotFoundPage(site, 'guide/missing.md')).toBe('404.md')
expect(resolveNotFoundPage(site, '')).toBe('404.md')
// a page named after the locale is not inside the locale directory
expect(resolveNotFoundPage(site, 'zh')).toBe('404.md')
// locale keys that are links to other sites never match
expect(resolveNotFoundPage(site, 'https://example.com/x')).toBe('404.md')
})
test('has only the root page without locales', () => {
expect(resolveNotFoundPage({} as SiteData, 'zh/missing')).toBe('404.md')
expect(resolveNotFoundPage(undefined, 'zh/missing')).toBe('404.md')
})
})
describe('createNotFoundPageData', () => {
test('is a virtual page of the given path', () => {
expect(createNotFoundPageData('zh/404.md')).toEqual({
relativePath: 'zh/404.md',
filePath: '',
title: '404',
description: 'Not Found',
headers: [],
frontmatter: {},
isNotFound: true
})
})
})
describe('mergeHead', () => { describe('mergeHead', () => {
test('replaces meta tags with the same key in place', () => { test('replaces meta tags with the same key in place', () => {
expect( expect(

@ -32,6 +32,11 @@ interface Theme {
* @required * @required
*/ */
Layout: Component Layout: Component
/**
* Content of the not-found page when the site has no `404.md`
* @optional
*/
NotFound?: Component
/** /**
* Enhance Vue app instance * Enhance Vue app instance
* @optional * @optional
@ -130,9 +135,21 @@ The most basic layout component needs to contain a [`<Content />`](../reference/
</template> </template>
``` ```
The above layout simply renders every page's markdown as HTML. The first improvement we can add is to handle 404 errors: The above layout renders every page's markdown as HTML. That includes the not-found page: when a visitor opens a URL that has no page, `<Content />` renders the site's `404.md`, or the theme's `NotFound` component when the site has none. A theme should ship that component, so every site gets a not-found page without writing one. Without it, a small unstyled built-in page is shown instead.
```js [.vitepress/theme/index.js]
import Layout from './Layout.vue'
import NotFound from './NotFound.vue'
export default {
Layout,
NotFound
}
```
The [`useData()`](../reference/runtime-api#usedata) helper provides us with all the runtime data we need to conditionally render different layouts. For example, `page.isNotFound` is `true` on the not-found page, so the layout can leave out the parts that only make sense for real pages:
```vue{1-4,9-12} ```vue{1-4,9}
<script setup> <script setup>
import { useData } from 'vitepress' import { useData } from 'vitepress'
const { page } = useData() const { page } = useData()
@ -141,14 +158,12 @@ const { page } = useData()
<template> <template>
<h1>Custom Layout!</h1> <h1>Custom Layout!</h1>
<div v-if="page.isNotFound"> <aside v-if="!page.isNotFound">Table of contents</aside>
Custom 404 page! <Content />
</div>
<Content v-else />
</template> </template>
``` ```
The [`useData()`](../reference/runtime-api#usedata) helper provides us with all the runtime data we need to conditionally render different layouts. One of the other data we can access is the current page's frontmatter. We can leverage this to allow the end user to control the layout in each page. For example, the user can indicate the page should use a special home page layout with: One of the other data we can access is the current page's frontmatter. We can leverage this to allow the end user to control the layout in each page. For example, the user can indicate the page should use a special home page layout with:
```md ```md
--- ---
@ -158,18 +173,15 @@ layout: home
And we can adjust our theme to handle this: And we can adjust our theme to handle this:
```vue{3,12-14} ```vue{3,9-12}
<script setup> <script setup>
import { useData } from 'vitepress' import { useData } from 'vitepress'
const { page, frontmatter } = useData() const { frontmatter } = useData()
</script> </script>
<template> <template>
<h1>Custom Layout!</h1> <h1>Custom Layout!</h1>
<div v-if="page.isNotFound">
Custom 404 page!
</div>
<div v-if="frontmatter.layout === 'home'"> <div v-if="frontmatter.layout === 'home'">
Custom home page! Custom home page!
</div> </div>
@ -179,20 +191,18 @@ const { page, frontmatter } = useData()
You can, of course, split the layout into more components: You can, of course, split the layout into more components:
```vue{3-5,12-15} ```vue{3-4,12-13}
<script setup> <script setup>
import { useData } from 'vitepress' import { useData } from 'vitepress'
import NotFound from './NotFound.vue'
import Home from './Home.vue' import Home from './Home.vue'
import Page from './Page.vue' import Page from './Page.vue'
const { page, frontmatter } = useData() const { frontmatter } = useData()
</script> </script>
<template> <template>
<h1>Custom Layout!</h1> <h1>Custom Layout!</h1>
<NotFound v-if="page.isNotFound" />
<Home v-if="frontmatter.layout === 'home'" /> <Home v-if="frontmatter.layout === 'home'" />
<Page v-else /> <!-- <Page /> renders <Content /> --> <Page v-else /> <!-- <Page /> renders <Content /> -->
</template> </template>

@ -240,8 +240,6 @@ Full list of slots available in the default theme layout:
- When `layout: 'page'` is enabled via frontmatter: - When `layout: 'page'` is enabled via frontmatter:
- `page-top` - `page-top`
- `page-bottom` - `page-bottom`
- On not found (404) page:
- `not-found`
- Always: - Always:
- `layout-top` - `layout-top`
- `layout-bottom` - `layout-bottom`

@ -102,6 +102,8 @@ docs/
├─ foo.md ├─ foo.md
``` ```
Each locale directory can also have its own [`404.md`](./routing#not-found-page). A locale without one shares the root `404.md`.
However, VitePress won't redirect `/` to `/en/` by default. You'll need to configure your server for that. For example, on Netlify, you can add a `docs/public/_redirects` file like this: However, VitePress won't redirect `/` to `/en/` by default. You'll need to configure your server for that. For example, on Netlify, you can add a `docs/public/_redirects` file like this:
``` ```

@ -151,6 +151,28 @@ If, however, you cannot configure your server with such support, you will have t
└─ index.md └─ index.md
``` ```
## Not Found Page
When a visitor opens a URL that has no page, VitePress shows the not-found page. The default theme ships one, and you can change its text with the [`notFound`](../reference/default-theme-config#notfound) theme option.
To replace the page entirely, add a `404.md` file to your source directory. It is a regular page: frontmatter, Markdown and Vue components all work.
```md [404.md]
---
title: Page not found
---
# Page not found
The page you are looking for does not exist. [Go to the homepage](/).
```
With [multiple locales](./i18n), each locale directory can have its own `404.md`, for example `zh/404.md`. A locale without one uses the root `404.md`, and the theme's default page when there is none either.
The build emits `404.html` at the output root and one in each locale directory. Most hosts pick up `404.html` automatically, see the [deployment guide](./deploy). The dev and preview servers answer a miss with a real 404 status too.
On the not-found page, `useData().page.isNotFound` is `true` and `useRoute().path` holds the URL the visitor asked for. The page is left out of the sitemap and the local search index.
## Route Rewrites ## Route Rewrites
You can customize the mapping between the source directory structure and the generated pages. It's useful when you have a complex project structure. For example, let's say you have a monorepo with multiple packages, and would like to place documentations along with the source files like this: You can customize the mapping between the source directory structure and the generated pages. It's useful when you have a complex project structure. For example, let's say you have a monorepo with multiple packages, and would like to place documentations along with the source files like this:

@ -431,6 +431,61 @@ export interface DocFooter {
} }
``` ```
## notFound
- Type: `NotFoundOptions`
Customizes the text of the not-found page. Set it under `locales.<locale>.themeConfig` to translate it. To replace the whole page, add a [`404.md`](../guide/routing#not-found-page) to your site instead.
```ts
export interface NotFoundOptions {
/**
* Set custom not found message.
*
* @default 'PAGE NOT FOUND'
*/
title?: string
/**
* Set custom not found description.
*
* @default "But if you don't change your direction, and if you keep looking, you may end up where you are heading."
*/
quote?: string
/**
* Target of the home link. Defaults to the home of the current locale.
*/
link?: string
/**
* Set custom home link text.
*
* @default 'Take me home'
*/
linkText?: string
/**
* @default '404'
*/
code?: string
}
```
**Example:**
```ts
export default {
themeConfig: {
notFound: {
title: 'Nothing here',
quote: 'The page you are looking for may have moved.',
linkText: 'Back to the docs'
}
}
}
```
## darkModeSwitchLabel ## darkModeSwitchLabel
- Type: `string` - Type: `string`
@ -521,6 +576,7 @@ Returns layout-related data. The returned object has the following type:
```ts ```ts
interface { interface {
layout: ComputedRef<string>
isHome: ComputedRef<boolean> isHome: ComputedRef<boolean>
sidebar: Readonly<ShallowRef<DefaultTheme.SidebarItem[]>> sidebar: Readonly<ShallowRef<DefaultTheme.SidebarItem[]>>

@ -64,6 +64,8 @@ interface PageData {
`page.headers` is populated only when [`markdown.headers`](./site-config#markdown) is enabled. Without that option, it remains an empty array. The default theme outline reads rendered headings from the page content, so it can still appear when `page.headers` is empty. `page.headers` is populated only when [`markdown.headers`](./site-config#markdown) is enabled. Without that option, it remains an empty array. The default theme outline reads rendered headings from the page content, so it can still appear when `page.headers` is empty.
`page.isNotFound` is `true` on the [not-found page](../guide/routing#not-found-page), which also answers every URL that has no page. `useRoute().path` still holds the URL the visitor asked for.
**Example:** **Example:**
```vue ```vue

@ -722,7 +722,7 @@ In many cases, using the [`transformPageData`](#transformpagedata) hook is a cle
```ts ```ts
export default { export default {
async transformHead(context) { async transformHead(context) {
if (context.page === '404.md') { if (context.pageData.isNotFound) {
return return
} }

@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: 'PÁGINA NO ENCONTRADA', title: 'PÁGINA NO ENCONTRADA',
quote: quote:
'Pero si no cambias de dirección y sigues buscando, podrías terminar donde te diriges.', 'Pero si no cambias de dirección y sigues buscando, podrías terminar donde te diriges.',
linkLabel: 'ir a inicio',
linkText: 'Llévame a inicio' linkText: 'Llévame a inicio'
}, },

@ -70,7 +70,6 @@ export default defineAdditionalConfig({
title: 'صفحه پیدا نشد', title: 'صفحه پیدا نشد',
quote: quote:
'اما اگر جهت خود را تغییر ندهید و همچنان به جستجو ادامه دهید، ممکن است در نهایت به جایی برسید که در حال رفتن به آن هستید.', 'اما اگر جهت خود را تغییر ندهید و همچنان به جستجو ادامه دهید، ممکن است در نهایت به جایی برسید که در حال رفتن به آن هستید.',
linkLabel: 'برو به خانه',
linkText: 'من را به خانه ببر' linkText: 'من را به خانه ببر'
}, },

@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: '페이지를 찾을 수 없습니다', title: '페이지를 찾을 수 없습니다',
quote: quote:
'방향을 바꾸지 않고 계속 찾다 보면 결국 당신이 가고 있는 곳에 도달할 수도 있습니다.', '방향을 바꾸지 않고 계속 찾다 보면 결국 당신이 가고 있는 곳에 도달할 수도 있습니다.',
linkLabel: '홈으로 가기',
linkText: '집으로 데려가줘' linkText: '집으로 데려가줘'
}, },

@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: 'PÁGINA NÃO ENCONTRADA', title: 'PÁGINA NÃO ENCONTRADA',
quote: quote:
'Mas se você não mudar de direção e continuar procurando, pode acabar onde está indo.', 'Mas se você não mudar de direção e continuar procurando, pode acabar onde está indo.',
linkLabel: 'ir para a página inicial',
linkText: 'Me leve para casa' linkText: 'Me leve para casa'
}, },

@ -60,7 +60,6 @@ export default defineAdditionalConfig({
title: 'СТРАНИЦА НЕ НАЙДЕНА', title: 'СТРАНИЦА НЕ НАЙДЕНА',
quote: quote:
'Но если ты не изменишь направление и продолжишь искать, ты можешь оказаться там, куда направляешься.', 'Но если ты не изменишь направление и продолжишь искать, ты можешь оказаться там, куда направляешься.',
linkLabel: 'перейти на главную',
linkText: 'Отведи меня домой' linkText: 'Отведи меня домой'
}, },

@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: '页面未找到', title: '页面未找到',
quote: quote:
'但如果你不改变方向,并且继续寻找,你可能最终会到达你所前往的地方。', '但如果你不改变方向,并且继续寻找,你可能最终会到达你所前往的地方。',
linkLabel: '前往首页',
linkText: '带我回首页' linkText: '带我回首页'
}, },

@ -2,6 +2,7 @@ import { useData, useRoute } from 'vitepress'
import { defineComponent, h, watch } from 'vue' import { defineComponent, h, watch } from 'vue'
import { contentUpdatedCallbacks } from '../utils' import { contentUpdatedCallbacks } from '../utils'
import { NotFound } from './NotFound'
const runCbs = () => contentUpdatedCallbacks.forEach((fn) => fn()) const runCbs = () => contentUpdatedCallbacks.forEach((fn) => fn())
@ -17,15 +18,17 @@ export const Content = defineComponent({
return () => return () =>
h( h(
props.as, props.as,
site.value.contentProps ?? { style: { position: 'relative' } }, site.value.contentProps ?? {
class: 'vp-content',
style: { position: 'relative' }
},
[ [
route.component // a route without a component has nothing to show but a miss
? h(route.component, { h(route.component ?? NotFound, {
onVnodeMounted: runCbs, onVnodeMounted: runCbs,
onVnodeUpdated: runCbs, onVnodeUpdated: runCbs,
onVnodeUnmounted: runCbs onVnodeUnmounted: runCbs
}) })
: '404 Page Not Found'
] ]
) )
} }

@ -0,0 +1,19 @@
import { defineComponent, h } from 'vue'
import { withBase } from '../utils'
/**
* The not-found page content of a site whose theme provides none. Same
* shape as the default theme's, so a theme can style it the same way.
*/
export const NotFound = defineComponent({
name: 'VitePressNotFound',
setup() {
return () =>
h('div', { class: 'vp-not-found' }, [
h('p', { class: 'code' }, '404'),
h('h1', { class: 'title' }, 'Page not found'),
h('a', { class: 'link', href: withBase('/') }, 'Take me home')
])
}
})

@ -16,30 +16,24 @@ import { useCopyCode } from './composables/copyCode'
import { useUpdateHead } from './composables/head' import { useUpdateHead } from './composables/head'
import { usePrefetch } from './composables/preFetch' import { usePrefetch } from './composables/preFetch'
import { dataSymbol, initData, siteDataRef, useData } from './data' import { dataSymbol, initData, siteDataRef, useData } from './data'
import { RouterSymbol, createRouter, scrollTo, type Router } from './router' import {
RouterSymbol,
createRouter,
isLoadFailure,
scrollTo,
type Router
} from './router'
import { resolveNotFound, resolveThemeExtends } from './theme'
import { inBrowser, pathToFile } from './utils' import { inBrowser, pathToFile } from './utils'
function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme {
if (theme.extends) {
const base = resolveThemeExtends(theme.extends)
return {
...base,
...theme,
async enhanceApp(ctx) {
await base.enhanceApp?.(ctx)
await theme.enhanceApp?.(ctx)
},
setup() {
base.setup?.()
theme.setup?.()
}
}
}
return theme
}
const Theme = resolveThemeExtends(RawTheme) const Theme = resolveThemeExtends(RawTheme)
// a pre-rendered not-found document is never hydrated: the host may serve
// it for any path, so its markup can belong to another page or locale
const isNotFoundDocument = () =>
inBrowser &&
!!document.getElementById('app')?.hasAttribute('data-vp-not-found')
const VitePressApp = defineComponent({ const VitePressApp = defineComponent({
name: 'VitePressApp', name: 'VitePressApp',
setup() { setup() {
@ -129,7 +123,9 @@ function newApp(): App {
} }
function newRouter(): Router { function newRouter(): Router {
let isInitialPageLoad = inBrowser // the lean build leaves the static content to the pre-rendered markup, so
// it only fits a page that is going to be hydrated
let isInitialPageLoad = inBrowser && !isNotFoundDocument()
return createRouter((path) => { return createRouter((path) => {
let pageFilePath = pathToFile(path) let pageFilePath = pathToFile(path)
@ -144,7 +140,7 @@ function newRouter(): Router {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
pageModule = import(/*@vite-ignore*/ pageFilePath).catch((e) => { pageModule = import(/*@vite-ignore*/ pageFilePath).catch((e) => {
// page load could fail for other reasons, don't swallow // page load could fail for other reasons, don't swallow
console.error(e) if (!isLoadFailure(e)) console.error(e)
// try with/without trailing slash // try with/without trailing slash
// in prod this is handled in src/client/app/utils.ts#pathToFile // in prod this is handled in src/client/app/utils.ts#pathToFile
const url = new URL(pageFilePath!, 'http://a.com') const url = new URL(pageFilePath!, 'http://a.com')
@ -166,7 +162,7 @@ function newRouter(): Router {
} }
return pageModule return pageModule
}, Theme.NotFound) }, resolveNotFound(RawTheme))
} }
if (inBrowser) { if (inBrowser) {
@ -175,6 +171,9 @@ if (inBrowser) {
router.go(location.href, { initialLoad: true }).then(() => { router.go(location.href, { initialLoad: true }).then(() => {
// dynamically update head tags // dynamically update head tags
useUpdateHead(router.route, data.site) useUpdateHead(router.route, data.site)
if (import.meta.env.PROD && isNotFoundDocument()) {
document.getElementById('app')!.replaceChildren()
}
app.mount('#app') app.mount('#app')
// scroll to hash on new tab during dev // scroll to hash on new tab during dev

@ -2,7 +2,11 @@ import type { Component, InjectionKey } from 'vue'
import { inject, markRaw, nextTick, reactive, readonly } from 'vue' import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload, Route } from '../shared' import type { Awaitable, PageData, PageDataPayload, Route } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared' import {
createNotFoundPageData,
resolveNotFoundPage,
treatAsHtml
} from '../shared'
import { siteDataRef } from './data' import { siteDataRef } from './data'
import { inBrowser, runtimeBase, withBase } from './utils' import { inBrowser, runtimeBase, withBase } from './utils'
@ -48,12 +52,20 @@ export const RouterSymbol: InjectionKey<Router> = Symbol()
// matter and is only passed to support same-host hrefs // matter and is only passed to support same-host hrefs
const fakeHost = 'http://a.com' const fakeHost = 'http://a.com'
// nothing is rendered before the first page resolves
const getDefaultRoute = (): Route => ({ const getDefaultRoute = (): Route => ({
path: '/', path: '/',
hash: '', hash: '',
query: '', query: '',
component: null, component: null,
data: notFoundPageData data: {
relativePath: '',
filePath: '',
title: '',
description: '',
headers: [],
frontmatter: {}
}
}) })
interface PageModule { interface PageModule {
@ -61,9 +73,20 @@ interface PageModule {
default: Component default: Component
} }
/**
* Whether a page module failed to load rather than to run: the browser's
* dynamic import rejection, or our own miss.
*/
export function isLoadFailure(err: unknown): boolean {
const message = (err as { message?: string } | null)?.message ?? ''
return /fetch|dynamically imported module|module script|Page not found/.test(
message
)
}
export function createRouter( export function createRouter(
loadPageModule: (path: string) => Awaitable<PageModule | null>, loadPageModule: (path: string) => Awaitable<PageModule | null>,
fallbackComponent?: Component fallbackComponent: Component
): Router { ): Router {
const route = reactive(getDefaultRoute()) const route = reactive(getDefaultRoute())
@ -141,17 +164,12 @@ export function createRouter(
} }
} }
} catch (err: any) { } catch (err: any) {
if ( if (!isLoadFailure(err)) console.error(err)
!/fetch|Page not found/.test(err.message) &&
!/^\/404(\.html|\/)?$/.test(href)
) {
console.error(err)
}
// retry on fetch fail: the page to hash map may have been invalidated // retry on fetch fail: the page to hash map may have been invalidated
// because a new deploy happened while the page is open. Try to fetch // because a new deploy happened while the page is open. Try to fetch
// the updated pageToHash map and fetch again. // the updated pageToHash map and fetch again.
if (!isRetry) { if (!isRetry && import.meta.env.PROD) {
try { try {
const res = await fetch(runtimeBase() + 'hashmap.json') const res = await fetch(runtimeBase() + 'hashmap.json')
;(window as any).__VP_HASH_MAP__ = await res.json() ;(window as any).__VP_HASH_MAP__ = await res.json()
@ -160,21 +178,44 @@ export function createRouter(
} catch (e) {} } catch (e) {}
} }
if (latestPendingPath === pendingPath) {
const { default: comp, __pageData } =
await loadNotFoundPage(pendingPath)
if (latestPendingPath === pendingPath) { if (latestPendingPath === pendingPath) {
latestPendingPath = null latestPendingPath = null
route.path = inBrowser ? pendingPath : withBase(pendingPath) route.path = inBrowser ? pendingPath : withBase(pendingPath)
route.component = fallbackComponent ? markRaw(fallbackComponent) : null route.component = markRaw(comp)
const relativePath = inBrowser route.data = import.meta.env.PROD
? route.path ? markRaw(__pageData)
.replace(/(^|\/)$/, '$1index') : (readonly(__pageData) as PageData)
.replace(/(\.html)?$/, '.md')
.slice(runtimeBase().length)
: '404.md'
route.data = { ...notFoundPageData, relativePath }
syncRouteQueryAndHash(targetLoc) syncRouteQueryAndHash(targetLoc)
} }
} }
} }
}
/**
* The not-found page that answers a path: the one of the path's locale,
* loaded like any page, or the theme's component when that fails too.
*/
async function loadNotFoundPage(pendingPath: string): Promise<PageModule> {
const base = inBrowser ? runtimeBase() : '/'
const relativePath = resolveNotFoundPage(
siteDataRef.value,
pendingPath.startsWith(base) ? pendingPath.slice(base.length) : ''
)
const target = base + relativePath.replace(/\.md$/, '')
if (target !== pendingPath.replace(/\.html$/, '')) {
try {
const page = await loadPageModule(target)
if (page?.default) return page
} catch {}
}
return {
default: fallbackComponent,
__pageData: createNotFoundPageData(relativePath)
}
}
function syncRouteQueryAndHash( function syncRouteQueryAndHash(
loc: { search: string; hash: string } = inBrowser loc: { search: string; hash: string } = inBrowser
@ -305,23 +346,18 @@ export function scrollTo(hash: string, scrollPosition = 0) {
} }
function handleHMR(route: Route): void { function handleHMR(route: Route): void {
// update route.data on HMR updates of active page // update route.data on HMR updates of active page; matched by page rather
// than by URL, since the not-found page answers URLs that are not its own
if (import.meta.hot) { if (import.meta.hot) {
// hot reload pageData // hot reload pageData
import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => { import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => {
if (shouldHotReload(payload)) route.data = payload.pageData if (payload.path === `/${route.data.relativePath}`) {
route.data = payload.pageData
}
}) })
} }
} }
function shouldHotReload(payload: PageDataPayload): boolean {
const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1')
const locationPath = location.pathname
.replace(/(?:(^|\/)index)?\.html$/, '')
.slice(runtimeBase().length - 1)
return payloadPath === locationPath
}
function normalizeHref(href: string): string { function normalizeHref(href: string): string {
const url = new URL(href, fakeHost) const url = new URL(href, fakeHost)
url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1')

@ -1,6 +1,7 @@
import type { App, Component, Ref } from 'vue' import type { App, Component, Ref } from 'vue'
import type { Awaitable, SiteData } from '../shared' import type { Awaitable, SiteData } from '../shared'
import { NotFound } from './components/NotFound'
import type { Router } from './router' import type { Router } from './router'
export interface EnhanceAppContext { export interface EnhanceAppContext {
@ -21,7 +22,40 @@ export interface Theme {
setup?: () => void setup?: () => void
/** /**
* @deprecated Render not found page by checking `useData().page.value.isNotFound` in Layout instead. * The content of the not-found page when the site has no `404.md`. It is
* rendered through `<Content />` like any page, with `page.isNotFound`
* set, so the layout can still decide what goes around it.
*/ */
NotFound?: Component NotFound?: Component
} }
/**
* Flattens a theme's `extends` chain: the theme's own fields win, and the
* `enhanceApp` and `setup` hooks run base-first.
*/
export function resolveThemeExtends<T extends Theme>(theme: T): T {
if (theme.extends) {
const base = resolveThemeExtends(theme.extends)
return {
...base,
...theme,
async enhanceApp(ctx) {
await base.enhanceApp?.(ctx)
await theme.enhanceApp?.(ctx)
},
setup() {
base.setup?.()
theme.setup?.()
}
}
}
return theme
}
/**
* The component rendered as the not-found page content when the site has no
* `404.md`: the theme's `NotFound`, or the built-in one.
*/
export function resolveNotFound(theme: Theme): Component {
return resolveThemeExtends(theme).NotFound ?? NotFound
}

@ -64,7 +64,6 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<template #page-top><slot name="page-top" /></template> <template #page-top><slot name="page-top" /></template>
<template #page-bottom><slot name="page-bottom" /></template> <template #page-bottom><slot name="page-bottom" /></template>
<template #not-found><slot name="not-found" /></template>
<template #home-hero-before><slot name="home-hero-before" /></template> <template #home-hero-before><slot name="home-hero-before" /></template>
<template #home-hero-info-before><slot name="home-hero-info-before" /></template> <template #home-hero-info-before><slot name="home-hero-info-before" /></template>
<template #home-hero-info><slot name="home-hero-info" /></template> <template #home-hero-info><slot name="home-hero-info" /></template>

@ -21,11 +21,7 @@ const { currentLang } = useLangs()
</blockquote> </blockquote>
<div class="action"> <div class="action">
<a <a class="link" :href="withBase(theme.notFound?.link ?? currentLang.link)">
class="link"
:href="withBase(theme.notFound?.link ?? currentLang.link)"
:aria-label="theme.notFound?.linkLabel ?? 'go to home'"
>
{{ theme.notFound?.linkText ?? 'Take me home' }} {{ theme.notFound?.linkText ?? 'Take me home' }}
</a> </a>
</div> </div>

@ -1,15 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { resolveDynamicComponent } from 'vue' import { resolveDynamicComponent } from 'vue'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import NotFound from '../NotFound.vue'
import VPDoc from './VPDoc.vue' import VPDoc from './VPDoc.vue'
import VPHome from './VPHome.vue' import VPHome from './VPHome.vue'
import VPPage from './VPPage.vue' import VPPage from './VPPage.vue'
const { page, frontmatter } = useData() const { isHome, hasSidebar, layout } = useLayout()
const { isHome, hasSidebar } = useLayout()
function isRegistered(component: string): boolean { function isRegistered(component: string): boolean {
return typeof resolveDynamicComponent(component) !== 'string' return typeof resolveDynamicComponent(component) !== 'string'
@ -22,14 +19,12 @@ function isRegistered(component: string): boolean {
id="VPContent" id="VPContent"
:class="{ 'has-sidebar': hasSidebar, 'is-home': isHome }" :class="{ 'has-sidebar': hasSidebar, 'is-home': isHome }"
> >
<slot name="not-found" v-if="page.isNotFound"><NotFound /></slot> <VPPage v-if="layout === 'page' && !isRegistered('page')">
<VPPage v-else-if="frontmatter.layout === 'page' && !isRegistered('page')">
<template #page-top><slot name="page-top" /></template> <template #page-top><slot name="page-top" /></template>
<template #page-bottom><slot name="page-bottom" /></template> <template #page-bottom><slot name="page-bottom" /></template>
</VPPage> </VPPage>
<VPHome v-else-if="frontmatter.layout === 'home' && !isRegistered('home')"> <VPHome v-else-if="layout === 'home' && !isRegistered('home')">
<template #home-hero-before><slot name="home-hero-before" /></template> <template #home-hero-before><slot name="home-hero-before" /></template>
<template #home-hero-info-before><slot name="home-hero-info-before" /></template> <template #home-hero-info-before><slot name="home-hero-info-before" /></template>
<template #home-hero-info><slot name="home-hero-info" /></template> <template #home-hero-info><slot name="home-hero-info" /></template>
@ -42,7 +37,7 @@ function isRegistered(component: string): boolean {
<template #home-features-after><slot name="home-features-after" /></template> <template #home-features-after><slot name="home-features-after" /></template>
</VPHome> </VPHome>
<VPDoc v-else-if="(!frontmatter.layout || frontmatter.layout === 'doc') && !isRegistered('doc')"> <VPDoc v-else-if="layout === 'doc' && !isRegistered('doc')">
<template #doc-top><slot name="doc-top" /></template> <template #doc-top><slot name="doc-top" /></template>
<template #doc-bottom><slot name="doc-bottom" /></template> <template #doc-bottom><slot name="doc-bottom" /></template>
@ -58,7 +53,7 @@ function isRegistered(component: string): boolean {
<template #aside-bottom><slot name="aside-bottom" /></template> <template #aside-bottom><slot name="aside-bottom" /></template>
</VPDoc> </VPDoc>
<component v-else :is="frontmatter.layout || 'doc'" /> <component v-else :is="layout" />
</div> </div>
</template> </template>

@ -13,7 +13,9 @@ const editLink = useEditLink()
const control = usePrevNext() const control = usePrevNext()
const hasEditLink = computed( const hasEditLink = computed(
() => theme.value.editLink && frontmatter.value.editLink !== false () =>
theme.value.editLink &&
(frontmatter.value.editLink ?? !page.value.isNotFound) !== false
) )
const hasLastUpdated = computed(() => page.value.lastUpdated) const hasLastUpdated = computed(() => page.value.lastUpdated)
const showFooter = computed( const showFooter = computed(

@ -94,7 +94,7 @@ useEventListener('pointerdown', (e) => {
class="button" class="button"
:aria-expanded="open" :aria-expanded="open"
:aria-controls="menuId" :aria-controls="menuId"
:aria-label="label" :aria-label="button ? undefined : label"
@pointerenter="onPointerEnter" @pointerenter="onPointerEnter"
@pointerleave="onPointerLeave" @pointerleave="onPointerLeave"
@click="toggle" @click="toggle"

@ -601,22 +601,28 @@ function onMouseMove(e: MouseEvent) {
<div class="search-keyboard-shortcuts"> <div class="search-keyboard-shortcuts">
<span> <span>
<kbd :aria-label="translate('modal.footer.navigateUpKeyAriaLabel')"> <kbd>
<span class="vpi-arrow-up navigate-icon" /> <span class="vpi-arrow-up navigate-icon" aria-hidden="true" />
<span class="visually-hidden">{{ translate('modal.footer.navigateUpKeyAriaLabel') }}</span>
</kbd> </kbd>
<kbd :aria-label="translate('modal.footer.navigateDownKeyAriaLabel')"> <kbd>
<span class="vpi-arrow-down navigate-icon" /> <span class="vpi-arrow-down navigate-icon" aria-hidden="true" />
<span class="visually-hidden">{{ translate('modal.footer.navigateDownKeyAriaLabel') }}</span>
</kbd> </kbd>
{{ translate('modal.footer.navigateText') }} {{ translate('modal.footer.navigateText') }}
</span> </span>
<span> <span>
<kbd :aria-label="translate('modal.footer.selectKeyAriaLabel')"> <kbd>
<span class="vpi-corner-down-left navigate-icon" /> <span class="vpi-corner-down-left navigate-icon" aria-hidden="true" />
<span class="visually-hidden">{{ translate('modal.footer.selectKeyAriaLabel') }}</span>
</kbd> </kbd>
{{ translate('modal.footer.selectText') }} {{ translate('modal.footer.selectText') }}
</span> </span>
<span> <span>
<kbd :aria-label="translate('modal.footer.closeKeyAriaLabel')">esc</kbd> <kbd>
<span aria-hidden="true">esc</span>
<span class="visually-hidden">{{ translate('modal.footer.closeKeyAriaLabel') }}</span>
</kbd>
{{ translate('modal.footer.closeText') }} {{ translate('modal.footer.closeText') }}
</span> </span>
</div> </div>

@ -163,7 +163,6 @@ function isEditingContent(event: KeyboardEvent): boolean {
<VPNavBarSearchButton <VPNavBarSearchButton
v-if="resolvedMode.showKeywordSearch" v-if="resolvedMode.showKeywordSearch"
:text="algoliaOptions.translations?.button?.buttonText || 'Search'" :text="algoliaOptions.translations?.button?.buttonText || 'Search'"
:aria-label="algoliaOptions.translations?.button?.buttonAriaLabel || 'Search'"
:aria-keyshortcuts="'/ control+k meta+k'" :aria-keyshortcuts="'/ control+k meta+k'"
@click="loadAndOpen('search')" @click="loadAndOpen('search')"
/> />
@ -183,7 +182,6 @@ function isEditingContent(event: KeyboardEvent): boolean {
<template v-else-if="provider === 'local'"> <template v-else-if="provider === 'local'">
<VPNavBarSearchButton <VPNavBarSearchButton
:text="algoliaOptions.translations?.button?.buttonText || 'Search'" :text="algoliaOptions.translations?.button?.buttonText || 'Search'"
:aria-label="algoliaOptions.translations?.button?.buttonAriaLabel || 'Search'"
:aria-keyshortcuts="'/ control+k meta+k'" :aria-keyshortcuts="'/ control+k meta+k'"
@click="showSearch = true" @click="showSearch = true"
/> />

@ -25,7 +25,17 @@ defineProps<{
font-size: 1.25rem; font-size: 1.25rem;
} }
.text, /* the text stays in the accessibility tree while the bar shows only the icon */
.text {
position: absolute;
width: 1px;
height: 1px;
white-space: nowrap;
clip: rect(0 0 0 0);
clip-path: inset(50%);
overflow: hidden;
}
.keys { .keys {
display: none; display: none;
} }
@ -59,7 +69,12 @@ kbd {
} }
.text { .text {
display: inline; position: static;
width: auto;
height: auto;
clip: auto;
clip-path: none;
overflow: visible;
font-size: 0.8125rem; font-size: 0.8125rem;
} }

@ -21,15 +21,26 @@ const sidebar = shallowRef<DefaultTheme.SidebarItem[]>([])
const isDesktop = useMediaQuery('(min-width: 60rem)') const isDesktop = useMediaQuery('(min-width: 60rem)')
export function useLayout(): DefaultTheme.Layout { export function useLayout(): DefaultTheme.Layout {
const { frontmatter, theme } = useData() const { frontmatter, page, theme } = useData()
// a not-found page reads like a doc page without the doc chrome; the one
// synthesized from the theme's `NotFound` component has no prose to style
const isNotFound = computed(() => !!page.value.isNotFound)
const layout = computed<string>(() => {
return (
frontmatter.value.layout ||
(isNotFound.value && !page.value.filePath ? 'page' : 'doc')
)
})
const isHome = computed(() => { const isHome = computed(() => {
return !!(frontmatter.value.isHome ?? frontmatter.value.layout === 'home') return !!(frontmatter.value.isHome ?? layout.value === 'home')
}) })
const hasSidebar = computed(() => { const hasSidebar = computed(() => {
return ( return (
frontmatter.value.sidebar !== false && (frontmatter.value.sidebar ?? !isNotFound.value) !== false &&
sidebar.value.length > 0 && sidebar.value.length > 0 &&
!isHome.value !isHome.value
) )
@ -43,7 +54,8 @@ export function useLayout(): DefaultTheme.Layout {
const hasAside = computed(() => { const hasAside = computed(() => {
if (isHome.value) return false if (isHome.value) return false
if (frontmatter.value.aside != null) return !!frontmatter.value.aside const aside = frontmatter.value.aside ?? (isNotFound.value ? false : null)
if (aside != null) return !!aside
return theme.value.aside !== false return theme.value.aside !== false
}) })
@ -59,6 +71,7 @@ export function useLayout(): DefaultTheme.Layout {
}) })
return { return {
layout,
isHome, isHome,
sidebar: shallowReadonly(sidebar), sidebar: shallowReadonly(sidebar),
sidebarGroups, sidebarGroups,

@ -22,6 +22,11 @@ export function usePrevNext() {
return isActive(page.value.relativePath, '', link.link, false, true) return isActive(page.value.relativePath, '', link.link, false, true)
}) })
// a page outside the sidebar (the not-found page, for one) has no
// neighbours; `candidates[-1 + 1]` would otherwise elect the first entry
const prevCandidate = index === -1 ? undefined : candidates[index - 1]
const nextCandidate = index === -1 ? undefined : candidates[index + 1]
const hidePrev = const hidePrev =
(theme.value.docFooter?.prev === false && !frontmatter.value.prev) || (theme.value.docFooter?.prev === false && !frontmatter.value.prev) ||
frontmatter.value.prev === false frontmatter.value.prev === false
@ -40,20 +45,20 @@ export function usePrevNext() {
: typeof frontmatter.value.prev === 'object' : typeof frontmatter.value.prev === 'object'
? frontmatter.value.prev.text ? frontmatter.value.prev.text
: undefined) ?? : undefined) ??
candidates[index - 1]?.docFooterText ?? prevCandidate?.docFooterText ??
candidates[index - 1]?.text, prevCandidate?.text,
link: link:
(typeof frontmatter.value.prev === 'object' (typeof frontmatter.value.prev === 'object'
? frontmatter.value.prev.link ? frontmatter.value.prev.link
: undefined) ?? candidates[index - 1]?.link, : undefined) ?? prevCandidate?.link,
target: target:
(typeof frontmatter.value.prev === 'object' (typeof frontmatter.value.prev === 'object'
? frontmatter.value.prev.target ? frontmatter.value.prev.target
: undefined) ?? candidates[index - 1]?.target, : undefined) ?? prevCandidate?.target,
rel: rel:
(typeof frontmatter.value.prev === 'object' (typeof frontmatter.value.prev === 'object'
? frontmatter.value.prev.rel ? frontmatter.value.prev.rel
: undefined) ?? candidates[index - 1]?.rel : undefined) ?? prevCandidate?.rel
}, },
next: hideNext next: hideNext
? undefined ? undefined
@ -64,20 +69,20 @@ export function usePrevNext() {
: typeof frontmatter.value.next === 'object' : typeof frontmatter.value.next === 'object'
? frontmatter.value.next.text ? frontmatter.value.next.text
: undefined) ?? : undefined) ??
candidates[index + 1]?.docFooterText ?? nextCandidate?.docFooterText ??
candidates[index + 1]?.text, nextCandidate?.text,
link: link:
(typeof frontmatter.value.next === 'object' (typeof frontmatter.value.next === 'object'
? frontmatter.value.next.link ? frontmatter.value.next.link
: undefined) ?? candidates[index + 1]?.link, : undefined) ?? nextCandidate?.link,
target: target:
(typeof frontmatter.value.next === 'object' (typeof frontmatter.value.next === 'object'
? frontmatter.value.next.target ? frontmatter.value.next.target
: undefined) ?? candidates[index + 1]?.target, : undefined) ?? nextCandidate?.target,
rel: rel:
(typeof frontmatter.value.next === 'object' (typeof frontmatter.value.next === 'object'
? frontmatter.value.next.rel ? frontmatter.value.next.rel
: undefined) ?? candidates[index + 1]?.rel : undefined) ?? nextCandidate?.rel
} }
} }
}) })

@ -12,6 +12,7 @@ import type { Theme } from 'vitepress'
import VPBadge from './components/VPBadge.vue' import VPBadge from './components/VPBadge.vue'
import Layout from './Layout.vue' import Layout from './Layout.vue'
import NotFound from './NotFound.vue'
export { default as VPBadge } from './components/VPBadge.vue' export { default as VPBadge } from './components/VPBadge.vue'
export { default as VPButton } from './components/VPButton.vue' export { default as VPButton } from './components/VPButton.vue'
@ -37,6 +38,7 @@ export { useLayout } from './composables/layout'
const theme: Theme = { const theme: Theme = {
Layout, Layout,
NotFound,
enhanceApp: ({ app }) => { enhanceApp: ({ app }) => {
app.component('Badge', VPBadge) app.component('Badge', VPBadge)
} }

@ -225,12 +225,12 @@ async function render(
const usedIcons = new Set<string>(Array.isArray(include) ? include : []) const usedIcons = new Set<string>(Array.isArray(include) ? include : [])
await pMap( await pMap(
['404.md', ...siteConfig.pages], outputPages(siteConfig),
async (page) => { async (page) => {
await renderPage( await renderPage(
render, render,
siteConfig, siteConfig,
siteConfig.rewrites.map[page] || page, page,
clientResult, clientResult,
appChunk, appChunk,
cssChunk, cssChunk,
@ -254,6 +254,17 @@ async function render(
) )
} }
/**
* Every page to emit, by output path: the not-found page of each locale
* plus the pages with their rewrites applied.
*/
function outputPages(config: SiteConfig): string[] {
return [
...config.notFoundPages.map((p) => p.path),
...config.pages.map((p) => config.rewrites.map[p] || p)
]
}
async function emitIconsCSS( async function emitIconsCSS(
config: SiteConfig, config: SiteConfig,
usedIcons: Set<string> usedIcons: Set<string>
@ -283,12 +294,9 @@ async function emitIconsCSS(
`[ \\t]*<link\\b[^>]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?` `[ \\t]*<link\\b[^>]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?`
) )
await pMap( await pMap(
['404.md', ...config.pages], outputPages(config),
async (page) => { async (page) => {
const file = path.join( const file = path.join(config.outDir, page.replace(/\.md$/, '.html'))
config.outDir,
(config.rewrites.map[page] || page).replace(/\.md$/, '.html')
)
const html = await readFile(file, 'utf-8').catch(() => null) const html = await readFile(file, 'utf-8').catch(() => null)
if (html === null || !html.includes(placeholder)) return if (html === null || !html.includes(placeholder)) return
// scoped to the tag so prose mentioning the placeholder stays intact // scoped to the tag so prose mentioning the placeholder stays intact

@ -69,6 +69,14 @@ export async function bundle(
const alias = config.rewrites.map[file] || file const alias = config.rewrites.map[file] || file
input[slash(alias).replace(/\//g, '_')] = path.resolve(config.srcDir, file) input[slash(alias).replace(/\//g, '_')] = path.resolve(config.srcDir, file)
}) })
// the not-found pages are entries too; a synthesized one resolves to its
// virtual module (see ../plugins/notFoundPlugin.ts)
config.notFoundPages.forEach(({ path: page, source }) => {
input[page.replace(/\//g, '_')] = path.resolve(
config.srcDir,
source ?? page
)
})
const themeEntryRE = new RegExp( const themeEntryRE = new RegExp(
`^${escapeRegExp(slash(path.resolve(config.themeDir, 'index.js'))).slice(0, -2)}m?(j|t)s` `^${escapeRegExp(slash(path.resolve(config.themeDir, 'index.js'))).slice(0, -2)}m?(j|t)s`

@ -14,7 +14,6 @@ import {
escapeHtml, escapeHtml,
isRelativeBase, isRelativeBase,
mergeHead, mergeHead,
notFoundPageData,
relativePathToRoot, relativePathToRoot,
resolveSiteDataByRoute, resolveSiteDataByRoute,
sanitizeFileName, sanitizeFileName,
@ -70,23 +69,10 @@ export async function renderPage(
// server build doesn't need hash // server build doesn't need hash
const pageServerJsFileName = pageName + '.js' const pageServerJsFileName = pageName + '.js'
let pageData: PageData
let hasCustom404 = true
try {
// resolve page data so we can render head tags // resolve page data so we can render head tags
const { __pageData } = await nativeImport( const { __pageData: pageData }: { __pageData: PageData } = await nativeImport(
path.join(config.tempDir, pageServerJsFileName) path.join(config.tempDir, pageServerJsFileName)
) )
pageData = __pageData
} catch (e) {
if (page === '404.md') {
hasCustom404 = false
pageData = notFoundPageData
} else {
throw e
}
}
const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath) const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath)
@ -100,15 +86,16 @@ export async function renderPage(
const title = createTitle(siteData, pageData) const title = createTitle(siteData, pageData)
const description = pageData.description || siteData.description const description = pageData.description || siteData.description
const dir = pageData.frontmatter.dir || siteData.dir || 'ltr' const dir = pageData.frontmatter.dir || siteData.dir || 'ltr'
const isDefault404 = page === '404.md' && !hasCustom404
// the initial load only needs the lean page js — the static content is // the initial load only needs the lean page js — the static content is
// already in the HTML // already in the HTML
const pageHash = pageToHashMap[pageName.toLowerCase()] const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js` // a not-found document is mounted afresh rather than hydrated, so it needs
// the full chunk
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}${pageData.isNotFound ? '' : '.lean'}.js`
let preloadLinks: string[] = [] let preloadLinks: string[] = []
if (result && appChunk && !config.mpa && !isDefault404) { if (result && appChunk && !config.mpa) {
preloadLinks = [ preloadLinks = [
...new Set([ ...new Set([
// the imports of index.js + page.md.js as well, so everything // the imports of index.js + page.md.js as well, so everything
@ -159,6 +146,12 @@ export async function renderPage(
) )
] ]
// hosts that answer a miss with 200 would otherwise get the not-found page
// indexed as a real page
if (pageData.isNotFound && !hasNamedMeta(headBeforeTransform, 'robots')) {
headBeforeTransform.push(['meta', { name: 'robots', content: 'noindex' }])
}
const transformContext = (head: HeadConfig[]) => ({ const transformContext = (head: HeadConfig[]) => ({
page, page,
siteConfig: config, siteConfig: config,
@ -185,7 +178,7 @@ export async function renderPage(
const matchingChunk = result.output.find( const matchingChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk => (chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.type === 'chunk' &&
chunk.facadeModuleId === slash(path.join(config.srcDir, page)) facadeFile(chunk) === slash(path.join(config.srcDir, page))
) )
if (matchingChunk) { if (matchingChunk) {
if (!matchingChunk.code.includes('import')) { if (!matchingChunk.code.includes('import')) {
@ -233,7 +226,7 @@ export async function renderPage(
${await renderHead(head)} ${await renderHead(head)}
</head> </head>
<body>${teleports?.body || ''} <body>${teleports?.body || ''}
<div id="app">${page === '404.md' ? '' : content}</div> <div id="app"${pageData.isNotFound ? ' data-vp-not-found' : ''}>${content}</div>
${metadataScript.inHead ? '' : metadataScript.html} ${metadataScript.inHead ? '' : metadataScript.html}
${inlinedScript} ${inlinedScript}
</body> </body>
@ -268,12 +261,18 @@ async function resolvePageImports(
srcPath = normalizePath(srcPath) srcPath = normalizePath(srcPath)
const pageChunk = result.output.find( const pageChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk => (chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.facadeModuleId === srcPath chunk.type === 'chunk' && facadeFile(chunk) === srcPath
) )
// dynamic imports are intentionally not preloaded // dynamic imports are intentionally not preloaded
return [...appChunk.imports, ...(pageChunk?.imports || [])] return [...appChunk.imports, ...(pageChunk?.imports || [])]
} }
// the file a chunk was built from; a synthesized not-found page carries the
// virtual-module marker in front of its would-be file
function facadeFile(chunk: Rolldown.OutputChunk): string | undefined {
return chunk.facadeModuleId?.replace(/^\0/, '')
}
async function renderHead(head: HeadConfig[]): Promise<string> { async function renderHead(head: HeadConfig[]): Promise<string> {
const tags = await Promise.all( const tags = await Promise.all(
head.map(async ([tag, attrs = {}, innerHTML = '']) => { head.map(async ([tag, attrs = {}, innerHTML = '']) => {

@ -187,7 +187,10 @@ export async function resolveConfig(
) )
} }
const config: Omit<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'> = { const config: Omit<
SiteConfig,
'pages' | 'dynamicRoutes' | 'rewrites' | 'notFoundPages'
> = {
root, root,
srcDir, srcDir,
publicDir, publicDir,

@ -44,6 +44,7 @@ const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\s*)default/
let __pages: string[] = [] let __pages: string[] = []
let __dynamicRoutes = new Map<string, [string, string]>() let __dynamicRoutes = new Map<string, [string, string]>()
let __rewrites = new Map<string, string>() let __rewrites = new Map<string, string>()
let __notFoundPages = new Map<string, string | null>()
let __ts: number let __ts: number
export interface MarkdownCompileResult { export interface MarkdownCompileResult {
@ -69,7 +70,12 @@ function normalizeDriveLetter(file: string) {
function getResolutionCache(siteConfig: SiteConfig) { function getResolutionCache(siteConfig: SiteConfig) {
// @ts-expect-error internal // @ts-expect-error internal
if (siteConfig.__dirty) { if (siteConfig.__dirty) {
__pages = siteConfig.pages.map((p) => slash(p.replace(/\.md$/, ''))) // link targets: every page plus the not-found pages (the authored source
// when there is one, the synthesized page otherwise)
__pages = [
...siteConfig.pages,
...siteConfig.notFoundPages.map((p) => p.source ?? p.path)
].map((p) => slash(p.replace(/\.md$/, '')))
__dynamicRoutes = new Map( __dynamicRoutes = new Map(
siteConfig.dynamicRoutes.map((r) => [ siteConfig.dynamicRoutes.map((r) => [
@ -85,6 +91,10 @@ function getResolutionCache(siteConfig: SiteConfig) {
]) ])
) )
__notFoundPages = new Map(
siteConfig.notFoundPages.map((p) => [p.path, p.source])
)
__ts = Date.now() __ts = Date.now()
// @ts-expect-error internal // @ts-expect-error internal
@ -95,6 +105,7 @@ function getResolutionCache(siteConfig: SiteConfig) {
pages: __pages, pages: __pages,
dynamicRoutes: __dynamicRoutes, dynamicRoutes: __dynamicRoutes,
rewrites: __rewrites, rewrites: __rewrites,
notFoundPages: __notFoundPages,
ts: __ts ts: __ts
} }
} }
@ -116,7 +127,7 @@ export async function createMarkdownToVueRenderFn(
) )
return async (src: string, file: string): Promise<MarkdownCompileResult> => { return async (src: string, file: string): Promise<MarkdownCompileResult> => {
const { pages, dynamicRoutes, rewrites, ts } = const { pages, dynamicRoutes, rewrites, notFoundPages, ts } =
getResolutionCache(siteConfig) getResolutionCache(siteConfig)
const dynamicRoute = dynamicRoutes.get(file) const dynamicRoute = dynamicRoutes.get(file)
@ -129,6 +140,11 @@ export async function createMarkdownToVueRenderFn(
file = rewrites.get(normalizeDriveLetter(file)) || file file = rewrites.get(normalizeDriveLetter(file)) || file
const relativePath = slash(path.relative(srcDir, file)) const relativePath = slash(path.relative(srcDir, file))
// the not-found page of a locale; synthesized when it has no source file
const notFoundSource = notFoundPages.get(relativePath)
const isNotFound = notFoundSource !== undefined
const isVirtual = notFoundSource === null
const srcHash = hash('sha256', src, 'base64url') const srcHash = hash('sha256', src, 'base64url')
const cacheKey = `${srcHash}:${ts}:${relativePath}` const cacheKey = `${srcHash}:${ts}:${relativePath}`
if (options.cache !== false) { if (options.cache !== false) {
@ -267,10 +283,15 @@ export async function createMarkdownToVueRenderFn(
headers, headers,
params, params,
relativePath, relativePath,
filePath: slash(path.relative(srcDir, fileOrig)) filePath: isVirtual ? '' : slash(path.relative(srcDir, fileOrig)),
...(isNotFound ? { isNotFound } : {})
} }
if (includeLastUpdatedData && frontmatter.lastUpdated !== false) { if (
includeLastUpdatedData &&
frontmatter.lastUpdated !== false &&
!isVirtual
) {
if (frontmatter.lastUpdated instanceof Date) { if (frontmatter.lastUpdated instanceof Date) {
pageData.lastUpdated = +frontmatter.lastUpdated pageData.lastUpdated = +frontmatter.lastUpdated
} else { } else {

@ -31,10 +31,11 @@ import { assetsBasePlugin } from './plugins/assetsBasePlugin'
import { iconsPlugin } from './plugins/iconsPlugin' import { iconsPlugin } from './plugins/iconsPlugin'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin'
import { notFoundPlugin } from './plugins/notFoundPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin'
import { staticDataPlugin } from './plugins/staticDataPlugin' import { staticDataPlugin } from './plugins/staticDataPlugin'
import { webFontsPlugin } from './plugins/webFontsPlugin' import { webFontsPlugin } from './plugins/webFontsPlugin'
import { slash, type PageDataPayload } from './shared' import { isRelativeBase, slash, type PageDataPayload } from './shared'
import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize' import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize'
import { cacheAllGitTimestamps } from './utils/getGitTimestamp' import { cacheAllGitTimestamps } from './utils/getGitTimestamp'
@ -118,6 +119,35 @@ export async function createVitePressPlugin(
let config: ResolvedConfig let config: ResolvedConfig
let importerMap: Record<string, Set<string> | undefined> = {} let importerMap: Record<string, Set<string> | undefined> = {}
// whether a request path has a page behind it, so the dev server can answer
// a miss with a real 404 status (the shell is served either way and the
// client renders the not-found page)
let knownPages: { pages: string[]; set: Set<string> } | undefined
const hasPage = (pathname: string): boolean => {
if (knownPages?.pages !== siteConfig.pages) {
knownPages = {
pages: siteConfig.pages,
set: new Set([
...siteConfig.pages.map((p) => siteConfig.rewrites.map[p] || p),
...siteConfig.notFoundPages.map((p) => p.path)
])
}
}
const base = isRelativeBase(site.base) ? '/' : site.base
if (!pathname.startsWith(base)) return false
let page: string
try {
page = decodeURIComponent(pathname.slice(base.length))
} catch {
return false
}
page = page.replace(/\.html$/, '')
if (page === '' || page.endsWith('/')) page += 'index'
return (
knownPages.set.has(`${page}.md`) || knownPages.set.has(`${page}/index.md`)
)
}
const vitePressPlugin: Plugin = { const vitePressPlugin: Plugin = {
name: 'vitepress', name: 'vitepress',
@ -224,6 +254,10 @@ export async function createVitePressPlugin(
return processClientJS(code, id) return processClientJS(code, id)
} }
if (id.endsWith('.md')) { if (id.endsWith('.md')) {
// a synthesized not-found page that re-exports another page is
// plain js (see ./plugins/notFoundPlugin.ts)
if (id.startsWith('\0')) return
const watchIncludes = (files: string[] = []) => { const watchIncludes = (files: string[] = []) => {
files.forEach((i) => { files.forEach((i) => {
;(importerMap[slash(i)] ??= new Set()).add(slash(id)) ;(importerMap[slash(i)] ??= new Set()).add(slash(id))
@ -305,7 +339,9 @@ export async function createVitePressPlugin(
server.middlewares.use(async (req, res, next) => { server.middlewares.use(async (req, res, next) => {
const url = req.url && cleanUrl(req.url) const url = req.url && cleanUrl(req.url)
if (url?.endsWith('.html')) { if (url?.endsWith('.html')) {
res.statusCode = 200 res.statusCode = hasPage(cleanUrl(req.originalUrl || url))
? 200
: 404
res.setHeader('Content-Type', 'text/html') res.setHeader('Content-Type', 'text/html')
let html = `\ let html = `\
<!DOCTYPE html> <!DOCTYPE html>
@ -391,6 +427,20 @@ export async function createVitePressPlugin(
// update pages, dynamicRoutes and rewrites on md file creation / deletion // update pages, dynamicRoutes and rewrites on md file creation / deletion
if (file.endsWith('.md') && type !== 'update') { if (file.endsWith('.md') && type !== 'update') {
await resolvePages(siteConfig) await resolvePages(siteConfig)
// a not-found page appearing or disappearing changes what the other
// locales' not-found modules re-export, so start over
const page = siteConfig.rewrites.map[relativePath] || relativePath
if (siteConfig.notFoundPages.some((p) => p.path === page)) {
for (const { path: notFoundPage } of siteConfig.notFoundPages) {
const mod = this.environment.moduleGraph.getModuleById(
normalizePath(path.join(srcDir, notFoundPage))
)
if (mod) this.environment.moduleGraph.invalidateModule(mod)
}
this.environment.hot.send({ type: 'full-reload' })
return []
}
} }
if ( if (
@ -467,7 +517,8 @@ export async function createVitePressPlugin(
iconsPlugin(siteConfig), iconsPlugin(siteConfig),
await localSearchPlugin(siteConfig), await localSearchPlugin(siteConfig),
staticDataPlugin, staticDataPlugin,
await dynamicRoutesPlugin(siteConfig) await dynamicRoutesPlugin(siteConfig),
notFoundPlugin(siteConfig)
] ]
} }

@ -17,6 +17,7 @@ import { type SiteConfig, type UserConfig } from '../siteConfig'
import { readTextFile } from '../utils/fs' import { readTextFile } from '../utils/fs'
import { glob, normalizeGlob, type GlobOptions } from '../utils/glob' import { glob, normalizeGlob, type GlobOptions } from '../utils/glob'
import { ModuleGraph } from '../utils/moduleGraph' import { ModuleGraph } from '../utils/moduleGraph'
import { resolveNotFoundPagePaths } from './notFoundPlugin'
import { resolveRewrites } from './rewritesPlugin' import { resolveRewrites } from './rewritesPlugin'
interface UserRouteConfig { interface UserRouteConfig {
@ -77,7 +78,10 @@ export function defineRoutes(loader: RouteModule): RouteModule {
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>> type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
export async function resolvePages( export async function resolvePages(
siteConfig: Optional<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'>, siteConfig: Optional<
SiteConfig,
'pages' | 'dynamicRoutes' | 'rewrites' | 'notFoundPages'
>,
rebuildCache = false rebuildCache = false
): Promise<void> { ): Promise<void> {
if (rebuildCache) { if (rebuildCache) {
@ -118,10 +122,21 @@ export async function resolvePages(
const rewrites = resolveRewrites(finalPages, siteConfig.userConfig.rewrites) const rewrites = resolveRewrites(finalPages, siteConfig.userConfig.rewrites)
// the not-found page of each locale, backed by a source page when one lands
// on that path (rewrites included) and synthesized otherwise; it is not a
// page in its own right, so sitemap, search and navigation never see it
const notFoundPages = resolveNotFoundPagePaths(siteConfig.site).map(
(page) => ({
path: page,
source: finalPages.find((p) => (rewrites.map[p] || p) === page) ?? null
})
)
Object.assign(siteConfig, { Object.assign(siteConfig, {
pages: finalPages, pages: finalPages.filter((p) => !notFoundPages.some((n) => n.source === p)),
dynamicRoutes: finalDynamicRoutes, dynamicRoutes: finalDynamicRoutes,
rewrites, rewrites,
notFoundPages,
// @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts // @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
__dirty: true __dirty: true
} satisfies Partial<SiteConfig>) } satisfies Partial<SiteConfig>)

@ -0,0 +1,129 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
import { APP_PATH } from '../alias'
import type { SiteConfig } from '../siteConfig'
import { isExternal, slash, type SiteData } from '../shared'
const notFoundRE = /(?:^|\/)404\.md(?:\?|$)/
// the re-export module is plain js under a `.md` id, which keeps it a page
// chunk; the virtual-module marker keeps the markdown and sfc transforms off
// it (both skip `\0` ids)
const VIRTUAL_PREFIX = '\0'
/**
* The not-found page of every locale, as output-relative paths: `404.md`
* for the root plus `<locale>/404.md` for each locale directory.
*/
export function resolveNotFoundPagePaths(site: SiteData): string[] {
const dirs = Object.keys(site.locales ?? {}).filter(
(key) => key !== 'root' && !isExternal(key)
)
return ['404.md', ...dirs.map((dir) => `${dir}/404.md`)]
}
/**
* Backs every not-found page with a module. A page the author wrote loads
* as-is; the others are synthesized here so the router, the build and the
* preview server can treat the not-found page like any page:
*
* - a locale without its own file re-exports the root `404.md`, keeping the
* locale in its page data
* - with no file at all, a markdown page renders the theme's `NotFound`
* component
*/
export const notFoundPlugin = (siteConfig: SiteConfig): Plugin => {
const { srcDir } = siteConfig
const splitQuery = (id: string): [file: string, query: string] => {
const index = id.indexOf('?')
return index === -1 ? [id, ''] : [id.slice(0, index), id.slice(index + 1)]
}
// ids arrive as urls, posix paths or native windows paths (the bundler
// entries), with the drive letter in either case
const isUnderSrcDir = (file: string) =>
file
.replace(/^[a-z]:/i, (d) => d.toLowerCase())
.startsWith(srcDir.replace(/^[a-z]:/i, (d) => d.toLowerCase()))
// the synthesized page a would-be file stands for, and the authored root
// page it inherits when there is one
const virtualPage = (file: string) => {
const relativePath = slash(
path.relative(srcDir, file.replace(VIRTUAL_PREFIX, ''))
)
// an authored page that a rewrite moves elsewhere still owns its file
if (siteConfig.pages.includes(relativePath)) return
const page = siteConfig.notFoundPages.find((p) => p.path === relativePath)
if (!page || page.source != null) return
const root = siteConfig.notFoundPages.find((p) => p.path === '404.md')
const inherits = page.path !== '404.md' ? (root?.source ?? null) : null
return { path: page.path, inherits }
}
return {
name: 'vitepress:not-found',
enforce: 'pre',
resolveId: {
filter: { id: notFoundRE },
handler(id, importer) {
const [rawFile, query] = splitQuery(id)
// sub-requests (`?vue&type=…`) belong to the module that owns them
if (query && !/^t=\d+$/.test(query)) return
// normalizing first would fold `./x` into `x`, so test the raw id
const file = normalizePath(rawFile)
const resolved = isUnderSrcDir(file)
? file
: rawFile.startsWith('/')
? normalizePath(path.join(srcDir, rawFile))
: importer && rawFile.startsWith('.')
? normalizePath(path.resolve(path.dirname(importer), rawFile))
: undefined
const page = resolved && virtualPage(resolved)
if (page) return page.inherits ? VIRTUAL_PREFIX + resolved : resolved
}
},
load: {
filter: { id: notFoundRE },
handler(id) {
const [file, query] = splitQuery(id)
if (query) return
const page = virtualPage(file)
if (!page) return
if (page.inherits) {
const source = normalizePath(path.resolve(srcDir, page.inherits))
return [
`import Root, { __pageData as base } from ${JSON.stringify(source)}`,
`export * from ${JSON.stringify(source)}`,
`export default Root`,
`export const __pageData = { ...base, relativePath: ${JSON.stringify(page.path)} }`
].join('\n')
}
const helper = normalizePath(path.join(APP_PATH, 'theme.js'))
return [
'---',
'title: "404"',
'description: Not Found',
'---',
'',
'<script setup>',
`import RawTheme from '@theme/index'`,
`import { resolveNotFound } from ${JSON.stringify(helper)}`,
'',
'const NotFound = resolveNotFound(RawTheme)',
'</script>',
'',
'<NotFound />',
''
].join('\n')
}
}
}
}

@ -6,7 +6,7 @@ import polka, { type IOptions } from 'polka'
import sirv from 'sirv' import sirv from 'sirv'
import { normalizeAssetsBase, resolveConfig } from '../config' import { normalizeAssetsBase, resolveConfig } from '../config'
import { EXTERNAL_URL_RE, isRelativeBase } from '../shared' import { EXTERNAL_URL_RE, isRelativeBase, resolveNotFoundPage } from '../shared'
import { readFile } from '../utils/fs' import { readFile } from '../utils/fs'
export interface ServeOptions { export interface ServeOptions {
@ -39,8 +39,27 @@ export async function serve(options: ServeOptions = {}) {
const notAnAsset = (pathname: string) => const notAnAsset = (pathname: string) =>
!pathname.includes(`/${config.assetsDir}/`) !pathname.includes(`/${config.assetsDir}/`)
const notFound = await readFile(path.resolve(config.outDir, './404.html'))
const onNoMatch: IOptions['onNoMatch'] = (req, res) => { // the not-found page of the locale the path belongs to, like hosts that
// look for the nearest 404.html do; the root one is the last resort
const prefix = base ? `/${base}/` : '/'
const notFoundPages = new Map<string, Promise<string | null>>()
const notFoundFor = (pathname: string): Promise<string | null> => {
const page = resolveNotFoundPage(
config.site,
pathname.startsWith(prefix) ? pathname.slice(prefix.length) : ''
)
let body = notFoundPages.get(page)
if (!body) {
body = readFile(path.join(config.outDir, page.replace(/\.md$/, '.html')))
.catch(() => readFile(path.join(config.outDir, '404.html')))
.catch(() => null)
notFoundPages.set(page, body)
}
return body
}
const onNoMatch: IOptions['onNoMatch'] = async (req, res) => {
if (base && req.path === '/') { if (base && req.path === '/') {
res.statusCode = 302 res.statusCode = 302
res.setHeader('location', `/${base}/`) res.setHeader('location', `/${base}/`)
@ -48,7 +67,15 @@ export async function serve(options: ServeOptions = {}) {
return return
} }
res.statusCode = 404 res.statusCode = 404
if (notAnAsset(req.path)) res.write(notFound) // req.path loses the base prefix under the mounted app; the original url
// still has it
const pathname = new URL(req.originalUrl || req.url || '', 'http://a.com')
.pathname
const body = notAnAsset(pathname) ? await notFoundFor(pathname) : null
if (body) {
res.setHeader('content-type', 'text/html; charset=utf-8')
res.write(body)
}
res.end() res.end()
} }

@ -205,7 +205,8 @@ export interface UserConfig<
*/ */
lastUpdated?: boolean lastUpdated?: boolean
/** /**
* Custom props passed to the `<Content />` component. * Custom props passed to the `<Content />` component. Replaces the
* default `{ class: 'vp-content', style: { position: 'relative' } }`.
*/ */
contentProps?: Record<string, any> contentProps?: Record<string, any>
/** /**
@ -417,6 +418,14 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
map: Record<string, string | undefined> map: Record<string, string | undefined>
inv: Record<string, string | undefined> inv: Record<string, string | undefined>
} }
/**
* The not-found page of each locale. `path` is where it is emitted
* (`404.md`, `zh/404.md`), relative to `srcDir` and with rewrites
* applied; `source` is the markdown file behind it, or `null` when the
* page is synthesized from the theme's `NotFound` component. These pages
* are not part of `pages`.
*/
notFoundPages: { path: string; source: string | null }[]
/** /**
* The logger used by vite. * The logger used by vite.
*/ */

@ -94,15 +94,37 @@ const shellLangs = ['shellscript', 'shell', 'bash', 'sh', 'zsh']
export const inBrowser = typeof document !== 'undefined' export const inBrowser = typeof document !== 'undefined'
export const notFoundPageData: PageData = { /**
relativePath: '404.md', * The not-found page that answers a site-relative path: `<locale>/404.md`
* when the path is under a locale directory, `404.md` otherwise.
*/
export function resolveNotFoundPage(
siteData: SiteData | undefined,
relativePath: string
): string {
let locale = 'root'
try {
locale = getLocaleForPath(siteData, relativePath)
} catch {
// a path that is not valid percent-encoding belongs to no locale
}
return (locale === 'root' ? '' : `${locale}/`) + '404.md'
}
/**
* Page data for a not-found page whose module could not be loaded: the last
* resort behind the theme's `NotFound` component.
*/
export function createNotFoundPageData(relativePath: string): PageData {
return {
relativePath,
filePath: '', filePath: '',
title: '404', title: '404',
description: 'Not Found', description: 'Not Found',
headers: [], headers: [],
frontmatter: { sidebar: false, layout: 'page' }, frontmatter: {},
lastUpdated: 0,
isNotFound: true isNotFound: true
}
} }
export function isActive( export function isActive(

@ -10,6 +10,12 @@ export namespace DefaultTheme {
* The layout state returned by `useLayout` from `vitepress/theme`. * The layout state returned by `useLayout` from `vitepress/theme`.
*/ */
export interface Layout { export interface Layout {
/**
* The layout the current page renders with: its `layout` frontmatter,
* or the default (`doc`, and `page` for the not-found page synthesized
* from the theme).
*/
layout: ComputedRef<string>
isHome: ComputedRef<boolean> isHome: ComputedRef<boolean>
sidebar: Readonly<ShallowRef<SidebarItem[]>> sidebar: Readonly<ShallowRef<SidebarItem[]>>
@ -505,13 +511,6 @@ export namespace DefaultTheme {
*/ */
link?: string link?: string
/**
* Set aria label for home link.
*
* @default 'go to home'
*/
linkLabel?: string
/** /**
* Set custom home link text. * Set custom home link text.
* *

5
types/shared.d.ts vendored

@ -69,7 +69,9 @@ export interface PageData {
*/ */
params?: Record<string, any> params?: Record<string, any>
/** /**
* Whether the page is the not-found (404) page. * Whether this is the not-found page: the `404.md` of the site or of a
* locale (or the page synthesized in its place), which also answers every
* URL that has no page.
*/ */
isNotFound?: boolean isNotFound?: boolean
/** /**
@ -234,6 +236,7 @@ export interface SiteData<ThemeConfig = any> {
localeIndex?: string localeIndex?: string
/** /**
* Props passed to the wrapper element rendered by the `Content` component. * Props passed to the wrapper element rendered by the `Content` component.
* Defaults to `{ class: 'vp-content', style: { position: 'relative' } }`.
*/ */
contentProps?: Record<string, any> contentProps?: Record<string, any>
/** /**

Loading…
Cancel
Save