From ebbd48c8c76a1f794ed52d8d4d9917f1bd52bea0 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:13:58 +0530 Subject: [PATCH 01/13] feat(theme): un-deprecate Theme.setup and compose it across extends (#5404) --- docs/en/guide/custom-theme.md | 27 ++++++++++++++++++++++++++- src/client/app/index.ts | 8 ++++++-- src/client/app/theme.ts | 3 ++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/en/guide/custom-theme.md b/docs/en/guide/custom-theme.md index 957c27dc..21cf4e26 100644 --- a/docs/en/guide/custom-theme.md +++ b/docs/en/guide/custom-theme.md @@ -38,7 +38,12 @@ interface Theme { */ enhanceApp?: (ctx: EnhanceAppContext) => Awaitable /** - * Extend another theme, calling its `enhanceApp` before ours + * Runs inside the root component's `setup()` + * @optional + */ + setup?: () => void + /** + * Extend another theme, calling its `enhanceApp` and `setup` before ours * @optional */ extends?: Theme @@ -88,6 +93,26 @@ export default { Return `false` from `onBeforeRouteChange` or `onBeforePageLoad` to cancel navigation. +The `setup` hook runs inside the root component's `setup()`, so Composition API calls (`onMounted`, `watch`, composables, ...) work there without wrapping the layout component: + +```ts [.vitepress/theme/index.ts] +import { watch } from 'vue' +import { useData } from 'vitepress' +import DefaultTheme from 'vitepress/theme' + +export default { + extends: DefaultTheme, + setup() { + const { page } = useData() + watch(() => page.value.relativePath, (path) => { + console.log('now viewing', path) + }) + } +} +``` + +With `extends`, each theme's `setup` runs base-first, like `enhanceApp`. It also runs during SSR/SSG rendering, so keep browser-only work inside `onMounted`. + The default export is the only contract for a custom theme, and only the `Layout` property is required. So technically, a VitePress theme can be as simple as a single Vue component. Inside your layout component, it works just like a normal Vite + Vue 3 application. Do note the theme also needs to be [SSR-compatible](./ssr-compat). diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 15f489e3..952e52f5 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -26,8 +26,12 @@ function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme { ...base, ...theme, async enhanceApp(ctx) { - if (base.enhanceApp) await base.enhanceApp(ctx) - if (theme.enhanceApp) await theme.enhanceApp(ctx) + await base.enhanceApp?.(ctx) + await theme.enhanceApp?.(ctx) + }, + setup() { + base.setup?.() + theme.setup?.() } } } diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts index 8c3fd2c1..4ebb222a 100644 --- a/src/client/app/theme.ts +++ b/src/client/app/theme.ts @@ -15,7 +15,8 @@ export interface Theme { extends?: Theme /** - * @deprecated can be replaced by wrapping layout component + * Runs inside the root component's `setup()` (during SSR too). With + * `extends`, setups run base-first, like `enhanceApp`. */ setup?: () => void From 14f4f09d32a5325a33629b5911c714d44882a726 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:42:51 +0530 Subject: [PATCH 02/13] fix(theme): keep search beside the title without a nav menu The nav menu is the bar's only growing element, so without `nav` configured everything packed to the right edge. Search now carries an auto right margin. A present menu's flex-grow still wins, so nothing moves in that case. Co-Authored-By: Claude Fable 5 --- src/client/theme-default/components/VPNavBar.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client/theme-default/components/VPNavBar.vue b/src/client/theme-default/components/VPNavBar.vue index 35b404cb..7daff981 100644 --- a/src/client/theme-default/components/VPNavBar.vue +++ b/src/client/theme-default/components/VPNavBar.vue @@ -227,6 +227,15 @@ const overflow = provideNavOverflow({ height: var(--vp-nav-height); } +@media (min-width: 48rem) { + /* keeps search on the title's side when there is no nav menu to grow + into the middle; with a menu present its flex-grow wins and this + margin resolves to zero */ + .content-body > .search { + margin-right: auto; + } +} + /* collapsed into the `⋯` menu — kept mounted (hidden, out of the a11y tree and tab order) so its natural width stays measurable */ .content-body > .collapsed { From 39b8f9f00140cc3f4a60c9e1ffb0c142de5ea089 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:42:55 +0530 Subject: [PATCH 03/13] fix(theme): cover the external link icon's paint box with its mask The icon is a masked background on an inline box whose height follows font metrics per engine. The fixed centered mask left an unmasked sliver of the background visible below the arrow on iOS. A full-size mask can only stretch the arrow slightly, never leak. Co-Authored-By: Claude Fable 5 --- src/client/theme-default/styles/components/vp-doc.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index e912c4a3..a1d85d90 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -605,8 +605,8 @@ mask-position: center; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; - -webkit-mask-size: 0.6875rem 0.6875rem; - mask-size: 0.6875rem 0.6875rem; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; /*rtl:raw:transform: scaleX(-1);*/ vertical-align: middle; font-size: 0.5625rem; From 9bebd092ebad1818e31cac8e86e5b03704554995 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:43:00 +0530 Subject: [PATCH 04/13] fix(theme): paint the navbar divider on its own layer Safari composites the sticky local nav above the bar's negative-z rule in their overlapping row, regardless of z-index, hiding the divider below 60rem (#5399). Promoting the rule to its own layer restores the order. Flyout panels still cover it. Co-Authored-By: Claude Fable 5 --- src/client/theme-default/components/VPNavBar.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/theme-default/components/VPNavBar.vue b/src/client/theme-default/components/VPNavBar.vue index 7daff981..81c84c1e 100644 --- a/src/client/theme-default/components/VPNavBar.vue +++ b/src/client/theme-default/components/VPNavBar.vue @@ -274,6 +274,9 @@ const overflow = provideNavOverflow({ /* above the background surface, below the bar's content — an open flyout panel overlaps the bar's bottom edge and must cover the rule */ z-index: -1; + /* own layer — Safari otherwise sorts the rule behind the sticky local + nav's surface in their overlapping row (#5399) */ + transform: translateZ(0); width: 100%; height: 1px; padding-left: var(--vp-nav-col-offset); From 60f656b0ec46a4bbe61f877036385ec3af64a9ff Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:07:37 +0530 Subject: [PATCH 05/13] fix(theme): keep the search keycap glyphs out of the DOM text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe's label-content-name-mismatch rule flags the search button (#5401): its visible text is "Search ⌘ K" while its accessible name is "Search". aria-hidden on the keys does not help — the rule counts visually rendered text, since WCAG 2.5.3 is about what sighted speech-input users see and say. The button was arguably conformant as it was: 2.5.3 constrains the label, and the keycaps are a shortcut hint, not the label. Nobody says "click Search command K". The glyphs move to CSS anyway because it costs nothing visible and layers things right: the DOM text now contains exactly the label, so speech software gets no stray "K" target, strict audits pass without a human ruling them false positives, and the decorative hint lives in the presentation layer where decorative content belongs. Screen readers are unaffected — the keys were already aria-hidden — and aria-keyshortcuts keeps carrying the shortcut semantics. Also folds the two display-toggled kbd elements into one whose content switches per platform. Co-Authored-By: Claude Fable 5 --- .../components/VPNavBarSearchButton.vue | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/client/theme-default/components/VPNavBarSearchButton.vue b/src/client/theme-default/components/VPNavBarSearchButton.vue index a9f7e42c..106fbd55 100644 --- a/src/client/theme-default/components/VPNavBarSearchButton.vue +++ b/src/client/theme-default/components/VPNavBarSearchButton.vue @@ -9,9 +9,8 @@ defineProps<{ {{ text }} @@ -27,9 +26,7 @@ defineProps<{ } .text, -.keys, -:root.mac .key-ctrl, -:root:not(.mac) .key-cmd { +.keys { display: none; } @@ -38,6 +35,18 @@ kbd { font-weight: 500; } +.key-mod::before { + content: 'Ctrl'; +} + +:root.mac .key-mod::before { + content: '\2318'; +} + +.key-k::before { + content: 'K'; +} + @media (min-width: 48rem) { .VPNavBarSearchButton { height: auto; From feadd9fcc1519d52a74940f8cd15a71ffcc25fe8 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:08:25 +0530 Subject: [PATCH 06/13] feat: relative base (`./`) and `assetsBase` (CDN prefix) (#5406) Co-authored-by: Claude Fable 5 --- .gitignore | 3 + __tests__/base/cdn.test.ts | 63 ++++++ __tests__/base/constants.ts | 2 + __tests__/base/emit.test.ts | 189 ++++++++++++++++++ __tests__/base/fixture/.vitepress/config.ts | 48 +++++ __tests__/base/fixture/blog.md | 7 + __tests__/base/fixture/img/photo.png | Bin 0 -> 70 bytes __tests__/base/fixture/index.md | 13 ++ __tests__/base/fixture/posts.data.ts | 3 + __tests__/base/fixture/posts/deep/post1.md | 5 + __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 | 60 ++++++ __tests__/base/relative-spa.test.ts | 92 +++++++++ __tests__/base/tsconfig.json | 5 + __tests__/base/vitest.config.ts | 14 ++ __tests__/base/vitestGlobalSetup.ts | 118 +++++++++++ __tests__/tsconfig.json | 2 +- __tests__/unit/node/config.test.ts | 54 ++++- .../unit/node/markdown/plugins/link.test.ts | 76 +++++++ __tests__/unit/shared/shared.test.ts | 46 ++++- docs/en/guide/asset-handling.md | 37 +++- docs/en/guide/deploy.md | 24 +++ docs/en/reference/cli.md | 2 + docs/en/reference/default-theme-search.md | 4 + docs/en/reference/site-config.md | 28 ++- docs/tsconfig.json | 2 +- package.json | 5 +- pnpm-lock.yaml | 6 + src/client/app/composables/preFetch.ts | 5 +- src/client/app/router.ts | 10 +- src/client/app/utils.ts | 39 +++- src/client/shims.d.ts | 1 + src/client/theme-default/components/VPDoc.vue | 15 +- .../components/VPLocalSearchBox.vue | 10 +- src/client/theme-default/support/utils.ts | 12 +- src/node/build/build.ts | 53 ++++- src/node/build/buildMPAClient.ts | 8 + src/node/build/bundle.ts | 33 ++- src/node/build/render.ts | 83 ++++++-- src/node/config.ts | 48 ++++- src/node/markdown/plugins/link.ts | 21 +- src/node/markdownToVue.ts | 1 + src/node/plugin.ts | 8 +- src/node/plugins/assetsBasePlugin.ts | 31 +++ src/node/plugins/localSearchPlugin.ts | 4 +- src/node/plugins/rewritesPlugin.ts | 5 +- src/node/serve/serve.ts | 59 +++++- src/node/server.ts | 4 +- src/node/siteConfig.ts | 19 +- src/shared/shared.ts | 29 +++ tsconfig.client.json | 2 +- tsconfig.json | 18 +- tsconfig.node.json | 2 +- tsconfig.shared.json | 2 +- types/shared.d.ts | 10 +- 62 files changed, 1418 insertions(+), 92 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/blog.md create mode 100644 __tests__/base/fixture/img/photo.png create mode 100644 __tests__/base/fixture/index.md create mode 100644 __tests__/base/fixture/posts.data.ts create mode 100644 __tests__/base/fixture/posts/deep/post1.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..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 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 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