mirror of https://github.com/vuejs/vitepress
feat: relative base (`./`) and `assetsBase` (CDN prefix) (#5406)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>pull/5408/head
parent
60f656b0ec
commit
feadd9fcc1
@ -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>
|
||||
|
After Width: | Height: | Size: 70 B |
@ -0,0 +1,13 @@
|
||||
# Home
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
[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 .
|
||||
|
||||
More body.
|
||||
|
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,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()))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue