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 .
+
+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.
*/