diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts
index aa805e6a..34bc8019 100644
--- a/__tests__/base/emit.test.ts
+++ b/__tests__/base/emit.test.ts
@@ -91,6 +91,14 @@ describe('relative base emit', () => {
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', () => {
for (const file of walk(dist('relative'))) {
if (!/\.(html|css)$/.test(file)) continue
@@ -208,3 +216,44 @@ describe('plain base emit is unchanged', () => {
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('
')
+ expect(html).toContain('class="NotFound"')
+ expect(html).toContain('
404 | Base Fixture')
+ expect(html).toContain('
')
+ expect(html).toContain(' {
+ const html = read(mode, 'zh/404.html')
+ expect(html).toContain('
')
+ expect(html).toContain('页面未找到')
+ expect(html).toContain('
页面未找到 | Base Fixture')
+ expect(html).toContain('
')
+ expect(html).toContain(' {
+ const sitemap = read(mode, 'sitemap.xml')
+ expect(sitemap).toContain('
https://example.com/zh/')
+ expect(sitemap).toContain('
https://example.com/sub/page.html')
+ 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('
Custom Layout!
-
- Custom 404 page!
-
Custom home page!
@@ -179,20 +191,18 @@ const { page, frontmatter } = useData()
You can, of course, split the layout into more components:
-```vue{3-5,12-15}
+```vue{3-4,12-13}
Custom Layout!
-
diff --git a/docs/en/guide/extending-default-theme.md b/docs/en/guide/extending-default-theme.md
index 1758d19b..733a872d 100644
--- a/docs/en/guide/extending-default-theme.md
+++ b/docs/en/guide/extending-default-theme.md
@@ -240,8 +240,6 @@ Full list of slots available in the default theme layout:
- When `layout: 'page'` is enabled via frontmatter:
- `page-top`
- `page-bottom`
-- On not found (404) page:
- - `not-found`
- Always:
- `layout-top`
- `layout-bottom`
diff --git a/docs/en/guide/i18n.md b/docs/en/guide/i18n.md
index f7f285e6..f18e3915 100644
--- a/docs/en/guide/i18n.md
+++ b/docs/en/guide/i18n.md
@@ -102,6 +102,8 @@ docs/
├─ 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:
```
diff --git a/docs/en/guide/routing.md b/docs/en/guide/routing.md
index c858ec13..ae87cfcf 100644
--- a/docs/en/guide/routing.md
+++ b/docs/en/guide/routing.md
@@ -151,6 +151,28 @@ If, however, you cannot configure your server with such support, you will have t
└─ 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
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:
diff --git a/docs/en/reference/default-theme-config.md b/docs/en/reference/default-theme-config.md
index 33760375..d32f4291 100644
--- a/docs/en/reference/default-theme-config.md
+++ b/docs/en/reference/default-theme-config.md
@@ -431,6 +431,61 @@ export interface DocFooter {
}
```
+## notFound
+
+- Type: `NotFoundOptions`
+
+Customizes the text of the not-found page. Set it under `locales..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
- Type: `string`
@@ -521,6 +576,7 @@ Returns layout-related data. The returned object has the following type:
```ts
interface {
+ layout: ComputedRef
isHome: ComputedRef
sidebar: Readonly>
diff --git a/docs/en/reference/runtime-api.md b/docs/en/reference/runtime-api.md
index 86d9a20b..a45f0e69 100644
--- a/docs/en/reference/runtime-api.md
+++ b/docs/en/reference/runtime-api.md
@@ -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.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:**
```vue
diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md
index 7f214127..01637d3a 100644
--- a/docs/en/reference/site-config.md
+++ b/docs/en/reference/site-config.md
@@ -722,7 +722,7 @@ In many cases, using the [`transformPageData`](#transformpagedata) hook is a cle
```ts
export default {
async transformHead(context) {
- if (context.page === '404.md') {
+ if (context.pageData.isNotFound) {
return
}
diff --git a/docs/es/config.ts b/docs/es/config.ts
index 547182e0..b194dba5 100644
--- a/docs/es/config.ts
+++ b/docs/es/config.ts
@@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: 'PÁGINA NO ENCONTRADA',
quote:
'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'
},
diff --git a/docs/fa/config.ts b/docs/fa/config.ts
index 773571a9..bb5d78cd 100644
--- a/docs/fa/config.ts
+++ b/docs/fa/config.ts
@@ -70,7 +70,6 @@ export default defineAdditionalConfig({
title: 'صفحه پیدا نشد',
quote:
'اما اگر جهت خود را تغییر ندهید و همچنان به جستجو ادامه دهید، ممکن است در نهایت به جایی برسید که در حال رفتن به آن هستید.',
- linkLabel: 'برو به خانه',
linkText: 'من را به خانه ببر'
},
diff --git a/docs/ko/config.ts b/docs/ko/config.ts
index ef8f941b..0f0859e5 100644
--- a/docs/ko/config.ts
+++ b/docs/ko/config.ts
@@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: '페이지를 찾을 수 없습니다',
quote:
'방향을 바꾸지 않고 계속 찾다 보면 결국 당신이 가고 있는 곳에 도달할 수도 있습니다.',
- linkLabel: '홈으로 가기',
linkText: '집으로 데려가줘'
},
diff --git a/docs/pt/config.ts b/docs/pt/config.ts
index f52eb484..c19c6d3a 100644
--- a/docs/pt/config.ts
+++ b/docs/pt/config.ts
@@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: 'PÁGINA NÃO ENCONTRADA',
quote:
'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'
},
diff --git a/docs/ru/config.ts b/docs/ru/config.ts
index ef695544..e3e213d6 100644
--- a/docs/ru/config.ts
+++ b/docs/ru/config.ts
@@ -60,7 +60,6 @@ export default defineAdditionalConfig({
title: 'СТРАНИЦА НЕ НАЙДЕНА',
quote:
'Но если ты не изменишь направление и продолжишь искать, ты можешь оказаться там, куда направляешься.',
- linkLabel: 'перейти на главную',
linkText: 'Отведи меня домой'
},
diff --git a/docs/zh/config.ts b/docs/zh/config.ts
index 376736c4..1ac39f59 100644
--- a/docs/zh/config.ts
+++ b/docs/zh/config.ts
@@ -62,7 +62,6 @@ export default defineAdditionalConfig({
title: '页面未找到',
quote:
'但如果你不改变方向,并且继续寻找,你可能最终会到达你所前往的地方。',
- linkLabel: '前往首页',
linkText: '带我回首页'
},
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 a15e0684..4d3dd4e8 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() {
@@ -129,7 +123,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)
@@ -144,7 +140,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')
@@ -166,7 +162,7 @@ function newRouter(): Router {
}
return pageModule
- }, Theme.NotFound)
+ }, resolveNotFound(RawTheme))
}
if (inBrowser) {
@@ -175,6 +171,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/client/theme-default/Layout.vue b/src/client/theme-default/Layout.vue
index c9a4af67..f0e7ef51 100644
--- a/src/client/theme-default/Layout.vue
+++ b/src/client/theme-default/Layout.vue
@@ -64,7 +64,6 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
-
diff --git a/src/client/theme-default/NotFound.vue b/src/client/theme-default/NotFound.vue
index 5805ac80..1cf11cbe 100644
--- a/src/client/theme-default/NotFound.vue
+++ b/src/client/theme-default/NotFound.vue
@@ -21,11 +21,7 @@ const { currentLang } = useLangs()
diff --git a/src/client/theme-default/components/VPContent.vue b/src/client/theme-default/components/VPContent.vue
index ef58f217..6bfdd845 100644
--- a/src/client/theme-default/components/VPContent.vue
+++ b/src/client/theme-default/components/VPContent.vue
@@ -1,15 +1,12 @@
',
+ '',
+ '',
+ ''
+ ].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/default-theme.d.ts b/types/default-theme.d.ts
index 8120fead..2313120e 100644
--- a/types/default-theme.d.ts
+++ b/types/default-theme.d.ts
@@ -10,6 +10,12 @@ export namespace DefaultTheme {
* The layout state returned by `useLayout` from `vitepress/theme`.
*/
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
isHome: ComputedRef
sidebar: Readonly>
@@ -505,13 +511,6 @@ export namespace DefaultTheme {
*/
link?: string
- /**
- * Set aria label for home link.
- *
- * @default 'go to home'
- */
- linkLabel?: string
-
/**
* Set custom home link text.
*
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
/**