diff --git a/src/client/app/composables/icon.ts b/src/client/app/composables/icon.ts index 23b3b86a..8fd6e16f 100644 --- a/src/client/app/composables/icon.ts +++ b/src/client/app/composables/icon.ts @@ -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--`); 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--` 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, @@ -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--` 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, { diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 9eceb362..0e8864d8 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -39,14 +39,13 @@ export async function renderPage( usedIcons: Set ) { 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 - ? `` - : '' + 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 + ? `` + : '' + 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 = ` ${ - isMetaViewportOverridden(head) + hasNamedMeta(head, 'viewport') ? '' : '' } ${escapeHtml(title)} ${ - isDescriptionOverridden(head) + hasNamedMeta(head, '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 { 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}` - } 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}` }) ) return tags.join('\n ') @@ -313,27 +293,18 @@ async function renderHead(head: HeadConfig[]): Promise { function renderAttrs(attrs: Record): 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) } diff --git a/src/node/icons.ts b/src/node/icons.ts index 8939aaad..e819f720 100644 --- a/src/node/icons.ts +++ b/src/node/icons.ts @@ -12,8 +12,8 @@ type IconifyJSON = Parameters[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, @@ -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, diff --git a/src/node/plugins/iconsPlugin.ts b/src/node/plugins/iconsPlugin.ts index 2dc6905f..28b8269c 100644 --- a/src/node/plugins/iconsPlugin.ts +++ b/src/node/plugins/iconsPlugin.ts @@ -8,9 +8,7 @@ const iconRequestRE = /\/@vpicons\/([a-z0-9-]+)\/([a-z0-9-]+)\.svg$/ /** * Serves `/@vpicons//.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() diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 03bebf90..2b4d1eb4 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -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 ``), 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 ``). * @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 */ diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 2436e3ea..1dbb3a59 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -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--`. 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--` 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[]) { const summaryTitle = `Config Layers for ${path}:` @@ -380,9 +377,8 @@ function reportConfigLayers(path: string, layers: Partial[]) { } /** - * 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(..._layers: Partial[]): T { const layers = _layers.filter((layer) => isObject(layer)) diff --git a/types/shared.d.ts b/types/shared.d.ts index 6aa88743..ff82c58a 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -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 { 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 | AdditionalConfigLoader @@ -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 } @@ -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 } @@ -483,9 +477,8 @@ export interface CodeCopyButtonOptions { } /** - * Build-time markdown strings that can be overridden per locale. Set them - * under `locales..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..markdown`, + * falling back to the root `markdown` options when unset. */ export interface MarkdownLocaleOptions { /** @@ -544,8 +537,8 @@ export type AdditionalConfigLoader = ( filePath: string ) => AdditionalConfig[] | 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. */