mirror of https://github.com/vuejs/vitepress
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
parent
60f656b0ec
commit
826527709d
@ -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' } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 70 B |
@ -0,0 +1,13 @@
|
|||||||
|
# Home
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[to sub](/sub/page)
|
||||||
|
|
||||||
|
[to dir](/sub/)
|
||||||
|
|
||||||
|
[zip](/file.zip)
|
||||||
|
|
||||||
|
[moved](/moved/target)
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[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 { normalizeAssetsBase, normalizeSiteBase } from 'node/config'
|
||||||
import { mergeConfig, type UserConfig } from 'node/config'
|
|
||||||
|
|
||||||
describe('node/config', () => {
|
describe('node/config', () => {
|
||||||
test('merges markdown hooks from extended configs', async () => {
|
describe('normalizeSiteBase', () => {
|
||||||
const calls: string[] = []
|
test('defaults to / and appends the trailing slash', () => {
|
||||||
const md = {} as MarkdownItAsync
|
expect(normalizeSiteBase(undefined)).toBe('/')
|
||||||
|
expect(normalizeSiteBase('')).toBe('/')
|
||||||
const merged = mergeConfig<UserConfig, UserConfig>(
|
expect(normalizeSiteBase('/docs')).toBe('/docs/')
|
||||||
{
|
expect(normalizeSiteBase('/docs/')).toBe('/docs/')
|
||||||
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']
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await merged.markdown?.preConfig?.(md)
|
test('normalizes relative forms to ./', () => {
|
||||||
await merged.markdown?.config?.(md)
|
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 () => {
|
describe('normalizeAssetsBase', () => {
|
||||||
const calls: string[] = []
|
test('accepts absolute urls, protocol-relative urls and paths', () => {
|
||||||
const md = {} as MarkdownItAsync
|
expect(normalizeAssetsBase('https://cdn.example.com')).toBe(
|
||||||
|
'https://cdn.example.com/'
|
||||||
const merged = mergeConfig<UserConfig, UserConfig>(
|
)
|
||||||
{
|
expect(normalizeAssetsBase('//cdn.example.com/x')).toBe(
|
||||||
markdown: {
|
'//cdn.example.com/x/'
|
||||||
preConfig() {
|
)
|
||||||
calls.push('base-pre')
|
expect(normalizeAssetsBase('/cdn/')).toBe('/cdn/')
|
||||||
}
|
})
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
markdown: {
|
|
||||||
config() {
|
|
||||||
calls.push('extended')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await merged.markdown?.preConfig?.(md)
|
|
||||||
await merged.markdown?.config?.(md)
|
|
||||||
|
|
||||||
expect(calls).toEqual(['base-pre', 'extended'])
|
test('rejects relative values', () => {
|
||||||
|
expect(() => normalizeAssetsBase('./cdn/')).toThrow(/assetsBase/)
|
||||||
|
expect(() => normalizeAssetsBase('cdn/')).toThrow(/assetsBase/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue