feat: make the not-found page a real page per locale

Every locale gets a not-found page at `404.md` / `<locale>/404.md`. When
the author has one it loads as a page; otherwise a Vite plugin synthesizes
it: a locale without its own file re-exports the root `404.md` with its own
locale, and with no file at all a markdown page renders the theme's
`NotFound` component (or a small built-in one). The router, the SSR build,
the dev server and the preview server all consume that page through the
normal page pipeline, so the `'404.md'` special cases, the body stripping
and the `null` route component go away.

- `siteConfig.notFoundPages` lists the page of every locale with its source;
  those pages are left out of `pages`, so out of the sitemap, the search
  index and prev/next links, and links to them are not dead links
- the build emits `404.html` and `<locale>/404.html` with pre-rendered
  bodies, a `data-vp-not-found` marker and a `noindex` meta; the client
  mounts such a document afresh instead of hydrating it, since a host may
  serve it for any path
- on a miss the router loads the page of the path's locale and keeps
  `route.path` as the requested URL
- the dev server answers a miss with a 404 status; the preview server
  serves the nearest locale's `404.html` with a content type
- `Theme.NotFound` is the theme's default not-found content and is no
  longer deprecated; `<Content />` renders the built-in page when a route
  has no component instead of a bare string, and its wrapper carries a
  `vp-content` class by default

BREAKING CHANGE: on a miss `page.relativePath` is the not-found page's own
path (`zh/404.md`) instead of a path made up from the URL, and its
frontmatter is empty instead of `{ sidebar: false, layout: 'page' }`; a
site `404.md` is no longer part of `siteConfig.pages`; `404.html` has a
pre-rendered body and a `noindex` meta, so use `pageData.isNotFound` in
`transformHead`/`transformHtml` instead of `page === '404.md'`; visiting
`/404` renders the page with `isNotFound` set; the dev server returns 404
for unknown pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat/not-found
Divyansh Singh 1 week ago
parent 09f9672ee1
commit cb4ae79c40

@ -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() {
@ -123,7 +117,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)
@ -138,7 +134,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')
@ -160,7 +156,7 @@ function newRouter(): Router {
} }
return pageModule return pageModule
}, Theme.NotFound) }, resolveNotFound(RawTheme))
} }
if (inBrowser) { if (inBrowser) {
@ -169,6 +165,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()
@ -161,21 +179,44 @@ export function createRouter(
} }
if (latestPendingPath === pendingPath) { if (latestPendingPath === pendingPath) {
latestPendingPath = null const { default: comp, __pageData } =
route.path = inBrowser ? pendingPath : withBase(pendingPath) await loadNotFoundPage(pendingPath)
route.component = fallbackComponent ? markRaw(fallbackComponent) : null if (latestPendingPath === pendingPath) {
const relativePath = inBrowser latestPendingPath = null
? route.path route.path = inBrowser ? pendingPath : withBase(pendingPath)
.replace(/(^|\/)$/, '$1index') route.component = markRaw(comp)
.replace(/(\.html)?$/, '.md') route.data = import.meta.env.PROD
.slice(runtimeBase().length) ? markRaw(__pageData)
: '404.md' : (readonly(__pageData) as PageData)
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
? location ? location
@ -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
}

@ -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 // resolve page data so we can render head tags
let hasCustom404 = true const { __pageData: pageData }: { __pageData: PageData } = await nativeImport(
path.join(config.tempDir, pageServerJsFileName)
try { )
// resolve page data so we can render head tags
const { __pageData } = await nativeImport(
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,120 @@
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)]
}
// 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 [file, query] = splitQuery(id)
// sub-requests (`?vue&type=…`) belong to the module that owns them
if (query && !/^t=\d+$/.test(query)) return
const resolved = file.startsWith(srcDir)
? file
: file.startsWith('/')
? normalizePath(path.join(srcDir, file))
: importer && file.startsWith('.')
? normalizePath(path.resolve(path.dirname(importer), file))
: 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`
filePath: '', * when the path is under a locale directory, `404.md` otherwise.
title: '404', */
description: 'Not Found', export function resolveNotFoundPage(
headers: [], siteData: SiteData | undefined,
frontmatter: { sidebar: false, layout: 'page' }, relativePath: string
lastUpdated: 0, ): string {
isNotFound: true 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: '',
title: '404',
description: 'Not Found',
headers: [],
frontmatter: {},
isNotFound: true
}
} }
export function isActive( export function isActive(

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