diff --git a/.gitignore b/.gitignore index e6e95ca9..dafdcdea 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ pnpm-global TODOs.md *.timestamp-*.mjs .claude + +# base fixture builds +__tests__/base/fixture/.vitepress/dist-* diff --git a/__tests__/base/cdn.test.ts b/__tests__/base/cdn.test.ts new file mode 100644 index 00000000..7b8ce80b --- /dev/null +++ b/__tests__/base/cdn.test.ts @@ -0,0 +1,63 @@ +import { newPage, realErrors, waitForHydration, type TestPage } from './helpers' + +const origin = () => `http://localhost:${process.env['PAGES_PORT']}` +const cdnPort = () => process.env['VP_CDN_PORT'] + +let t: TestPage + +beforeAll(async () => { + t = await newPage() +}) + +afterAll(async () => { + await t.page.close() + await t.browser.close() +}) + +describe('assetsBase with a separate cdn origin', () => { + test('pages hydrate from cross-origin assets', async () => { + await t.page.goto(`${origin()}/`) + await waitForHydration(t.page) + const cdnResources = await t.page.evaluate( + (port) => + performance + .getEntriesByType('resource') + .filter((r) => r.name.includes(`:${port}/`)).length, + cdnPort() + ) + expect(cdnResources).toBeGreaterThan(5) + }) + + test('client-side navigation loads page chunks from the cdn', async () => { + await t.page.evaluate(() => ((window as any).__spa_marker = 1)) + await t.page.click('.vp-doc a[href="/sub/page.html"]') + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Sub page') + ) + expect( + await t.page.evaluate(() => (window as any).__spa_marker === 1) + ).toBe(true) + const chunkFromCdn = await t.page.evaluate( + (port) => + performance + .getEntriesByType('resource') + .some((r) => r.name.includes(`:${port}/`) && r.name.includes('.md.')), + cdnPort() + ) + expect(chunkFromCdn).toBe(true) + }) + + test('search works with the index chunk on the cdn', async () => { + await t.page.click('.VPNavBarSearchButton') + const input = await t.page.waitForSelector('input#localsearch-input') + await input.type('xylophone') + await t.page.waitForSelector('#localsearch-list li[role=option] a') + expect( + await t.page.getAttribute('#localsearch-list li[role=option] a', 'href') + ).toBe('/sub/deep/page2.html#deep-heading') + }) + + test('no console or page errors across the whole flow', () => { + expect(realErrors(t.errors)).toEqual([]) + }) +}) diff --git a/__tests__/base/constants.ts b/__tests__/base/constants.ts new file mode 100644 index 00000000..a2d5bcbe --- /dev/null +++ b/__tests__/base/constants.ts @@ -0,0 +1,2 @@ +export const SUB_PREFIX = '/ipfs/QmRelocatableTest123/' +export const ALT_PREFIX = '/some/other/place/' diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts new file mode 100644 index 00000000..9a9b7efd --- /dev/null +++ b/__tests__/base/emit.test.ts @@ -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( + ` + +
diff --git a/__tests__/base/fixture/img/photo.png b/__tests__/base/fixture/img/photo.png new file mode 100644 index 00000000..f37764b1 Binary files /dev/null and b/__tests__/base/fixture/img/photo.png differ diff --git a/__tests__/base/fixture/index.md b/__tests__/base/fixture/index.md new file mode 100644 index 00000000..50f40608 --- /dev/null +++ b/__tests__/base/fixture/index.md @@ -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) diff --git a/__tests__/base/fixture/posts.data.ts b/__tests__/base/fixture/posts.data.ts new file mode 100644 index 00000000..8a3fb96b --- /dev/null +++ b/__tests__/base/fixture/posts.data.ts @@ -0,0 +1,3 @@ +import { createContentLoader } from 'vitepress' + +export default createContentLoader('posts/**/*.md', { render: true }) diff --git a/__tests__/base/fixture/posts/deep/post1.md b/__tests__/base/fixture/posts/deep/post1.md new file mode 100644 index 00000000..abcd0e35 --- /dev/null +++ b/__tests__/base/fixture/posts/deep/post1.md @@ -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. diff --git a/__tests__/base/fixture/public/file.zip b/__tests__/base/fixture/public/file.zip new file mode 100644 index 00000000..8c3b76fb --- /dev/null +++ b/__tests__/base/fixture/public/file.zip @@ -0,0 +1 @@ +PKtest \ No newline at end of file diff --git a/__tests__/base/fixture/public/logo.png b/__tests__/base/fixture/public/logo.png new file mode 100644 index 00000000..f37764b1 Binary files /dev/null and b/__tests__/base/fixture/public/logo.png differ diff --git a/__tests__/base/fixture/src-moved.md b/__tests__/base/fixture/src-moved.md new file mode 100644 index 00000000..92f16eaa --- /dev/null +++ b/__tests__/base/fixture/src-moved.md @@ -0,0 +1,3 @@ +# Moved page + +Rewritten target. diff --git a/__tests__/base/fixture/sub/deep/page2.md b/__tests__/base/fixture/sub/deep/page2.md new file mode 100644 index 00000000..21f56a29 --- /dev/null +++ b/__tests__/base/fixture/sub/deep/page2.md @@ -0,0 +1,7 @@ +# Deep page + +[up](/sub/page) + +## Deep heading + +The xylophone paragraph for search. diff --git a/__tests__/base/fixture/sub/index.md b/__tests__/base/fixture/sub/index.md new file mode 100644 index 00000000..2a28bbf3 --- /dev/null +++ b/__tests__/base/fixture/sub/index.md @@ -0,0 +1,3 @@ +# Sub index + +Index of sub. diff --git a/__tests__/base/fixture/sub/page.md b/__tests__/base/fixture/sub/page.md new file mode 100644 index 00000000..6a5ca71a --- /dev/null +++ b/__tests__/base/fixture/sub/page.md @@ -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. diff --git a/__tests__/base/helpers.ts b/__tests__/base/helpers.ts new file mode 100644 index 00000000..05b9723d --- /dev/null +++ b/__tests__/base/helpers.ts @@ -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 { + 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 { + await page.waitForSelector('#app .Layout') + await page.waitForFunction( + () => (document.querySelector('#app') as any)?.__vue_app__ !== undefined + ) +} diff --git a/__tests__/base/package.json b/__tests__/base/package.json new file mode 100644 index 00000000..555db084 --- /dev/null +++ b/__tests__/base/package.json @@ -0,0 +1,12 @@ +{ + "name": "tests-base", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "watch": "DEBUG=1 vitest" + }, + "devDependencies": { + "vitepress": "workspace:*" + } +} diff --git a/__tests__/base/relative-file.test.ts b/__tests__/base/relative-file.test.ts new file mode 100644 index 00000000..7e839416 --- /dev/null +++ b/__tests__/base/relative-file.test.ts @@ -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('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') + }) +}) diff --git a/__tests__/base/relative-spa.test.ts b/__tests__/base/relative-spa.test.ts new file mode 100644 index 00000000..04e0732e --- /dev/null +++ b/__tests__/base/relative-spa.test.ts @@ -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([]) + }) +}) diff --git a/__tests__/base/tsconfig.json b/__tests__/base/tsconfig.json new file mode 100644 index 00000000..1759c08d --- /dev/null +++ b/__tests__/base/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*"], + "exclude": ["fixture/.vitepress/dist-*", "fixture/.vitepress/cache"] +} diff --git a/__tests__/base/vitest.config.ts b/__tests__/base/vitest.config.ts new file mode 100644 index 00000000..43f17310 --- /dev/null +++ b/__tests__/base/vitest.config.ts @@ -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 + } +}) diff --git a/__tests__/base/vitestGlobalSetup.ts b/__tests__/base/vitestGlobalSetup.ts new file mode 100644 index 00000000..0f7e5d0c --- /dev/null +++ b/__tests__/base/vitestGlobalSetup.ts @@ -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 = { + '.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 { + 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 = { + '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((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + ) + ) +} diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 366c4ab8..83d5121c 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "noEmit": true, "isolatedModules": false, diff --git a/__tests__/unit/node/config.test.ts b/__tests__/unit/node/config.test.ts index df4af72c..de1eb2f8 100644 --- a/__tests__/unit/node/config.test.ts +++ b/__tests__/unit/node/config.test.ts @@ -1,5 +1,10 @@ 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', () => { test('merges markdown hooks from extended configs', async () => { @@ -71,3 +76,50 @@ describe('node/config', () => { 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/) + }) + }) +}) diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 5e4bc7f1..119132c3 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -62,3 +62,79 @@ describe('node/markdown/plugins/link', () => { expect(env.linkLines).toEqual([3]) }) }) + +describe('node/markdown/plugins/link with a relative base', () => { + const md = new MarkdownItAsync() + linkPlugin(md, {}, './', slugify) + const render = (src: string, env: object = {}) => + md.renderAsync(src, { + cleanUrls: false, + relativePath: 'guide/page.md', + 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"') + }) +}) diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts index 77826db3..16b971ae 100644 --- a/__tests__/unit/shared/shared.test.ts +++ b/__tests__/unit/shared/shared.test.ts @@ -1,4 +1,10 @@ -import { mergeHead, type HeadConfig } from 'shared/shared' +import { + isRelativeBase, + joinPath, + mergeHead, + relativePathToRoot, + type HeadConfig +} from 'shared/shared' describe('shared/shared', () => { describe('mergeHead', () => { @@ -54,3 +60,41 @@ describe('shared/shared', () => { }) }) }) + +describe('shared/shared url helpers', () => { + describe('joinPath', () => { + test('joins and collapses slash collisions', () => { + expect(joinPath('/', '/guide/')).toBe('/guide/') + expect(joinPath('/docs/', '/guide/page')).toBe('/docs/guide/page') + expect(joinPath('/docs', 'guide')).toBe('/docsguide') + }) + + test('preserves the protocol of absolute url bases', () => { + expect(joinPath('https://cdn.example.com/', '/guide/')).toBe( + 'https://cdn.example.com/guide/' + ) + expect(joinPath('https://cdn.example.com/sub//x/', '/a')).toBe( + 'https://cdn.example.com/sub/x/a' + ) + expect(joinPath('//cdn.example.com/', '/a')).toBe('//cdn.example.com/a') + }) + }) + + describe('isRelativeBase', () => { + test('only ./ is relative', () => { + expect(isRelativeBase('./')).toBe(true) + expect(isRelativeBase('/')).toBe(false) + expect(isRelativeBase('/docs/')).toBe(false) + expect(isRelativeBase('https://example.com/')).toBe(false) + }) + }) + + describe('relativePathToRoot', () => { + test('maps a page path to its ../-prefix', () => { + expect(relativePathToRoot('index.md')).toBe('./') + expect(relativePathToRoot('foo.md')).toBe('./') + expect(relativePathToRoot('guide/index.md')).toBe('../') + expect(relativePathToRoot('guide/nested/page.md')).toBe('../../') + }) + }) +}) diff --git a/docs/en/guide/asset-handling.md b/docs/en/guide/asset-handling.md index 63394fd0..76b0c1c4 100644 --- a/docs/en/guide/asset-handling.md +++ b/docs/en/guide/asset-handling.md @@ -36,23 +36,15 @@ Note that you should reference files placed in `public` using root absolute path ## 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 ![An image](/image-inside-public.png) ``` -You do **not** need to update it when you change the `base` config value in this case. - -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 - -``` - -In this case it is recommended to wrap the path with the [`withBase` helper](../reference/runtime-api#withbase) provided by VitePress: +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: ```vue