mirror of https://github.com/vuejs/vitepress
feat: hashed icon styles, offline dev icons, arbitrary iconify collections (#5407)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>pull/5408/head
parent
d3b2957db0
commit
d00a5e0f87
@ -0,0 +1,158 @@
|
|||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const isBuild = !!process.env.VITE_TEST_BUILD
|
||||||
|
|
||||||
|
const maskImage = (selector: string) =>
|
||||||
|
page.$eval(selector, (el) => {
|
||||||
|
const styles = getComputedStyle(el)
|
||||||
|
return styles.maskImage || styles.webkitMaskImage
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('icons', () => {
|
||||||
|
const externalRequests: string[] = []
|
||||||
|
const devIconRequests: string[] = []
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const url = request.url()
|
||||||
|
if (!url.startsWith(`http://localhost:${process.env['PORT']}`)) {
|
||||||
|
externalRequests.push(url)
|
||||||
|
}
|
||||||
|
if (url.includes('/_vpi/')) devIconRequests.push(url)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('social links render from both collections', async () => {
|
||||||
|
await goto('/')
|
||||||
|
|
||||||
|
for (const [label, cls] of [
|
||||||
|
['Home social link', '.vpi-simple-icons-github'],
|
||||||
|
['Heart social link', '.vpi-lucide-heart']
|
||||||
|
]) {
|
||||||
|
const selector = `a[aria-label="${label}"] span`
|
||||||
|
expect(await page.getAttribute(selector, 'class')).toBe(cls.slice(1))
|
||||||
|
// an unresolved icon computes to mask-image: none and renders nothing
|
||||||
|
await page.waitForFunction(
|
||||||
|
(sel) => {
|
||||||
|
const el = document.querySelector(sel)
|
||||||
|
if (!el) return false
|
||||||
|
const styles = getComputedStyle(el)
|
||||||
|
return (styles.maskImage || styles.webkitMaskImage) !== 'none'
|
||||||
|
},
|
||||||
|
selector,
|
||||||
|
{ timeout: 3000 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VPIcon renders collection, default-collection and raw svg icons', async () => {
|
||||||
|
await goto('/icons/')
|
||||||
|
|
||||||
|
expect(await page.getAttribute('[data-test-icon="lucide"]', 'class')).toBe(
|
||||||
|
'vpi-lucide-rocket'
|
||||||
|
)
|
||||||
|
expect(await page.getAttribute('[data-test-icon="simple"]', 'class')).toBe(
|
||||||
|
'vpi-simple-icons-vuedotjs'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await page.$eval('[data-test-icon="raw"]', (el) => el.innerHTML)
|
||||||
|
).toContain('<svg')
|
||||||
|
// the raw-svg wrapper must not pick up the mask machinery, which would
|
||||||
|
// paint a solid currentColor box over the svg
|
||||||
|
expect(
|
||||||
|
await page.$eval('[data-test-icon="raw"]', (el) => {
|
||||||
|
const styles = getComputedStyle(el)
|
||||||
|
return {
|
||||||
|
background: styles.backgroundColor,
|
||||||
|
svgWidth: getComputedStyle(el.querySelector('svg')!).width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({ background: 'rgba(0, 0, 0, 0)', svgWidth: '16px' })
|
||||||
|
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const el = document.querySelector('[data-test-icon="lucide"]')
|
||||||
|
if (!el) return false
|
||||||
|
const styles = getComputedStyle(el)
|
||||||
|
return (styles.maskImage || styles.webkitMaskImage) !== 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no icon is ever fetched from an external origin', () => {
|
||||||
|
expect(externalRequests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.runIf(!isBuild)(
|
||||||
|
'dev resolves icons from the local endpoint',
|
||||||
|
async () => {
|
||||||
|
await goto('/')
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
'a[aria-label="Heart social link"] span'
|
||||||
|
)
|
||||||
|
if (!el) return false
|
||||||
|
const styles = getComputedStyle(el)
|
||||||
|
return (styles.maskImage || styles.webkitMaskImage).includes('/_vpi/')
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
devIconRequests.some((url) => url.includes('/_vpi/lucide/heart.svg'))
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test.runIf(isBuild)(
|
||||||
|
'build inlines icons into the hashed stylesheet',
|
||||||
|
async () => {
|
||||||
|
await goto('/')
|
||||||
|
expect(
|
||||||
|
await maskImage('a[aria-label="Heart social link"] span')
|
||||||
|
).toContain('data:image/svg+xml')
|
||||||
|
expect(devIconRequests).toEqual([])
|
||||||
|
|
||||||
|
const html = readFileSync(
|
||||||
|
resolve(
|
||||||
|
fileURLToPath(import.meta.url),
|
||||||
|
'../../.vitepress/dist/index.html'
|
||||||
|
),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
expect(html).toMatch(/href="\/assets\/vp-icons\.[\w-]+\.css"/)
|
||||||
|
expect(html).not.toContain('__VP_ICONS_HASH__')
|
||||||
|
|
||||||
|
// prose mentioning the placeholder is left alone — only the link tag
|
||||||
|
// gets the hash substituted
|
||||||
|
const iconsPage = readFileSync(
|
||||||
|
resolve(
|
||||||
|
fileURLToPath(import.meta.url),
|
||||||
|
'../../.vitepress/dist/icons/index.html'
|
||||||
|
),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
expect(iconsPage).toContain('vp-icons.__VP_ICONS_HASH__.css</code>')
|
||||||
|
expect(iconsPage).toMatch(
|
||||||
|
/<link rel="preload stylesheet" href="\/assets\/vp-icons\.[\w-]+\.css" as="style">/
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test.runIf(isBuild)(
|
||||||
|
'icons.include forces unrendered icons into the sheet',
|
||||||
|
() => {
|
||||||
|
const assetsDir = resolve(
|
||||||
|
fileURLToPath(import.meta.url),
|
||||||
|
'../../.vitepress/dist/assets'
|
||||||
|
)
|
||||||
|
const cssFile = readdirSync(assetsDir).find((f) =>
|
||||||
|
/^vp-icons\.[\w-]+\.css$/.test(f)
|
||||||
|
)!
|
||||||
|
expect(cssFile).toBeTruthy()
|
||||||
|
const css = readFileSync(join(assetsDir, cssFile), 'utf-8')
|
||||||
|
expect(css).toContain('.vpi-lucide-egg')
|
||||||
|
expect(css).toContain('.vpi-lucide-heart')
|
||||||
|
expect(css).toContain('.vpi-simple-icons-github')
|
||||||
|
// zero-specificity base rules ship with the sheet for any theme
|
||||||
|
expect(css).toContain(':where(')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
# Icons
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { VPIcon } from 'vitepress/theme'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<VPIcon icon="lucide:rocket" data-test-icon="lucide" />
|
||||||
|
<VPIcon icon="simple-icons:vuedotjs" data-test-icon="simple" />
|
||||||
|
<VPIcon :icon="{ svg: '<svg viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'4\'/></svg>' }" data-test-icon="raw" />
|
||||||
|
|
||||||
|
Prose about the build internals must survive the rewrite pass:
|
||||||
|
`vp-icons.__VP_ICONS_HASH__.css`
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { generateIconsCSS, resolveIconSVG } from 'node/icons'
|
||||||
|
import { parseIconName } from 'shared/shared'
|
||||||
|
|
||||||
|
// the e2e workspace has @iconify-json/lucide installed — use it as the
|
||||||
|
// resolution root for collection-loading tests
|
||||||
|
const e2eRoot = resolve(fileURLToPath(import.meta.url), '../../../e2e')
|
||||||
|
|
||||||
|
describe('node/icons', () => {
|
||||||
|
describe('parseIconName', () => {
|
||||||
|
test('parses qualified names', () => {
|
||||||
|
expect(parseIconName('lucide:heart')).toEqual({
|
||||||
|
collection: 'lucide',
|
||||||
|
icon: 'heart'
|
||||||
|
})
|
||||||
|
expect(parseIconName('simple-icons:github')).toEqual({
|
||||||
|
collection: 'simple-icons',
|
||||||
|
icon: 'github'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects bare names and anything outside iconify grammar', () => {
|
||||||
|
for (const name of [
|
||||||
|
'github',
|
||||||
|
'GitHub',
|
||||||
|
'foo bar',
|
||||||
|
'foo:',
|
||||||
|
':bar',
|
||||||
|
'a<b',
|
||||||
|
'foo:bar:baz',
|
||||||
|
'-leading',
|
||||||
|
''
|
||||||
|
]) {
|
||||||
|
expect(parseIconName(name), name).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateIconsCSS', () => {
|
||||||
|
test('emits base rules and per-icon rules, no legacy common rule', async () => {
|
||||||
|
// simple-icons is not in the e2e workspace's package.json — this also
|
||||||
|
// covers the fallback to vitepress's own dependency
|
||||||
|
const { css, warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['simple-icons:github']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(warnings).toEqual([])
|
||||||
|
expect(css).toContain(
|
||||||
|
'.vpi-simple-icons-github{--icon:url("data:image/svg+xml'
|
||||||
|
)
|
||||||
|
expect(css).toContain(":where([class^='vpi-']")
|
||||||
|
expect(css).toContain('display:inline-block')
|
||||||
|
expect(css).not.toContain('.vpi-social')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('suggests qualification for bare names', async () => {
|
||||||
|
const { css, warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['github']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(css).toBe('')
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('"github" has no collection prefix')
|
||||||
|
])
|
||||||
|
expect(warnings[0]).toContain('simple-icons:github')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('groups collections and stays deterministic across insertion order', async () => {
|
||||||
|
const a = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['lucide:heart', 'simple-icons:github', 'lucide:egg']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
const b = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['simple-icons:github', 'lucide:egg', 'lucide:heart']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(a.css).toBe(b.css)
|
||||||
|
expect(a.css).toContain('.vpi-lucide-heart')
|
||||||
|
expect(a.css).toContain('.vpi-lucide-egg')
|
||||||
|
expect(a.css).toContain('.vpi-simple-icons-github')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warns on icons missing from an installed collection', async () => {
|
||||||
|
const { css, warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['simple-icons:github', 'simple-icons:thisiconisnotreal']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(css).toContain('.vpi-simple-icons-github')
|
||||||
|
expect(css).not.toContain('thisiconisnotreal')
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining(
|
||||||
|
'"thisiconisnotreal" was not found in the "simple-icons"'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warns on uninstalled collections with an install hint', async () => {
|
||||||
|
const { css, warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['notinstalled:foo']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(css).toBe('')
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('@iconify-json/notinstalled')
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warns on invalid names', async () => {
|
||||||
|
const { warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(['Not A Name']),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('"Not A Name" is not a valid icon name')
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty css for an empty set', async () => {
|
||||||
|
const { css, warnings } = await generateIconsCSS(
|
||||||
|
e2eRoot,
|
||||||
|
new Set(),
|
||||||
|
'compressed'
|
||||||
|
)
|
||||||
|
expect(css).toBe('')
|
||||||
|
expect(warnings).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveIconSVG', () => {
|
||||||
|
test('resolves an svg offline', async () => {
|
||||||
|
const resolved = await resolveIconSVG(e2eRoot, 'lucide', 'heart')
|
||||||
|
expect(resolved).toHaveProperty('svg')
|
||||||
|
const svg = (resolved as { svg: string }).svg
|
||||||
|
expect(svg).toContain('<svg')
|
||||||
|
expect(svg).toContain('viewBox')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports missing icons and collections distinctly', async () => {
|
||||||
|
expect(await resolveIconSVG(e2eRoot, 'lucide', 'noicon')).toEqual({
|
||||||
|
error: expect.stringContaining('was not found in the "lucide"')
|
||||||
|
})
|
||||||
|
expect(await resolveIconSVG(e2eRoot, 'nocollection', 'x')).toEqual({
|
||||||
|
error: expect.stringContaining('@iconify-json/nocollection')
|
||||||
|
})
|
||||||
|
expect(await resolveIconSVG(e2eRoot, 'Bad Name', 'x')).toEqual({
|
||||||
|
error: expect.stringContaining('not a valid icon name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
toValue,
|
||||||
|
useSSRContext,
|
||||||
|
watchPostEffect,
|
||||||
|
type ComputedRef,
|
||||||
|
type MaybeRefOrGetter
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
|
import { parseIconName, type SSGContext } from '../../shared'
|
||||||
|
import { withBase } from '../utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an icon name (`collection:name`, e.g. `simple-icons:github`) to
|
||||||
|
* its `vpi-<collection>-<name>` class. During SSR the name is registered so
|
||||||
|
* the build emits its CSS rule; in dev the SVG is served on demand and
|
||||||
|
* applied to `el` inline.
|
||||||
|
*/
|
||||||
|
export function useIcon(
|
||||||
|
icon: MaybeRefOrGetter<string | { svg: string } | undefined>,
|
||||||
|
el?: MaybeRefOrGetter<HTMLElement | null>
|
||||||
|
): ComputedRef<string | undefined> {
|
||||||
|
const parsed = computed(() => {
|
||||||
|
const value = toValue(icon)
|
||||||
|
return typeof value === 'string' ? parseIconName(value) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconClass = computed(() =>
|
||||||
|
parsed.value
|
||||||
|
? `vpi-${parsed.value.collection}-${parsed.value.icon}`
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
if (import.meta.env.SSR) {
|
||||||
|
const ctx = useSSRContext<SSGContext>()
|
||||||
|
const value = toValue(icon)
|
||||||
|
// unparseable names are registered too — the build warns about them
|
||||||
|
if (typeof value === 'string') ctx?.vpIcons.add(value)
|
||||||
|
} else if (import.meta.env.DEV) {
|
||||||
|
// dev has no generated stylesheet — the icon is always fetched from the
|
||||||
|
// dev server, re-resolved when the name changes
|
||||||
|
let applied: string | undefined
|
||||||
|
onMounted(() => {
|
||||||
|
watchPostEffect(() => {
|
||||||
|
const span = toValue(el)
|
||||||
|
if (!span) return
|
||||||
|
const name = parsed.value
|
||||||
|
if (!name) {
|
||||||
|
if (applied) {
|
||||||
|
span.style.removeProperty('--icon')
|
||||||
|
applied = undefined
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = `${name.collection}/${name.icon}`
|
||||||
|
if (applied === key) return
|
||||||
|
applied = key
|
||||||
|
span.style.setProperty(
|
||||||
|
'--icon',
|
||||||
|
`url('${withBase(`/_vpi/${name.collection}/${name.icon}.svg`)}')`
|
||||||
|
)
|
||||||
|
// inline the mask setup for themes without the default icon rules
|
||||||
|
const styles = getComputedStyle(span)
|
||||||
|
if ((styles.maskImage || styles.webkitMaskImage) === 'none') {
|
||||||
|
Object.assign(span.style, {
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
mask: 'var(--icon) no-repeat',
|
||||||
|
webkitMask: 'var(--icon) no-repeat',
|
||||||
|
maskSize: '100% 100%',
|
||||||
|
webkitMaskSize: '100% 100%',
|
||||||
|
backgroundColor: 'currentColor'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconClass
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useIcon } from 'vitepress'
|
||||||
|
import { useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon: string | { svg: string }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const el = useTemplateRef('el')
|
||||||
|
const iconClass = useIcon(() => props.icon, el)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-if="typeof icon === 'object'" class="VPIcon" v-html="icon.svg"></span>
|
||||||
|
<span v-else ref="el" :class="iconClass"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.VPIcon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPIcon :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,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) }
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue