feat: hashed icon styles, offline dev icons, arbitrary iconify collections

- emit the collected icon CSS as a fingerprinted assets/vp-icons.<hash>.css
  that rides assetsBase and immutable caching; pages render a placeholder
  href that a post-render pass substitutes, or strips entirely on
  zero-icon sites (#4869)
- resolve icons in dev from locally installed collections through a
  /@vpicons/ middleware instead of api.iconify.design (#5102); the
  runtime API fallback is removed everywhere and missing icons warn at
  build time with install hints
- icon names generalize to `collection:name` for any installed
  @iconify-json/* package, in socialLinks and anywhere else; new VPIcon
  theme component, useIcon composable, documented vpIcons SSG channel
  and an icons.include site option for client-only renders

BREAKING CHANGE: `vpSocialIcons` on the SSG context is replaced by
`vpIcons`; generated icon classes rename from `vpi-social-<name>` to
`vpi-<collection>-<name>`; the unhashed root `vp-icons.css` is gone;
icons missing from locally installed collections no longer resolve at
runtime via api.iconify.design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5407/head
Divyansh Singh 2 weeks ago
parent d3b2957db0
commit 509553667e

@ -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(mpa.split('/').pop()).toBe(plain.split('/').pop())
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' },

@ -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,162 @@
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('/@vpicons/')) 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(
'/@vpicons/'
)
})
expect(
devIconRequests.some((url) =>
url.includes('/@vpicons/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="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,157 @@
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('bare names resolve in simple-icons', () => {
expect(parseIconName('github')).toEqual({
collection: 'simple-icons',
icon: 'github'
})
})
test('prefixed names resolve in their collection', () => {
expect(parseIconName('lucide:heart')).toEqual({
collection: 'lucide',
icon: 'heart'
})
})
test('rejects names outside iconify grammar', () => {
for (const name of [
'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 () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['github']),
'compressed'
)
expect(warnings).toEqual([])
expect(css).toContain(
'.vpi-simple-icons-github{--icon:url("data:image/svg+xml'
)
expect(css).toContain('simple-icons (CC0 1.0)')
expect(css).toContain(":where([class^='vpi-']")
expect(css).toContain('display:inline-block')
expect(css).not.toContain('.vpi-social')
})
test('credits simple-icons only when it contributed rules', async () => {
const { css } = await generateIconsCSS(
e2eRoot,
new Set(['notarealiconname', 'lucide:heart']),
'compressed'
)
expect(css).toContain('.vpi-lucide-heart')
expect(css).not.toContain('simple-icons (CC0 1.0)')
})
test('groups collections and stays deterministic across insertion order', async () => {
const a = await generateIconsCSS(
e2eRoot,
new Set(['lucide:heart', 'github', 'lucide:egg']),
'compressed'
)
const b = await generateIconsCSS(
e2eRoot,
new Set(['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(['github', '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: {
@ -277,6 +280,10 @@ interface SocialLink {
}
```
Icon styles are generated at build time from collections installed locally, and dev mode serves them from the dev server — no icon is ever fetched from an external service. Icons rendered only on the client (e.g. inside `<ClientOnly>`) can't be detected during the build; list them in [`icons.include`](site-config#icons) instead.
To render one of these icons in your own Markdown or components, use the `VPIcon` component from `vitepress/theme` (`<VPIcon icon="lucide:rocket" />`), or the lower-level `useIcon` composable from `vitepress` when building a custom theme.
## footer
- Type: `Footer`

@ -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 ([social links](default-theme-config#sociallinks), the `VPIcon` theme component, or any element registered through the `useIcon` composable) and emits their styles as a hashed `assets/vp-icons.<hash>.css` asset. Names are `name` (resolved in [simple-icons](https://simpleicons.org/)) or `collection:name` for any `@iconify-json/*` collection installed in your project.
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', 'discord']
}
}
```
### cacheDir
- Type: `string`
@ -658,10 +674,14 @@ export default {
interface SSGContext {
content: string
teleports?: Record<string, string>
/** icons rendered on the page, emitted into the generated stylesheet */
vpIcons: Set<string>
[key: string]: any
}
```
Custom themes can add icon names (`name` for simple-icons, or `collection:name`) to `vpIcons` during SSR to have their styles emitted — the `useIcon` composable from `vitepress` does this for you.
### transformHead
- Type: `(context: TransformContext) => Awaitable<HeadConfig[]>`
@ -736,6 +756,8 @@ 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.
:::
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,88 @@
import {
computed,
onMounted,
toValue,
useSSRContext,
watchPostEffect,
type ComputedRef,
type MaybeRefOrGetter
} from 'vue'
import { parseIconName, type SSGContext } from '../../shared'
import { withBase } from '../utils'
/**
* Renders an iconify icon through vitepress's icon pipeline: during SSR the
* name is registered so the build emits its CSS rule; in dev the icon is
* resolved from locally installed collections, without network access.
*
* Accepts `name` (resolved in simple-icons) or `collection:name` for any
* `@iconify-json/*` collection installed in the project. Returns the class
* to render (`vpi-<collection>-<name>`); pass the template ref of the
* element carrying it so dev can apply the on-demand fallback.
*/
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, so a `vpi-<collection>-<name>` class
// never has a rule — the icon always comes from the dev server, tracked
// per name so a reactive icon prop re-resolves
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(`/@vpicons/${name.collection}/${name.icon}.svg`)}')`
)
// a theme without the default theme's icon rules gets the mask
// machinery inline, so dev works before any styling exists
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,35 @@
<script lang="ts" setup>
import { useIcon } from 'vitepress'
import { useTemplateRef } from 'vue'
const props = defineProps<{
/**
* `name` (a simple-icons name) or `collection:name` for any
* `@iconify-json/*` collection installed in the project, or a raw
* `{ svg }` string.
*/
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 { useIcon } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import {
computed,
nextTick,
onMounted,
useSSRContext,
useTemplateRef
} from 'vue'
import { useTemplateRef } from 'vue'
import { isExternal, type SSGContext } from '../../shared'
import { isExternal } from '../../shared'
const props = defineProps<{
icon: DefaultTheme.SocialLinkIcon
@ -19,44 +14,20 @@ const props = defineProps<{
}>()
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 iconClass = useIcon(() => props.icon, el)
</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>
>
<span v-if="typeof icon === 'object'" v-html="icon.svg"></span>
<span v-else ref="el" :class="iconClass"></span>
</a>
</template>
<style scoped>
@ -75,8 +46,13 @@ 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;
}
.VPSocialLink :deep(svg),
.VPSocialLink > :deep([class^='vpi-']) {
width: 1.25rem;
height: 1.25rem;
fill: currentColor;

@ -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,19 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises'
import {
mkdir,
readFile,
readdir,
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 +33,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 +46,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 & {
@ -161,10 +172,12 @@ async function render(
!!chunk.facadeModuleId?.endsWith('.js')
)
const isDefaultTheme = clientOutput.some(
// MPA has no client bundle — detect the theme from the bundle that exists
const isDefaultTheme = (
(siteConfig.mpa ? serverResult : clientResult)?.output || []
).some(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' &&
chunk.name === 'theme' &&
chunk.moduleIds.some((id) => id.includes('client/theme-default'))
)
@ -213,7 +226,11 @@ async function render(
}
}
const usedIcons = new Set<string>()
// pre-seeded with icons SSR collection cannot see (client-only renders);
// the Array.isArray guard keeps an untyped config's bare string from
// spreading into characters
const include = siteConfig.icons?.include
const usedIcons = new Set<string>(Array.isArray(include) ? include : [])
await pMap(
['404.md', ...siteConfig.pages],
@ -235,16 +252,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 +262,68 @@ 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}`))
}
// MPA builds never empty outDir, so files from prior builds linger — both
// hashed sheets and the fixed-name file pre-rework versions emitted
const assetsDir = path.join(config.outDir, config.assetsDir)
const existing = await readdir(assetsDir).catch(() => [] as string[])
await Promise.all([
unlink(path.join(config.outDir, 'vp-icons.css')).catch(() => {}),
...existing
.filter((file) => /^vp-icons\.[0-9a-f]{8}\.css$/.test(file))
.map((file) => unlink(path.join(assetsDir, file)))
])
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)
}
// pages linked the placeholder name before the hash could exist — point
// them at the emitted file, or drop the whole tag when there is none.
// Anchored on the placeholder, not the tag shape, so a transformHtml
// hook reformatting attributes doesn't defeat it
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

@ -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,
@ -55,11 +56,16 @@ export async function renderPage(
}
}
}
const { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context
// SSR filled the original context's icon set — drain it before postRender,
// which may return a fresh object without it; a hook can still contribute
// additional icons through the object it returns
context.vpIcons?.forEach((icon) => usedIcons.add(icon))
// add used social icons to the set
vpSocialIcons.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
@ -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

@ -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,156 @@
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 { DEFAULT_ICONS_COLLECTION, parseIconName } from './shared'
type IconifyJSON = Parameters<typeof getIconsCSSData>[0]
const require = createRequire(import.meta.url)
/**
* Replaced with the content hash (or stripped together with the link tag when
* no icons are used) after all pages have rendered the icon set, and hence
* the hash, is only complete once every page's SSR pass has run.
*/
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; always emitted — no reliable way exists to tell whether a
// bundle carries the default theme's copy, and duplication is inert. The
// `--icon` default keeps an unresolved icon invisible instead of painting a
// solid currentColor box.
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> {
if (name === DEFAULT_ICONS_COLLECTION) {
// vitepress's own dependency — the user's root may not resolve it
return require('@iconify-json/simple-icons/icons.json')
}
const key = `${root}\0${name}`
let cached = collectionCache.get(key)
if (!cached) {
cached = loadCollectionFromFS(name, false, '@iconify-json', root).catch(
() => undefined
)
collectionCache.set(key, cached)
// don't memoize a miss — the user may install the collection while the
// dev server is running
cached.then((data) => {
if (!data) collectionCache.delete(key)
})
}
return cached
}
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}" 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[] = []
let hasSimpleIcons = false
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
// no commonSelector: `css` then holds only per-icon rules, and the
// common declarations land in `common`, which the theme's static rules
// (or BASE_RULES) replace
const cssData = getIconsCSSData(data, found, {
iconSelector: '.vpi-{prefix}-{name}',
varName: 'icon',
format,
mode: 'mask'
})
chunks.push(formatCSS(cssData.css, format))
if (collection === DEFAULT_ICONS_COLLECTION) hasSimpleIcons = true
}
if (!chunks.length) return { css: '', warnings }
const attribution = hasSimpleIcons
? '/* simple-icons (CC0 1.0) — https://simpleicons.org */\n'
: ''
return {
css: attribution + BASE_RULES + '\n' + chunks.join(''),
warnings
}
}
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.`
/** 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,41 @@
import c from 'picocolors'
import type { Plugin } from 'vite'
import { resolveIconSVG } from '../icons'
import type { SiteConfig } from '../siteConfig'
const iconRequestRE = /\/@vpicons\/([a-z0-9-]+)\/([a-z0-9-]+)\.svg$/
/**
* Serves `/@vpicons/<collection>/<name>.svg` in dev from locally installed
* `@iconify-json/*` collections, so icons render without the generated
* stylesheet and without any network access (the `useIcon` composable
* requests these on demand).
*/
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()
}
})
}
}
}

@ -125,12 +125,27 @@ export interface UserConfig<
* 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.
* links, `public/` files and `hashmap.json` 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.
* @example 'https://cdn.example.com/'
*/
assetsBase?: string
/**
* Options for the generated icon styles (a hashed `vp-icons.*.css` asset
* holding every iconify icon rendered during SSR).
*/
icons?: {
/**
* Icons to include in the generated stylesheet in addition to the ones
* collected while rendering pages needed for icons that only render
* client-side (e.g. inside `<ClientOnly>`), which SSR collection cannot
* see. Names are `name` (simple-icons) or `collection:name` for any
* installed `@iconify-json/*` collection.
* @example ['mdi:home', 'discord']
*/
include?: string[]
}
/**
* Directory for cache files, relative to the project root.
* @default './.vitepress/cache'
@ -322,6 +337,7 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
| 'transformHtml'
| 'transformPageData'
| 'sitemap'
| 'icons'
> {
/**
* Absolute path of the project root (the directory containing

@ -30,6 +30,30 @@ export type {
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
export const APPEARANCE_KEY = 'vitepress-theme-appearance'
/** collection bare icon names resolve in — the one vitepress ships itself */
export const DEFAULT_ICONS_COLLECTION = 'simple-icons'
// iconify's icon/collection name grammar
const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* Parse `name` (resolved in [[DEFAULT_ICONS_COLLECTION]]) or
* `collection:name` for any installed `@iconify-json/*` collection.
* The corresponding class is `vpi-<collection>-<name>`. Returns null for
* names that don't fit iconify's grammar (also keeping malformed input
* out of generated selectors and class attributes).
*/
export function parseIconName(
name: string
): { collection: string; icon: string } | null {
const colon = name.indexOf(':')
const collection =
colon === -1 ? DEFAULT_ICONS_COLLECTION : name.slice(0, colon)
const icon = colon === -1 ? name : 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

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

10
types/shared.d.ts vendored

@ -364,11 +364,13 @@ 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, collected during SSR so that only the
* styles of used icons are emitted into the generated stylesheet.
* Names are `name` (resolved in simple-icons) or `collection:name` for
* any `@iconify-json/*` collection installed in the project. Theme
* components register icons here (see the `useIcon` composable).
*/
vpSocialIcons: Set<string>
vpIcons: Set<string>
}
/**

Loading…
Cancel
Save