feat: relative base (`./`) and `assetsBase` (CDN prefix) (#5406)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/5408/head
Divyansh Singh 2 weeks ago committed by GitHub
parent 60f656b0ec
commit feadd9fcc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

3
.gitignore vendored

@ -17,3 +17,6 @@ pnpm-global
TODOs.md TODOs.md
*.timestamp-*.mjs *.timestamp-*.mjs
.claude .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,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(
`<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,48 @@
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 }
},
// user hooks must only ever see final urls, never the build sentinel
postRender(context) {
if (JSON.stringify(context.teleports ?? {}).includes('__VP_BASE__')) {
throw new Error('sentinel leaked to postRender teleports')
}
if (context.content.includes('__VP_BASE__')) {
throw new Error('sentinel leaked to postRender')
}
},
transformHead({ assets, head, content }) {
if ((JSON.stringify([assets, head]) + content).includes('__VP_BASE__')) {
throw new Error('sentinel leaked to transformHead')
}
},
transformHtml(code, _id, { assets, content }) {
if ((code + JSON.stringify(assets) + content).includes('__VP_BASE__')) {
throw new Error('sentinel leaked to transformHtml')
}
},
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' } })
}
})

@ -0,0 +1,7 @@
# Blog
<script setup>
import { data } from './posts.data.ts'
</script>
<div v-for="p in data" :key="p.url" class="post-excerpt" v-html="p.html"></div>

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)

@ -0,0 +1,3 @@
import { createContentLoader } from 'vitepress'
export default createContentLoader('posts/**/*.md', { render: true })

@ -0,0 +1,5 @@
# Post one
This is the intro of post one with a [site link](/sub/page) and ![img](/logo.png).
More body.

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,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<HTMLImageElement>('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')
})
})

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

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

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

@ -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<string, string> = {
'.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<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(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<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
)
)
)
}

@ -1,5 +1,5 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"isolatedModules": false, "isolatedModules": false,

@ -1,5 +1,10 @@
import type { MarkdownItAsync } from 'markdown-it-async' 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', () => { describe('node/config', () => {
test('merges markdown hooks from extended configs', async () => { test('merges markdown hooks from extended configs', async () => {
@ -71,3 +76,50 @@ describe('node/config', () => {
expect(calls).toEqual(['base-pre', 'extended']) 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/)
})
})
})

@ -62,3 +62,79 @@ describe('node/markdown/plugins/link', () => {
expect(env.linkLines).toEqual([3]) 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"')
})
})

@ -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('shared/shared', () => {
describe('mergeHead', () => { 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('../../')
})
})
})

@ -36,23 +36,15 @@ Note that you should reference files placed in `public` using root absolute path
## Base URL ## 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 ```md
![An image](/image-inside-public.png) ![An image](/image-inside-public.png)
``` ```
You do **not** need to update it when you change the `base` config value in this case. 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:
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
<img :src="theme.logoPath" />
```
In this case it is recommended to wrap the path with the [`withBase` helper](../reference/runtime-api#withbase) provided by VitePress:
```vue ```vue
<script setup> <script setup>
@ -65,3 +57,26 @@ const { theme } = useData()
<img :src="withBase(theme.logoPath)" /> <img :src="withBase(theme.logoPath)" />
</template> </template>
``` ```
## Serving Assets from a CDN
To serve the generated assets — scripts, styles, fonts, and images imported from Markdown or components — from a different origin than the pages, set [`assetsBase`](../reference/site-config#assetsbase):
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
}
```
Upload the `assets` directory from the build output to the CDN so it is reachable at `https://cdn.example.com/assets/`, and deploy the rest of the output to your site as usual. Files in `public` are referenced from `base` and stay with the pages.
Since the value is often environment-specific, it can also be passed on the command line:
```sh
vitepress build docs --assetsBase "$CDN_URL"
```
::: warning CORS Required
Module scripts are always fetched in CORS mode, so a cross-origin CDN must respond with an appropriate `Access-Control-Allow-Origin` header.
:::

@ -54,6 +54,30 @@ By default, we assume the site is going to be deployed at the root path of a dom
**Example:** If you're using Github (or GitLab) Pages and deploying to `user.github.io/repo/`, then set your `base` to `/repo/`. **Example:** If you're using Github (or GitLab) Pages and deploying to `user.github.io/repo/`, then set your `base` to `/repo/`.
## Relocatable Builds (Relative Base) {#relocatable-builds-relative-base}
When the final URL of the site isn't known at build time — an IPFS gateway (`https://gateway/ipfs/<cid>/…`), the Wayback Machine, a shared folder, docs bundled into an app — set `base` to `'./'`:
```ts
export default {
base: './'
}
```
Every page then references assets and other pages relative to its own location, and the client runtime recovers the real mount point when the page loads. The same build works from **any** sub path without rebuilding — including several at once — with routing, search and prefetching fully functional.
Opening the generated HTML files straight from the file system (`file://`) also works as a styled, fully navigable static site. Browsers block JavaScript modules over `file://`, so there is no hydration there — interactive features like search stay inactive, while all pre-rendered content and links keep working.
A few things to know:
- Keep [`cleanUrls`](../reference/site-config#cleanurls) off (the default): portable output needs links that end in `.html`, since there is no server to rewrite pretty URLs.
- `404.html` is generated for the root depth. Hosts that serve it as a fallback for arbitrarily deep URLs will render it without styles (there is no correct relative prefix for an unknown depth).
- [`head`](../reference/site-config#head) entries are emitted verbatim, as always — avoid root-absolute paths like `/favicon.ico` there and prefer absolute URLs or `transformHead`.
- Raw HTML `<a>` tags in Markdown keep their `href` as written — use Markdown link syntax for site-absolute links (embedded `<img>` sources go through the asset pipeline and are handled).
- Links created by [`createContentLoader`](./data-loading#createcontentloader) content stay site-absolute (their HTML is embedded into other pages, so no single relative prefix is correct) — they resolve only for a root mount.
- Serve pages at their canonical URLs: the root as `/dir/` (not `/dir`), and no added trailing slashes on page URLs. The relative prefix is resolved against the URL the browser actually shows, and virtually all static hosts canonicalize this way already.
- The dev server always serves at `/`; the relative behavior applies to the production build.
## HTTP Cache Headers ## HTTP Cache Headers
If you have control over the HTTP headers on your production server, you can configure `cache-control` headers to achieve better performance on repeated visits. If you have control over the HTTP headers on your production server, you can configure `cache-control` headers to achieve better performance on repeated visits.

@ -45,6 +45,7 @@ vitepress build [root]
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `--mpa` (experimental) | Build in [MPA mode](../guide/mpa-mode) without client-side hydration (`boolean`) | | `--mpa` (experimental) | Build in [MPA mode](../guide/mpa-mode) without client-side hydration (`boolean`) |
| `--base <path>` | Public base path (default: `/`) (`string`) | | `--base <path>` | Public base path (default: `/`) (`string`) |
| `--assetsBase <url>` | URL prefix the generated assets are served from, e.g. a CDN (`string`) |
| `--target <target>` | Transpile target (default: `"modules"`) (`string`) | | `--target <target>` | Transpile target (default: `"modules"`) (`string`) |
| `--outDir <dir>` | Output directory relative to **cwd** (default: `<root>/.vitepress/dist`) (`string`) | | `--outDir <dir>` | Output directory relative to **cwd** (default: `<root>/.vitepress/dist`) (`string`) |
| `--assetsInlineLimit <number>` | Static asset base64 inline threshold in bytes (default: `4096`) (`number`) | | `--assetsInlineLimit <number>` | Static asset base64 inline threshold in bytes (default: `4096`) (`number`) |
@ -64,6 +65,7 @@ vitepress preview [root]
| Option | Description | | Option | Description |
| --------------- | ------------------------------------------ | | --------------- | ------------------------------------------ |
| `--base <path>` | Public base path (default: `/`) (`string`) | | `--base <path>` | Public base path (default: `/`) (`string`) |
| `--assetsBase <url>` | URL prefix the generated assets are served from, e.g. a CDN (`string`) |
| `--port <port>` | Specify port (`number`) | | `--port <port>` | Specify port (`number`) |
## `vitepress init` ## `vitepress init`

@ -112,6 +112,10 @@ export default defineConfig({
Learn more in [MiniSearch docs](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html). Learn more in [MiniSearch docs](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html).
::: info Document IDs
Search document IDs (as seen by `searchOptions.filter`, `boostDocument`, and in the raw index) are site-relative paths like `/guide/page.html#section` — they do not include [`base`](../reference/site-config#base). The theme resolves them against the base when rendering results.
:::
### Custom content renderer ### Custom content renderer
You can customize the function used to render the markdown content before indexing it: You can customize the function used to render the markdown content before indexing it:

@ -372,7 +372,9 @@ export default {
- Type: `string` - Type: `string`
- Default: `/` - Default: `/`
The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash. Relative bases are not supported. The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash.
The one exception is `'./'`, which produces a [relocatable build](../guide/deploy#relocatable-builds-relative-base): pages reference everything relative to their own location, so the same output works from any sub path (IPFS gateways, archives) without rebuilding and stays browsable when opened directly from the file system.
The base is automatically prepended to all the URLs that start with / in other options, so you only need to specify it once. The base is automatically prepended to all the URLs that start with / in other options, so you only need to specify it once.
@ -382,6 +384,8 @@ export default {
} }
``` ```
Can also be set per build with `vitepress build --base /base/`.
## Routing ## Routing
### cleanUrls ### cleanUrls
@ -463,6 +467,28 @@ export default {
} }
``` ```
### assetsBase
- Type: `string`
- Default: `undefined`
URL prefix the generated assets (everything under [`assetsDir`](#assetsdir)) are served from — typically a CDN. Must be an absolute URL, a protocol-relative URL, or a root-absolute path; a trailing slash is appended if missing.
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
// scripts, styles, fonts and imported images resolve to
// https://cdn.example.com/assets/*
}
```
The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `<assetsBase>/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files, `hashmap.json` and `vp-icons.css` stay on [`base`](#base).
When `assetsBase` points at another origin, VitePress adds `crossorigin` to the emitted script and preload tags — the CDN must send `Access-Control-Allow-Origin` for your site's origin (module scripts are always fetched in CORS mode).
Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`.
### cacheDir ### cacheDir
- Type: `string` - Type: `string`

@ -1,5 +1,5 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noEmit": true, "noEmit": true,

@ -56,8 +56,9 @@
"build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check", "build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check",
"build:check": "publint && attw --pack . --profile esm-only", "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", "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": "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 && vue-tsc -p docs", "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": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit", "test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build", "test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",

@ -268,6 +268,12 @@ importers:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.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: __tests__/e2e:
devDependencies: devDependencies:
vitepress: vitepress:

@ -3,6 +3,7 @@
import { onMounted, onUnmounted, watch } from 'vue' import { onMounted, onUnmounted, watch } from 'vue'
import { EXTERNAL_URL_RE } from '../../shared'
import { useRoute } from '../router' import { useRoute } from '../router'
import { inBrowser, pathToFile } from '../utils' import { inBrowser, pathToFile } from '../utils'
@ -12,13 +13,15 @@ const createLink = () => document.createElement('link')
const viaDOM = (url: string) => { const viaDOM = (url: string) => {
const link = createLink() const link = createLink()
link.rel = `prefetch` link.rel = `prefetch`
if (EXTERNAL_URL_RE.test(url)) link.crossOrigin = ''
link.href = url link.href = url
document.head.appendChild(link) document.head.appendChild(link)
} }
const viaXHR = (url: string) => { const viaXHR = (url: string) => {
const req = new XMLHttpRequest() const req = new XMLHttpRequest()
req.open('GET', url, (req.withCredentials = true)) req.open('GET', url, true)
req.withCredentials = !EXTERNAL_URL_RE.test(url)
req.send() req.send()
} }

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

@ -3,19 +3,42 @@ import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL,
inBrowser, inBrowser,
isRelativeBase,
joinPath,
sanitizeFileName, sanitizeFileName,
type Awaitable type Awaitable
} from '../shared' } from '../shared'
import { siteDataRef } from './data' import { siteDataRef } from './data'
export { escapeHtml as _escapeHtml, inBrowser } from '../shared' export { escapeHtml as _escapeHtml, inBrowser } from '../shared'
export { joinPath } from '../shared'
let resolvedBase: string | undefined
/** /**
* Join two paths by resolving the slash collision. * Runtime base path used by the app.
*
* Usually this is the configured site base.
*
* For a relative base (`'./'`), the mount point is unknown at build time, so:
* - SSR: uses `RELATIVE_BASE_SENTINEL` (for per-page URL relativization)
* - dev browser: uses `'/'` (dev server always mounts at root)
* - prod browser: resolves from the page's `__VP_SITE_ROOT__`
*/ */
export function joinPath(base: string, path: string) { export function runtimeBase(): string {
return `${base}${path}`.replace(/\/+/g, '/') 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 +47,7 @@ export function joinPath(base: string, path: string) {
export function withBase(path: string) { export function withBase(path: string) {
return EXTERNAL_URL_RE.test(path) || !path.startsWith('/') return EXTERNAL_URL_RE.test(path) || !path.startsWith('/')
? path ? path
: joinPath(siteDataRef.value.base, path) : joinPath(runtimeBase(), path)
} }
/** /**
@ -42,7 +65,11 @@ export function pathToFile(path: string) {
// the path conversion scheme. // the path conversion scheme.
// /foo/bar.html -> ./foo_bar.md // /foo/bar.html -> ./foo_bar.md
if (inBrowser) { if (inBrowser) {
const base = import.meta.env.BASE_URL const base = runtimeBase()
// the site root may arrive without its trailing slash; anything
// outside the base has no page chunk at all
if (pagePath + '/' === base) pagePath = base
if (!pagePath.startsWith(base)) return null
pagePath = pagePath =
sanitizeFileName( sanitizeFileName(
pagePath.slice(base.length).replace(/\//g, '_') || 'index' pagePath.slice(base.length).replace(/\//g, '_') || 'index'
@ -57,7 +84,7 @@ export function pathToFile(path: string) {
pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()] pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()]
} }
if (!pageHash) return null if (!pageHash) return null
pagePath = `${base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js` pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
} else { } else {
// ssr build uses much simpler name mapping // ssr build uses much simpler name mapping
pagePath = `./${sanitizeFileName( pagePath = `./${sanitizeFileName(

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

@ -2,18 +2,25 @@
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import { computed } from 'vue' import { computed } from 'vue'
import { runtimeBase } from '../../app/utils'
import { isRelativeBase } from '../../shared'
import { useData } from '../composables/data' import { useData } from '../composables/data'
import { useLayout } from '../composables/layout' import { useLayout } from '../composables/layout'
import VPDocAside from './VPDocAside.vue' import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue' import VPDocFooter from './VPDocFooter.vue'
const { theme } = useData() const { theme, site } = useData()
const route = useRoute() const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useLayout() const { hasSidebar, hasAside, leftAside } = useLayout()
const pageName = computed(() => const pageName = computed(() => {
route.path.replace(/[./]+/g, '_').replace(/_html$/, '') // the mount point is unknown at build time, so the class must come from
) // the site-relative path or ssr and hydration disagree
const path = isRelativeBase(site.value.base)
? '/' + route.path.slice(runtimeBase().length)
: route.path
return path.replace(/[./]+/g, '_').replace(/_html$/, '')
})
</script> </script>
<template> <template>

@ -11,7 +11,7 @@ import {
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js' import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch' import MiniSearch, { type SearchResult } from 'minisearch'
import { dataSymbol, useRouter } from 'vitepress' import { dataSymbol, useRouter, withBase } from 'vitepress'
import { import {
computed, computed,
createApp, createApp,
@ -177,7 +177,7 @@ watchDebounced(
: [] : []
if (canceled) return if (canceled) return
for (const { id, mod } of mods) { for (const { id, mod } of mods) {
const mapId = id.slice(0, id.indexOf('#')) const mapId = id.replace(/#.*$/, '')
let map = cache.get(mapId) let map = cache.get(mapId)
if (map) continue if (map) continue
map = new Map() map = new Map()
@ -254,7 +254,7 @@ watchDebounced(
) )
async function fetchExcerpt(id: string) { async function fetchExcerpt(id: string) {
const file = pathToFile(id.slice(0, id.indexOf('#'))) const file = pathToFile(withBase(id.replace(/#.*$/, '')))
try { try {
if (!file) throw new Error(`Cannot find file for id: ${id}`) if (!file) throw new Error(`Cannot find file for id: ${id}`)
return { id, mod: await import(/*@vite-ignore*/ file) } return { id, mod: await import(/*@vite-ignore*/ file) }
@ -363,7 +363,7 @@ onKeyStroke('Enter', (e) => {
} }
if (selectedPackage) { if (selectedPackage) {
router.go(selectedPackage.id) router.go(withBase(selectedPackage.id))
emit('close') emit('close')
} }
}) })
@ -554,7 +554,7 @@ function onMouseMove(e: MouseEvent) {
role="option" role="option"
> >
<a <a
:href="p.id" :href="withBase(p.id)"
class="result" class="result"
:class="{ :class="{
selected: selectedIndex === index selected: selectedIndex === index

@ -1,6 +1,6 @@
import { withBase } from 'vitepress' import { withBase } from 'vitepress'
import { isExternal, treatAsHtml } from '../../shared' import { isExternal, isRelativeBase, treatAsHtml } from '../../shared'
import { useData } from '../composables/data' import { useData } from '../composables/data'
export function throttleAndDebounce(fn: () => void, delay: number): () => void { export function throttleAndDebounce(fn: () => void, delay: number): () => void {
@ -46,7 +46,7 @@ export function normalizeLink(url: string): string {
const { site } = useData() const { site } = useData()
const normalizedPath = let normalizedPath =
pathname.endsWith('/') || pathname.endsWith('.html') pathname.endsWith('/') || pathname.endsWith('.html')
? url ? url
: url.replace( : url.replace(
@ -57,6 +57,14 @@ export function normalizeLink(url: string): string {
)}${search}${hash}` )}${search}${hash}`
) )
if (isRelativeBase(site.value.base) && !site.value.cleanUrls) {
const pathPart = normalizedPath.replace(/[?#].*$/, '')
if (pathPart.endsWith('/')) {
normalizedPath =
pathPart + 'index.html' + normalizedPath.slice(pathPart.length)
}
}
return withBase(normalizedPath) return withBase(normalizedPath)
} }

@ -9,10 +9,22 @@ import pMap from 'p-map'
import { packageDirectory } from 'package-directory' import { packageDirectory } from 'package-directory'
import type { BuildOptions, Rolldown } from 'vite' 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 { clearCache } from '../markdownToVue'
import type { PageMeta } from '../plugin' 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 { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
import { logVersion } from '../utils/logVersion' import { logVersion } from '../utils/logVersion'
import { nativeImport } from '../utils/nativeImport' import { nativeImport } from '../utils/nativeImport'
@ -27,6 +39,7 @@ export async function build(
root?: string, root?: string,
buildOptions: BuildOptions & { buildOptions: BuildOptions & {
base?: string base?: string
assetsBase?: string
mpa?: string mpa?: string
onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable<void> onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable<void>
} = {} } = {}
@ -46,10 +59,23 @@ export async function build(
const unlinkVue = await linkVue() const unlinkVue = await linkVue()
if (buildOptions.base) { if (buildOptions.base) {
siteConfig.site.base = buildOptions.base if (typeof buildOptions.base !== 'string') {
throw new Error('--base requires a value (e.g. --base /docs/)')
}
siteConfig.site.base = normalizeSiteBase(buildOptions.base)
delete buildOptions.base delete buildOptions.base
} }
if (buildOptions.assetsBase) {
if (typeof buildOptions.assetsBase !== 'string') {
throw new Error(
'--assetsBase requires a value (e.g. --assetsBase https://cdn.example.com/)'
)
}
siteConfig.assetsBase = normalizeAssetsBase(buildOptions.assetsBase)
delete buildOptions.assetsBase
}
if (buildOptions.mpa) { if (buildOptions.mpa) {
siteConfig.mpa = true siteConfig.mpa = true
delete buildOptions.mpa delete buildOptions.mpa
@ -152,11 +178,17 @@ async function render(
chunk.type === 'asset' && chunk.fileName.endsWith('.css') chunk.type === 'asset' && chunk.fileName.endsWith('.css')
) )
const assetsUrlBase =
siteConfig.assetsBase ??
(isRelativeBase(siteConfig.site.base)
? RELATIVE_BASE_SENTINEL
: siteConfig.site.base)
// prettier-ignore // prettier-ignore
const assets = resultOutput.filter( const assets = resultOutput.filter(
(chunk): chunk is Rolldown.OutputAsset => (chunk): chunk is Rolldown.OutputAsset =>
chunk.type === 'asset' && !chunk.fileName.endsWith('.css') chunk.type === 'asset' && !chunk.fileName.endsWith('.css')
).map((asset) => siteConfig.site.base + asset.fileName) ).map((asset) => assetsUrlBase + asset.fileName)
// ---- // ----
@ -255,13 +287,22 @@ async function generateMetadataScript(
) )
const resolvedMetadataFile = path.join(config.outDir, metadataFile) 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 mkdir(path.dirname(resolvedMetadataFile), { recursive: true })
await writeFile(resolvedMetadataFile, metadataContent) await writeFile(resolvedMetadataFile, metadataContent)
return { return {
html: `<script type="module" src="${metadataFileURL}"></script>`, html: `<script type="module" src="${metadataFileURL}"${crossorigin}></script>`,
inHead: true inHead: true
} }
} }

@ -17,6 +17,14 @@ export async function buildMPAClient(
cacheDir: config.cacheDir, cacheDir: config.cacheDir,
base: config.site.base, base: config.site.base,
logLevel: config.vite?.logLevel ?? 'warn', logLevel: config.vite?.logLevel ?? 'warn',
...(config.assetsBase
? {
experimental: {
renderBuiltUrl: (filename, ctx) =>
ctx.type === 'asset' ? config.assetsBase! + filename : undefined
}
}
: {}),
build: { build: {
emptyOutDir: false, emptyOutDir: false,
outDir: config.outDir, outDir: config.outDir,

@ -1,5 +1,5 @@
import fs from 'node:fs' 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 path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@ -15,7 +15,13 @@ import {
import { APP_PATH } from '../alias' import { APP_PATH } from '../alias'
import type { SiteConfig } from '../config' import type { SiteConfig } from '../config'
import { createVitePressPlugin, type PageMeta } from '../plugin' 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' import { buildMPAClient } from './buildMPAClient'
// https://github.com/vitejs/vite/blob/a55d0b34400e3360c4100d05e422ae9cf10fa07b/packages/vite/src/node/constants.ts#L50 // https://github.com/vitejs/vite/blob/a55d0b34400e3360c4100d05e422ae9cf10fa07b/packages/vite/src/node/constants.ts#L50
@ -75,12 +81,17 @@ export async function bundle(
...restOptions ...restOptions
} = options } = options
const relativeBase = isRelativeBase(config.site.base)
const resolveViteConfig = async ( const resolveViteConfig = async (
ssr: boolean ssr: boolean
): Promise<ViteInlineConfig> => ({ ): Promise<ViteInlineConfig> => ({
root: config.srcDir, root: config.srcDir,
cacheDir: config.cacheDir, 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', logLevel: config.vite?.logLevel ?? 'warn',
plugins: await createVitePressPlugin( plugins: await createVitePressPlugin(
config, config,
@ -152,8 +163,22 @@ export async function bundle(
if (!chunk.fileName.endsWith('.js')) { if (!chunk.fileName.endsWith('.js')) {
const tempPath = path.resolve(config.tempDir, chunk.fileName) const tempPath = path.resolve(config.tempDir, chunk.fileName)
const outPath = path.resolve(config.outDir, chunk.fileName) const outPath = path.resolve(config.outDir, chunk.fileName)
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) await cp(tempPath, outPath)
} }
}
}, },
{ concurrency: config.buildConcurrency } { concurrency: config.buildConcurrency }
) )

@ -8,10 +8,13 @@ import { version } from '../../../package.json' with { type: 'json' }
import type { SiteConfig } from '../config' import type { SiteConfig } from '../config'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL,
createTitle, createTitle,
escapeHtml, escapeHtml,
isRelativeBase,
mergeHead, mergeHead,
notFoundPageData, notFoundPageData,
relativePathToRoot,
resolveSiteDataByRoute, resolveSiteDataByRoute,
sanitizeFileName, sanitizeFileName,
slash, slash,
@ -36,8 +39,22 @@ export async function renderPage(
) { ) {
const routePath = `/${page.replace(/\.md$/, '')}` 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 // render page
const context = await render(routePath) 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 } = const { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context (await config.postRender?.(context)) ?? context
@ -72,10 +89,17 @@ export async function renderPage(
const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath) 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 title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description const description: string = pageData.description || siteData.description
const stylesheetLink = cssChunk 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 = let preloadLinks =
@ -107,15 +131,24 @@ export async function renderPage(
{ {
rel, rel,
// don't add base to external urls // 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 preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload')
const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch') const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch')
const pageHeadTags: HeadConfig[] = relativeBase
? JSON.parse(desentinel(JSON.stringify(additionalHeadTags)))
: additionalHeadTags
const headBeforeTransform = [ const headBeforeTransform = [
...additionalHeadTags, ...pageHeadTags,
...preloadHeadTags, ...preloadHeadTags,
...prefetchHeadTags, ...prefetchHeadTags,
...mergeHead( ...mergeHead(
@ -135,7 +168,7 @@ export async function renderPage(
description, description,
head: headBeforeTransform, head: headBeforeTransform,
content, content,
assets assets: pageAssets
})) || [] })) || []
) )
@ -153,7 +186,7 @@ export async function renderPage(
force: true force: true
}) })
} else { } else {
inlinedScript = `<script type="module" src="${siteData.base}${matchingChunk.fileName}"></script>` inlinedScript = `<script type="module" src="${assetUrl(matchingChunk.fileName)}"${assetsCrossOrigin}></script>`
} }
} }
} }
@ -176,12 +209,19 @@ export async function renderPage(
: `<meta name="description" content="${escapeHtml(description)}">` : `<meta name="description" content="${escapeHtml(description)}">`
} }
<meta name="generator" content="VitePress v${version}"> <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} ${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 : ''} ${metadataScript.inHead ? metadataScript.html : ''}
${ ${
appChunk appChunk
? `<script type="module" src="${siteData.base}${appChunk.fileName}"></script>` ? `<script type="module" src="${assetUrl(appChunk.fileName)}"${assetsCrossOrigin}></script>`
: '' : ''
} }
${await renderHead(head)} ${await renderHead(head)}
@ -195,7 +235,11 @@ export async function renderPage(
const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html'))
await mkdir(path.dirname(htmlFileName), { recursive: true }) await mkdir(path.dirname(htmlFileName), { recursive: true })
const transformedHtml = await config.transformHtml?.(html, htmlFileName, { const finalHtml = desentinel(html)
const transformedHtml = await config.transformHtml?.(
finalHtml,
htmlFileName,
{
page, page,
siteConfig: config, siteConfig: config,
siteData, siteData,
@ -204,9 +248,10 @@ export async function renderPage(
description, description,
head, head,
content, content,
assets assets: pageAssets
}) }
await writeFile(htmlFileName, transformedHtml || html) )
await writeFile(htmlFileName, transformedHtml || finalHtml)
} }
async function resolvePageImports( async function resolvePageImports(

@ -18,8 +18,10 @@ import type { MarkdownOptions } from './markdown/markdown'
import { resolvePages } from './plugins/dynamicRoutesPlugin' import { resolvePages } from './plugins/dynamicRoutesPlugin'
import { import {
APPEARANCE_KEY, APPEARANCE_KEY,
EXTERNAL_URL_RE,
VP_SOURCE_KEY, VP_SOURCE_KEY,
isObject, isObject,
isRelativeBase,
slash, slash,
type AdditionalConfig, type AdditionalConfig,
type Awaitable, type Awaitable,
@ -42,6 +44,35 @@ const additionalConfigGlob = `**/config.{js,mjs,ts,mts}`
const resolve = (root: string, file: string) => const resolve = (root: string, file: string) =>
normalizePath(path.resolve(root, `.vitepress`, file)) 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 { ConfigEnv }
export type UserConfigFn<ThemeConfig> = ( export type UserConfigFn<ThemeConfig> = (
env: ConfigEnv env: ConfigEnv
@ -142,11 +173,26 @@ export async function resolveConfig(
? '' ? ''
: normalizePath(path.resolve(srcDir, vitePublicDir || 'public')) : 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'> = { const config: Omit<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'> = {
root, root,
srcDir, srcDir,
publicDir, publicDir,
assetsDir, assetsDir,
assetsBase,
site, site,
themeDir, themeDir,
configPath, configPath,
@ -366,7 +412,7 @@ export async function resolveSiteData(
title: userConfig.title || 'VitePress', title: userConfig.title || 'VitePress',
titleTemplate: userConfig.titleTemplate, titleTemplate: userConfig.titleTemplate,
description: userConfig.description || 'A VitePress site', description: userConfig.description || 'A VitePress site',
base: userConfig.base ? userConfig.base.replace(/([^/])$/, '$1/') : '/', base: normalizeSiteBase(userConfig.base),
head: resolveSiteDataHead(userConfig), head: resolveSiteDataHead(userConfig),
router: { router: {
prefetchLinks: userConfig.router?.prefetchLinks ?? true prefetchLinks: userConfig.router?.prefetchLinks ?? true

@ -9,6 +9,9 @@ import type { MarkdownItAsync } from 'markdown-it-async'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
isExternal, isExternal,
isRelativeBase,
joinPath,
relativePathToRoot,
treatAsHtml, treatAsHtml,
type MarkdownEnv type MarkdownEnv
} from '../../shared' } from '../../shared'
@ -81,7 +84,15 @@ export const linkPlugin = (
// append base to internal (non-relative) urls // append base to internal (non-relative) urls
if (hrefAttr[1].startsWith('/')) { 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) { if (frag) {
@ -98,10 +109,13 @@ export const linkPlugin = (
) { ) {
let url = hrefAttr[1] 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) const indexMatch = url.match(indexRE)
if (indexMatch) { if (indexMatch) {
const [, path, hash] = indexMatch const [, path, hash] = indexMatch
url = path + normalizeHash(hash) url = path + (explicitIndex ? 'index.html' : '') + normalizeHash(hash)
} else { } else {
let cleanUrl = url.replace(/[?#].*$/, '') let cleanUrl = url.replace(/[?#].*$/, '')
// transform foo.md -> foo[.html] // transform foo.md -> foo[.html]
@ -116,6 +130,9 @@ export const linkPlugin = (
) { ) {
cleanUrl += '.html' cleanUrl += '.html'
} }
if (explicitIndex && cleanUrl.endsWith('/')) {
cleanUrl += 'index.html'
}
const parsed = new URL(url, 'http://a.com') const parsed = new URL(url, 'http://a.com')
url = cleanUrl + parsed.search + normalizeHash(parsed.hash) url = cleanUrl + parsed.search + normalizeHash(parsed.hash)
} }

@ -158,6 +158,7 @@ export async function createMarkdownToVueRenderFn(
path: file, path: file,
relativePath, relativePath,
cleanUrls, cleanUrls,
relativizeUrls: true,
includes: [], includes: [],
realPath: fileOrig, realPath: fileOrig,
localeIndex localeIndex

@ -27,6 +27,7 @@ import {
createMarkdownToVueRenderFn, createMarkdownToVueRenderFn,
type MarkdownCompileResult type MarkdownCompileResult
} from './markdownToVue' } from './markdownToVue'
import { assetsBasePlugin } from './plugins/assetsBasePlugin'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin'
@ -129,7 +130,9 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn( markdownToVue = await createMarkdownToVueRenderFn(
srcDir, srcDir,
markdown ?? {}, 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, lastUpdated ?? false,
cleanUrls ?? false, cleanUrls ?? false,
siteConfig siteConfig
@ -148,6 +151,7 @@ export async function createVitePressPlugin(
!!site.themeConfig?.algolia, // legacy !!site.themeConfig?.algolia, // legacy
__CARBON__: !!site.themeConfig?.carbonAds, __CARBON__: !!site.themeConfig?.carbonAds,
__ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir), __ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir),
__ASSETS_BASE__: JSON.stringify(siteConfig.assetsBase ?? ''),
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG
}, },
optimizeDeps: { optimizeDeps: {
@ -452,6 +456,8 @@ export async function createVitePressPlugin(
hmrFix, hmrFix,
webFontsPlugin(siteConfig.useWebFonts), webFontsPlugin(siteConfig.useWebFonts),
...(userViteConfig?.plugins || []), ...(userViteConfig?.plugins || []),
// must stay after the user plugins; see assetsBasePlugin
...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []),
await localSearchPlugin(siteConfig), await localSearchPlugin(siteConfig),
staticDataPlugin, staticDataPlugin,
await dynamicRoutesPlugin(siteConfig) await dynamicRoutesPlugin(siteConfig)

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

@ -127,7 +127,9 @@ export async function localSearchPlugin(
function getDocId(file: string) { function getDocId(file: string) {
let relFile = slash(path.relative(siteConfig.srcDir, file)) let relFile = slash(path.relative(siteConfig.srcDir, file))
relFile = siteConfig.rewrites.map[relFile] || relFile 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(/(^|\/)index\.md$/, '$1')
id = id.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html') id = id.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html')
return id return id

@ -1,6 +1,7 @@
import { compile, match } from 'path-to-regexp' import { compile, match } from 'path-to-regexp'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { isRelativeBase } from '../shared'
import type { SiteConfig, UserConfig } from '../siteConfig' import type { SiteConfig, UserConfig } from '../siteConfig'
export function resolveRewrites( export function resolveRewrites(
@ -51,12 +52,14 @@ export const rewritesPlugin = (config: SiteConfig): Plugin => {
return { return {
name: 'vitepress:rewrites', name: 'vitepress:rewrites',
configureServer(server) { configureServer(server) {
// dev always serves at the root when the base is relative
const base = isRelativeBase(config.site.base) ? '/' : config.site.base
// dev rewrite // dev rewrite
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
if (req.url) { if (req.url) {
const page = decodeURI(req.url) const page = decodeURI(req.url)
.replace(/[?#].*$/, '') .replace(/[?#].*$/, '')
.slice(config.site.base.length) .slice(base.length)
if (config.rewrites.inv[page]) { if (config.rewrites.inv[page]) {
req.url = req.url.replace( req.url = req.url.replace(

@ -5,11 +5,13 @@ import compression from '@polka/compression'
import polka, { type IOptions } from 'polka' import polka, { type IOptions } from 'polka'
import sirv from 'sirv' 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' import { readFile } from '../utils/fs'
export interface ServeOptions { export interface ServeOptions {
base?: string base?: string
assetsBase?: string
root?: string root?: string
port?: number port?: number
} }
@ -17,15 +19,34 @@ export interface ServeOptions {
export async function serve(options: ServeOptions = {}) { export async function serve(options: ServeOptions = {}) {
const port = options.port ?? 4173 const port = options.port ?? 4173
const config = await resolveConfig(options.root, 'serve', 'production') 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) => const notAnAsset = (pathname: string) =>
!pathname.includes(`/${config.assetsDir}/`) !pathname.includes(`/${config.assetsDir}/`)
const notFound = await readFile(path.resolve(config.outDir, './404.html')) const notFound = await readFile(path.resolve(config.outDir, './404.html'))
const onNoMatch: IOptions['onNoMatch'] = (req, res) => { const onNoMatch: IOptions['onNoMatch'] = (req, res) => {
if (base && req.path === '/') {
res.statusCode = 302
res.setHeader('location', `/${base}/`)
res.end()
return
}
res.statusCode = 404 res.statusCode = 404
if (notAnAsset(req.path)) res.write(notFound) if (notAnAsset(req.path)) res.write(notFound)
res.end() res.end()
@ -45,9 +66,31 @@ export async function serve(options: ServeOptions = {}) {
} }
}) })
const app = base const app = polka({ onNoMatch })
? polka({ onNoMatch }).use(base, compress, serve)
: polka({ onNoMatch }).use(compress, serve) 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) app.listen(port)
await once(app.server, 'listening') await once(app.server, 'listening')

@ -1,6 +1,6 @@
import { createServer as createViteServer, type ServerOptions } from 'vite' 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' import { createVitePressPlugin } from './plugin'
export async function createServer( export async function createServer(
@ -12,7 +12,7 @@ export async function createServer(
config ??= await resolveConfig(root) config ??= await resolveConfig(root)
const { base, ...server } = serverOptions const { base, ...server } = serverOptions
config.site.base = base ?? config.site.base if (typeof base === 'string') config.site.base = normalizeSiteBase(base)
return createViteServer({ return createViteServer({
root: config.srcDir, root: config.srcDir,

@ -92,7 +92,9 @@ export interface UserConfig<
*/ */
extends?: RawConfigExports<ThemeConfig> extends?: RawConfigExports<ThemeConfig>
/** /**
* 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 '/' * @default '/'
*/ */
base?: string base?: string
@ -118,6 +120,17 @@ export interface UserConfig<
* @default 'assets' * @default 'assets'
*/ */
assetsDir?: string 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. * Directory for cache files, relative to the project root.
* @default './.vitepress/cache' * @default './.vitepress/cache'
@ -349,6 +362,10 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
* Directory for assets within the build output. * Directory for assets within the build output.
*/ */
assetsDir: string assetsDir: string
/**
* URL prefix for built assets, normalized to end with a slash.
*/
assetsBase?: string
/** /**
* Absolute path of the cache directory. * Absolute path of the cache directory.
*/ */

@ -30,6 +30,35 @@ export type {
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
export const APPEARANCE_KEY = 'vitepress-theme-appearance' 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]' export const VP_SOURCE_KEY = '[VP_SOURCE]'
const UnpackStackView = Symbol('stack-view:unpack') const UnpackStackView = Symbol('stack-view:unpack')

@ -1,5 +1,5 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["./client.d.ts"], "types": ["./client.d.ts"],

@ -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"
]
} }

@ -1,5 +1,5 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2023"], "lib": ["ES2023"],
"types": ["node"] "types": ["node"]

@ -1,5 +1,5 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2023"], "lib": ["ES2023"],
"types": [], "types": [],

10
types/shared.d.ts vendored

@ -168,7 +168,8 @@ export interface Header {
*/ */
export interface SiteData<ThemeConfig = any> { export interface SiteData<ThemeConfig = any> {
/** /**
* 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 '/' * @default '/'
*/ */
base: string base: string
@ -586,6 +587,13 @@ export interface MarkdownEnv {
* Whether clean URLs are enabled. * Whether clean URLs are enabled.
*/ */
cleanUrls: boolean 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. * The URLs of the links collected from the page for the dead link check.
*/ */

Loading…
Cancel
Save