From 47c8ccba6ff677243c4970db6a4a0863ff601de6 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Mon, 18 May 2026 15:09:41 +0100 Subject: [PATCH] Fix: clipping of FA icons (#4257) --- .changeset/lovely-clouds-kick.md | 6 + bun.lock | 5 + packages/gitbook/e2e/util.ts | 18 +- packages/gitbook/package.json | 3 +- .../RootLayout/CustomizationRootLayout.tsx | 39 +- .../src/components/SitePage/SitePage.tsx | 85 ++-- packages/gitbook/src/lib/icons/inline.test.ts | 169 ++++++++ packages/gitbook/src/lib/icons/inline.ts | 383 ++++++++++++++++++ packages/icons/package.json | 20 + packages/icons/src/Icon.tsx | 23 +- packages/icons/src/IconSources.ts | 13 + packages/icons/src/IconsProvider.tsx | 22 +- packages/icons/src/index.ts | 3 + packages/icons/src/version.ts | 8 + packages/icons/tsdown.config.ts | 9 +- 15 files changed, 744 insertions(+), 62 deletions(-) create mode 100644 .changeset/lovely-clouds-kick.md create mode 100644 packages/gitbook/src/lib/icons/inline.test.ts create mode 100644 packages/gitbook/src/lib/icons/inline.ts create mode 100644 packages/icons/src/IconSources.ts create mode 100644 packages/icons/src/version.ts diff --git a/.changeset/lovely-clouds-kick.md b/.changeset/lovely-clouds-kick.md new file mode 100644 index 000000000..dfd6124bc --- /dev/null +++ b/.changeset/lovely-clouds-kick.md @@ -0,0 +1,6 @@ +--- +"@gitbook/icons": minor +"gitbook": patch +--- + +Render icons as svg markup diff --git a/bun.lock b/bun.lock index ee95faf0a..ccdb9a771 100644 --- a/bun.lock +++ b/bun.lock @@ -172,6 +172,7 @@ "object-identity": "^0.1.2", "openapi-types": "^12.1.3", "p-map": "^7.0.3", + "p-retry": "^8.0.0", "quick-lru": "^7.0.1", "react": "19.2.4", "react-dom": "19.2.4", @@ -2535,6 +2536,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -2925,6 +2928,8 @@ "p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="], + "p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index 1248d53a6..fe206d040 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -396,11 +396,13 @@ export function getCustomizationURL(partial: DeepPartial { - const urlStates: Record< + type IconURLStates = Record< string, { state: 'pending'; uri: null } | { state: 'loaded'; uri: string } - > = (window as any).__ICONS_STATES__ || {}; - (window as any).__ICONS_STATES__ = urlStates; + >; + const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates }; + const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {}; + iconStatesWindow.__ICONS_STATES__ = urlStates; const fetchSvgAsDataUri = async (url: string): Promise => { const response = await fetch(url); @@ -445,7 +447,15 @@ export async function waitForIcons(page: Page) { const maskImage = icon.querySelector('[data-testid="mask-image"]'); if (!maskImage) { - throw new Error('No mask-image element'); + const inlineContent = icon.querySelector( + 'path, circle, ellipse, line, polygon, polyline, rect, g, use' + ); + if (inlineContent) { + icon.setAttribute('data-argos-state', 'loaded'); + return true; + } + + throw new Error('Icon has no inline SVG content or mask-image element'); } const url = maskImage.getAttribute('href'); diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 93f7e53d7..6ac9218f3 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -23,12 +23,12 @@ "@opennextjs/cloudflare": "1.19.8", "@panzoom/panzoom": "^4.6.1", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.8", - "@radix-ui/react-collapsible": "^1.1.12", "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", "@tusbar/cache-control": "^1.0.2", @@ -64,6 +64,7 @@ "object-identity": "^0.1.2", "openapi-types": "^12.1.3", "p-map": "^7.0.3", + "p-retry": "^8.0.0", "quick-lru": "^7.0.1", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index bd69f3674..aa03e6638 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,6 +1,5 @@ import { CustomizationDefaultThemeMode, - CustomizationIconsStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, type CustomizationThemedColor, @@ -18,7 +17,7 @@ import { colorScale, hexToRgb, } from '@gitbook/colors'; -import { IconStyle, IconsProvider } from '@gitbook/icons'; +import { IconsProvider } from '@gitbook/icons'; import * as ReactDOM from 'react-dom'; import { type FontData, getFontData } from '@/fonts'; @@ -32,6 +31,12 @@ import { RootLayoutClientContexts } from './RootLayoutClientContexts'; import './globals.css'; import type { GitBookAnyContext } from '@/lib/context'; import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env'; +import { + getContentInlineIconSourceRequests, + getCustomizationIconStyle, + getDefaultInlineIconSourceRequests, + getInlineIconSources, +} from '@/lib/icons/inline'; import { defaultCustomization } from '@/lib/utils'; import { AnnouncementDismissedScript } from '../Announcement'; import { OperatingSystemClassScript } from './OperatingSystemClassScript'; @@ -88,6 +93,19 @@ export async function CustomizationRootLayout(props: { // Preconnect and preload custom fonts if needed preloadFont(fontData); preloadFont(monospaceFontData); + const iconStyle = getCustomizationIconStyle(customization); + const iconSources = await getInlineIconSources([ + ...getDefaultInlineIconSourceRequests(iconStyle), + ...getContentInlineIconSourceRequests({ + iconStyle, + pages: context.revision.pages, + tags: context.revision.tags, + sections: + 'sections' in context + ? [...(context.sections?.list ?? []), ...(context.visibleSections?.list ?? [])] + : null, + }), + ]); return ( {children} @@ -345,13 +360,3 @@ function generateColorVariable( }) .join('\n'); } - -const apiToIconsStyles: { - [key in CustomizationIconsStyle]: IconStyle; -} = { - [CustomizationIconsStyle.Regular]: IconStyle.Regular, - [CustomizationIconsStyle.Solid]: IconStyle.Solid, - [CustomizationIconsStyle.Duotone]: IconStyle.Duotone, - [CustomizationIconsStyle.Thin]: IconStyle.Thin, - [CustomizationIconsStyle.Light]: IconStyle.Light, -}; diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 2284b1fce..48d3b1947 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -7,6 +7,7 @@ import { SiteInsightsDisplayContext, type TranslationLanguage, } from '@gitbook/api'; +import { IconsProvider } from '@gitbook/icons'; import type { Metadata, Viewport } from 'next'; import { notFound, redirect } from 'next/navigation'; @@ -15,6 +16,11 @@ import { PageBody, PageCover } from '@/components/PageBody'; import { getPagePath } from '@/lib/pages'; import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; +import { + getContentInlineIconSourceRequests, + getCustomizationIconStyle, + getInlineIconSources, +} from '@/lib/icons/inline'; import { getResizedImageURL } from '@/lib/images'; import { resolveContentRef } from '@/lib/references'; import { getLocalizedTitle } from '@/lib/sites'; @@ -65,47 +71,52 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean }) withSections, withTopHeader, pageMetaLinks, + iconSources, } = await getSitePageData(props); const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader }; return ( - - {/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default. + + + {/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default. If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */} -
- {withFullPageCover && page.cover ? ( - - ) : null} -
- - + {withFullPageCover && page.cover ? ( + + ) : null} + +
+ + +
+
- -
-
+
+ ); } @@ -280,6 +291,13 @@ export async function getSitePageData(props: SitePageProps) { const withSections = Boolean(visibleSections && visibleSections.list.length > 0); const document = await getPageDocument(context, page); + const iconStyle = getCustomizationIconStyle(customization); + const iconSources = await getInlineIconSources( + getContentInlineIconSourceRequests({ + iconStyle, + document, + }) + ); return { context, @@ -291,6 +309,7 @@ export async function getSitePageData(props: SitePageProps) { withFullPageCover, withTopHeader, pageMetaLinks, + iconSources, }; } diff --git a/packages/gitbook/src/lib/icons/inline.test.ts b/packages/gitbook/src/lib/icons/inline.test.ts new file mode 100644 index 000000000..2bec3c81b --- /dev/null +++ b/packages/gitbook/src/lib/icons/inline.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'bun:test'; +import type { + JSONDocument, + RevisionPage, + RevisionTag, + SiteSection, + SiteSectionGroup, +} from '@gitbook/api'; +import { IconStyle } from '@gitbook/icons/types'; + +import { getContentInlineIconSourceRequests, parseRawSVG } from './inline'; + +describe('parseRawSVG', () => { + it('extracts the viewBox and inner SVG markup', () => { + expect( + parseRawSVG(` + + + + `) + ).toEqual({ + viewBox: '0 0 20 20', + markup: '', + }); + }); + + it('strips comments and script tags from the inline markup', () => { + const source = parseRawSVG(` + + + + + + + `); + + expect(source?.viewBox).toBe('0 0 24 24'); + expect(source?.markup).toContain(''); + expect(source?.markup).toContain(''); + expect(source?.markup).not.toContain(' + + + + + + `); + + expect(source?.markup).toContain(''); + expect(source?.markup).not.toContain('/g; +const scriptPattern = //gi; + +/** + * A request for an icon whose SVG source should be resolved and provided to the client renderer. + */ +export type IconSourceRequest = { + icon: IconName; + iconStyle?: IconStyle; +}; + +const apiToIconsStyles: { + [key in CustomizationIconsStyle]: IconStyle; +} = { + [CustomizationIconsStyle.Regular]: IconStyle.Regular, + [CustomizationIconsStyle.Solid]: IconStyle.Solid, + [CustomizationIconsStyle.Duotone]: IconStyle.Duotone, + [CustomizationIconsStyle.Thin]: IconStyle.Thin, + [CustomizationIconsStyle.Light]: IconStyle.Light, +}; + +const defaultIconSourceRequests = [ + 'arrow-down', + 'arrow-down-short-wide', + 'arrow-left', + 'arrow-turn-down-left', + 'arrow-up', + 'arrow-up-right', + 'arrow-up-right-from-square', + 'ban', + 'bars', + 'block-quote', + 'chart-simple', + 'check', + 'check-circle', + 'circle-check', + 'circle-exclamation', + 'circle-info', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'chevron-up', + 'circle-xmark', + 'close', + 'code-commit', + 'code-pull-request', + 'comment', + 'copy', + 'desktop', + 'download', + 'edit', + 'ellipsis', + 'ellipsis-h', + 'envelope', + 'exclamation-triangle', + 'eye', + 'eye-slash', + 'file-archive', + 'file-download', + 'file-image', + 'file-lines', + 'file-pdf', + 'folder-gear', + 'gear', + 'gitbook', + 'github', + 'gitlab', + 'globe', + 'hashtag', + 'link', + 'lock', + 'magnifying-glass', + 'markdown', + 'maximize', + 'mcp', + 'memo', + 'message-question', + 'minus', + 'moon', + 'palette', + 'pen-to-square', + 'plus', + 'print', + 'question-circle', + 'refresh', + 'rotate', + 'rss', + 'search', + 'sparkle', + 'spinner-third', + 'star', + 'sun-bright', + 'thumbs-down', + 'thumbs-up', + 'trash', + 'trash-can', + 'triangle-exclamation', + 'user', + 'x', + 'xmark', +] satisfies IconName[]; + +const customIconSourceRequests = [ + 'chatgpt', + 'bytedance', + 'claude', + 'cohere', + 'common-crawl', + 'cursor', + 'deepseek', + 'gitbook-assistant', + 'mistral', + 'vscode', +] satisfies IconName[]; + +const explicitStyleIconSourceRequests = [ + { icon: 'check', iconStyle: IconStyle.Solid }, + { icon: 'chevron-right', iconStyle: IconStyle.Solid }, + { icon: 'circle-info', iconStyle: IconStyle.Solid }, + { icon: 'circle-exclamation', iconStyle: IconStyle.Solid }, + { icon: 'circle-check', iconStyle: IconStyle.Solid }, +] satisfies IconSourceRequest[]; + +/** + * Icons used by core UI that can render after hydration, such as menus and popovers. + */ +export function getDefaultInlineIconSourceRequests(iconStyle: IconStyle): IconSourceRequest[] { + return [ + ...defaultIconSourceRequests.map((icon) => ({ icon, iconStyle })), + ...customIconSourceRequests.map((icon) => ({ icon, iconStyle: IconStyle.Regular })), + ...explicitStyleIconSourceRequests, + ]; +} + +/** + * Resolve the Font Awesome style configured for a site's customization. + */ +export function getCustomizationIconStyle(customization: SiteCustomizationSettings): IconStyle { + return ( + ('icons' in customization.styling ? apiToIconsStyles[customization.styling.icons] : null) || + IconStyle.Regular + ); +} + +/** + * Collect icon source requests from content data that can render icons outside the default UI set. + */ +export function getContentInlineIconSourceRequests(input: { + iconStyle: IconStyle; + pages?: RevisionPage[]; + document?: JSONDocument | null; + tags?: RevisionTag[]; + sections?: (SiteSection | SiteSectionGroup)[] | null; +}): IconSourceRequest[] { + const { iconStyle, pages = [], document = null, tags = [], sections = null } = input; + const requests: IconSourceRequest[] = []; + + collectPageIconSourceRequests(pages, iconStyle, requests); + collectTagIconSourceRequests(tags, iconStyle, requests); + collectSiteSectionIconSourceRequests(sections ?? [], iconStyle, requests); + + if (document) { + collectDocumentIconSourceRequests(document, iconStyle, requests); + } + + return requests; +} + +/** + * Resolve SVG markup for icons that should be available to client components at first paint. + */ +export async function getInlineIconSources( + requests: IconSourceRequest[] +): Promise> { + const resolved = new Map(); + + for (const request of requests) { + const [style, icon] = getIconStyle(request.iconStyle ?? IconStyle.Regular, request.icon); + resolved.set(getInlineIconSourceKey(style, icon), { style, icon }); + } + + const sources = await Promise.all( + [...resolved.entries()].map(async ([key, request]) => { + const source = await getInlineIconSource(request.style, request.icon); + return source ? ([key, source] as const) : null; + }) + ); + + return Object.fromEntries( + sources.filter((source): source is [string, InlineIconSource] => { + return source !== null; + }) + ); +} + +async function getInlineIconSource( + style: string, + icon: IconName +): Promise { + const cacheKey = getInlineIconSourceKey(style, icon); + const existing = rawSvgPromises.get(cacheKey); + if (existing) { + return existing; + } + + try { + const request = pRetry( + () => + fetch(getIconAssetURL(style, icon), { + cache: 'force-cache', + }).then(async (response) => { + if (!response.ok) { + throw new Error('Failed to fetch icon'); + } + + return parseRawSVG(await response.text()); + }), + { retries: 3 } + ); + rawSvgPromises.set(cacheKey, request); + return await request; + } catch { + console.warn(`Failed to fetch icon ${icon} with style ${style} after multiple attempts`); + return null; + } +} + +export function parseRawSVG(document: string): InlineIconSource | null { + const svgMatch = document.match(svgPattern); + if (!svgMatch) { + return null; + } + + const svgAttributes = svgMatch[1]; + const rawMarkup = svgMatch[2]; + if (!svgAttributes || rawMarkup === undefined) { + return null; + } + + const viewBox = svgAttributes.match(viewBoxPattern)?.[2]; + if (!viewBox) { + return null; + } + + return { + viewBox, + markup: rawMarkup.replace(commentPattern, '').replace(scriptPattern, '').trim(), + }; +} + +function getIconAssetURL(style: string, icon: string): string { + const url = new URL( + joinPathWithBaseURL( + getAbsoluteIconAssetBaseURL(style), + joinPath('svgs', style, `${icon}.svg`) + ) + ); + url.searchParams.set('v', GITBOOK_ICONS_ASSET_VERSION); + + if (style !== 'custom-icons' && GITBOOK_ICONS_TOKEN) { + url.searchParams.set('token', GITBOOK_ICONS_TOKEN); + } + + return url.toString(); +} + +function getIconAssetBaseURL(style: string): string { + if (style === 'custom-icons') { + return getAssetURL('icons'); + } + + return GITBOOK_ICONS_URL; +} + +function getAbsoluteIconAssetBaseURL(style: string): string { + const baseURL = getIconAssetBaseURL(style); + + if (baseURL.startsWith('http://') || baseURL.startsWith('https://')) { + return baseURL; + } + + return joinPathWithBaseURL(GITBOOK_URL, baseURL); +} + +function collectPageIconSourceRequests( + pages: RevisionPage[], + iconStyle: IconStyle, + requests: IconSourceRequest[] +) { + for (const page of pages) { + addIconSourceRequest(requests, page.icon, iconStyle); + + if ('pages' in page) { + collectPageIconSourceRequests(page.pages, iconStyle, requests); + } + } +} + +function collectTagIconSourceRequests( + tags: RevisionTag[], + iconStyle: IconStyle, + requests: IconSourceRequest[] +) { + for (const tag of tags) { + if ('icon' in tag) { + addIconSourceRequest(requests, tag.icon, iconStyle); + } + } +} + +function collectSiteSectionIconSourceRequests( + sections: (SiteSection | SiteSectionGroup)[], + iconStyle: IconStyle, + requests: IconSourceRequest[] +) { + for (const section of sections) { + addIconSourceRequest(requests, section.icon, iconStyle); + + if (section.object === 'site-section-group') { + collectSiteSectionIconSourceRequests(section.children, iconStyle, requests); + } + } +} + +function collectDocumentIconSourceRequests( + node: unknown, + iconStyle: IconStyle, + requests: IconSourceRequest[] +) { + if (!node || typeof node !== 'object') { + return; + } + + const record = node as Record; + + if (record.type === 'icon' || record.type === 'button') { + const data = record.data; + if (data && typeof data === 'object') { + addIconSourceRequest(requests, (data as Record).icon, iconStyle); + } + } + + for (const value of Object.values(record)) { + if (Array.isArray(value)) { + for (const item of value) { + collectDocumentIconSourceRequests(item, iconStyle, requests); + } + } else if (value && typeof value === 'object') { + collectDocumentIconSourceRequests(value, iconStyle, requests); + } + } +} + +function addIconSourceRequest(requests: IconSourceRequest[], icon: unknown, iconStyle: IconStyle) { + if (typeof icon === 'string' && validateIconName(icon)) { + requests.push({ icon, iconStyle }); + } +} diff --git a/packages/icons/package.json b/packages/icons/package.json index b6459d683..35e445689 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -11,6 +11,26 @@ "types": "./dist/icons.d.ts", "development": "./src/icons.ts", "default": "./dist/icons.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "development": "./src/types.ts", + "default": "./dist/types.js" + }, + "./getIconStyle": { + "types": "./dist/getIconStyle.d.ts", + "development": "./src/getIconStyle.ts", + "default": "./dist/getIconStyle.js" + }, + "./IconSources": { + "types": "./dist/IconSources.d.ts", + "development": "./src/IconSources.ts", + "default": "./dist/IconSources.js" + }, + "./version": { + "types": "./dist/version.d.ts", + "development": "./src/version.ts", + "default": "./dist/version.js" } }, "sideEffects": false, diff --git a/packages/icons/src/Icon.tsx b/packages/icons/src/Icon.tsx index 92e1f7fb4..ffcbc831d 100644 --- a/packages/icons/src/Icon.tsx +++ b/packages/icons/src/Icon.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import { getInlineIconSourceKey } from './IconSources'; import { getIconAssetURL, useIcons } from './IconsProvider'; import { getIconStyle } from './getIconStyle'; import type { IconName, IconStyle } from './types'; @@ -50,9 +51,29 @@ export const Icon = React.forwardRef(function Icon( } = props; const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon); - const url = getIconAssetURL(context, iconStyle, icon); + const source = context.iconSources?.[getInlineIconSourceKey(iconStyle, icon)]; const maskId = React.useId(); + if (source) { + return ( + + ); + } + + const url = getIconAssetURL(context, iconStyle, icon); + return ( & { /** Assets location for special styles */ assetsByStyles?: Record; + /** Server-resolved SVG sources keyed by style and icon. */ + iconSources?: Record; /** Current default style for icons */ iconStyle: IconStyle; }; @@ -34,10 +36,20 @@ export function IconsProvider(props: React.PropsWithChildren { - return { assetsURL, assetsURLToken, iconStyle, assetsByStyles }; - }, [assetsURL, assetsURLToken, iconStyle, assetsByStyles]); + return { + assetsURL, + assetsURLToken, + iconStyle, + assetsByStyles, + iconSources: + parent.iconSources && iconSources + ? { ...parent.iconSources, ...iconSources } + : (iconSources ?? parent.iconSources), + }; + }, [assetsURL, assetsURLToken, iconStyle, assetsByStyles, iconSources, parent.iconSources]); return {children}; } @@ -56,7 +68,7 @@ export function getAssetURL(location: Partial, path: string if (!location.assetsURL) { throw new Error('You first need to pass a assetsURL to '); } - const rawUrl = `${location.assetsURL + (location.assetsURL.endsWith('/') ? '' : '/') + path}?v=${version}`; + const rawUrl = `${location.assetsURL + (location.assetsURL.endsWith('/') ? '' : '/') + path}?v=${GITBOOK_ICONS_ASSET_VERSION}`; if (location.assetsURLToken) { const url = new URL(rawUrl); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 8148812a5..b479363e1 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -1,3 +1,6 @@ export * from './Icon'; +export * from './IconSources'; export * from './types'; export * from './IconsProvider'; +export * from './getIconStyle'; +export * from './version'; diff --git a/packages/icons/src/version.ts b/packages/icons/src/version.ts new file mode 100644 index 000000000..0beac8189 --- /dev/null +++ b/packages/icons/src/version.ts @@ -0,0 +1,8 @@ +/** + * Cache-busting version for the static icon SVG assets served by GitBook. + * + * This value must be shared by both the client-side icon renderer and any + * server-side code that fetches or inlines icon SVGs, otherwise the browser can + * receive different cache keys for the same icon asset. + */ +export const GITBOOK_ICONS_ASSET_VERSION = '2'; diff --git a/packages/icons/tsdown.config.ts b/packages/icons/tsdown.config.ts index 60973dabc..c772c7e8b 100644 --- a/packages/icons/tsdown.config.ts +++ b/packages/icons/tsdown.config.ts @@ -2,7 +2,14 @@ import { defineConfig } from 'tsdown'; export default defineConfig([ { - entry: ['src/index.ts', 'src/icons.ts'], + entry: [ + 'src/index.ts', + 'src/icons.ts', + 'src/types.ts', + 'src/getIconStyle.ts', + 'src/IconSources.ts', + 'src/version.ts', + ], unbundle: true, }, ]);