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 { join, resolve } from 'node:path'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node: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(/src="\.\/assets\/app\.[\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', () => {
@ -50,7 +55,7 @@ describe('relative base emit', () => {
'window.__VP_SITE_ROOT__=new URL("../",location).href'
)
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('href="../index.html"')
expect(html).toContain('href="../sub/deep/page2.html"')
@ -130,11 +135,15 @@ describe('assetsBase emit', () => {
`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="/vp-icons.css"')
expect(html).toContain('href="/sub/page.html"')
expect(html).toContain('src="/logo.png"')
expect(read('cdn', 'hashmap.json')).toBeTruthy()
@ -159,7 +168,9 @@ describe('mpa + relative base emit', () => {
test('no sentinel leaks anywhere', () => {
for (const file of walk(dist('mpa'))) {
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"/
)
})
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', () => {

@ -38,6 +38,7 @@ export default defineConfig({
},
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' },

@ -1,5 +1,5 @@
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 type { AddressInfo } from 'node:net'
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
// 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: {

@ -201,6 +201,8 @@ export default defineConfig({
markdown: {
image: { lazyLoad: true }
},
// exercises force-inclusion of icons SSR never renders
icons: { include: ['lucide:egg'] },
themeConfig: {
nav,
sidebar,
@ -210,6 +212,11 @@ export default defineConfig({
link: '/home',
ariaLabel: 'Home social link',
target: '_self'
},
{
icon: 'lucide:heart',
link: '/home',
ariaLabel: 'Heart social link'
}
],
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"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.126",
"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: 'twitter', link: '...' },
{ icon: 'discord', link: '/community', target: '_self' },
// You can use any other iconify collection installed in your project
// as `collection:name` (e.g. after `npm add -D @iconify-json/lucide`):
{ icon: 'lucide:rss', link: '/feed.rss' },
// You can also add custom icons by passing SVG as string:
{
icon: {

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

@ -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).
Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`.
### icons
- Type: `{ include?: string[] }`
Options for the generated icon styles. The build collects every iconify icon rendered during SSR. Names are fully qualified as `collection:name`, resolved against the `@iconify-json/*` packages declared in your project's dependencies.
Icons rendered only on the client — inside `<ClientOnly>`, or after hydration — are invisible to SSR collection. List them in `include` to force them into the stylesheet:
```ts
export default {
icons: {
include: ['mdi:home', 'simple-icons:discord']
}
}
```
### cacheDir
- Type: `string`
@ -658,6 +674,7 @@ export default {
interface SSGContext {
content: string
teleports?: Record<string, string>
vpIcons: Set<string>
[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.
:::
::: note
The icon stylesheet link still carries its `vp-icons.__VP_ICONS_HASH__.css` placeholder at this point — the content hash only exists once every page has rendered, and it is substituted right after. Hooks that inline or fingerprint head assets should skip that tag.
:::
```ts
export default {
async transformHtml(code, id, context) {

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

@ -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) {
const { app, router } = await createApp()
await router.go(path)
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
const ctx: SSGContext = { content: '', vpIcons: new Set<string>() }
ctx.content = await renderToString(app, ctx)
return ctx
}

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

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

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

@ -21,6 +21,7 @@ export { default as VPHomeContent } from './components/VPHomeContent.vue'
export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue'
export { default as VPHomeHero } from './components/VPHomeHero.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 VPLink } from './components/VPLink.vue'
export { default as VPNavBarSearch } from './components/VPNavBarSearch.vue'

@ -1,11 +1,18 @@
import { createHash } from 'node:crypto'
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 path from 'node:path'
import { getIconsCSS } from '@iconify/utils'
import pMap from 'p-map'
import c from 'picocolors'
import { packageDirectory } from 'package-directory'
import type { BuildOptions, Rolldown } from 'vite'
@ -25,6 +32,11 @@ import {
type Awaitable,
type HeadConfig
} from '../shared'
import {
VP_ICONS_HASH_PLACEHOLDER,
generateIconsCSS,
vpIconsFileName
} from '../icons'
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
import { logVersion } from '../utils/logVersion'
import { nativeImport } from '../utils/nativeImport'
@ -33,8 +45,6 @@ import { bundle } from './bundle'
import { generateSitemap } from './generateSitemap'
import { renderPage } from './render'
const require = createRequire(import.meta.url)
export async function build(
root?: string,
buildOptions: BuildOptions & {
@ -154,6 +164,9 @@ async function render(
const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
clientResult?.output || []
const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
(siteConfig.mpa ? serverResult : clientResult)?.output || []
const appChunk = clientOutput.find(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' &&
@ -161,18 +174,12 @@ async function render(
!!chunk.facadeModuleId?.endsWith('.js')
)
const isDefaultTheme = clientOutput.some(
const isDefaultTheme = resultOutput.some(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' &&
chunk.name === 'theme' &&
chunk.moduleIds.some((id) => id.includes('client/theme-default'))
)
// ----
const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
(siteConfig.mpa ? serverResult : clientResult)?.output || []
const cssChunk = resultOutput.find(
(chunk): chunk is Rolldown.OutputAsset =>
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(
['404.md', ...siteConfig.pages],
@ -235,16 +244,7 @@ async function render(
{ concurrency: siteConfig.buildConcurrency }
)
const icons = require('@iconify-json/simple-icons/icons.json')
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)
await emitIconsCSS(siteConfig, usedIcons)
// emit page hash map for the case where a user session is open
// 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(
pageToHashMap: Record<string, string>,
config: SiteConfig

@ -155,6 +155,10 @@ export async function bundle(
)) as Rolldown.RolldownOutput
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
// server build since there is no client-side build.
await pMap(

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

@ -220,6 +220,7 @@ export async function resolveConfig(
transformPageData: userConfig.transformPageData,
userConfig,
sitemap: userConfig.sitemap,
icons: userConfig.icons,
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
} from './markdownToVue'
import { assetsBasePlugin } from './plugins/assetsBasePlugin'
import { iconsPlugin } from './plugins/iconsPlugin'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin'
import { rewritesPlugin } from './plugins/rewritesPlugin'
@ -463,6 +464,7 @@ export async function createVitePressPlugin(
...(userViteConfig?.plugins || []),
// must stay after the user plugins; see assetsBasePlugin
...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []),
iconsPlugin(siteConfig),
await localSearchPlugin(siteConfig),
staticDataPlugin,
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
/**
* URL prefix the built assets (everything under `assetsDir`) are served
* 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
* is this prefix plus the file's output-relative path. Pages, `withBase`
* links, `public/` files, `hashmap.json` and `vp-icons.css` stay on
* `base`. A cross-origin prefix must send CORS headers, as the generated
* tags are marked `crossorigin`. Applies to builds and preview, not dev.
* URL prefix for built assets (everything under `assetsDir`), e.g. a CDN.
*
* Must be one of:
* - an absolute URL
* - a protocol-relative URL
* - a root-absolute path
*
* 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/'
*/
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.
* @default './.vitepress/cache'
@ -222,10 +248,10 @@ export interface UserConfig<
*/
cleanUrls?: boolean
/**
* Use web fonts instead of emitting font files to dist. The active
* theme must import a file named `fonts.(s)css` for this to work. If
* you are a theme author, to support this, place your web font import
* between `webfont-marker-begin` and `webfont-marker-end` comments.
* Use web fonts instead of emitting font files to dist. Requires the
* active theme to import a file named `fonts.(s)css`, with its web font
* imports placed between `webfont-marker-begin` and `webfont-marker-end`
* comments.
* @experimental
* @default true in webcontainers, else false
*/
@ -322,6 +348,7 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
| 'transformHtml'
| 'transformPageData'
| 'sitemap'
| 'icons'
> {
/**
* 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 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.
* It is prepended to emitted URLs, then replaced with the ../ prefix
* from each file back to the site root.
* Placeholder prepended to SSR-emitted URLs when base is relative, later
* replaced with each page's `../` prefix back to the site root.
*/
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(
siteData: SiteData,
@ -154,8 +173,8 @@ export function resolveSiteDataByRoute(
siteData.locales[localeIndex] ?? ({} as (typeof siteData.locales)[string])
Object.assign(localeConfig, { localeIndex })
// additional configs are colocated with sources, so resolve them by the
// source path (filePath) rather than the rewritten one
// additional configs are colocated with sources — resolve them by source
// path rather than the rewritten one
const additionalConfigs = resolveAdditionalConfig(
siteData,
filePath || relativePath
@ -342,7 +361,7 @@ function resolveAdditionalConfig(
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>[]) {
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.
* Returns a readonly proxy behaving like a merged object of the input objects.
* Layers are merged in descending precedence, i.e. earlier layer is on top.
* Creates a readonly proxy behaving like a deep merge of the given layers,
* without mutating them. Earlier layers take precedence.
*/
export function stackView<T extends ObjectType>(..._layers: Partial<T>[]): T {
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 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 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 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

45
types/shared.d.ts vendored

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

Loading…
Cancel
Save