Merge branch 'main' into feat/docsearch-v5

pull/5402/head
Paul Jankowski 1 week ago committed by GitHub
commit f329b2d3f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

3
.gitignore vendored

@ -17,3 +17,6 @@ pnpm-global
TODOs.md
*.timestamp-*.mjs
.claude
# base fixture builds
__tests__/base/fixture/.vitepress/dist-*

File diff suppressed because it is too large Load Diff

@ -0,0 +1,63 @@
import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
const origin = () => `http://localhost:${process.env['PAGES_PORT']}`
const cdnPort = () => process.env['VP_CDN_PORT']
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
describe('assetsBase with a separate cdn origin', () => {
test('pages hydrate from cross-origin assets', async () => {
await t.page.goto(`${origin()}/`)
await waitForHydration(t.page)
const cdnResources = await t.page.evaluate(
(port) =>
performance
.getEntriesByType('resource')
.filter((r) => r.name.includes(`:${port}/`)).length,
cdnPort()
)
expect(cdnResources).toBeGreaterThan(5)
})
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')
)
expect(
await t.page.evaluate(() => (window as any).__spa_marker === 1)
).toBe(true)
const chunkFromCdn = await t.page.evaluate(
(port) =>
performance
.getEntriesByType('resource')
.some((r) => r.name.includes(`:${port}/`) && r.name.includes('.md.')),
cdnPort()
)
expect(chunkFromCdn).toBe(true)
})
test('search works with the index chunk on the cdn', async () => {
await t.page.click('.VPNavBarSearchButton')
const input = await t.page.waitForSelector('input#localsearch-input')
await input.type('xylophone')
await t.page.waitForSelector('#localsearch-list li[role=option] a')
expect(
await t.page.getAttribute('#localsearch-list li[role=option] a', 'href')
).toBe('/sub/deep/page2.html#deep-heading')
})
test('no console or page errors across the whole flow', () => {
expect(realErrors(t.errors)).toEqual([])
})
})

@ -0,0 +1,2 @@
export const SUB_PREFIX = '/ipfs/QmRelocatableTest123/'
export const ALT_PREFIX = '/some/other/place/'

@ -0,0 +1,210 @@
import { readFileSync, readdirSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const dir = resolve(fileURLToPath(import.meta.url), '..')
const dist = (mode: string, ...p: string[]) =>
join(dir, `fixture/.vitepress/dist-${mode}`, ...p)
const read = (mode: string, file: string) =>
readFileSync(dist(mode, file), 'utf-8')
const walk = (root: string): string[] =>
readdirSync(root, { recursive: true, withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => join(e.parentPath, e.name))
describe('relative base emit', () => {
test('root page references everything at ./', () => {
const html = read('relative', 'index.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("./",location).href'
)
expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/)
expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/)
expect(html).toMatch(/href="\.\/assets\/vp-icons\.[\w-]+\.css"/)
expect(
walk(dist('relative', 'assets')).some((f) =>
/vp-icons\.[\w-]+\.css$/.test(f)
)
).toBe(true)
})
test('markdown links compile page-relative with explicit index.html', () => {
const html = read('relative', 'index.html')
expect(html).toContain('href="./sub/page.html"')
expect(html).toContain('href="./sub/index.html"')
expect(html).toContain('href="./moved/target.html"')
})
test('non-page links get the prefix but no .html', () => {
const html = read('relative', 'index.html')
expect(html).toContain('href="./file.zip"')
expect(html).not.toContain('file.zip.html')
})
test('public and hashed assets in content are page-relative', () => {
const html = read('relative', 'index.html')
expect(html).toContain('src="./logo.png"')
expect(html).toMatch(/src="\.\/assets\/photo\.[\w-]+\.png"/)
})
test('depth 1 pages use ../', () => {
const html = read('relative', 'sub/page.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("../",location).href'
)
expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
expect(html).toMatch(/href="\.\.\/assets\/vp-icons\.[\w-]+\.css"/)
expect(html).toContain('src="../logo.png"')
expect(html).toContain('href="../index.html"')
expect(html).toContain('href="../sub/deep/page2.html"')
})
test('hash and external links stay untouched', () => {
const html = read('relative', 'sub/page.html')
expect(html).toContain('href="#local-anchor"')
expect(html).toContain('href="https://example.com/x"')
})
test('depth 2 pages use ../../', () => {
const html = read('relative', 'sub/deep/page2.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("../../",location).href'
)
expect(html).toMatch(/href="\.\.\/\.\.\/assets\/style\.[\w-]+\.css"/)
})
test('rewritten page lands at its rewrite depth', () => {
const html = read('relative', 'moved/target.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("../",location).href'
)
expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
})
test('404 renders at root depth', () => {
const html = read('relative', '404.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("./",location).href'
)
expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
})
test('no sentinel leaks into emitted html or css', () => {
for (const file of walk(dist('relative'))) {
if (!/\.(html|css)$/.test(file)) continue
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', () => {
const cdn = () => `http://localhost:${process.env['VP_CDN_PORT']}/`
test('scripts, styles and preloads move to the cdn with crossorigin', () => {
const html = read('cdn', 'index.html')
expect(html).toMatch(
new RegExp(
`<script type="module" src="${cdn()}assets/app\\.[\\w-]+\\.js" crossorigin>`
)
)
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(
`<link rel="modulepreload" href="${cdn()}assets/chunks/[^"]+" crossorigin="">`
)
)
expect(html).toMatch(
new RegExp(
`rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"`
)
)
expect(html).toMatch(
new RegExp(
`href="${cdn()}assets/vp-icons\\.[\\w-]+\\.css" as="style" crossorigin>`
)
)
})
test('pages, links and root-level files stay on the site origin', () => {
const html = read('cdn', 'index.html')
expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('src="/logo.png"')
expect(read('cdn', 'hashmap.json')).toBeTruthy()
})
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"`)
)
})
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-`
)
})
})
describe('mpa + relative base emit', () => {
test('no sentinel leaks anywhere', () => {
for (const file of walk(dist('mpa'))) {
if (!/\.(html|css|js)$/.test(file)) continue
const content = readFileSync(file, 'utf-8')
expect(content, file).not.toContain('__VP_BASE__')
expect(content, file).not.toContain('__VP_ICONS_HASH__')
}
})
test('css urls are relative to the css file', () => {
const cssFile = walk(dist('mpa', 'assets')).find((f) => f.endsWith('.css'))!
expect(readFileSync(cssFile, 'utf-8')).toContain('url(../assets/inter-')
})
test('pages reference assets by depth', () => {
expect(read('mpa', 'sub/page.html')).toMatch(
/href="\.\.\/assets\/style\.[\w-]+\.css"/
)
})
test('icons sheet is identical across mpa and spa builds', () => {
const find = (mode: string) =>
walk(dist(mode, 'assets')).find((f) => /vp-icons\.[\w-]+\.css$/.test(f))!
const mpa = find('mpa')
const plain = find('plain')
// same icon set — same content, same hash, mode-independent
expect(basename(mpa)).toBe(basename(plain))
expect(readFileSync(mpa, 'utf-8')).toBe(readFileSync(plain, 'utf-8'))
})
})
describe('plain base emit is unchanged', () => {
test('root-absolute urls and no runtime-root script', () => {
const html = read('plain', 'index.html')
expect(html).toMatch(/href="\/assets\/style\.[\w-]+\.css"/)
expect(html).toMatch(/src="\/assets\/app\.[\w-]+\.js"><\/script>/)
expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('href="/sub/"')
expect(html).toContain('src="/logo.png"')
expect(html).not.toContain('__VP_SITE_ROOT__')
expect(html).not.toContain('crossorigin>')
})
})

@ -0,0 +1,49 @@
import { defineConfig } from 'vitepress'
const mode = process.env.VP_TEST_MODE || 'relative'
export default defineConfig({
title: 'Base Fixture',
description: 'Fixture site for base/assetsBase behavior',
base: mode === 'plain' || mode === 'cdn' ? '/' : './',
assetsBase:
mode === 'cdn' ? `http://localhost:${process.env.VP_CDN_PORT}/` : undefined,
mpa: mode === 'mpa',
outDir: `.vitepress/dist-${mode}`,
cleanUrls: false,
rewrites: { 'src-moved.md': 'moved/target.md' },
vite: {
logLevel: 'error',
// keep the tiny fixture images as real emitted assets
build: { assetsInlineLimit: 0 }
},
// user hooks must only ever see final urls, never the build sentinel
postRender(context) {
if (JSON.stringify(context.teleports ?? {}).includes('__VP_BASE__')) {
throw new Error('sentinel leaked to postRender teleports')
}
if (context.content.includes('__VP_BASE__')) {
throw new Error('sentinel leaked to postRender')
}
},
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' }],
socialLinks: [{ icon: 'github', link: 'https://github.com' }],
sidebar: [
{ text: 'Sub', link: '/sub/page' },
{ text: 'Deep', link: '/sub/deep/page2' },
{ text: 'Moved', link: '/moved/target' }
],
...(mode === 'mpa' ? {} : { search: { provider: 'local' } })
}
})

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@ -0,0 +1,13 @@
# Home
![logo](/logo.png)
![photo](/img/photo.png)
[to sub](/sub/page)
[to dir](/sub/)
[zip](/file.zip)
[moved](/moved/target)

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@ -0,0 +1,3 @@
# Moved page
Rewritten target.

@ -0,0 +1,7 @@
# Deep page
[up](/sub/page)
## Deep heading
The xylophone paragraph for search.

@ -0,0 +1,3 @@
# Sub index
Index of sub.

@ -0,0 +1,15 @@
# Sub page
![logo again](/logo.png)
[home](/)
[deep](/sub/deep/page2)
[hash](#local-anchor)
[external](https://example.com/x)
## Local anchor
Body text here.

@ -0,0 +1,29 @@
import { chromium, type Browser, type Page } from 'playwright-chromium'
export interface TestPage {
browser: Browser
page: Page
errors: string[]
}
export async function newPage(): Promise<TestPage> {
const browser = await chromium.connect(process.env['WS_ENDPOINT']!)
const page = await browser.newPage()
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text())
})
page.on('pageerror', (err) => errors.push(String(err)))
return { browser, page, errors }
}
export function realErrors(errors: string[]): string[] {
return errors.filter((e) => !e.includes('favicon'))
}
export async function waitForHydration(page: Page): Promise<void> {
await page.waitForSelector('#app .Layout')
await page.waitForFunction(
() => (document.querySelector('#app') as any)?.__vue_app__ !== undefined
)
}

@ -0,0 +1,12 @@
{
"name": "tests-base",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"watch": "DEBUG=1 vitest"
},
"devDependencies": {
"vitepress": "workspace:*"
}
}

@ -0,0 +1,60 @@
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { newPage, type TestPage } from './helpers'
const dist = resolve(
fileURLToPath(import.meta.url),
'..',
'fixture/.vitepress/dist-relative'
)
const fileUrl = (...p: string[]) => pathToFileURL(join(dist, ...p)).href
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
// module scripts are cors-blocked from disk, so nothing hydrates here; the
// pre-rendered site must still be styled and navigable
describe('relative base opened over file://', () => {
test('pages render styled with working images', async () => {
await t.page.goto(fileUrl('sub/page.html'))
expect(await t.page.textContent('h1')).toContain('Sub page')
const fontFamily = await t.page.evaluate(
() => getComputedStyle(document.body).fontFamily
)
expect(fontFamily).toContain('Inter')
const logoLoaded = await t.page.evaluate(
() =>
document.querySelector<HTMLImageElement>('img[alt="logo again"]')!
.naturalWidth
)
expect(logoLoaded).toBe(1)
})
test('content links navigate between files', async () => {
await t.page.click('.vp-doc a[href="../sub/deep/page2.html"]')
expect(await t.page.textContent('h1')).toContain('Deep page')
expect(t.page.url()).toBe(fileUrl('sub/deep/page2.html'))
})
test('theme links navigate between files', async () => {
await t.page.click('.VPSidebar a[href="../../moved/target.html"]')
expect(await t.page.textContent('h1')).toContain('Moved page')
expect(t.page.url()).toBe(fileUrl('moved/target.html'))
})
test('the root page reaches nested pages', async () => {
await t.page.goto(fileUrl('index.html'))
await t.page.click('.vp-doc a[href="./sub/index.html"]')
expect(await t.page.textContent('h1')).toContain('Sub index')
})
})

@ -0,0 +1,92 @@
import { ALT_PREFIX, SUB_PREFIX } from './constants'
import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
const origin = () => `http://localhost:${process.env['SUB_PORT']}`
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
// mark the window with a marker that only survives client-side navigation,
// proving no full document reload occurred
const mark = () => t.page.evaluate(() => ((window as any).__spa_marker = 1))
const marked = () => t.page.evaluate(() => (window as any).__spa_marker === 1)
describe('relative base served from a deep subpath', () => {
test('deep link loads and hydrates', async () => {
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}`
)
})
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')
)
expect(await marked()).toBe(true)
expect(new URL(t.page.url()).pathname).toBe(`${SUB_PREFIX}sub/page.html`)
})
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')
)
expect(await marked()).toBe(true)
// the router strips index.html from the address bar
expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX)
})
test('search finds pages and navigates to them', async () => {
await t.page.click('.VPNavBarSearchButton')
const input = await t.page.waitForSelector('input#localsearch-input')
await input.type('xylophone')
await t.page.waitForSelector('#localsearch-list li[role=option] a')
const href = await t.page.getAttribute(
'#localsearch-list li[role=option] a',
'href'
)
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')
)
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')
)
expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX)
})
test('the same build works mounted at a different prefix', async () => {
await t.page.goto(`${origin()}${ALT_PREFIX}index.html`)
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')
)
expect(await marked()).toBe(true)
expect(new URL(t.page.url()).pathname).toBe(`${ALT_PREFIX}sub/page.html`)
})
test('no console or page errors across the whole flow', () => {
expect(realErrors(t.errors)).toEqual([])
})
})

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"include": ["**/*"],
"exclude": ["fixture/.vitepress/dist-*", "fixture/.vitepress/cache"]
}

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
const timeout = 60_000
export default defineConfig({
test: {
globalSetup: ['vitestGlobalSetup.ts'],
testTimeout: timeout,
hookTimeout: timeout,
teardownTimeout: timeout,
globals: true,
fileParallelism: false
}
})

@ -0,0 +1,120 @@
import { spawnSync } from 'node:child_process'
import { readFile, rm } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type BrowserServer } from 'playwright-chromium'
import { ALT_PREFIX, SUB_PREFIX } from './constants'
const dir = resolve(fileURLToPath(import.meta.url), '..')
const bin = resolve(dir, '../../bin/vitepress.js')
const dist = (mode: string) => resolve(dir, `fixture/.vitepress/dist-${mode}`)
const types: Record<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.zip': 'application/zip'
}
// listens on an os-assigned port (the other suites run in parallel on CI,
// so a pre-picked "free" port can be taken before we bind it)
function serveStatic(
mounts: [prefix: string, root: string][],
cors: boolean
): Promise<Server> {
const server = createServer(async (req, res) => {
const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname)
for (const [prefix, root] of mounts) {
if (!url.startsWith(prefix)) continue
let file = url.slice(prefix.length) || 'index.html'
if (file.endsWith('/')) file += 'index.html'
try {
const data = await readFile(join(root, file))
const headers: Record<string, string> = {
'content-type': types[extname(file)] ?? 'application/octet-stream'
}
if (cors) headers['access-control-allow-origin'] = '*'
res.writeHead(200, headers)
res.end(data)
return
} catch {}
}
res.writeHead(404)
res.end('not found')
})
return new Promise((r) => server.listen(0, () => r(server)))
}
const portOf = (server: Server) => (server.address() as AddressInfo).port
let browserServer: BrowserServer
let servers: Server[] = []
export async function setup() {
// started before its dist exists so its real port can go into assetsBase
const cdnServer = await serveStatic([['/', dist('cdn')]], true)
const cdnPort = portOf(cdnServer)
// one process per flavor: the markdown renderer is a module-level
// singleton, so in-process builds would leak the first base into the rest
for (const mode of ['plain', 'relative', 'cdn', 'mpa']) {
// mpa builds never empty outDir, so stale assets would survive reruns
await rm(dist(mode), { recursive: true, force: true })
const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], {
cwd: dir,
env: {
...process.env,
VP_TEST_MODE: mode,
VP_CDN_PORT: String(cdnPort)
},
encoding: 'utf-8'
})
if (res.status !== 0) {
throw new Error(`build (${mode}) failed:\n${res.stdout}\n${res.stderr}`)
}
}
servers = [
// one relative-base build mounted at two unrelated prefixes
await serveStatic(
[
[SUB_PREFIX, dist('relative')],
[ALT_PREFIX, dist('relative')]
],
false
),
await serveStatic([['/', dist('cdn')]], false),
cdnServer
]
browserServer = await chromium.launchServer({
headless: !process.env.DEBUG,
args: process.env.CI
? ['--no-sandbox', '--disable-setuid-sandbox']
: undefined
})
process.env['WS_ENDPOINT'] = browserServer.wsEndpoint()
process.env['SUB_PORT'] = String(portOf(servers[0]!))
process.env['PAGES_PORT'] = String(portOf(servers[1]!))
process.env['VP_CDN_PORT'] = String(cdnPort)
}
export async function teardown() {
await browserServer.close()
await Promise.all(
servers.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
)
)
)
}

@ -201,6 +201,8 @@ export default defineConfig({
markdown: {
image: { lazyLoad: true }
},
// exercises force-inclusion of icons SSR never renders
icons: { include: ['lucide:egg'] },
themeConfig: {
nav,
sidebar,
@ -210,11 +212,22 @@ export default defineConfig({
link: '/home',
ariaLabel: 'Home social link',
target: '_self'
},
{
icon: 'lucide:heart',
link: '/home',
ariaLabel: 'Heart social link'
}
],
search: {
provider: 'local',
options: {
miniSearch: {
options: {
tokenize: (text) =>
text.split(/[\n\r\p{Z}\p{Terminal_Punctuation}]+/u)
}
},
async _render(src, env, md) {
const html = await md.renderAsync(src, env)
if (env.frontmatter?.search === false) return ''

@ -0,0 +1,158 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const isBuild = !!process.env.VITE_TEST_BUILD
const maskImage = (selector: string) =>
page.$eval(selector, (el) => {
const styles = getComputedStyle(el)
return styles.maskImage || styles.webkitMaskImage
})
describe('icons', () => {
const externalRequests: string[] = []
const devIconRequests: string[] = []
beforeAll(() => {
page.on('request', (request) => {
const url = request.url()
if (!url.startsWith(`http://localhost:${process.env['PORT']}`)) {
externalRequests.push(url)
}
if (url.includes('/_vpi/')) devIconRequests.push(url)
})
})
test('social links render from both collections', async () => {
await goto('/')
for (const [label, cls] of [
['Home social link', '.vpi-simple-icons-github'],
['Heart social link', '.vpi-lucide-heart']
]) {
const selector = `a[aria-label="${label}"] span`
expect(await page.getAttribute(selector, 'class')).toBe(cls.slice(1))
// an unresolved icon computes to mask-image: none and renders nothing
await page.waitForFunction(
(sel) => {
const el = document.querySelector(sel)
if (!el) return false
const styles = getComputedStyle(el)
return (styles.maskImage || styles.webkitMaskImage) !== 'none'
},
selector,
{ timeout: 3000 }
)
}
})
test('VPIcon renders collection, default-collection and raw svg icons', async () => {
await goto('/icons/')
expect(await page.getAttribute('[data-test-icon="lucide"]', 'class')).toBe(
'vpi-lucide-rocket'
)
expect(await page.getAttribute('[data-test-icon="simple"]', 'class')).toBe(
'vpi-simple-icons-vuedotjs'
)
expect(
await page.$eval('[data-test-icon="raw"]', (el) => el.innerHTML)
).toContain('<svg')
// the raw-svg wrapper must not pick up the mask machinery, which would
// paint a solid currentColor box over the svg
expect(
await page.$eval('[data-test-icon="raw"]', (el) => {
const styles = getComputedStyle(el)
return {
background: styles.backgroundColor,
svgWidth: getComputedStyle(el.querySelector('svg')!).width
}
})
).toEqual({ background: 'rgba(0, 0, 0, 0)', svgWidth: '16px' })
await page.waitForFunction(() => {
const el = document.querySelector('[data-test-icon="lucide"]')
if (!el) return false
const styles = getComputedStyle(el)
return (styles.maskImage || styles.webkitMaskImage) !== 'none'
})
})
test('no icon is ever fetched from an external origin', () => {
expect(externalRequests).toEqual([])
})
test.runIf(!isBuild)(
'dev resolves icons from the local endpoint',
async () => {
await goto('/')
await page.waitForFunction(() => {
const el = document.querySelector(
'a[aria-label="Heart social link"] span'
)
if (!el) return false
const styles = getComputedStyle(el)
return (styles.maskImage || styles.webkitMaskImage).includes('/_vpi/')
})
expect(
devIconRequests.some((url) => url.includes('/_vpi/lucide/heart.svg'))
).toBe(true)
}
)
test.runIf(isBuild)(
'build inlines icons into the hashed stylesheet',
async () => {
await goto('/')
expect(
await maskImage('a[aria-label="Heart social link"] span')
).toContain('data:image/svg+xml')
expect(devIconRequests).toEqual([])
const html = readFileSync(
resolve(
fileURLToPath(import.meta.url),
'../../.vitepress/dist/index.html'
),
'utf-8'
)
expect(html).toMatch(/href="\/assets\/vp-icons\.[\w-]+\.css"/)
expect(html).not.toContain('__VP_ICONS_HASH__')
// prose mentioning the placeholder is left alone — only the link tag
// gets the hash substituted
const iconsPage = readFileSync(
resolve(
fileURLToPath(import.meta.url),
'../../.vitepress/dist/icons/index.html'
),
'utf-8'
)
expect(iconsPage).toContain('vp-icons.__VP_ICONS_HASH__.css</code>')
expect(iconsPage).toMatch(
/<link rel="preload stylesheet" href="\/assets\/vp-icons\.[\w-]+\.css" as="style">/
)
}
)
test.runIf(isBuild)(
'icons.include forces unrendered icons into the sheet',
() => {
const assetsDir = resolve(
fileURLToPath(import.meta.url),
'../../.vitepress/dist/assets'
)
const cssFile = readdirSync(assetsDir).find((f) =>
/^vp-icons\.[\w-]+\.css$/.test(f)
)!
expect(cssFile).toBeTruthy()
const css = readFileSync(join(assetsDir, cssFile), 'utf-8')
expect(css).toContain('.vpi-lucide-egg')
expect(css).toContain('.vpi-lucide-heart')
expect(css).toContain('.vpi-simple-icons-github')
// zero-specificity base rules ship with the sheet for any theme
expect(css).toContain(':where(')
}
)
})

@ -0,0 +1,12 @@
# Icons
<script setup>
import { VPIcon } from 'vitepress/theme'
</script>
<VPIcon icon="lucide:rocket" data-test-icon="lucide" />
<VPIcon icon="simple-icons:vuedotjs" data-test-icon="simple" />
<VPIcon :icon="{ svg: '<svg viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'4\'/></svg>' }" data-test-icon="raw" />
Prose about the build internals must survive the rewrite pass:
`vp-icons.__VP_ICONS_HASH__.css`

@ -0,0 +1,7 @@
---
title: Frontmatter Title Resolved
---
# {{ $frontmatter.title }}
This page uses a frontmatter title expression.

@ -1 +1,3 @@
# Local search included
# Local search included
The custom tokenizer keeps #hash-probe and hyphen-linked-words whole.

@ -16,7 +16,7 @@ describe('local search', () => {
})
try {
await page.locator('.VPNavBarSearchButton').click()
await openSearch()
const loading = page.locator('.search-loading')
const results = page.locator('.results')
@ -49,22 +49,10 @@ describe('local search', () => {
)
test('exclude content from search results', async () => {
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('local')
await searchFor('local')
await waitForSearchResults({ text: 'Local search included', count: 1 })
const searchResults = page.locator('#localsearch-list')
await page.waitForFunction(() => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
options.length === 1 &&
options[0].textContent?.includes('Local search included')
)
})
expect(await searchResults.locator('li[role=option]').count()).toBe(1)
@ -83,6 +71,48 @@ describe('local search', () => {
).toBe(0)
})
test('resolves $frontmatter expressions in search results', async () => {
await searchFor('Frontmatter Title Resolved')
await waitForSearchResults({ text: 'Frontmatter Title Resolved' })
const searchResults = page.locator('#localsearch-list')
expect(
await searchResults
.filter({ hasText: 'Frontmatter Title Resolved' })
.count()
).toBe(1)
expect(
await searchResults.filter({ hasText: '$frontmatter.title' }).count()
).toBe(0)
})
test('typing replaces the persisted query', async () => {
await searchFor('lorem')
await waitForSearchResults({ minCount: 2 })
await page.keyboard.press('Escape')
// reopening restores the persisted query pre-selected, so keystrokes
// must replace it instead of appending to it
const input = await openSearch()
await input.type('Frontmatter Title Resolved')
await waitForSearchResults({ text: 'Frontmatter Title Resolved' })
expect(await input.inputValue()).toBe('Frontmatter Title Resolved')
})
test('custom tokenize function reaches the client', async () => {
// '#hash-probe' survives as one token only under the custom tokenizer —
// MiniSearch's default one would degrade the query to 'hash'/'probe'
// and miss the index built with the custom tokenizer
const input = await searchFor('#hash-probe')
await waitForSearchResults({ text: 'Local search included', count: 1 })
// a fragment of a kept-whole token must not match anything
await input.fill('linked-words')
await page.waitForSelector('.no-results')
})
test('uses the same desktop breakpoint as the nav bar', async () => {
try {
for (const { width, isDesktop } of [
@ -91,8 +121,7 @@ describe('local search', () => {
]) {
await page.setViewportSize({ width, height: 600 })
await goto('/')
await page.locator('.VPNavBarSearchButton').click()
await page.waitForSelector('input#localsearch-input')
await openSearch()
expect(await page.locator('.VPNavBarHamburger').isVisible()).toBe(
!isDesktop
@ -108,17 +137,9 @@ describe('local search', () => {
test('navigate results with macOS Ctrl shortcuts', async () => {
await page.evaluate(() => document.documentElement.classList.add('mac'))
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('lorem')
await page.waitForFunction(() => {
return (
document.querySelectorAll('#localsearch-list li[role=option]').length >
1
)
})
const input = await searchFor('lorem')
await waitForSearchResults({ minCount: 2 })
expect(await input.getAttribute('aria-activedescendant')).toBe(
'localsearch-item-0'
@ -136,6 +157,43 @@ describe('local search', () => {
})
})
async function openSearch() {
await page.locator('.VPNavBarSearchButton').click()
return page.waitForSelector('input#localsearch-input')
}
// fills the query in one step, so exactly one search runs and the result
// list settles into the state for this query and nothing else
async function searchFor(query: string) {
const input = await openSearch()
await input.fill(query)
return input
}
// waits until the result list matches, so assertions never run against the
// results of an earlier query
function waitForSearchResults(condition: {
/** some result must contain this text */
text?: string
/** exactly this many results */
count?: number
/** at least this many results */
minCount?: number
}) {
return page.waitForFunction(({ text, count, minCount }) => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
(count === undefined || options.length === count) &&
(minCount === undefined || options.length >= minCount) &&
(text === undefined ||
options.some((option) => option.textContent?.includes(text)))
)
}, condition)
}
function pressMacCtrl(key: string) {
return page.evaluate((key) => {
window.dispatchEvent(

@ -10,6 +10,7 @@
"site:preview": "vitepress preview"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.126",
"vitepress": "workspace:*"
}
}

@ -1,5 +1,5 @@
{
"extends": "../tsconfig.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"isolatedModules": false,

@ -1,5 +1,10 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import { mergeConfig, type UserConfig } from 'node/config'
import {
mergeConfig,
normalizeAssetsBase,
normalizeSiteBase,
type UserConfig
} from 'node/config'
describe('node/config', () => {
test('merges markdown hooks from extended configs', async () => {
@ -71,3 +76,50 @@ describe('node/config', () => {
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('/')
expect(normalizeSiteBase('')).toBe('/')
expect(normalizeSiteBase('/docs')).toBe('/docs/')
expect(normalizeSiteBase('/docs/')).toBe('/docs/')
})
test('coerces a leading slash onto path bases', () => {
expect(normalizeSiteBase('docs')).toBe('/docs/')
expect(normalizeSiteBase('docs/')).toBe('/docs/')
expect(normalizeSiteBase('https://example.com/x')).toBe(
'https://example.com/x/'
)
expect(normalizeSiteBase('//cdn.example.com/')).toBe('//cdn.example.com/')
})
test('normalizes relative forms to ./', () => {
expect(normalizeSiteBase('.')).toBe('./')
expect(normalizeSiteBase('./')).toBe('./')
})
test('rejects relative bases with a subpath', () => {
expect(() => normalizeSiteBase('./docs/')).toThrow(/relative base/)
expect(() => normalizeSiteBase('../x')).toThrow(/relative base/)
})
})
describe('normalizeAssetsBase', () => {
test('accepts absolute urls, protocol-relative urls and paths', () => {
expect(normalizeAssetsBase('https://cdn.example.com')).toBe(
'https://cdn.example.com/'
)
expect(normalizeAssetsBase('//cdn.example.com/x')).toBe(
'//cdn.example.com/x/'
)
expect(normalizeAssetsBase('/cdn/')).toBe('/cdn/')
})
test('rejects relative values', () => {
expect(() => normalizeAssetsBase('./cdn/')).toThrow(/assetsBase/)
expect(() => normalizeAssetsBase('cdn/')).toThrow(/assetsBase/)
})
})
})

@ -49,4 +49,19 @@ describe('node/contentLoader', () => {
expect(data[0].html).toContain('href="./other"')
expect(data[0].html).not.toContain('./other.html')
})
test('excerpts resolve $frontmatter without render', async () => {
await setup(false)
const { writeFile } = await import('node:fs/promises')
await writeFile(
path.join(root!, 'post.md'),
'---\ntitle: My Post\n---\n\nIntro says {{ $frontmatter.title }}.\n\n---\n\nBody.\n'
)
const data = await createContentLoader('post.md', {
excerpt: true
}).load()
expect(data[0].excerpt).toContain('Intro says My Post.')
})
})

@ -0,0 +1,159 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateIconsCSS, resolveIconSVG } from 'node/icons'
import { parseIconName } from 'shared/shared'
// the e2e workspace has @iconify-json/lucide installed — use it as the
// resolution root for collection-loading tests
const e2eRoot = resolve(fileURLToPath(import.meta.url), '../../../e2e')
describe('node/icons', () => {
describe('parseIconName', () => {
test('parses qualified names', () => {
expect(parseIconName('lucide:heart')).toEqual({
collection: 'lucide',
icon: 'heart'
})
expect(parseIconName('simple-icons:github')).toEqual({
collection: 'simple-icons',
icon: 'github'
})
})
test('rejects bare names and anything outside iconify grammar', () => {
for (const name of [
'github',
'GitHub',
'foo bar',
'foo:',
':bar',
'a<b',
'foo:bar:baz',
'-leading',
''
]) {
expect(parseIconName(name), name).toBeNull()
}
})
})
describe('generateIconsCSS', () => {
test('emits base rules and per-icon rules, no legacy common rule', async () => {
// simple-icons is not in the e2e workspace's package.json — this also
// covers the fallback to vitepress's own dependency
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['simple-icons:github']),
'compressed'
)
expect(warnings).toEqual([])
expect(css).toContain(
'.vpi-simple-icons-github{--icon:url("data:image/svg+xml'
)
expect(css).toContain(":where([class^='vpi-']")
expect(css).toContain('display:inline-block')
expect(css).not.toContain('.vpi-social')
})
test('suggests qualification for bare names', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['github']),
'compressed'
)
expect(css).toBe('')
expect(warnings).toEqual([
expect.stringContaining('"github" has no collection prefix')
])
expect(warnings[0]).toContain('simple-icons:github')
})
test('groups collections and stays deterministic across insertion order', async () => {
const a = await generateIconsCSS(
e2eRoot,
new Set(['lucide:heart', 'simple-icons:github', 'lucide:egg']),
'compressed'
)
const b = await generateIconsCSS(
e2eRoot,
new Set(['simple-icons:github', 'lucide:egg', 'lucide:heart']),
'compressed'
)
expect(a.css).toBe(b.css)
expect(a.css).toContain('.vpi-lucide-heart')
expect(a.css).toContain('.vpi-lucide-egg')
expect(a.css).toContain('.vpi-simple-icons-github')
})
test('warns on icons missing from an installed collection', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['simple-icons:github', 'simple-icons:thisiconisnotreal']),
'compressed'
)
expect(css).toContain('.vpi-simple-icons-github')
expect(css).not.toContain('thisiconisnotreal')
expect(warnings).toEqual([
expect.stringContaining(
'"thisiconisnotreal" was not found in the "simple-icons"'
)
])
})
test('warns on uninstalled collections with an install hint', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['notinstalled:foo']),
'compressed'
)
expect(css).toBe('')
expect(warnings).toEqual([
expect.stringContaining('@iconify-json/notinstalled')
])
})
test('warns on invalid names', async () => {
const { warnings } = await generateIconsCSS(
e2eRoot,
new Set(['Not A Name']),
'compressed'
)
expect(warnings).toEqual([
expect.stringContaining('"Not A Name" is not a valid icon name')
])
})
test('returns empty css for an empty set', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(),
'compressed'
)
expect(css).toBe('')
expect(warnings).toEqual([])
})
})
describe('resolveIconSVG', () => {
test('resolves an svg offline', async () => {
const resolved = await resolveIconSVG(e2eRoot, 'lucide', 'heart')
expect(resolved).toHaveProperty('svg')
const svg = (resolved as { svg: string }).svg
expect(svg).toContain('<svg')
expect(svg).toContain('viewBox')
})
test('reports missing icons and collections distinctly', async () => {
expect(await resolveIconSVG(e2eRoot, 'lucide', 'noicon')).toEqual({
error: expect.stringContaining('was not found in the "lucide"')
})
expect(await resolveIconSVG(e2eRoot, 'nocollection', 'x')).toEqual({
error: expect.stringContaining('@iconify-json/nocollection')
})
expect(await resolveIconSVG(e2eRoot, 'Bad Name', 'x')).toEqual({
error: expect.stringContaining('not a valid icon name')
})
})
})
})

@ -42,6 +42,16 @@ describe('node/markdown/markdown', () => {
expect(await render(':tada:', { emoji: false })).toContain(':tada:')
})
test('eagerFrontmatterInterpolation', async () => {
const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}'
expect(await render(src)).toContain('<p>Hello</p>')
const disabled = await render(src, {
eagerFrontmatterInterpolation: false
})
expect(disabled).toContain('<p>{{ $frontmatter.title }}</p>')
})
test('tasklist', async () => {
const src = '- [ ] todo'
expect(await render(src)).toContain('<input type="checkbox"')

@ -0,0 +1,401 @@
import {
createMarkdownRenderer,
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import { escapeHtml } from 'node/shared'
// the full build, for compiling templates the way the Vue plugin would
// @ts-expect-error no types for dist builds
import { createSSRApp } from 'vue/dist/vue.cjs.js'
import { renderToString } from 'vue/server-renderer'
async function createMd(options: MarkdownOptions = {}) {
disposeMdItInstance()
return createMarkdownRenderer('.', { highlight: (code) => code, ...options })
}
async function render(src: string, env: Record<string, any> = {}) {
return (await createMd()).renderAsync(src, env)
}
const frontmatter = `\
---
title: Hello World
count: 5
flag: true
nothing: null
date: 2024-01-18
html: '<b>bold</b>'
mustache: '{{ x }}'
amp: 'a &lt; b'
k-y: dashed
spaced: 'a b'
multiline: |
line one
line two
homepage: https://vitepress.dev/
nested:
deep: value
list:
- a
- b
---
`
async function renderBody(body: string, env: Record<string, any> = {}) {
return (await render(frontmatter + body, env)).trim()
}
describe('node/markdown/plugins/eagerFrontmatterInterpolation', () => {
test('resolves property paths and escapes the value', async () => {
const html = await render(`\
---
meta:
title: A & B
count: 2
done: false
---
{{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }}
`)
expect(html).toContain('<p>A &#38; B / 2 / false</p>')
})
test('resolves bracket paths and dates', async () => {
expect(await renderBody("{{ $frontmatter['k-y'] }}")).toBe('<p>dashed</p>')
expect(await renderBody('{{ $frontmatter["k-y"] }}')).toBe('<p>dashed</p>')
expect(await renderBody('{{ $frontmatter.list[1] }}')).toBe('<p>b</p>')
expect(await renderBody('{{ $frontmatter.list.length }}')).toBe('<p>2</p>')
// dates are normalized the same way the `__pageData` JSON round-trip
// normalizes them for the runtime
expect(await renderBody('{{ $frontmatter.date }}')).toBe(
'<p>2024-01-18T00:00:00.000Z</p>'
)
})
test('escapes values so they render as this exact text', async () => {
// a value containing mustaches must not be interpolated again by Vue
expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
'<p>&#123;&#123; x &#125;&#125;</p>'
)
// entity look-alikes must survive the template compiler's decoding
expect(await renderBody('{{ $frontmatter.amp }}')).toBe(
'<p>a &#38;lt; b</p>'
)
expect(
await renderBody(
'&copy; {{ $frontmatter.title }} / {{ $frontmatter.no }}'
)
).toBe('<p>&copy; Hello World / {{ $frontmatter.no }}</p>')
})
test('leaves everything else to Vue', async () => {
const expressions = [
'{{ $frontmatter.missing }}', // key not in frontmatter
'{{ $frontmatter.title.length }}', // path through a non-object
'{{ $frontmatter.nothing.x }}',
'{{ $frontmatter.nothing }}', // renders '' but may be transformed later
'{{ $frontmatter }}',
'{{ $frontmatter.nested }}', // objects are for Vue's display formatting
'{{ $frontmatter.list }}',
'{{ $frontmatter.html }}', // `<` could smuggle markup into titles
'{{ $frontmatter.spaced }}', // double space would be condensed
'{{ $frontmatter.multiline }}',
'{{ $frontmatter.list[01] }}',
'{{ $frontmatter.title.toUpperCase() }}',
'{{ $frontmatter[title] }}',
'{{ $frontmatterX }}',
'{{ $params.id }}',
'{{ frontmatter.title }}'
]
const html = await renderBody(expressions.join('\n\n'))
for (const expression of expressions) {
expect(html).toContain(`<p>${expression}</p>`)
}
})
test('skips code and v-pre', async () => {
const html = await render(`\
---
title: Hi
---
\`{{ $frontmatter.title }}\`
\`\`\`js
{{ $frontmatter.title }}
\`\`\`
::: v-pre
{{ $frontmatter.title }}
:::
<span v-pre>{{ $frontmatter.title }}</span> {{ $frontmatter.title }}
`)
expect(html.match(/\{\{ \$frontmatter\.title \}\}/g)).toHaveLength(4)
expect(html).toContain('</span> Hi</p>')
})
test('skips v-pre scopes from attrs', async () => {
const html = await renderBody(
'**{{ $frontmatter.title }}**{v-pre} {{ $frontmatter.title }}'
)
expect(html).toContain(
'<strong v-pre="">{{ $frontmatter.title }}</strong> Hello World'
)
})
test('tracks raw inline v-pre elements the way Vue parses them', async () => {
// a quoted attribute value may contain `>`
expect(
await renderBody(
'<span title="a>b" v-pre>{{ $frontmatter.title }}</span>'
)
).toContain('<span title="a>b" v-pre>{{ $frontmatter.title }}</span>')
// tag names match case-insensitively, so the inner pair nests
expect(
await renderBody(
'z <span v-pre>a<SPAN>b</span>c {{ $frontmatter.title }}</SPAN> {{ $frontmatter.title }}'
)
).toContain('c {{ $frontmatter.title }}</SPAN> Hello World')
// a self-closing same-name tag does not affect the scope
expect(
await renderBody('<span v-pre>a<span/>b</span> {{ $frontmatter.title }}')
).toContain('</span> Hello World')
})
test('scopes v-pre in raw html blocks instead of bailing out', async () => {
// mentions of v-pre that open no scope leave the page alone
for (const block of [
'<style>\n.v-pre { color: red }\n</style>',
'<script setup>\nconst a = "v-pre"\n</script>',
'<!-- see v-pre -->',
'<div v-pre>{{ literal }}</div>'
]) {
const html = await renderBody(`${block}\n\n{{ $frontmatter.title }}`)
expect(html).toContain('<p>Hello World</p>')
}
// a scope that spans markdown ends at its closing tag
const html = await renderBody(
'<div v-pre>\n\n{{ $frontmatter.title }}\n\n</div>\n\n{{ $frontmatter.title }}'
)
expect(html).toContain('<p>{{ $frontmatter.title }}</p>')
expect(html).toContain('<p>Hello World</p>')
// an unclosed scope spans the rest of the page
expect(
await renderBody('<div v-pre>\n\n{{ $frontmatter.title }}')
).not.toContain('Hello World')
})
test('leaves whitespace-sensitive spots inside raw inline elements', async () => {
// the runtime drops whitespace-only text nodes at element edges; an
// inlined value would merge with that whitespace and keep it
expect(
await renderBody('a<code> {{ $frontmatter.title }} </code>b')
).toContain('<code> {{ $frontmatter.title }} </code>')
expect(
await renderBody('a<em>\n{{ $frontmatter.title }}\n</em>b')
).toContain('{{ $frontmatter.title }}')
// non-whitespace neighbors and closing-tag adjacency are safe
expect(await renderBody('a<em>x {{ $frontmatter.title }}</em>b')).toContain(
'<em>x Hello World</em>'
)
expect(await renderBody('<em>x</em> {{ $frontmatter.title }}')).toContain(
'</em> Hello World'
)
})
test('keeps values safe when the text renderer rule is replaced', async () => {
const md = await createMd({
config: (md) => {
md.renderer.rules.text = (tokens, idx) =>
escapeHtml(tokens[idx].content)
}
})
const html = await md.renderAsync(
frontmatter + '{{ $frontmatter.mustache }} and {{ $frontmatter.title }}'
)
// the unsafe value renders through its own token, not the text rule
expect(html).toContain('&#123;&#123; x &#125;&#125; and Hello World')
expect(html).not.toContain('{{ x }}')
})
test('keeps toc titles escaped like the heading', async () => {
const html = await renderBody(
'## {{ $frontmatter.mustache }} {{ $frontmatter.amp }}\n\n[[toc]]'
)
expect(html).toContain('&#123;&#123; x &#125;&#125; a &#38;lt; b')
// no live interpolation may reach the toc markup, and the toc must show
// the same text as the heading
const toc = html.slice(html.indexOf('<nav'))
expect(toc).not.toContain('{{ x }}')
expect(toc).toContain('&#123;&#123; x &#125;&#125;')
expect(toc).toContain('a &#38;lt; b')
})
test('feeds the resolved text to anchors and the page title', async () => {
const env: Record<string, any> = {}
const html = await render(
`\
---
title: Hello World
---
# {{ $frontmatter.title }}
`,
env
)
expect(html).toContain('id="hello-world"')
expect(env.title).toBe('Hello World')
})
test('records what was inlined on the env', async () => {
const env: Record<string, any> = {}
await renderBody(
'{{ $frontmatter.title }} {{ $frontmatter.missing }} [x]({{$frontmatter.homepage}})',
env
)
expect(env.eagerInterpolations).toEqual([
{ expression: '$frontmatter.title', value: 'Hello World' },
{ expression: '$frontmatter.homepage', value: 'https://vitepress.dev/' }
])
})
test('resolves link and image destinations', async () => {
const html = await renderBody(
[
'[home]({{$frontmatter.homepage}})',
'[docs](<{{ $frontmatter.homepage }}>)',
'[nope]({{$frontmatter.nope}})'
].join('\n\n')
)
expect(html).toContain('href="https://vitepress.dev/"')
// external link handling applies to the resolved destination
expect(html).toContain('target="_blank"')
// unresolvable destinations keep their expression
expect(html).toContain('$frontmatter.nope')
})
test('resolves destinations even when only encoded delimiters exist', async () => {
const html = await render(`\
---
count: 5
---
[v](https://vitepress.dev/%7B%7B$frontmatter.count%7D%7D)
`)
expect(html).toContain('href="https://vitepress.dev/5"')
})
test('resolves image sources', async () => {
const html = await render(`\
---
logo: /logo.png
---
![logo]({{$frontmatter.logo}})
`)
expect(html).toContain('src="/logo.png"')
})
test('resolves custom container titles', async () => {
const html = await renderBody(
'::: tip {{ $frontmatter.title }}\nbody {{ $frontmatter.count }}\n:::'
)
expect(html).toContain('<p class="custom-block-title">Hello World</p>')
expect(html).toContain('<p>body 5</p>')
})
test('leaves everything alone without frontmatter data', async () => {
expect((await render('{{ $frontmatter.title }}')).trim()).toBe(
'<p>{{ $frontmatter.title }}</p>'
)
})
// entries passed via `env.frontmatter` must keep merging and inlining -
// a future `renderMd(src, env)` (#2410) relies on this
test('merges and inlines frontmatter provided via env', async () => {
// env entries only, no frontmatter block in the source
const env: Record<string, any> = {
frontmatter: { intro: 'From Env', n: 42 }
}
expect(
(
await render('{{ $frontmatter.intro }} ({{ $frontmatter.n }})', env)
).trim()
).toBe('<p>From Env (42)</p>')
// the page's own frontmatter wins on conflicts
const merged: Record<string, any> = {
frontmatter: { title: 'From Env', extra: 'Extra' }
}
expect(
(
await render(
'---\ntitle: From Page\n---\n\n{{ $frontmatter.title }} / {{ $frontmatter.extra }}',
merged
)
).trim()
).toBe('<p>From Page / Extra</p>')
expect(merged.frontmatter).toEqual({ title: 'From Page', extra: 'Extra' })
})
describe('equivalence with runtime interpolation', () => {
async function ssr(html: string, $frontmatter: unknown) {
const app = createSSRApp({ template: `<div>${html}</div>` })
app.config.globalProperties.$frontmatter = $frontmatter
app.config.warnHandler = () => {}
return renderToString(app)
}
async function compare(body: string) {
const runtimeEnv: any = {}
const runtimeMd = await createMd({ eagerFrontmatterInterpolation: false })
const runtimeHtml = await runtimeMd.renderAsync(
frontmatter + body,
runtimeEnv
)
const resolvedHtml = await (
await createMd()
).renderAsync(frontmatter + body, {})
// the runtime sees the frontmatter after the `__pageData` JSON
// round-trip
const runtimeData = JSON.parse(JSON.stringify(runtimeEnv.frontmatter))
expect(await ssr(resolvedHtml, runtimeData)).toBe(
await ssr(runtimeHtml, runtimeData)
)
return resolvedHtml
}
test('inlined values render exactly what the runtime would', async () => {
const resolvedHtml = await compare(
[
'Welcome to {{ $frontmatter.title }}!',
'{{ $frontmatter.mustache }}',
'{{ $frontmatter.amp }}',
'{{ $frontmatter.count }} / {{ $frontmatter.flag }}',
'{{ $frontmatter.date }}',
'a {{$frontmatter.title}} b' // whitespace condensing parity
].join('\n\n')
)
// and nothing was left for the runtime to do
expect(resolvedHtml).not.toContain('$frontmatter')
})
test('spots left to the runtime render identically too', async () => {
await compare(
[
'a<code> {{ $frontmatter.title }} </code>b',
'a<em>\n{{ $frontmatter.title }}\n</em>b',
'<span title="a>b" v-pre>{{ $frontmatter.title }}</span>',
'{{ $frontmatter.html }}',
'{{ $frontmatter.spaced }}'
].join('\n\n')
)
})
})
})

@ -532,4 +532,26 @@ describe('node/markdown/plugins/include', () => {
expect(html).toContain('href="https://example.com/x"')
expect(html).toContain('href="/abs/target.html"')
})
test('does not rebase destinations resolved from frontmatter', async () => {
await write(
'guide/shared/note.md',
'![logo]({{$frontmatter.logo}}) [x]({{$frontmatter.doc}}) ![lit](./local.png)'
)
const { html, env } = await render(
'---\nlogo: ./assets/a.png\ndoc: ./other.md\n---\n\n<!-- @include: ./shared/note.md -->',
{},
{
path: path.join(root, 'guide/index.md'),
relativePath: 'guide/index.md'
}
)
// values from the including page's frontmatter keep meaning what they
// meant there
expect(html).toContain('src="./assets/a.png"')
expect(html).toContain('href="./other.html"')
expect(env.links).toContain('./other')
// urls authored in the included file still rebase
expect(html).toContain('src="./shared/local.png"')
})
})

@ -62,3 +62,79 @@ describe('node/markdown/plugins/link', () => {
expect(env.linkLines).toEqual([3])
})
})
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',
relativizeUrls: true,
...env
})
test('site-absolute links become page-relative', async () => {
expect(await render('[x](/other/thing)')).toContain(
'href="../other/thing.html"'
)
expect(
await render('[x](/other/thing)', { relativePath: 'index.md' })
).toContain('href="./other/thing.html"')
expect(
await render('[x](/other/thing)', { relativePath: 'a/b/c.md' })
).toContain('href="../../other/thing.html"')
})
test('directory links point at index.html', async () => {
expect(await render('[home](/)')).toContain('href="../index.html"')
expect(await render('[dir](/guide/)')).toContain(
'href="../guide/index.html"'
)
})
test('non-page files get the prefix but no .html', async () => {
expect(await render('[zip](/file.zip)')).toContain('href="../file.zip"')
})
test('hash, external and relative links stay untouched', async () => {
expect(await render('[a](#section)')).toContain('href="#section"')
expect(await render('[a](https://example.com/x)')).toContain(
'href="https://example.com/x"'
)
expect(await render('[a](./sibling)')).toContain('href="./sibling.html"')
})
test('cleanUrls drops .html and the index suffix', async () => {
expect(await render('[x](/other/thing)', { cleanUrls: true })).toContain(
'href="../other/thing"'
)
expect(await render('[dir](/guide/)', { cleanUrls: true })).toContain(
'href="../guide/"'
)
})
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"')
})
})
describe('node/markdown/plugins/link with an absolute base', () => {
const md = new MarkdownItAsync()
linkPlugin(md, {}, '/docs/', slugify)
test('site-absolute links get the base and keep one slash', async () => {
const html = await md.renderAsync('[x](/guide/what)', {
cleanUrls: false,
relativePath: 'index.md'
})
expect(html).toContain('href="/docs/guide/what.html"')
})
})

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import { disposeMdItInstance } from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
describe('node/markdownToVue', () => {
@ -153,4 +154,47 @@ describe('node/markdownToVue', () => {
expect(result.pageData.relativePath).toBe('index.md')
})
test('warns when transformPageData rewrites an interpolated value', async () => {
disposeMdItInstance()
root = await mkdtemp(path.join(tmpdir(), 'vitepress-eager-'))
const file = path.join(root, 'index.md')
const src = '---\ntitle: Old\n---\n\n# {{ $frontmatter.title }}\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const warnings: string[] = []
siteConfig.logger = {
...siteConfig.logger,
warn: (msg: string) => warnings.push(msg)
}
siteConfig.transformPageData = (pageData) => {
pageData.frontmatter.title = 'New'
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(src, file)
expect(result.vueSrc).toContain('Old')
expect(warnings.join('\n')).toContain('{{ $frontmatter.title }}')
// keys only added by the transform are left to the runtime - no warning
warnings.length = 0
siteConfig.transformPageData = (pageData) => {
pageData.frontmatter.added = 'later'
}
const src2 =
'---\ntitle: Old\n---\n\n{{ $frontmatter.title }} {{ $frontmatter.added }}\n'
await writeFile(file, src2)
await render(src2, file)
expect(warnings).toHaveLength(0)
})
})

@ -0,0 +1,98 @@
import {
deserializeFunctions,
serializeFunctions
} from 'node/utils/fnSerialize'
// runs the exact code shape that plugin.ts / build.ts emit into the site-data
// module and the metadata script — the revived value must come back without
// the deserializer ever compiling a string (new Function is used here only to
// stand in for the browser executing the emitted file)
function emitAndRevive(data: any): any {
const fns: string[] = []
const serialized = serializeFunctions(data, fns)
const script = `${deserializeFunctions};return deserializeFunctions(JSON.parse(${JSON.stringify(
JSON.stringify(serialized)
)}),[${fns.join(',')}])`
return new Function(script)()
}
describe('node/utils/fnSerialize', () => {
test('emitted deserializer does not rely on unsafe-eval', () => {
expect(deserializeFunctions).not.toContain('new Function')
expect(deserializeFunctions).not.toContain('eval')
})
test('serializes functions as indexed markers', () => {
const fns: string[] = []
const serialized = serializeFunctions(
{ a: (x: number) => x, b: { c: (x: number) => x * 2 } },
fns
)
expect(serialized).toEqual({ a: '_vp-fn_0', b: { c: '_vp-fn_1' } })
expect(fns).toHaveLength(2)
})
test('revives functions nested in objects and arrays', () => {
const data = {
search: {
options: {
miniSearch: {
options: {
tokenize: (text: string) => text.split(/\s+/)
},
searchOptions: {
boostDocument: (id: string) => (id === 'index.md' ? 2 : 1)
}
}
}
},
list: [(n: number) => n + 1, 'plain', 42]
}
const revived = emitAndRevive(data)
expect(revived.search.options.miniSearch.options.tokenize('a b')).toEqual([
'a',
'b'
])
expect(
revived.search.options.miniSearch.searchOptions.boostDocument('index.md')
).toBe(2)
expect(revived.list[0](1)).toBe(2)
expect(revived.list[1]).toBe('plain')
expect(revived.list[2]).toBe(42)
})
test('revives method shorthand and async functions', () => {
const data = {
tokenize(text: string) {
return text.toUpperCase()
},
async extractField(doc: { id: string }) {
return doc.id
}
}
const revived = emitAndRevive(data)
expect(revived.tokenize('abc')).toBe('ABC')
return expect(revived.extractField({ id: 'x' })).resolves.toBe('x')
})
test('drops underscore-prefixed keys', () => {
const revived = emitAndRevive({ _render: () => '', keep: 1 })
expect(revived).toEqual({ keep: 1 })
})
test('leaves data strings resembling markers untouched', () => {
const data = {
fn: (x: number) => x,
note: '_vp-fn_alert(1)'
}
const revived = emitAndRevive(data)
expect(revived.fn(1)).toBe(1)
expect(revived.note).toBe('_vp-fn_alert(1)')
})
})

@ -1,4 +1,10 @@
import { mergeHead, type HeadConfig } from 'shared/shared'
import {
isRelativeBase,
joinPath,
mergeHead,
relativePathToRoot,
type HeadConfig
} from 'shared/shared'
describe('shared/shared', () => {
describe('mergeHead', () => {
@ -54,3 +60,41 @@ describe('shared/shared', () => {
})
})
})
describe('shared/shared url helpers', () => {
describe('joinPath', () => {
test('joins and collapses slash collisions', () => {
expect(joinPath('/', '/guide/')).toBe('/guide/')
expect(joinPath('/docs/', '/guide/page')).toBe('/docs/guide/page')
expect(joinPath('/docs', 'guide')).toBe('/docsguide')
})
test('preserves the protocol of absolute url bases', () => {
expect(joinPath('https://cdn.example.com/', '/guide/')).toBe(
'https://cdn.example.com/guide/'
)
expect(joinPath('https://cdn.example.com/sub//x/', '/a')).toBe(
'https://cdn.example.com/sub/x/a'
)
expect(joinPath('//cdn.example.com/', '/a')).toBe('//cdn.example.com/a')
})
})
describe('isRelativeBase', () => {
test('only ./ is relative', () => {
expect(isRelativeBase('./')).toBe(true)
expect(isRelativeBase('/')).toBe(false)
expect(isRelativeBase('/docs/')).toBe(false)
expect(isRelativeBase('https://example.com/')).toBe(false)
})
})
describe('relativePathToRoot', () => {
test('maps a page path to its ../-prefix', () => {
expect(relativePathToRoot('index.md')).toBe('./')
expect(relativePathToRoot('foo.md')).toBe('./')
expect(relativePathToRoot('guide/index.md')).toBe('../')
expect(relativePathToRoot('guide/nested/page.md')).toBe('../../')
})
})
})

@ -0,0 +1,79 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', ({ clientX, clientY }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const x = (100 * clientX) / innerWidth
const y = (100 * clientY) / innerHeight
const maxRadius =
(100 *
Math.hypot(
Math.max(clientX, innerWidth - clientX),
Math.max(clientY, innerHeight - clientY)
)) /
(Math.hypot(innerWidth, innerHeight) / Math.SQRT2)
document.documentElement.style.setProperty('--switch-x', `${x}%`)
document.documentElement.style.setProperty('--switch-y', `${y}%`)
document.documentElement.style.setProperty('--switch-r', `${maxRadius}%`)
document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
})
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: switch-appearance 300ms ease-in;
}
.dark::view-transition-new(root) {
animation: none;
}
.dark::view-transition-old(root) {
animation: switch-appearance 300ms ease-in reverse forwards;
z-index: 1;
}
@keyframes switch-appearance {
from {
clip-path: circle(0 at var(--switch-x) var(--switch-y));
}
to {
clip-path: circle(var(--switch-r) at var(--switch-x) var(--switch-y));
}
}
.VPSwitchAppearance {
width: 1.375rem !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>

@ -36,23 +36,15 @@ Note that you should reference files placed in `public` using root absolute path
## Base URL
If your site is deployed to a non-root URL, you will need to set the `base` option in `.vitepress/config.js`. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'` (it should always start and end with a slash).
If your site is deployed to a non-root URL, set the [`base`](../reference/site-config#base) option. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'`
All your static asset paths are automatically processed to adjust for different `base` config values. For example, if you have an absolute reference to an asset under `public` in your markdown:
Static asset references are automatically adjusted for the base, so an absolute reference to a file in `public` works with any `base` and never needs updating:
```md
![An image](/image-inside-public.png)
```
You do **not** need to update it when you change the `base` config value in this case.
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:
```vue
<img :src="theme.logoPath" />
```
In this case it is recommended to wrap the path with the [`withBase` helper](../reference/runtime-api#withbase) provided by VitePress:
Only dynamically constructed paths need care — for example, an image whose `src` is based on a theme config value. Wrap those with the [`withBase` helper](../reference/runtime-api#withbase) so the base is prepended at runtime:
```vue
<script setup>
@ -65,3 +57,26 @@ const { theme } = useData()
<img :src="withBase(theme.logoPath)" />
</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.
:::

@ -38,7 +38,12 @@ interface Theme {
*/
enhanceApp?: (ctx: EnhanceAppContext) => Awaitable<void>
/**
* Extend another theme, calling its `enhanceApp` before ours
* Runs inside the root component's `setup()`
* @optional
*/
setup?: () => void
/**
* Extend another theme, calling its `enhanceApp` and `setup` before ours
* @optional
*/
extends?: Theme
@ -88,6 +93,26 @@ export default {
Return `false` from `onBeforeRouteChange` or `onBeforePageLoad` to cancel navigation.
The `setup` hook runs inside the root component's `setup()`, so Composition API calls (`onMounted`, `watch`, composables, ...) work there without wrapping the layout component:
```ts [.vitepress/theme/index.ts]
import { watch } from 'vue'
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
export default {
extends: DefaultTheme,
setup() {
const { page } = useData()
watch(() => page.value.relativePath, (path) => {
console.log('now viewing', path)
})
}
}
```
With `extends`, each theme's `setup` runs base-first, like `enhanceApp`. It also runs during SSR/SSG rendering, so keep browser-only work inside `onMounted`.
The default export is the only contract for a custom theme, and only the `Layout` property is required. So technically, a VitePress theme can be as simple as a single Vue component.
Inside your layout component, it works just like a normal Vite + Vue 3 application. Do note the theme also needs to be [SSR-compatible](./ssr-compat).

@ -54,6 +54,30 @@ 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/<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 `<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.
## 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.

@ -258,79 +258,7 @@ Full list of slots available in the default theme layout:
You can extend the default theme to provide a custom transition when the color mode is toggled. An example:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
Result (**warning!**: flashing colors, sudden movements, bright lights):

@ -36,6 +36,8 @@ editLink: true
Guide content
```
Property accesses like `{{ $frontmatter.title }}` are resolved while the Markdown is rendered, so the value also ends up in the local search index, in [content loader](./data-loading#createcontentloader) output, in heading anchors - the heading above gets `id="docs-with-vitepress"` - and in link targets written without spaces around the expression, like `[text]({{$frontmatter.link}})`. Other expressions are evaluated by Vue at runtime as usual, and wrapping an expression in [`v-pre`](./using-vue#escaping) shows it literally.
You can also access current page's frontmatter data in `<script setup>` with the [`useData()`](../reference/runtime-api#usedata) helper.
## Alternative Frontmatter Formats

@ -45,6 +45,7 @@ vitepress build [root]
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `--mpa` (experimental) | Build in [MPA mode](../guide/mpa-mode) without client-side hydration (`boolean`) |
| `--base <path>` | Public base path (default: `/`) (`string`) |
| `--assetsBase <url>` | URL prefix the generated assets are served from, e.g. a CDN (`string`) |
| `--target <target>` | Transpile target (default: `"modules"`) (`string`) |
| `--outDir <dir>` | Output directory relative to **cwd** (default: `<root>/.vitepress/dist`) (`string`) |
| `--assetsInlineLimit <number>` | Static asset base64 inline threshold in bytes (default: `4096`) (`number`) |
@ -64,6 +65,7 @@ vitepress preview [root]
| Option | Description |
| --------------- | ------------------------------------------ |
| `--base <path>` | Public base path (default: `/`) (`string`) |
| `--assetsBase <url>` | URL prefix the generated assets are served from, e.g. a CDN (`string`) |
| `--port <port>` | Specify port (`number`) |
## `vitepress init`

@ -254,6 +254,9 @@ export default {
{ icon: 'github', link: 'https://github.com/vuejs/vitepress' },
{ icon: 'twitter', link: '...' },
{ icon: 'discord', link: '/community', target: '_self' },
// You can use any other iconify collection installed in your project
// as `collection:name` (e.g. after `npm add -D @iconify-json/lucide`):
{ icon: 'lucide:rss', link: '/feed.rss' },
// You can also add custom icons by passing SVG as string:
{
icon: {

@ -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:

@ -136,6 +136,38 @@ router.onBeforeRouteChange = (to) => {
For custom themes, the same router is available from [`enhanceApp`](../guide/custom-theme#theme-interface).
## `useIcon` <Badge type="info" text="composable" />
- **Type**: `(icon: MaybeRefOrGetter<string | { svg: string } | undefined>, el?: MaybeRefOrGetter<HTMLElement | null>) => ComputedRef<string | undefined>`
Renders an [iconify](https://iconify.design/) icon through VitePress's icon pipeline. Takes a fully qualified `collection:name` (resolved against the `@iconify-json/*` packages in your project's dependencies) and returns the class to put on the element — `vpi-<collection>-<name>`.
During SSR the name is registered on the page's [`SSGContext`](./site-config#postrender), so the build emits the icon's styles into the generated stylesheet; in dev, icons are served on demand by the dev server from the locally installed collections. No icon is ever fetched from an external service.
```vue
<script setup>
import { useIcon } from 'vitepress'
import { useTemplateRef } from 'vue'
const el = useTemplateRef('el')
const iconClass = useIcon('lucide:rocket', el)
</script>
<template>
<span ref="el" :class="iconClass" />
</template>
```
Pass the template ref of the element carrying the class so dev mode can resolve the icon on it. The element needs the mask rules the default theme ships; in a custom theme without them, dev applies an inline equivalent and the generated stylesheet includes zero-specificity base rules for production.
When using the default theme, the `VPIcon` component from `vitepress/theme` wraps this composable (and also accepts a raw `{ svg }` string):
```vue-html
<VPIcon icon="lucide:rocket" />
```
Icons rendered only on the client (e.g. inside `<ClientOnly />`) can't be collected during the build — list them in [`icons.include`](./site-config#icons) instead.
## `withBase` <Badge type="info" text="helper" />
- **Type**: `(path: string) => string`

@ -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.
The one exception is `'./'`, which produces a [relocatable build](../guide/deploy#relocatable-builds-relative-base): 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.
@ -382,6 +384,8 @@ export default {
}
```
Can also be set per build with `vitepress build --base /base/`.
## Routing
### cleanUrls
@ -463,6 +467,44 @@ 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 and `hashmap.json` 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/`.
### icons
- Type: `{ include?: string[] }`
Options for the generated icon styles. The build collects every iconify icon rendered during SSR. Names are fully qualified as `collection:name`, resolved against the `@iconify-json/*` packages declared in your project's dependencies.
Icons rendered only on the client — inside `<ClientOnly>`, or after hydration — are invisible to SSR collection. List them in `include` to force them into the stylesheet:
```ts
export default {
icons: {
include: ['mdi:home', 'simple-icons:discord']
}
}
```
### cacheDir
- Type: `string`
@ -632,6 +674,7 @@ export default {
interface SSGContext {
content: string
teleports?: Record<string, string>
vpIcons: Set<string>
[key: string]: any
}
```
@ -710,6 +753,10 @@ For simpler cases, it may be possible to use the [`head`](./frontmatter-config#h
Don't mutate anything inside the `context`. Also, modifying the html content may cause hydration problems in runtime.
:::
::: note
The icon stylesheet link still carries its `vp-icons.__VP_ICONS_HASH__.css` placeholder at this point — the content hash only exists once every page has rendered, and it is substituted right after. Hooks that inline or fingerprint head assets should skip that tag.
:::
```ts
export default {
async transformHtml(code, id, context) {

@ -218,79 +218,7 @@ Lista completa de _slots_ disponibles en el layout del tema por defecto:
Puede extender el tema por defecto para proporcionar una transición personalizada cuando el modo de color es alternado. Un ejemplo:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
Resultado (**atención!**: colores destellantes, movimientos súbitos, luces brillantes):

@ -220,79 +220,7 @@ export default {
شما می‌توانید تم پیش‌فرض را گسترش دهید تا هنگام تغییر حالت رنگ، یک انتقال سفارشی را فراهم کند. به عنوان مثال:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
نتیجه (**هشدار!**: رنگ‌های فلاشینگ، حرکات ناگهانی، نورهای شدید):

@ -220,79 +220,7 @@ export default {
カラーモード切り替え時にカスタムトランジションを提供するよう、デフォルトテーマを拡張できます。例:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
結果(**注意!**:点滅や急な動き、明るい光を含みます):

@ -218,79 +218,7 @@ export default {
기본 테마를 확장하여 컬러 모드가 전환될 때 커스텀 트랜지션 효과를 제공할 수 있습니다. 예제:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
결과 (**광과민성 주의!**: 색상 깜빡임, 갑작스러운 움직임, 밝은 빛):

@ -218,79 +218,7 @@ Lista completa de _slots_ disponíveis no layout do tema padrão:
Você pode estender o tema padrão para fornecer uma transição personalizada quando o modo de cor é alternado. Um exemplo:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
Resultado (**atenção!**: cores piscantes, movimentos súbitos, luzes brilhantes):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 226 KiB

@ -219,79 +219,7 @@ export default {
Вы можете расширить стандартную тему, чтобы обеспечить пользовательский переход при переключении цветового режима. Пример:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
Результат (**предупреждение!**: мигающие цвета, резкие движения, яркий свет):

@ -1,5 +1,5 @@
{
"extends": "../tsconfig.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": true,

@ -217,79 +217,7 @@ export default {
可以扩展默认主题以在切换颜色模式时提供自定义过渡动画。例如:
```vue [.vitepress/theme/Layout.vue]
<script setup lang="ts">
import { useData } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import { nextTick, provide } from 'vue'
const { isDark } = useData()
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
fill: 'forwards',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
</script>
<template>
<DefaultTheme.Layout />
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>
```
<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue]
结果(**注意!**:画面闪烁、快速闪现、强光刺激):

@ -42,6 +42,7 @@
"vitepress": "bin/vitepress.js"
},
"files": [
"THIRD-PARTY-NOTICES.md",
"bin",
"dist",
"types",
@ -56,8 +57,9 @@
"build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check",
"build:check": "publint && attw --pack . --profile esm-only",
"typecheck": "tsc -p tsconfig.shared.json && vue-tsc -p tsconfig.client.json && tsc -p tsconfig.node.json",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'",
"test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init|base)$/'",
"test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && tsc -p __tests__/base && vue-tsc -p docs",
"test:base": "pnpm -F=tests-base test",
"test:unit": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",
@ -80,7 +82,7 @@
"docs:lunaria:open": "pnpm -F=docs lunaria:open",
"format": "prettier --experimental-cli --write .",
"format:fail": "prettier --experimental-cli --check .",
"check": "pnpm format:fail && pnpm build && pnpm test",
"check": "pnpm format:fail && pnpm build && git ls-files --error-unmatch THIRD-PARTY-NOTICES.md && git diff --exit-code -- THIRD-PARTY-NOTICES.md && pnpm test",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "node scripts/release.ts"
},
@ -110,6 +112,7 @@
"vue": "^3.5.41"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@clack/prompts": "^1.7.0",
"@iconify/utils": "^3.1.4",
"@mdit-vue/plugin-component": "^3.0.2",
@ -125,11 +128,9 @@
"@mdit/plugin-emoji": "^1.1.1",
"@mdit/plugin-footnote": "^1.0.2",
"@mdit/plugin-tasklist": "^1.0.2",
"@arethetypeswrong/cli": "^0.18.5",
"@polka/compression": "^1.0.0-next.28",
"@rolldown/pluginutils": "^1.0.1",
"@types/cross-spawn": "^6.0.6",
"@types/lodash.template": "^4.5.3",
"@types/mark.js": "^8.11.12",
"@types/minimist": "^1.2.5",
"@types/node": "^26.2.0",
@ -140,11 +141,11 @@
"conventional-changelog": "^8.1.1",
"conventional-changelog-angular": "^9.2.1",
"cross-spawn": "^7.0.6",
"eta": "^4.6.0",
"get-port": "^7.2.0",
"gray-matter": "^4.0.3",
"image-size": "^2.0.2",
"lint-staged": "^17.3.0",
"lodash.template": "^4.18.1",
"lru-cache": "^11.5.2",
"markdown-it": "^14.3.0",
"markdown-it-async": "^2.2.0",
@ -190,5 +191,5 @@
"optional": true
}
},
"packageManager": "pnpm@11.21.0"
"packageManager": "pnpm@11.24.0"
}

@ -34,7 +34,7 @@ importers:
version: 14.1.2
'@vitejs/plugin-vue':
specifier: ^6.0.8
version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
version: 6.0.8(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
'@vue/devtools-api':
specifier: ^8.2.1
version: 8.2.1
@ -61,7 +61,7 @@ importers:
version: 4.4.3
vite:
specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
version: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vue:
specifier: ^3.5.41
version: 3.5.41(typescript@6.0.3)
@ -123,9 +123,6 @@ importers:
'@types/cross-spawn':
specifier: ^6.0.6
version: 6.0.6
'@types/lodash.template':
specifier: ^4.5.3
version: 4.5.3
'@types/mark.js':
specifier: ^8.11.12
version: 8.11.12
@ -156,6 +153,9 @@ importers:
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
eta:
specifier: ^4.6.0
version: 4.6.0
get-port:
specifier: ^7.2.0
version: 7.2.0
@ -168,9 +168,6 @@ importers:
lint-staged:
specifier: ^17.3.0
version: 17.3.0
lodash.template:
specifier: ^4.18.1
version: 4.18.1
lru-cache:
specifier: ^11.5.2
version: 11.5.2
@ -257,7 +254,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
version: 4.1.10(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
vue-sfc-transformer:
specifier: ^0.2.5
version: 0.2.5(patch_hash=06dbce7d98fac77faf5e66a94aa6e04f34c0cc7ec0910ec10848e5f2e08b8b68)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/compiler-core@3.5.41)(@vue/language-core@3.3.11)(rolldown@1.2.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
@ -268,8 +265,17 @@ importers:
specifier: ^9.1.0
version: 9.1.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
__tests__/base:
devDependencies:
vitepress:
specifier: workspace:*
version: link:../..
__tests__/e2e:
devDependencies:
'@iconify-json/lucide':
specifier: ^1.2.126
version: 1.2.126
vitepress:
specifier: workspace:*
version: link:../..
@ -299,7 +305,7 @@ importers:
version: link:..
vitepress-plugin-group-icons:
specifier: ^1.7.6
version: 1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
version: 1.7.6(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
vitepress-plugin-llms:
specifier: ^1.13.4
version: 1.13.4(supports-color@7.2.0)
@ -411,6 +417,9 @@ packages:
'@iconify-json/logos@1.2.12':
resolution: {integrity: sha512-zUi/AoezU2F3L65nPVd2smiU6Y+ZI7RjdVPlGfeAeYbPbZ9kWn7Ucxj+KshmyQRBYwLtKoqAlUyoGgMqWG1T8g==}
'@iconify-json/lucide@1.2.126':
resolution: {integrity: sha512-Fl3OfR71yeWLrlTLp6C4W5W3rJJDWH9/e70mjtq9VAldYDxvHh149JMNPz7foTeLLTE2Paynnp2aYKVL9rgF5Q==}
'@iconify-json/simple-icons@1.2.93':
resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==}
@ -751,12 +760,6 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash.template@4.5.3':
resolution: {integrity: sha512-Mo0UYKLu1oXgkV9TVoXZLlXXjyIXlW7ZQRxi/4gQJmzJr63dmicE8gG0OkPjYTKBrBic852q0JzqrtNUWLBIyA==}
'@types/lodash@4.17.25':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/mark.js@8.11.12':
resolution: {integrity: sha512-244ZnaIBpz4c6xutliAnYVZp6xJlmC569jZqnR3ElO1Y01ooYASSVQEqpd2x0A2UfrgVMs5V9/9tUAdZaDMytQ==}
@ -1457,6 +1460,10 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eta@4.6.0:
resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==}
engines: {node: '>=20'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@ -1801,16 +1808,6 @@ packages:
engines: {node: '>=22.22.1'}
hasBin: true
lodash._reinterpolate@3.0.0:
resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
lodash.template@4.18.1:
resolution: {integrity: sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==}
deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead.
lodash.templatesettings@4.2.0:
resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@ -2594,13 +2591,13 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.2.1:
resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==}
vite@8.2.2:
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.4.0
'@vitejs/devtools': ^0.4.0 || ^0.5.0
esbuild: '*'
jiti: '>=1.21.0'
less: ^4.0.0
@ -2920,6 +2917,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/lucide@1.2.126':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/simple-icons@1.2.93':
dependencies:
'@iconify/types': 2.0.0
@ -3228,12 +3229,6 @@ snapshots:
'@types/linkify-it@5.0.0': {}
'@types/lodash.template@4.5.3':
dependencies:
'@types/lodash': 4.17.25
'@types/lodash@4.17.25': {}
'@types/mark.js@8.11.12':
dependencies:
'@types/jquery': 4.0.1
@ -3275,10 +3270,10 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))':
'@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vue: 3.5.41(typescript@6.0.3)
'@vitest/expect@4.1.10':
@ -3290,13 +3285,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))':
'@vitest/mocker@4.1.10(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@ -3846,6 +3841,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.9
eta@4.6.0: {}
expect-type@1.4.0: {}
extend-shallow@2.0.1:
@ -4164,17 +4161,6 @@ snapshots:
optionalDependencies:
yaml: 2.9.0
lodash._reinterpolate@3.0.0: {}
lodash.template@4.18.1:
dependencies:
lodash._reinterpolate: 3.0.0
lodash.templatesettings: 4.2.0
lodash.templatesettings@4.2.0:
dependencies:
lodash._reinterpolate: 3.0.0
lodash@4.18.1: {}
log-symbols@7.0.1:
@ -5053,7 +5039,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0):
vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@ -5066,13 +5052,13 @@ snapshots:
jiti: 1.21.7
yaml: 2.9.0
vitepress-plugin-group-icons@1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)):
vitepress-plugin-group-icons@1.7.6(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)):
dependencies:
'@iconify-json/logos': 1.2.12
'@iconify-json/vscode-icons': 1.2.70
'@iconify/utils': 3.1.4
optionalDependencies:
vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vitepress-plugin-llms@1.13.4(supports-color@7.2.0):
dependencies:
@ -5093,10 +5079,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)):
vitest@4.1.10(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
'@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@ -5113,7 +5099,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0

@ -100,7 +100,12 @@ async function main() {
// Commit changes to the Git and create a tag.
prompts.log.step('Committing changes...')
await run('git', ['add', 'CHANGELOG.md', 'package.json'])
await run('git', [
'add',
'CHANGELOG.md',
'package.json',
'THIRD-PARTY-NOTICES.md'
])
await run('git', ['commit', '-m', `release: v${targetVersion}`])
await run('git', ['tag', `v${targetVersion}`])

@ -181,6 +181,11 @@ def build_subsets(release: Path, subsets: dict[str, str]) -> None:
options = subset.Options()
options.flavor = "woff2"
options.layout_features = [*options.layout_features, "pnum", "tnum"]
# keep the OFL license notice (13) and url (14) name records that
# upstream embeds: the subsets are Modified Versions, and OFL §2
# requires the license to travel with every copy - including the
# ones Vite copies into users' publicly served site builds
options.name_IDs = [*options.name_IDs, 13, 14]
font = subset.load_font(release / file, options)
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=parse_ranges(value))

@ -0,0 +1,82 @@
import {
computed,
onMounted,
toValue,
useSSRContext,
watchPostEffect,
type ComputedRef,
type MaybeRefOrGetter
} from 'vue'
import { parseIconName, type SSGContext } from '../../shared'
import { withBase } from '../utils'
/**
* Resolves an icon name (`collection:name`, e.g. `simple-icons:github`) to
* its `vpi-<collection>-<name>` class. During SSR the name is registered so
* the build emits its CSS rule; in dev the SVG is served on demand and
* applied to `el` inline.
*/
export function useIcon(
icon: MaybeRefOrGetter<string | { svg: string } | undefined>,
el?: MaybeRefOrGetter<HTMLElement | null>
): ComputedRef<string | undefined> {
const parsed = computed(() => {
const value = toValue(icon)
return typeof value === 'string' ? parseIconName(value) : null
})
const iconClass = computed(() =>
parsed.value
? `vpi-${parsed.value.collection}-${parsed.value.icon}`
: undefined
)
if (import.meta.env.SSR) {
const ctx = useSSRContext<SSGContext>()
const value = toValue(icon)
// unparseable names are registered too — the build warns about them
if (typeof value === 'string') ctx?.vpIcons.add(value)
} else if (import.meta.env.DEV) {
// dev has no generated stylesheet — the icon is always fetched from the
// dev server, re-resolved when the name changes
let applied: string | undefined
onMounted(() => {
watchPostEffect(() => {
const span = toValue(el)
if (!span) return
const name = parsed.value
if (!name) {
if (applied) {
span.style.removeProperty('--icon')
applied = undefined
}
return
}
const key = `${name.collection}/${name.icon}`
if (applied === key) return
applied = key
span.style.setProperty(
'--icon',
`url('${withBase(`/_vpi/${name.collection}/${name.icon}.svg`)}')`
)
// inline the mask setup for themes without the default icon rules
const styles = getComputedStyle(span)
if ((styles.maskImage || styles.webkitMaskImage) === 'none') {
Object.assign(span.style, {
display: 'inline-block',
width: '1em',
height: '1em',
mask: 'var(--icon) no-repeat',
webkitMask: 'var(--icon) no-repeat',
maskSize: '100% 100%',
webkitMaskSize: '100% 100%',
backgroundColor: 'currentColor'
})
}
})
})
}
return iconClass
}

@ -3,6 +3,7 @@
import { onMounted, onUnmounted, watch } from 'vue'
import { EXTERNAL_URL_RE } from '../../shared'
import { useRoute } from '../router'
import { inBrowser, pathToFile } from '../utils'
@ -12,13 +13,15 @@ const createLink = () => document.createElement('link')
const viaDOM = (url: string) => {
const link = createLink()
link.rel = `prefetch`
if (EXTERNAL_URL_RE.test(url)) link.crossOrigin = ''
link.href = url
document.head.appendChild(link)
}
const viaXHR = (url: string) => {
const req = new XMLHttpRequest()
req.open('GET', url, (req.withCredentials = true))
req.open('GET', url, true)
req.withCredentials = !EXTERNAL_URL_RE.test(url)
req.send()
}

@ -26,8 +26,12 @@ function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme {
...base,
...theme,
async enhanceApp(ctx) {
if (base.enhanceApp) await base.enhanceApp(ctx)
if (theme.enhanceApp) await theme.enhanceApp(ctx)
await base.enhanceApp?.(ctx)
await theme.enhanceApp?.(ctx)
},
setup() {
base.setup?.()
theme.setup?.()
}
}
}

@ -4,7 +4,7 @@ import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload, Route } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared'
import { siteDataRef } from './data'
import { inBrowser, withBase } from './utils'
import { inBrowser, runtimeBase, withBase } from './utils'
export interface Router {
/**
@ -123,7 +123,7 @@ export function createRouter(
if (inBrowser) {
nextTick(() => {
let actualPathname =
siteDataRef.value.base +
runtimeBase() +
__pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1')
if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) {
@ -153,7 +153,7 @@ export function createRouter(
// the updated pageToHash map and fetch again.
if (!isRetry) {
try {
const res = await fetch(siteDataRef.value.base + 'hashmap.json')
const res = await fetch(runtimeBase() + 'hashmap.json')
;(window as any).__VP_HASH_MAP__ = await res.json()
await loadPage(href, { scrollPosition, isRetry: true, initialLoad })
return
@ -168,7 +168,7 @@ export function createRouter(
? route.path
.replace(/(^|\/)$/, '$1index')
.replace(/(\.html)?$/, '.md')
.slice(siteDataRef.value.base.length)
.slice(runtimeBase().length)
: '404.md'
route.data = { ...notFoundPageData, relativePath }
syncRouteQueryAndHash(targetLoc)
@ -318,7 +318,7 @@ function shouldHotReload(payload: PageDataPayload): boolean {
const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1')
const locationPath = location.pathname
.replace(/(?:(^|\/)index)?\.html$/, '')
.slice(siteDataRef.value.base.length - 1)
.slice(runtimeBase().length - 1)
return payloadPath === locationPath
}

@ -7,7 +7,7 @@ import { createApp } from './index'
export async function render(path: string) {
const { app, router } = await createApp()
await router.go(path)
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
const ctx: SSGContext = { content: '', vpIcons: new Set<string>() }
ctx.content = await renderToString(app, ctx)
return ctx
}

@ -15,7 +15,8 @@ export interface Theme {
extends?: Theme
/**
* @deprecated can be replaced by wrapping layout component
* Runs inside the root component's `setup()` (during SSR too). With
* `extends`, setups run base-first, like `enhanceApp`.
*/
setup?: () => void

@ -3,19 +3,42 @@ import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue'
import {
EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL,
inBrowser,
isRelativeBase,
joinPath,
sanitizeFileName,
type Awaitable
} from '../shared'
import { siteDataRef } from './data'
export { escapeHtml as _escapeHtml, inBrowser } from '../shared'
export { joinPath } from '../shared'
let resolvedBase: string | undefined
/**
* Join two paths by resolving the slash collision.
* Runtime base path used by the app.
*
* Usually this is the configured site base.
*
* For a relative base (`'./'`), the mount point is unknown at build time, so:
* - SSR: uses `RELATIVE_BASE_SENTINEL` (for per-page URL relativization)
* - dev browser: uses `'/'` (dev server always mounts at root)
* - prod browser: resolves from the page's `__VP_SITE_ROOT__`
*/
export function joinPath(base: string, path: string) {
return `${base}${path}`.replace(/\/+/g, '/')
export function runtimeBase(): string {
if (resolvedBase === undefined) {
const base = siteDataRef.value.base
if (!isRelativeBase(base)) return (resolvedBase = base)
if (!inBrowser) return (resolvedBase = RELATIVE_BASE_SENTINEL)
if (import.meta.env.DEV) return (resolvedBase = '/')
const root = (window as any).__VP_SITE_ROOT__
resolvedBase = root
? decodeURIComponent(new URL(root, location.href).pathname)
: '/'
}
return resolvedBase
}
/**
@ -24,7 +47,7 @@ export function joinPath(base: string, path: string) {
export function withBase(path: string) {
return EXTERNAL_URL_RE.test(path) || !path.startsWith('/')
? path
: joinPath(siteDataRef.value.base, path)
: joinPath(runtimeBase(), path)
}
/**
@ -42,7 +65,11 @@ export function pathToFile(path: string) {
// the path conversion scheme.
// /foo/bar.html -> ./foo_bar.md
if (inBrowser) {
const base = import.meta.env.BASE_URL
const base = runtimeBase()
// the site root may arrive without its trailing slash; anything
// outside the base has no page chunk at all
if (pagePath + '/' === base) pagePath = base
if (!pagePath.startsWith(base)) return null
pagePath =
sanitizeFileName(
pagePath.slice(base.length).replace(/\//g, '_') || 'index'
@ -57,7 +84,7 @@ export function pathToFile(path: string) {
pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()]
}
if (!pageHash) return null
pagePath = `${base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js`
} else {
// ssr build uses much simpler name mapping
pagePath = `./${sanitizeFileName(

@ -17,6 +17,7 @@ import { ClientOnly } from './app/components/ClientOnly'
import { Content } from './app/components/Content'
// composables
export { useIcon } from './app/composables/icon'
export { dataSymbol, useData } from './app/data'
export { useRoute, useRouter } from './app/router'

@ -9,6 +9,7 @@ declare const __ALGOLIA__: boolean
declare const __CARBON__: boolean
declare const __VUE_PROD_DEVTOOLS__: boolean
declare const __ASSETS_DIR__: string
declare const __ASSETS_BASE__: string
declare module '@siteData' {
import type { SiteData } from 'vitepress'

@ -2,18 +2,25 @@
import { useRoute } from 'vitepress'
import { computed } from 'vue'
import { runtimeBase } from '../../app/utils'
import { isRelativeBase } from '../../shared'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout'
import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue'
const { theme } = useData()
const { theme, site } = useData()
const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useLayout()
const pageName = computed(() =>
route.path.replace(/[./]+/g, '_').replace(/_html$/, '')
)
const pageName = computed(() => {
// the mount point is unknown at build time, so the class must come from
// the site-relative path or ssr and hydration disagree
const path = isRelativeBase(site.value.base)
? '/' + route.path.slice(runtimeBase().length)
: route.path
return path.replace(/[./]+/g, '_').replace(/_html$/, '')
})
</script>
<template>

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { useIcon } from 'vitepress'
import { useTemplateRef } from 'vue'
const props = defineProps<{
icon: string | { svg: string }
}>()
const el = useTemplateRef('el')
const iconClass = useIcon(() => props.icon, el)
</script>
<template>
<span v-if="typeof icon === 'object'" class="VPIcon" v-html="icon.svg"></span>
<span v-else ref="el" :class="iconClass"></span>
</template>
<style scoped>
.VPIcon {
display: inline-block;
width: 1em;
height: 1em;
}
.VPIcon :deep(svg) {
width: 100%;
height: 100%;
fill: currentColor;
}
</style>

@ -11,7 +11,7 @@ import {
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch'
import { dataSymbol, useRouter } from 'vitepress'
import { dataSymbol, useRouter, withBase } from 'vitepress'
import {
computed,
createApp,
@ -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()
@ -254,7 +254,7 @@ watchDebounced(
)
async function fetchExcerpt(id: string) {
const file = pathToFile(id.slice(0, id.indexOf('#')))
const file = pathToFile(withBase(id.replace(/#.*$/, '')))
try {
if (!file) throw new Error(`Cannot find file for id: ${id}`)
return { id, mod: await import(/*@vite-ignore*/ file) }
@ -363,7 +363,7 @@ onKeyStroke('Enter', (e) => {
}
if (selectedPackage) {
router.go(selectedPackage.id)
router.go(withBase(selectedPackage.id))
emit('close')
}
})
@ -554,7 +554,7 @@ function onMouseMove(e: MouseEvent) {
role="option"
>
<a
:href="p.id"
:href="withBase(p.id)"
class="result"
:class="{
selected: selectedIndex === index

@ -227,6 +227,15 @@ const overflow = provideNavOverflow({
height: var(--vp-nav-height);
}
@media (min-width: 48rem) {
/* keeps search on the title's side when there is no nav menu to grow
into the middle; with a menu present its flex-grow wins and this
margin resolves to zero */
.content-body > .search {
margin-right: auto;
}
}
/* collapsed into the `` menu kept mounted (hidden, out of the a11y tree
and tab order) so its natural width stays measurable */
.content-body > .collapsed {
@ -265,6 +274,9 @@ const overflow = provideNavOverflow({
/* above the background surface, below the bar's content an open flyout
panel overlaps the bar's bottom edge and must cover the rule */
z-index: -1;
/* own layer Safari otherwise sorts the rule behind the sticky local
nav's surface in their overlapping row (#5399) */
transform: translateZ(0);
width: 100%;
height: 1px;
padding-left: var(--vp-nav-col-offset);

@ -9,9 +9,8 @@ defineProps<{
<span class="vpi-search" aria-hidden="true"></span>
<span class="text">{{ text }}</span>
<span class="keys" aria-hidden="true">
<kbd class="key-cmd">&#x2318;</kbd>
<kbd class="key-ctrl">Ctrl</kbd>
<kbd>K</kbd>
<kbd class="key-mod"></kbd>
<kbd class="key-k"></kbd>
</span>
</button>
</template>
@ -27,9 +26,7 @@ defineProps<{
}
.text,
.keys,
:root.mac .key-ctrl,
:root:not(.mac) .key-cmd {
.keys {
display: none;
}
@ -38,6 +35,18 @@ kbd {
font-weight: 500;
}
.key-mod::before {
content: 'Ctrl';
}
:root.mac .key-mod::before {
content: '\2318';
}
.key-k::before {
content: 'K';
}
@media (min-width: 48rem) {
.VPNavBarSearchButton {
height: auto;

@ -1,14 +1,9 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import {
computed,
nextTick,
onMounted,
useSSRContext,
useTemplateRef
} from 'vue'
import { computed } from 'vue'
import { isExternal, type SSGContext } from '../../shared'
import { isExternal } from '../../shared'
import VPIcon from './VPIcon.vue'
const props = defineProps<{
icon: DefaultTheme.SocialLinkIcon
@ -18,45 +13,23 @@ const props = defineProps<{
me: boolean
}>()
const el = useTemplateRef('el')
onMounted(async () => {
await nextTick()
const span = el.value?.children[0]
if (
span instanceof HTMLElement &&
span.className.startsWith('vpi-social-') &&
(getComputedStyle(span).maskImage ||
getComputedStyle(span).webkitMaskImage) === 'none'
) {
span.style.setProperty(
'--icon',
`url('https://api.iconify.design/simple-icons/${props.icon}.svg')`
)
}
})
const svg = computed(() => {
if (typeof props.icon === 'object') return props.icon.svg
return `<span class="vpi-social-${props.icon}"></span>`
})
if (import.meta.env.SSR) {
typeof props.icon === 'string' &&
useSSRContext<SSGContext>()?.vpSocialIcons.add(props.icon)
}
const qualifiedIcon = computed(() =>
typeof props.icon === 'string' && !props.icon.includes(':')
? `simple-icons:${props.icon}`
: props.icon
)
</script>
<template>
<a
ref="el"
class="VPSocialLink no-icon"
:href="link"
:aria-label="ariaLabel ?? (typeof icon === 'string' ? icon : '')"
:target="target ?? (isExternal(link) ? '_blank' : undefined)"
:rel="me ? 'me noopener' : 'noopener'"
v-html="svg"
></a>
>
<VPIcon :icon="qualifiedIcon" />
</a>
</template>
<style scoped>
@ -75,10 +48,14 @@ if (import.meta.env.SSR) {
transition: color 0.25s;
}
.VPSocialLink > :deep(svg),
.VPSocialLink > :deep([class^="vpi-social-"]) {
.VPSocialLink > :deep(span) {
/* keeps a nested custom svg centered instead of baseline-aligned */
display: flex;
width: 1.25rem;
height: 1.25rem;
}
.VPSocialLink :deep(svg) {
fill: currentColor;
}
</style>

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

@ -605,8 +605,8 @@
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 0.6875rem 0.6875rem;
mask-size: 0.6875rem 0.6875rem;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
/*rtl:raw:transform: scaleX(-1);*/
vertical-align: middle;
font-size: 0.5625rem;

@ -1,6 +1,9 @@
[class^='vpi-'],
[class*=' vpi-'],
.vp-icon {
/* an unresolved icon masks to nothing instead of a currentColor box */
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
display: inline-block;
width: 1em;
height: 1em;
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save