refactor: simplify code and comments around the icons pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5407/head
Divyansh Singh 2 weeks ago
parent 4eb940510b
commit 98fafe4a01

@ -12,15 +12,10 @@ 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 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.
* 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>,
@ -43,9 +38,8 @@ export function useIcon(
// 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
// 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(() => {
@ -66,8 +60,7 @@ export function useIcon(
'--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
// 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, {

@ -39,14 +39,13 @@ export async function renderPage(
usedIcons: Set<string>
) {
const routePath = `/${page.replace(/\.md$/, '')}`
const relativeBase = isRelativeBase(config.site.base)
const pageBase = relativeBase ? relativePathToRoot(page) : config.site.base
// user hooks must never see the build sentinel
const desentinel = (value: string) =>
relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value
// render page
const context = await render(routePath)
if (relativeBase) {
context.content = desentinel(context.content)
@ -56,9 +55,9 @@ export async function renderPage(
}
}
}
// 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
// collect the icons rendered during SSR; postRender may replace the
// context and contribute more
context.vpIcons?.forEach((icon) => usedIcons.add(icon))
const rendered = (await config.postRender?.(context)) ?? context
@ -70,10 +69,6 @@ export async function renderPage(
const pageName = sanitizeFileName(page.replace(/\//g, '_'))
// server build doesn't need hash
const pageServerJsFileName = pageName + '.js'
// for any initial page load, we only need the lean version of the page js
// since the static content is already on the page!
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
let pageData: PageData
let hasCustom404 = true
@ -102,26 +97,27 @@ export async function renderPage(
: ''
const pageAssets = relativeBase ? assets.map(desentinel) : assets
const title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description
const stylesheetLink = cssChunk
? `<link rel="preload stylesheet" href="${assetUrl(cssChunk.fileName)}" as="style">`
: ''
const title = createTitle(siteData, pageData)
const description = pageData.description || siteData.description
const dir = pageData.frontmatter.dir || siteData.dir || 'ltr'
const isDefault404 = page === '404.md' && !hasCustom404
let preloadLinks =
config.mpa || (!hasCustom404 && page === '404.md')
? []
: result && appChunk
? [
...new Set([
// resolve imports for index.js + page.md.js and inject script tags
// for them as well so we fetch everything as early as possible
// without having to wait for entry chunks to parse
...(await resolvePageImports(config, page, result, appChunk)),
pageClientJsFileName
])
]
: []
// the initial load only needs the lean page js — the static content is
// already in the HTML
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
let preloadLinks: string[] = []
if (result && appChunk && !config.mpa && !isDefault404) {
preloadLinks = [
...new Set([
// the imports of index.js + page.md.js as well, so everything
// fetches without waiting for the entry chunks to parse
...(await resolvePageImports(config, page, result, appChunk)),
pageClientJsFileName
])
]
}
let prefetchLinks: string[] = []
@ -163,21 +159,27 @@ export async function renderPage(
)
]
const transformContext = (head: HeadConfig[]) => ({
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head,
content,
assets: pageAssets
})
const head = mergeHead(
headBeforeTransform,
(await config.transformHead?.({
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head: headBeforeTransform,
content,
assets: pageAssets
})) || []
(await config.transformHead?.(transformContext(headBeforeTransform))) || []
)
const stylesheetLink = cssChunk
? `<link rel="preload stylesheet" href="${assetUrl(cssChunk.fileName)}" as="style">`
: ''
let inlinedScript = ''
if (config.mpa && result) {
const matchingChunk = result.output.find(
@ -197,20 +199,18 @@ export async function renderPage(
}
}
const dir = pageData.frontmatter.dir || siteData.dir || 'ltr'
const html = `<!DOCTYPE html>
<html lang="${siteData.lang}" dir="${dir}">
<head>
<meta charset="utf-8">
${
isMetaViewportOverridden(head)
hasNamedMeta(head, 'viewport')
? ''
: '<meta name="viewport" content="width=device-width,initial-scale=1">'
}
<title>${escapeHtml(title)}</title>
${
isDescriptionOverridden(head)
hasNamedMeta(head, 'description')
? ''
: `<meta name="description" content="${escapeHtml(description)}">`
}
@ -245,17 +245,7 @@ export async function renderPage(
const transformedHtml = await config.transformHtml?.(
finalHtml,
htmlFileName,
{
page,
siteConfig: config,
siteData,
pageData,
title,
description,
head,
content,
assets: pageAssets
}
transformContext(head)
)
await writeFile(htmlFileName, transformedHtml || finalHtml)
}
@ -267,45 +257,35 @@ async function resolvePageImports(
appChunk: Rolldown.OutputChunk
) {
page = config.rewrites.inv[page] || page
// find the page's js chunk and inject script tags for its imports so that
// they start fetching as early as possible
let srcPath = path.resolve(config.srcDir, page)
try {
if (!config.vite?.resolve?.preserveSymlinks) {
srcPath = await realpath(srcPath)
}
} catch (e) {
// if the page is a virtual page generated by a dynamic route this would
// fail, which is expected
} catch {
// virtual pages generated by dynamic routes have no file on disk
}
srcPath = normalizePath(srcPath)
const pageChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.facadeModuleId === srcPath
)
return [
...appChunk.imports,
// ...appChunk.dynamicImports,
...(pageChunk?.imports || [])
// ...pageChunk.dynamicImports
]
// dynamic imports are intentionally not preloaded
return [...appChunk.imports, ...(pageChunk?.imports || [])]
}
async function renderHead(head: HeadConfig[]): Promise<string> {
const tags = await Promise.all(
head.map(async ([tag, attrs = {}, innerHTML = '']) => {
const openTag = `<${tag}${renderAttrs(attrs)}>`
if (tag !== 'link' && tag !== 'meta') {
if (
tag === 'script' &&
(attrs.type === undefined || attrs.type.includes('javascript'))
) {
innerHTML = (await minify('inline-script.js', innerHTML)).code
}
return `${openTag}${innerHTML}</${tag}>`
} else {
return openTag
if (tag === 'link' || tag === 'meta') return openTag
if (
tag === 'script' &&
(attrs.type === undefined || attrs.type.includes('javascript'))
) {
innerHTML = (await minify('inline-script.js', innerHTML)).code
}
return `${openTag}${innerHTML}</${tag}>`
})
)
return tags.join('\n ')
@ -313,27 +293,18 @@ async function renderHead(head: HeadConfig[]): Promise<string> {
function renderAttrs(attrs: Record<string, string>): string {
return Object.keys(attrs)
.map((key) => {
if (isBooleanAttr(key)) return ` ${key}`
return ` ${key}="${escapeHtml(attrs[key] as string)}"`
})
.map((key) =>
isBooleanAttr(key) ? ` ${key}` : ` ${key}="${escapeHtml(attrs[key])}"`
)
.join('')
}
function filterOutHeadDescription(head: HeadConfig[] = []) {
return head.filter(([type, attrs]) => {
return !(type === 'meta' && attrs?.name === 'description')
})
}
function isDescriptionOverridden(head: HeadConfig[] = []) {
return head.some(([type, attrs]) => {
return type === 'meta' && attrs?.name === 'description'
})
return head.filter(
([type, attrs]) => !(type === 'meta' && attrs?.name === 'description')
)
}
function isMetaViewportOverridden(head: HeadConfig[] = []) {
return head.some(([type, attrs]) => {
return type === 'meta' && attrs?.name === 'viewport'
})
function hasNamedMeta(head: HeadConfig[], name: string) {
return head.some(([type, attrs]) => type === 'meta' && attrs?.name === name)
}

@ -12,8 +12,8 @@ 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
// 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/'))
@ -21,9 +21,8 @@ const ownCollections = new Set(
)
/**
* 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.
* 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__'
@ -31,11 +30,9 @@ 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.
// 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");` +
@ -60,8 +57,7 @@ async function loadCollection(
const key = `${root}\0${name}`
let cached = collectionCache.get(key)
if (!cached) {
// resolvable from anywhere in the project's tree; falls back to
// vitepress's own dependencies for the collections it ships
// falls back to vitepress's own dependencies for the collections it ships
cached = loadCollectionFromFS(name, false, '@iconify-json', root)
.catch(() => undefined)
.then(
@ -72,8 +68,7 @@ async function loadCollection(
: undefined)
)
collectionCache.set(key, cached)
// don't memoize a miss — the user may install the collection while the
// dev server is running
// don't cache misses — the collection may be installed during dev
cached.then((data) => {
if (!data) collectionCache.delete(key)
})
@ -81,6 +76,14 @@ async function loadCollection(
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>,
@ -123,9 +126,6 @@ export async function generateIconsCSS(
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',
@ -135,19 +135,12 @@ export async function generateIconsCSS(
chunks.push(formatCSS(cssData.css, format))
}
if (!chunks.length) return { css: '', warnings }
return { css: BASE_RULES + '\n' + chunks.join(''), warnings }
return {
css: chunks.length ? 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,

@ -8,9 +8,7 @@ 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).
* `@iconify-json/*` collections (requested on demand by `useIcon`).
*/
export function iconsPlugin(siteConfig: SiteConfig): Plugin {
const warned = new Set<string>()

@ -121,27 +121,38 @@ export interface UserConfig<
*/
assetsDir?: string
/**
* URL prefix the built assets (everything under `assetsDir`) are served
* 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 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.
* URL prefix for built assets (everything under `assetsDir`), e.g. a CDN.
*
* Must be one of:
* - an absolute URL
* - a protocol-relative URL
* - a root-absolute path
*
* The prefix must mirror `outDir` layout: each asset URL = this prefix +
* file output-relative path.
*
* These still use `base`:
* - pages
* - `withBase` links
* - `public/` files
* - `hashmap.json`
*
* If the prefix is cross-origin, it must serve CORS headers, because
* 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).
* Options for the generated icon stylesheet (`vp-icons.*.css`).
*/
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 fully qualified as `collection:name`, for any
* `@iconify-json/*` collection in the project's dependencies.
* Fully qualified `collection:name` icons to include in addition to
* the ones collected during SSR for icons that only render
* client-side (e.g. inside `<ClientOnly>`).
* @example ['mdi:home', 'simple-icons:discord']
*/
include?: string[]
@ -237,10 +248,10 @@ export interface UserConfig<
*/
cleanUrls?: boolean
/**
* Use web fonts instead of emitting font files to dist. The active
* theme must import a file named `fonts.(s)css` for this to work. If
* you are a theme author, to support this, place your web font import
* between `webfont-marker-begin` and `webfont-marker-end` comments.
* Use web fonts instead of emitting font files to dist. Requires the
* active theme to import a file named `fonts.(s)css`, with its web font
* imports placed between `webfont-marker-begin` and `webfont-marker-end`
* comments.
* @experimental
* @default true in webcontainers, else false
*/

@ -34,12 +34,9 @@ export const APPEARANCE_KEY = 'vitepress-theme-appearance'
const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* 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.)
* Parses a fully qualified `collection:name` icon name, corresponding to
* the `vpi-<collection>-<name>` class. Returns null for anything else,
* keeping malformed input out of generated selectors and class attributes.
*/
export function parseIconName(
name: string
@ -53,9 +50,8 @@ export function parseIconName(
}
/**
* Placeholder base used by SSR when base is relative.
* It is prepended to emitted URLs, then replaced with the ../ prefix
* from each file back to the site root.
* Placeholder prepended to SSR-emitted URLs when base is relative, later
* replaced with each page's `../` prefix back to the site root.
*/
export const RELATIVE_BASE_SENTINEL = '/__VP_BASE__/'
@ -164,7 +160,8 @@ export function getLocaleForPath(
}
/**
* this merges the locales data to the main data by the route
* Resolves the site data for a route, layering the matched locale and
* additional configs over the root config.
*/
export function resolveSiteDataByRoute(
siteData: SiteData,
@ -176,8 +173,8 @@ export function resolveSiteDataByRoute(
siteData.locales[localeIndex] ?? ({} as (typeof siteData.locales)[string])
Object.assign(localeConfig, { localeIndex })
// additional configs are colocated with sources, so resolve them by the
// source path (filePath) rather than the rewritten one
// additional configs are colocated with sources — resolve them by source
// path rather than the rewritten one
const additionalConfigs = resolveAdditionalConfig(
siteData,
filePath || relativePath
@ -364,7 +361,7 @@ function resolveAdditionalConfig(
return configs.filter((config) => config !== undefined)
}
// This helps users to understand which configuration files are active
// logs the config layers active for a page (dev only)
function reportConfigLayers(path: string, layers: Partial<SiteData>[]) {
const summaryTitle = `Config Layers for ${path}:`
@ -380,9 +377,8 @@ function reportConfigLayers(path: string, layers: Partial<SiteData>[]) {
}
/**
* Creates a deep, merged view of multiple objects without mutating originals.
* Returns a readonly proxy behaving like a merged object of the input objects.
* Layers are merged in descending precedence, i.e. earlier layer is on top.
* Creates a readonly proxy behaving like a deep merge of the given layers,
* without mutating them. Earlier layers take precedence.
*/
export function stackView<T extends ObjectType>(..._layers: Partial<T>[]): T {
const layers = _layers.filter((layer) => isObject(layer))

45
types/shared.d.ts vendored

@ -36,10 +36,10 @@ export interface PageData {
*/
relativePath: string
/**
* The path of the actual source file relative to the source directory.
* Differs from `relativePath` when path rewrites are in use, points to
* the route template for dynamic routes, and is an empty string if the
* page is virtual (e.g. the 404 page).
* The path of the actual source file relative to the source directory:
* differs from `relativePath` when rewrites are in use, points to the
* route template for dynamic routes, and is empty for virtual pages
* (e.g. the 404 page).
*/
filePath: string
/**
@ -247,11 +247,10 @@ export interface SiteData<ThemeConfig = any> {
prefetchLinks: boolean
}
/**
* Config overrides applied to pages by source directory: either a dict
* mapping a directory (e.g. `/guide/`) to overrides, where deeper
* directories take precedence, or a function returning the overrides to
* apply for a page. Directories are resolved against the source paths of
* pages, before rewrites.
* Config overrides applied to pages by source directory (before
* rewrites): a dict mapping a directory (e.g. `/guide/`) to overrides,
* deeper directories taking precedence, or a function returning the
* overrides for a page.
*/
additionalConfig?:
AdditionalConfigDict<ThemeConfig> | AdditionalConfigLoader<ThemeConfig>
@ -364,11 +363,9 @@ export interface SSGContext extends SSRContext {
*/
content: string
/**
* 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 fully qualified as `collection:name`, for any
* `@iconify-json/*` collection in the project's dependencies. Theme
* components register icons here (see the `useIcon` composable).
* The icons used on the page, registered during SSR (via `useIcon`) so
* that only their styles are emitted. Names are fully qualified as
* `collection:name`.
*/
vpIcons: Set<string>
}
@ -455,13 +452,10 @@ export interface ContainerOptions {
cautionLabel?: string
/**
* Additional containers to register, mapping the container name to its
* default title. Registered names work both as `::: name` blocks and as
* GitHub-style alerts (`> [!NAME]`), and are styleable in the theme via
* `.custom-block.name`. Names must be lowercase and may only contain
* letters, numbers, hyphens, and underscores.
*
* In locale-specific overrides only the titles of containers registered
* at the root level can be changed - new names cannot be added there.
* default title. Names must be lowercase (letters, numbers, hyphens,
* underscores), work as both `::: name` blocks and `> [!NAME]` alerts,
* and are styleable via `.custom-block.name`. Locale overrides may only
* change the titles of root-registered names.
*/
customContainers?: Record<string, string>
}
@ -483,9 +477,8 @@ export interface CodeCopyButtonOptions {
}
/**
* Build-time markdown strings that can be overridden per locale. Set them
* under `locales.<index>.markdown` in the site config; values fall back to
* the root `markdown` options when a locale leaves them unset.
* Markdown strings overridable per locale via `locales.<index>.markdown`,
* falling back to the root `markdown` options when unset.
*/
export interface MarkdownLocaleOptions {
/**
@ -544,8 +537,8 @@ export type AdditionalConfigLoader<ThemeConfig = any> = (
filePath: string
) => AdditionalConfig<ThemeConfig>[] | void
// Manually declaring all properties as rollup-plugin-dts
// is unable to merge augmented module declarations
// all properties are declared manually as rollup-plugin-dts cannot merge
// augmented module declarations
/**
* The environment object passed to `markdown-it` when rendering a page.
*/

Loading…
Cancel
Save