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..60657877
--- /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..e217fce5
--- /dev/null
+++ b/__tests__/base/emit.test.ts
@@ -0,0 +1,171 @@
+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__')
+ }
+ })
+})
+
+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/src/client/theme-default/components/VPLocalSearchBox.vue b/src/client/theme-default/components/VPLocalSearchBox.vue
index 046c849b..ac1533b2 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,
@@ -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..80bb0cfa 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,
@@ -72,10 +75,32 @@ export async function renderPage(
const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath)
+ const relativeBase = isRelativeBase(siteData.base)
+ // under a relative base every page addresses the site root through its
+ // own ../-prefix; otherwise this is just the configured base
+ const pageBase = relativeBase ? relativePathToRoot(page) : siteData.base
+
+ const userBuiltUrl = config.vite?.experimental?.renderBuiltUrl
+ const htmlPath = page.replace(/\.md$/, '.html')
+ const assetUrl = (file: string) => {
+ const userResult = userBuiltUrl?.(file, {
+ type: 'asset',
+ hostType: 'html',
+ hostId: htmlPath,
+ ssr: false
+ })
+ if (typeof userResult === 'string') return userResult
+ return (config.assetsBase ?? pageBase) + file
+ }
+ const assetsCrossOrigin =
+ config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase)
+ ? ' crossorigin'
+ : ''
+
const title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description
const stylesheetLink = cssChunk
- ? ``
+ ? ``
: ''
let preloadLinks =
@@ -107,7 +132,12 @@ 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),
+ // keep the prefetch/preload request mode aligned with the later
+ // cross-origin module fetch, or the cache entry is not reused
+ ...(assetsCrossOrigin && !EXTERNAL_URL_RE.test(file)
+ ? { crossorigin: '' }
+ : {})
}
])
@@ -153,7 +183,7 @@ export async function renderPage(
force: true
})
} else {
- inlinedScript = ``
+ inlinedScript = ``
}
}
}
@@ -176,12 +206,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)}
@@ -206,7 +243,13 @@ export async function renderPage(
content,
assets
})
- await writeFile(htmlFileName, transformedHtml || html)
+ let finalHtml = transformedHtml || html
+ if (relativeBase) {
+ // last step, after transformHtml, so sentinel urls a transform injects
+ // (e.g. from the `assets` array) are relativized too
+ finalHtml = finalHtml.replaceAll(RELATIVE_BASE_SENTINEL, pageBase)
+ }
+ await writeFile(htmlFileName, finalHtml)
}
async function resolvePageImports(
diff --git a/src/node/config.ts b/src/node/config.ts
index 093b455b..ff548a67 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,28 @@ 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 {
+ const 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`
+ )
+ }
+ 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 +166,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 +405,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..52422607 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,18 @@ export const linkPlugin = (
// append base to internal (non-relative) urls
if (hrefAttr[1].startsWith('/')) {
- hrefAttr[1] = `${base}${hrefAttr[1]}`.replace(/\/+/g, '/')
+ if (isRelativeBase(base)) {
+ // resolve site-absolute links relative to this page so the
+ // output is identical in both builds and correct at any mount
+ // point; without a page context (content loaders) the
+ // site-absolute form is the only meaningful one — keep it
+ if (env.relativePath != null) {
+ hrefAttr[1] =
+ relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1)
+ }
+ } else {
+ hrefAttr[1] = joinPath(base, hrefAttr[1])
+ }
}
}
if (frag) {
@@ -98,10 +112,14 @@ export const linkPlugin = (
) {
let url = hrefAttr[1]
+ // a relative base has no server guaranteed to resolve directory urls,
+ // so page links must point at the index.html file itself
+ 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 +134,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/plugin.ts b/src/node/plugin.ts
index af0f3502..fa80d0b5 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,10 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown ?? {},
- config.base,
+ // the site base, not config.base: the SSR build runs under the
+ // relative-base sentinel, but markdown must compile identically in
+ // both builds (they share one md singleton and one compile cache)
+ site.base,
lastUpdated ?? false,
cleanUrls ?? false,
siteConfig
@@ -148,6 +152,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 +457,8 @@ export async function createVitePressPlugin(
hmrFix,
webFontsPlugin(siteConfig.useWebFonts),
...(userViteConfig?.plugins || []),
+ // last so its config hook sees (and chains behind) any user renderBuiltUrl
+ ...(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..1b3c70e7
--- /dev/null
+++ b/src/node/plugins/assetsBasePlugin.ts
@@ -0,0 +1,32 @@
+import type { Plugin, UserConfig as ViteUserConfig } from 'vite'
+
+import type { SiteConfig } from '../config'
+
+export type RenderBuiltUrl = NonNullable<
+ NonNullable['renderBuiltUrl']
+>
+
+/**
+ * Routes built asset URLs through `assetsBase` via Vite's renderBuiltUrl,
+ * chaining behind any user-provided hook. Only plain-string returns are
+ * produced: {runtime} would poison the SSR bundle that pre-renders pages
+ * (it executes at module scope in Node) and errors in CSS.
+ */
+export function assetsBasePlugin(config: SiteConfig): Plugin {
+ return {
+ name: 'vitepress:assets-base',
+ 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..cca2c8a2 100644
--- a/src/node/serve/serve.ts
+++ b/src/node/serve/serve.ts
@@ -6,6 +6,7 @@ import polka, { type IOptions } from 'polka'
import sirv from 'sirv'
import { resolveConfig } from '../config'
+import { EXTERNAL_URL_RE, isRelativeBase } from '../shared'
import { readFile } from '../utils/fs'
export interface ServeOptions {
@@ -17,15 +18,26 @@ 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,
- ''
- )
+
+ let rawBase = options?.base ?? config?.site?.base ?? '/'
+ if (isRelativeBase(rawBase)) {
+ // a relocatable build 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 +57,34 @@ 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 (config.assetsBase) {
+ if (EXTERNAL_URL_RE.test(config.assetsBase)) {
+ config.logger.info(
+ `assetsBase is external (${config.assetsBase}) — assets will be ` +
+ `requested from that URL, not from this preview server.`
+ )
+ } else {
+ // mirror the asset subtree at the configured prefix
+ const assetsPath = `${config.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..91f7e393 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 (base != null) config.site.base = normalizeSiteBase(base)
return createViteServer({
root: config.srcDir,
diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts
index 6538395e..72320cd0 100644
--- a/src/node/siteConfig.ts
+++ b/src/node/siteConfig.ts
@@ -93,6 +93,9 @@ export interface UserConfig<
extends?: RawConfigExports
/**
* The base URL the site is deployed at. Must start and end with a slash.
+ * Can also be `'./'` to build a relocatable site whose pages reference
+ * everything relatively, so the output works from any subpath (IPFS,
+ * archives) and stays browsable over `file://`.
* @default '/'
*/
base?: string
@@ -118,6 +121,18 @@ export interface UserConfig<
* @default 'assets'
*/
assetsDir?: string
+ /**
+ * URL prefix the built assets (everything under `assetsDir`) are served
+ * from, e.g. a CDN. The emitted asset URL is this prefix joined with the
+ * output-relative file path, so the target should mirror the layout of
+ * `outDir` (`https://cdn.example.com/` serves `outDir/assets/*` at
+ * `https://cdn.example.com/assets/*`). Must be an absolute URL, a
+ * protocol-relative URL, or a root-absolute path; a trailing slash is
+ * appended if missing. HTML pages, `withBase` links, `public/` files,
+ * `hashmap.json` and `vp-icons.css` stay on `base`. Applied only to
+ * production builds and preview, never to dev.
+ */
+ assetsBase?: string
/**
* Directory for cache files, relative to the project root.
* @default './.vitepress/cache'
@@ -349,6 +364,11 @@ export interface SiteConfig extends Pick<
* Directory for assets within the build output.
*/
assetsDir: string
+ /**
+ * Normalized URL prefix for built assets (ends with a slash), when
+ * configured.
+ */
+ assetsBase?: string
/**
* Absolute path of the cache directory.
*/
diff --git a/src/shared/shared.ts b/src/shared/shared.ts
index c5f36a60..2d0c4765 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'
+// stand-in base for the SSR build under a relative base — every URL the SSR
+// bundle base-joins carries it into the rendered HTML, where renderPage
+// replaces it with the page's own ../-prefix as the final build step
+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 by resolving the slash collision, preserving the double
+ * slash of an absolute or protocol-relative URL base.
+ */
+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/types/shared.d.ts b/types/shared.d.ts
index c1e280b2..b60fe74f 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 './' for a relocatable build
+ * whose pages reference everything relative to their own depth.
* @default '/'
*/
base: string