feat: hashed icon styles, offline dev icons, arbitrary iconify collections (#5407)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/5408/head
Divyansh Singh 2 weeks ago committed by GitHub
parent d3b2957db0
commit d00a5e0f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +1,5 @@
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path' import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const dir = resolve(fileURLToPath(import.meta.url), '..') const dir = resolve(fileURLToPath(import.meta.url), '..')
@ -22,7 +22,12 @@ describe('relative base emit', () => {
expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/) expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/)
expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/) expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/)
expect(html).toContain('href="./vp-icons.css"') 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', () => { test('markdown links compile page-relative with explicit index.html', () => {
@ -50,7 +55,7 @@ describe('relative base emit', () => {
'window.__VP_SITE_ROOT__=new URL("../",location).href' 'window.__VP_SITE_ROOT__=new URL("../",location).href'
) )
expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/) expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
expect(html).toContain('href="../vp-icons.css"') expect(html).toMatch(/href="\.\.\/assets\/vp-icons\.[\w-]+\.css"/)
expect(html).toContain('src="../logo.png"') expect(html).toContain('src="../logo.png"')
expect(html).toContain('href="../index.html"') expect(html).toContain('href="../index.html"')
expect(html).toContain('href="../sub/deep/page2.html"') expect(html).toContain('href="../sub/deep/page2.html"')
@ -130,11 +135,15 @@ describe('assetsBase emit', () => {
`rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"` `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', () => { test('pages, links and root-level files stay on the site origin', () => {
const html = read('cdn', 'index.html') const html = read('cdn', 'index.html')
expect(html).toContain('href="/vp-icons.css"')
expect(html).toContain('href="/sub/page.html"') expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('src="/logo.png"') expect(html).toContain('src="/logo.png"')
expect(read('cdn', 'hashmap.json')).toBeTruthy() expect(read('cdn', 'hashmap.json')).toBeTruthy()
@ -159,7 +168,9 @@ describe('mpa + relative base emit', () => {
test('no sentinel leaks anywhere', () => { test('no sentinel leaks anywhere', () => {
for (const file of walk(dist('mpa'))) { for (const file of walk(dist('mpa'))) {
if (!/\.(html|css|js)$/.test(file)) continue if (!/\.(html|css|js)$/.test(file)) continue
expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__') const content = readFileSync(file, 'utf-8')
expect(content, file).not.toContain('__VP_BASE__')
expect(content, file).not.toContain('__VP_ICONS_HASH__')
} }
}) })
@ -173,6 +184,16 @@ describe('mpa + relative base emit', () => {
/href="\.\.\/assets\/style\.[\w-]+\.css"/ /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', () => { describe('plain base emit is unchanged', () => {

@ -38,6 +38,7 @@ export default defineConfig({
}, },
themeConfig: { themeConfig: {
nav: [{ text: 'Guide', link: '/sub/page' }], nav: [{ text: 'Guide', link: '/sub/page' }],
socialLinks: [{ icon: 'github', link: 'https://github.com' }],
sidebar: [ sidebar: [
{ text: 'Sub', link: '/sub/page' }, { text: 'Sub', link: '/sub/page' },
{ text: 'Deep', link: '/sub/deep/page2' }, { text: 'Deep', link: '/sub/deep/page2' },

@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises' import { readFile, rm } from 'node:fs/promises'
import { createServer, type Server } from 'node:http' import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net' import type { AddressInfo } from 'node:net'
import { extname, join, resolve } from 'node:path' import { extname, join, resolve } from 'node:path'
@ -65,6 +65,8 @@ export async function setup() {
// one process per flavor: the markdown renderer is a module-level // one process per flavor: the markdown renderer is a module-level
// singleton, so in-process builds would leak the first base into the rest // singleton, so in-process builds would leak the first base into the rest
for (const mode of ['plain', 'relative', 'cdn', 'mpa']) { 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'], { const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], {
cwd: dir, cwd: dir,
env: { env: {

@ -201,6 +201,8 @@ export default defineConfig({
markdown: { markdown: {
image: { lazyLoad: true } image: { lazyLoad: true }
}, },
// exercises force-inclusion of icons SSR never renders
icons: { include: ['lucide:egg'] },
themeConfig: { themeConfig: {
nav, nav,
sidebar, sidebar,
@ -210,6 +212,11 @@ export default defineConfig({
link: '/home', link: '/home',
ariaLabel: 'Home social link', ariaLabel: 'Home social link',
target: '_self' target: '_self'
},
{
icon: 'lucide:heart',
link: '/home',
ariaLabel: 'Heart social link'
} }
], ],
search: { search: {

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

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

@ -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')
})
})
})
})

@ -254,6 +254,9 @@ export default {
{ icon: 'github', link: 'https://github.com/vuejs/vitepress' }, { icon: 'github', link: 'https://github.com/vuejs/vitepress' },
{ icon: 'twitter', link: '...' }, { icon: 'twitter', link: '...' },
{ icon: 'discord', link: '/community', target: '_self' }, { 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: // You can also add custom icons by passing SVG as string:
{ {
icon: { icon: {

@ -136,6 +136,38 @@ router.onBeforeRouteChange = (to) => {
For custom themes, the same router is available from [`enhanceApp`](../guide/custom-theme#theme-interface). 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" /> ## `withBase` <Badge type="info" text="helper" />
- **Type**: `(path: string) => string` - **Type**: `(path: string) => string`

@ -483,12 +483,28 @@ export default {
} }
``` ```
The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `<assetsBase>/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files, `hashmap.json` and `vp-icons.css` stay on [`base`](#base). 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). 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/`. 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 ### cacheDir
- Type: `string` - Type: `string`
@ -658,6 +674,7 @@ export default {
interface SSGContext { interface SSGContext {
content: string content: string
teleports?: Record<string, string> teleports?: Record<string, string>
vpIcons: Set<string>
[key: string]: any [key: string]: any
} }
``` ```
@ -736,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. 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 ```ts
export default { export default {
async transformHtml(code, id, context) { async transformHtml(code, id, context) {

@ -276,6 +276,9 @@ importers:
__tests__/e2e: __tests__/e2e:
devDependencies: devDependencies:
'@iconify-json/lucide':
specifier: ^1.2.126
version: 1.2.126
vitepress: vitepress:
specifier: workspace:* specifier: workspace:*
version: link:../.. version: link:../..
@ -417,6 +420,9 @@ packages:
'@iconify-json/logos@1.2.12': '@iconify-json/logos@1.2.12':
resolution: {integrity: sha512-zUi/AoezU2F3L65nPVd2smiU6Y+ZI7RjdVPlGfeAeYbPbZ9kWn7Ucxj+KshmyQRBYwLtKoqAlUyoGgMqWG1T8g==} 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': '@iconify-json/simple-icons@1.2.93':
resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==}
@ -2926,6 +2932,10 @@ snapshots:
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
'@iconify-json/lucide@1.2.126':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/simple-icons@1.2.93': '@iconify-json/simple-icons@1.2.93':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0

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

@ -7,7 +7,7 @@ import { createApp } from './index'
export async function render(path: string) { export async function render(path: string) {
const { app, router } = await createApp() const { app, router } = await createApp()
await router.go(path) 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) ctx.content = await renderToString(app, ctx)
return ctx return ctx
} }

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

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

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

@ -1,6 +1,9 @@
[class^='vpi-'], [class^='vpi-'],
[class*=' vpi-'], [class*=' vpi-'],
.vp-icon { .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; width: 1em;
height: 1em; height: 1em;
} }

@ -21,6 +21,7 @@ export { default as VPHomeContent } from './components/VPHomeContent.vue'
export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue' export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue'
export { default as VPHomeHero } from './components/VPHomeHero.vue' export { default as VPHomeHero } from './components/VPHomeHero.vue'
export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue' export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue'
export { default as VPIcon } from './components/VPIcon.vue'
export { default as VPImage } from './components/VPImage.vue' export { default as VPImage } from './components/VPImage.vue'
export { default as VPLink } from './components/VPLink.vue' export { default as VPLink } from './components/VPLink.vue'
export { default as VPNavBarSearch } from './components/VPNavBarSearch.vue' export { default as VPNavBarSearch } from './components/VPNavBarSearch.vue'

@ -1,11 +1,18 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import { mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises' import {
mkdir,
readFile,
rm,
symlink,
unlink,
writeFile
} from 'node:fs/promises'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import path from 'node:path' import path from 'node:path'
import { getIconsCSS } from '@iconify/utils'
import pMap from 'p-map' import pMap from 'p-map'
import c from 'picocolors'
import { packageDirectory } from 'package-directory' import { packageDirectory } from 'package-directory'
import type { BuildOptions, Rolldown } from 'vite' import type { BuildOptions, Rolldown } from 'vite'
@ -25,6 +32,11 @@ import {
type Awaitable, type Awaitable,
type HeadConfig type HeadConfig
} from '../shared' } from '../shared'
import {
VP_ICONS_HASH_PLACEHOLDER,
generateIconsCSS,
vpIconsFileName
} from '../icons'
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
import { logVersion } from '../utils/logVersion' import { logVersion } from '../utils/logVersion'
import { nativeImport } from '../utils/nativeImport' import { nativeImport } from '../utils/nativeImport'
@ -33,8 +45,6 @@ import { bundle } from './bundle'
import { generateSitemap } from './generateSitemap' import { generateSitemap } from './generateSitemap'
import { renderPage } from './render' import { renderPage } from './render'
const require = createRequire(import.meta.url)
export async function build( export async function build(
root?: string, root?: string,
buildOptions: BuildOptions & { buildOptions: BuildOptions & {
@ -154,6 +164,9 @@ async function render(
const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
clientResult?.output || [] clientResult?.output || []
const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
(siteConfig.mpa ? serverResult : clientResult)?.output || []
const appChunk = clientOutput.find( const appChunk = clientOutput.find(
(chunk): chunk is Rolldown.OutputChunk => (chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.type === 'chunk' &&
@ -161,18 +174,12 @@ async function render(
!!chunk.facadeModuleId?.endsWith('.js') !!chunk.facadeModuleId?.endsWith('.js')
) )
const isDefaultTheme = clientOutput.some( const isDefaultTheme = resultOutput.some(
(chunk): chunk is Rolldown.OutputChunk => (chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.type === 'chunk' &&
chunk.name === 'theme' &&
chunk.moduleIds.some((id) => id.includes('client/theme-default')) chunk.moduleIds.some((id) => id.includes('client/theme-default'))
) )
// ----
const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
(siteConfig.mpa ? serverResult : clientResult)?.output || []
const cssChunk = resultOutput.find( const cssChunk = resultOutput.find(
(chunk): chunk is Rolldown.OutputAsset => (chunk): chunk is Rolldown.OutputAsset =>
chunk.type === 'asset' && chunk.fileName.endsWith('.css') chunk.type === 'asset' && chunk.fileName.endsWith('.css')
@ -213,7 +220,9 @@ async function render(
} }
} }
const usedIcons = new Set<string>() // pre-seeded with icons SSR collection cannot see (client-only renders)
const include = siteConfig.icons?.include
const usedIcons = new Set<string>(Array.isArray(include) ? include : [])
await pMap( await pMap(
['404.md', ...siteConfig.pages], ['404.md', ...siteConfig.pages],
@ -235,16 +244,7 @@ async function render(
{ concurrency: siteConfig.buildConcurrency } { concurrency: siteConfig.buildConcurrency }
) )
const icons = require('@iconify-json/simple-icons/icons.json') await emitIconsCSS(siteConfig, usedIcons)
const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), {
iconSelector: '.vpi-social-{name}',
commonSelector: '.vpi-social',
varName: 'icon',
format: process.env.DEBUG ? 'expanded' : 'compressed',
mode: 'mask'
}).replace(/[^]*?}\n*/, '')
await writeFile(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss)
// emit page hash map for the case where a user session is open // emit page hash map for the case where a user session is open
// when the site got redeployed (which invalidates current hash map) // when the site got redeployed (which invalidates current hash map)
@ -254,6 +254,55 @@ async function render(
) )
} }
async function emitIconsCSS(
config: SiteConfig,
usedIcons: Set<string>
): Promise<void> {
const { css, warnings } = await generateIconsCSS(
config.root,
usedIcons,
process.env.DEBUG ? 'expanded' : 'compressed'
)
for (const warning of warnings) {
config.logger.warn(c.yellow(`(icons) ${warning}`))
}
const assetsDir = path.join(config.outDir, config.assetsDir)
const placeholder = vpIconsFileName(VP_ICONS_HASH_PLACEHOLDER)
let hashedName = ''
if (css) {
hashedName = vpIconsFileName(
createHash('sha256').update(css).digest('hex').slice(0, 8)
)
await mkdir(assetsDir, { recursive: true })
await writeFile(path.join(assetsDir, hashedName), css)
}
const linkRE = new RegExp(
`[ \\t]*<link\\b[^>]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?`
)
await pMap(
['404.md', ...config.pages],
async (page) => {
const file = path.join(
config.outDir,
(config.rewrites.map[page] || page).replace(/\.md$/, '.html')
)
const html = await readFile(file, 'utf-8').catch(() => null)
if (html === null || !html.includes(placeholder)) return
// scoped to the tag so prose mentioning the placeholder stays intact
await writeFile(
file,
html.replace(linkRE, (tag) =>
hashedName ? tag.replaceAll(placeholder, hashedName) : ''
)
)
},
{ concurrency: config.buildConcurrency }
)
}
async function generateMetadataScript( async function generateMetadataScript(
pageToHashMap: Record<string, string>, pageToHashMap: Record<string, string>,
config: SiteConfig config: SiteConfig

@ -155,6 +155,10 @@ export async function bundle(
)) as Rolldown.RolldownOutput )) as Rolldown.RolldownOutput
if (config.mpa) { if (config.mpa) {
// FIXME: nothing ever empties outDir in MPA mode (no client build runs
// with emptyOutDir, and buildMPAClient sets emptyOutDir: false), so
// hashed assets of every kind accumulate across rebuilds into a dirty
// output directory
// in MPA mode, we need to copy over the non-js asset files from the // in MPA mode, we need to copy over the non-js asset files from the
// server build since there is no client-side build. // server build since there is no client-side build.
await pMap( await pMap(

@ -6,6 +6,7 @@ import { minify, normalizePath, type Rolldown } from 'vite'
import { version } from '../../../package.json' with { type: 'json' } import { version } from '../../../package.json' with { type: 'json' }
import type { SiteConfig } from '../config' import type { SiteConfig } from '../config'
import { VP_ICONS_HASH_PLACEHOLDER, vpIconsFileName } from '../icons'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
RELATIVE_BASE_SENTINEL, RELATIVE_BASE_SENTINEL,
@ -38,14 +39,13 @@ export async function renderPage(
usedIcons: Set<string> usedIcons: Set<string>
) { ) {
const routePath = `/${page.replace(/\.md$/, '')}` const routePath = `/${page.replace(/\.md$/, '')}`
const relativeBase = isRelativeBase(config.site.base) const relativeBase = isRelativeBase(config.site.base)
const pageBase = relativeBase ? relativePathToRoot(page) : config.site.base const pageBase = relativeBase ? relativePathToRoot(page) : config.site.base
// user hooks must never see the build sentinel // user hooks must never see the build sentinel
const desentinel = (value: string) => const desentinel = (value: string) =>
relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value
// render page
const context = await render(routePath) const context = await render(routePath)
if (relativeBase) { if (relativeBase) {
context.content = desentinel(context.content) context.content = desentinel(context.content)
@ -55,19 +55,20 @@ export async function renderPage(
} }
} }
} }
const { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context
// add used social icons to the set // collect the icons rendered during SSR; postRender may replace the
vpSocialIcons.forEach((icon) => usedIcons.add(icon)) // context and contribute more
context.vpIcons?.forEach((icon) => usedIcons.add(icon))
const rendered = (await config.postRender?.(context)) ?? context
const { content, teleports } = rendered
if (rendered !== context) {
rendered.vpIcons?.forEach((icon: string) => usedIcons.add(icon))
}
const pageName = sanitizeFileName(page.replace(/\//g, '_')) const pageName = sanitizeFileName(page.replace(/\//g, '_'))
// server build doesn't need hash // server build doesn't need hash
const pageServerJsFileName = pageName + '.js' const pageServerJsFileName = pageName + '.js'
// for any initial page load, we only need the lean version of the page js
// since the static content is already on the page!
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
let pageData: PageData let pageData: PageData
let hasCustom404 = true let hasCustom404 = true
@ -96,26 +97,27 @@ export async function renderPage(
: '' : ''
const pageAssets = relativeBase ? assets.map(desentinel) : assets const pageAssets = relativeBase ? assets.map(desentinel) : assets
const title: string = createTitle(siteData, pageData) const title = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description const description = pageData.description || siteData.description
const stylesheetLink = cssChunk const dir = pageData.frontmatter.dir || siteData.dir || 'ltr'
? `<link rel="preload stylesheet" href="${assetUrl(cssChunk.fileName)}" as="style">` const isDefault404 = page === '404.md' && !hasCustom404
: ''
// the initial load only needs the lean page js — the static content is
// already in the HTML
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
let preloadLinks = let preloadLinks: string[] = []
config.mpa || (!hasCustom404 && page === '404.md') if (result && appChunk && !config.mpa && !isDefault404) {
? [] preloadLinks = [
: result && appChunk ...new Set([
? [ // the imports of index.js + page.md.js as well, so everything
...new Set([ // fetches without waiting for the entry chunks to parse
// resolve imports for index.js + page.md.js and inject script tags ...(await resolvePageImports(config, page, result, appChunk)),
// for them as well so we fetch everything as early as possible pageClientJsFileName
// without having to wait for entry chunks to parse ])
...(await resolvePageImports(config, page, result, appChunk)), ]
pageClientJsFileName }
])
]
: []
let prefetchLinks: string[] = [] let prefetchLinks: string[] = []
@ -157,21 +159,27 @@ export async function renderPage(
) )
] ]
const transformContext = (head: HeadConfig[]) => ({
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head,
content,
assets: pageAssets
})
const head = mergeHead( const head = mergeHead(
headBeforeTransform, headBeforeTransform,
(await config.transformHead?.({ (await config.transformHead?.(transformContext(headBeforeTransform))) || []
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head: headBeforeTransform,
content,
assets: pageAssets
})) || []
) )
const stylesheetLink = cssChunk
? `<link rel="preload stylesheet" href="${assetUrl(cssChunk.fileName)}" as="style">`
: ''
let inlinedScript = '' let inlinedScript = ''
if (config.mpa && result) { if (config.mpa && result) {
const matchingChunk = result.output.find( const matchingChunk = result.output.find(
@ -191,20 +199,18 @@ export async function renderPage(
} }
} }
const dir = pageData.frontmatter.dir || siteData.dir || 'ltr'
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${siteData.lang}" dir="${dir}"> <html lang="${siteData.lang}" dir="${dir}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
${ ${
isMetaViewportOverridden(head) hasNamedMeta(head, 'viewport')
? '' ? ''
: '<meta name="viewport" content="width=device-width,initial-scale=1">' : '<meta name="viewport" content="width=device-width,initial-scale=1">'
} }
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
${ ${
isDescriptionOverridden(head) hasNamedMeta(head, 'description')
? '' ? ''
: `<meta name="description" content="${escapeHtml(description)}">` : `<meta name="description" content="${escapeHtml(description)}">`
} }
@ -217,7 +223,7 @@ export async function renderPage(
: '' : ''
} }
${stylesheetLink} ${stylesheetLink}
<link rel="preload stylesheet" href="${pageBase}vp-icons.css" as="style"> <link rel="preload stylesheet" href="${assetUrl(`${config.assetsDir}/${vpIconsFileName(VP_ICONS_HASH_PLACEHOLDER)}`)}" as="style"${assetsCrossOrigin}>
${metadataScript.inHead ? metadataScript.html : ''} ${metadataScript.inHead ? metadataScript.html : ''}
${ ${
appChunk appChunk
@ -239,17 +245,7 @@ export async function renderPage(
const transformedHtml = await config.transformHtml?.( const transformedHtml = await config.transformHtml?.(
finalHtml, finalHtml,
htmlFileName, htmlFileName,
{ transformContext(head)
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head,
content,
assets: pageAssets
}
) )
await writeFile(htmlFileName, transformedHtml || finalHtml) await writeFile(htmlFileName, transformedHtml || finalHtml)
} }
@ -261,45 +257,35 @@ async function resolvePageImports(
appChunk: Rolldown.OutputChunk appChunk: Rolldown.OutputChunk
) { ) {
page = config.rewrites.inv[page] || page page = config.rewrites.inv[page] || page
// find the page's js chunk and inject script tags for its imports so that
// they start fetching as early as possible
let srcPath = path.resolve(config.srcDir, page) let srcPath = path.resolve(config.srcDir, page)
try { try {
if (!config.vite?.resolve?.preserveSymlinks) { if (!config.vite?.resolve?.preserveSymlinks) {
srcPath = await realpath(srcPath) srcPath = await realpath(srcPath)
} }
} catch (e) { } catch {
// if the page is a virtual page generated by a dynamic route this would // virtual pages generated by dynamic routes have no file on disk
// fail, which is expected
} }
srcPath = normalizePath(srcPath) srcPath = normalizePath(srcPath)
const pageChunk = result.output.find( const pageChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk => (chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.facadeModuleId === srcPath chunk.type === 'chunk' && chunk.facadeModuleId === srcPath
) )
return [ // dynamic imports are intentionally not preloaded
...appChunk.imports, return [...appChunk.imports, ...(pageChunk?.imports || [])]
// ...appChunk.dynamicImports,
...(pageChunk?.imports || [])
// ...pageChunk.dynamicImports
]
} }
async function renderHead(head: HeadConfig[]): Promise<string> { async function renderHead(head: HeadConfig[]): Promise<string> {
const tags = await Promise.all( const tags = await Promise.all(
head.map(async ([tag, attrs = {}, innerHTML = '']) => { head.map(async ([tag, attrs = {}, innerHTML = '']) => {
const openTag = `<${tag}${renderAttrs(attrs)}>` const openTag = `<${tag}${renderAttrs(attrs)}>`
if (tag !== 'link' && tag !== 'meta') { if (tag === 'link' || tag === 'meta') return openTag
if ( if (
tag === 'script' && tag === 'script' &&
(attrs.type === undefined || attrs.type.includes('javascript')) (attrs.type === undefined || attrs.type.includes('javascript'))
) { ) {
innerHTML = (await minify('inline-script.js', innerHTML)).code innerHTML = (await minify('inline-script.js', innerHTML)).code
}
return `${openTag}${innerHTML}</${tag}>`
} else {
return openTag
} }
return `${openTag}${innerHTML}</${tag}>`
}) })
) )
return tags.join('\n ') return tags.join('\n ')
@ -307,27 +293,18 @@ async function renderHead(head: HeadConfig[]): Promise<string> {
function renderAttrs(attrs: Record<string, string>): string { function renderAttrs(attrs: Record<string, string>): string {
return Object.keys(attrs) return Object.keys(attrs)
.map((key) => { .map((key) =>
if (isBooleanAttr(key)) return ` ${key}` isBooleanAttr(key) ? ` ${key}` : ` ${key}="${escapeHtml(attrs[key])}"`
return ` ${key}="${escapeHtml(attrs[key] as string)}"` )
})
.join('') .join('')
} }
function filterOutHeadDescription(head: HeadConfig[] = []) { function filterOutHeadDescription(head: HeadConfig[] = []) {
return head.filter(([type, attrs]) => { return head.filter(
return !(type === 'meta' && attrs?.name === 'description') ([type, attrs]) => !(type === 'meta' && attrs?.name === 'description')
}) )
}
function isDescriptionOverridden(head: HeadConfig[] = []) {
return head.some(([type, attrs]) => {
return type === 'meta' && attrs?.name === 'description'
})
} }
function isMetaViewportOverridden(head: HeadConfig[] = []) { function hasNamedMeta(head: HeadConfig[], name: string) {
return head.some(([type, attrs]) => { return head.some(([type, attrs]) => type === 'meta' && attrs?.name === name)
return type === 'meta' && attrs?.name === 'viewport'
})
} }

@ -220,6 +220,7 @@ export async function resolveConfig(
transformPageData: userConfig.transformPageData, transformPageData: userConfig.transformPageData,
userConfig, userConfig,
sitemap: userConfig.sitemap, sitemap: userConfig.sitemap,
icons: userConfig.icons,
buildConcurrency: userConfig.buildConcurrency ?? 64 buildConcurrency: userConfig.buildConcurrency ?? 64
} }

@ -0,0 +1,159 @@
import { createRequire } from 'node:module'
import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils'
import { formatCSS } from '@iconify/utils/lib/css/format'
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
import { loadCollectionFromFS } from '@iconify/utils/lib/loader/fs'
import { dependencies } from '../../package.json' with { type: 'json' }
import { parseIconName } from './shared'
type IconifyJSON = Parameters<typeof getIconsCSSData>[0]
const require = createRequire(import.meta.url)
// collections vitepress itself depends on, resolvable even when the project
// doesn't install them
const ownCollections = new Set(
Object.keys(dependencies)
.filter((dep) => dep.startsWith('@iconify-json/'))
.map((dep) => dep.slice('@iconify-json/'.length))
)
/**
* Placeholder for the stylesheet's content hash, replaced once all pages
* have rendered and the icon set is complete.
*/
export const VP_ICONS_HASH_PLACEHOLDER = '__VP_ICONS_HASH__'
export function vpIconsFileName(hash: string): string {
return `vp-icons.${hash}.css`
}
// mirrors theme-default/styles/icons.css at zero specificity, so any theme's
// rules win and duplication is inert; the `--icon` default keeps unresolved
// icons invisible instead of solid currentColor boxes
const BASE_RULES =
":where([class^='vpi-'],[class*=' vpi-'])" +
`{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");` +
'display:inline-block;width:1em;height:1em}' +
":where([class^='vpi-']:not(.bg),[class*=' vpi-']:not(.bg))" +
'{-webkit-mask:var(--icon) no-repeat;mask:var(--icon) no-repeat;' +
'-webkit-mask-size:100% 100%;mask-size:100% 100%;' +
'background-color:currentColor;color:inherit}'
export interface IconsCSSResult {
/** empty string when no icon resolved */
css: string
warnings: string[]
}
const collectionCache = new Map<string, Promise<IconifyJSON | undefined>>()
async function loadCollection(
name: string,
root: string
): Promise<IconifyJSON | undefined> {
const key = `${root}\0${name}`
let cached = collectionCache.get(key)
if (!cached) {
// falls back to vitepress's own dependencies for the collections it ships
cached = loadCollectionFromFS(name, false, '@iconify-json', root)
.catch(() => undefined)
.then(
(data) =>
data ??
(ownCollections.has(name)
? require(`@iconify-json/${name}/icons.json`)
: undefined)
)
collectionCache.set(key, cached)
// don't cache misses — the collection may be installed during dev
cached.then((data) => {
if (!data) collectionCache.delete(key)
})
}
return cached
}
const collectionMissingMessage = (collection: string) =>
`icon collection "${collection}" is not installed — ` +
`run \`npm add -D @iconify-json/${collection}\` in your project`
const iconMissingMessage = (collection: string, icon: string) =>
`icon "${icon}" was not found in the "${collection}" collection — ` +
`check https://icones.js.org/collection/${collection} for valid names.`
export async function generateIconsCSS(
root: string,
icons: Set<string>,
format: 'expanded' | 'compressed'
): Promise<IconsCSSResult> {
const warnings: string[] = []
const byCollection = new Map<string, Set<string>>()
for (const raw of icons) {
const parsed = parseIconName(raw)
if (!parsed) {
warnings.push(
!raw.includes(':') && parseIconName(`x:${raw}`)
? `"${raw}" has no collection prefix — write it as ` +
`"<collection>:${raw}" (e.g. "simple-icons:${raw}"). Only ` +
`\`socialLinks\` qualifies bare names automatically.`
: `"${raw}" is not a valid icon name and was skipped.`
)
continue
}
let names = byCollection.get(parsed.collection)
if (!names) byCollection.set(parsed.collection, (names = new Set()))
names.add(parsed.icon)
}
const chunks: string[] = []
for (const collection of Array.from(byCollection.keys()).sort()) {
const data = await loadCollection(collection, root)
const names = Array.from(byCollection.get(collection)!).sort()
if (!data) {
warnings.push(
`${collectionMissingMessage(collection)} (needed by: ${names.join(', ')})`
)
continue
}
const found = names.filter((name) => {
if (getIconData(data, name)) return true
warnings.push(iconMissingMessage(collection, name))
return false
})
if (!found.length) continue
const cssData = getIconsCSSData(data, found, {
iconSelector: '.vpi-{prefix}-{name}',
varName: 'icon',
format,
mode: 'mask'
})
chunks.push(formatCSS(cssData.css, format))
}
return {
css: chunks.length ? BASE_RULES + '\n' + chunks.join('') : '',
warnings
}
}
/** single-icon SVG for the dev-server endpoint */
export async function resolveIconSVG(
root: string,
collection: string,
icon: string
): Promise<{ svg: string } | { error: string }> {
if (!parseIconName(`${collection}:${icon}`)) {
return { error: `"${collection}:${icon}" is not a valid icon name.` }
}
const data = await loadCollection(collection, root)
if (!data) return { error: collectionMissingMessage(collection) }
const iconData = getIconData(data, icon)
if (!iconData) return { error: iconMissingMessage(collection, icon) }
const built = iconToSVG(iconData)
return { svg: iconToHTML(built.body, built.attributes) }
}

@ -28,6 +28,7 @@ import {
type MarkdownCompileResult type MarkdownCompileResult
} from './markdownToVue' } from './markdownToVue'
import { assetsBasePlugin } from './plugins/assetsBasePlugin' import { assetsBasePlugin } from './plugins/assetsBasePlugin'
import { iconsPlugin } from './plugins/iconsPlugin'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin'
@ -463,6 +464,7 @@ export async function createVitePressPlugin(
...(userViteConfig?.plugins || []), ...(userViteConfig?.plugins || []),
// must stay after the user plugins; see assetsBasePlugin // must stay after the user plugins; see assetsBasePlugin
...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []), ...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []),
iconsPlugin(siteConfig),
await localSearchPlugin(siteConfig), await localSearchPlugin(siteConfig),
staticDataPlugin, staticDataPlugin,
await dynamicRoutesPlugin(siteConfig) await dynamicRoutesPlugin(siteConfig)

@ -0,0 +1,39 @@
import c from 'picocolors'
import type { Plugin } from 'vite'
import { resolveIconSVG } from '../icons'
import type { SiteConfig } from '../siteConfig'
const iconRequestRE = /\/_vpi\/([a-z0-9-]+)\/([a-z0-9-]+)\.svg$/
/**
* Serves `/_vpi/<collection>/<name>.svg` in dev from locally installed
* `@iconify-json/*` collections (requested on demand by `useIcon`).
*/
export function iconsPlugin(siteConfig: SiteConfig): Plugin {
const warned = new Set<string>()
return {
name: 'vitepress:icons',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const match = req.url?.split('?')[0].match(iconRequestRE)
if (!match) return next()
const [, collection, icon] = match
const resolved = await resolveIconSVG(siteConfig.root, collection, icon)
if ('svg' in resolved) {
res.setHeader('Content-Type', 'image/svg+xml')
res.setHeader('Cache-Control', 'no-cache')
res.end(resolved.svg)
} else {
const key = `${collection}:${icon}`
if (!warned.has(key)) {
warned.add(key)
siteConfig.logger.warn(c.yellow(`(icons) ${resolved.error}`))
}
res.statusCode = 404
res.end()
}
})
}
}
}

@ -121,16 +121,42 @@ export interface UserConfig<
*/ */
assetsDir?: string assetsDir?: string
/** /**
* URL prefix the built assets (everything under `assetsDir`) are served * URL prefix for built assets (everything under `assetsDir`), e.g. a CDN.
* from, e.g. a CDN. Must be an absolute URL, a protocol-relative URL, or *
* a root-absolute path, and must mirror the layout of `outDir`: each URL * Must be one of:
* is this prefix plus the file's output-relative path. Pages, `withBase` * - an absolute URL
* links, `public/` files, `hashmap.json` and `vp-icons.css` stay on * - a protocol-relative URL
* `base`. A cross-origin prefix must send CORS headers, as the generated * - a root-absolute path
* tags are marked `crossorigin`. Applies to builds and preview, not dev. *
* The prefix must mirror `outDir` layout: each asset URL = this prefix +
* file output-relative path.
*
* These still use `base`:
* - pages
* - `withBase` links
* - `public/` files
* - `hashmap.json`
*
* If the prefix is cross-origin, it must serve CORS headers, because
* generated tags are marked `crossorigin`.
*
* Applies to builds and preview (not dev).
*
* @example 'https://cdn.example.com/' * @example 'https://cdn.example.com/'
*/ */
assetsBase?: string assetsBase?: string
/**
* Options for the generated icon stylesheet (`vp-icons.*.css`).
*/
icons?: {
/**
* Fully qualified `collection:name` icons to include in addition to
* the ones collected during SSR for icons that only render
* client-side (e.g. inside `<ClientOnly>`).
* @example ['mdi:home', 'simple-icons:discord']
*/
include?: string[]
}
/** /**
* Directory for cache files, relative to the project root. * Directory for cache files, relative to the project root.
* @default './.vitepress/cache' * @default './.vitepress/cache'
@ -222,10 +248,10 @@ export interface UserConfig<
*/ */
cleanUrls?: boolean cleanUrls?: boolean
/** /**
* Use web fonts instead of emitting font files to dist. The active * Use web fonts instead of emitting font files to dist. Requires the
* theme must import a file named `fonts.(s)css` for this to work. If * active theme to import a file named `fonts.(s)css`, with its web font
* you are a theme author, to support this, place your web font import * imports placed between `webfont-marker-begin` and `webfont-marker-end`
* between `webfont-marker-begin` and `webfont-marker-end` comments. * comments.
* @experimental * @experimental
* @default true in webcontainers, else false * @default true in webcontainers, else false
*/ */
@ -322,6 +348,7 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
| 'transformHtml' | 'transformHtml'
| 'transformPageData' | 'transformPageData'
| 'sitemap' | 'sitemap'
| 'icons'
> { > {
/** /**
* Absolute path of the project root (the directory containing * Absolute path of the project root (the directory containing

@ -30,10 +30,28 @@ export type {
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
export const APPEARANCE_KEY = 'vitepress-theme-appearance' export const APPEARANCE_KEY = 'vitepress-theme-appearance'
// iconify's icon/collection name grammar
const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* Parses a fully qualified `collection:name` icon name, corresponding to
* the `vpi-<collection>-<name>` class. Returns null for anything else,
* keeping malformed input out of generated selectors and class attributes.
*/
export function parseIconName(
name: string
): { collection: string; icon: string } | null {
const colon = name.indexOf(':')
if (colon === -1) return null
const collection = name.slice(0, colon)
const icon = name.slice(colon + 1)
if (!iconNameRE.test(collection) || !iconNameRE.test(icon)) return null
return { collection, icon }
}
/** /**
* Placeholder base used by SSR when base is relative. * Placeholder prepended to SSR-emitted URLs when base is relative, later
* It is prepended to emitted URLs, then replaced with the ../ prefix * replaced with each page's `../` prefix back to the site root.
* from each file back to the site root.
*/ */
export const RELATIVE_BASE_SENTINEL = '/__VP_BASE__/' export const RELATIVE_BASE_SENTINEL = '/__VP_BASE__/'
@ -142,7 +160,8 @@ export function getLocaleForPath(
} }
/** /**
* this merges the locales data to the main data by the route * Resolves the site data for a route, layering the matched locale and
* additional configs over the root config.
*/ */
export function resolveSiteDataByRoute( export function resolveSiteDataByRoute(
siteData: SiteData, siteData: SiteData,
@ -154,8 +173,8 @@ export function resolveSiteDataByRoute(
siteData.locales[localeIndex] ?? ({} as (typeof siteData.locales)[string]) siteData.locales[localeIndex] ?? ({} as (typeof siteData.locales)[string])
Object.assign(localeConfig, { localeIndex }) Object.assign(localeConfig, { localeIndex })
// additional configs are colocated with sources, so resolve them by the // additional configs are colocated with sources — resolve them by source
// source path (filePath) rather than the rewritten one // path rather than the rewritten one
const additionalConfigs = resolveAdditionalConfig( const additionalConfigs = resolveAdditionalConfig(
siteData, siteData,
filePath || relativePath filePath || relativePath
@ -342,7 +361,7 @@ function resolveAdditionalConfig(
return configs.filter((config) => config !== undefined) return configs.filter((config) => config !== undefined)
} }
// This helps users to understand which configuration files are active // logs the config layers active for a page (dev only)
function reportConfigLayers(path: string, layers: Partial<SiteData>[]) { function reportConfigLayers(path: string, layers: Partial<SiteData>[]) {
const summaryTitle = `Config Layers for ${path}:` const summaryTitle = `Config Layers for ${path}:`
@ -358,9 +377,8 @@ function reportConfigLayers(path: string, layers: Partial<SiteData>[]) {
} }
/** /**
* Creates a deep, merged view of multiple objects without mutating originals. * Creates a readonly proxy behaving like a deep merge of the given layers,
* Returns a readonly proxy behaving like a merged object of the input objects. * without mutating them. Earlier layers take precedence.
* Layers are merged in descending precedence, i.e. earlier layer is on top.
*/ */
export function stackView<T extends ObjectType>(..._layers: Partial<T>[]): T { export function stackView<T extends ObjectType>(..._layers: Partial<T>[]): T {
const layers = _layers.filter((layer) => isObject(layer)) const layers = _layers.filter((layer) => isObject(layer))

1
theme.d.ts vendored

@ -24,6 +24,7 @@ export declare const VPHomeContent: typeof import('./dist/client/theme-default/c
export declare const VPHomeFeatures: typeof import('./dist/client/theme-default/components/VPHomeFeatures.vue').default export declare const VPHomeFeatures: typeof import('./dist/client/theme-default/components/VPHomeFeatures.vue').default
export declare const VPHomeHero: typeof import('./dist/client/theme-default/components/VPHomeHero.vue').default export declare const VPHomeHero: typeof import('./dist/client/theme-default/components/VPHomeHero.vue').default
export declare const VPHomeSponsors: typeof import('./dist/client/theme-default/components/VPHomeSponsors.vue').default export declare const VPHomeSponsors: typeof import('./dist/client/theme-default/components/VPHomeSponsors.vue').default
export declare const VPIcon: typeof import('./dist/client/theme-default/components/VPIcon.vue').default
export declare const VPImage: typeof import('./dist/client/theme-default/components/VPImage.vue').default export declare const VPImage: typeof import('./dist/client/theme-default/components/VPImage.vue').default
export declare const VPLink: typeof import('./dist/client/theme-default/components/VPLink.vue').default export declare const VPLink: typeof import('./dist/client/theme-default/components/VPLink.vue').default
export declare const VPNavBarSearch: typeof import('./dist/client/theme-default/components/VPNavBarSearch.vue').default export declare const VPNavBarSearch: typeof import('./dist/client/theme-default/components/VPNavBarSearch.vue').default

45
types/shared.d.ts vendored

@ -36,10 +36,10 @@ export interface PageData {
*/ */
relativePath: string relativePath: string
/** /**
* The path of the actual source file relative to the source directory. * The path of the actual source file relative to the source directory:
* Differs from `relativePath` when path rewrites are in use, points to * differs from `relativePath` when rewrites are in use, points to the
* the route template for dynamic routes, and is an empty string if the * route template for dynamic routes, and is empty for virtual pages
* page is virtual (e.g. the 404 page). * (e.g. the 404 page).
*/ */
filePath: string filePath: string
/** /**
@ -247,11 +247,10 @@ export interface SiteData<ThemeConfig = any> {
prefetchLinks: boolean prefetchLinks: boolean
} }
/** /**
* Config overrides applied to pages by source directory: either a dict * Config overrides applied to pages by source directory (before
* mapping a directory (e.g. `/guide/`) to overrides, where deeper * rewrites): a dict mapping a directory (e.g. `/guide/`) to overrides,
* directories take precedence, or a function returning the overrides to * deeper directories taking precedence, or a function returning the
* apply for a page. Directories are resolved against the source paths of * overrides for a page.
* pages, before rewrites.
*/ */
additionalConfig?: additionalConfig?:
AdditionalConfigDict<ThemeConfig> | AdditionalConfigLoader<ThemeConfig> AdditionalConfigDict<ThemeConfig> | AdditionalConfigLoader<ThemeConfig>
@ -364,11 +363,11 @@ export interface SSGContext extends SSRContext {
*/ */
content: string content: string
/** /**
* The names of the social icons used on the page, collected so that only * The icons used on the page, registered during SSR (via `useIcon`) so
* the styles of used icons are emitted. * that only their styles are emitted. Names are fully qualified as
* @experimental * `collection:name`.
*/ */
vpSocialIcons: Set<string> vpIcons: Set<string>
} }
/** /**
@ -453,13 +452,10 @@ export interface ContainerOptions {
cautionLabel?: string cautionLabel?: string
/** /**
* Additional containers to register, mapping the container name to its * Additional containers to register, mapping the container name to its
* default title. Registered names work both as `::: name` blocks and as * default title. Names must be lowercase (letters, numbers, hyphens,
* GitHub-style alerts (`> [!NAME]`), and are styleable in the theme via * underscores), work as both `::: name` blocks and `> [!NAME]` alerts,
* `.custom-block.name`. Names must be lowercase and may only contain * and are styleable via `.custom-block.name`. Locale overrides may only
* letters, numbers, hyphens, and underscores. * change the titles of root-registered names.
*
* In locale-specific overrides only the titles of containers registered
* at the root level can be changed - new names cannot be added there.
*/ */
customContainers?: Record<string, string> customContainers?: Record<string, string>
} }
@ -481,9 +477,8 @@ export interface CodeCopyButtonOptions {
} }
/** /**
* Build-time markdown strings that can be overridden per locale. Set them * Markdown strings overridable per locale via `locales.<index>.markdown`,
* under `locales.<index>.markdown` in the site config; values fall back to * falling back to the root `markdown` options when unset.
* the root `markdown` options when a locale leaves them unset.
*/ */
export interface MarkdownLocaleOptions { export interface MarkdownLocaleOptions {
/** /**
@ -542,8 +537,8 @@ export type AdditionalConfigLoader<ThemeConfig = any> = (
filePath: string filePath: string
) => AdditionalConfig<ThemeConfig>[] | void ) => AdditionalConfig<ThemeConfig>[] | void
// Manually declaring all properties as rollup-plugin-dts // all properties are declared manually as rollup-plugin-dts cannot merge
// is unable to merge augmented module declarations // augmented module declarations
/** /**
* The environment object passed to `markdown-it` when rendering a page. * The environment object passed to `markdown-it` when rendering a page.
*/ */

Loading…
Cancel
Save