mirror of https://github.com/vuejs/vitepress
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
parent
09f9672ee1
commit
cb4ae79c40
@ -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')
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue