From cb4ae79c406b2bae338eab51b07f1e191616de19 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:30:06 +0530 Subject: [PATCH] feat: make the not-found page a real page per locale Every locale gets a not-found page at `404.md` / `/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 `/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; `` 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 --- src/client/app/components/Content.ts | 19 ++-- src/client/app/components/NotFound.ts | 19 ++++ src/client/app/index.ts | 45 +++++---- src/client/app/router.ts | 98 +++++++++++++------ src/client/app/theme.ts | 36 ++++++- src/node/build/build.ts | 22 +++-- src/node/build/bundle.ts | 8 ++ src/node/build/render.ts | 47 +++++----- src/node/config.ts | 5 +- src/node/markdownToVue.ts | 29 +++++- src/node/plugin.ts | 57 ++++++++++- src/node/plugins/dynamicRoutesPlugin.ts | 19 +++- src/node/plugins/notFoundPlugin.ts | 120 ++++++++++++++++++++++++ src/node/serve/serve.ts | 35 ++++++- src/node/siteConfig.ts | 11 ++- src/shared/shared.ts | 40 ++++++-- types/shared.d.ts | 5 +- 17 files changed, 496 insertions(+), 119 deletions(-) create mode 100644 src/client/app/components/NotFound.ts create mode 100644 src/node/plugins/notFoundPlugin.ts diff --git a/src/client/app/components/Content.ts b/src/client/app/components/Content.ts index ec950d57..c300fc7f 100644 --- a/src/client/app/components/Content.ts +++ b/src/client/app/components/Content.ts @@ -2,6 +2,7 @@ import { useData, useRoute } from 'vitepress' import { defineComponent, h, watch } from 'vue' import { contentUpdatedCallbacks } from '../utils' +import { NotFound } from './NotFound' const runCbs = () => contentUpdatedCallbacks.forEach((fn) => fn()) @@ -17,15 +18,17 @@ export const Content = defineComponent({ return () => h( props.as, - site.value.contentProps ?? { style: { position: 'relative' } }, + site.value.contentProps ?? { + class: 'vp-content', + style: { position: 'relative' } + }, [ - route.component - ? h(route.component, { - onVnodeMounted: runCbs, - onVnodeUpdated: runCbs, - onVnodeUnmounted: runCbs - }) - : '404 Page Not Found' + // a route without a component has nothing to show but a miss + h(route.component ?? NotFound, { + onVnodeMounted: runCbs, + onVnodeUpdated: runCbs, + onVnodeUnmounted: runCbs + }) ] ) } diff --git a/src/client/app/components/NotFound.ts b/src/client/app/components/NotFound.ts new file mode 100644 index 00000000..a550a956 --- /dev/null +++ b/src/client/app/components/NotFound.ts @@ -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') + ]) + } +}) diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 952e52f5..b6f4cacc 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -16,30 +16,24 @@ import { useCopyCode } from './composables/copyCode' import { useUpdateHead } from './composables/head' import { usePrefetch } from './composables/preFetch' 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' -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) +// 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({ name: 'VitePressApp', setup() { @@ -123,7 +117,9 @@ function newApp(): App { } 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) => { let pageFilePath = pathToFile(path) @@ -138,7 +134,7 @@ function newRouter(): Router { if (import.meta.env.DEV) { pageModule = import(/*@vite-ignore*/ pageFilePath).catch((e) => { // page load could fail for other reasons, don't swallow - console.error(e) + if (!isLoadFailure(e)) console.error(e) // try with/without trailing slash // in prod this is handled in src/client/app/utils.ts#pathToFile const url = new URL(pageFilePath!, 'http://a.com') @@ -160,7 +156,7 @@ function newRouter(): Router { } return pageModule - }, Theme.NotFound) + }, resolveNotFound(RawTheme)) } if (inBrowser) { @@ -169,6 +165,9 @@ if (inBrowser) { router.go(location.href, { initialLoad: true }).then(() => { // dynamically update head tags useUpdateHead(router.route, data.site) + if (import.meta.env.PROD && isNotFoundDocument()) { + document.getElementById('app')!.replaceChildren() + } app.mount('#app') // scroll to hash on new tab during dev diff --git a/src/client/app/router.ts b/src/client/app/router.ts index bb03338c..8283c97d 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -2,7 +2,11 @@ import type { Component, InjectionKey } from 'vue' import { inject, markRaw, nextTick, reactive, readonly } from 'vue' import type { Awaitable, PageData, PageDataPayload, Route } from '../shared' -import { notFoundPageData, treatAsHtml } from '../shared' +import { + createNotFoundPageData, + resolveNotFoundPage, + treatAsHtml +} from '../shared' import { siteDataRef } from './data' import { inBrowser, runtimeBase, withBase } from './utils' @@ -48,12 +52,20 @@ export const RouterSymbol: InjectionKey = Symbol() // matter and is only passed to support same-host hrefs const fakeHost = 'http://a.com' +// nothing is rendered before the first page resolves const getDefaultRoute = (): Route => ({ path: '/', hash: '', query: '', component: null, - data: notFoundPageData + data: { + relativePath: '', + filePath: '', + title: '', + description: '', + headers: [], + frontmatter: {} + } }) interface PageModule { @@ -61,9 +73,20 @@ interface PageModule { 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( loadPageModule: (path: string) => Awaitable, - fallbackComponent?: Component + fallbackComponent: Component ): Router { const route = reactive(getDefaultRoute()) @@ -141,17 +164,12 @@ export function createRouter( } } } catch (err: any) { - if ( - !/fetch|Page not found/.test(err.message) && - !/^\/404(\.html|\/)?$/.test(href) - ) { - console.error(err) - } + if (!isLoadFailure(err)) console.error(err) // 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 // the updated pageToHash map and fetch again. - if (!isRetry) { + if (!isRetry && import.meta.env.PROD) { try { const res = await fetch(runtimeBase() + 'hashmap.json') ;(window as any).__VP_HASH_MAP__ = await res.json() @@ -161,21 +179,44 @@ export function createRouter( } if (latestPendingPath === pendingPath) { - latestPendingPath = null - route.path = inBrowser ? pendingPath : withBase(pendingPath) - route.component = fallbackComponent ? markRaw(fallbackComponent) : null - const relativePath = inBrowser - ? route.path - .replace(/(^|\/)$/, '$1index') - .replace(/(\.html)?$/, '.md') - .slice(runtimeBase().length) - : '404.md' - route.data = { ...notFoundPageData, relativePath } - syncRouteQueryAndHash(targetLoc) + const { default: comp, __pageData } = + await loadNotFoundPage(pendingPath) + if (latestPendingPath === pendingPath) { + latestPendingPath = null + route.path = inBrowser ? pendingPath : withBase(pendingPath) + route.component = markRaw(comp) + route.data = import.meta.env.PROD + ? markRaw(__pageData) + : (readonly(__pageData) as PageData) + 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 { + 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( loc: { search: string; hash: string } = inBrowser ? location @@ -305,23 +346,18 @@ export function scrollTo(hash: string, scrollPosition = 0) { } 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) { // hot reload pageData 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 { const url = new URL(href, fakeHost) url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts index 4ebb222a..09d0b71f 100644 --- a/src/client/app/theme.ts +++ b/src/client/app/theme.ts @@ -1,6 +1,7 @@ import type { App, Component, Ref } from 'vue' import type { Awaitable, SiteData } from '../shared' +import { NotFound } from './components/NotFound' import type { Router } from './router' export interface EnhanceAppContext { @@ -21,7 +22,40 @@ export interface Theme { 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 `` like any page, with `page.isNotFound` + * set, so the layout can still decide what goes around it. */ 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(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 +} diff --git a/src/node/build/build.ts b/src/node/build/build.ts index e81df4a3..962812eb 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -225,12 +225,12 @@ async function render( const usedIcons = new Set(Array.isArray(include) ? include : []) await pMap( - ['404.md', ...siteConfig.pages], + outputPages(siteConfig), async (page) => { await renderPage( render, siteConfig, - siteConfig.rewrites.map[page] || page, + page, clientResult, appChunk, 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( config: SiteConfig, usedIcons: Set @@ -283,12 +294,9 @@ async function emitIconsCSS( `[ \\t]*]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?` ) await pMap( - ['404.md', ...config.pages], + outputPages(config), async (page) => { - const file = path.join( - config.outDir, - (config.rewrites.map[page] || page).replace(/\.md$/, '.html') - ) + const file = path.join(config.outDir, page.replace(/\.md$/, '.html')) const html = await readFile(file, 'utf-8').catch(() => null) if (html === null || !html.includes(placeholder)) return // scoped to the tag so prose mentioning the placeholder stays intact diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index f2d0ad08..5f278db8 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -69,6 +69,14 @@ export async function bundle( const alias = config.rewrites.map[file] || 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( `^${escapeRegExp(slash(path.resolve(config.themeDir, 'index.js'))).slice(0, -2)}m?(j|t)s` diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 0e8864d8..35ac5396 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -14,7 +14,6 @@ import { escapeHtml, isRelativeBase, mergeHead, - notFoundPageData, relativePathToRoot, resolveSiteDataByRoute, sanitizeFileName, @@ -70,23 +69,10 @@ export async function renderPage( // server build doesn't need hash const pageServerJsFileName = pageName + '.js' - let pageData: PageData - let hasCustom404 = true - - 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 - } - } + // resolve page data so we can render head tags + const { __pageData: pageData }: { __pageData: PageData } = await nativeImport( + path.join(config.tempDir, pageServerJsFileName) + ) const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath) @@ -100,15 +86,16 @@ export async function renderPage( const title = createTitle(siteData, pageData) const description = pageData.description || siteData.description 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 // already in the HTML 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[] = [] - if (result && appChunk && !config.mpa && !isDefault404) { + if (result && appChunk && !config.mpa) { preloadLinks = [ ...new Set([ // 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[]) => ({ page, siteConfig: config, @@ -185,7 +178,7 @@ export async function renderPage( const matchingChunk = result.output.find( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && - chunk.facadeModuleId === slash(path.join(config.srcDir, page)) + facadeFile(chunk) === slash(path.join(config.srcDir, page)) ) if (matchingChunk) { if (!matchingChunk.code.includes('import')) { @@ -233,7 +226,7 @@ export async function renderPage( ${await renderHead(head)} ${teleports?.body || ''} -
${page === '404.md' ? '' : content}
+
${content}
${metadataScript.inHead ? '' : metadataScript.html} ${inlinedScript} @@ -268,12 +261,18 @@ async function resolvePageImports( srcPath = normalizePath(srcPath) const pageChunk = result.output.find( (chunk): chunk is Rolldown.OutputChunk => - chunk.type === 'chunk' && chunk.facadeModuleId === srcPath + chunk.type === 'chunk' && facadeFile(chunk) === srcPath ) // dynamic imports are intentionally not preloaded 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 { const tags = await Promise.all( head.map(async ([tag, attrs = {}, innerHTML = '']) => { diff --git a/src/node/config.ts b/src/node/config.ts index baea4a86..8dea011b 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -187,7 +187,10 @@ export async function resolveConfig( ) } - const config: Omit = { + const config: Omit< + SiteConfig, + 'pages' | 'dynamicRoutes' | 'rewrites' | 'notFoundPages' + > = { root, srcDir, publicDir, diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 661af795..a8b74677 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -44,6 +44,7 @@ const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\s*)default/ let __pages: string[] = [] let __dynamicRoutes = new Map() let __rewrites = new Map() +let __notFoundPages = new Map() let __ts: number export interface MarkdownCompileResult { @@ -69,7 +70,12 @@ function normalizeDriveLetter(file: string) { function getResolutionCache(siteConfig: SiteConfig) { // @ts-expect-error internal 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( 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-expect-error internal @@ -95,6 +105,7 @@ function getResolutionCache(siteConfig: SiteConfig) { pages: __pages, dynamicRoutes: __dynamicRoutes, rewrites: __rewrites, + notFoundPages: __notFoundPages, ts: __ts } } @@ -116,7 +127,7 @@ export async function createMarkdownToVueRenderFn( ) return async (src: string, file: string): Promise => { - const { pages, dynamicRoutes, rewrites, ts } = + const { pages, dynamicRoutes, rewrites, notFoundPages, ts } = getResolutionCache(siteConfig) const dynamicRoute = dynamicRoutes.get(file) @@ -129,6 +140,11 @@ export async function createMarkdownToVueRenderFn( file = rewrites.get(normalizeDriveLetter(file)) || 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 cacheKey = `${srcHash}:${ts}:${relativePath}` if (options.cache !== false) { @@ -267,10 +283,15 @@ export async function createMarkdownToVueRenderFn( headers, params, 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) { pageData.lastUpdated = +frontmatter.lastUpdated } else { diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 886bae0d..6f792c14 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -31,10 +31,11 @@ import { assetsBasePlugin } from './plugins/assetsBasePlugin' import { iconsPlugin } from './plugins/iconsPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin' +import { notFoundPlugin } from './plugins/notFoundPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin' import { staticDataPlugin } from './plugins/staticDataPlugin' 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 { cacheAllGitTimestamps } from './utils/getGitTimestamp' @@ -118,6 +119,35 @@ export async function createVitePressPlugin( let config: ResolvedConfig let importerMap: Record | 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 } | 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 = { name: 'vitepress', @@ -224,6 +254,10 @@ export async function createVitePressPlugin( return processClientJS(code, id) } 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[] = []) => { files.forEach((i) => { ;(importerMap[slash(i)] ??= new Set()).add(slash(id)) @@ -305,7 +339,9 @@ export async function createVitePressPlugin( server.middlewares.use(async (req, res, next) => { const url = req.url && cleanUrl(req.url) if (url?.endsWith('.html')) { - res.statusCode = 200 + res.statusCode = hasPage(cleanUrl(req.originalUrl || url)) + ? 200 + : 404 res.setHeader('Content-Type', 'text/html') let html = `\ @@ -391,6 +427,20 @@ export async function createVitePressPlugin( // update pages, dynamicRoutes and rewrites on md file creation / deletion if (file.endsWith('.md') && type !== 'update') { 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 ( @@ -467,7 +517,8 @@ export async function createVitePressPlugin( iconsPlugin(siteConfig), await localSearchPlugin(siteConfig), staticDataPlugin, - await dynamicRoutesPlugin(siteConfig) + await dynamicRoutesPlugin(siteConfig), + notFoundPlugin(siteConfig) ] } diff --git a/src/node/plugins/dynamicRoutesPlugin.ts b/src/node/plugins/dynamicRoutesPlugin.ts index def6cc61..2011ce75 100644 --- a/src/node/plugins/dynamicRoutesPlugin.ts +++ b/src/node/plugins/dynamicRoutesPlugin.ts @@ -17,6 +17,7 @@ import { type SiteConfig, type UserConfig } from '../siteConfig' import { readTextFile } from '../utils/fs' import { glob, normalizeGlob, type GlobOptions } from '../utils/glob' import { ModuleGraph } from '../utils/moduleGraph' +import { resolveNotFoundPagePaths } from './notFoundPlugin' import { resolveRewrites } from './rewritesPlugin' interface UserRouteConfig { @@ -77,7 +78,10 @@ export function defineRoutes(loader: RouteModule): RouteModule { type Optional = Omit & Partial> export async function resolvePages( - siteConfig: Optional, + siteConfig: Optional< + SiteConfig, + 'pages' | 'dynamicRoutes' | 'rewrites' | 'notFoundPages' + >, rebuildCache = false ): Promise { if (rebuildCache) { @@ -118,10 +122,21 @@ export async function resolvePages( 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, { - pages: finalPages, + pages: finalPages.filter((p) => !notFoundPages.some((n) => n.source === p)), dynamicRoutes: finalDynamicRoutes, rewrites, + notFoundPages, // @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts __dirty: true } satisfies Partial) diff --git a/src/node/plugins/notFoundPlugin.ts b/src/node/plugins/notFoundPlugin.ts new file mode 100644 index 00000000..47d8c0fa --- /dev/null +++ b/src/node/plugins/notFoundPlugin.ts @@ -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 `/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', + '---', + '', + '', + '', + '', + '' + ].join('\n') + } + } + } +} diff --git a/src/node/serve/serve.ts b/src/node/serve/serve.ts index d41b9a4c..540874e8 100644 --- a/src/node/serve/serve.ts +++ b/src/node/serve/serve.ts @@ -6,7 +6,7 @@ import polka, { type IOptions } from 'polka' import sirv from 'sirv' 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' export interface ServeOptions { @@ -39,8 +39,27 @@ export async function serve(options: ServeOptions = {}) { const notAnAsset = (pathname: string) => !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>() + const notFoundFor = (pathname: string): Promise => { + 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 === '/') { res.statusCode = 302 res.setHeader('location', `/${base}/`) @@ -48,7 +67,15 @@ export async function serve(options: ServeOptions = {}) { return } 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() } diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 2b4d1eb4..01dd16b9 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -205,7 +205,8 @@ export interface UserConfig< */ lastUpdated?: boolean /** - * Custom props passed to the `` component. + * Custom props passed to the `` component. Replaces the + * default `{ class: 'vp-content', style: { position: 'relative' } }`. */ contentProps?: Record /** @@ -417,6 +418,14 @@ export interface SiteConfig extends Pick< map: Record inv: Record } + /** + * 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. */ diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 1dbb3a59..17e18734 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -94,15 +94,37 @@ const shellLangs = ['shellscript', 'shell', 'bash', 'sh', 'zsh'] export const inBrowser = typeof document !== 'undefined' -export const notFoundPageData: PageData = { - relativePath: '404.md', - filePath: '', - title: '404', - description: 'Not Found', - headers: [], - frontmatter: { sidebar: false, layout: 'page' }, - lastUpdated: 0, - isNotFound: true +/** + * The not-found page that answers a site-relative path: `/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: '', + title: '404', + description: 'Not Found', + headers: [], + frontmatter: {}, + isNotFound: true + } } export function isActive( diff --git a/types/shared.d.ts b/types/shared.d.ts index a35663f9..44df304f 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -69,7 +69,9 @@ export interface PageData { */ params?: Record /** - * 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 /** @@ -234,6 +236,7 @@ export interface SiteData { localeIndex?: string /** * Props passed to the wrapper element rendered by the `Content` component. + * Defaults to `{ class: 'vp-content', style: { position: 'relative' } }`. */ contentProps?: Record /**