From 7cc6090e03c6c007fcf3393bdddac7984e868203 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Wed, 29 Apr 2026 16:55:56 +0100 Subject: [PATCH 01/16] load icons as symbols --- packages/gitbook/.gitignore | 3 +- packages/gitbook/package.json | 3 +- .../gitbook/scripts/generate-icon-symbols.js | 73 ++++++++++ packages/gitbook/scripts/generate.sh | 1 + .../icons/symbol/[style]/[icon]/route.ts | 32 +++++ .../RootLayout/CustomizationRootLayout.tsx | 13 +- .../RootLayout/IconSpriteDefinitions.tsx | 56 ++++++++ .../src/components/SiteLayout/SiteLayout.tsx | 3 +- .../gitbook/src/lib/icons/symbols.test.tsx | 110 +++++++++++++++ packages/gitbook/src/lib/icons/symbols.ts | 81 +++++++++++ packages/gitbook/src/lib/icons/types.ts | 6 + packages/icons/src/Icon.test.tsx | 63 +++++++++ packages/icons/src/Icon.tsx | 42 +++++- packages/icons/src/IconSymbolLoader.tsx | 129 ++++++++++++++++++ packages/icons/src/IconsProvider.tsx | 23 +++- packages/icons/src/index.ts | 2 + packages/icons/src/symbols.ts | 53 +++++++ packages/icons/tsconfig.json | 2 +- turbo.json | 2 +- 19 files changed, 681 insertions(+), 16 deletions(-) create mode 100644 packages/gitbook/scripts/generate-icon-symbols.js create mode 100644 packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts create mode 100644 packages/gitbook/src/components/RootLayout/IconSpriteDefinitions.tsx create mode 100644 packages/gitbook/src/lib/icons/symbols.test.tsx create mode 100644 packages/gitbook/src/lib/icons/symbols.ts create mode 100644 packages/gitbook/src/lib/icons/types.ts create mode 100644 packages/icons/src/Icon.test.tsx create mode 100644 packages/icons/src/IconSymbolLoader.tsx create mode 100644 packages/icons/src/symbols.ts diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore index f7a6b34753..f8c2962787 100644 --- a/packages/gitbook/.gitignore +++ b/packages/gitbook/.gitignore @@ -32,8 +32,9 @@ screenshots/ # Generated public files /public/~gitbook/static/* !/public/~gitbook/static/images +/.generated/ # cloudflare .open-next .wrangler -worker-configuration.d.ts \ No newline at end of file +worker-configuration.d.ts diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index f67cb7c6de..9088261887 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -118,8 +118,9 @@ "rss-parser": "^3.13.0" }, "scripts": { + "generate:icon-symbols": "node ./scripts/generate-icon-symbols.js", "generate": "./scripts/generate.sh", - "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math", + "clean": "rm -rf ./.next && rm -rf ./.generated && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math", "dev": "env-cmd --silent -f ../../.env.local next --webpack", "build": "next build --webpack", "build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack", diff --git a/packages/gitbook/scripts/generate-icon-symbols.js b/packages/gitbook/scripts/generate-icon-symbols.js new file mode 100644 index 0000000000..fb5127d955 --- /dev/null +++ b/packages/gitbook/scripts/generate-icon-symbols.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +const { existsSync } = require('node:fs'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const packageJSONPath = require.resolve('@gitbook/fontawesome-pro/package.json'); +const packageRoot = path.dirname(packageJSONPath); +const outputDirectory = path.resolve(__dirname, '../.generated/icon-symbols'); +const metadataPath = path.join(packageRoot, 'icons', 'metadata', 'icons.json'); + +const supportedStyles = [ + 'brands', + 'custom-icons', + 'duotone', + 'light', + 'regular', + 'sharp-duotone-solid', + 'sharp-light', + 'sharp-regular', + 'sharp-solid', + 'sharp-thin', + 'solid', + 'thin', +]; + +const symbolPattern = /([\s\S]*?)<\/symbol>/g; + +async function main() { + await fs.rm(outputDirectory, { recursive: true, force: true }); + await fs.mkdir(outputDirectory, { recursive: true }); + const iconMetadata = JSON.parse(await fs.readFile(metadataPath, 'utf8')); + + await Promise.all( + supportedStyles.map(async (style) => { + const spritePath = path.join(packageRoot, 'icons', 'sprites', `${style}.svg`); + if (!existsSync(spritePath)) { + throw new Error(`Missing sprite file for "${style}": ${spritePath}`); + } + + const sprite = await fs.readFile(spritePath, 'utf8'); + const entries = {}; + + for (const match of sprite.matchAll(symbolPattern)) { + const [, icon, viewBox, markup] = match; + entries[icon] = { + viewBox, + markup, + }; + + const aliases = iconMetadata[icon]?.aliases?.names ?? []; + for (const alias of aliases) { + if (!entries[alias]) { + entries[alias] = entries[icon]; + } + } + } + + await fs.writeFile( + path.join(outputDirectory, `${style}.json`), + JSON.stringify(entries), + 'utf8' + ); + }) + ); + + // biome-ignore lint/suspicious/noConsole: CLI output is useful when regenerating manifests. + console.log(`Generated ${supportedStyles.length} icon symbol manifests in ${outputDirectory}`); +} + +main().catch((error) => { + console.error(`Error generating icon symbols: ${error}`); + process.exit(1); +}); diff --git a/packages/gitbook/scripts/generate.sh b/packages/gitbook/scripts/generate.sh index a1cfab43bd..28e9aaf830 100755 --- a/packages/gitbook/scripts/generate.sh +++ b/packages/gitbook/scripts/generate.sh @@ -5,6 +5,7 @@ set -o pipefail # Copy the assets gitbook-icons ./public/~gitbook/static/icons custom-icons +bun run generate:icon-symbols gitbook-math ./public/~gitbook/static/math cp -r ../embed/standalone/ ./public/~gitbook/static/embed 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..4828323010 --- /dev/null +++ b/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts @@ -0,0 +1,32 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { getIconSymbol } from '@/lib/icons/symbols'; + +function getSymbolId(style: string, icon: string) { + const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '-'); + return `gb-icon-${sanitize(style)}-${sanitize(icon)}`; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ style: string; icon: string }> } +) { + const { style, icon } = await params; + const symbol = await getIconSymbol(style, icon, getSymbolId(style, icon)); + + if (!symbol) { + return NextResponse.json( + { + error: 'Symbol not found', + }, + { status: 404 } + ); + } + + return new NextResponse(symbol.symbol, { + 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 ( +