diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 56840a2cb..b1d1f5fb8 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -118,7 +118,6 @@ "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/icon-symbols && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math", "dev": "env-cmd --silent -f ../../.env.local next --webpack", diff --git a/packages/gitbook/scripts/generate-icon-symbols.js b/packages/gitbook/scripts/generate-icon-symbols.js deleted file mode 100644 index 41073cf94..000000000 --- a/packages/gitbook/scripts/generate-icon-symbols.js +++ /dev/null @@ -1,134 +0,0 @@ -#!/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 supportedStyles = require('../src/lib/icons/supported-styles.json'); -const outputDirectory = path.resolve(__dirname, '../public/~gitbook/static/icon-symbols'); -const metadataPath = path.join(packageRoot, 'icons', 'metadata', 'icons.json'); - -const svgPattern = /]*)>([\s\S]*?)<\/svg>\s*$/i; -const symbolPattern = /([\s\S]*?)<\/symbol>/g; -const viewBoxPattern = /\bviewBox="([^"]+)"/i; -const commentPattern = //g; - -function sanitizeSymbolFragment(fragment) { - return fragment.replace(/[^a-zA-Z0-9_-]/g, '-'); -} - -function getSymbolId(style, icon) { - return `gb-icon-${sanitizeSymbolFragment(style)}-${sanitizeSymbolFragment(icon)}`; -} - -function buildSymbolDocumentFromSource(style, icon, source) { - const symbolId = getSymbolId(style, icon); - - return `${source.markup}`; -} - -function getSymbolSourceFromSVG(svgMarkup, style, icon) { - const svgMatch = svgMarkup.match(svgPattern); - if (!svgMatch) { - throw new Error(`Invalid SVG source for "${style}/${icon}"`); - } - - const [, svgAttributes, innerMarkup] = svgMatch; - const viewBoxMatch = svgAttributes.match(viewBoxPattern); - if (!viewBoxMatch) { - throw new Error(`Missing viewBox for "${style}/${icon}"`); - } - - return { - viewBox: viewBoxMatch[1], - markup: innerMarkup.replace(commentPattern, '').trim(), - }; -} - -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 stylePath = path.join(packageRoot, 'icons', 'svgs', style); - const spritePath = path.join(packageRoot, 'icons', 'sprites', `${style}.svg`); - if (!existsSync(stylePath)) { - if (!existsSync(spritePath)) { - throw new Error( - `Missing SVG directory and sprite file for "${style}": ${stylePath}, ${spritePath}` - ); - } - } - - const styleOutputDirectory = path.join(outputDirectory, style); - await fs.mkdir(styleOutputDirectory, { recursive: true }); - const generatedIcons = new Map(); - - if (existsSync(stylePath)) { - const sourceFiles = await fs.readdir(stylePath, { withFileTypes: true }); - await Promise.all( - sourceFiles - .filter((entry) => entry.isFile() && entry.name.endsWith('.svg')) - .map(async (entry) => { - const sourcePath = path.join(stylePath, entry.name); - const icon = path.basename(entry.name, '.svg'); - const svgMarkup = await fs.readFile(sourcePath, 'utf8'); - const symbolSource = getSymbolSourceFromSVG(svgMarkup, style, icon); - - generatedIcons.set(icon, symbolSource); - await fs.writeFile( - path.join(styleOutputDirectory, entry.name), - buildSymbolDocumentFromSource(style, icon, symbolSource), - 'utf8' - ); - }) - ); - } else { - const sprite = await fs.readFile(spritePath, 'utf8'); - await Promise.all( - [...sprite.matchAll(symbolPattern)].map(async (match) => { - const [, icon, viewBox, markup] = match; - const symbolSource = { - viewBox, - markup: markup.replace(commentPattern, '').trim(), - }; - - generatedIcons.set(icon, symbolSource); - await fs.writeFile( - path.join(styleOutputDirectory, `${icon}.svg`), - buildSymbolDocumentFromSource(style, icon, symbolSource), - 'utf8' - ); - }) - ); - } - - await Promise.all( - [...generatedIcons.entries()].flatMap(([icon, symbolSource]) => { - const aliases = iconMetadata[icon]?.aliases?.names ?? []; - return aliases - .filter((alias) => !generatedIcons.has(alias)) - .map(async (alias) => { - generatedIcons.set(alias, symbolSource); - await fs.writeFile( - path.join(styleOutputDirectory, `${alias}.svg`), - buildSymbolDocumentFromSource(style, alias, symbolSource), - 'utf8' - ); - }); - }) - ); - }) - ); - - // biome-ignore lint/suspicious/noConsole: CLI output is useful when regenerating assets. - console.log(`Generated symbol SVGs for ${supportedStyles.length} styles 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 28e9aaf83..a1cfab43b 100755 --- a/packages/gitbook/scripts/generate.sh +++ b/packages/gitbook/scripts/generate.sh @@ -5,7 +5,6 @@ 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 000000000..f008cc42a --- /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 ca2a96e80..22583c067 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -199,7 +199,7 @@ export async function CustomizationRootLayout(props: { }, }} renderMode="symbol" - symbolLoaderURL="/~gitbook/static/icon-symbols" + symbolLoaderURL="/~gitbook/icons/symbol" iconStyle={iconStyle} > diff --git a/packages/gitbook/src/lib/icons/supported-styles.json b/packages/gitbook/src/lib/icons/supported-styles.json deleted file mode 100644 index 38879ea4f..000000000 --- a/packages/gitbook/src/lib/icons/supported-styles.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - "brands", - "custom-icons", - "duotone", - "light", - "regular", - "sharp-duotone-solid", - "sharp-light", - "sharp-regular", - "sharp-solid", - "sharp-thin", - "solid", - "thin" -] diff --git a/packages/gitbook/src/lib/icons/symbols.ts b/packages/gitbook/src/lib/icons/symbols.ts index b2c9fe0e2..07586b7be 100644 --- a/packages/gitbook/src/lib/icons/symbols.ts +++ b/packages/gitbook/src/lib/icons/symbols.ts @@ -1,18 +1,20 @@ import 'server-only'; import { getAssetURL } from '@/lib/assets'; -import { GITBOOK_RUNTIME } from '@/lib/env'; -import { joinPath } from '@/lib/paths'; +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>(); +const svgPattern = /]*)>([\s\S]*?)<\/svg>\s*$/i; +const viewBoxPattern = /\bviewBox="([^"]+)"/i; +const commentPattern = //g; interface IconSymbolSource { viewBox: string; markup: string; } -const symbolSources = new Map>(); -const symbolPattern = /]*)>([\s\S]*?)<\/symbol>/i; -const viewBoxPattern = /\bviewBox="([^"]+)"/i; - function escapeAttribute(value: string): string { return value .replaceAll('&', '&') @@ -21,11 +23,49 @@ function escapeAttribute(value: string): string { .replaceAll('>', '>'); } -/** - * Build the path to a generated static symbol document. - */ -function getStaticSymbolPath(style: string, icon: string): string { - return joinPath('icon-symbols', style, `${icon}.svg`); +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 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 buildSymbolMarkup(symbolId: string, viewBox: string, markup: string) { @@ -36,100 +76,40 @@ function buildSymbolDocument(symbolId: string, symbol: string) { return `${symbol}`; } -function parseSymbolDocument(document: string): IconSymbolSource | null { - const symbolMatch = document.match(symbolPattern); - if (!symbolMatch) { - return null; - } - - const symbolAttributes = symbolMatch[1]; - const rawMarkup = symbolMatch[2]; - if (!symbolAttributes || rawMarkup === undefined) { - return null; - } - - const viewBoxMatch = symbolAttributes.match(viewBoxPattern); - if (!viewBoxMatch) { - return null; - } - const viewBox = viewBoxMatch[1]; - if (!viewBox) { - return null; - } - - return { - viewBox, - markup: rawMarkup.trim(), - }; -} - -async function readLocalSymbolDocument(style: string, icon: string): Promise { - const [{ readFile }, path] = await Promise.all([ - import('node:fs/promises'), - import('node:path'), - ]); - - try { - return await readFile( - path.resolve( - process.cwd(), - 'public', - '~gitbook', - 'static', - 'icon-symbols', - style, - `${icon}.svg` - ), - 'utf8' - ); - } catch { - return null; - } -} - -async function fetchSymbolDocument(style: string, icon: string): Promise { - try { - const response = await fetch(getAssetURL(getStaticSymbolPath(style, icon))); - if (!response.ok) { - return null; - } - - return response.text(); - } catch { - return null; - } -} - -async function loadSymbolSource(style: string, icon: string): Promise { +async function fetchRawSVG(style: string, icon: string): Promise { const cacheKey = `${style}/${icon}`; - const existing = symbolSources.get(cacheKey); + const existing = rawSvgPromises.get(cacheKey); if (existing) { return existing; } - const sourcePromise = (async () => { - const document = - (GITBOOK_RUNTIME === 'cloudflare' - ? null - : await readLocalSymbolDocument(style, icon)) ?? - (await fetchSymbolDocument(style, icon)); - if (!document) { - return null; - } + const request = fetch(getIconAssetURL(style, icon), { + cache: 'force-cache', + }) + .then(async (response) => { + if (!response.ok) { + return null; + } - return parseSymbolDocument(document); - })(); - symbolSources.set(cacheKey, sourcePromise); + return response.text(); + }) + .catch(() => null); - return sourcePromise; + rawSvgPromises.set(cacheKey, request); + return request; } /** - * Resolve one icon entry from the generated static symbol assets and serialize it for sprite - * injection or lazy-loading. + * 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 source = await loadSymbolSource(style, icon); + const rawSVG = await fetchRawSVG(style, icon); + if (!rawSVG) { + return null; + } + + const source = parseRawSVG(rawSVG); if (!source) { return null; } diff --git a/packages/icons/src/Icon.test.tsx b/packages/icons/src/Icon.test.tsx index 5bff7da8d..a91acb288 100644 --- a/packages/icons/src/Icon.test.tsx +++ b/packages/icons/src/Icon.test.tsx @@ -40,7 +40,7 @@ describe('Icon', () => { assetsURL="https://icons.example.test" iconStyle={IconStyle.Regular} renderMode="symbol" - symbolLoaderURL="/~gitbook/static/icon-symbols" + symbolLoaderURL="/~gitbook/icons/symbol" > <> diff --git a/packages/icons/src/IconSymbolLoader.tsx b/packages/icons/src/IconSymbolLoader.tsx index 1fe139c27..71993de11 100644 --- a/packages/icons/src/IconSymbolLoader.tsx +++ b/packages/icons/src/IconSymbolLoader.tsx @@ -6,8 +6,8 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const pendingSymbolLoads = new Map>(); /** - * Ensure a symbol referenced by an inline `` exists after hydration, fetching a static - * symbol document only when the SSR sprite did not already include it. + * Ensure a symbol referenced by an inline `` 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; @@ -78,7 +78,7 @@ function hasSymbol(symbolId: string): boolean { function buildSymbolURL(loaderURL: string, style: string, icon: string): string { const normalizedLoaderURL = loaderURL.endsWith('/') ? loaderURL.slice(0, -1) : loaderURL; - return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}.svg`; + return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}`; } function appendSymbolsFromDocument(markup: string): boolean {