diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts index 9a9b7efd..aa805e6a 100644 --- a/__tests__/base/emit.test.ts +++ b/__tests__/base/emit.test.ts @@ -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), '..') @@ -22,7 +22,12 @@ describe('relative base emit', () => { expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/) expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/) - expect(html).toContain('href="./vp-icons.css"') + expect(html).toMatch(/href="\.\/assets\/vp-icons\.[\w-]+\.css"/) + expect( + walk(dist('relative', 'assets')).some((f) => + /vp-icons\.[\w-]+\.css$/.test(f) + ) + ).toBe(true) }) test('markdown links compile page-relative with explicit index.html', () => { @@ -50,7 +55,7 @@ describe('relative base emit', () => { 'window.__VP_SITE_ROOT__=new URL("../",location).href' ) expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/) - expect(html).toContain('href="../vp-icons.css"') + expect(html).toMatch(/href="\.\.\/assets\/vp-icons\.[\w-]+\.css"/) expect(html).toContain('src="../logo.png"') expect(html).toContain('href="../index.html"') expect(html).toContain('href="../sub/deep/page2.html"') @@ -130,11 +135,15 @@ describe('assetsBase emit', () => { `rel="preload" href="${cdn()}assets/inter-roman-latin\\.[^"]+"` ) ) + expect(html).toMatch( + new RegExp( + `href="${cdn()}assets/vp-icons\\.[\\w-]+\\.css" as="style" crossorigin>` + ) + ) }) test('pages, links and root-level files stay on the site origin', () => { const html = read('cdn', 'index.html') - expect(html).toContain('href="/vp-icons.css"') expect(html).toContain('href="/sub/page.html"') expect(html).toContain('src="/logo.png"') expect(read('cdn', 'hashmap.json')).toBeTruthy() @@ -159,7 +168,9 @@ describe('mpa + relative base emit', () => { test('no sentinel leaks anywhere', () => { for (const file of walk(dist('mpa'))) { if (!/\.(html|css|js)$/.test(file)) continue - expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__') + const content = readFileSync(file, 'utf-8') + expect(content, file).not.toContain('__VP_BASE__') + expect(content, file).not.toContain('__VP_ICONS_HASH__') } }) @@ -173,6 +184,16 @@ describe('mpa + relative base emit', () => { /href="\.\.\/assets\/style\.[\w-]+\.css"/ ) }) + + test('icons sheet is identical across mpa and spa builds', () => { + const find = (mode: string) => + walk(dist(mode, 'assets')).find((f) => /vp-icons\.[\w-]+\.css$/.test(f))! + const mpa = find('mpa') + const plain = find('plain') + // same icon set — same content, same hash, mode-independent + expect(basename(mpa)).toBe(basename(plain)) + expect(readFileSync(mpa, 'utf-8')).toBe(readFileSync(plain, 'utf-8')) + }) }) describe('plain base emit is unchanged', () => { diff --git a/__tests__/base/fixture/.vitepress/config.ts b/__tests__/base/fixture/.vitepress/config.ts index 74557091..f135dd33 100644 --- a/__tests__/base/fixture/.vitepress/config.ts +++ b/__tests__/base/fixture/.vitepress/config.ts @@ -38,6 +38,7 @@ export default defineConfig({ }, themeConfig: { nav: [{ text: 'Guide', link: '/sub/page' }], + socialLinks: [{ icon: 'github', link: 'https://github.com' }], sidebar: [ { text: 'Sub', link: '/sub/page' }, { text: 'Deep', link: '/sub/deep/page2' }, diff --git a/__tests__/base/vitestGlobalSetup.ts b/__tests__/base/vitestGlobalSetup.ts index 0f7e5d0c..62953c6d 100644 --- a/__tests__/base/vitestGlobalSetup.ts +++ b/__tests__/base/vitestGlobalSetup.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { readFile } from 'node:fs/promises' +import { readFile, rm } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { extname, join, resolve } from 'node:path' @@ -65,6 +65,8 @@ export async function setup() { // one process per flavor: the markdown renderer is a module-level // singleton, so in-process builds would leak the first base into the rest for (const mode of ['plain', 'relative', 'cdn', 'mpa']) { + // mpa builds never empty outDir, so stale assets would survive reruns + await rm(dist(mode), { recursive: true, force: true }) const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], { cwd: dir, env: { diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index 2a899efc..2525f02b 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -201,6 +201,8 @@ export default defineConfig({ markdown: { image: { lazyLoad: true } }, + // exercises force-inclusion of icons SSR never renders + icons: { include: ['lucide:egg'] }, themeConfig: { nav, sidebar, @@ -210,6 +212,11 @@ export default defineConfig({ link: '/home', ariaLabel: 'Home social link', target: '_self' + }, + { + icon: 'lucide:heart', + link: '/home', + ariaLabel: 'Heart social link' } ], search: { diff --git a/__tests__/e2e/icons/icons.test.ts b/__tests__/e2e/icons/icons.test.ts new file mode 100644 index 00000000..e1127b2e --- /dev/null +++ b/__tests__/e2e/icons/icons.test.ts @@ -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(' { + 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') + expect(iconsPage).toMatch( + // + ) + } + ) + + 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(') + } + ) +}) diff --git a/__tests__/e2e/icons/index.md b/__tests__/e2e/icons/index.md new file mode 100644 index 00000000..9bce3c59 --- /dev/null +++ b/__tests__/e2e/icons/index.md @@ -0,0 +1,12 @@ +# Icons + + + + + + + +Prose about the build internals must survive the rewrite pass: +`vp-icons.__VP_ICONS_HASH__.css` diff --git a/__tests__/e2e/package.json b/__tests__/e2e/package.json index 1d558ff7..db6f261d 100644 --- a/__tests__/e2e/package.json +++ b/__tests__/e2e/package.json @@ -10,6 +10,7 @@ "site:preview": "vitepress preview" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.126", "vitepress": "workspace:*" } } diff --git a/__tests__/unit/node/icons.test.ts b/__tests__/unit/node/icons.test.ts new file mode 100644 index 00000000..35e3303e --- /dev/null +++ b/__tests__/unit/node/icons.test.ts @@ -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 { + 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(' { + 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') + }) + }) + }) +}) diff --git a/docs/en/reference/default-theme-config.md b/docs/en/reference/default-theme-config.md index 19f0cc76..33760375 100644 --- a/docs/en/reference/default-theme-config.md +++ b/docs/en/reference/default-theme-config.md @@ -254,6 +254,9 @@ export default { { icon: 'github', link: 'https://github.com/vuejs/vitepress' }, { icon: 'twitter', link: '...' }, { icon: 'discord', link: '/community', target: '_self' }, + // You can use any other iconify collection installed in your project + // as `collection:name` (e.g. after `npm add -D @iconify-json/lucide`): + { icon: 'lucide:rss', link: '/feed.rss' }, // You can also add custom icons by passing SVG as string: { icon: { diff --git a/docs/en/reference/runtime-api.md b/docs/en/reference/runtime-api.md index b69808ee..86d9a20b 100644 --- a/docs/en/reference/runtime-api.md +++ b/docs/en/reference/runtime-api.md @@ -136,6 +136,38 @@ router.onBeforeRouteChange = (to) => { For custom themes, the same router is available from [`enhanceApp`](../guide/custom-theme#theme-interface). +## `useIcon` + +- **Type**: `(icon: MaybeRefOrGetter, el?: MaybeRefOrGetter) => ComputedRef` + +Renders an [iconify](https://iconify.design/) icon through VitePress's icon pipeline. Takes a fully qualified `collection:name` (resolved against the `@iconify-json/*` packages in your project's dependencies) and returns the class to put on the element — `vpi--`. + +During SSR the name is registered on the page's [`SSGContext`](./site-config#postrender), so the build emits the icon's styles into the generated stylesheet; in dev, icons are served on demand by the dev server from the locally installed collections. No icon is ever fetched from an external service. + +```vue + + + +``` + +Pass the template ref of the element carrying the class so dev mode can resolve the icon on it. The element needs the mask rules the default theme ships; in a custom theme without them, dev applies an inline equivalent and the generated stylesheet includes zero-specificity base rules for production. + +When using the default theme, the `VPIcon` component from `vitepress/theme` wraps this composable (and also accepts a raw `{ svg }` string): + +```vue-html + +``` + +Icons rendered only on the client (e.g. inside ``) can't be collected during the build — list them in [`icons.include`](./site-config#icons) instead. + ## `withBase` - **Type**: `(path: string) => string` diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 81c7f6f4..7f214127 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -483,12 +483,28 @@ export default { } ``` -The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files, `hashmap.json` and `vp-icons.css` stay on [`base`](#base). +The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files and `hashmap.json` stay on [`base`](#base). When `assetsBase` points at another origin, VitePress adds `crossorigin` to the emitted script and preload tags — the CDN must send `Access-Control-Allow-Origin` for your site's origin (module scripts are always fetched in CORS mode). Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`. +### icons + +- Type: `{ include?: string[] }` + +Options for the generated icon styles. The build collects every iconify icon rendered during SSR. Names are fully qualified as `collection:name`, resolved against the `@iconify-json/*` packages declared in your project's dependencies. + +Icons rendered only on the client — inside ``, 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', 'simple-icons:discord'] + } +} +``` + ### cacheDir - Type: `string` @@ -658,6 +674,7 @@ export default { interface SSGContext { content: string teleports?: Record + vpIcons: Set [key: string]: any } ``` @@ -736,6 +753,10 @@ For simpler cases, it may be possible to use the [`head`](./frontmatter-config#h Don't mutate anything inside the `context`. Also, modifying the html content may cause hydration problems in runtime. ::: +::: note +The icon stylesheet link still carries its `vp-icons.__VP_ICONS_HASH__.css` placeholder at this point — the content hash only exists once every page has rendered, and it is substituted right after. Hooks that inline or fingerprint head assets should skip that tag. +::: + ```ts export default { async transformHtml(code, id, context) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dcc81a7..ec90881c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,9 @@ importers: __tests__/e2e: devDependencies: + '@iconify-json/lucide': + specifier: ^1.2.126 + version: 1.2.126 vitepress: specifier: workspace:* version: link:../.. @@ -417,6 +420,9 @@ packages: '@iconify-json/logos@1.2.12': resolution: {integrity: sha512-zUi/AoezU2F3L65nPVd2smiU6Y+ZI7RjdVPlGfeAeYbPbZ9kWn7Ucxj+KshmyQRBYwLtKoqAlUyoGgMqWG1T8g==} + '@iconify-json/lucide@1.2.126': + resolution: {integrity: sha512-Fl3OfR71yeWLrlTLp6C4W5W3rJJDWH9/e70mjtq9VAldYDxvHh149JMNPz7foTeLLTE2Paynnp2aYKVL9rgF5Q==} + '@iconify-json/simple-icons@1.2.93': resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} @@ -2926,6 +2932,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/lucide@1.2.126': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.93': dependencies: '@iconify/types': 2.0.0 diff --git a/src/client/app/composables/icon.ts b/src/client/app/composables/icon.ts new file mode 100644 index 00000000..ab2d5ebb --- /dev/null +++ b/src/client/app/composables/icon.ts @@ -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--` 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, + el?: MaybeRefOrGetter +): ComputedRef { + 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() + 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 +} diff --git a/src/client/app/ssr.ts b/src/client/app/ssr.ts index d8a4f05b..ec55258c 100644 --- a/src/client/app/ssr.ts +++ b/src/client/app/ssr.ts @@ -7,7 +7,7 @@ import { createApp } from './index' export async function render(path: string) { const { app, router } = await createApp() await router.go(path) - const ctx: SSGContext = { content: '', vpSocialIcons: new Set() } + const ctx: SSGContext = { content: '', vpIcons: new Set() } ctx.content = await renderToString(app, ctx) return ctx } diff --git a/src/client/index.ts b/src/client/index.ts index 80322bd7..6e424ece 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -17,6 +17,7 @@ import { ClientOnly } from './app/components/ClientOnly' import { Content } from './app/components/Content' // composables +export { useIcon } from './app/composables/icon' export { dataSymbol, useData } from './app/data' export { useRoute, useRouter } from './app/router' diff --git a/src/client/theme-default/components/VPIcon.vue b/src/client/theme-default/components/VPIcon.vue new file mode 100644 index 00000000..0e7983e8 --- /dev/null +++ b/src/client/theme-default/components/VPIcon.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/client/theme-default/components/VPSocialLink.vue b/src/client/theme-default/components/VPSocialLink.vue index cc19a5f3..420ebca8 100644 --- a/src/client/theme-default/components/VPSocialLink.vue +++ b/src/client/theme-default/components/VPSocialLink.vue @@ -1,14 +1,9 @@ diff --git a/src/client/theme-default/styles/icons.css b/src/client/theme-default/styles/icons.css index 9bc867e5..b57b9417 100644 --- a/src/client/theme-default/styles/icons.css +++ b/src/client/theme-default/styles/icons.css @@ -1,6 +1,9 @@ [class^='vpi-'], [class*=' vpi-'], .vp-icon { + /* an unresolved icon masks to nothing instead of a currentColor box */ + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); + display: inline-block; width: 1em; height: 1em; } diff --git a/src/client/theme-default/without-fonts.ts b/src/client/theme-default/without-fonts.ts index 6a551bc6..91ae2a46 100644 --- a/src/client/theme-default/without-fonts.ts +++ b/src/client/theme-default/without-fonts.ts @@ -21,6 +21,7 @@ export { default as VPHomeContent } from './components/VPHomeContent.vue' export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue' export { default as VPHomeHero } from './components/VPHomeHero.vue' export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue' +export { default as VPIcon } from './components/VPIcon.vue' export { default as VPImage } from './components/VPImage.vue' export { default as VPLink } from './components/VPLink.vue' export { default as VPNavBarSearch } from './components/VPNavBarSearch.vue' diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 5bd5f5a1..e81df4a3 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -1,11 +1,18 @@ import { createHash } from 'node:crypto' import fs from 'node:fs' -import { mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { + mkdir, + readFile, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' -import { getIconsCSS } from '@iconify/utils' import pMap from 'p-map' +import c from 'picocolors' import { packageDirectory } from 'package-directory' import type { BuildOptions, Rolldown } from 'vite' @@ -25,6 +32,11 @@ import { type Awaitable, type HeadConfig } from '../shared' +import { + VP_ICONS_HASH_PLACEHOLDER, + generateIconsCSS, + vpIconsFileName +} from '../icons' import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' import { logVersion } from '../utils/logVersion' import { nativeImport } from '../utils/nativeImport' @@ -33,8 +45,6 @@ import { bundle } from './bundle' import { generateSitemap } from './generateSitemap' import { renderPage } from './render' -const require = createRequire(import.meta.url) - export async function build( root?: string, buildOptions: BuildOptions & { @@ -154,6 +164,9 @@ async function render( const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = clientResult?.output || [] + const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = + (siteConfig.mpa ? serverResult : clientResult)?.output || [] + const appChunk = clientOutput.find( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && @@ -161,18 +174,12 @@ async function render( !!chunk.facadeModuleId?.endsWith('.js') ) - const isDefaultTheme = clientOutput.some( + const isDefaultTheme = resultOutput.some( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && - chunk.name === 'theme' && chunk.moduleIds.some((id) => id.includes('client/theme-default')) ) - // ---- - - const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = - (siteConfig.mpa ? serverResult : clientResult)?.output || [] - const cssChunk = resultOutput.find( (chunk): chunk is Rolldown.OutputAsset => chunk.type === 'asset' && chunk.fileName.endsWith('.css') @@ -213,7 +220,9 @@ async function render( } } - const usedIcons = new Set() + // pre-seeded with icons SSR collection cannot see (client-only renders) + const include = siteConfig.icons?.include + const usedIcons = new Set(Array.isArray(include) ? include : []) await pMap( ['404.md', ...siteConfig.pages], @@ -235,16 +244,7 @@ async function render( { concurrency: siteConfig.buildConcurrency } ) - const icons = require('@iconify-json/simple-icons/icons.json') - const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), { - iconSelector: '.vpi-social-{name}', - commonSelector: '.vpi-social', - varName: 'icon', - format: process.env.DEBUG ? 'expanded' : 'compressed', - mode: 'mask' - }).replace(/[^]*?}\n*/, '') - - await writeFile(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss) + await emitIconsCSS(siteConfig, usedIcons) // emit page hash map for the case where a user session is open // when the site got redeployed (which invalidates current hash map) @@ -254,6 +254,55 @@ async function render( ) } +async function emitIconsCSS( + config: SiteConfig, + usedIcons: Set +): Promise { + const { css, warnings } = await generateIconsCSS( + config.root, + usedIcons, + process.env.DEBUG ? 'expanded' : 'compressed' + ) + for (const warning of warnings) { + config.logger.warn(c.yellow(`(icons) ${warning}`)) + } + + const assetsDir = path.join(config.outDir, config.assetsDir) + const placeholder = vpIconsFileName(VP_ICONS_HASH_PLACEHOLDER) + + let hashedName = '' + if (css) { + hashedName = vpIconsFileName( + createHash('sha256').update(css).digest('hex').slice(0, 8) + ) + await mkdir(assetsDir, { recursive: true }) + await writeFile(path.join(assetsDir, hashedName), css) + } + + const linkRE = new RegExp( + `[ \\t]*]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?` + ) + await pMap( + ['404.md', ...config.pages], + async (page) => { + const file = path.join( + config.outDir, + (config.rewrites.map[page] || page).replace(/\.md$/, '.html') + ) + const html = await readFile(file, 'utf-8').catch(() => null) + if (html === null || !html.includes(placeholder)) return + // scoped to the tag so prose mentioning the placeholder stays intact + await writeFile( + file, + html.replace(linkRE, (tag) => + hashedName ? tag.replaceAll(placeholder, hashedName) : '' + ) + ) + }, + { concurrency: config.buildConcurrency } + ) +} + async function generateMetadataScript( pageToHashMap: Record, config: SiteConfig diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index 9290c77f..f2d0ad08 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -155,6 +155,10 @@ export async function bundle( )) as Rolldown.RolldownOutput if (config.mpa) { + // FIXME: nothing ever empties outDir in MPA mode (no client build runs + // with emptyOutDir, and buildMPAClient sets emptyOutDir: false), so + // hashed assets of every kind accumulate across rebuilds into a dirty + // output directory // in MPA mode, we need to copy over the non-js asset files from the // server build since there is no client-side build. await pMap( diff --git a/src/node/build/render.ts b/src/node/build/render.ts index bab5268b..0e8864d8 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -6,6 +6,7 @@ import { minify, normalizePath, type Rolldown } from 'vite' import { version } from '../../../package.json' with { type: 'json' } import type { SiteConfig } from '../config' +import { VP_ICONS_HASH_PLACEHOLDER, vpIconsFileName } from '../icons' import { EXTERNAL_URL_RE, RELATIVE_BASE_SENTINEL, @@ -38,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) @@ -55,19 +55,20 @@ export async function renderPage( } } } - const { content, teleports, vpSocialIcons } = - (await config.postRender?.(context)) ?? context - // add used social icons to the set - vpSocialIcons.forEach((icon) => usedIcons.add(icon)) + // 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 + const { content, teleports } = rendered + if (rendered !== context) { + rendered.vpIcons?.forEach((icon: string) => usedIcons.add(icon)) + } 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 @@ -96,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 + + // 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 = - 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 - ]) - ] - : [] + 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[] = [] @@ -157,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( @@ -191,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') ? '' : `` } @@ -217,7 +223,7 @@ export async function renderPage( : '' } ${stylesheetLink} - + ${metadataScript.inHead ? metadataScript.html : ''} ${ appChunk @@ -239,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) } @@ -261,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 ') @@ -307,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/config.ts b/src/node/config.ts index f60663f6..baea4a86 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -220,6 +220,7 @@ export async function resolveConfig( transformPageData: userConfig.transformPageData, userConfig, sitemap: userConfig.sitemap, + icons: userConfig.icons, buildConcurrency: userConfig.buildConcurrency ?? 64 } diff --git a/src/node/icons.ts b/src/node/icons.ts new file mode 100644 index 00000000..e819f720 --- /dev/null +++ b/src/node/icons.ts @@ -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[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>() + +async function loadCollection( + name: string, + root: string +): Promise { + 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, + format: 'expanded' | 'compressed' +): Promise { + const warnings: string[] = [] + const byCollection = new Map>() + + 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 ` + + `":${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) } +} diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 42f1f9d4..886bae0d 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -28,6 +28,7 @@ import { type MarkdownCompileResult } from './markdownToVue' import { assetsBasePlugin } from './plugins/assetsBasePlugin' +import { iconsPlugin } from './plugins/iconsPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin' @@ -463,6 +464,7 @@ export async function createVitePressPlugin( ...(userViteConfig?.plugins || []), // must stay after the user plugins; see assetsBasePlugin ...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []), + iconsPlugin(siteConfig), await localSearchPlugin(siteConfig), staticDataPlugin, await dynamicRoutesPlugin(siteConfig) diff --git a/src/node/plugins/iconsPlugin.ts b/src/node/plugins/iconsPlugin.ts new file mode 100644 index 00000000..548d5011 --- /dev/null +++ b/src/node/plugins/iconsPlugin.ts @@ -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//.svg` in dev from locally installed + * `@iconify-json/*` collections (requested on demand by `useIcon`). + */ +export function iconsPlugin(siteConfig: SiteConfig): Plugin { + const warned = new Set() + 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() + } + }) + } + } +} diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 6574d347..2b4d1eb4 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -121,16 +121,42 @@ 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, `hashmap.json` and `vp-icons.css` 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 stylesheet (`vp-icons.*.css`). + */ + icons?: { + /** + * 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[] + } /** * Directory for cache files, relative to the project root. * @default './.vitepress/cache' @@ -222,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 */ @@ -322,6 +348,7 @@ export interface SiteConfig extends Pick< | 'transformHtml' | 'transformPageData' | 'sitemap' + | 'icons' > { /** * Absolute path of the project root (the directory containing diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 003dfaff..1dbb3a59 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -30,10 +30,28 @@ export type { export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const APPEARANCE_KEY = 'vitepress-theme-appearance' +// iconify's icon/collection name grammar +const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** + * 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 +): { collection: string; icon: string } | null { + const colon = name.indexOf(':') + 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 } +} + /** - * 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__/' @@ -142,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, @@ -154,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 @@ -342,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}:` @@ -358,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/theme.d.ts b/theme.d.ts index 01a486da..834bf3f0 100644 --- a/theme.d.ts +++ b/theme.d.ts @@ -24,6 +24,7 @@ export declare const VPHomeContent: typeof import('./dist/client/theme-default/c export declare const VPHomeFeatures: typeof import('./dist/client/theme-default/components/VPHomeFeatures.vue').default export declare const VPHomeHero: typeof import('./dist/client/theme-default/components/VPHomeHero.vue').default export declare const VPHomeSponsors: typeof import('./dist/client/theme-default/components/VPHomeSponsors.vue').default +export declare const VPIcon: typeof import('./dist/client/theme-default/components/VPIcon.vue').default export declare const VPImage: typeof import('./dist/client/theme-default/components/VPImage.vue').default export declare const VPLink: typeof import('./dist/client/theme-default/components/VPLink.vue').default export declare const VPNavBarSearch: typeof import('./dist/client/theme-default/components/VPNavBarSearch.vue').default diff --git a/types/shared.d.ts b/types/shared.d.ts index 02878f10..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,11 @@ export interface SSGContext extends SSRContext { */ content: string /** - * The names of the social icons used on the page, collected so that only - * the styles of used icons are emitted. - * @experimental + * 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`. */ - vpSocialIcons: Set + vpIcons: Set } /** @@ -453,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 } @@ -481,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 { /** @@ -542,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. */