diff --git a/packages/gitbook/src/lib/icons/symbols.ts b/packages/gitbook/src/lib/icons/symbols.ts index 07586b7be..4a6a1eb60 100644 --- a/packages/gitbook/src/lib/icons/symbols.ts +++ b/packages/gitbook/src/lib/icons/symbols.ts @@ -6,8 +6,11 @@ import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; const ICON_ASSET_VERSION = '2'; const rawSvgPromises = new Map>(); +const styleSpritePromises = new Map | null>>(); const svgPattern = /]*)>([\s\S]*?)<\/svg>\s*$/i; +const symbolPattern = /]*)>([\s\S]*?)<\/symbol>/gi; const viewBoxPattern = /\bviewBox="([^"]+)"/i; +const idPattern = /\bid="([^"]+)"/i; const commentPattern = //g; interface IconSymbolSource { @@ -44,6 +47,19 @@ function getIconAssetURL(style: string, icon: string): string { return url.toString(); } +function getStyleSpriteAssetURL(style: string): string { + const url = new URL( + joinPathWithBaseURL(getIconAssetBaseURL(style), joinPath('sprites', `${style}.svg`)) + ); + url.searchParams.set('v', ICON_ASSET_VERSION); + + if (style !== 'custom-icons' && GITBOOK_ICONS_TOKEN) { + url.searchParams.set('token', GITBOOK_ICONS_TOKEN); + } + + return url.toString(); +} + function parseRawSVG(document: string): IconSymbolSource | null { const svgMatch = document.match(svgPattern); if (!svgMatch) { @@ -68,6 +84,35 @@ function parseRawSVG(document: string): IconSymbolSource | null { }; } +function parseStyleSprite(document: string): Map | null { + const symbols = new Map(); + + for (const match of document.matchAll(symbolPattern)) { + const symbolAttributes = match[1]; + const rawMarkup = match[2]; + + if (!symbolAttributes || rawMarkup === undefined) { + continue; + } + + const idMatch = symbolAttributes.match(idPattern); + const viewBoxMatch = symbolAttributes.match(viewBoxPattern); + const icon = idMatch?.[1]; + const viewBox = viewBoxMatch?.[1]; + + if (!icon || !viewBox) { + continue; + } + + symbols.set(icon, { + viewBox, + markup: rawMarkup.replace(commentPattern, '').trim(), + }); + } + + return symbols.size > 0 ? symbols : null; +} + function buildSymbolMarkup(symbolId: string, viewBox: string, markup: string) { return `${markup}`; } @@ -99,17 +144,45 @@ async function fetchRawSVG(style: string, icon: string): Promise return request; } +async function fetchStyleSprite(style: string): Promise | null> { + const existing = styleSpritePromises.get(style); + if (existing) { + return existing; + } + + const request = fetch(getStyleSpriteAssetURL(style), { + cache: 'force-cache', + }) + .then(async (response) => { + if (!response.ok) { + return null; + } + + return parseStyleSprite(await response.text()); + }) + .catch(() => null); + + styleSpritePromises.set(style, request); + return request; +} + /** * Resolve one icon entry from the raw SVG source and serialize it for sprite injection or * same-origin lazy loading. */ export async function getIconSymbol(style: string, icon: string, symbolId: string) { - const rawSVG = await fetchRawSVG(style, icon); - if (!rawSVG) { - return null; + const spriteSymbols = await fetchStyleSprite(style); + let source = spriteSymbols?.get(icon) ?? null; + + if (!source) { + const rawSVG = await fetchRawSVG(style, icon); + if (!rawSVG) { + return null; + } + + source = parseRawSVG(rawSVG); } - const source = parseRawSVG(rawSVG); if (!source) { return null; } diff --git a/packages/icons/src/Icon.tsx b/packages/icons/src/Icon.tsx index 7db56110c..c82a9ea1f 100644 --- a/packages/icons/src/Icon.tsx +++ b/packages/icons/src/Icon.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { IconSymbolLoader } from './IconSymbolLoader'; -import { getIconAssetURL, useIcons } from './IconsProvider'; +import { getIconAssetURL, getIconSpriteAssetURL, useIcons } from './IconsProvider'; import { getIconStyle } from './getIconStyle'; import { getIconSymbolId, prefetchServerIconAsset, registerServerIconSymbol } from './symbols'; import type { IconName, IconStyle } from './types'; @@ -56,14 +56,14 @@ export const Icon = React.forwardRef(function Icon( const iconInstanceId = React.useId(); const symbolId = getIconSymbolId(iconStyle, icon); const iconAssetURL = getIconAssetURL(context, iconStyle, icon); + const iconSpriteAssetURL = getIconSpriteAssetURL(context, iconStyle); if (context.renderMode === 'symbol') { - prefetchServerIconAsset(iconAssetURL); + prefetchServerIconAsset(iconSpriteAssetURL); registerServerIconSymbol({ style: iconStyle, icon, symbolId, - assetURL: iconAssetURL, }); return ( diff --git a/packages/icons/src/IconsProvider.tsx b/packages/icons/src/IconsProvider.tsx index b25081b79..2be9c0a38 100644 --- a/packages/icons/src/IconsProvider.tsx +++ b/packages/icons/src/IconsProvider.tsx @@ -88,3 +88,11 @@ export function getIconAssetURL(context: IconsContextType, style: string, icon: const iconName = typeof icon === 'string' ? icon : String(icon); return getAssetURL(location, `svgs/${style}/${iconName}.svg`); } + +/** + * Get the URL for the sprite document of an icon style. + */ +export function getIconSpriteAssetURL(context: IconsContextType, style: string): string { + const location = context.assetsByStyles?.[style] ?? context; + return getAssetURL(location, `sprites/${style}.svg`); +} diff --git a/packages/icons/src/symbols.ts b/packages/icons/src/symbols.ts index f6e9c20ba..1d0f5bed8 100644 --- a/packages/icons/src/symbols.ts +++ b/packages/icons/src/symbols.ts @@ -4,7 +4,6 @@ export interface RegisteredIconSymbol { style: string; icon: string; symbolId: string; - assetURL?: string; } const REGISTERED_SYMBOLS_KEY = Symbol.for('gitbook.icons.registeredSymbols'); @@ -63,8 +62,8 @@ export function registerServerIconSymbol(symbol: RegisteredIconSymbol): void { } /** - * Start fetching a raw SVG asset during SSR so sprite generation can reuse the in-flight or warm - * request instead of waiting until the end of the render. + * Start fetching a server-side icon asset during SSR so sprite generation can reuse the in-flight + * or warm request instead of waiting until the end of the render. */ export function prefetchServerIconAsset(assetURL: string): void { if (typeof window !== 'undefined') {