docs: relative base and assetsBase

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5406/head
Divyansh Singh 2 weeks ago
parent 826527709d
commit 9e1943ed6f

@ -31,8 +31,8 @@ describe('assetsBase with a separate cdn origin', () => {
test('client-side navigation loads page chunks from the cdn', async () => { test('client-side navigation loads page chunks from the cdn', async () => {
await t.page.evaluate(() => ((window as any).__spa_marker = 1)) await t.page.evaluate(() => ((window as any).__spa_marker = 1))
await t.page.click('.vp-doc a[href="/sub/page.html"]') await t.page.click('.vp-doc a[href="/sub/page.html"]')
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Sub page') document.querySelector('h1')?.textContent?.includes('Sub page')
) )
expect( expect(
await t.page.evaluate(() => (window as any).__spa_marker === 1) await t.page.evaluate(() => (window as any).__spa_marker === 1)

@ -107,14 +107,18 @@ describe('assetsBase emit', () => {
expect(html).toMatch( expect(html).toMatch(
new RegExp(`src="${cdn()}assets/chunks/metadata\\.[\\w-]+\\.js"`) new RegExp(`src="${cdn()}assets/chunks/metadata\\.[\\w-]+\\.js"`)
) )
expect(html).toMatch(new RegExp(`href="${cdn()}assets/style\\.[\\w-]+\\.css"`)) expect(html).toMatch(
new RegExp(`href="${cdn()}assets/style\\.[\\w-]+\\.css"`)
)
expect(html).toMatch( expect(html).toMatch(
new RegExp( new RegExp(
`<link rel="modulepreload" href="${cdn()}assets/chunks/[^"]+" crossorigin="">` `<link rel="modulepreload" href="${cdn()}assets/chunks/[^"]+" crossorigin="">`
) )
) )
expect(html).toMatch( expect(html).toMatch(
new RegExp(`rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"`) new RegExp(
`rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"`
)
) )
}) })
@ -128,12 +132,16 @@ describe('assetsBase emit', () => {
test('hashed content assets move to the cdn', () => { test('hashed content assets move to the cdn', () => {
const html = read('cdn', 'index.html') const html = read('cdn', 'index.html')
expect(html).toMatch(new RegExp(`src="${cdn()}assets/photo\\.[\\w-]+\\.png"`)) expect(html).toMatch(
new RegExp(`src="${cdn()}assets/photo\\.[\\w-]+\\.png"`)
)
}) })
test('fonts referenced from css move to the cdn', () => { test('fonts referenced from css move to the cdn', () => {
const cssFile = walk(dist('cdn', 'assets')).find((f) => f.endsWith('.css'))! const cssFile = walk(dist('cdn', 'assets')).find((f) => f.endsWith('.css'))!
expect(readFileSync(cssFile, 'utf-8')).toContain(`url(${cdn()}assets/inter-`) expect(readFileSync(cssFile, 'utf-8')).toContain(
`url(${cdn()}assets/inter-`
)
}) })
}) })

@ -23,16 +23,16 @@ describe('relative base served from a deep subpath', () => {
await t.page.goto(`${origin()}${SUB_PREFIX}sub/deep/page2.html`) await t.page.goto(`${origin()}${SUB_PREFIX}sub/deep/page2.html`)
await waitForHydration(t.page) await waitForHydration(t.page)
expect(await t.page.textContent('h1')).toContain('Deep page') expect(await t.page.textContent('h1')).toContain('Deep page')
expect( expect(await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__)).toBe(
await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__) `${origin()}${SUB_PREFIX}`
).toBe(`${origin()}${SUB_PREFIX}`) )
}) })
test('sidebar navigation is client-side and lands on the right url', async () => { test('sidebar navigation is client-side and lands on the right url', async () => {
await mark() await mark()
await t.page.click(`.VPSidebar a[href="${SUB_PREFIX}sub/page.html"]`) await t.page.click(`.VPSidebar a[href="${SUB_PREFIX}sub/page.html"]`)
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Sub page') document.querySelector('h1')?.textContent?.includes('Sub page')
) )
expect(await marked()).toBe(true) expect(await marked()).toBe(true)
expect(new URL(t.page.url()).pathname).toBe(`${SUB_PREFIX}sub/page.html`) expect(new URL(t.page.url()).pathname).toBe(`${SUB_PREFIX}sub/page.html`)
@ -40,8 +40,8 @@ describe('relative base served from a deep subpath', () => {
test('content links navigate client-side', async () => { test('content links navigate client-side', async () => {
await t.page.click('.vp-doc a[href="../index.html"]') await t.page.click('.vp-doc a[href="../index.html"]')
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Home') document.querySelector('h1')?.textContent?.includes('Home')
) )
expect(await marked()).toBe(true) expect(await marked()).toBe(true)
// the router strips index.html from the address bar // the router strips index.html from the address bar
@ -59,16 +59,16 @@ describe('relative base served from a deep subpath', () => {
) )
expect(href).toBe(`${SUB_PREFIX}sub/deep/page2.html#deep-heading`) expect(href).toBe(`${SUB_PREFIX}sub/deep/page2.html#deep-heading`)
await t.page.click('#localsearch-list li[role=option] a') await t.page.click('#localsearch-list li[role=option] a')
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Deep page') document.querySelector('h1')?.textContent?.includes('Deep page')
) )
expect(await marked()).toBe(true) expect(await marked()).toBe(true)
}) })
test('history back keeps working', async () => { test('history back keeps working', async () => {
await t.page.goBack() await t.page.goBack()
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Home') document.querySelector('h1')?.textContent?.includes('Home')
) )
expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX) expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX)
}) })
@ -78,8 +78,8 @@ describe('relative base served from a deep subpath', () => {
await waitForHydration(t.page) await waitForHydration(t.page)
await mark() await mark()
await t.page.click('.vp-doc a[href="./sub/page.html"]') await t.page.click('.vp-doc a[href="./sub/page.html"]')
await t.page.waitForFunction( await t.page.waitForFunction(() =>
() => document.querySelector('h1')?.textContent?.includes('Sub page') document.querySelector('h1')?.textContent?.includes('Sub page')
) )
expect(await marked()).toBe(true) expect(await marked()).toBe(true)
expect(new URL(t.page.url()).pathname).toBe(`${ALT_PREFIX}sub/page.html`) expect(new URL(t.page.url()).pathname).toBe(`${ALT_PREFIX}sub/page.html`)

@ -67,7 +67,11 @@ describe('node/markdown/plugins/link with a relative base', () => {
const md = new MarkdownItAsync() const md = new MarkdownItAsync()
linkPlugin(md, {}, './', slugify) linkPlugin(md, {}, './', slugify)
const render = (src: string, env: object = {}) => const render = (src: string, env: object = {}) =>
md.renderAsync(src, { cleanUrls: false, relativePath: 'guide/page.md', ...env }) md.renderAsync(src, {
cleanUrls: false,
relativePath: 'guide/page.md',
...env
})
test('site-absolute links become page-relative', async () => { test('site-absolute links become page-relative', async () => {
expect(await render('[x](/other/thing)')).toContain( expect(await render('[x](/other/thing)')).toContain(
@ -110,9 +114,9 @@ describe('node/markdown/plugins/link with a relative base', () => {
}) })
test('without a page context absolute links are preserved', async () => { test('without a page context absolute links are preserved', async () => {
expect(await render('[x](/other/thing)', { relativePath: undefined })).toContain( expect(
'href="/other/thing.html"' await render('[x](/other/thing)', { relativePath: undefined })
) ).toContain('href="/other/thing.html"')
}) })
}) })

@ -44,7 +44,7 @@ All your static asset paths are automatically processed to adjust for different
![An image](/image-inside-public.png) ![An image](/image-inside-public.png)
``` ```
You do **not** need to update it when you change the `base` config value in this case. You do **not** need to update it when you change the `base` config value in this case. This includes a relative base (`'./'`), which makes the whole build [relocatable](./deploy#relocatable-builds-relative-base).
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: 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:
@ -65,3 +65,26 @@ const { theme } = useData()
<img :src="withBase(theme.logoPath)" /> <img :src="withBase(theme.logoPath)" />
</template> </template>
``` ```
## Serving Assets from a CDN
To serve the generated assets — scripts, styles, fonts, and images imported from Markdown or components — from a different origin than the pages, set [`assetsBase`](../reference/site-config#assetsbase):
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
}
```
Upload the `assets` directory from the build output to the CDN so it is reachable at `https://cdn.example.com/assets/`, and deploy the rest of the output to your site as usual. Files in `public` are referenced from `base` and stay with the pages.
Since the value is often environment-specific, it can also be passed on the command line:
```sh
vitepress build docs --assetsBase "$CDN_URL"
```
::: warning CORS Required
Module scripts are always fetched in CORS mode, so a cross-origin CDN must respond with an appropriate `Access-Control-Allow-Origin` header.
:::

@ -54,6 +54,28 @@ By default, we assume the site is going to be deployed at the root path of a dom
**Example:** If you're using Github (or GitLab) Pages and deploying to `user.github.io/repo/`, then set your `base` to `/repo/`. **Example:** If you're using Github (or GitLab) Pages and deploying to `user.github.io/repo/`, then set your `base` to `/repo/`.
## Relocatable Builds (Relative Base) {#relocatable-builds-relative-base}
When the final URL of the site isn't known at build time — an IPFS gateway (`https://gateway/ipfs/<cid>/…`), the Wayback Machine, a shared folder, docs bundled into an app — set `base` to `'./'`:
```ts
export default {
base: './'
}
```
Every page then references assets and other pages relative to its own location, and the client runtime recovers the real mount point when the page loads. The same build works from **any** sub path without rebuilding — including several at once — with routing, search and prefetching fully functional.
Opening the generated HTML files straight from the file system (`file://`) also works as a styled, fully navigable static site. Browsers block JavaScript modules over `file://`, so there is no hydration there — interactive features like search stay inactive, while all pre-rendered content and links keep working.
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.
- The dev server always serves at `/`; the relative behavior applies to the production build.
## HTTP Cache Headers ## HTTP Cache Headers
If you have control over the HTTP headers on your production server, you can configure `cache-control` headers to achieve better performance on repeated visits. If you have control over the HTTP headers on your production server, you can configure `cache-control` headers to achieve better performance on repeated visits.

@ -372,7 +372,9 @@ export default {
- Type: `string` - Type: `string`
- Default: `/` - Default: `/`
The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash. Relative bases are not supported. The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash.
Setting base to `'./'` produces a [relocatable build](../guide/deploy#relocatable-builds-relative-base) whose pages reference everything relative to their own location, so the same output works from any sub path (IPFS gateways, archives) without rebuilding and stays browsable when opened directly from the file system.
The base is automatically prepended to all the URLs that start with / in other options, so you only need to specify it once. The base is automatically prepended to all the URLs that start with / in other options, so you only need to specify it once.
@ -463,6 +465,28 @@ export default {
} }
``` ```
### assetsBase
- Type: `string`
- Default: `undefined`
URL prefix the generated assets (everything under [`assetsDir`](#assetsdir)) are served from — typically a CDN. Must be an absolute URL, a protocol-relative URL, or a root-absolute path; a trailing slash is appended if missing.
```ts
export default {
base: '/',
assetsBase: 'https://cdn.example.com/'
// scripts, styles, fonts and imported images resolve to
// https://cdn.example.com/assets/*
}
```
The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `<assetsBase>/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files, `hashmap.json` and `vp-icons.css` stay on [`base`](#base).
When `assetsBase` points at another origin, VitePress adds `crossorigin` to the emitted script and preload tags — the CDN must send `Access-Control-Allow-Origin` for your site's origin (module scripts are always fetched in CORS mode).
Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`.
### cacheDir ### cacheDir
- Type: `string` - Type: `string`

@ -54,9 +54,7 @@ export function relativePathToRoot(relativePath: string): string {
*/ */
export function joinPath(base: string, path: string): string { export function joinPath(base: string, path: string): string {
const protocol = /^(?:[a-z]+:)?\/\//i.exec(base)?.[0] ?? '' const protocol = /^(?:[a-z]+:)?\/\//i.exec(base)?.[0] ?? ''
return ( return protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/')
protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/')
)
} }
export const VP_SOURCE_KEY = '[VP_SOURCE]' export const VP_SOURCE_KEY = '[VP_SOURCE]'

Loading…
Cancel
Save