refactor: qualified icon names everywhere, socialLinks-only bare mapping

- parseIconName requires `collection:name`; bare simple-icons names are
  qualified by VPSocialLink alone, with a build warning suggesting
  qualification when a bare name reaches generation any other way
- collection resolution keeps loadCollectionFromFS (any level of the
  project tree) and falls back generically to the @iconify-json/*
  packages in vitepress's own dependencies instead of hardcoding
  simple-icons; the attribution comment special case is dropped
- fix the mpa/spa parity test on windows (path.basename, not split('/'))

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

@ -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), '..')
@ -191,7 +191,7 @@ describe('mpa + relative base emit', () => {
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(basename(mpa)).toBe(basename(plain))
expect(readFileSync(mpa, 'utf-8')).toBe(readFileSync(plain, 'utf-8'))
})
})

@ -5,7 +5,7 @@ import { VPIcon } from 'vitepress/theme'
</script>
<VPIcon icon="lucide:rocket" data-test-icon="lucide" />
<VPIcon icon="vuedotjs" data-test-icon="simple" />
<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:

@ -10,22 +10,20 @@ 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', () => {
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 names outside iconify grammar', () => {
test('rejects bare names and anything outside iconify grammar', () => {
for (const name of [
'github',
'GitHub',
'foo bar',
'foo:',
@ -42,40 +40,44 @@ describe('node/icons', () => {
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(['github']),
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('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(
test('suggests qualification for bare names', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['notarealiconname', 'lucide:heart']),
new Set(['github']),
'compressed'
)
expect(css).toContain('.vpi-lucide-heart')
expect(css).not.toContain('simple-icons (CC0 1.0)')
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', 'github', 'lucide:egg']),
new Set(['lucide:heart', 'simple-icons:github', 'lucide:egg']),
'compressed'
)
const b = await generateIconsCSS(
e2eRoot,
new Set(['github', 'lucide:egg', 'lucide:heart']),
new Set(['simple-icons:github', 'lucide:egg', 'lucide:heart']),
'compressed'
)
expect(a.css).toBe(b.css)
@ -87,7 +89,7 @@ describe('node/icons', () => {
test('warns on icons missing from an installed collection', async () => {
const { css, warnings } = await generateIconsCSS(
e2eRoot,
new Set(['github', 'thisiconisnotreal']),
new Set(['simple-icons:github', 'simple-icons:thisiconisnotreal']),
'compressed'
)
expect(css).toContain('.vpi-simple-icons-github')

@ -280,9 +280,9 @@ 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.
Icon styles are generated at build time from collections declared in your project's dependencies, and dev mode serves them from the dev server — no icon is ever fetched from an external service. Bare names are a `socialLinks` convenience and map to simple-icons; everywhere else icons are written as `collection:name`. 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.
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 — both take fully qualified names.
## footer

@ -493,14 +493,14 @@ Only production builds are affected. `vitepress preview` serves a root-absolute
- 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.
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 fully qualified as `collection:name`, resolved against the `@iconify-json/*` packages declared in your project's dependencies (`socialLinks` is the one place bare names are accepted — they map to [simple-icons](https://simpleicons.org/), which VitePress itself depends on).
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']
include: ['mdi:home', 'simple-icons:discord']
}
}
```
@ -680,7 +680,7 @@ interface SSGContext {
}
```
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.
Custom themes can add qualified `collection:name` icon names to `vpIcons` during SSR to have their styles emitted — the `useIcon` composable from `vitepress` does this for you.
### transformHead

@ -16,10 +16,11 @@ import { withBase } from '../utils'
* 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.
* Accepts a fully qualified `collection:name` for any `@iconify-json/*`
* collection in the project's dependencies (e.g. `simple-icons:github`).
* 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>,

@ -4,9 +4,9 @@ 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.
* A fully qualified `collection:name` for any `@iconify-json/*`
* collection in the project's dependencies (e.g. `simple-icons:github`,
* `lucide:rocket`), or a raw `{ svg }` string.
*/
icon: string | { svg: string }
}>()

@ -14,7 +14,16 @@ const props = defineProps<{
}>()
const el = useTemplateRef('el')
const iconClass = useIcon(() => props.icon, el)
// socialLinks accepts bare simple-icons names; everything downstream
// speaks fully qualified `collection:name`
const iconClass = useIcon(
() =>
typeof props.icon === 'string' && !props.icon.includes(':')
? `simple-icons:${props.icon}`
: props.icon,
el
)
</script>
<template>

@ -5,12 +5,21 @@ 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'
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 (simple-icons today) — resolvable
// through vitepress 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))
)
/**
* 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
@ -48,16 +57,20 @@ 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
)
// resolvable from anywhere in the project's tree; 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 memoize a miss — the user may install the collection while the
// dev server is running
@ -79,7 +92,13 @@ export async function generateIconsCSS(
for (const raw of icons) {
const parsed = parseIconName(raw)
if (!parsed) {
warnings.push(`"${raw}" is not a valid icon name and was skipped.`)
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)
@ -88,7 +107,6 @@ export async function generateIconsCSS(
}
const chunks: string[] = []
let hasSimpleIcons = false
for (const collection of Array.from(byCollection.keys()).sort()) {
const data = await loadCollection(collection, root)
@ -115,19 +133,11 @@ export async function generateIconsCSS(
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
}
return { css: BASE_RULES + '\n' + chunks.join(''), warnings }
}
const collectionMissingMessage = (collection: string) =>

@ -140,9 +140,9 @@ export interface UserConfig<
* 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']
* see. Names are fully qualified as `collection:name`, for any
* `@iconify-json/*` collection in the project's dependencies.
* @example ['mdi:home', 'simple-icons:discord']
*/
include?: string[]
}

@ -30,26 +30,24 @@ 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).
* Parse a fully qualified `collection:name` icon name (any installed
* `@iconify-json/*` collection). The corresponding class is
* `vpi-<collection>-<name>`. Returns null for anything else bare names
* included which also keeps malformed input out of generated selectors
* and class attributes. (`socialLinks` additionally accepts bare
* simple-icons names; the theme qualifies them before they get here.)
*/
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 (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 }
}

4
types/shared.d.ts vendored

@ -366,8 +366,8 @@ export interface SSGContext extends SSRContext {
/**
* 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
* Names are fully qualified as `collection:name`, for any
* `@iconify-json/*` collection in the project's dependencies. Theme
* components register icons here (see the `useIcon` composable).
*/
vpIcons: Set<string>

Loading…
Cancel
Save