diff --git a/.changeset/olive-bats-yell.md b/.changeset/olive-bats-yell.md new file mode 100644 index 0000000000..df2ac7e9aa --- /dev/null +++ b/.changeset/olive-bats-yell.md @@ -0,0 +1,6 @@ +--- +"@gitbook/icons": minor +"gitbook": patch +--- + +Update icon usage to render svg symbols rather than file. diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore index f7a6b34753..9dc62697e7 100644 --- a/packages/gitbook/.gitignore +++ b/packages/gitbook/.gitignore @@ -36,4 +36,4 @@ screenshots/ # cloudflare .open-next .wrangler -worker-configuration.d.ts \ No newline at end of file +worker-configuration.d.ts diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index 1248d53a65..6a16a975b0 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -146,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [ }, ]; +type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string }; +type IconStateWindow = Window & { + __ICONS_STATES__?: Record; +}; + 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 { - 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 = iconWindow.__ICONS_STATES__ || {}; + iconWindow.__ICONS_STATES__ = urlStates; const fetchSvgAsDataUri = async (url: string): Promise => { 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') { diff --git a/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts b/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts new file mode 100644 index 0000000000..f008cc42a5 --- /dev/null +++ b/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts @@ -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', + }, + }); +} diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index cbb0a71b71..22583c0675 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -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} > {children} + diff --git a/packages/gitbook/src/components/RootLayout/IconSpriteDefinitions.tsx b/packages/gitbook/src/components/RootLayout/IconSpriteDefinitions.tsx new file mode 100644 index 0000000000..1da2e5fb9c --- /dev/null +++ b/packages/gitbook/src/components/RootLayout/IconSpriteDefinitions.tsx @@ -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 = 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 => !!symbol); + + clearRegisteredServerIconSymbols(); + + if (!definitions.length) { + return null; + } + + return ( +