Update method of loading symbols

This commit is contained in:
Brett Jephson
2026-04-30 13:47:16 +01:00
parent 2746ffc000
commit 21053f56fb
10 changed files with 213 additions and 110 deletions
-1
View File
@@ -32,7 +32,6 @@ screenshots/
# Generated public files
/public/~gitbook/static/*
!/public/~gitbook/static/images
/.generated/
# cloudflare
.open-next
+1 -1
View File
@@ -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",
@@ -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 = /<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 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<string, () => Promise<{ default: StyleIconSymbolManifest }>>;
`;
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() {
@@ -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) => {
@@ -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',
},
});
}
@@ -199,7 +199,7 @@ export async function CustomizationRootLayout(props: {
},
}}
renderMode="symbol"
symbolLoaderURL="/~gitbook/icons/symbol"
symbolLoaderURL="/~gitbook/static/icon-symbols"
iconStyle={iconStyle}
>
<RootLayoutClientContexts language={language}>
+109 -23
View File
@@ -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<string, Promise<StyleIconSymbolManifest>>();
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
@@ -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<StyleIconSymbolManifest | null> {
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 `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(viewBox)}" overflow="visible">${markup}</symbol>`;
}
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 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<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> {
const cacheKey = `${style}/${icon}`;
const existing = symbolSources.get(cacheKey);
if (existing) {
return existing;
}
const manifestPromise: Promise<StyleIconSymbolManifest> = 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 = `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(entry.viewBox)}" overflow="visible">${entry.markup}</symbol>`;
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: `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbol}</defs><use href="#${escapeAttribute(symbolId)}"/></svg>`,
document: buildSymbolDocument(symbolId, symbol),
};
}
-6
View File
@@ -1,6 +0,0 @@
export interface StyleIconSymbolManifestEntry {
viewBox: string;
markup: string;
}
export type StyleIconSymbolManifest = Record<string, StyleIconSymbolManifestEntry>;
+1 -1
View File
@@ -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"
>
<>
<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 it from the
* internal symbol route only when the SSR sprite did not already include it.
* 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.
*/
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 {
+1 -1
View File
@@ -20,7 +20,7 @@ export type IconsContextType = Partial<IconsAssetsLocation> & {
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;
};