Compare commits

...

19 Commits

Author SHA1 Message Date
Brett Jephson 1ae09b1ec5 Merge branch 'main' into brett/fa-icons-rendering 2026-05-05 09:49:03 +01:00
Brett Jephson 6cd9f2aaaa update 2026-04-30 20:38:57 +01:00
Brett Jephson 6d4a1653a2 use sprites to parse symbols 2026-04-30 18:52:05 +01:00
Brett Jephson 9ce2759e1b move spritesheet up 2026-04-30 18:05:38 +01:00
Brett Jephson 5663064b46 Speed up icon rendering 2026-04-30 17:32:51 +01:00
Brett Jephson 6246b106af formatting 2026-04-30 15:11:15 +01:00
Brett Jephson 9ad85cb30c tidy 2026-04-30 15:07:46 +01:00
Brett Jephson 25bcff6eff Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 15:02:00 +01:00
Brett Jephson f232b8fff5 e2e tests 2026-04-30 15:01:32 +01:00
Brett Jephson e7f9bc2565 use route to transform svg to local symbol 2026-04-30 14:57:34 +01:00
Brett Jephson 21053f56fb Update method of loading symbols 2026-04-30 13:47:16 +01:00
Brett Jephson 2746ffc000 test fix 2026-04-30 12:01:53 +01:00
Brett Jephson 568ee3a660 tidy and update icon loading in test 2026-04-30 11:31:00 +01:00
Brett Jephson 420bf4a6f6 Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 10:52:51 +01:00
Brett Jephson 72cffdd14b tidy 2026-04-30 10:45:25 +01:00
Brett Jephson 07be0e224c use shared supported style info 2026-04-30 10:16:35 +01:00
Brett Jephson c765394914 tidy 2026-04-30 10:16:35 +01:00
Brett Jephson ab96ee806b changeset 2026-04-30 10:16:35 +01:00
Brett Jephson 7cc6090e03 load icons as symbols 2026-04-30 10:16:35 +01:00
15 changed files with 709 additions and 20 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Update icon usage to render svg symbols rather than file.
+1 -1
View File
@@ -36,4 +36,4 @@ screenshots/
# cloudflare
.open-next
.wrangler
worker-configuration.d.ts
worker-configuration.d.ts
+22 -5
View File
@@ -146,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
},
];
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
type IconStateWindow = Window & {
__ICONS_STATES__?: Record<string, IconURLState>;
};
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({
@@ -396,11 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const iconWindow = window as IconStateWindow;
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
iconWindow.__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -433,6 +436,20 @@ export async function waitForIcons(page: Page) {
return true;
}
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
if (svgSymbol) {
if (icon.dataset.gbIconSymbolState === 'loaded') {
return true;
}
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
if (!href?.startsWith('#')) {
return false;
}
return document.getElementById(href.slice(1)) instanceof SVGElement;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
@@ -0,0 +1,28 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getIconSymbol } from '@/lib/icons/symbols';
import { getIconSymbolId } from '@gitbook/icons';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ style: string; icon: string }> }
) {
const { style, icon } = await params;
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
if (!symbol) {
return NextResponse.json(
{
error: 'Symbol not found',
},
{ status: 404 }
);
}
return new NextResponse(symbol.document, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=31536000, immutable',
},
});
}
@@ -27,6 +27,7 @@ import { getContentLocale, getSpaceLanguage } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
import { tcls } from '@/lib/tailwind';
import { IconSpriteDefinitions } from './IconSpriteDefinitions';
import { RootLayoutClientContexts } from './RootLayoutClientContexts';
import './globals.css';
@@ -76,6 +77,9 @@ export async function CustomizationRootLayout(props: {
const sidebarStyles = getSidebarStyles(customization);
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
const fontData = getFontData(customization.styling.font, 'content');
const iconStyle =
('icons' in customization.styling ? apiToIconsStyles[customization.styling.icons] : null) ||
IconStyle.Regular;
// Temporarily add a if here while the cache is being warmed up.
// We can remove the condition after 14-07-2025.
const monospaceFontData = customization.styling.monospaceFont
@@ -194,15 +198,14 @@ export async function CustomizationRootLayout(props: {
assetsURL: getAssetURL('icons'),
},
}}
iconStyle={
('icons' in customization.styling
? apiToIconsStyles[customization.styling.icons]
: null) || IconStyle.Regular
}
renderMode="symbol"
symbolLoaderURL="/~gitbook/icons/symbol"
iconStyle={iconStyle}
>
<RootLayoutClientContexts language={language}>
{children}
</RootLayoutClientContexts>
<IconSpriteDefinitions />
</IconsProvider>
</body>
</html>
@@ -0,0 +1,56 @@
import { getIconSymbol } from '@/lib/icons/symbols';
import {
type RegisteredIconSymbol,
clearRegisteredServerIconSymbols,
getRegisteredServerIconSymbols,
} from '@gitbook/icons';
/**
* Emits the subset of icon symbols that were rendered during the current request.
*/
export async function IconSpriteDefinitions() {
const registered: Map<string, RegisteredIconSymbol> = new Map(
getRegisteredServerIconSymbols().map((symbol: RegisteredIconSymbol) => [
`${symbol.style}/${symbol.icon}`,
symbol,
])
);
if (registered.size === 0) {
return null;
}
const definitions = (
await Promise.all(
[...registered.values()].map((symbol) => {
return getIconSymbol(symbol.style, symbol.icon, symbol.symbolId);
})
)
).filter((symbol): symbol is NonNullable<typeof symbol> => !!symbol);
clearRegisteredServerIconSymbols();
if (!definitions.length) {
return null;
}
return (
<svg
id="gb-icon-sprite-root"
data-testid="icon-sprite-root"
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
style={{
position: 'absolute',
width: 0,
height: 0,
overflow: 'hidden',
pointerEvents: 'none',
}}
dangerouslySetInnerHTML={{
__html: definitions.map((symbol) => symbol.symbol).join(''),
}}
/>
);
}
@@ -10,7 +10,7 @@ import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL } from '@/lib/env';
import { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
import { AIContextProvider } from '../AI';
@@ -34,7 +34,6 @@ export async function SiteLayout(props: {
const scripts = withTracking ? context.scripts : [];
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
ReactDOM.preconnect(GITBOOK_ICONS_URL);
if (GITBOOK_ASSETS_URL) {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
+201
View File
@@ -0,0 +1,201 @@
import 'server-only';
import { getAssetURL } from '@/lib/assets';
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
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 {
viewBox: string;
markup: string;
}
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function getIconAssetBaseURL(style: string): string {
if (style === 'custom-icons') {
return getAssetURL('icons');
}
return GITBOOK_ICONS_URL;
}
function getIconAssetURL(style: string, icon: string): string {
const url = new URL(
joinPathWithBaseURL(getIconAssetBaseURL(style), joinPath('svgs', style, `${icon}.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 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) {
return null;
}
const svgAttributes = svgMatch[1];
const rawMarkup = svgMatch[2];
if (!svgAttributes || rawMarkup === undefined) {
return null;
}
const viewBoxMatch = svgAttributes.match(viewBoxPattern);
const viewBox = viewBoxMatch?.[1];
if (!viewBox) {
return null;
}
return {
viewBox,
markup: rawMarkup.replace(commentPattern, '').trim(),
};
}
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>`;
}
function buildSymbolDocument(symbolId: string, symbol: string) {
return `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbol}</defs><use href="#${escapeAttribute(symbolId)}"/></svg>`;
}
async function fetchRawSVG(style: string, icon: string): Promise<string | null> {
const cacheKey = `${style}/${icon}`;
const existing = rawSvgPromises.get(cacheKey);
if (existing) {
return existing;
}
const request = fetch(getIconAssetURL(style, icon), {
cache: 'force-cache',
})
.then(async (response) => {
if (!response.ok) {
return null;
}
return response.text();
})
.catch(() => null);
rawSvgPromises.set(cacheKey, request);
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 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);
}
if (!source) {
return null;
}
const symbol = buildSymbolMarkup(symbolId, source.viewBox, source.markup);
return {
style,
icon,
symbolId,
viewBox: source.viewBox,
markup: source.markup,
symbol,
document: buildSymbolDocument(symbolId, symbol),
};
}
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { Icon } from './Icon';
import { IconsProvider } from './IconsProvider';
import { clearRegisteredServerIconSymbols, getRegisteredServerIconSymbols } from './symbols';
import { IconStyle } from './types';
afterEach(() => {
clearRegisteredServerIconSymbols();
});
function RegisteredSymbolsSummary() {
const symbols = getRegisteredServerIconSymbols();
return (
<output
data-testid="registered-symbols"
data-count={symbols.length}
data-symbol-ids={symbols.map((symbol) => symbol.symbolId).join(',')}
/>
);
}
describe('Icon', () => {
it('renders the mask-image path when symbol mode is disabled', () => {
const html = renderToStaticMarkup(
<IconsProvider assetsURL="https://icons.example.test" iconStyle={IconStyle.Regular}>
<Icon icon="github" className="size-4" />
</IconsProvider>
);
expect(html).toContain('data-testid="mask-image"');
expect(html).toContain('https://icons.example.test/svgs/brands/github.svg?v=2');
expect(html).not.toContain('data-testid="symbol-use"');
});
it('renders inline symbol references and deduplicates SSR registrations', () => {
const html = renderToStaticMarkup(
<IconsProvider
assetsURL="https://icons.example.test"
iconStyle={IconStyle.Regular}
renderMode="symbol"
symbolLoaderURL="/~gitbook/icons/symbol"
>
<>
<Icon icon="github" />
<Icon icon="github" />
<Icon icon="gitbook" />
<RegisteredSymbolsSummary />
</>
</IconsProvider>
);
expect(html).toContain('data-testid="symbol-use"');
expect(html).toContain('href="#gb-icon-brands-github"');
expect(html).toContain('href="#gb-icon-custom-icons-gitbook"');
expect(html).toContain('data-testid="registered-symbols"');
expect(html).toContain('data-count="2"');
expect(html).toContain(
'data-symbol-ids="gb-icon-brands-github,gb-icon-custom-icons-gitbook"'
);
});
});
+44 -3
View File
@@ -2,8 +2,10 @@
import * as React from 'react';
import { getIconAssetURL, useIcons } from './IconsProvider';
import { IconSymbolLoader } from './IconSymbolLoader';
import { getIconAssetURL, getIconSpriteAssetURL, useIcons } from './IconsProvider';
import { getIconStyle } from './getIconStyle';
import { getIconSymbolId, prefetchServerIconAsset, registerServerIconSymbol } from './symbols';
import type { IconName, IconStyle } from './types';
/**
@@ -50,8 +52,47 @@ export const Icon = React.forwardRef(function Icon(
} = props;
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
const url = getIconAssetURL(context, iconStyle, icon);
const maskId = React.useId();
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(iconSpriteAssetURL);
registerServerIconSymbol({
style: iconStyle,
icon,
symbolId,
});
return (
<svg
ref={ref}
{...rest}
viewBox="0 0 512 512"
data-gb-icon-instance={iconInstanceId}
style={{
overflow: 'visible',
...(size ? { width: size, height: size } : {}),
...rest.style,
}}
className={`gb-icon ${className}`}
>
<title>{icon}</title>
<use data-testid="symbol-use" href={`#${symbolId}`} width="100%" height="100%" />
{context.symbolLoaderURL ? (
<IconSymbolLoader
instanceId={iconInstanceId}
symbolId={symbolId}
style={iconStyle}
icon={icon}
loaderURL={context.symbolLoaderURL}
/>
) : null}
</svg>
);
}
return (
<svg
@@ -73,7 +114,7 @@ export const Icon = React.forwardRef(function Icon(
>
<image
data-testid="mask-image"
href={url}
href={iconAssetURL}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
+155
View File
@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const pendingSymbolLoads = new Map<string, Promise<boolean>>();
/**
* Ensure a symbol referenced by an inline `<use>` exists after hydration, fetching it from the
* same-origin symbol route only when the SSR sprite did not already include it.
*/
export function IconSymbolLoader(props: {
instanceId: string;
symbolId: string;
style: string;
icon: string;
loaderURL: string;
}) {
const { instanceId, symbolId, style, icon, loaderURL } = props;
React.useEffect(() => {
let mounted = true;
if (hasSymbol(symbolId)) {
setIconState(instanceId, true);
return;
}
loadSymbol(symbolId, loaderURL, style, icon).then((loaded) => {
if (!mounted) {
return;
}
setIconState(instanceId, loaded);
});
return () => {
mounted = false;
};
}, [icon, instanceId, loaderURL, style, symbolId]);
return null;
}
/**
* A root SVG element used to host the loaded symbols as a spritesheet. This is used as a reference point for the `<use>` elements in the icons.
*/
function getSpriteRoot(): SVGSVGElement {
const existing = document.getElementById('gb-icon-sprite-root');
if (existing instanceof SVGSVGElement) {
return existing;
}
return createSpriteRoot();
}
/**
* Creates the root SVG element for the spritesheet if it doesn't already exist, and appends it to the document body.
*/
function createSpriteRoot(): SVGSVGElement {
const spriteRoot = document.createElementNS(SVG_NAMESPACE, 'svg');
spriteRoot.setAttribute('id', 'gb-icon-sprite-root');
spriteRoot.setAttribute('aria-hidden', 'true');
spriteRoot.setAttribute('focusable', 'false');
spriteRoot.setAttribute(
'style',
'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'
);
document.body.prepend(spriteRoot);
return spriteRoot;
}
function hasSymbol(symbolId: string): boolean {
return document.getElementById(symbolId) instanceof SVGElement;
}
function buildSymbolURL(loaderURL: string, style: string, icon: string): string {
const normalizedLoaderURL = loaderURL.endsWith('/') ? loaderURL.slice(0, -1) : loaderURL;
return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}`;
}
function appendSymbolsFromDocument(markup: string): boolean {
const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
const symbols = Array.from(parsed.querySelectorAll('symbol'));
if (symbols.length === 0) {
return false;
}
const spriteRoot = getSpriteRoot();
for (const symbol of symbols) {
const symbolId = symbol.getAttribute('id');
if (!symbolId || hasSymbol(symbolId)) {
continue;
}
spriteRoot.appendChild(document.importNode(symbol, true));
}
return true;
}
async function loadSymbol(symbolId: string, loaderURL: string, style: string, icon: string) {
if (hasSymbol(symbolId)) {
return true;
}
const cached = pendingSymbolLoads.get(symbolId);
if (cached) {
return cached;
}
const request = fetch(buildSymbolURL(loaderURL, style, icon), {
credentials: 'same-origin',
})
.then(async (response) => {
if (!response.ok) {
return false;
}
const symbolMarkup = await response.text();
if (hasSymbol(symbolId)) {
return true;
}
if (!appendSymbolsFromDocument(symbolMarkup)) {
return false;
}
return hasSymbol(symbolId);
})
.catch(() => false)
.finally(() => {
pendingSymbolLoads.delete(symbolId);
});
pendingSymbolLoads.set(symbolId, request);
return request;
}
function setIconState(instanceId: string, loaded: boolean) {
const icon = document.querySelector<SVGSVGElement>(
`svg[data-gb-icon-instance="${instanceId}"]`
);
if (!icon) {
return;
}
const symbolUse = icon.querySelector<SVGUseElement>('[data-testid="symbol-use"]');
if (symbolUse) {
symbolUse.style.display = loaded ? '' : 'none';
}
icon.setAttribute('data-gb-icon-symbol-state', loaded ? 'loaded' : 'failed');
}
+24 -3
View File
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import type { IconRenderMode } from './symbols';
import { IconStyle } from './types';
const version = 2;
@@ -17,10 +18,15 @@ export type IconsContextType = Partial<IconsAssetsLocation> & {
assetsByStyles?: Record<string, IconsAssetsLocation>;
/** Current default style for icons */
iconStyle: IconStyle;
/** Rendering strategy for icons */
renderMode: IconRenderMode;
/** Base URL used to lazily load prebuilt symbol documents introduced after hydration */
symbolLoaderURL?: string;
};
const IconsContext = React.createContext<IconsContextType>({
iconStyle: IconStyle.Regular,
renderMode: 'mask',
});
/**
@@ -34,10 +40,17 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
assetsURLToken = parent.assetsURLToken,
iconStyle = parent.iconStyle,
assetsByStyles = parent.assetsByStyles,
renderMode = parent.renderMode,
symbolLoaderURL = parent.symbolLoaderURL,
} = props;
const value = React.useMemo(() => {
return { assetsURL, assetsURLToken, iconStyle, assetsByStyles };
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles]);
const value = {
assetsURL,
assetsURLToken,
iconStyle,
assetsByStyles,
renderMode,
symbolLoaderURL,
};
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
}
@@ -75,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
View File
@@ -1,3 +1,5 @@
export * from './Icon';
export * from './types';
export * from './symbols';
export * from './getIconStyle';
export * from './IconsProvider';
+97
View File
@@ -0,0 +1,97 @@
export type IconRenderMode = 'mask' | 'symbol';
export interface RegisteredIconSymbol {
style: string;
icon: string;
symbolId: string;
}
const REGISTERED_SYMBOLS_KEY = Symbol.for('gitbook.icons.registeredSymbols');
const PREFETCHED_ICON_ASSETS_KEY = Symbol.for('gitbook.icons.prefetchedAssets');
function sanitizeSymbolFragment(fragment: string): string {
return fragment.replace(/[^a-zA-Z0-9_-]/g, '-');
}
/**
* Build the DOM id used by both SSR-emitted symbols and the lazy symbol loader.
*/
export function getIconSymbolId(style: string, icon: string): string {
return `gb-icon-${sanitizeSymbolFragment(style)}-${sanitizeSymbolFragment(icon)}`;
}
function getRegisteredSymbolsStore(): Map<string, RegisteredIconSymbol> {
const store = globalThis as typeof globalThis & {
[REGISTERED_SYMBOLS_KEY]?: Map<string, RegisteredIconSymbol>;
};
if (!store[REGISTERED_SYMBOLS_KEY]) {
store[REGISTERED_SYMBOLS_KEY] = new Map<string, RegisteredIconSymbol>();
}
return store[REGISTERED_SYMBOLS_KEY];
}
function getPrefetchedAssetsStore(): Map<string, Promise<void>> {
const store = globalThis as typeof globalThis & {
[PREFETCHED_ICON_ASSETS_KEY]?: Map<string, Promise<void>>;
};
if (!store[PREFETCHED_ICON_ASSETS_KEY]) {
store[PREFETCHED_ICON_ASSETS_KEY] = new Map<string, Promise<void>>();
}
return store[PREFETCHED_ICON_ASSETS_KEY];
}
function shouldTrackSymbolRegistrations() {
const runtime = globalThis as typeof globalThis & { Bun?: unknown };
return typeof window === 'undefined' || typeof runtime.Bun !== 'undefined';
}
/**
* Record a symbol used during server rendering so the app can emit a deduplicated sprite subset.
*/
export function registerServerIconSymbol(symbol: RegisteredIconSymbol): void {
if (!shouldTrackSymbolRegistrations()) {
return;
}
getRegisteredSymbolsStore().set(`${symbol.style}/${symbol.icon}`, symbol);
}
/**
* 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') {
return;
}
const prefetchedAssets = getPrefetchedAssetsStore();
if (prefetchedAssets.has(assetURL)) {
return;
}
const request = fetch(assetURL, { cache: 'force-cache' })
.then(() => undefined)
.catch(() => undefined);
prefetchedAssets.set(assetURL, request);
}
/**
* Return the currently registered server-rendered symbols in insertion order.
*/
export function getRegisteredServerIconSymbols(): RegisteredIconSymbol[] {
return [...getRegisteredSymbolsStore().values()];
}
/**
* Reset the per-request symbol registry after the sprite subset has been emitted.
*/
export function clearRegisteredServerIconSymbols(): void {
getRegisteredSymbolsStore().clear();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["es2023"],
"lib": ["es2023", "dom"],
"module": "ESNext",
"target": "es2022",
"strict": true,