diff --git a/.gitignore b/.gitignore
index e6e95ca9..dafdcdea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ pnpm-global
TODOs.md
*.timestamp-*.mjs
.claude
+
+# base fixture builds
+__tests__/base/fixture/.vitepress/dist-*
diff --git a/__tests__/base/cdn.test.ts b/__tests__/base/cdn.test.ts
new file mode 100644
index 00000000..7b8ce80b
--- /dev/null
+++ b/__tests__/base/cdn.test.ts
@@ -0,0 +1,63 @@
+import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
+
+const origin = () => `http://localhost:${process.env['PAGES_PORT']}`
+const cdnPort = () => process.env['VP_CDN_PORT']
+
+let t: TestPage
+
+beforeAll(async () => {
+ t = await newPage()
+})
+
+afterAll(async () => {
+ await t.page.close()
+ await t.browser.close()
+})
+
+describe('assetsBase with a separate cdn origin', () => {
+ test('pages hydrate from cross-origin assets', async () => {
+ await t.page.goto(`${origin()}/`)
+ await waitForHydration(t.page)
+ const cdnResources = await t.page.evaluate(
+ (port) =>
+ performance
+ .getEntriesByType('resource')
+ .filter((r) => r.name.includes(`:${port}/`)).length,
+ cdnPort()
+ )
+ expect(cdnResources).toBeGreaterThan(5)
+ })
+
+ test('client-side navigation loads page chunks from the cdn', async () => {
+ await t.page.evaluate(() => ((window as any).__spa_marker = 1))
+ await t.page.click('.vp-doc a[href="/sub/page.html"]')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Sub page')
+ )
+ expect(
+ await t.page.evaluate(() => (window as any).__spa_marker === 1)
+ ).toBe(true)
+ const chunkFromCdn = await t.page.evaluate(
+ (port) =>
+ performance
+ .getEntriesByType('resource')
+ .some((r) => r.name.includes(`:${port}/`) && r.name.includes('.md.')),
+ cdnPort()
+ )
+ expect(chunkFromCdn).toBe(true)
+ })
+
+ test('search works with the index chunk on the cdn', async () => {
+ await t.page.click('.VPNavBarSearchButton')
+ const input = await t.page.waitForSelector('input#localsearch-input')
+ await input.type('xylophone')
+ await t.page.waitForSelector('#localsearch-list li[role=option] a')
+ expect(
+ await t.page.getAttribute('#localsearch-list li[role=option] a', 'href')
+ ).toBe('/sub/deep/page2.html#deep-heading')
+ })
+
+ test('no console or page errors across the whole flow', () => {
+ expect(realErrors(t.errors)).toEqual([])
+ })
+})
diff --git a/__tests__/base/constants.ts b/__tests__/base/constants.ts
new file mode 100644
index 00000000..a2d5bcbe
--- /dev/null
+++ b/__tests__/base/constants.ts
@@ -0,0 +1,2 @@
+export const SUB_PREFIX = '/ipfs/QmRelocatableTest123/'
+export const ALT_PREFIX = '/some/other/place/'
diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts
new file mode 100644
index 00000000..9a9b7efd
--- /dev/null
+++ b/__tests__/base/emit.test.ts
@@ -0,0 +1,189 @@
+import { readFileSync, readdirSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const dir = resolve(fileURLToPath(import.meta.url), '..')
+const dist = (mode: string, ...p: string[]) =>
+ join(dir, `fixture/.vitepress/dist-${mode}`, ...p)
+const read = (mode: string, file: string) =>
+ readFileSync(dist(mode, file), 'utf-8')
+
+const walk = (root: string): string[] =>
+ readdirSync(root, { recursive: true, withFileTypes: true })
+ .filter((e) => e.isFile())
+ .map((e) => join(e.parentPath, e.name))
+
+describe('relative base emit', () => {
+ test('root page references everything at ./', () => {
+ const html = read('relative', 'index.html')
+ expect(html).toContain(
+ 'window.__VP_SITE_ROOT__=new URL("./",location).href'
+ )
+ expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
+ expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/)
+ expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/)
+ expect(html).toContain('href="./vp-icons.css"')
+ })
+
+ test('markdown links compile page-relative with explicit index.html', () => {
+ const html = read('relative', 'index.html')
+ expect(html).toContain('href="./sub/page.html"')
+ expect(html).toContain('href="./sub/index.html"')
+ expect(html).toContain('href="./moved/target.html"')
+ })
+
+ test('non-page links get the prefix but no .html', () => {
+ const html = read('relative', 'index.html')
+ expect(html).toContain('href="./file.zip"')
+ expect(html).not.toContain('file.zip.html')
+ })
+
+ test('public and hashed assets in content are page-relative', () => {
+ const html = read('relative', 'index.html')
+ expect(html).toContain('src="./logo.png"')
+ expect(html).toMatch(/src="\.\/assets\/photo\.[\w-]+\.png"/)
+ })
+
+ test('depth 1 pages use ../', () => {
+ const html = read('relative', 'sub/page.html')
+ expect(html).toContain(
+ 'window.__VP_SITE_ROOT__=new URL("../",location).href'
+ )
+ expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
+ expect(html).toContain('href="../vp-icons.css"')
+ expect(html).toContain('src="../logo.png"')
+ expect(html).toContain('href="../index.html"')
+ expect(html).toContain('href="../sub/deep/page2.html"')
+ })
+
+ test('hash and external links stay untouched', () => {
+ const html = read('relative', 'sub/page.html')
+ expect(html).toContain('href="#local-anchor"')
+ expect(html).toContain('href="https://example.com/x"')
+ })
+
+ test('depth 2 pages use ../../', () => {
+ const html = read('relative', 'sub/deep/page2.html')
+ expect(html).toContain(
+ 'window.__VP_SITE_ROOT__=new URL("../../",location).href'
+ )
+ expect(html).toMatch(/href="\.\.\/\.\.\/assets\/style\.[\w-]+\.css"/)
+ })
+
+ test('rewritten page lands at its rewrite depth', () => {
+ const html = read('relative', 'moved/target.html')
+ expect(html).toContain(
+ 'window.__VP_SITE_ROOT__=new URL("../",location).href'
+ )
+ expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
+ })
+
+ test('404 renders at root depth', () => {
+ const html = read('relative', '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
+ expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__')
+ }
+ })
+
+ test('content-loader html keeps site-absolute links', () => {
+ const html = read('relative', 'blog.html')
+ // the loader source lives at posts/deep/, the consumer at the root —
+ // per-source relativizing would point above the site root
+ expect(html).toContain('href="/sub/page.html"')
+ expect(html).not.toContain('../../sub/page.html')
+ // the consuming page's own chrome is still relative
+ expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
+ })
+})
+
+describe('assetsBase emit', () => {
+ const cdn = () => `http://localhost:${process.env['VP_CDN_PORT']}/`
+
+ test('scripts, styles and preloads move to the cdn with crossorigin', () => {
+ const html = read('cdn', 'index.html')
+ expect(html).toMatch(
+ new RegExp(
+ `
+
+
diff --git a/__tests__/base/fixture/img/photo.png b/__tests__/base/fixture/img/photo.png
new file mode 100644
index 00000000..f37764b1
Binary files /dev/null and b/__tests__/base/fixture/img/photo.png differ
diff --git a/__tests__/base/fixture/index.md b/__tests__/base/fixture/index.md
new file mode 100644
index 00000000..50f40608
--- /dev/null
+++ b/__tests__/base/fixture/index.md
@@ -0,0 +1,13 @@
+# Home
+
+
+
+
+
+[to sub](/sub/page)
+
+[to dir](/sub/)
+
+[zip](/file.zip)
+
+[moved](/moved/target)
diff --git a/__tests__/base/fixture/posts.data.ts b/__tests__/base/fixture/posts.data.ts
new file mode 100644
index 00000000..8a3fb96b
--- /dev/null
+++ b/__tests__/base/fixture/posts.data.ts
@@ -0,0 +1,3 @@
+import { createContentLoader } from 'vitepress'
+
+export default createContentLoader('posts/**/*.md', { render: true })
diff --git a/__tests__/base/fixture/posts/deep/post1.md b/__tests__/base/fixture/posts/deep/post1.md
new file mode 100644
index 00000000..abcd0e35
--- /dev/null
+++ b/__tests__/base/fixture/posts/deep/post1.md
@@ -0,0 +1,5 @@
+# Post one
+
+This is the intro of post one with a [site link](/sub/page) and .
+
+More body.
diff --git a/__tests__/base/fixture/public/file.zip b/__tests__/base/fixture/public/file.zip
new file mode 100644
index 00000000..8c3b76fb
--- /dev/null
+++ b/__tests__/base/fixture/public/file.zip
@@ -0,0 +1 @@
+PKtest
\ No newline at end of file
diff --git a/__tests__/base/fixture/public/logo.png b/__tests__/base/fixture/public/logo.png
new file mode 100644
index 00000000..f37764b1
Binary files /dev/null and b/__tests__/base/fixture/public/logo.png differ
diff --git a/__tests__/base/fixture/src-moved.md b/__tests__/base/fixture/src-moved.md
new file mode 100644
index 00000000..92f16eaa
--- /dev/null
+++ b/__tests__/base/fixture/src-moved.md
@@ -0,0 +1,3 @@
+# Moved page
+
+Rewritten target.
diff --git a/__tests__/base/fixture/sub/deep/page2.md b/__tests__/base/fixture/sub/deep/page2.md
new file mode 100644
index 00000000..21f56a29
--- /dev/null
+++ b/__tests__/base/fixture/sub/deep/page2.md
@@ -0,0 +1,7 @@
+# Deep page
+
+[up](/sub/page)
+
+## Deep heading
+
+The xylophone paragraph for search.
diff --git a/__tests__/base/fixture/sub/index.md b/__tests__/base/fixture/sub/index.md
new file mode 100644
index 00000000..2a28bbf3
--- /dev/null
+++ b/__tests__/base/fixture/sub/index.md
@@ -0,0 +1,3 @@
+# Sub index
+
+Index of sub.
diff --git a/__tests__/base/fixture/sub/page.md b/__tests__/base/fixture/sub/page.md
new file mode 100644
index 00000000..6a5ca71a
--- /dev/null
+++ b/__tests__/base/fixture/sub/page.md
@@ -0,0 +1,15 @@
+# Sub page
+
+
+
+[home](/)
+
+[deep](/sub/deep/page2)
+
+[hash](#local-anchor)
+
+[external](https://example.com/x)
+
+## Local anchor
+
+Body text here.
diff --git a/__tests__/base/helpers.ts b/__tests__/base/helpers.ts
new file mode 100644
index 00000000..05b9723d
--- /dev/null
+++ b/__tests__/base/helpers.ts
@@ -0,0 +1,29 @@
+import { chromium, type Browser, type Page } from 'playwright-chromium'
+
+export interface TestPage {
+ browser: Browser
+ page: Page
+ errors: string[]
+}
+
+export async function newPage(): Promise {
+ const browser = await chromium.connect(process.env['WS_ENDPOINT']!)
+ const page = await browser.newPage()
+ const errors: string[] = []
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') errors.push(msg.text())
+ })
+ page.on('pageerror', (err) => errors.push(String(err)))
+ return { browser, page, errors }
+}
+
+export function realErrors(errors: string[]): string[] {
+ return errors.filter((e) => !e.includes('favicon'))
+}
+
+export async function waitForHydration(page: Page): Promise {
+ await page.waitForSelector('#app .Layout')
+ await page.waitForFunction(
+ () => (document.querySelector('#app') as any)?.__vue_app__ !== undefined
+ )
+}
diff --git a/__tests__/base/package.json b/__tests__/base/package.json
new file mode 100644
index 00000000..555db084
--- /dev/null
+++ b/__tests__/base/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "tests-base",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "watch": "DEBUG=1 vitest"
+ },
+ "devDependencies": {
+ "vitepress": "workspace:*"
+ }
+}
diff --git a/__tests__/base/relative-file.test.ts b/__tests__/base/relative-file.test.ts
new file mode 100644
index 00000000..7e839416
--- /dev/null
+++ b/__tests__/base/relative-file.test.ts
@@ -0,0 +1,60 @@
+import { join, resolve } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+import { newPage, type TestPage } from './helpers'
+
+const dist = resolve(
+ fileURLToPath(import.meta.url),
+ '..',
+ 'fixture/.vitepress/dist-relative'
+)
+
+const fileUrl = (...p: string[]) => pathToFileURL(join(dist, ...p)).href
+
+let t: TestPage
+
+beforeAll(async () => {
+ t = await newPage()
+})
+
+afterAll(async () => {
+ await t.page.close()
+ await t.browser.close()
+})
+
+// module scripts are cors-blocked from disk, so nothing hydrates here; the
+// pre-rendered site must still be styled and navigable
+describe('relative base opened over file://', () => {
+ test('pages render styled with working images', async () => {
+ await t.page.goto(fileUrl('sub/page.html'))
+ expect(await t.page.textContent('h1')).toContain('Sub page')
+ const fontFamily = await t.page.evaluate(
+ () => getComputedStyle(document.body).fontFamily
+ )
+ expect(fontFamily).toContain('Inter')
+ const logoLoaded = await t.page.evaluate(
+ () =>
+ document.querySelector('img[alt="logo again"]')!
+ .naturalWidth
+ )
+ expect(logoLoaded).toBe(1)
+ })
+
+ test('content links navigate between files', async () => {
+ await t.page.click('.vp-doc a[href="../sub/deep/page2.html"]')
+ expect(await t.page.textContent('h1')).toContain('Deep page')
+ expect(t.page.url()).toBe(fileUrl('sub/deep/page2.html'))
+ })
+
+ test('theme links navigate between files', async () => {
+ await t.page.click('.VPSidebar a[href="../../moved/target.html"]')
+ expect(await t.page.textContent('h1')).toContain('Moved page')
+ expect(t.page.url()).toBe(fileUrl('moved/target.html'))
+ })
+
+ test('the root page reaches nested pages', async () => {
+ await t.page.goto(fileUrl('index.html'))
+ await t.page.click('.vp-doc a[href="./sub/index.html"]')
+ expect(await t.page.textContent('h1')).toContain('Sub index')
+ })
+})
diff --git a/__tests__/base/relative-spa.test.ts b/__tests__/base/relative-spa.test.ts
new file mode 100644
index 00000000..04e0732e
--- /dev/null
+++ b/__tests__/base/relative-spa.test.ts
@@ -0,0 +1,92 @@
+import { ALT_PREFIX, SUB_PREFIX } from './constants'
+import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
+
+const origin = () => `http://localhost:${process.env['SUB_PORT']}`
+
+let t: TestPage
+
+beforeAll(async () => {
+ t = await newPage()
+})
+
+afterAll(async () => {
+ await t.page.close()
+ await t.browser.close()
+})
+
+// mark the window with a marker that only survives client-side navigation,
+// proving no full document reload occurred
+const mark = () => t.page.evaluate(() => ((window as any).__spa_marker = 1))
+const marked = () => t.page.evaluate(() => (window as any).__spa_marker === 1)
+
+describe('relative base served from a deep subpath', () => {
+ test('deep link loads and hydrates', async () => {
+ await t.page.goto(`${origin()}${SUB_PREFIX}sub/deep/page2.html`)
+ await waitForHydration(t.page)
+ expect(await t.page.textContent('h1')).toContain('Deep page')
+ expect(await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__)).toBe(
+ `${origin()}${SUB_PREFIX}`
+ )
+ })
+
+ test('sidebar navigation is client-side and lands on the right url', async () => {
+ await mark()
+ await t.page.click(`.VPSidebar a[href="${SUB_PREFIX}sub/page.html"]`)
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Sub page')
+ )
+ expect(await marked()).toBe(true)
+ expect(new URL(t.page.url()).pathname).toBe(`${SUB_PREFIX}sub/page.html`)
+ })
+
+ test('content links navigate client-side', async () => {
+ await t.page.click('.vp-doc a[href="../index.html"]')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Home')
+ )
+ expect(await marked()).toBe(true)
+ // the router strips index.html from the address bar
+ expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX)
+ })
+
+ test('search finds pages and navigates to them', async () => {
+ await t.page.click('.VPNavBarSearchButton')
+ const input = await t.page.waitForSelector('input#localsearch-input')
+ await input.type('xylophone')
+ await t.page.waitForSelector('#localsearch-list li[role=option] a')
+ const href = await t.page.getAttribute(
+ '#localsearch-list li[role=option] a',
+ 'href'
+ )
+ expect(href).toBe(`${SUB_PREFIX}sub/deep/page2.html#deep-heading`)
+ await t.page.click('#localsearch-list li[role=option] a')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Deep page')
+ )
+ expect(await marked()).toBe(true)
+ })
+
+ test('history back keeps working', async () => {
+ await t.page.goBack()
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Home')
+ )
+ expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX)
+ })
+
+ test('the same build works mounted at a different prefix', async () => {
+ await t.page.goto(`${origin()}${ALT_PREFIX}index.html`)
+ await waitForHydration(t.page)
+ await mark()
+ await t.page.click('.vp-doc a[href="./sub/page.html"]')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Sub page')
+ )
+ expect(await marked()).toBe(true)
+ expect(new URL(t.page.url()).pathname).toBe(`${ALT_PREFIX}sub/page.html`)
+ })
+
+ test('no console or page errors across the whole flow', () => {
+ expect(realErrors(t.errors)).toEqual([])
+ })
+})
diff --git a/__tests__/base/tsconfig.json b/__tests__/base/tsconfig.json
new file mode 100644
index 00000000..1759c08d
--- /dev/null
+++ b/__tests__/base/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["**/*"],
+ "exclude": ["fixture/.vitepress/dist-*", "fixture/.vitepress/cache"]
+}
diff --git a/__tests__/base/vitest.config.ts b/__tests__/base/vitest.config.ts
new file mode 100644
index 00000000..43f17310
--- /dev/null
+++ b/__tests__/base/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vitest/config'
+
+const timeout = 60_000
+
+export default defineConfig({
+ test: {
+ globalSetup: ['vitestGlobalSetup.ts'],
+ testTimeout: timeout,
+ hookTimeout: timeout,
+ teardownTimeout: timeout,
+ globals: true,
+ fileParallelism: false
+ }
+})
diff --git a/__tests__/base/vitestGlobalSetup.ts b/__tests__/base/vitestGlobalSetup.ts
new file mode 100644
index 00000000..0f7e5d0c
--- /dev/null
+++ b/__tests__/base/vitestGlobalSetup.ts
@@ -0,0 +1,118 @@
+import { spawnSync } from 'node:child_process'
+import { readFile } from 'node:fs/promises'
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { extname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { chromium, type BrowserServer } from 'playwright-chromium'
+
+import { ALT_PREFIX, SUB_PREFIX } from './constants'
+
+const dir = resolve(fileURLToPath(import.meta.url), '..')
+const bin = resolve(dir, '../../bin/vitepress.js')
+const dist = (mode: string) => resolve(dir, `fixture/.vitepress/dist-${mode}`)
+
+const types: Record = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+ '.zip': 'application/zip'
+}
+
+// listens on an os-assigned port (the other suites run in parallel on CI,
+// so a pre-picked "free" port can be taken before we bind it)
+function serveStatic(
+ mounts: [prefix: string, root: string][],
+ cors: boolean
+): Promise {
+ const server = createServer(async (req, res) => {
+ const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname)
+ for (const [prefix, root] of mounts) {
+ if (!url.startsWith(prefix)) continue
+ let file = url.slice(prefix.length) || 'index.html'
+ if (file.endsWith('/')) file += 'index.html'
+ try {
+ const data = await readFile(join(root, file))
+ const headers: Record = {
+ 'content-type': types[extname(file)] ?? 'application/octet-stream'
+ }
+ if (cors) headers['access-control-allow-origin'] = '*'
+ res.writeHead(200, headers)
+ res.end(data)
+ return
+ } catch {}
+ }
+ res.writeHead(404)
+ res.end('not found')
+ })
+ return new Promise((r) => server.listen(0, () => r(server)))
+}
+
+const portOf = (server: Server) => (server.address() as AddressInfo).port
+
+let browserServer: BrowserServer
+let servers: Server[] = []
+
+export async function setup() {
+ // started before its dist exists so its real port can go into assetsBase
+ const cdnServer = await serveStatic([['/', dist('cdn')]], true)
+ const cdnPort = portOf(cdnServer)
+
+ // one process per flavor: the markdown renderer is a module-level
+ // singleton, so in-process builds would leak the first base into the rest
+ for (const mode of ['plain', 'relative', 'cdn', 'mpa']) {
+ const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], {
+ cwd: dir,
+ env: {
+ ...process.env,
+ VP_TEST_MODE: mode,
+ VP_CDN_PORT: String(cdnPort)
+ },
+ encoding: 'utf-8'
+ })
+ if (res.status !== 0) {
+ throw new Error(`build (${mode}) failed:\n${res.stdout}\n${res.stderr}`)
+ }
+ }
+
+ servers = [
+ // one relative-base build mounted at two unrelated prefixes
+ await serveStatic(
+ [
+ [SUB_PREFIX, dist('relative')],
+ [ALT_PREFIX, dist('relative')]
+ ],
+ false
+ ),
+ await serveStatic([['/', dist('cdn')]], false),
+ cdnServer
+ ]
+
+ browserServer = await chromium.launchServer({
+ headless: !process.env.DEBUG,
+ args: process.env.CI
+ ? ['--no-sandbox', '--disable-setuid-sandbox']
+ : undefined
+ })
+
+ process.env['WS_ENDPOINT'] = browserServer.wsEndpoint()
+ process.env['SUB_PORT'] = String(portOf(servers[0]!))
+ process.env['PAGES_PORT'] = String(portOf(servers[1]!))
+ process.env['VP_CDN_PORT'] = String(cdnPort)
+}
+
+export async function teardown() {
+ await browserServer.close()
+ await Promise.all(
+ servers.map(
+ (server) =>
+ new Promise((resolve, reject) =>
+ server.close((err) => (err ? reject(err) : resolve()))
+ )
+ )
+ )
+}
diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json
index 366c4ab8..83d5121c 100644
--- a/__tests__/tsconfig.json
+++ b/__tests__/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "../tsconfig.json",
+ "extends": "../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"isolatedModules": false,
diff --git a/__tests__/unit/node/config.test.ts b/__tests__/unit/node/config.test.ts
index df4af72c..de1eb2f8 100644
--- a/__tests__/unit/node/config.test.ts
+++ b/__tests__/unit/node/config.test.ts
@@ -1,5 +1,10 @@
import type { MarkdownItAsync } from 'markdown-it-async'
-import { mergeConfig, type UserConfig } from 'node/config'
+import {
+ mergeConfig,
+ normalizeAssetsBase,
+ normalizeSiteBase,
+ type UserConfig
+} from 'node/config'
describe('node/config', () => {
test('merges markdown hooks from extended configs', async () => {
@@ -71,3 +76,50 @@ describe('node/config', () => {
expect(calls).toEqual(['base-pre', 'extended'])
})
})
+
+describe('node/config base normalization', () => {
+ describe('normalizeSiteBase', () => {
+ test('defaults to / and appends the trailing slash', () => {
+ expect(normalizeSiteBase(undefined)).toBe('/')
+ expect(normalizeSiteBase('')).toBe('/')
+ expect(normalizeSiteBase('/docs')).toBe('/docs/')
+ expect(normalizeSiteBase('/docs/')).toBe('/docs/')
+ })
+
+ test('coerces a leading slash onto path bases', () => {
+ expect(normalizeSiteBase('docs')).toBe('/docs/')
+ expect(normalizeSiteBase('docs/')).toBe('/docs/')
+ expect(normalizeSiteBase('https://example.com/x')).toBe(
+ 'https://example.com/x/'
+ )
+ expect(normalizeSiteBase('//cdn.example.com/')).toBe('//cdn.example.com/')
+ })
+
+ test('normalizes relative forms to ./', () => {
+ expect(normalizeSiteBase('.')).toBe('./')
+ expect(normalizeSiteBase('./')).toBe('./')
+ })
+
+ test('rejects relative bases with a subpath', () => {
+ expect(() => normalizeSiteBase('./docs/')).toThrow(/relative base/)
+ expect(() => normalizeSiteBase('../x')).toThrow(/relative base/)
+ })
+ })
+
+ describe('normalizeAssetsBase', () => {
+ test('accepts absolute urls, protocol-relative urls and paths', () => {
+ expect(normalizeAssetsBase('https://cdn.example.com')).toBe(
+ 'https://cdn.example.com/'
+ )
+ expect(normalizeAssetsBase('//cdn.example.com/x')).toBe(
+ '//cdn.example.com/x/'
+ )
+ expect(normalizeAssetsBase('/cdn/')).toBe('/cdn/')
+ })
+
+ test('rejects relative values', () => {
+ expect(() => normalizeAssetsBase('./cdn/')).toThrow(/assetsBase/)
+ expect(() => normalizeAssetsBase('cdn/')).toThrow(/assetsBase/)
+ })
+ })
+})
diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts
index 5e4bc7f1..119132c3 100644
--- a/__tests__/unit/node/markdown/plugins/link.test.ts
+++ b/__tests__/unit/node/markdown/plugins/link.test.ts
@@ -62,3 +62,79 @@ describe('node/markdown/plugins/link', () => {
expect(env.linkLines).toEqual([3])
})
})
+
+describe('node/markdown/plugins/link with a relative base', () => {
+ const md = new MarkdownItAsync()
+ linkPlugin(md, {}, './', slugify)
+ const render = (src: string, env: object = {}) =>
+ md.renderAsync(src, {
+ cleanUrls: false,
+ relativePath: 'guide/page.md',
+ relativizeUrls: true,
+ ...env
+ })
+
+ test('site-absolute links become page-relative', async () => {
+ expect(await render('[x](/other/thing)')).toContain(
+ 'href="../other/thing.html"'
+ )
+ expect(
+ await render('[x](/other/thing)', { relativePath: 'index.md' })
+ ).toContain('href="./other/thing.html"')
+ expect(
+ await render('[x](/other/thing)', { relativePath: 'a/b/c.md' })
+ ).toContain('href="../../other/thing.html"')
+ })
+
+ test('directory links point at index.html', async () => {
+ expect(await render('[home](/)')).toContain('href="../index.html"')
+ expect(await render('[dir](/guide/)')).toContain(
+ 'href="../guide/index.html"'
+ )
+ })
+
+ test('non-page files get the prefix but no .html', async () => {
+ expect(await render('[zip](/file.zip)')).toContain('href="../file.zip"')
+ })
+
+ test('hash, external and relative links stay untouched', async () => {
+ expect(await render('[a](#section)')).toContain('href="#section"')
+ expect(await render('[a](https://example.com/x)')).toContain(
+ 'href="https://example.com/x"'
+ )
+ expect(await render('[a](./sibling)')).toContain('href="./sibling.html"')
+ })
+
+ test('cleanUrls drops .html and the index suffix', async () => {
+ expect(await render('[x](/other/thing)', { cleanUrls: true })).toContain(
+ 'href="../other/thing"'
+ )
+ expect(await render('[dir](/guide/)', { cleanUrls: true })).toContain(
+ 'href="../guide/"'
+ )
+ })
+
+ test('content-loader renders keep absolute links site-absolute', async () => {
+ // content loaders set relativePath but not relativizeUrls — their html
+ // is embedded in other pages, so the source's depth must not apply
+ expect(
+ await render('[x](/other/thing)', { relativizeUrls: undefined })
+ ).toContain('href="/other/thing.html"')
+ expect(
+ await render('[x](/other/thing)', { relativePath: undefined })
+ ).toContain('href="/other/thing.html"')
+ })
+})
+
+describe('node/markdown/plugins/link with an absolute base', () => {
+ const md = new MarkdownItAsync()
+ linkPlugin(md, {}, '/docs/', slugify)
+
+ test('site-absolute links get the base and keep one slash', async () => {
+ const html = await md.renderAsync('[x](/guide/what)', {
+ cleanUrls: false,
+ relativePath: 'index.md'
+ })
+ expect(html).toContain('href="/docs/guide/what.html"')
+ })
+})
diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts
index 77826db3..16b971ae 100644
--- a/__tests__/unit/shared/shared.test.ts
+++ b/__tests__/unit/shared/shared.test.ts
@@ -1,4 +1,10 @@
-import { mergeHead, type HeadConfig } from 'shared/shared'
+import {
+ isRelativeBase,
+ joinPath,
+ mergeHead,
+ relativePathToRoot,
+ type HeadConfig
+} from 'shared/shared'
describe('shared/shared', () => {
describe('mergeHead', () => {
@@ -54,3 +60,41 @@ describe('shared/shared', () => {
})
})
})
+
+describe('shared/shared url helpers', () => {
+ describe('joinPath', () => {
+ test('joins and collapses slash collisions', () => {
+ expect(joinPath('/', '/guide/')).toBe('/guide/')
+ expect(joinPath('/docs/', '/guide/page')).toBe('/docs/guide/page')
+ expect(joinPath('/docs', 'guide')).toBe('/docsguide')
+ })
+
+ test('preserves the protocol of absolute url bases', () => {
+ expect(joinPath('https://cdn.example.com/', '/guide/')).toBe(
+ 'https://cdn.example.com/guide/'
+ )
+ expect(joinPath('https://cdn.example.com/sub//x/', '/a')).toBe(
+ 'https://cdn.example.com/sub/x/a'
+ )
+ expect(joinPath('//cdn.example.com/', '/a')).toBe('//cdn.example.com/a')
+ })
+ })
+
+ describe('isRelativeBase', () => {
+ test('only ./ is relative', () => {
+ expect(isRelativeBase('./')).toBe(true)
+ expect(isRelativeBase('/')).toBe(false)
+ expect(isRelativeBase('/docs/')).toBe(false)
+ expect(isRelativeBase('https://example.com/')).toBe(false)
+ })
+ })
+
+ describe('relativePathToRoot', () => {
+ test('maps a page path to its ../-prefix', () => {
+ expect(relativePathToRoot('index.md')).toBe('./')
+ expect(relativePathToRoot('foo.md')).toBe('./')
+ expect(relativePathToRoot('guide/index.md')).toBe('../')
+ expect(relativePathToRoot('guide/nested/page.md')).toBe('../../')
+ })
+ })
+})
diff --git a/docs/en/guide/asset-handling.md b/docs/en/guide/asset-handling.md
index 63394fd0..76b0c1c4 100644
--- a/docs/en/guide/asset-handling.md
+++ b/docs/en/guide/asset-handling.md
@@ -36,23 +36,15 @@ Note that you should reference files placed in `public` using root absolute path
## Base URL
-If your site is deployed to a non-root URL, you will need to set the `base` option in `.vitepress/config.js`. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'` (it should always start and end with a slash).
+If your site is deployed to a non-root URL, set the [`base`](../reference/site-config#base) option. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'`
-All your static asset paths are automatically processed to adjust for different `base` config values. For example, if you have an absolute reference to an asset under `public` in your markdown:
+Static asset references are automatically adjusted for the base, so an absolute reference to a file in `public` works with any `base` and never needs updating:
```md

```
-You do **not** need to update it when you change the `base` config value in this case.
-
-However, if you are authoring a theme component that links to assets dynamically, e.g. an image whose `src` is based on a theme config value:
-
-```vue
-
-```
-
-In this case it is recommended to wrap the path with the [`withBase` helper](../reference/runtime-api#withbase) provided by VitePress:
+Only dynamically constructed paths need care — for example, an image whose `src` is based on a theme config value. Wrap those with the [`withBase` helper](../reference/runtime-api#withbase) so the base is prepended at runtime:
```vue
diff --git a/src/client/theme-default/components/VPLocalSearchBox.vue b/src/client/theme-default/components/VPLocalSearchBox.vue
index 046c849b..af727577 100644
--- a/src/client/theme-default/components/VPLocalSearchBox.vue
+++ b/src/client/theme-default/components/VPLocalSearchBox.vue
@@ -11,7 +11,7 @@ import {
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch'
-import { dataSymbol, useRouter } from 'vitepress'
+import { dataSymbol, useRouter, withBase } from 'vitepress'
import {
computed,
createApp,
@@ -177,7 +177,7 @@ watchDebounced(
: []
if (canceled) return
for (const { id, mod } of mods) {
- const mapId = id.slice(0, id.indexOf('#'))
+ const mapId = id.replace(/#.*$/, '')
let map = cache.get(mapId)
if (map) continue
map = new Map()
@@ -254,7 +254,7 @@ watchDebounced(
)
async function fetchExcerpt(id: string) {
- const file = pathToFile(id.slice(0, id.indexOf('#')))
+ const file = pathToFile(withBase(id.replace(/#.*$/, '')))
try {
if (!file) throw new Error(`Cannot find file for id: ${id}`)
return { id, mod: await import(/*@vite-ignore*/ file) }
@@ -363,7 +363,7 @@ onKeyStroke('Enter', (e) => {
}
if (selectedPackage) {
- router.go(selectedPackage.id)
+ router.go(withBase(selectedPackage.id))
emit('close')
}
})
@@ -554,7 +554,7 @@ function onMouseMove(e: MouseEvent) {
role="option"
>
`,
+ html: ``,
inHead: true
}
}
diff --git a/src/node/build/buildMPAClient.ts b/src/node/build/buildMPAClient.ts
index 3eebe9dd..1f7f3c20 100644
--- a/src/node/build/buildMPAClient.ts
+++ b/src/node/build/buildMPAClient.ts
@@ -17,6 +17,14 @@ export async function buildMPAClient(
cacheDir: config.cacheDir,
base: config.site.base,
logLevel: config.vite?.logLevel ?? 'warn',
+ ...(config.assetsBase
+ ? {
+ experimental: {
+ renderBuiltUrl: (filename, ctx) =>
+ ctx.type === 'asset' ? config.assetsBase! + filename : undefined
+ }
+ }
+ : {}),
build: {
emptyOutDir: false,
outDir: config.outDir,
diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts
index be85540f..9290c77f 100644
--- a/src/node/build/bundle.ts
+++ b/src/node/build/bundle.ts
@@ -1,5 +1,5 @@
import fs from 'node:fs'
-import { cp } from 'node:fs/promises'
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -15,7 +15,13 @@ import {
import { APP_PATH } from '../alias'
import type { SiteConfig } from '../config'
import { createVitePressPlugin, type PageMeta } from '../plugin'
-import { escapeRegExp, sanitizeFileName, slash } from '../shared'
+import {
+ RELATIVE_BASE_SENTINEL,
+ escapeRegExp,
+ isRelativeBase,
+ sanitizeFileName,
+ slash
+} from '../shared'
import { buildMPAClient } from './buildMPAClient'
// https://github.com/vitejs/vite/blob/a55d0b34400e3360c4100d05e422ae9cf10fa07b/packages/vite/src/node/constants.ts#L50
@@ -75,12 +81,17 @@ export async function bundle(
...restOptions
} = options
+ const relativeBase = isRelativeBase(config.site.base)
+
const resolveViteConfig = async (
ssr: boolean
): Promise => ({
root: config.srcDir,
cacheDir: config.cacheDir,
- base: config.site.base,
+ // the client build relativizes its own asset URLs natively; the SSR
+ // build renders into per-page HTML, so it gets the sentinel base that
+ // renderPage swaps for each page's ../-prefix
+ base: ssr && relativeBase ? RELATIVE_BASE_SENTINEL : config.site.base,
logLevel: config.vite?.logLevel ?? 'warn',
plugins: await createVitePressPlugin(
config,
@@ -152,7 +163,21 @@ export async function bundle(
if (!chunk.fileName.endsWith('.js')) {
const tempPath = path.resolve(config.tempDir, chunk.fileName)
const outPath = path.resolve(config.outDir, chunk.fileName)
- await cp(tempPath, outPath)
+ if (relativeBase && chunk.fileName.endsWith('.css')) {
+ // the server build emits sentinel-based url()s; rewrite them
+ // relative to the css file's own location
+ const css = await readFile(tempPath, 'utf-8')
+ const dir = path.posix.dirname(slash(chunk.fileName))
+ const toRoot =
+ dir === '.' ? './' : '../'.repeat(dir.split('/').length)
+ await mkdir(path.dirname(outPath), { recursive: true })
+ await writeFile(
+ outPath,
+ css.replaceAll(RELATIVE_BASE_SENTINEL, toRoot)
+ )
+ } else {
+ await cp(tempPath, outPath)
+ }
}
},
{ concurrency: config.buildConcurrency }
diff --git a/src/node/build/render.ts b/src/node/build/render.ts
index 49c9a43b..bab5268b 100644
--- a/src/node/build/render.ts
+++ b/src/node/build/render.ts
@@ -8,10 +8,13 @@ import { version } from '../../../package.json' with { type: 'json' }
import type { SiteConfig } from '../config'
import {
EXTERNAL_URL_RE,
+ RELATIVE_BASE_SENTINEL,
createTitle,
escapeHtml,
+ isRelativeBase,
mergeHead,
notFoundPageData,
+ relativePathToRoot,
resolveSiteDataByRoute,
sanitizeFileName,
slash,
@@ -36,8 +39,22 @@ export async function renderPage(
) {
const routePath = `/${page.replace(/\.md$/, '')}`
+ const relativeBase = isRelativeBase(config.site.base)
+ const pageBase = relativeBase ? relativePathToRoot(page) : config.site.base
+ // user hooks must never see the build sentinel
+ const desentinel = (value: string) =>
+ relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value
+
// render page
const context = await render(routePath)
+ if (relativeBase) {
+ context.content = desentinel(context.content)
+ if (context.teleports) {
+ for (const key in context.teleports) {
+ context.teleports[key] = desentinel(context.teleports[key])
+ }
+ }
+ }
const { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context
@@ -72,10 +89,17 @@ export async function renderPage(
const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath)
+ const assetUrl = (file: string) => (config.assetsBase ?? pageBase) + file
+ const assetsCrossOrigin =
+ config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase)
+ ? ' crossorigin'
+ : ''
+ const pageAssets = relativeBase ? assets.map(desentinel) : assets
+
const title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description
const stylesheetLink = cssChunk
- ? ``
+ ? ``
: ''
let preloadLinks =
@@ -107,15 +131,24 @@ export async function renderPage(
{
rel,
// don't add base to external urls
- href: (EXTERNAL_URL_RE.test(file) ? '' : siteData.base) + file
+ href: EXTERNAL_URL_RE.test(file) ? file : assetUrl(file),
+ // must match the cors mode of the later module fetch, or the
+ // cached response is not reused
+ ...(assetsCrossOrigin && !EXTERNAL_URL_RE.test(file)
+ ? { crossorigin: '' }
+ : {})
}
])
const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload')
const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch')
+ const pageHeadTags: HeadConfig[] = relativeBase
+ ? JSON.parse(desentinel(JSON.stringify(additionalHeadTags)))
+ : additionalHeadTags
+
const headBeforeTransform = [
- ...additionalHeadTags,
+ ...pageHeadTags,
...preloadHeadTags,
...prefetchHeadTags,
...mergeHead(
@@ -135,7 +168,7 @@ export async function renderPage(
description,
head: headBeforeTransform,
content,
- assets
+ assets: pageAssets
})) || []
)
@@ -153,7 +186,7 @@ export async function renderPage(
force: true
})
} else {
- inlinedScript = ``
+ inlinedScript = ``
}
}
}
@@ -176,12 +209,19 @@ export async function renderPage(
: ``
}
+ ${
+ // recovers the absolute site root at runtime; a classic inline script
+ // so it runs before any module resolves URLs
+ relativeBase && !config.mpa
+ ? ``
+ : ''
+ }
${stylesheetLink}
-
+
${metadataScript.inHead ? metadataScript.html : ''}
${
appChunk
- ? ``
+ ? ``
: ''
}
${await renderHead(head)}
@@ -195,18 +235,23 @@ export async function renderPage(
const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html'))
await mkdir(path.dirname(htmlFileName), { recursive: true })
- const transformedHtml = await config.transformHtml?.(html, htmlFileName, {
- page,
- siteConfig: config,
- siteData,
- pageData,
- title,
- description,
- head,
- content,
- assets
- })
- await writeFile(htmlFileName, transformedHtml || html)
+ const finalHtml = desentinel(html)
+ const transformedHtml = await config.transformHtml?.(
+ finalHtml,
+ htmlFileName,
+ {
+ page,
+ siteConfig: config,
+ siteData,
+ pageData,
+ title,
+ description,
+ head,
+ content,
+ assets: pageAssets
+ }
+ )
+ await writeFile(htmlFileName, transformedHtml || finalHtml)
}
async function resolvePageImports(
diff --git a/src/node/config.ts b/src/node/config.ts
index 093b455b..f60663f6 100644
--- a/src/node/config.ts
+++ b/src/node/config.ts
@@ -18,8 +18,10 @@ import type { MarkdownOptions } from './markdown/markdown'
import { resolvePages } from './plugins/dynamicRoutesPlugin'
import {
APPEARANCE_KEY,
+ EXTERNAL_URL_RE,
VP_SOURCE_KEY,
isObject,
+ isRelativeBase,
slash,
type AdditionalConfig,
type Awaitable,
@@ -42,6 +44,35 @@ const additionalConfigGlob = `**/config.{js,mjs,ts,mts}`
const resolve = (root: string, file: string) =>
normalizePath(path.resolve(root, `.vitepress`, file))
+export function normalizeSiteBase(base?: string): string {
+ let normalized = base ? base.replace(/([^/])$/, '$1/') : '/'
+ if (normalized.startsWith('.') && !isRelativeBase(normalized)) {
+ throw new Error(
+ `a relative base must be exactly './' (got: ${base}) — pages always ` +
+ `reference the site root relative to their own depth`
+ )
+ }
+ if (
+ !isRelativeBase(normalized) &&
+ !EXTERNAL_URL_RE.test(normalized) &&
+ !normalized.startsWith('/')
+ ) {
+ normalized = '/' + normalized
+ }
+ return normalized
+}
+
+export function normalizeAssetsBase(assetsBase: string): string {
+ const normalized = assetsBase.replace(/([^/])$/, '$1/')
+ if (!EXTERNAL_URL_RE.test(normalized) && !normalized.startsWith('/')) {
+ throw new Error(
+ `assetsBase must be an absolute URL, a protocol-relative URL, or a ` +
+ `root-absolute path (got: ${assetsBase})`
+ )
+ }
+ return normalized
+}
+
export type { ConfigEnv }
export type UserConfigFn = (
env: ConfigEnv
@@ -142,11 +173,26 @@ export async function resolveConfig(
? ''
: normalizePath(path.resolve(srcDir, vitePublicDir || 'public'))
+ const assetsBase = userConfig.assetsBase
+ ? normalizeAssetsBase(userConfig.assetsBase)
+ : undefined
+
+ if (isRelativeBase(site.base) && site.cleanUrls && command === 'build') {
+ logger.warn(
+ c.yellow(
+ `cleanUrls with a relative base needs server-side rewrites and breaks ` +
+ `file:// browsing — links won't end in .html. Consider disabling ` +
+ `cleanUrls for relocatable builds.`
+ )
+ )
+ }
+
const config: Omit = {
root,
srcDir,
publicDir,
assetsDir,
+ assetsBase,
site,
themeDir,
configPath,
@@ -366,7 +412,7 @@ export async function resolveSiteData(
title: userConfig.title || 'VitePress',
titleTemplate: userConfig.titleTemplate,
description: userConfig.description || 'A VitePress site',
- base: userConfig.base ? userConfig.base.replace(/([^/])$/, '$1/') : '/',
+ base: normalizeSiteBase(userConfig.base),
head: resolveSiteDataHead(userConfig),
router: {
prefetchLinks: userConfig.router?.prefetchLinks ?? true
diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts
index f046537e..e9c52105 100644
--- a/src/node/markdown/plugins/link.ts
+++ b/src/node/markdown/plugins/link.ts
@@ -9,6 +9,9 @@ import type { MarkdownItAsync } from 'markdown-it-async'
import {
EXTERNAL_URL_RE,
isExternal,
+ isRelativeBase,
+ joinPath,
+ relativePathToRoot,
treatAsHtml,
type MarkdownEnv
} from '../../shared'
@@ -81,7 +84,15 @@ export const linkPlugin = (
// append base to internal (non-relative) urls
if (hrefAttr[1].startsWith('/')) {
- hrefAttr[1] = `${base}${hrefAttr[1]}`.replace(/\/+/g, '/')
+ if (isRelativeBase(base)) {
+ // page-relative, so the same html works at any mount point
+ if (env.relativizeUrls && env.relativePath != null) {
+ hrefAttr[1] =
+ relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1)
+ }
+ } else {
+ hrefAttr[1] = joinPath(base, hrefAttr[1])
+ }
}
}
if (frag) {
@@ -98,10 +109,13 @@ export const linkPlugin = (
) {
let url = hrefAttr[1]
+ // directory urls need a server to resolve them, and file:// has none
+ const explicitIndex = isRelativeBase(base) && !env.cleanUrls
+
const indexMatch = url.match(indexRE)
if (indexMatch) {
const [, path, hash] = indexMatch
- url = path + normalizeHash(hash)
+ url = path + (explicitIndex ? 'index.html' : '') + normalizeHash(hash)
} else {
let cleanUrl = url.replace(/[?#].*$/, '')
// transform foo.md -> foo[.html]
@@ -116,6 +130,9 @@ export const linkPlugin = (
) {
cleanUrl += '.html'
}
+ if (explicitIndex && cleanUrl.endsWith('/')) {
+ cleanUrl += 'index.html'
+ }
const parsed = new URL(url, 'http://a.com')
url = cleanUrl + parsed.search + normalizeHash(parsed.hash)
}
diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts
index 8586fb1b..22e5cb4b 100644
--- a/src/node/markdownToVue.ts
+++ b/src/node/markdownToVue.ts
@@ -158,6 +158,7 @@ export async function createMarkdownToVueRenderFn(
path: file,
relativePath,
cleanUrls,
+ relativizeUrls: true,
includes: [],
realPath: fileOrig,
localeIndex
diff --git a/src/node/plugin.ts b/src/node/plugin.ts
index af0f3502..ec2ffd9d 100644
--- a/src/node/plugin.ts
+++ b/src/node/plugin.ts
@@ -27,6 +27,7 @@ import {
createMarkdownToVueRenderFn,
type MarkdownCompileResult
} from './markdownToVue'
+import { assetsBasePlugin } from './plugins/assetsBasePlugin'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin'
@@ -129,7 +130,9 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown ?? {},
- config.base,
+ // the site base, not the vite base: the ssr build runs under the
+ // sentinel, and one md singleton serves both builds
+ site.base,
lastUpdated ?? false,
cleanUrls ?? false,
siteConfig
@@ -148,6 +151,7 @@ export async function createVitePressPlugin(
!!site.themeConfig?.algolia, // legacy
__CARBON__: !!site.themeConfig?.carbonAds,
__ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir),
+ __ASSETS_BASE__: JSON.stringify(siteConfig.assetsBase ?? ''),
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG
},
optimizeDeps: {
@@ -452,6 +456,8 @@ export async function createVitePressPlugin(
hmrFix,
webFontsPlugin(siteConfig.useWebFonts),
...(userViteConfig?.plugins || []),
+ // must stay after the user plugins; see assetsBasePlugin
+ ...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []),
await localSearchPlugin(siteConfig),
staticDataPlugin,
await dynamicRoutesPlugin(siteConfig)
diff --git a/src/node/plugins/assetsBasePlugin.ts b/src/node/plugins/assetsBasePlugin.ts
new file mode 100644
index 00000000..2489244e
--- /dev/null
+++ b/src/node/plugins/assetsBasePlugin.ts
@@ -0,0 +1,31 @@
+import type { Plugin } from 'vite'
+
+import type { SiteConfig } from '../config'
+
+/**
+ * Routes built asset URLs through `assetsBase`, chaining behind any user
+ * renderBuiltUrl. Plain strings only: a `{ runtime }` return would execute
+ * at module scope in the Node SSR bundle, and is an error in CSS.
+ */
+export function assetsBasePlugin(config: SiteConfig): Plugin {
+ return {
+ name: 'vitepress:assets-base',
+ // 'post', plus a position after the user plugins in plugin.ts: the
+ // config hook must run after theirs to chain behind (not under) their
+ // renderBuiltUrl
+ enforce: 'post',
+ config(userConfig, env) {
+ if (env.command !== 'build') return
+ const userHook = userConfig.experimental?.renderBuiltUrl
+ return {
+ experimental: {
+ renderBuiltUrl(filename, ctx) {
+ const userResult = userHook?.(filename, ctx)
+ if (userResult !== undefined) return userResult
+ if (ctx.type === 'asset') return config.assetsBase! + filename
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts
index 3bd154ce..b91ad0a1 100644
--- a/src/node/plugins/localSearchPlugin.ts
+++ b/src/node/plugins/localSearchPlugin.ts
@@ -127,7 +127,9 @@ export async function localSearchPlugin(
function getDocId(file: string) {
let relFile = slash(path.relative(siteConfig.srcDir, file))
relFile = siteConfig.rewrites.map[relFile] || relFile
- let id = slash(path.join(siteConfig.site.base, relFile))
+ // site-relative — the search box applies the runtime base on use, so
+ // the same index works for absolute and relative bases
+ let id = '/' + relFile
id = id.replace(/(^|\/)index\.md$/, '$1')
id = id.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html')
return id
diff --git a/src/node/plugins/rewritesPlugin.ts b/src/node/plugins/rewritesPlugin.ts
index 23153244..9d597d25 100644
--- a/src/node/plugins/rewritesPlugin.ts
+++ b/src/node/plugins/rewritesPlugin.ts
@@ -1,6 +1,7 @@
import { compile, match } from 'path-to-regexp'
import type { Plugin } from 'vite'
+import { isRelativeBase } from '../shared'
import type { SiteConfig, UserConfig } from '../siteConfig'
export function resolveRewrites(
@@ -51,12 +52,14 @@ export const rewritesPlugin = (config: SiteConfig): Plugin => {
return {
name: 'vitepress:rewrites',
configureServer(server) {
+ // dev always serves at the root when the base is relative
+ const base = isRelativeBase(config.site.base) ? '/' : config.site.base
// dev rewrite
server.middlewares.use((req, _res, next) => {
if (req.url) {
const page = decodeURI(req.url)
.replace(/[?#].*$/, '')
- .slice(config.site.base.length)
+ .slice(base.length)
if (config.rewrites.inv[page]) {
req.url = req.url.replace(
diff --git a/src/node/serve/serve.ts b/src/node/serve/serve.ts
index 6ed7704c..d41b9a4c 100644
--- a/src/node/serve/serve.ts
+++ b/src/node/serve/serve.ts
@@ -5,11 +5,13 @@ import compression from '@polka/compression'
import polka, { type IOptions } from 'polka'
import sirv from 'sirv'
-import { resolveConfig } from '../config'
+import { normalizeAssetsBase, resolveConfig } from '../config'
+import { EXTERNAL_URL_RE, isRelativeBase } from '../shared'
import { readFile } from '../utils/fs'
export interface ServeOptions {
base?: string
+ assetsBase?: string
root?: string
port?: number
}
@@ -17,15 +19,34 @@ export interface ServeOptions {
export async function serve(options: ServeOptions = {}) {
const port = options.port ?? 4173
const config = await resolveConfig(options.root, 'serve', 'production')
- const base = (options?.base ?? config?.site?.base ?? '').replace(
- /^\/+|\/+$/g,
- ''
- )
+
+ const assetsBase =
+ typeof options.assetsBase === 'string'
+ ? normalizeAssetsBase(options.assetsBase)
+ : config.assetsBase
+
+ let rawBase =
+ (typeof options.base === 'string' ? options.base : undefined) ??
+ config?.site?.base ??
+ '/'
+ if (isRelativeBase(rawBase)) {
+ // a relative base works at any mount point; serve it at the root
+ rawBase = '/'
+ } else if (EXTERNAL_URL_RE.test(rawBase)) {
+ rawBase = new URL(rawBase, 'http://a.com').pathname
+ }
+ const base = rawBase.replace(/^\/+|\/+$/g, '')
const notAnAsset = (pathname: string) =>
!pathname.includes(`/${config.assetsDir}/`)
const notFound = await readFile(path.resolve(config.outDir, './404.html'))
const onNoMatch: IOptions['onNoMatch'] = (req, res) => {
+ if (base && req.path === '/') {
+ res.statusCode = 302
+ res.setHeader('location', `/${base}/`)
+ res.end()
+ return
+ }
res.statusCode = 404
if (notAnAsset(req.path)) res.write(notFound)
res.end()
@@ -45,9 +66,31 @@ export async function serve(options: ServeOptions = {}) {
}
})
- const app = base
- ? polka({ onNoMatch }).use(base, compress, serve)
- : polka({ onNoMatch }).use(compress, serve)
+ const app = polka({ onNoMatch })
+
+ if (assetsBase) {
+ if (EXTERNAL_URL_RE.test(assetsBase)) {
+ config.logger.info(
+ `assetsBase is external (${assetsBase}) — assets will be ` +
+ `requested from that URL, not from this preview server.`
+ )
+ } else {
+ // mirror the asset subtree at the configured prefix
+ const assetsPath = `${assetsBase}${config.assetsDir}`.replace(/\/+$/, '')
+ app.use(
+ assetsPath,
+ compress,
+ sirv(path.join(config.outDir, config.assetsDir), {
+ etag: true,
+ maxAge: 31536000,
+ immutable: true
+ })
+ )
+ }
+ }
+
+ if (base) app.use(base, compress, serve)
+ else app.use(compress, serve)
app.listen(port)
await once(app.server, 'listening')
diff --git a/src/node/server.ts b/src/node/server.ts
index 6f8708ff..6ad0d1af 100644
--- a/src/node/server.ts
+++ b/src/node/server.ts
@@ -1,6 +1,6 @@
import { createServer as createViteServer, type ServerOptions } from 'vite'
-import { resolveConfig, type SiteConfig } from './config'
+import { normalizeSiteBase, resolveConfig, type SiteConfig } from './config'
import { createVitePressPlugin } from './plugin'
export async function createServer(
@@ -12,7 +12,7 @@ export async function createServer(
config ??= await resolveConfig(root)
const { base, ...server } = serverOptions
- config.site.base = base ?? config.site.base
+ if (typeof base === 'string') config.site.base = normalizeSiteBase(base)
return createViteServer({
root: config.srcDir,
diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts
index 6538395e..6574d347 100644
--- a/src/node/siteConfig.ts
+++ b/src/node/siteConfig.ts
@@ -92,7 +92,9 @@ export interface UserConfig<
*/
extends?: RawConfigExports
/**
- * The base URL the site is deployed at. Must start and end with a slash.
+ * The base URL the site is deployed at. Usually starts and ends with a
+ * slash. Use `'./'` to make page references relative to their own depth,
+ * so the output works at any subpath.
* @default '/'
*/
base?: string
@@ -118,6 +120,17 @@ export interface UserConfig<
* @default 'assets'
*/
assetsDir?: string
+ /**
+ * URL prefix the built assets (everything under `assetsDir`) are served
+ * from, e.g. a CDN. Must be an absolute URL, a protocol-relative URL, or
+ * a root-absolute path, and must mirror the layout of `outDir`: each URL
+ * is this prefix plus the file's output-relative path. Pages, `withBase`
+ * links, `public/` files, `hashmap.json` and `vp-icons.css` stay on
+ * `base`. A cross-origin prefix must send CORS headers, as the generated
+ * tags are marked `crossorigin`. Applies to builds and preview, not dev.
+ * @example 'https://cdn.example.com/'
+ */
+ assetsBase?: string
/**
* Directory for cache files, relative to the project root.
* @default './.vitepress/cache'
@@ -349,6 +362,10 @@ export interface SiteConfig extends Pick<
* Directory for assets within the build output.
*/
assetsDir: string
+ /**
+ * URL prefix for built assets, normalized to end with a slash.
+ */
+ assetsBase?: string
/**
* Absolute path of the cache directory.
*/
diff --git a/src/shared/shared.ts b/src/shared/shared.ts
index c5f36a60..003dfaff 100644
--- a/src/shared/shared.ts
+++ b/src/shared/shared.ts
@@ -30,6 +30,35 @@ export type {
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
export const APPEARANCE_KEY = 'vitepress-theme-appearance'
+/**
+ * Placeholder base used by SSR when base is relative.
+ * It is prepended to emitted URLs, then replaced with the ../ prefix
+ * from each file back to the site root.
+ */
+export const RELATIVE_BASE_SENTINEL = '/__VP_BASE__/'
+
+export function isRelativeBase(base: string): boolean {
+ return base === './'
+}
+
+/**
+ * The ../-prefix that leads from `relativePath`'s directory back to the
+ * site root ('./' for root-level pages).
+ */
+export function relativePathToRoot(relativePath: string): string {
+ const depth = relativePath.split('/').length - 1
+ return depth ? '../'.repeat(depth) : './'
+}
+
+/**
+ * Join two paths, collapsing slash collisions but keeping the `//` that
+ * follows a protocol.
+ */
+export function joinPath(base: string, path: string): string {
+ const protocol = /^(?:[a-z]+:)?\/\//i.exec(base)?.[0] ?? ''
+ return protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/')
+}
+
export const VP_SOURCE_KEY = '[VP_SOURCE]'
const UnpackStackView = Symbol('stack-view:unpack')
diff --git a/tsconfig.client.json b/tsconfig.client.json
index 0e47095d..d625950b 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -1,5 +1,5 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["./client.d.ts"],
diff --git a/tsconfig.json b/tsconfig.json
index ffcbb947..762d0a40 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,3 +1,19 @@
{
- "extends": "./tsconfig.base.json"
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "paths": {
+ "vitepress": ["./src/client/index.ts"],
+ "vitepress/theme": ["./types/default-theme.d.ts"],
+ "@siteData": ["./src/client/shims.d.ts"],
+ "@theme/index": ["./src/client/shims.d.ts"],
+ "@localSearchIndex": ["./src/client/shims.d.ts"]
+ }
+ },
+ "include": [
+ "src",
+ "types",
+ "scripts",
+ "shared-globals.d.ts",
+ "tsdown.config.ts"
+ ]
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 3de1cdef..36330db4 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -1,5 +1,5 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": ["node"]
diff --git a/tsconfig.shared.json b/tsconfig.shared.json
index 0a9eb997..0956fe06 100644
--- a/tsconfig.shared.json
+++ b/tsconfig.shared.json
@@ -1,5 +1,5 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": [],
diff --git a/types/shared.d.ts b/types/shared.d.ts
index c1e280b2..02878f10 100644
--- a/types/shared.d.ts
+++ b/types/shared.d.ts
@@ -168,7 +168,8 @@ export interface Header {
*/
export interface SiteData {
/**
- * The base URL the site is deployed at.
+ * The base URL the site is deployed at, or `'./'` when each page
+ * references the site relative to its own depth.
* @default '/'
*/
base: string
@@ -586,6 +587,13 @@ export interface MarkdownEnv {
* Whether clean URLs are enabled.
*/
cleanUrls: boolean
+ /**
+ * Whether the rendered HTML is emitted at `relativePath`, so site-absolute
+ * links may be rewritten relative to it. Content loaders must not set it:
+ * their HTML is embedded in other pages.
+ * @internal
+ */
+ relativizeUrls?: boolean
/**
* The URLs of the links collected from the page for the dead link check.
*/