diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore index f8c296278..9dc62697e 100644 --- a/packages/gitbook/.gitignore +++ b/packages/gitbook/.gitignore @@ -32,7 +32,6 @@ screenshots/ # Generated public files /public/~gitbook/static/* !/public/~gitbook/static/images -/.generated/ # cloudflare .open-next diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 908826188..56840a2cb 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -120,7 +120,7 @@ "scripts": { "generate:icon-symbols": "node ./scripts/generate-icon-symbols.js", "generate": "./scripts/generate.sh", - "clean": "rm -rf ./.next && rm -rf ./.generated && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math", + "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", "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 index ed1c7b250..41073cf94 100644 --- a/packages/gitbook/scripts/generate-icon-symbols.js +++ b/packages/gitbook/scripts/generate-icon-symbols.js @@ -6,26 +6,44 @@ 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, '../.generated/icon-symbols'); -const loadersModulePath = path.resolve(__dirname, '../.generated/icon-symbol-loaders.ts'); +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 generateLoadersModule(styles) { - const entries = styles - .map((style) => { - return ` ${JSON.stringify(style)}: () => - import('./icon-symbols/${style}.json', { with: { type: 'json' } }),`; - }) - .join('\n'); +function sanitizeSymbolFragment(fragment) { + return fragment.replace(/[^a-zA-Z0-9_-]/g, '-'); +} - return `import type { StyleIconSymbolManifest } from '../src/lib/icons/types'; +function getSymbolId(style, icon) { + return `gb-icon-${sanitizeSymbolFragment(style)}-${sanitizeSymbolFragment(icon)}`; +} -export const loaders = { -${entries} -} satisfies Record Promise<{ default: StyleIconSymbolManifest }>>; -`; +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() { @@ -35,41 +53,79 @@ async function main() { 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(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]; - } + if (!existsSync(stylePath)) { + if (!existsSync(spritePath)) { + throw new Error( + `Missing SVG directory and sprite file for "${style}": ${stylePath}, ${spritePath}` + ); } } - await fs.writeFile( - path.join(outputDirectory, `${style}.json`), - JSON.stringify(entries), - 'utf8' + 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' + ); + }); + }) ); }) ); - await fs.writeFile(loadersModulePath, generateLoadersModule(supportedStyles), 'utf8'); - - // biome-ignore lint/suspicious/noConsole: CLI output is useful when regenerating manifests. - console.log(`Generated ${supportedStyles.length} icon symbol manifests in ${outputDirectory}`); + // 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) => { 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 deleted file mode 100644 index 39c0e2088..000000000 --- a/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -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.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 22583c067..ca2a96e80 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/icons/symbol" + symbolLoaderURL="/~gitbook/static/icon-symbols" iconStyle={iconStyle} > diff --git a/packages/gitbook/src/lib/icons/symbols.ts b/packages/gitbook/src/lib/icons/symbols.ts index af514b730..b2c9fe0e2 100644 --- a/packages/gitbook/src/lib/icons/symbols.ts +++ b/packages/gitbook/src/lib/icons/symbols.ts @@ -1,11 +1,17 @@ import 'server-only'; -import { loaders } from '../../../.generated/icon-symbol-loaders'; -import type { StyleIconSymbolManifest } from './types'; +import { getAssetURL } from '@/lib/assets'; +import { GITBOOK_RUNTIME } from '@/lib/env'; +import { joinPath } from '@/lib/paths'; -type SupportedSymbolStyle = keyof typeof loaders; +interface IconSymbolSource { + viewBox: string; + markup: string; +} -const manifestPromises = new Map>(); +const symbolSources = new Map>(); +const symbolPattern = /]*)>([\s\S]*?)<\/symbol>/i; +const viewBoxPattern = /\bviewBox="([^"]+)"/i; function escapeAttribute(value: string): string { return value @@ -16,47 +22,127 @@ function escapeAttribute(value: string): string { } /** - * Load and memoize the generated symbol manifest for a Font Awesome style. + * Build the path to a generated static symbol document. */ -export async function getIconStyleManifest(style: string): Promise { - const load = loaders[style as SupportedSymbolStyle]; - if (!load) { +function getStaticSymbolPath(style: string, icon: string): string { + return joinPath('icon-symbols', style, `${icon}.svg`); +} + +function buildSymbolMarkup(symbolId: string, viewBox: string, markup: string) { + return `${markup}`; +} + +function buildSymbolDocument(symbolId: string, symbol: string) { + return `${symbol}`; +} + +function parseSymbolDocument(document: string): IconSymbolSource | null { + const symbolMatch = document.match(symbolPattern); + if (!symbolMatch) { return null; } - const existing = manifestPromises.get(style); + 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 { + const cacheKey = `${style}/${icon}`; + const existing = symbolSources.get(cacheKey); if (existing) { return existing; } - const manifestPromise: Promise = load().then( - (module) => module.default as StyleIconSymbolManifest - ); - manifestPromises.set(style, manifestPromise); + const sourcePromise = (async () => { + const document = + (GITBOOK_RUNTIME === 'cloudflare' + ? null + : await readLocalSymbolDocument(style, icon)) ?? + (await fetchSymbolDocument(style, icon)); + if (!document) { + return null; + } - return manifestPromise; + return parseSymbolDocument(document); + })(); + symbolSources.set(cacheKey, sourcePromise); + + return sourcePromise; } /** - * Resolve one icon entry from the generated manifest and serialize it for sprite injection or - * lazy-loading through the symbol route. + * Resolve one icon entry from the generated static symbol assets and serialize it for sprite + * injection or lazy-loading. */ export async function getIconSymbol(style: string, icon: string, symbolId: string) { - const manifest = await getIconStyleManifest(style); - const entry = manifest?.[icon]; - if (!entry) { + const source = await loadSymbolSource(style, icon); + if (!source) { return null; } - const symbol = `${entry.markup}`; + const symbol = buildSymbolMarkup(symbolId, source.viewBox, source.markup); return { style, icon, symbolId, - viewBox: entry.viewBox, - markup: entry.markup, + viewBox: source.viewBox, + markup: source.markup, symbol, - document: `${symbol}`, + document: buildSymbolDocument(symbolId, symbol), }; } diff --git a/packages/gitbook/src/lib/icons/types.ts b/packages/gitbook/src/lib/icons/types.ts deleted file mode 100644 index de220e0a1..000000000 --- a/packages/gitbook/src/lib/icons/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface StyleIconSymbolManifestEntry { - viewBox: string; - markup: string; -} - -export type StyleIconSymbolManifest = Record; diff --git a/packages/icons/src/Icon.test.tsx b/packages/icons/src/Icon.test.tsx index a91acb288..5bff7da8d 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/icons/symbol" + symbolLoaderURL="/~gitbook/static/icon-symbols" > <> diff --git a/packages/icons/src/IconSymbolLoader.tsx b/packages/icons/src/IconSymbolLoader.tsx index c1c8720c3..1fe139c27 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 it from the - * internal symbol route only when the SSR sprite did not already include it. + * 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. */ 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)}`; + return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}.svg`; } function appendSymbolsFromDocument(markup: string): boolean { diff --git a/packages/icons/src/IconsProvider.tsx b/packages/icons/src/IconsProvider.tsx index 69cc1c8cc..b25081b79 100644 --- a/packages/icons/src/IconsProvider.tsx +++ b/packages/icons/src/IconsProvider.tsx @@ -20,7 +20,7 @@ export type IconsContextType = Partial & { iconStyle: IconStyle; /** Rendering strategy for icons */ renderMode: IconRenderMode; - /** Internal route used to lazily load symbols introduced after hydration */ + /** Base URL used to lazily load prebuilt symbol documents introduced after hydration */ symbolLoaderURL?: string; };