From bcb3172c29db3e4c72af9a139ecd1998ef15364b Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:28:49 +0530 Subject: [PATCH] fix: address adversarial review findings - content-loader html keeps site-absolute links under a relative base: the link plugin now only relativizes renders marked relativizeUrls (page renders), since loader output is embedded into other pages (its source depth is meaningless there) - user hooks never see the build sentinel: content/teleports/assets/head are relativized per page before postRender consumers, transformHead and transformHtml run, and the html handed to transformHtml is final - vitepress-emitted tags no longer consult a user renderBuiltUrl, keeping plain-base output byte-stable and the asset url policy symmetric (renderBuiltUrl still chains for Vite-managed urls via the plugin, now enforce: post so late user plugins cannot clobber it) - preview accepts --assetsBase and mirrors a build made with the flag - --base/--assetsBase without a value fail with a clear message instead of a TypeError - theme normalizeLink appends index.html via the path portion, fixing non-slash-leading, percent-encoded and query-only links - search excerpt map keys handle anchor-less ids - restore the mergeConfig markdown-hook unit tests this branch had accidentally replaced - docs: search ids are site-relative; relative-base notes on raw anchors, content loaders and canonical directory urls Co-Authored-By: Claude Fable 5 --- __tests__/base/emit.test.ts | 10 +++ __tests__/base/fixture/.vitepress/config.ts | 11 +++ __tests__/base/fixture/blog.md | 7 ++ __tests__/base/fixture/posts.data.ts | 3 + __tests__/base/fixture/posts/deep/post1.md | 5 ++ __tests__/unit/node/config.test.ts | 79 ++++++++++++++++++- .../unit/node/markdown/plugins/link.test.ts | 8 +- docs/en/guide/deploy.md | 4 +- docs/en/reference/default-theme-search.md | 4 + .../components/VPLocalSearchBox.vue | 2 +- src/client/theme-default/support/utils.ts | 12 +-- src/node/build/build.ts | 8 ++ src/node/build/render.ts | 69 ++++++++-------- src/node/markdown/plugins/link.ts | 6 +- src/node/markdownToVue.ts | 1 + src/node/plugins/assetsBasePlugin.ts | 3 + src/node/serve/serve.ts | 25 +++--- src/node/server.ts | 2 +- types/shared.d.ts | 7 ++ 19 files changed, 210 insertions(+), 56 deletions(-) create mode 100644 __tests__/base/fixture/blog.md create mode 100644 __tests__/base/fixture/posts.data.ts create mode 100644 __tests__/base/fixture/posts/deep/post1.md diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts index 13112979..9a9b7efd 100644 --- a/__tests__/base/emit.test.ts +++ b/__tests__/base/emit.test.ts @@ -92,6 +92,16 @@ describe('relative base emit', () => { 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', () => { diff --git a/__tests__/base/fixture/.vitepress/config.ts b/__tests__/base/fixture/.vitepress/config.ts index f8090c47..e274c403 100644 --- a/__tests__/base/fixture/.vitepress/config.ts +++ b/__tests__/base/fixture/.vitepress/config.ts @@ -17,6 +17,17 @@ export default defineConfig({ // keep the tiny fixture images as real emitted assets build: { assetsInlineLimit: 0 } }, + // user hooks must only ever see final urls, never the build sentinel + 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: [ diff --git a/__tests__/base/fixture/blog.md b/__tests__/base/fixture/blog.md new file mode 100644 index 00000000..380a9b8d --- /dev/null +++ b/__tests__/base/fixture/blog.md @@ -0,0 +1,7 @@ +# Blog + + + +
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__/unit/node/config.test.ts b/__tests__/unit/node/config.test.ts index a779b34e..3fd32610 100644 --- a/__tests__/unit/node/config.test.ts +++ b/__tests__/unit/node/config.test.ts @@ -1,6 +1,83 @@ -import { normalizeAssetsBase, normalizeSiteBase } from 'node/config' +import type { MarkdownItAsync } from 'markdown-it-async' +import { + mergeConfig, + normalizeAssetsBase, + normalizeSiteBase, + type UserConfig +} from 'node/config' describe('node/config', () => { + test('merges markdown hooks from extended configs', async () => { + const calls: string[] = [] + const md = {} as MarkdownItAsync + + const merged = mergeConfig( + { + markdown: { + lineNumbers: true, + preConfig() { + calls.push('base-pre') + }, + config() { + calls.push('base') + } + } + }, + { + markdown: { + attrs: { + allowed: ['id'] + }, + async preConfig() { + calls.push('extended-pre') + }, + async config() { + calls.push('extended') + } + } + } + ) + + expect(merged.markdown?.lineNumbers).toBe(true) + expect(merged.markdown?.attrs).toEqual({ + allowed: ['id'] + }) + + await merged.markdown?.preConfig?.(md) + await merged.markdown?.config?.(md) + + expect(calls).toEqual(['base-pre', 'extended-pre', 'base', 'extended']) + }) + + test('keeps one-sided markdown hooks when the other config omits them', async () => { + const calls: string[] = [] + const md = {} as MarkdownItAsync + + const merged = mergeConfig( + { + markdown: { + preConfig() { + calls.push('base-pre') + } + } + }, + { + markdown: { + config() { + calls.push('extended') + } + } + } + ) + + await merged.markdown?.preConfig?.(md) + await merged.markdown?.config?.(md) + + 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('/') diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 90f47184..119132c3 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -70,6 +70,7 @@ describe('node/markdown/plugins/link with a relative base', () => { md.renderAsync(src, { cleanUrls: false, relativePath: 'guide/page.md', + relativizeUrls: true, ...env }) @@ -113,7 +114,12 @@ describe('node/markdown/plugins/link with a relative base', () => { ) }) - test('without a page context absolute links are preserved', async () => { + 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"') diff --git a/docs/en/guide/deploy.md b/docs/en/guide/deploy.md index 8b5811f1..d72abc25 100644 --- a/docs/en/guide/deploy.md +++ b/docs/en/guide/deploy.md @@ -73,7 +73,9 @@ A few things to know: - Keep [`cleanUrls`](../reference/site-config#cleanurls) off (the default): portable output needs links that end in `.html`, since there is no server to rewrite pretty URLs. - `404.html` is generated for the root depth. Hosts that serve it as a fallback for arbitrarily deep URLs will render it without styles (there is no correct relative prefix for an unknown depth). - [`head`](../reference/site-config#head) entries are emitted verbatim, as always — avoid root-absolute paths like `/favicon.ico` there and prefer absolute URLs or `transformHead`. -- Raw HTML in Markdown is not rewritten — use Markdown image/link syntax or relative paths inside embedded HTML. +- Raw HTML `` tags in Markdown keep their `href` as written — use Markdown link syntax for site-absolute links (embedded `` sources go through the asset pipeline and are handled). +- Links created by [`createContentLoader`](./data-loading#createcontentloader) content stay site-absolute (their HTML is embedded into other pages, so no single relative prefix is correct) — they resolve only for a root mount. +- Serve pages at their canonical URLs: the root as `/dir/` (not `/dir`), and no added trailing slashes on page URLs. The relative prefix is resolved against the URL the browser actually shows, and virtually all static hosts canonicalize this way already. - The dev server always serves at `/`; the relative behavior applies to the production build. ## HTTP Cache Headers diff --git a/docs/en/reference/default-theme-search.md b/docs/en/reference/default-theme-search.md index d383288e..e497d84c 100644 --- a/docs/en/reference/default-theme-search.md +++ b/docs/en/reference/default-theme-search.md @@ -112,6 +112,10 @@ export default defineConfig({ Learn more in [MiniSearch docs](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html). +::: info Document IDs +Search document IDs (as seen by `searchOptions.filter`, `boostDocument`, and in the raw index) are site-relative paths like `/guide/page.html#section` — they do not include [`base`](../reference/site-config#base). The theme resolves them against the base when rendering results. +::: + ### Custom content renderer You can customize the function used to render the markdown content before indexing it: diff --git a/src/client/theme-default/components/VPLocalSearchBox.vue b/src/client/theme-default/components/VPLocalSearchBox.vue index ac1533b2..af727577 100644 --- a/src/client/theme-default/components/VPLocalSearchBox.vue +++ b/src/client/theme-default/components/VPLocalSearchBox.vue @@ -177,7 +177,7 @@ watchDebounced( : [] if (canceled) return for (const { id, mod } of mods) { - const mapId = id.slice(0, id.indexOf('#')) + const mapId = id.replace(/#.*$/, '') let map = cache.get(mapId) if (map) continue map = new Map() diff --git a/src/client/theme-default/support/utils.ts b/src/client/theme-default/support/utils.ts index 098ad21f..1c03d453 100644 --- a/src/client/theme-default/support/utils.ts +++ b/src/client/theme-default/support/utils.ts @@ -57,14 +57,14 @@ export function normalizeLink(url: string): string { )}${search}${hash}` ) - if ( - isRelativeBase(site.value.base) && - !site.value.cleanUrls && - pathname.endsWith('/') - ) { + if (isRelativeBase(site.value.base) && !site.value.cleanUrls) { // file:// has no directory index; the router strips index.html back // out of the address bar on navigation - normalizedPath = normalizedPath.replace(pathname, pathname + 'index.html') + const pathPart = normalizedPath.replace(/[?#].*$/, '') + if (pathPart.endsWith('/')) { + normalizedPath = + pathPart + 'index.html' + normalizedPath.slice(pathPart.length) + } } return withBase(normalizedPath) diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 50406fc3..12fa1abb 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -59,11 +59,19 @@ export async function build( const unlinkVue = await linkVue() if (buildOptions.base) { + if (typeof buildOptions.base !== 'string') { + throw new Error('--base requires a value (e.g. --base /docs/)') + } siteConfig.site.base = normalizeSiteBase(buildOptions.base) delete buildOptions.base } if (buildOptions.assetsBase) { + if (typeof buildOptions.assetsBase !== 'string') { + throw new Error( + '--assetsBase requires a value (e.g. --assetsBase https://cdn.example.com/)' + ) + } siteConfig.assetsBase = normalizeAssetsBase(buildOptions.assetsBase) delete buildOptions.assetsBase } diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 80bb0cfa..c2833a13 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -41,7 +41,7 @@ export async function renderPage( // render page const context = await render(routePath) - const { content, teleports, vpSocialIcons } = + let { content, teleports, vpSocialIcons } = (await config.postRender?.(context)) ?? context // add used social icons to the set @@ -80,23 +80,21 @@ export async function renderPage( // own ../-prefix; otherwise this is just the configured base const pageBase = relativeBase ? relativePathToRoot(page) : siteData.base - const userBuiltUrl = config.vite?.experimental?.renderBuiltUrl - const htmlPath = page.replace(/\.md$/, '.html') - const assetUrl = (file: string) => { - const userResult = userBuiltUrl?.(file, { - type: 'asset', - hostType: 'html', - hostId: htmlPath, - ssr: false - }) - if (typeof userResult === 'string') return userResult - return (config.assetsBase ?? pageBase) + file - } + const assetUrl = (file: string) => (config.assetsBase ?? pageBase) + file const assetsCrossOrigin = config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase) ? ' crossorigin' : '' + // user hooks must never see the build sentinel + const desentinel = (value: string) => + relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value + const pageAssets = relativeBase ? assets.map(desentinel) : assets + if (relativeBase) { + content = desentinel(content) + if (teleports?.body) teleports.body = desentinel(teleports.body) + } + const title: string = createTitle(siteData, pageData) const description: string = pageData.description || siteData.description const stylesheetLink = cssChunk @@ -144,8 +142,12 @@ export async function renderPage( const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload') const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch') + const pageHeadTags: HeadConfig[] = relativeBase + ? JSON.parse(desentinel(JSON.stringify(additionalHeadTags))) + : additionalHeadTags + const headBeforeTransform = [ - ...additionalHeadTags, + ...pageHeadTags, ...preloadHeadTags, ...prefetchHeadTags, ...mergeHead( @@ -165,7 +167,7 @@ export async function renderPage( description, head: headBeforeTransform, content, - assets + assets: pageAssets })) || [] ) @@ -232,24 +234,25 @@ export async function renderPage( const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) await mkdir(path.dirname(htmlFileName), { recursive: true }) - const transformedHtml = await config.transformHtml?.(html, htmlFileName, { - page, - siteConfig: config, - siteData, - pageData, - title, - description, - head, - content, - assets - }) - let finalHtml = transformedHtml || html - if (relativeBase) { - // last step, after transformHtml, so sentinel urls a transform injects - // (e.g. from the `assets` array) are relativized too - finalHtml = finalHtml.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) - } - await writeFile(htmlFileName, finalHtml) + // relativized before the hook: transforms see (and may inject) final + // urls, never the build sentinel + const finalHtml = desentinel(html) + const transformedHtml = await config.transformHtml?.( + finalHtml, + htmlFileName, + { + page, + siteConfig: config, + siteData, + pageData, + title, + description, + head, + content, + assets: pageAssets + } + ) + await writeFile(htmlFileName, transformedHtml || finalHtml) } async function resolvePageImports( diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index 52422607..6ddbc6c5 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -87,9 +87,9 @@ export const linkPlugin = ( if (isRelativeBase(base)) { // resolve site-absolute links relative to this page so the // output is identical in both builds and correct at any mount - // point; without a page context (content loaders) the - // site-absolute form is the only meaningful one — keep it - if (env.relativePath != null) { + // point; content-loader output is embedded in other pages, so + // there the site-absolute form is the only meaningful one + if (env.relativizeUrls && env.relativePath != null) { hrefAttr[1] = relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1) } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 8586fb1b..22e5cb4b 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -158,6 +158,7 @@ export async function createMarkdownToVueRenderFn( path: file, relativePath, cleanUrls, + relativizeUrls: true, includes: [], realPath: fileOrig, localeIndex diff --git a/src/node/plugins/assetsBasePlugin.ts b/src/node/plugins/assetsBasePlugin.ts index 1b3c70e7..dd45dcf9 100644 --- a/src/node/plugins/assetsBasePlugin.ts +++ b/src/node/plugins/assetsBasePlugin.ts @@ -15,6 +15,9 @@ export type RenderBuiltUrl = NonNullable< export function assetsBasePlugin(config: SiteConfig): Plugin { return { name: 'vitepress:assets-base', + // post + appended last: the config hook must run after every user + // plugin so it chains behind (not under) their renderBuiltUrl + enforce: 'post', config(userConfig, env) { if (env.command !== 'build') return const userHook = userConfig.experimental?.renderBuiltUrl diff --git a/src/node/serve/serve.ts b/src/node/serve/serve.ts index cca2c8a2..1438f6fc 100644 --- a/src/node/serve/serve.ts +++ b/src/node/serve/serve.ts @@ -5,12 +5,13 @@ import compression from '@polka/compression' import polka, { type IOptions } from 'polka' import sirv from 'sirv' -import { resolveConfig } from '../config' +import { normalizeAssetsBase, resolveConfig } from '../config' import { EXTERNAL_URL_RE, isRelativeBase } from '../shared' import { readFile } from '../utils/fs' export interface ServeOptions { base?: string + assetsBase?: string root?: string port?: number } @@ -19,7 +20,16 @@ export async function serve(options: ServeOptions = {}) { const port = options.port ?? 4173 const config = await resolveConfig(options.root, 'serve', 'production') - let rawBase = options?.base ?? config?.site?.base ?? '/' + // a build may have been made with --assetsBase; let preview mirror it + const assetsBase = + typeof options.assetsBase === 'string' + ? normalizeAssetsBase(options.assetsBase) + : config.assetsBase + + let rawBase = + (typeof options.base === 'string' ? options.base : undefined) ?? + config?.site?.base ?? + '/' if (isRelativeBase(rawBase)) { // a relocatable build works at any mount point; serve it at the root rawBase = '/' @@ -59,18 +69,15 @@ export async function serve(options: ServeOptions = {}) { const app = polka({ onNoMatch }) - if (config.assetsBase) { - if (EXTERNAL_URL_RE.test(config.assetsBase)) { + if (assetsBase) { + if (EXTERNAL_URL_RE.test(assetsBase)) { config.logger.info( - `assetsBase is external (${config.assetsBase}) — assets will be ` + + `assetsBase is external (${assetsBase}) — assets will be ` + `requested from that URL, not from this preview server.` ) } else { // mirror the asset subtree at the configured prefix - const assetsPath = `${config.assetsBase}${config.assetsDir}`.replace( - /\/+$/, - '' - ) + const assetsPath = `${assetsBase}${config.assetsDir}`.replace(/\/+$/, '') app.use( assetsPath, compress, diff --git a/src/node/server.ts b/src/node/server.ts index 91f7e393..6ad0d1af 100644 --- a/src/node/server.ts +++ b/src/node/server.ts @@ -12,7 +12,7 @@ export async function createServer( config ??= await resolveConfig(root) const { base, ...server } = serverOptions - if (base != null) config.site.base = normalizeSiteBase(base) + if (typeof base === 'string') config.site.base = normalizeSiteBase(base) return createViteServer({ root: config.srcDir, diff --git a/types/shared.d.ts b/types/shared.d.ts index b60fe74f..adffdfe4 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -587,6 +587,13 @@ export interface MarkdownEnv { * Whether clean URLs are enabled. */ cleanUrls: boolean + /** + * Whether the rendered HTML is emitted at `relativePath`'s location, so + * site-absolute links may be rewritten relative to it (page renders set + * this; content-loader output is embedded in other pages, so it must not). + * @internal + */ + relativizeUrls?: boolean /** * The URLs of the links collected from the page for the dead link check. */