feat: support relative base ('./') and assetsBase (CDN prefix)

Relative base makes builds relocatable: pages reference everything through
their own ../-prefix (SSR renders under a sentinel base that renderPage
replaces per page; markdown links compile page-relative in both builds), a
per-page inline script recovers the absolute site root at runtime for the
router, chunk resolution, search and hashmap fallback. Works from any
subpath (IPFS path gateways) and degrades to a styled, navigable static
site over file://.

assetsBase serves everything under assetsDir from a URL prefix (CDN):
plain-string renderBuiltUrl on both builds chained behind any user hook,
the SSR-assembled tags (stylesheet/preload/script/metadata/font) resolved
through the same prefix with crossorigin when cross-origin, and page-chunk
fetches via an __ASSETS_BASE__ define. Pages, withBase links, public/,
hashmap.json and vp-icons.css stay on the site origin.

Also: --base/--assetsBase CLI normalization, protocol-safe joinPath
(fixes the https:/ collapse), preview support for relative and same-origin
assetsBase plus a root redirect, and site-relative local-search doc ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5406/head
Divyansh Singh 2 weeks ago
parent 60f656b0ec
commit 826527709d

3
.gitignore vendored

@ -17,3 +17,6 @@ pnpm-global
TODOs.md
*.timestamp-*.mjs
.claude
# base fixture builds
__tests__/base/fixture/.vitepress/dist-*

@ -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([])
})
})

@ -0,0 +1,2 @@
export const SUB_PREFIX = '/ipfs/QmRelocatableTest123/'
export const ALT_PREFIX = '/some/other/place/'

@ -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(
`<script type="module" src="${cdn()}assets/app\\.[\\w-]+\\.js" crossorigin>`
)
)
expect(html).toMatch(
new RegExp(`src="${cdn()}assets/chunks/metadata\\.[\\w-]+\\.js"`)
)
expect(html).toMatch(new RegExp(`href="${cdn()}assets/style\\.[\\w-]+\\.css"`))
expect(html).toMatch(
new RegExp(
`<link rel="modulepreload" href="${cdn()}assets/chunks/[^"]+" crossorigin="">`
)
)
expect(html).toMatch(
new RegExp(`rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"`)
)
})
test('pages, links and root-level files stay on the site origin', () => {
const html = read('cdn', 'index.html')
expect(html).toContain('href="/vp-icons.css"')
expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('src="/logo.png"')
expect(read('cdn', 'hashmap.json')).toBeTruthy()
})
test('hashed content assets move to the cdn', () => {
const html = read('cdn', 'index.html')
expect(html).toMatch(new RegExp(`src="${cdn()}assets/photo\\.[\\w-]+\\.png"`))
})
test('fonts referenced from css move to the cdn', () => {
const cssFile = walk(dist('cdn', 'assets')).find((f) => f.endsWith('.css'))!
expect(readFileSync(cssFile, 'utf-8')).toContain(`url(${cdn()}assets/inter-`)
})
})
describe('mpa + relative base emit', () => {
test('no sentinel leaks anywhere', () => {
for (const file of walk(dist('mpa'))) {
if (!/\.(html|css|js)$/.test(file)) continue
expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__')
}
})
test('css urls are relative to the css file', () => {
const cssFile = walk(dist('mpa', 'assets')).find((f) => f.endsWith('.css'))!
expect(readFileSync(cssFile, 'utf-8')).toContain('url(../assets/inter-')
})
test('pages reference assets by depth', () => {
expect(read('mpa', 'sub/page.html')).toMatch(
/href="\.\.\/assets\/style\.[\w-]+\.css"/
)
})
})
describe('plain base emit is unchanged', () => {
test('root-absolute urls and no runtime-root script', () => {
const html = read('plain', 'index.html')
expect(html).toMatch(/href="\/assets\/style\.[\w-]+\.css"/)
expect(html).toMatch(/src="\/assets\/app\.[\w-]+\.js"><\/script>/)
expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('href="/sub/"')
expect(html).toContain('src="/logo.png"')
expect(html).not.toContain('__VP_SITE_ROOT__')
expect(html).not.toContain('crossorigin>')
})
})

@ -0,0 +1,29 @@
import { defineConfig } from 'vitepress'
const mode = process.env.VP_TEST_MODE || 'relative'
export default defineConfig({
title: 'Base Fixture',
description: 'Fixture site for base/assetsBase behavior',
base: mode === 'plain' || mode === 'cdn' ? '/' : './',
assetsBase:
mode === 'cdn' ? `http://localhost:${process.env.VP_CDN_PORT}/` : undefined,
mpa: mode === 'mpa',
outDir: `.vitepress/dist-${mode}`,
cleanUrls: false,
rewrites: { 'src-moved.md': 'moved/target.md' },
vite: {
logLevel: 'error',
// keep the tiny fixture images as real emitted assets
build: { assetsInlineLimit: 0 }
},
themeConfig: {
nav: [{ text: 'Guide', link: '/sub/page' }],
sidebar: [
{ text: 'Sub', link: '/sub/page' },
{ text: 'Deep', link: '/sub/deep/page2' },
{ text: 'Moved', link: '/moved/target' }
],
...(mode === 'mpa' ? {} : { search: { provider: 'local' } })
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@ -0,0 +1,13 @@
# Home
![logo](/logo.png)
![photo](/img/photo.png)
[to sub](/sub/page)
[to dir](/sub/)
[zip](/file.zip)
[moved](/moved/target)

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@ -0,0 +1,3 @@
# Moved page
Rewritten target.

@ -0,0 +1,7 @@
# Deep page
[up](/sub/page)
## Deep heading
The xylophone paragraph for search.

@ -0,0 +1,3 @@
# Sub index
Index of sub.

@ -0,0 +1,15 @@
# Sub page
![logo again](/logo.png)
[home](/)
[deep](/sub/deep/page2)
[hash](#local-anchor)
[external](https://example.com/x)
## Local anchor
Body text here.

@ -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<TestPage> {
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<void> {
await page.waitForSelector('#app .Layout')
await page.waitForFunction(
() => (document.querySelector('#app') as any)?.__vue_app__ !== undefined
)
}

@ -0,0 +1,12 @@
{
"name": "tests-base",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"watch": "DEBUG=1 vitest"
},
"devDependencies": {
"vitepress": "workspace:*"
}
}

@ -0,0 +1,56 @@
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { newPage, type TestPage } from './helpers'
const dist = resolve(
fileURLToPath(import.meta.url),
'..',
'fixture/.vitepress/dist-relative'
)
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
// no hydration over file:// — module scripts are CORS-blocked from disk in
// every engine — but the pre-rendered site must stay styled and navigable
describe('relative base opened over file://', () => {
test('pages render styled with working images', async () => {
await t.page.goto('file://' + join(dist, '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('file://' + join(dist, '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('file://' + join(dist, 'moved/target.html'))
})
test('the root page reaches nested pages', async () => {
await t.page.goto('file://' + join(dist, 'index.html'))
await t.page.click('.vp-doc a[href="./sub/index.html"]')
expect(await t.page.textContent('h1')).toContain('Sub index')
})
})

@ -0,0 +1,91 @@
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 so a passing test proves navigation stayed client-side
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([])
})
})

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"include": ["**/*"],
"exclude": ["fixture/.vitepress/dist-*", "fixture/.vitepress/cache"]
}

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
const timeout = 60_000
export default defineConfig({
test: {
globalSetup: ['vitestGlobalSetup.ts'],
testTimeout: timeout,
hookTimeout: timeout,
teardownTimeout: timeout,
globals: true,
// suites share fixture builds but not servers/pages; keep them serial
fileParallelism: false
}
})

@ -0,0 +1,119 @@
import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import { extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import getPort from 'get-port'
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<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.zip': 'application/zip'
}
function serveStatic(
port: number,
mounts: [prefix: string, root: string][],
cors: boolean
): Promise<Server> {
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<string, string> = {
'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(port, () => r(server)))
}
let browserServer: BrowserServer
let servers: Server[] = []
export async function setup() {
const [subPort, pagesPort, cdnPort] = await Promise.all([
getPort(),
getPort(),
getPort()
])
// each flavor builds in its own process: the markdown renderer is a
// process-wide singleton, so sequential in-process builds would leak the
// first build's 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(
subPort,
[
[SUB_PREFIX, dist('relative')],
[ALT_PREFIX, dist('relative')]
],
false
),
await serveStatic(pagesPort, [['/', dist('cdn')]], false),
await serveStatic(cdnPort, [['/', dist('cdn')]], true)
]
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(subPort)
process.env['PAGES_PORT'] = String(pagesPort)
process.env['VP_CDN_PORT'] = String(cdnPort)
}
export async function teardown() {
await browserServer.close()
await Promise.all(
servers.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
)
)
)
}

@ -1,73 +1,39 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import { mergeConfig, type UserConfig } from 'node/config'
import { normalizeAssetsBase, normalizeSiteBase } from 'node/config'
describe('node/config', () => {
test('merges markdown hooks from extended configs', async () => {
const calls: string[] = []
const md = {} as MarkdownItAsync
const merged = mergeConfig<UserConfig, UserConfig>(
{
markdown: {
lineNumbers: true,
preConfig() {
calls.push('base-pre')
},
config() {
calls.push('base')
}
}
},
{
markdown: {
attrs: {
allowed: ['id']
},
async preConfig() {
calls.push('extended-pre')
},
async config() {
calls.push('extended')
}
}
}
)
expect(merged.markdown?.lineNumbers).toBe(true)
expect(merged.markdown?.attrs).toEqual({
allowed: ['id']
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/')
})
await merged.markdown?.preConfig?.(md)
await merged.markdown?.config?.(md)
test('normalizes relative forms to ./', () => {
expect(normalizeSiteBase('.')).toBe('./')
expect(normalizeSiteBase('./')).toBe('./')
})
expect(calls).toEqual(['base-pre', 'extended-pre', 'base', 'extended'])
test('rejects relative bases with a subpath', () => {
expect(() => normalizeSiteBase('./docs/')).toThrow(/relative base/)
expect(() => normalizeSiteBase('../x')).toThrow(/relative base/)
})
})
test('keeps one-sided markdown hooks when the other config omits them', async () => {
const calls: string[] = []
const md = {} as MarkdownItAsync
const merged = mergeConfig<UserConfig, UserConfig>(
{
markdown: {
preConfig() {
calls.push('base-pre')
}
}
},
{
markdown: {
config() {
calls.push('extended')
}
}
}
)
await merged.markdown?.preConfig?.(md)
await merged.markdown?.config?.(md)
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/')
})
expect(calls).toEqual(['base-pre', 'extended'])
test('rejects relative values', () => {
expect(() => normalizeAssetsBase('./cdn/')).toThrow(/assetsBase/)
expect(() => normalizeAssetsBase('cdn/')).toThrow(/assetsBase/)
})
})
})

@ -62,3 +62,69 @@ 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', ...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('without a page context absolute links are preserved', async () => {
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"')
})
})

@ -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('../../')
})
})
})

@ -56,8 +56,9 @@
"build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check",
"build:check": "publint && attw --pack . --profile esm-only",
"typecheck": "tsc -p tsconfig.shared.json && vue-tsc -p tsconfig.client.json && tsc -p tsconfig.node.json",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'",
"test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init|base)$/'",
"test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && tsc -p __tests__/base && vue-tsc -p docs",
"test:base": "pnpm -F=tests-base test",
"test:unit": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",

@ -268,6 +268,12 @@ importers:
specifier: ^9.1.0
version: 9.1.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
__tests__/base:
devDependencies:
vitepress:
specifier: workspace:*
version: link:../..
__tests__/e2e:
devDependencies:
vitepress:

@ -3,6 +3,7 @@
import { onMounted, onUnmounted, watch } from 'vue'
import { EXTERNAL_URL_RE } from '../../shared'
import { useRoute } from '../router'
import { inBrowser, pathToFile } from '../utils'
@ -12,6 +13,9 @@ const createLink = () => document.createElement('link')
const viaDOM = (url: string) => {
const link = createLink()
link.rel = `prefetch`
// chunks on an external assetsBase are later fetched in CORS mode; the
// prefetch must match or the cache entry is not reused
if (EXTERNAL_URL_RE.test(url)) link.crossOrigin = ''
link.href = url
document.head.appendChild(link)
}

@ -4,7 +4,7 @@ import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload, Route } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared'
import { siteDataRef } from './data'
import { inBrowser, withBase } from './utils'
import { inBrowser, runtimeBase, withBase } from './utils'
export interface Router {
/**
@ -123,7 +123,7 @@ export function createRouter(
if (inBrowser) {
nextTick(() => {
let actualPathname =
siteDataRef.value.base +
runtimeBase() +
__pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1')
if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) {
@ -153,7 +153,7 @@ export function createRouter(
// the updated pageToHash map and fetch again.
if (!isRetry) {
try {
const res = await fetch(siteDataRef.value.base + 'hashmap.json')
const res = await fetch(runtimeBase() + 'hashmap.json')
;(window as any).__VP_HASH_MAP__ = await res.json()
await loadPage(href, { scrollPosition, isRetry: true, initialLoad })
return
@ -168,7 +168,7 @@ export function createRouter(
? route.path
.replace(/(^|\/)$/, '$1index')
.replace(/(\.html)?$/, '.md')
.slice(siteDataRef.value.base.length)
.slice(runtimeBase().length)
: '404.md'
route.data = { ...notFoundPageData, relativePath }
syncRouteQueryAndHash(targetLoc)
@ -318,7 +318,7 @@ function shouldHotReload(payload: PageDataPayload): boolean {
const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1')
const locationPath = location.pathname
.replace(/(?:(^|\/)index)?\.html$/, '')
.slice(siteDataRef.value.base.length - 1)
.slice(runtimeBase().length - 1)
return payloadPath === locationPath
}

@ -3,19 +3,39 @@ import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue'
import {
EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL,
inBrowser,
isRelativeBase,
joinPath,
sanitizeFileName,
type Awaitable
} from '../shared'
import { siteDataRef } from './data'
export { escapeHtml as _escapeHtml, inBrowser } from '../shared'
export { joinPath } from '../shared'
let resolvedBase: string | undefined
/**
* Join two paths by resolving the slash collision.
* The base the site is actually served under. Equals the configured base,
* except for a relative base ('./'), which cannot be known at build time:
* there it is recovered from the per-page `__VP_SITE_ROOT__` inline script
* in the browser, is '/' in dev (dev always serves at the root), and is the
* build sentinel during SSR so rendered URLs can be relativized per page.
*/
export function joinPath(base: string, path: string) {
return `${base}${path}`.replace(/\/+/g, '/')
export function runtimeBase(): string {
if (resolvedBase === undefined) {
const base = siteDataRef.value.base
if (!isRelativeBase(base)) return (resolvedBase = base)
if (!inBrowser) return (resolvedBase = RELATIVE_BASE_SENTINEL)
if (import.meta.env.DEV) return (resolvedBase = '/')
const root = (window as any).__VP_SITE_ROOT__
resolvedBase = root
? decodeURIComponent(new URL(root, location.href).pathname)
: '/'
}
return resolvedBase
}
/**
@ -24,7 +44,7 @@ export function joinPath(base: string, path: string) {
export function withBase(path: string) {
return EXTERNAL_URL_RE.test(path) || !path.startsWith('/')
? path
: joinPath(siteDataRef.value.base, path)
: joinPath(runtimeBase(), path)
}
/**
@ -42,7 +62,9 @@ export function pathToFile(path: string) {
// the path conversion scheme.
// /foo/bar.html -> ./foo_bar.md
if (inBrowser) {
const base = import.meta.env.BASE_URL
const base = runtimeBase()
if (pagePath + '/' === base) pagePath = base
if (!pagePath.startsWith(base)) return null
pagePath =
sanitizeFileName(
pagePath.slice(base.length).replace(/\//g, '_') || 'index'
@ -57,7 +79,7 @@ export function pathToFile(path: string) {
pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()]
}
if (!pageHash) return null
pagePath = `${base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
} else {
// ssr build uses much simpler name mapping
pagePath = `./${sanitizeFileName(

@ -9,6 +9,7 @@ declare const __ALGOLIA__: boolean
declare const __CARBON__: boolean
declare const __VUE_PROD_DEVTOOLS__: boolean
declare const __ASSETS_DIR__: string
declare const __ASSETS_BASE__: string
declare module '@siteData' {
import type { SiteData } from 'vitepress'

@ -2,18 +2,26 @@
import { useRoute } from 'vitepress'
import { computed } from 'vue'
import { runtimeBase } from '../../app/utils'
import { isRelativeBase } from '../../shared'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout'
import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue'
const { theme } = useData()
const { theme, site } = useData()
const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useLayout()
const pageName = computed(() =>
route.path.replace(/[./]+/g, '_').replace(/_html$/, '')
)
const pageName = computed(() => {
// under a relative base the mount point is unknowable at build time, so
// the page class must be derived from the site-relative path to stay
// identical between SSR and any hydration location
const path = isRelativeBase(site.value.base)
? '/' + route.path.slice(runtimeBase().length)
: route.path
return path.replace(/[./]+/g, '_').replace(/_html$/, '')
})
</script>
<template>

@ -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"
>
<a
:href="p.id"
:href="withBase(p.id)"
class="result"
:class="{
selected: selectedIndex === index

@ -1,6 +1,6 @@
import { withBase } from 'vitepress'
import { isExternal, treatAsHtml } from '../../shared'
import { isExternal, isRelativeBase, treatAsHtml } from '../../shared'
import { useData } from '../composables/data'
export function throttleAndDebounce(fn: () => void, delay: number): () => void {
@ -46,7 +46,7 @@ export function normalizeLink(url: string): string {
const { site } = useData()
const normalizedPath =
let normalizedPath =
pathname.endsWith('/') || pathname.endsWith('.html')
? url
: url.replace(
@ -57,6 +57,16 @@ export function normalizeLink(url: string): string {
)}${search}${hash}`
)
if (
isRelativeBase(site.value.base) &&
!site.value.cleanUrls &&
pathname.endsWith('/')
) {
// file:// has no directory index; the router strips index.html back
// out of the address bar on navigation
normalizedPath = normalizedPath.replace(pathname, pathname + 'index.html')
}
return withBase(normalizedPath)
}

@ -9,10 +9,22 @@ import pMap from 'p-map'
import { packageDirectory } from 'package-directory'
import type { BuildOptions, Rolldown } from 'vite'
import { resolveConfig, type SiteConfig } from '../config'
import {
normalizeAssetsBase,
normalizeSiteBase,
resolveConfig,
type SiteConfig
} from '../config'
import { clearCache } from '../markdownToVue'
import type { PageMeta } from '../plugin'
import { slash, type Awaitable, type HeadConfig } from '../shared'
import {
EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL,
isRelativeBase,
slash,
type Awaitable,
type HeadConfig
} from '../shared'
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
import { logVersion } from '../utils/logVersion'
import { nativeImport } from '../utils/nativeImport'
@ -27,6 +39,7 @@ export async function build(
root?: string,
buildOptions: BuildOptions & {
base?: string
assetsBase?: string
mpa?: string
onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable<void>
} = {}
@ -46,10 +59,15 @@ export async function build(
const unlinkVue = await linkVue()
if (buildOptions.base) {
siteConfig.site.base = buildOptions.base
siteConfig.site.base = normalizeSiteBase(buildOptions.base)
delete buildOptions.base
}
if (buildOptions.assetsBase) {
siteConfig.assetsBase = normalizeAssetsBase(buildOptions.assetsBase)
delete buildOptions.assetsBase
}
if (buildOptions.mpa) {
siteConfig.mpa = true
delete buildOptions.mpa
@ -152,11 +170,17 @@ async function render(
chunk.type === 'asset' && chunk.fileName.endsWith('.css')
)
const assetsUrlBase =
siteConfig.assetsBase ??
(isRelativeBase(siteConfig.site.base)
? RELATIVE_BASE_SENTINEL
: siteConfig.site.base)
// prettier-ignore
const assets = resultOutput.filter(
(chunk): chunk is Rolldown.OutputAsset =>
chunk.type === 'asset' && !chunk.fileName.endsWith('.css')
).map((asset) => siteConfig.site.base + asset.fileName)
).map((asset) => assetsUrlBase + asset.fileName)
// ----
@ -255,13 +279,22 @@ async function generateMetadataScript(
)
const resolvedMetadataFile = path.join(config.outDir, metadataFile)
const metadataFileURL = slash(`${config.site.base}${metadataFile}`)
const urlBase =
config.assetsBase ??
(isRelativeBase(config.site.base)
? RELATIVE_BASE_SENTINEL
: config.site.base)
const metadataFileURL = urlBase + slash(metadataFile)
const crossorigin =
config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase)
? ' crossorigin'
: ''
await mkdir(path.dirname(resolvedMetadataFile), { recursive: true })
await writeFile(resolvedMetadataFile, metadataContent)
return {
html: `<script type="module" src="${metadataFileURL}"></script>`,
html: `<script type="module" src="${metadataFileURL}"${crossorigin}></script>`,
inHead: true
}
}

@ -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,

@ -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<ViteInlineConfig> => ({
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 }

@ -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
? `<link rel="preload stylesheet" href="${siteData.base}${cssChunk.fileName}" as="style">`
? `<link rel="preload stylesheet" href="${assetUrl(cssChunk.fileName)}" as="style">`
: ''
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 = `<script type="module" src="${siteData.base}${matchingChunk.fileName}"></script>`
inlinedScript = `<script type="module" src="${assetUrl(matchingChunk.fileName)}"${assetsCrossOrigin}></script>`
}
}
}
@ -176,12 +206,19 @@ export async function renderPage(
: `<meta name="description" content="${escapeHtml(description)}">`
}
<meta name="generator" content="VitePress v${version}">
${
// recovers the absolute site root at runtime; a classic inline script
// so it runs before any module resolves URLs
relativeBase && !config.mpa
? `<script>window.__VP_SITE_ROOT__=new URL("${pageBase}",location).href</script>`
: ''
}
${stylesheetLink}
<link rel="preload stylesheet" href="${siteData.base}vp-icons.css" as="style">
<link rel="preload stylesheet" href="${pageBase}vp-icons.css" as="style">
${metadataScript.inHead ? metadataScript.html : ''}
${
appChunk
? `<script type="module" src="${siteData.base}${appChunk.fileName}"></script>`
? `<script type="module" src="${assetUrl(appChunk.fileName)}"${assetsCrossOrigin}></script>`
: ''
}
${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(

@ -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<ThemeConfig> = (
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<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'> = {
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

@ -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)
}

@ -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)

@ -0,0 +1,32 @@
import type { Plugin, UserConfig as ViteUserConfig } from 'vite'
import type { SiteConfig } from '../config'
export type RenderBuiltUrl = NonNullable<
NonNullable<ViteUserConfig['experimental']>['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
}
}
}
}
}
}

@ -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

@ -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(

@ -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')

@ -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,

@ -93,6 +93,9 @@ export interface UserConfig<
extends?: RawConfigExports<ThemeConfig>
/**
* 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<ThemeConfig = any> 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.
*/

@ -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')

3
types/shared.d.ts vendored

@ -168,7 +168,8 @@ export interface Header {
*/
export interface SiteData<ThemeConfig = any> {
/**
* 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

Loading…
Cancel
Save