use sprites to parse symbols

This commit is contained in:
Brett Jephson
2026-04-30 18:52:05 +01:00
parent 9ce2759e1b
commit 6d4a1653a2
4 changed files with 90 additions and 10 deletions
+77 -4
View File
@@ -6,8 +6,11 @@ import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
const ICON_ASSET_VERSION = '2';
const rawSvgPromises = new Map<string, Promise<string | null>>();
const styleSpritePromises = new Map<string, Promise<Map<string, IconSymbolSource> | null>>();
const svgPattern = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
const symbolPattern = /<symbol\b([^>]*)>([\s\S]*?)<\/symbol>/gi;
const viewBoxPattern = /\bviewBox="([^"]+)"/i;
const idPattern = /\bid="([^"]+)"/i;
const commentPattern = /<!--[\s\S]*?-->/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<string, IconSymbolSource> | null {
const symbols = new Map<string, IconSymbolSource>();
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 `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(viewBox)}" overflow="visible">${markup}</symbol>`;
}
@@ -99,17 +144,45 @@ async function fetchRawSVG(style: string, icon: string): Promise<string | null>
return request;
}
async function fetchStyleSprite(style: string): Promise<Map<string, IconSymbolSource> | 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;
}
+3 -3
View File
@@ -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 (
+8
View File
@@ -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`);
}
+2 -3
View File
@@ -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') {