From 826527709d63a954751991b3812c6f466dfb52a1 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:50:56 +0530 Subject: [PATCH] feat: support relative base ('./') and assetsBase (CDN prefix) Relative base makes builds relocatable: pages reference everything through their own ../-prefix (SSR renders under a sentinel base that renderPage replaces per page; markdown links compile page-relative in both builds), a per-page inline script recovers the absolute site root at runtime for the router, chunk resolution, search and hashmap fallback. Works from any subpath (IPFS path gateways) and degrades to a styled, navigable static site over file://. assetsBase serves everything under assetsDir from a URL prefix (CDN): plain-string renderBuiltUrl on both builds chained behind any user hook, the SSR-assembled tags (stylesheet/preload/script/metadata/font) resolved through the same prefix with crossorigin when cross-origin, and page-chunk fetches via an __ASSETS_BASE__ define. Pages, withBase links, public/, hashmap.json and vp-icons.css stay on the site origin. Also: --base/--assetsBase CLI normalization, protocol-safe joinPath (fixes the https:/ collapse), preview support for relative and same-origin assetsBase plus a root redirect, and site-relative local-search doc ids. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + __tests__/base/cdn.test.ts | 63 +++++++ __tests__/base/constants.ts | 2 + __tests__/base/emit.test.ts | 171 ++++++++++++++++++ __tests__/base/fixture/.vitepress/config.ts | 29 +++ __tests__/base/fixture/img/photo.png | Bin 0 -> 70 bytes __tests__/base/fixture/index.md | 13 ++ __tests__/base/fixture/public/file.zip | 1 + __tests__/base/fixture/public/logo.png | Bin 0 -> 70 bytes __tests__/base/fixture/src-moved.md | 3 + __tests__/base/fixture/sub/deep/page2.md | 7 + __tests__/base/fixture/sub/index.md | 3 + __tests__/base/fixture/sub/page.md | 15 ++ __tests__/base/helpers.ts | 29 +++ __tests__/base/package.json | 12 ++ __tests__/base/relative-file.test.ts | 56 ++++++ __tests__/base/relative-spa.test.ts | 91 ++++++++++ __tests__/base/tsconfig.json | 5 + __tests__/base/vitest.config.ts | 15 ++ __tests__/base/vitestGlobalSetup.ts | 119 ++++++++++++ __tests__/unit/node/config.test.ts | 92 +++------- .../unit/node/markdown/plugins/link.test.ts | 66 +++++++ __tests__/unit/shared/shared.test.ts | 46 ++++- package.json | 5 +- pnpm-lock.yaml | 6 + src/client/app/composables/preFetch.ts | 4 + src/client/app/router.ts | 10 +- src/client/app/utils.ts | 34 +++- src/client/shims.d.ts | 1 + src/client/theme-default/components/VPDoc.vue | 16 +- .../components/VPLocalSearchBox.vue | 8 +- src/client/theme-default/support/utils.ts | 14 +- src/node/build/build.ts | 45 ++++- src/node/build/buildMPAClient.ts | 8 + src/node/build/bundle.ts | 33 +++- src/node/build/render.ts | 55 +++++- src/node/config.ts | 41 ++++- src/node/markdown/plugins/link.ts | 25 ++- src/node/plugin.ts | 9 +- src/node/plugins/assetsBasePlugin.ts | 32 ++++ src/node/plugins/localSearchPlugin.ts | 4 +- src/node/plugins/rewritesPlugin.ts | 5 +- src/node/serve/serve.ts | 51 +++++- src/node/server.ts | 4 +- src/node/siteConfig.ts | 20 ++ src/shared/shared.ts | 29 +++ types/shared.d.ts | 3 +- 47 files changed, 1184 insertions(+), 119 deletions(-) create mode 100644 __tests__/base/cdn.test.ts create mode 100644 __tests__/base/constants.ts create mode 100644 __tests__/base/emit.test.ts create mode 100644 __tests__/base/fixture/.vitepress/config.ts create mode 100644 __tests__/base/fixture/img/photo.png create mode 100644 __tests__/base/fixture/index.md create mode 100644 __tests__/base/fixture/public/file.zip create mode 100644 __tests__/base/fixture/public/logo.png create mode 100644 __tests__/base/fixture/src-moved.md create mode 100644 __tests__/base/fixture/sub/deep/page2.md create mode 100644 __tests__/base/fixture/sub/index.md create mode 100644 __tests__/base/fixture/sub/page.md create mode 100644 __tests__/base/helpers.ts create mode 100644 __tests__/base/package.json create mode 100644 __tests__/base/relative-file.test.ts create mode 100644 __tests__/base/relative-spa.test.ts create mode 100644 __tests__/base/tsconfig.json create mode 100644 __tests__/base/vitest.config.ts create mode 100644 __tests__/base/vitestGlobalSetup.ts create mode 100644 src/node/plugins/assetsBasePlugin.ts 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..60657877 --- /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..e217fce5 --- /dev/null +++ b/__tests__/base/emit.test.ts @@ -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( + `