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 { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path' import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const dir = resolve(fileURLToPath(import.meta.url), '..') const dir = resolve(fileURLToPath(import.meta.url), '..')
@ -191,7 +191,7 @@ describe('mpa + relative base emit', () => {
const mpa = find('mpa') const mpa = find('mpa')
const plain = find('plain') const plain = find('plain')
// same icon set — same content, same hash, mode-independent // 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')) expect(readFileSync(mpa, 'utf-8')).toBe(readFileSync(plain, 'utf-8'))
}) })
}) })

@ -5,7 +5,7 @@ import { VPIcon } from 'vitepress/theme'
</script> </script>
<VPIcon icon="lucide:rocket" data-test-icon="lucide" /> <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" /> <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: 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('node/icons', () => {
describe('parseIconName', () => { describe('parseIconName', () => {
test('bare names resolve in simple-icons', () => { test('parses qualified names', () => {
expect(parseIconName('github')).toEqual({
collection: 'simple-icons',
icon: 'github'
})
})
test('prefixed names resolve in their collection', () => {
expect(parseIconName('lucide:heart')).toEqual({ expect(parseIconName('lucide:heart')).toEqual({
collection: 'lucide', collection: 'lucide',
icon: 'heart' 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 [ for (const name of [
'github',
'GitHub', 'GitHub',
'foo bar', 'foo bar',
'foo:', 'foo:',
@ -42,40 +40,44 @@ describe('node/icons', () => {
describe('generateIconsCSS', () => { describe('generateIconsCSS', () => {
test('emits base rules and per-icon rules, no legacy common rule', async () => { 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( const { css, warnings } = await generateIconsCSS(
e2eRoot, e2eRoot,
new Set(['github']), new Set(['simple-icons:github']),
'compressed' 'compressed'
) )
expect(warnings).toEqual([]) expect(warnings).toEqual([])
expect(css).toContain( expect(css).toContain(
'.vpi-simple-icons-github{--icon:url("data:image/svg+xml' '.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(":where([class^='vpi-']")
expect(css).toContain('display:inline-block') expect(css).toContain('display:inline-block')
expect(css).not.toContain('.vpi-social') expect(css).not.toContain('.vpi-social')
}) })
test('credits simple-icons only when it contributed rules', async () => { test('suggests qualification for bare names', async () => {
const { css } = await generateIconsCSS( const { css, warnings } = await generateIconsCSS(
e2eRoot, e2eRoot,
new Set(['notarealiconname', 'lucide:heart']), new Set(['github']),
'compressed' 'compressed'
) )
expect(css).toContain('.vpi-lucide-heart') expect(css).toBe('')
expect(css).not.toContain('simple-icons (CC0 1.0)') 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 () => { test('groups collections and stays deterministic across insertion order', async () => {
const a = await generateIconsCSS( const a = await generateIconsCSS(
e2eRoot, e2eRoot,
new Set(['lucide:heart', 'github', 'lucide:egg']), new Set(['lucide:heart', 'simple-icons:github', 'lucide:egg']),
'compressed' 'compressed'
) )
const b = await generateIconsCSS( const b = await generateIconsCSS(
e2eRoot, e2eRoot,
new Set(['github', 'lucide:egg', 'lucide:heart']), new Set(['simple-icons:github', 'lucide:egg', 'lucide:heart']),
'compressed' 'compressed'
) )
expect(a.css).toBe(b.css) expect(a.css).toBe(b.css)
@ -87,7 +89,7 @@ describe('node/icons', () => {
test('warns on icons missing from an installed collection', async () => { test('warns on icons missing from an installed collection', async () => {
const { css, warnings } = await generateIconsCSS( const { css, warnings } = await generateIconsCSS(
e2eRoot, e2eRoot,
new Set(['github', 'thisiconisnotreal']), new Set(['simple-icons:github', 'simple-icons:thisiconisnotreal']),
'compressed' 'compressed'
) )
expect(css).toContain('.vpi-simple-icons-github') 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 ## footer

@ -493,14 +493,14 @@ Only production builds are affected. `vitepress preview` serves a root-absolute
- Type: `{ include?: string[] }` - 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: 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 ```ts
export default { export default {
icons: { 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 ### transformHead

@ -16,10 +16,11 @@ import { withBase } from '../utils'
* name is registered so the build emits its CSS rule; in dev the icon is * name is registered so the build emits its CSS rule; in dev the icon is
* resolved from locally installed collections, without network access. * resolved from locally installed collections, without network access.
* *
* Accepts `name` (resolved in simple-icons) or `collection:name` for any * Accepts a fully qualified `collection:name` for any `@iconify-json/*`
* `@iconify-json/*` collection installed in the project. Returns the class * collection in the project's dependencies (e.g. `simple-icons:github`).
* to render (`vpi-<collection>-<name>`); pass the template ref of the * Returns the class to render (`vpi-<collection>-<name>`); pass the
* element carrying it so dev can apply the on-demand fallback. * template ref of the element carrying it so dev can apply the on-demand
* fallback.
*/ */
export function useIcon( export function useIcon(
icon: MaybeRefOrGetter<string | { svg: string } | undefined>, icon: MaybeRefOrGetter<string | { svg: string } | undefined>,

@ -4,9 +4,9 @@ import { useTemplateRef } from 'vue'
const props = defineProps<{ const props = defineProps<{
/** /**
* `name` (a simple-icons name) or `collection:name` for any * A fully qualified `collection:name` for any `@iconify-json/*`
* `@iconify-json/*` collection installed in the project, or a raw * collection in the project's dependencies (e.g. `simple-icons:github`,
* `{ svg }` string. * `lucide:rocket`), or a raw `{ svg }` string.
*/ */
icon: string | { svg: string } icon: string | { svg: string }
}>() }>()

@ -14,7 +14,16 @@ const props = defineProps<{
}>() }>()
const el = useTemplateRef('el') 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> </script>
<template> <template>

@ -5,12 +5,21 @@ import { formatCSS } from '@iconify/utils/lib/css/format'
import { getIconsCSSData } from '@iconify/utils/lib/css/icons' import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
import { loadCollectionFromFS } from '@iconify/utils/lib/loader/fs' 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] type IconifyJSON = Parameters<typeof getIconsCSSData>[0]
const require = createRequire(import.meta.url) 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 * 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 * no icons are used) after all pages have rendered the icon set, and hence
@ -48,16 +57,20 @@ async function loadCollection(
name: string, name: string,
root: string root: string
): Promise<IconifyJSON | undefined> { ): 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}` const key = `${root}\0${name}`
let cached = collectionCache.get(key) let cached = collectionCache.get(key)
if (!cached) { if (!cached) {
cached = loadCollectionFromFS(name, false, '@iconify-json', root).catch( // resolvable from anywhere in the project's tree; falls back to
() => undefined // 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) collectionCache.set(key, cached)
// don't memoize a miss — the user may install the collection while the // don't memoize a miss — the user may install the collection while the
// dev server is running // dev server is running
@ -79,7 +92,13 @@ export async function generateIconsCSS(
for (const raw of icons) { for (const raw of icons) {
const parsed = parseIconName(raw) const parsed = parseIconName(raw)
if (!parsed) { 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 continue
} }
let names = byCollection.get(parsed.collection) let names = byCollection.get(parsed.collection)
@ -88,7 +107,6 @@ export async function generateIconsCSS(
} }
const chunks: string[] = [] const chunks: string[] = []
let hasSimpleIcons = false
for (const collection of Array.from(byCollection.keys()).sort()) { for (const collection of Array.from(byCollection.keys()).sort()) {
const data = await loadCollection(collection, root) const data = await loadCollection(collection, root)
@ -115,19 +133,11 @@ export async function generateIconsCSS(
mode: 'mask' mode: 'mask'
}) })
chunks.push(formatCSS(cssData.css, format)) chunks.push(formatCSS(cssData.css, format))
if (collection === DEFAULT_ICONS_COLLECTION) hasSimpleIcons = true
} }
if (!chunks.length) return { css: '', warnings } if (!chunks.length) return { css: '', warnings }
const attribution = hasSimpleIcons return { css: BASE_RULES + '\n' + chunks.join(''), warnings }
? '/* simple-icons (CC0 1.0) — https://simpleicons.org */\n'
: ''
return {
css: attribution + BASE_RULES + '\n' + chunks.join(''),
warnings
}
} }
const collectionMissingMessage = (collection: string) => const collectionMissingMessage = (collection: string) =>

@ -140,9 +140,9 @@ export interface UserConfig<
* Icons to include in the generated stylesheet in addition to the ones * Icons to include in the generated stylesheet in addition to the ones
* collected while rendering pages needed for icons that only render * collected while rendering pages needed for icons that only render
* client-side (e.g. inside `<ClientOnly>`), which SSR collection cannot * client-side (e.g. inside `<ClientOnly>`), which SSR collection cannot
* see. Names are `name` (simple-icons) or `collection:name` for any * see. Names are fully qualified as `collection:name`, for any
* installed `@iconify-json/*` collection. * `@iconify-json/*` collection in the project's dependencies.
* @example ['mdi:home', 'discord'] * @example ['mdi:home', 'simple-icons:discord']
*/ */
include?: string[] include?: string[]
} }

@ -30,26 +30,24 @@ export type {
export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
export const APPEARANCE_KEY = 'vitepress-theme-appearance' 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 // iconify's icon/collection name grammar
const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/** /**
* Parse `name` (resolved in [[DEFAULT_ICONS_COLLECTION]]) or * Parse a fully qualified `collection:name` icon name (any installed
* `collection:name` for any installed `@iconify-json/*` collection. * `@iconify-json/*` collection). The corresponding class is
* The corresponding class is `vpi-<collection>-<name>`. Returns null for * `vpi-<collection>-<name>`. Returns null for anything else bare names
* names that don't fit iconify's grammar (also keeping malformed input * included which also keeps malformed input out of generated selectors
* out of generated selectors and class attributes). * and class attributes. (`socialLinks` additionally accepts bare
* simple-icons names; the theme qualifies them before they get here.)
*/ */
export function parseIconName( export function parseIconName(
name: string name: string
): { collection: string; icon: string } | null { ): { collection: string; icon: string } | null {
const colon = name.indexOf(':') const colon = name.indexOf(':')
const collection = if (colon === -1) return null
colon === -1 ? DEFAULT_ICONS_COLLECTION : name.slice(0, colon) const collection = name.slice(0, colon)
const icon = colon === -1 ? name : name.slice(colon + 1) const icon = name.slice(colon + 1)
if (!iconNameRE.test(collection) || !iconNameRE.test(icon)) return null if (!iconNameRE.test(collection) || !iconNameRE.test(icon)) return null
return { collection, icon } 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 * The icons used on the page, collected during SSR so that only the
* styles of used icons are emitted into the generated stylesheet. * styles of used icons are emitted into the generated stylesheet.
* Names are `name` (resolved in simple-icons) or `collection:name` for * Names are fully qualified as `collection:name`, for any
* any `@iconify-json/*` collection installed in the project. Theme * `@iconify-json/*` collection in the project's dependencies. Theme
* components register icons here (see the `useIcon` composable). * components register icons here (see the `useIcon` composable).
*/ */
vpIcons: Set<string> vpIcons: Set<string>

Loading…
Cancel
Save