use route to transform svg to local symbol

This commit is contained in:
Brett Jephson
2026-04-30 14:57:34 +01:00
parent 21053f56fb
commit e7f9bc2565
9 changed files with 106 additions and 248 deletions
-1
View File
@@ -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",
@@ -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 = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
const symbolPattern = /<symbol id="([^"]+)" viewBox="([^"]+)">([\s\S]*?)<\/symbol>/g;
const viewBoxPattern = /\bviewBox="([^"]+)"/i;
const commentPattern = /<!--[\s\S]*?-->/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 `<svg xmlns="http://www.w3.org/2000/svg"><defs><symbol id="${symbolId}" viewBox="${source.viewBox}" overflow="visible">${source.markup}</symbol></defs><use href="#${symbolId}"/></svg>`;
}
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);
});
-1
View File
@@ -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
@@ -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',
},
});
}
@@ -199,7 +199,7 @@ export async function CustomizationRootLayout(props: {
},
}}
renderMode="symbol"
symbolLoaderURL="/~gitbook/static/icon-symbols"
symbolLoaderURL="/~gitbook/icons/symbol"
iconStyle={iconStyle}
>
<RootLayoutClientContexts language={language}>
@@ -1,14 +0,0 @@
[
"brands",
"custom-icons",
"duotone",
"light",
"regular",
"sharp-duotone-solid",
"sharp-light",
"sharp-regular",
"sharp-solid",
"sharp-thin",
"solid",
"thin"
]
+73 -93
View File
@@ -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<string, Promise<string | null>>();
const svgPattern = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
const viewBoxPattern = /\bviewBox="([^"]+)"/i;
const commentPattern = /<!--[\s\S]*?-->/g;
interface IconSymbolSource {
viewBox: string;
markup: string;
}
const symbolSources = new Map<string, Promise<IconSymbolSource | null>>();
const symbolPattern = /<symbol\b([^>]*)>([\s\S]*?)<\/symbol>/i;
const viewBoxPattern = /\bviewBox="([^"]+)"/i;
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
@@ -21,11 +23,49 @@ function escapeAttribute(value: string): string {
.replaceAll('>', '&gt;');
}
/**
* 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 `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbol}</defs><use href="#${escapeAttribute(symbolId)}"/></svg>`;
}
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<string | null> {
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<string | null> {
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<IconSymbolSource | null> {
async function fetchRawSVG(style: string, icon: string): Promise<string | null> {
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;
}
+1 -1
View File
@@ -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"
>
<>
<Icon icon="github" />
+3 -3
View File
@@ -6,8 +6,8 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const pendingSymbolLoads = new Map<string, Promise<boolean>>();
/**
* Ensure a symbol referenced by an inline `<use>` 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 `<use>` 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 {