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