mirror of https://github.com/vuejs/vitepress
commit
f329b2d3f1
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>
|
||||||
|
After Width: | Height: | Size: 70 B |
@ -0,0 +1,13 @@
|
|||||||
|
# Home
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[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 .
|
||||||
|
|
||||||
|
More body.
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[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()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 < 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 & 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>{{ x }}</p>'
|
||||||
|
)
|
||||||
|
// entity look-alikes must survive the template compiler's decoding
|
||||||
|
expect(await renderBody('{{ $frontmatter.amp }}')).toBe(
|
||||||
|
'<p>a &lt; b</p>'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await renderBody(
|
||||||
|
'© {{ $frontmatter.title }} / {{ $frontmatter.no }}'
|
||||||
|
)
|
||||||
|
).toBe('<p>© 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('{{ x }} 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('{{ x }} a &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('{{ x }}')
|
||||||
|
expect(toc).toContain('a &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
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
`)
|
||||||
|
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')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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>
|
||||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 226 KiB |
@ -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
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue