Fix: clipping of FA icons (#4257)

This commit is contained in:
Brett Jephson
2026-05-18 15:09:41 +01:00
committed by GitHub
parent e9842feeef
commit 47c8ccba6f
15 changed files with 744 additions and 62 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Render icons as svg markup
+5
View File
@@ -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=="],
+14 -4
View File
@@ -396,11 +396,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
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<string> => {
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');
+2 -1
View File
@@ -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",
@@ -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 (
<html
@@ -194,11 +212,8 @@ export async function CustomizationRootLayout(props: {
assetsURL: getAssetURL('icons'),
},
}}
iconStyle={
('icons' in customization.styling
? apiToIconsStyles[customization.styling.icons]
: null) || IconStyle.Regular
}
iconSources={iconSources}
iconStyle={iconStyle}
>
<RootLayoutClientContexts language={language}>
{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,
};
@@ -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 (
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* 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.
<IconsProvider iconSources={iconSources}>
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* 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. */}
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
<div
className={tcls(
withFullPageCover && page.cover ? 'flex grow flex-row' : 'contents',
withSections
? '[--content-scroll-margin:calc(var(--spacing)*27)]'
: '[--content-scroll-margin:calc(var(--spacing)*16)]'
)}
className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}
>
<PageAside
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={context}
/>
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Site}
staticRoute={props.staticRoute}
/>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
<div
className={tcls(
withFullPageCover && page.cover ? 'flex grow flex-row' : 'contents',
withSections
? '[--content-scroll-margin:calc(var(--spacing)*27)]'
: '[--content-scroll-margin:calc(var(--spacing)*16)]'
)}
>
<PageAside
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={context}
/>
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Site}
staticRoute={props.staticRoute}
/>
</div>
<PageClientLayout pageMetaLinks={pageMetaLinks} />
</div>
<PageClientLayout pageMetaLinks={pageMetaLinks} />
</div>
</PageContextProvider>
</PageContextProvider>
</IconsProvider>
);
}
@@ -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,
};
}
@@ -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(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M1 2h3" />
</svg>
`)
).toEqual({
viewBox: '0 0 20 20',
markup: '<path d="M1 2h3" />',
});
});
it('strips comments and script tags from the inline markup', () => {
const source = parseRawSVG(`
<svg viewBox='0 0 24 24'>
<!-- Font Awesome metadata -->
<path d="M4 5h6" />
<script>alert("bad")</script>
<path d="M7 8h9" />
</svg>
`);
expect(source?.viewBox).toBe('0 0 24 24');
expect(source?.markup).toContain('<path d="M4 5h6" />');
expect(source?.markup).toContain('<path d="M7 8h9" />');
expect(source?.markup).not.toContain('<!--');
expect(source?.markup).not.toContain('<script');
});
it('returns null when the document is not an SVG with a viewBox', () => {
expect(parseRawSVG('<div>not an icon</div>')).toBeNull();
expect(parseRawSVG('<svg><path d="M1 2h3" /></svg>')).toBeNull();
});
it('extracts the viewBox when other attributes surround it', () => {
expect(
parseRawSVG(
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 2h3" /></svg>'
)
).toEqual({
viewBox: '0 0 16 16',
markup: '<path d="M1 2h3" />',
});
});
it('returns null when non-whitespace content follows the closing SVG tag', () => {
expect(
parseRawSVG('<svg viewBox="0 0 16 16"><path d="M1 2h3" /></svg><span>extra</span>')
).toBeNull();
});
it('strips multiple comments and script tags from the markup', () => {
const source = parseRawSVG(`
<svg viewBox="0 0 16 16">
<!-- first -->
<path d="M1 2h3" />
<script>first()</script>
<!-- second -->
<script type="text/javascript">second()</script>
</svg>
`);
expect(source?.markup).toContain('<path d="M1 2h3" />');
expect(source?.markup).not.toContain('<!--');
expect(source?.markup).not.toContain('<script');
expect(source?.markup).not.toContain('second()');
});
});
describe('getContentInlineIconSourceRequests', () => {
it('collects icon requests from pages, tags, sections, and document nodes', () => {
const pages = [
{
icon: 'github',
pages: [{ icon: 'lock' }],
},
{ icon: 'star' },
] as unknown as RevisionPage[];
const tags = [{ icon: 'gear' }] as unknown as RevisionTag[];
const sections = [
{
object: 'site-section-group',
icon: 'magnifying-glass',
children: [{ object: 'site-section', icon: 'xmark' }],
},
] as unknown as (SiteSection | SiteSectionGroup)[];
const document = {
object: 'document',
nodes: [
{
type: 'paragraph',
children: [{ type: 'icon', data: { icon: 'copy' } }],
},
{
type: 'button',
data: { icon: 'download' },
},
],
} as unknown as JSONDocument;
expect(
getContentInlineIconSourceRequests({
iconStyle: IconStyle.Solid,
pages,
tags,
sections,
document,
})
).toEqual([
{ icon: 'github', iconStyle: IconStyle.Solid },
{ icon: 'lock', iconStyle: IconStyle.Solid },
{ icon: 'star', iconStyle: IconStyle.Solid },
{ icon: 'gear', iconStyle: IconStyle.Solid },
{ icon: 'magnifying-glass', iconStyle: IconStyle.Solid },
{ icon: 'xmark', iconStyle: IconStyle.Solid },
{ icon: 'copy', iconStyle: IconStyle.Solid },
{ icon: 'download', iconStyle: IconStyle.Solid },
]);
});
it('ignores invalid, missing, and non-string icon values', () => {
const pages = [
{ icon: 'not-a-real-icon' },
{ icon: 123 },
{ icon: 'github' },
] as unknown as RevisionPage[];
const tags = [{ icon: null }, { icon: 'lock' }] as unknown as RevisionTag[];
const sections = [
{ object: 'site-section', icon: undefined },
{ object: 'site-section', icon: 'also-not-real' },
] as unknown as (SiteSection | SiteSectionGroup)[];
const document = {
object: 'document',
nodes: [
{ type: 'icon', data: { icon: 'made-up' } },
{ type: 'button', data: { icon: false } },
{ type: 'icon', data: { icon: 'copy' } },
],
} as unknown as JSONDocument;
expect(
getContentInlineIconSourceRequests({
iconStyle: IconStyle.Regular,
pages,
tags,
sections,
document,
})
).toEqual([
{ icon: 'github', iconStyle: IconStyle.Regular },
{ icon: 'lock', iconStyle: IconStyle.Regular },
{ icon: 'copy', iconStyle: IconStyle.Regular },
]);
});
});
+383
View File
@@ -0,0 +1,383 @@
import 'server-only';
import {
CustomizationIconsStyle,
type JSONDocument,
type RevisionPage,
type RevisionTag,
type SiteCustomizationSettings,
type SiteSection,
type SiteSectionGroup,
} from '@gitbook/api';
import { type InlineIconSource, getInlineIconSourceKey } from '@gitbook/icons/IconSources';
import { getIconStyle } from '@gitbook/icons/getIconStyle';
import { validateIconName } from '@gitbook/icons/icons';
import { type IconName, IconStyle } from '@gitbook/icons/types';
import { GITBOOK_ICONS_ASSET_VERSION } from '@gitbook/icons/version';
import pRetry from 'p-retry';
import { getAssetURL } from '@/lib/assets';
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL, GITBOOK_URL } from '@/lib/env';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
const rawSvgPromises = new Map<string, Promise<InlineIconSource | null>>();
const svgPattern = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
const viewBoxPattern = /\bviewBox=(["'])(.*?)\1/i;
const commentPattern = /<!--[\s\S]*?-->/g;
const scriptPattern = /<script\b[\s\S]*?<\/script>/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<Record<string, InlineIconSource>> {
const resolved = new Map<string, { style: string; icon: IconName }>();
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<InlineIconSource | null> {
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<string, unknown>;
if (record.type === 'icon' || record.type === 'button') {
const data = record.data;
if (data && typeof data === 'object') {
addIconSourceRequest(requests, (data as Record<string, unknown>).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 });
}
}
+20
View File
@@ -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,
+22 -1
View File
@@ -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 (
<svg
ref={ref}
{...rest}
viewBox={source.viewBox}
fill="currentColor"
style={{
overflow: 'visible',
...(size ? { width: size, height: size } : {}),
...rest.style,
}}
className={`gb-icon ${className}`}
dangerouslySetInnerHTML={{ __html: source.markup }}
/>
);
}
const url = getIconAssetURL(context, iconStyle, icon);
return (
<svg
ref={ref}
+13
View File
@@ -0,0 +1,13 @@
export interface InlineIconSource {
/** The SVG viewBox for the icon. */
viewBox: string;
/** Trusted inner SVG markup for the icon. */
markup: string;
}
/**
* Get the lookup key for a server-resolved inline SVG source.
*/
export function getInlineIconSourceKey(style: string, icon: string): string {
return `${style}/${icon}`;
}
+17 -5
View File
@@ -1,9 +1,9 @@
'use client';
import * as React from 'react';
import type { InlineIconSource } from './IconSources';
import { IconStyle } from './types';
const version = 2;
import { GITBOOK_ICONS_ASSET_VERSION } from './version';
export interface IconsAssetsLocation {
/** Rroot url where the icon assets are served */
@@ -15,6 +15,8 @@ export interface IconsAssetsLocation {
export type IconsContextType = Partial<IconsAssetsLocation> & {
/** Assets location for special styles */
assetsByStyles?: Record<string, IconsAssetsLocation>;
/** Server-resolved SVG sources keyed by style and icon. */
iconSources?: Record<string, InlineIconSource>;
/** Current default style for icons */
iconStyle: IconStyle;
};
@@ -34,10 +36,20 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
assetsURLToken = parent.assetsURLToken,
iconStyle = parent.iconStyle,
assetsByStyles = parent.assetsByStyles,
iconSources = parent.iconSources,
} = props;
const value = React.useMemo(() => {
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 <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
}
@@ -56,7 +68,7 @@ export function getAssetURL(location: Partial<IconsAssetsLocation>, path: string
if (!location.assetsURL) {
throw new Error('You first need to pass a assetsURL to <IconsProvider>');
}
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);
+3
View File
@@ -1,3 +1,6 @@
export * from './Icon';
export * from './IconSources';
export * from './types';
export * from './IconsProvider';
export * from './getIconStyle';
export * from './version';
+8
View File
@@ -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';
+8 -1
View File
@@ -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,
},
]);