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 <noreply@anthropic.com>
pull/5406/head
Divyansh Singh 2 weeks ago
parent 9e1943ed6f
commit bcb3172c29

@ -92,6 +92,16 @@ describe('relative base emit', () => {
expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__') 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', () => { describe('assetsBase emit', () => {

@ -17,6 +17,17 @@ export default defineConfig({
// keep the tiny fixture images as real emitted assets // keep the tiny fixture images as real emitted assets
build: { assetsInlineLimit: 0 } 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: { themeConfig: {
nav: [{ text: 'Guide', link: '/sub/page' }], nav: [{ text: 'Guide', link: '/sub/page' }],
sidebar: [ sidebar: [

@ -0,0 +1,7 @@
# Blog
<script setup>
import { data } from './posts.data.ts'
</script>
<div v-for="p in data" :key="p.url" class="post-excerpt" v-html="p.html"></div>

@ -0,0 +1,3 @@
import { createContentLoader } from 'vitepress'
export default createContentLoader('posts/**/*.md', { render: true })

@ -0,0 +1,5 @@
# Post one
This is the intro of post one with a [site link](/sub/page) and ![img](/logo.png).
More body.

@ -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', () => { describe('node/config', () => {
test('merges markdown hooks from extended configs', async () => {
const calls: string[] = []
const md = {} as MarkdownItAsync
const merged = mergeConfig<UserConfig, UserConfig>(
{
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<UserConfig, UserConfig>(
{
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', () => { describe('normalizeSiteBase', () => {
test('defaults to / and appends the trailing slash', () => { test('defaults to / and appends the trailing slash', () => {
expect(normalizeSiteBase(undefined)).toBe('/') expect(normalizeSiteBase(undefined)).toBe('/')

@ -70,6 +70,7 @@ describe('node/markdown/plugins/link with a relative base', () => {
md.renderAsync(src, { md.renderAsync(src, {
cleanUrls: false, cleanUrls: false,
relativePath: 'guide/page.md', relativePath: 'guide/page.md',
relativizeUrls: true,
...env ...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( expect(
await render('[x](/other/thing)', { relativePath: undefined }) await render('[x](/other/thing)', { relativePath: undefined })
).toContain('href="/other/thing.html"') ).toContain('href="/other/thing.html"')

@ -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. - 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). - `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`. - [`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 `<a>` tags in Markdown keep their `href` as written — use Markdown link syntax for site-absolute links (embedded `<img>` 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. - The dev server always serves at `/`; the relative behavior applies to the production build.
## HTTP Cache Headers ## HTTP Cache Headers

@ -112,6 +112,10 @@ export default defineConfig({
Learn more in [MiniSearch docs](https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html). 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 ### Custom content renderer
You can customize the function used to render the markdown content before indexing it: You can customize the function used to render the markdown content before indexing it:

@ -177,7 +177,7 @@ watchDebounced(
: [] : []
if (canceled) return if (canceled) return
for (const { id, mod } of mods) { for (const { id, mod } of mods) {
const mapId = id.slice(0, id.indexOf('#')) const mapId = id.replace(/#.*$/, '')
let map = cache.get(mapId) let map = cache.get(mapId)
if (map) continue if (map) continue
map = new Map() map = new Map()

@ -57,14 +57,14 @@ export function normalizeLink(url: string): string {
)}${search}${hash}` )}${search}${hash}`
) )
if ( if (isRelativeBase(site.value.base) && !site.value.cleanUrls) {
isRelativeBase(site.value.base) &&
!site.value.cleanUrls &&
pathname.endsWith('/')
) {
// file:// has no directory index; the router strips index.html back // file:// has no directory index; the router strips index.html back
// out of the address bar on navigation // 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) return withBase(normalizedPath)

@ -59,11 +59,19 @@ export async function build(
const unlinkVue = await linkVue() const unlinkVue = await linkVue()
if (buildOptions.base) { 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) siteConfig.site.base = normalizeSiteBase(buildOptions.base)
delete buildOptions.base delete buildOptions.base
} }
if (buildOptions.assetsBase) { 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) siteConfig.assetsBase = normalizeAssetsBase(buildOptions.assetsBase)
delete buildOptions.assetsBase delete buildOptions.assetsBase
} }

@ -41,7 +41,7 @@ export async function renderPage(
// render page // render page
const context = await render(routePath) const context = await render(routePath)
const { content, teleports, vpSocialIcons } = let { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context (await config.postRender?.(context)) ?? context
// add used social icons to the set // add used social icons to the set
@ -80,23 +80,21 @@ export async function renderPage(
// own ../-prefix; otherwise this is just the configured base // own ../-prefix; otherwise this is just the configured base
const pageBase = relativeBase ? relativePathToRoot(page) : siteData.base const pageBase = relativeBase ? relativePathToRoot(page) : siteData.base
const userBuiltUrl = config.vite?.experimental?.renderBuiltUrl const assetUrl = (file: string) => (config.assetsBase ?? pageBase) + file
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 assetsCrossOrigin = const assetsCrossOrigin =
config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase) config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase)
? ' crossorigin' ? ' 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 title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description const description: string = pageData.description || siteData.description
const stylesheetLink = cssChunk const stylesheetLink = cssChunk
@ -144,8 +142,12 @@ export async function renderPage(
const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload') const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload')
const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch') const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch')
const pageHeadTags: HeadConfig[] = relativeBase
? JSON.parse(desentinel(JSON.stringify(additionalHeadTags)))
: additionalHeadTags
const headBeforeTransform = [ const headBeforeTransform = [
...additionalHeadTags, ...pageHeadTags,
...preloadHeadTags, ...preloadHeadTags,
...prefetchHeadTags, ...prefetchHeadTags,
...mergeHead( ...mergeHead(
@ -165,7 +167,7 @@ export async function renderPage(
description, description,
head: headBeforeTransform, head: headBeforeTransform,
content, content,
assets assets: pageAssets
})) || [] })) || []
) )
@ -232,24 +234,25 @@ export async function renderPage(
const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html'))
await mkdir(path.dirname(htmlFileName), { recursive: true }) await mkdir(path.dirname(htmlFileName), { recursive: true })
const transformedHtml = await config.transformHtml?.(html, htmlFileName, { // relativized before the hook: transforms see (and may inject) final
page, // urls, never the build sentinel
siteConfig: config, const finalHtml = desentinel(html)
siteData, const transformedHtml = await config.transformHtml?.(
pageData, finalHtml,
title, htmlFileName,
description, {
head, page,
content, siteConfig: config,
assets siteData,
}) pageData,
let finalHtml = transformedHtml || html title,
if (relativeBase) { description,
// last step, after transformHtml, so sentinel urls a transform injects head,
// (e.g. from the `assets` array) are relativized too content,
finalHtml = finalHtml.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) assets: pageAssets
} }
await writeFile(htmlFileName, finalHtml) )
await writeFile(htmlFileName, transformedHtml || finalHtml)
} }
async function resolvePageImports( async function resolvePageImports(

@ -87,9 +87,9 @@ export const linkPlugin = (
if (isRelativeBase(base)) { if (isRelativeBase(base)) {
// resolve site-absolute links relative to this page so the // resolve site-absolute links relative to this page so the
// output is identical in both builds and correct at any mount // output is identical in both builds and correct at any mount
// point; without a page context (content loaders) the // point; content-loader output is embedded in other pages, so
// site-absolute form is the only meaningful one — keep it // there the site-absolute form is the only meaningful one
if (env.relativePath != null) { if (env.relativizeUrls && env.relativePath != null) {
hrefAttr[1] = hrefAttr[1] =
relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1) relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1)
} }

@ -158,6 +158,7 @@ export async function createMarkdownToVueRenderFn(
path: file, path: file,
relativePath, relativePath,
cleanUrls, cleanUrls,
relativizeUrls: true,
includes: [], includes: [],
realPath: fileOrig, realPath: fileOrig,
localeIndex localeIndex

@ -15,6 +15,9 @@ export type RenderBuiltUrl = NonNullable<
export function assetsBasePlugin(config: SiteConfig): Plugin { export function assetsBasePlugin(config: SiteConfig): Plugin {
return { return {
name: 'vitepress:assets-base', 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) { config(userConfig, env) {
if (env.command !== 'build') return if (env.command !== 'build') return
const userHook = userConfig.experimental?.renderBuiltUrl const userHook = userConfig.experimental?.renderBuiltUrl

@ -5,12 +5,13 @@ import compression from '@polka/compression'
import polka, { type IOptions } from 'polka' import polka, { type IOptions } from 'polka'
import sirv from 'sirv' import sirv from 'sirv'
import { resolveConfig } from '../config' import { normalizeAssetsBase, resolveConfig } from '../config'
import { EXTERNAL_URL_RE, isRelativeBase } from '../shared' import { EXTERNAL_URL_RE, isRelativeBase } from '../shared'
import { readFile } from '../utils/fs' import { readFile } from '../utils/fs'
export interface ServeOptions { export interface ServeOptions {
base?: string base?: string
assetsBase?: string
root?: string root?: string
port?: number port?: number
} }
@ -19,7 +20,16 @@ export async function serve(options: ServeOptions = {}) {
const port = options.port ?? 4173 const port = options.port ?? 4173
const config = await resolveConfig(options.root, 'serve', 'production') 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)) { if (isRelativeBase(rawBase)) {
// a relocatable build works at any mount point; serve it at the root // a relocatable build works at any mount point; serve it at the root
rawBase = '/' rawBase = '/'
@ -59,18 +69,15 @@ export async function serve(options: ServeOptions = {}) {
const app = polka({ onNoMatch }) const app = polka({ onNoMatch })
if (config.assetsBase) { if (assetsBase) {
if (EXTERNAL_URL_RE.test(config.assetsBase)) { if (EXTERNAL_URL_RE.test(assetsBase)) {
config.logger.info( 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.` `requested from that URL, not from this preview server.`
) )
} else { } else {
// mirror the asset subtree at the configured prefix // mirror the asset subtree at the configured prefix
const assetsPath = `${config.assetsBase}${config.assetsDir}`.replace( const assetsPath = `${assetsBase}${config.assetsDir}`.replace(/\/+$/, '')
/\/+$/,
''
)
app.use( app.use(
assetsPath, assetsPath,
compress, compress,

@ -12,7 +12,7 @@ export async function createServer(
config ??= await resolveConfig(root) config ??= await resolveConfig(root)
const { base, ...server } = serverOptions const { base, ...server } = serverOptions
if (base != null) config.site.base = normalizeSiteBase(base) if (typeof base === 'string') config.site.base = normalizeSiteBase(base)
return createViteServer({ return createViteServer({
root: config.srcDir, root: config.srcDir,

7
types/shared.d.ts vendored

@ -587,6 +587,13 @@ export interface MarkdownEnv {
* Whether clean URLs are enabled. * Whether clean URLs are enabled.
*/ */
cleanUrls: boolean 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. * The URLs of the links collected from the page for the dead link check.
*/ */

Loading…
Cancel
Save