From 509553667e0cbcf06a7bcec135fe5fa488e94b48 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:38:37 +0530 Subject: [PATCH] feat: hashed icon styles, offline dev icons, arbitrary iconify collections - emit the collected icon CSS as a fingerprinted assets/vp-icons..css that rides assetsBase and immutable caching; pages render a placeholder href that a post-render pass substitutes, or strips entirely on zero-icon sites (#4869) - resolve icons in dev from locally installed collections through a /@vpicons/ middleware instead of api.iconify.design (#5102); the runtime API fallback is removed everywhere and missing icons warn at build time with install hints - icon names generalize to `collection:name` for any installed @iconify-json/* package, in socialLinks and anywhere else; new VPIcon theme component, useIcon composable, documented vpIcons SSG channel and an icons.include site option for client-only renders BREAKING CHANGE: `vpSocialIcons` on the SSG context is replaced by `vpIcons`; generated icon classes rename from `vpi-social-` to `vpi--`; the unhashed root `vp-icons.css` is gone; icons missing from locally installed collections no longer resolve at runtime via api.iconify.design. Co-Authored-By: Claude Fable 5 --- __tests__/base/emit.test.ts | 29 +++- __tests__/base/fixture/.vitepress/config.ts | 1 + __tests__/e2e/.vitepress/config.ts | 7 + __tests__/e2e/icons/icons.test.ts | 162 ++++++++++++++++++ __tests__/e2e/icons/index.md | 12 ++ __tests__/e2e/package.json | 1 + __tests__/unit/node/icons.test.ts | 157 +++++++++++++++++ docs/en/reference/default-theme-config.md | 7 + docs/en/reference/site-config.md | 24 ++- pnpm-lock.yaml | 10 ++ src/client/app/composables/icon.ts | 88 ++++++++++ src/client/app/ssr.ts | 2 +- src/client/index.ts | 1 + .../theme-default/components/VPIcon.vue | 35 ++++ .../theme-default/components/VPSocialLink.vue | 54 ++---- src/client/theme-default/styles/icons.css | 3 + src/client/theme-default/without-fonts.ts | 1 + src/node/build/build.ts | 104 +++++++++-- src/node/build/render.ts | 16 +- src/node/config.ts | 1 + src/node/icons.ts | 156 +++++++++++++++++ src/node/plugin.ts | 2 + src/node/plugins/iconsPlugin.ts | 41 +++++ src/node/siteConfig.ts | 22 ++- src/shared/shared.ts | 24 +++ theme.d.ts | 1 + types/shared.d.ts | 10 +- 27 files changed, 897 insertions(+), 74 deletions(-) create mode 100644 __tests__/e2e/icons/icons.test.ts create mode 100644 __tests__/e2e/icons/index.md create mode 100644 __tests__/unit/node/icons.test.ts create mode 100644 src/client/app/composables/icon.ts create mode 100644 src/client/theme-default/components/VPIcon.vue create mode 100644 src/node/icons.ts create mode 100644 src/node/plugins/iconsPlugin.ts diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts index 9a9b7efd..de275f7a 100644 --- a/__tests__/base/emit.test.ts +++ b/__tests__/base/emit.test.ts @@ -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(mpa.split('/').pop()).toBe(plain.split('/').pop()) + 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__/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..66bc0fa0 --- /dev/null +++ b/__tests__/e2e/icons/icons.test.ts @@ -0,0 +1,162 @@ +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('/@vpicons/')) 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( + '/@vpicons/' + ) + }) + expect( + devIconRequests.some((url) => + url.includes('/@vpicons/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..7f5fdb02 --- /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..a1f28081 --- /dev/null +++ b/__tests__/unit/node/icons.test.ts @@ -0,0 +1,157 @@ +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('bare names resolve in simple-icons', () => { + expect(parseIconName('github')).toEqual({ + collection: 'simple-icons', + icon: 'github' + }) + }) + + test('prefixed names resolve in their collection', () => { + expect(parseIconName('lucide:heart')).toEqual({ + collection: 'lucide', + icon: 'heart' + }) + }) + + test('rejects names outside iconify grammar', () => { + for (const name of [ + 'GitHub', + 'foo bar', + 'foo:', + ':bar', + 'a { + test('emits base rules and per-icon rules, no legacy common rule', async () => { + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(['github']), + 'compressed' + ) + expect(warnings).toEqual([]) + expect(css).toContain( + '.vpi-simple-icons-github{--icon:url("data:image/svg+xml' + ) + expect(css).toContain('simple-icons (CC0 1.0)') + expect(css).toContain(":where([class^='vpi-']") + expect(css).toContain('display:inline-block') + expect(css).not.toContain('.vpi-social') + }) + + test('credits simple-icons only when it contributed rules', async () => { + const { css } = await generateIconsCSS( + e2eRoot, + new Set(['notarealiconname', 'lucide:heart']), + 'compressed' + ) + expect(css).toContain('.vpi-lucide-heart') + expect(css).not.toContain('simple-icons (CC0 1.0)') + }) + + test('groups collections and stays deterministic across insertion order', async () => { + const a = await generateIconsCSS( + e2eRoot, + new Set(['lucide:heart', 'github', 'lucide:egg']), + 'compressed' + ) + const b = await generateIconsCSS( + e2eRoot, + new Set(['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(['github', '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..6737d86c 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: { @@ -277,6 +280,10 @@ interface SocialLink { } ``` +Icon styles are generated at build time from collections installed locally, and dev mode serves them from the dev server — no icon is ever fetched from an external service. Icons rendered only on the client (e.g. inside ``) can't be detected during the build; list them in [`icons.include`](site-config#icons) instead. + +To render one of these icons in your own Markdown or components, use the `VPIcon` component from `vitepress/theme` (``), or the lower-level `useIcon` composable from `vitepress` when building a custom theme. + ## footer - Type: `Footer` diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 81c7f6f4..c0fc5e05 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 ([social links](default-theme-config#sociallinks), the `VPIcon` theme component, or any element registered through the `useIcon` composable) and emits their styles as a hashed `assets/vp-icons..css` asset. Names are `name` (resolved in [simple-icons](https://simpleicons.org/)) or `collection:name` for any `@iconify-json/*` collection installed in your project. + +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', 'discord'] + } +} +``` + ### cacheDir - Type: `string` @@ -658,10 +674,14 @@ export default { interface SSGContext { content: string teleports?: Record + /** icons rendered on the page, emitted into the generated stylesheet */ + vpIcons: Set [key: string]: any } ``` +Custom themes can add icon names (`name` for simple-icons, or `collection:name`) to `vpIcons` during SSR to have their styles emitted — the `useIcon` composable from `vitepress` does this for you. + ### transformHead - Type: `(context: TransformContext) => Awaitable` @@ -736,6 +756,8 @@ 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. ::: +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..d17fcd89 --- /dev/null +++ b/src/client/app/composables/icon.ts @@ -0,0 +1,88 @@ +import { + computed, + onMounted, + toValue, + useSSRContext, + watchPostEffect, + type ComputedRef, + type MaybeRefOrGetter +} from 'vue' + +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 `name` (resolved in simple-icons) or `collection:name` for any + * `@iconify-json/*` collection installed in the project. Returns the class + * to render (`vpi--`); pass the template ref of the + * element carrying it so dev can apply the on-demand fallback. + */ +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, 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 + 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(`/@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 + 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..55935044 --- /dev/null +++ b/src/client/theme-default/components/VPIcon.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/client/theme-default/components/VPSocialLink.vue b/src/client/theme-default/components/VPSocialLink.vue index cc19a5f3..2009dad9 100644 --- a/src/client/theme-default/components/VPSocialLink.vue +++ b/src/client/theme-default/components/VPSocialLink.vue @@ -1,14 +1,9 @@