diff --git a/__tests__/base/cdn.test.ts b/__tests__/base/cdn.test.ts
index 60657877..7b8ce80b 100644
--- a/__tests__/base/cdn.test.ts
+++ b/__tests__/base/cdn.test.ts
@@ -31,8 +31,8 @@ describe('assetsBase with a separate cdn origin', () => {
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')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Sub page')
)
expect(
await t.page.evaluate(() => (window as any).__spa_marker === 1)
diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts
index e217fce5..13112979 100644
--- a/__tests__/base/emit.test.ts
+++ b/__tests__/base/emit.test.ts
@@ -107,14 +107,18 @@ describe('assetsBase emit', () => {
expect(html).toMatch(
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(
new RegExp(
``
)
)
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', () => {
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', () => {
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-`
+ )
})
})
diff --git a/__tests__/base/relative-spa.test.ts b/__tests__/base/relative-spa.test.ts
index 4825505c..f7ce9a5d 100644
--- a/__tests__/base/relative-spa.test.ts
+++ b/__tests__/base/relative-spa.test.ts
@@ -23,16 +23,16 @@ describe('relative base served from a deep subpath', () => {
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}`)
+ 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')
+ 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`)
@@ -40,8 +40,8 @@ describe('relative base served from a deep subpath', () => {
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')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Home')
)
expect(await marked()).toBe(true)
// 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`)
await t.page.click('#localsearch-list li[role=option] a')
- await t.page.waitForFunction(
- () => document.querySelector('h1')?.textContent?.includes('Deep page')
+ 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')
+ await t.page.waitForFunction(() =>
+ document.querySelector('h1')?.textContent?.includes('Home')
)
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 mark()
await t.page.click('.vp-doc a[href="./sub/page.html"]')
- await t.page.waitForFunction(
- () => document.querySelector('h1')?.textContent?.includes('Sub page')
+ 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`)
diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts
index 594e905b..90f47184 100644
--- a/__tests__/unit/node/markdown/plugins/link.test.ts
+++ b/__tests__/unit/node/markdown/plugins/link.test.ts
@@ -67,7 +67,11 @@ 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', ...env })
+ md.renderAsync(src, {
+ cleanUrls: false,
+ relativePath: 'guide/page.md',
+ ...env
+ })
test('site-absolute links become page-relative', async () => {
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 () => {
- expect(await render('[x](/other/thing)', { relativePath: 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/asset-handling.md b/docs/en/guide/asset-handling.md
index 63394fd0..b32990dd 100644
--- a/docs/en/guide/asset-handling.md
+++ b/docs/en/guide/asset-handling.md
@@ -44,7 +44,7 @@ All your static asset paths are automatically processed to adjust for different

```
-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:
@@ -65,3 +65,26 @@ const { theme } = useData()
```
+
+## 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.
+:::
diff --git a/docs/en/guide/deploy.md b/docs/en/guide/deploy.md
index d0efa808..8b5811f1 100644
--- a/docs/en/guide/deploy.md
+++ b/docs/en/guide/deploy.md
@@ -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/`.
+## 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//…`), 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
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.
diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md
index d79eb637..bd0c0b8b 100644
--- a/docs/en/reference/site-config.md
+++ b/docs/en/reference/site-config.md
@@ -372,7 +372,9 @@ export default {
- Type: `string`
- 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.
@@ -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 `/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
- Type: `string`
diff --git a/src/shared/shared.ts b/src/shared/shared.ts
index 2d0c4765..a52fe595 100644
--- a/src/shared/shared.ts
+++ b/src/shared/shared.ts
@@ -54,9 +54,7 @@ export function relativePathToRoot(relativePath: string): string {
*/
export function joinPath(base: string, path: string): string {
const protocol = /^(?:[a-z]+:)?\/\//i.exec(base)?.[0] ?? ''
- return (
- protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/')
- )
+ return protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/')
}
export const VP_SOURCE_KEY = '[VP_SOURCE]'