mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
use route to transform svg to local symbol
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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('&', '&')
|
||||
@@ -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 `<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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user