mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 02:23:30 +00:00
load icons as symbols
This commit is contained in:
@@ -32,8 +32,9 @@ screenshots/
|
||||
# Generated public files
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
/.generated/
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
worker-configuration.d.ts
|
||||
|
||||
@@ -118,8 +118,9 @@
|
||||
"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/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"clean": "rm -rf ./.next && rm -rf ./.generated && 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",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/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 outputDirectory = path.resolve(__dirname, '../.generated/icon-symbols');
|
||||
const metadataPath = path.join(packageRoot, 'icons', 'metadata', 'icons.json');
|
||||
|
||||
const supportedStyles = [
|
||||
'brands',
|
||||
'custom-icons',
|
||||
'duotone',
|
||||
'light',
|
||||
'regular',
|
||||
'sharp-duotone-solid',
|
||||
'sharp-light',
|
||||
'sharp-regular',
|
||||
'sharp-solid',
|
||||
'sharp-thin',
|
||||
'solid',
|
||||
'thin',
|
||||
];
|
||||
|
||||
const symbolPattern = /<symbol id="([^"]+)" viewBox="([^"]+)">([\s\S]*?)<\/symbol>/g;
|
||||
|
||||
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 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(outputDirectory, `${style}.json`),
|
||||
JSON.stringify(entries),
|
||||
'utf8'
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: CLI output is useful when regenerating manifests.
|
||||
console.log(`Generated ${supportedStyles.length} icon symbol manifests in ${outputDirectory}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Error generating icon symbols: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ 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,32 @@
|
||||
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.symbol, {
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml; charset=utf-8',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { getContentLocale, getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { IconSpriteDefinitions } from './IconSpriteDefinitions';
|
||||
import { RootLayoutClientContexts } from './RootLayoutClientContexts';
|
||||
|
||||
import './globals.css';
|
||||
@@ -76,6 +77,9 @@ export async function CustomizationRootLayout(props: {
|
||||
const sidebarStyles = getSidebarStyles(customization);
|
||||
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
|
||||
const fontData = getFontData(customization.styling.font, 'content');
|
||||
const iconStyle =
|
||||
('icons' in customization.styling ? apiToIconsStyles[customization.styling.icons] : null) ||
|
||||
IconStyle.Regular;
|
||||
// Temporarily add a if here while the cache is being warmed up.
|
||||
// We can remove the condition after 14-07-2025.
|
||||
const monospaceFontData = customization.styling.monospaceFont
|
||||
@@ -194,15 +198,14 @@ export async function CustomizationRootLayout(props: {
|
||||
assetsURL: getAssetURL('icons'),
|
||||
},
|
||||
}}
|
||||
iconStyle={
|
||||
('icons' in customization.styling
|
||||
? apiToIconsStyles[customization.styling.icons]
|
||||
: null) || IconStyle.Regular
|
||||
}
|
||||
renderMode="symbol"
|
||||
symbolLoaderURL="/~gitbook/icons/symbol"
|
||||
iconStyle={iconStyle}
|
||||
>
|
||||
<RootLayoutClientContexts language={language}>
|
||||
{children}
|
||||
</RootLayoutClientContexts>
|
||||
<IconSpriteDefinitions />
|
||||
</IconsProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getIconSymbol } from '@/lib/icons/symbols';
|
||||
import {
|
||||
type RegisteredIconSymbol,
|
||||
clearRegisteredServerIconSymbols,
|
||||
getRegisteredServerIconSymbols,
|
||||
} from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Emits the subset of icon symbols that were rendered during the current request.
|
||||
*/
|
||||
export async function IconSpriteDefinitions() {
|
||||
const registered: Map<string, RegisteredIconSymbol> = new Map(
|
||||
getRegisteredServerIconSymbols().map((symbol: RegisteredIconSymbol) => [
|
||||
`${symbol.style}/${symbol.icon}`,
|
||||
symbol,
|
||||
])
|
||||
);
|
||||
|
||||
if (registered.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definitions = (
|
||||
await Promise.all(
|
||||
[...registered.values()].map((symbol) => {
|
||||
return getIconSymbol(symbol.style, symbol.icon, symbol.symbolId);
|
||||
})
|
||||
)
|
||||
).filter((symbol): symbol is NonNullable<typeof symbol> => !!symbol);
|
||||
|
||||
clearRegisteredServerIconSymbols();
|
||||
|
||||
if (!definitions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
id="gb-icon-sprite-root"
|
||||
data-testid="icon-sprite-root"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 0,
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: definitions.map((symbol) => symbol.symbol).join(''),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { LoadIntegrations } from '@/components/Integrations';
|
||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL } from '@/lib/env';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { isSiteIndexable } from '@/lib/seo';
|
||||
import { AIContextProvider } from '../AI';
|
||||
@@ -34,7 +34,6 @@ export async function SiteLayout(props: {
|
||||
const scripts = withTracking ? context.scripts : [];
|
||||
|
||||
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
|
||||
ReactDOM.preconnect(GITBOOK_ICONS_URL);
|
||||
if (GITBOOK_ASSETS_URL) {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { Icon, IconStyle, IconsProvider, clearRegisteredServerIconSymbols } from '@gitbook/icons';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { GET } from '@/app/~gitbook/icons/symbol/[style]/[icon]/route';
|
||||
import { IconSpriteDefinitions } from '@/components/RootLayout/IconSpriteDefinitions';
|
||||
|
||||
import { getIconSymbol } from './symbols';
|
||||
|
||||
afterEach(() => {
|
||||
clearRegisteredServerIconSymbols();
|
||||
});
|
||||
|
||||
describe('icon symbols', () => {
|
||||
it('loads symbol markup for multiple families', async () => {
|
||||
const regular = await getIconSymbol('regular', 'jar', 'gb-icon-regular-jar');
|
||||
const brand = await getIconSymbol('brands', 'github', 'gb-icon-brands-github');
|
||||
const custom = await getIconSymbol(
|
||||
'custom-icons',
|
||||
'gitbook',
|
||||
'gb-icon-custom-icons-gitbook'
|
||||
);
|
||||
const sharp = await getIconSymbol(
|
||||
'sharp-solid',
|
||||
'download',
|
||||
'gb-icon-sharp-solid-download'
|
||||
);
|
||||
|
||||
expect(regular?.symbol).toContain('id="gb-icon-regular-jar"');
|
||||
expect(regular?.symbol).toContain('viewBox="0 0 320 512"');
|
||||
expect(brand?.symbol).toContain('id="gb-icon-brands-github"');
|
||||
expect(custom?.symbol).toContain('id="gb-icon-custom-icons-gitbook"');
|
||||
expect(sharp?.symbol).toContain('id="gb-icon-sharp-solid-download"');
|
||||
});
|
||||
|
||||
it('emits only the registered subset sprite definitions', async () => {
|
||||
renderToStaticMarkup(
|
||||
<IconsProvider
|
||||
assetsURL="https://icons.example.test"
|
||||
iconStyle={IconStyle.Regular}
|
||||
renderMode="symbol"
|
||||
symbolLoaderURL="/~gitbook/icons/symbol"
|
||||
>
|
||||
<>
|
||||
<Icon icon="jar" iconStyle={IconStyle.Regular} />
|
||||
<Icon icon="jar" iconStyle={IconStyle.Regular} />
|
||||
<Icon icon="github" />
|
||||
<Icon icon="download" iconStyle={IconStyle.SharpSolid} />
|
||||
<Icon icon="gitbook" />
|
||||
</>
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
const sprite = await IconSpriteDefinitions();
|
||||
const html = sprite ? renderToStaticMarkup(sprite) : '';
|
||||
|
||||
expect(html).toContain('data-testid="icon-sprite-root"');
|
||||
expect(html).toContain('id="gb-icon-regular-jar"');
|
||||
expect(html).toContain('id="gb-icon-brands-github"');
|
||||
expect(html).toContain('id="gb-icon-sharp-solid-download"');
|
||||
expect(html).toContain('id="gb-icon-custom-icons-gitbook"');
|
||||
expect(html.match(/id="gb-icon-regular-jar"/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('eagerly seeds search chrome icons into the sprite', async () => {
|
||||
const sprite = await IconSpriteDefinitions();
|
||||
const html = sprite ? renderToStaticMarkup(sprite) : '';
|
||||
|
||||
expect(html).toContain('id="gb-icon-regular-search"');
|
||||
expect(html).toContain('id="gb-icon-regular-chevron-right"');
|
||||
expect(html).toContain('id="gb-icon-regular-arrow-turn-down-left"');
|
||||
expect(html).toContain('id="gb-icon-regular-xmark"');
|
||||
});
|
||||
|
||||
it('serves symbols from the internal route', async () => {
|
||||
const response = await GET(
|
||||
new Request(
|
||||
'http://localhost/~gitbook/icons/symbol/brands/github'
|
||||
) as unknown as NextRequest,
|
||||
{
|
||||
params: Promise.resolve({
|
||||
style: 'brands',
|
||||
icon: 'github',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('image/svg+xml; charset=utf-8');
|
||||
expect(await response.text()).toContain('id="gb-icon-brands-github"');
|
||||
});
|
||||
|
||||
it('serves alias-based symbols from the internal route', async () => {
|
||||
const response = await GET(
|
||||
new Request(
|
||||
'http://localhost/~gitbook/icons/symbol/regular/search'
|
||||
) as unknown as NextRequest,
|
||||
{
|
||||
params: Promise.resolve({
|
||||
style: 'regular',
|
||||
icon: 'search',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain('id="gb-icon-regular-search"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { StyleIconSymbolManifest } from './types';
|
||||
|
||||
const loaders = {
|
||||
brands: () =>
|
||||
import('../../../.generated/icon-symbols/brands.json', { with: { type: 'json' } }),
|
||||
'custom-icons': () =>
|
||||
import('../../../.generated/icon-symbols/custom-icons.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
duotone: () =>
|
||||
import('../../../.generated/icon-symbols/duotone.json', { with: { type: 'json' } }),
|
||||
light: () => import('../../../.generated/icon-symbols/light.json', { with: { type: 'json' } }),
|
||||
regular: () =>
|
||||
import('../../../.generated/icon-symbols/regular.json', { with: { type: 'json' } }),
|
||||
'sharp-duotone-solid': () =>
|
||||
import('../../../.generated/icon-symbols/sharp-duotone-solid.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
'sharp-light': () =>
|
||||
import('../../../.generated/icon-symbols/sharp-light.json', { with: { type: 'json' } }),
|
||||
'sharp-regular': () =>
|
||||
import('../../../.generated/icon-symbols/sharp-regular.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
'sharp-solid': () =>
|
||||
import('../../../.generated/icon-symbols/sharp-solid.json', { with: { type: 'json' } }),
|
||||
'sharp-thin': () =>
|
||||
import('../../../.generated/icon-symbols/sharp-thin.json', { with: { type: 'json' } }),
|
||||
solid: () => import('../../../.generated/icon-symbols/solid.json', { with: { type: 'json' } }),
|
||||
thin: () => import('../../../.generated/icon-symbols/thin.json', { with: { type: 'json' } }),
|
||||
} satisfies Record<string, () => Promise<{ default: StyleIconSymbolManifest }>>;
|
||||
|
||||
type SupportedSymbolStyle = keyof typeof loaders;
|
||||
|
||||
const manifestPromises = new Map<string, Promise<StyleIconSymbolManifest>>();
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
export async function getIconStyleManifest(style: string): Promise<StyleIconSymbolManifest | null> {
|
||||
const load = loaders[style as SupportedSymbolStyle];
|
||||
if (!load) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = manifestPromises.get(style);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const manifestPromise: Promise<StyleIconSymbolManifest> = load().then(
|
||||
(module) => module.default
|
||||
);
|
||||
manifestPromises.set(style, manifestPromise);
|
||||
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
export async function getIconSymbol(style: string, icon: string, symbolId: string) {
|
||||
const manifest = await getIconStyleManifest(style);
|
||||
const entry = manifest?.[icon];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
style,
|
||||
icon,
|
||||
symbolId,
|
||||
viewBox: entry.viewBox,
|
||||
markup: entry.markup,
|
||||
symbol: `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(entry.viewBox)}" overflow="visible">${entry.markup}</symbol>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface StyleIconSymbolManifestEntry {
|
||||
viewBox: string;
|
||||
markup: string;
|
||||
}
|
||||
|
||||
export type StyleIconSymbolManifest = Record<string, StyleIconSymbolManifestEntry>;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { IconsProvider } from './IconsProvider';
|
||||
import { clearRegisteredServerIconSymbols, getRegisteredServerIconSymbols } from './symbols';
|
||||
import { IconStyle } from './types';
|
||||
|
||||
afterEach(() => {
|
||||
clearRegisteredServerIconSymbols();
|
||||
});
|
||||
|
||||
function RegisteredSymbolsSummary() {
|
||||
const symbols = getRegisteredServerIconSymbols();
|
||||
return (
|
||||
<output
|
||||
data-testid="registered-symbols"
|
||||
data-count={symbols.length}
|
||||
data-symbol-ids={symbols.map((symbol) => symbol.symbolId).join(',')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
it('renders the mask-image path when symbol mode is disabled', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider assetsURL="https://icons.example.test" iconStyle={IconStyle.Regular}>
|
||||
<Icon icon="github" className="size-4" />
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="mask-image"');
|
||||
expect(html).toContain('https://icons.example.test/svgs/brands/github.svg?v=2');
|
||||
expect(html).not.toContain('data-testid="symbol-use"');
|
||||
});
|
||||
|
||||
it('renders inline symbol references and deduplicates SSR registrations', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider
|
||||
assetsURL="https://icons.example.test"
|
||||
iconStyle={IconStyle.Regular}
|
||||
renderMode="symbol"
|
||||
symbolLoaderURL="/~gitbook/icons/symbol"
|
||||
>
|
||||
<>
|
||||
<Icon icon="github" />
|
||||
<Icon icon="github" />
|
||||
<Icon icon="gitbook" />
|
||||
<RegisteredSymbolsSummary />
|
||||
</>
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="symbol-use"');
|
||||
expect(html).toContain('href="#gb-icon-brands-github"');
|
||||
expect(html).toContain('href="#gb-icon-custom-icons-gitbook"');
|
||||
expect(html).toContain('data-testid="registered-symbols"');
|
||||
expect(html).toContain('data-count="2"');
|
||||
expect(html).toContain(
|
||||
'data-symbol-ids="gb-icon-brands-github,gb-icon-custom-icons-gitbook"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconSymbolLoader } from './IconSymbolLoader';
|
||||
import { getIconAssetURL, useIcons } from './IconsProvider';
|
||||
import { getIconStyle } from './getIconStyle';
|
||||
import { getIconSymbolId, registerServerIconSymbol } from './symbols';
|
||||
import type { IconName, IconStyle } from './types';
|
||||
|
||||
/**
|
||||
@@ -50,8 +52,44 @@ export const Icon = React.forwardRef(function Icon(
|
||||
} = props;
|
||||
|
||||
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
|
||||
const url = getIconAssetURL(context, iconStyle, icon);
|
||||
const maskId = React.useId();
|
||||
const iconInstanceId = React.useId();
|
||||
const symbolId = getIconSymbolId(context.symbolIdPrefix, iconStyle, icon);
|
||||
|
||||
if (context.renderMode === 'symbol') {
|
||||
registerServerIconSymbol({
|
||||
style: iconStyle,
|
||||
icon,
|
||||
symbolId,
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
{...rest}
|
||||
viewBox="0 0 512 512"
|
||||
data-gb-icon-instance={iconInstanceId}
|
||||
style={{
|
||||
overflow: 'visible',
|
||||
...(size ? { width: size, height: size } : {}),
|
||||
...rest.style,
|
||||
}}
|
||||
className={`gb-icon ${className}`}
|
||||
>
|
||||
<title>{icon}</title>
|
||||
<use data-testid="symbol-use" href={`#${symbolId}`} width="100%" height="100%" />
|
||||
{context.symbolLoaderURL ? (
|
||||
<IconSymbolLoader
|
||||
instanceId={iconInstanceId}
|
||||
symbolId={symbolId}
|
||||
style={iconStyle}
|
||||
icon={icon}
|
||||
loaderURL={context.symbolLoaderURL}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -73,7 +111,7 @@ export const Icon = React.forwardRef(function Icon(
|
||||
>
|
||||
<image
|
||||
data-testid="mask-image"
|
||||
href={url}
|
||||
href={getIconAssetURL(context, iconStyle, icon)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
||||
const pendingSymbolLoads = new Map<string, Promise<boolean>>();
|
||||
|
||||
function createSpriteRoot(): SVGSVGElement {
|
||||
const spriteRoot = document.createElementNS(SVG_NAMESPACE, 'svg');
|
||||
spriteRoot.setAttribute('id', 'gb-icon-sprite-root');
|
||||
spriteRoot.setAttribute('aria-hidden', 'true');
|
||||
spriteRoot.setAttribute('focusable', 'false');
|
||||
spriteRoot.setAttribute(
|
||||
'style',
|
||||
'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'
|
||||
);
|
||||
document.body.prepend(spriteRoot);
|
||||
|
||||
return spriteRoot;
|
||||
}
|
||||
|
||||
function getSpriteRoot(): SVGSVGElement {
|
||||
const existing = document.getElementById('gb-icon-sprite-root');
|
||||
if (existing instanceof SVGSVGElement) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return createSpriteRoot();
|
||||
}
|
||||
|
||||
function hasSymbol(symbolId: string): boolean {
|
||||
return document.getElementById(symbolId) instanceof SVGElement;
|
||||
}
|
||||
|
||||
function buildSymbolURL(loaderURL: string, style: string, icon: string): string {
|
||||
const normalizedLoaderURL = loaderURL.endsWith('/') ? loaderURL.slice(0, -1) : loaderURL;
|
||||
|
||||
return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}`;
|
||||
}
|
||||
|
||||
async function loadSymbol(symbolId: string, loaderURL: string, style: string, icon: string) {
|
||||
if (hasSymbol(symbolId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cached = pendingSymbolLoads.get(symbolId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const request = fetch(buildSymbolURL(loaderURL, style, icon), {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const symbolMarkup = await response.text();
|
||||
if (hasSymbol(symbolId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
getSpriteRoot().insertAdjacentHTML('beforeend', symbolMarkup);
|
||||
return hasSymbol(symbolId);
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
pendingSymbolLoads.delete(symbolId);
|
||||
});
|
||||
|
||||
pendingSymbolLoads.set(symbolId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function setIconFallbackState(instanceId: string, failed: boolean) {
|
||||
const icon = document.querySelector<SVGSVGElement>(
|
||||
`svg[data-gb-icon-instance="${instanceId}"]`
|
||||
);
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
const symbolUse = icon.querySelector<SVGUseElement>('[data-testid="symbol-use"]');
|
||||
const fallback = icon.querySelector<SVGRectElement>('[data-testid="mask-fallback"]');
|
||||
|
||||
if (symbolUse) {
|
||||
symbolUse.style.display = failed ? 'none' : '';
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
fallback.style.display = failed ? 'block' : 'none';
|
||||
}
|
||||
|
||||
icon.setAttribute('data-gb-icon-symbol-state', failed ? 'failed' : 'loaded');
|
||||
}
|
||||
|
||||
export function IconSymbolLoader(props: {
|
||||
instanceId: string;
|
||||
symbolId: string;
|
||||
style: string;
|
||||
icon: string;
|
||||
loaderURL: string;
|
||||
}) {
|
||||
const { instanceId, symbolId, style, icon, loaderURL } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
if (hasSymbol(symbolId)) {
|
||||
setIconFallbackState(instanceId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
loadSymbol(symbolId, loaderURL, style, icon).then((loaded) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIconFallbackState(instanceId, !loaded);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [icon, instanceId, loaderURL, style, symbolId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { IconRenderMode } from './symbols';
|
||||
import { IconStyle } from './types';
|
||||
|
||||
const version = 2;
|
||||
@@ -17,10 +18,17 @@ export type IconsContextType = Partial<IconsAssetsLocation> & {
|
||||
assetsByStyles?: Record<string, IconsAssetsLocation>;
|
||||
/** Current default style for icons */
|
||||
iconStyle: IconStyle;
|
||||
/** Rendering strategy for icons */
|
||||
renderMode: IconRenderMode;
|
||||
/** Prefix used for inline SVG symbol ids */
|
||||
symbolIdPrefix?: string;
|
||||
/** Internal route used to lazily load symbols introduced after hydration */
|
||||
symbolLoaderURL?: string;
|
||||
};
|
||||
|
||||
const IconsContext = React.createContext<IconsContextType>({
|
||||
iconStyle: IconStyle.Regular,
|
||||
renderMode: 'mask',
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -34,10 +42,19 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
|
||||
assetsURLToken = parent.assetsURLToken,
|
||||
iconStyle = parent.iconStyle,
|
||||
assetsByStyles = parent.assetsByStyles,
|
||||
renderMode = parent.renderMode,
|
||||
symbolIdPrefix = parent.symbolIdPrefix,
|
||||
symbolLoaderURL = parent.symbolLoaderURL,
|
||||
} = props;
|
||||
const value = React.useMemo(() => {
|
||||
return { assetsURL, assetsURLToken, iconStyle, assetsByStyles };
|
||||
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles]);
|
||||
const value = {
|
||||
assetsURL,
|
||||
assetsURLToken,
|
||||
iconStyle,
|
||||
assetsByStyles,
|
||||
renderMode,
|
||||
symbolIdPrefix,
|
||||
symbolLoaderURL,
|
||||
};
|
||||
|
||||
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './Icon';
|
||||
export * from './types';
|
||||
export * from './symbols';
|
||||
export * from './getIconStyle';
|
||||
export * from './IconsProvider';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export type IconRenderMode = 'mask' | 'symbol';
|
||||
|
||||
export interface RegisteredIconSymbol {
|
||||
style: string;
|
||||
icon: string;
|
||||
symbolId: string;
|
||||
}
|
||||
|
||||
const REGISTERED_SYMBOLS_KEY = Symbol.for('gitbook.icons.registeredSymbols');
|
||||
|
||||
function sanitizeSymbolFragment(fragment: string): string {
|
||||
return fragment.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
|
||||
export function getIconSymbolId(prefix: string | undefined, style: string, icon: string): string {
|
||||
const basePrefix = prefix ?? 'gb-icon-';
|
||||
|
||||
return `${sanitizeSymbolFragment(basePrefix)}${sanitizeSymbolFragment(style)}-${sanitizeSymbolFragment(icon)}`;
|
||||
}
|
||||
|
||||
function getRegisteredSymbolsStore(): Map<string, RegisteredIconSymbol> {
|
||||
const store = globalThis as typeof globalThis & {
|
||||
[REGISTERED_SYMBOLS_KEY]?: Map<string, RegisteredIconSymbol>;
|
||||
};
|
||||
|
||||
if (!store[REGISTERED_SYMBOLS_KEY]) {
|
||||
store[REGISTERED_SYMBOLS_KEY] = new Map<string, RegisteredIconSymbol>();
|
||||
}
|
||||
|
||||
return store[REGISTERED_SYMBOLS_KEY];
|
||||
}
|
||||
|
||||
function shouldTrackSymbolRegistrations() {
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: unknown };
|
||||
|
||||
return typeof window === 'undefined' || typeof runtime.Bun !== 'undefined';
|
||||
}
|
||||
|
||||
export function registerServerIconSymbol(symbol: RegisteredIconSymbol): void {
|
||||
if (!shouldTrackSymbolRegistrations()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getRegisteredSymbolsStore().set(`${symbol.style}/${symbol.icon}`, symbol);
|
||||
}
|
||||
|
||||
export function getRegisteredServerIconSymbols(): RegisteredIconSymbol[] {
|
||||
return [...getRegisteredSymbolsStore().values()];
|
||||
}
|
||||
|
||||
export function clearRegisteredServerIconSymbols(): void {
|
||||
getRegisteredSymbolsStore().clear();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"lib": ["es2023", "dom"],
|
||||
"module": "ESNext",
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
// Prepare the package for all other tasks
|
||||
"generate": {
|
||||
"dependsOn": ["^generate"],
|
||||
"outputs": ["dist"]
|
||||
"outputs": ["dist", ".generated/**"]
|
||||
},
|
||||
// Build the package for publishing
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user