mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Update method of loading symbols
This commit is contained in:
@@ -32,7 +32,6 @@ screenshots/
|
||||
# Generated public files
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
/.generated/
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface StyleIconSymbolManifestEntry {
|
||||
viewBox: string;
|
||||
markup: string;
|
||||
}
|
||||
|
||||
export type StyleIconSymbolManifest = Record<string, StyleIconSymbolManifestEntry>;
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user