mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Fix: clipping of FA icons (#4257)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/icons": minor
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Render icons as svg markup
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
"object-identity": "^0.1.2",
|
"object-identity": "^0.1.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
|
"p-retry": "^8.0.0",
|
||||||
"quick-lru": "^7.0.1",
|
"quick-lru": "^7.0.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -2535,6 +2536,8 @@
|
|||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||||
|
|
||||||
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
|
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
|
||||||
|
|
||||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
@@ -2925,6 +2928,8 @@
|
|||||||
|
|
||||||
"p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="],
|
"p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="],
|
||||||
|
|
||||||
|
"p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="],
|
||||||
|
|
||||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
||||||
|
|||||||
@@ -396,11 +396,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
*/
|
*/
|
||||||
export async function waitForIcons(page: Page) {
|
export async function waitForIcons(page: Page) {
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const urlStates: Record<
|
type IconURLStates = Record<
|
||||||
string,
|
string,
|
||||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||||
> = (window as any).__ICONS_STATES__ || {};
|
>;
|
||||||
(window as any).__ICONS_STATES__ = urlStates;
|
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
|
||||||
|
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
|
||||||
|
iconStatesWindow.__ICONS_STATES__ = urlStates;
|
||||||
|
|
||||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -445,7 +447,15 @@ export async function waitForIcons(page: Page) {
|
|||||||
|
|
||||||
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
||||||
if (!maskImage) {
|
if (!maskImage) {
|
||||||
throw new Error('No mask-image element');
|
const inlineContent = icon.querySelector(
|
||||||
|
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
|
||||||
|
);
|
||||||
|
if (inlineContent) {
|
||||||
|
icon.setAttribute('data-argos-state', 'loaded');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Icon has no inline SVG content or mask-image element');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = maskImage.getAttribute('href');
|
const url = maskImage.getAttribute('href');
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
"@opennextjs/cloudflare": "1.19.8",
|
"@opennextjs/cloudflare": "1.19.8",
|
||||||
"@panzoom/panzoom": "^4.6.1",
|
"@panzoom/panzoom": "^4.6.1",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tusbar/cache-control": "^1.0.2",
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"object-identity": "^0.1.2",
|
"object-identity": "^0.1.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
|
"p-retry": "^8.0.0",
|
||||||
"quick-lru": "^7.0.1",
|
"quick-lru": "^7.0.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
CustomizationDefaultThemeMode,
|
CustomizationDefaultThemeMode,
|
||||||
CustomizationIconsStyle,
|
|
||||||
CustomizationSidebarBackgroundStyle,
|
CustomizationSidebarBackgroundStyle,
|
||||||
CustomizationSidebarListStyle,
|
CustomizationSidebarListStyle,
|
||||||
type CustomizationThemedColor,
|
type CustomizationThemedColor,
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
colorScale,
|
colorScale,
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
} from '@gitbook/colors';
|
} from '@gitbook/colors';
|
||||||
import { IconStyle, IconsProvider } from '@gitbook/icons';
|
import { IconsProvider } from '@gitbook/icons';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
import { type FontData, getFontData } from '@/fonts';
|
import { type FontData, getFontData } from '@/fonts';
|
||||||
@@ -32,6 +31,12 @@ import { RootLayoutClientContexts } from './RootLayoutClientContexts';
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import type { GitBookAnyContext } from '@/lib/context';
|
import type { GitBookAnyContext } from '@/lib/context';
|
||||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
|
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||||
|
import {
|
||||||
|
getContentInlineIconSourceRequests,
|
||||||
|
getCustomizationIconStyle,
|
||||||
|
getDefaultInlineIconSourceRequests,
|
||||||
|
getInlineIconSources,
|
||||||
|
} from '@/lib/icons/inline';
|
||||||
import { defaultCustomization } from '@/lib/utils';
|
import { defaultCustomization } from '@/lib/utils';
|
||||||
import { AnnouncementDismissedScript } from '../Announcement';
|
import { AnnouncementDismissedScript } from '../Announcement';
|
||||||
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
|
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
|
||||||
@@ -88,6 +93,19 @@ export async function CustomizationRootLayout(props: {
|
|||||||
// Preconnect and preload custom fonts if needed
|
// Preconnect and preload custom fonts if needed
|
||||||
preloadFont(fontData);
|
preloadFont(fontData);
|
||||||
preloadFont(monospaceFontData);
|
preloadFont(monospaceFontData);
|
||||||
|
const iconStyle = getCustomizationIconStyle(customization);
|
||||||
|
const iconSources = await getInlineIconSources([
|
||||||
|
...getDefaultInlineIconSourceRequests(iconStyle),
|
||||||
|
...getContentInlineIconSourceRequests({
|
||||||
|
iconStyle,
|
||||||
|
pages: context.revision.pages,
|
||||||
|
tags: context.revision.tags,
|
||||||
|
sections:
|
||||||
|
'sections' in context
|
||||||
|
? [...(context.sections?.list ?? []), ...(context.visibleSections?.list ?? [])]
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
@@ -194,11 +212,8 @@ export async function CustomizationRootLayout(props: {
|
|||||||
assetsURL: getAssetURL('icons'),
|
assetsURL: getAssetURL('icons'),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
iconStyle={
|
iconSources={iconSources}
|
||||||
('icons' in customization.styling
|
iconStyle={iconStyle}
|
||||||
? apiToIconsStyles[customization.styling.icons]
|
|
||||||
: null) || IconStyle.Regular
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<RootLayoutClientContexts language={language}>
|
<RootLayoutClientContexts language={language}>
|
||||||
{children}
|
{children}
|
||||||
@@ -345,13 +360,3 @@ function generateColorVariable(
|
|||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiToIconsStyles: {
|
|
||||||
[key in CustomizationIconsStyle]: IconStyle;
|
|
||||||
} = {
|
|
||||||
[CustomizationIconsStyle.Regular]: IconStyle.Regular,
|
|
||||||
[CustomizationIconsStyle.Solid]: IconStyle.Solid,
|
|
||||||
[CustomizationIconsStyle.Duotone]: IconStyle.Duotone,
|
|
||||||
[CustomizationIconsStyle.Thin]: IconStyle.Thin,
|
|
||||||
[CustomizationIconsStyle.Light]: IconStyle.Light,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SiteInsightsDisplayContext,
|
SiteInsightsDisplayContext,
|
||||||
type TranslationLanguage,
|
type TranslationLanguage,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
|
import { IconsProvider } from '@gitbook/icons';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
@@ -15,6 +16,11 @@ import { PageBody, PageCover } from '@/components/PageBody';
|
|||||||
import { getPagePath } from '@/lib/pages';
|
import { getPagePath } from '@/lib/pages';
|
||||||
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
|
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getContentInlineIconSourceRequests,
|
||||||
|
getCustomizationIconStyle,
|
||||||
|
getInlineIconSources,
|
||||||
|
} from '@/lib/icons/inline';
|
||||||
import { getResizedImageURL } from '@/lib/images';
|
import { getResizedImageURL } from '@/lib/images';
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
import { getLocalizedTitle } from '@/lib/sites';
|
import { getLocalizedTitle } from '@/lib/sites';
|
||||||
@@ -65,47 +71,52 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
|||||||
withSections,
|
withSections,
|
||||||
withTopHeader,
|
withTopHeader,
|
||||||
pageMetaLinks,
|
pageMetaLinks,
|
||||||
|
iconSources,
|
||||||
} = await getSitePageData(props);
|
} = await getSitePageData(props);
|
||||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
<IconsProvider iconSources={iconSources}>
|
||||||
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
|
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
||||||
|
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
|
||||||
If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */}
|
If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */}
|
||||||
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
|
|
||||||
{withFullPageCover && page.cover ? (
|
|
||||||
<PageCover as="full" page={page} cover={page.cover} context={context} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}
|
||||||
withFullPageCover && page.cover ? 'flex grow flex-row' : 'contents',
|
|
||||||
withSections
|
|
||||||
? '[--content-scroll-margin:calc(var(--spacing)*27)]'
|
|
||||||
: '[--content-scroll-margin:calc(var(--spacing)*16)]'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<PageAside
|
{withFullPageCover && page.cover ? (
|
||||||
page={page}
|
<PageCover as="full" page={page} cover={page.cover} context={context} />
|
||||||
document={document}
|
) : null}
|
||||||
withHeaderOffset={headerOffset}
|
|
||||||
withFullPageCover={withFullPageCover}
|
<div
|
||||||
withPageFeedback={withPageFeedback}
|
className={tcls(
|
||||||
context={context}
|
withFullPageCover && page.cover ? 'flex grow flex-row' : 'contents',
|
||||||
/>
|
withSections
|
||||||
<PageBody
|
? '[--content-scroll-margin:calc(var(--spacing)*27)]'
|
||||||
context={context}
|
: '[--content-scroll-margin:calc(var(--spacing)*16)]'
|
||||||
page={page}
|
)}
|
||||||
ancestors={ancestors}
|
>
|
||||||
document={document}
|
<PageAside
|
||||||
withPageFeedback={withPageFeedback}
|
page={page}
|
||||||
insightsDisplayContext={SiteInsightsDisplayContext.Site}
|
document={document}
|
||||||
staticRoute={props.staticRoute}
|
withHeaderOffset={headerOffset}
|
||||||
/>
|
withFullPageCover={withFullPageCover}
|
||||||
|
withPageFeedback={withPageFeedback}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
|
<PageBody
|
||||||
|
context={context}
|
||||||
|
page={page}
|
||||||
|
ancestors={ancestors}
|
||||||
|
document={document}
|
||||||
|
withPageFeedback={withPageFeedback}
|
||||||
|
insightsDisplayContext={SiteInsightsDisplayContext.Site}
|
||||||
|
staticRoute={props.staticRoute}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PageClientLayout pageMetaLinks={pageMetaLinks} />
|
||||||
</div>
|
</div>
|
||||||
<PageClientLayout pageMetaLinks={pageMetaLinks} />
|
</PageContextProvider>
|
||||||
</div>
|
</IconsProvider>
|
||||||
</PageContextProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +291,13 @@ export async function getSitePageData(props: SitePageProps) {
|
|||||||
const withSections = Boolean(visibleSections && visibleSections.list.length > 0);
|
const withSections = Boolean(visibleSections && visibleSections.list.length > 0);
|
||||||
|
|
||||||
const document = await getPageDocument(context, page);
|
const document = await getPageDocument(context, page);
|
||||||
|
const iconStyle = getCustomizationIconStyle(customization);
|
||||||
|
const iconSources = await getInlineIconSources(
|
||||||
|
getContentInlineIconSourceRequests({
|
||||||
|
iconStyle,
|
||||||
|
document,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
@@ -291,6 +309,7 @@ export async function getSitePageData(props: SitePageProps) {
|
|||||||
withFullPageCover,
|
withFullPageCover,
|
||||||
withTopHeader,
|
withTopHeader,
|
||||||
pageMetaLinks,
|
pageMetaLinks,
|
||||||
|
iconSources,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import type {
|
||||||
|
JSONDocument,
|
||||||
|
RevisionPage,
|
||||||
|
RevisionTag,
|
||||||
|
SiteSection,
|
||||||
|
SiteSectionGroup,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import { IconStyle } from '@gitbook/icons/types';
|
||||||
|
|
||||||
|
import { getContentInlineIconSourceRequests, parseRawSVG } from './inline';
|
||||||
|
|
||||||
|
describe('parseRawSVG', () => {
|
||||||
|
it('extracts the viewBox and inner SVG markup', () => {
|
||||||
|
expect(
|
||||||
|
parseRawSVG(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path d="M1 2h3" />
|
||||||
|
</svg>
|
||||||
|
`)
|
||||||
|
).toEqual({
|
||||||
|
viewBox: '0 0 20 20',
|
||||||
|
markup: '<path d="M1 2h3" />',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips comments and script tags from the inline markup', () => {
|
||||||
|
const source = parseRawSVG(`
|
||||||
|
<svg viewBox='0 0 24 24'>
|
||||||
|
<!-- Font Awesome metadata -->
|
||||||
|
<path d="M4 5h6" />
|
||||||
|
<script>alert("bad")</script>
|
||||||
|
<path d="M7 8h9" />
|
||||||
|
</svg>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(source?.viewBox).toBe('0 0 24 24');
|
||||||
|
expect(source?.markup).toContain('<path d="M4 5h6" />');
|
||||||
|
expect(source?.markup).toContain('<path d="M7 8h9" />');
|
||||||
|
expect(source?.markup).not.toContain('<!--');
|
||||||
|
expect(source?.markup).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the document is not an SVG with a viewBox', () => {
|
||||||
|
expect(parseRawSVG('<div>not an icon</div>')).toBeNull();
|
||||||
|
expect(parseRawSVG('<svg><path d="M1 2h3" /></svg>')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the viewBox when other attributes surround it', () => {
|
||||||
|
expect(
|
||||||
|
parseRawSVG(
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 2h3" /></svg>'
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
viewBox: '0 0 16 16',
|
||||||
|
markup: '<path d="M1 2h3" />',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when non-whitespace content follows the closing SVG tag', () => {
|
||||||
|
expect(
|
||||||
|
parseRawSVG('<svg viewBox="0 0 16 16"><path d="M1 2h3" /></svg><span>extra</span>')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips multiple comments and script tags from the markup', () => {
|
||||||
|
const source = parseRawSVG(`
|
||||||
|
<svg viewBox="0 0 16 16">
|
||||||
|
<!-- first -->
|
||||||
|
<path d="M1 2h3" />
|
||||||
|
<script>first()</script>
|
||||||
|
<!-- second -->
|
||||||
|
<script type="text/javascript">second()</script>
|
||||||
|
</svg>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(source?.markup).toContain('<path d="M1 2h3" />');
|
||||||
|
expect(source?.markup).not.toContain('<!--');
|
||||||
|
expect(source?.markup).not.toContain('<script');
|
||||||
|
expect(source?.markup).not.toContain('second()');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContentInlineIconSourceRequests', () => {
|
||||||
|
it('collects icon requests from pages, tags, sections, and document nodes', () => {
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
icon: 'github',
|
||||||
|
pages: [{ icon: 'lock' }],
|
||||||
|
},
|
||||||
|
{ icon: 'star' },
|
||||||
|
] as unknown as RevisionPage[];
|
||||||
|
const tags = [{ icon: 'gear' }] as unknown as RevisionTag[];
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
object: 'site-section-group',
|
||||||
|
icon: 'magnifying-glass',
|
||||||
|
children: [{ object: 'site-section', icon: 'xmark' }],
|
||||||
|
},
|
||||||
|
] as unknown as (SiteSection | SiteSectionGroup)[];
|
||||||
|
const document = {
|
||||||
|
object: 'document',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'icon', data: { icon: 'copy' } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
data: { icon: 'download' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as JSONDocument;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getContentInlineIconSourceRequests({
|
||||||
|
iconStyle: IconStyle.Solid,
|
||||||
|
pages,
|
||||||
|
tags,
|
||||||
|
sections,
|
||||||
|
document,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{ icon: 'github', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'lock', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'star', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'gear', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'magnifying-glass', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'xmark', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'copy', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'download', iconStyle: IconStyle.Solid },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid, missing, and non-string icon values', () => {
|
||||||
|
const pages = [
|
||||||
|
{ icon: 'not-a-real-icon' },
|
||||||
|
{ icon: 123 },
|
||||||
|
{ icon: 'github' },
|
||||||
|
] as unknown as RevisionPage[];
|
||||||
|
const tags = [{ icon: null }, { icon: 'lock' }] as unknown as RevisionTag[];
|
||||||
|
const sections = [
|
||||||
|
{ object: 'site-section', icon: undefined },
|
||||||
|
{ object: 'site-section', icon: 'also-not-real' },
|
||||||
|
] as unknown as (SiteSection | SiteSectionGroup)[];
|
||||||
|
const document = {
|
||||||
|
object: 'document',
|
||||||
|
nodes: [
|
||||||
|
{ type: 'icon', data: { icon: 'made-up' } },
|
||||||
|
{ type: 'button', data: { icon: false } },
|
||||||
|
{ type: 'icon', data: { icon: 'copy' } },
|
||||||
|
],
|
||||||
|
} as unknown as JSONDocument;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getContentInlineIconSourceRequests({
|
||||||
|
iconStyle: IconStyle.Regular,
|
||||||
|
pages,
|
||||||
|
tags,
|
||||||
|
sections,
|
||||||
|
document,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{ icon: 'github', iconStyle: IconStyle.Regular },
|
||||||
|
{ icon: 'lock', iconStyle: IconStyle.Regular },
|
||||||
|
{ icon: 'copy', iconStyle: IconStyle.Regular },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomizationIconsStyle,
|
||||||
|
type JSONDocument,
|
||||||
|
type RevisionPage,
|
||||||
|
type RevisionTag,
|
||||||
|
type SiteCustomizationSettings,
|
||||||
|
type SiteSection,
|
||||||
|
type SiteSectionGroup,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import { type InlineIconSource, getInlineIconSourceKey } from '@gitbook/icons/IconSources';
|
||||||
|
import { getIconStyle } from '@gitbook/icons/getIconStyle';
|
||||||
|
import { validateIconName } from '@gitbook/icons/icons';
|
||||||
|
import { type IconName, IconStyle } from '@gitbook/icons/types';
|
||||||
|
import { GITBOOK_ICONS_ASSET_VERSION } from '@gitbook/icons/version';
|
||||||
|
import pRetry from 'p-retry';
|
||||||
|
|
||||||
|
import { getAssetURL } from '@/lib/assets';
|
||||||
|
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL, GITBOOK_URL } from '@/lib/env';
|
||||||
|
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||||
|
|
||||||
|
const rawSvgPromises = new Map<string, Promise<InlineIconSource | null>>();
|
||||||
|
const svgPattern = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
|
||||||
|
const viewBoxPattern = /\bviewBox=(["'])(.*?)\1/i;
|
||||||
|
const commentPattern = /<!--[\s\S]*?-->/g;
|
||||||
|
const scriptPattern = /<script\b[\s\S]*?<\/script>/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request for an icon whose SVG source should be resolved and provided to the client renderer.
|
||||||
|
*/
|
||||||
|
export type IconSourceRequest = {
|
||||||
|
icon: IconName;
|
||||||
|
iconStyle?: IconStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiToIconsStyles: {
|
||||||
|
[key in CustomizationIconsStyle]: IconStyle;
|
||||||
|
} = {
|
||||||
|
[CustomizationIconsStyle.Regular]: IconStyle.Regular,
|
||||||
|
[CustomizationIconsStyle.Solid]: IconStyle.Solid,
|
||||||
|
[CustomizationIconsStyle.Duotone]: IconStyle.Duotone,
|
||||||
|
[CustomizationIconsStyle.Thin]: IconStyle.Thin,
|
||||||
|
[CustomizationIconsStyle.Light]: IconStyle.Light,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultIconSourceRequests = [
|
||||||
|
'arrow-down',
|
||||||
|
'arrow-down-short-wide',
|
||||||
|
'arrow-left',
|
||||||
|
'arrow-turn-down-left',
|
||||||
|
'arrow-up',
|
||||||
|
'arrow-up-right',
|
||||||
|
'arrow-up-right-from-square',
|
||||||
|
'ban',
|
||||||
|
'bars',
|
||||||
|
'block-quote',
|
||||||
|
'chart-simple',
|
||||||
|
'check',
|
||||||
|
'check-circle',
|
||||||
|
'circle-check',
|
||||||
|
'circle-exclamation',
|
||||||
|
'circle-info',
|
||||||
|
'chevron-down',
|
||||||
|
'chevron-left',
|
||||||
|
'chevron-right',
|
||||||
|
'chevron-up',
|
||||||
|
'circle-xmark',
|
||||||
|
'close',
|
||||||
|
'code-commit',
|
||||||
|
'code-pull-request',
|
||||||
|
'comment',
|
||||||
|
'copy',
|
||||||
|
'desktop',
|
||||||
|
'download',
|
||||||
|
'edit',
|
||||||
|
'ellipsis',
|
||||||
|
'ellipsis-h',
|
||||||
|
'envelope',
|
||||||
|
'exclamation-triangle',
|
||||||
|
'eye',
|
||||||
|
'eye-slash',
|
||||||
|
'file-archive',
|
||||||
|
'file-download',
|
||||||
|
'file-image',
|
||||||
|
'file-lines',
|
||||||
|
'file-pdf',
|
||||||
|
'folder-gear',
|
||||||
|
'gear',
|
||||||
|
'gitbook',
|
||||||
|
'github',
|
||||||
|
'gitlab',
|
||||||
|
'globe',
|
||||||
|
'hashtag',
|
||||||
|
'link',
|
||||||
|
'lock',
|
||||||
|
'magnifying-glass',
|
||||||
|
'markdown',
|
||||||
|
'maximize',
|
||||||
|
'mcp',
|
||||||
|
'memo',
|
||||||
|
'message-question',
|
||||||
|
'minus',
|
||||||
|
'moon',
|
||||||
|
'palette',
|
||||||
|
'pen-to-square',
|
||||||
|
'plus',
|
||||||
|
'print',
|
||||||
|
'question-circle',
|
||||||
|
'refresh',
|
||||||
|
'rotate',
|
||||||
|
'rss',
|
||||||
|
'search',
|
||||||
|
'sparkle',
|
||||||
|
'spinner-third',
|
||||||
|
'star',
|
||||||
|
'sun-bright',
|
||||||
|
'thumbs-down',
|
||||||
|
'thumbs-up',
|
||||||
|
'trash',
|
||||||
|
'trash-can',
|
||||||
|
'triangle-exclamation',
|
||||||
|
'user',
|
||||||
|
'x',
|
||||||
|
'xmark',
|
||||||
|
] satisfies IconName[];
|
||||||
|
|
||||||
|
const customIconSourceRequests = [
|
||||||
|
'chatgpt',
|
||||||
|
'bytedance',
|
||||||
|
'claude',
|
||||||
|
'cohere',
|
||||||
|
'common-crawl',
|
||||||
|
'cursor',
|
||||||
|
'deepseek',
|
||||||
|
'gitbook-assistant',
|
||||||
|
'mistral',
|
||||||
|
'vscode',
|
||||||
|
] satisfies IconName[];
|
||||||
|
|
||||||
|
const explicitStyleIconSourceRequests = [
|
||||||
|
{ icon: 'check', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'chevron-right', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'circle-info', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'circle-exclamation', iconStyle: IconStyle.Solid },
|
||||||
|
{ icon: 'circle-check', iconStyle: IconStyle.Solid },
|
||||||
|
] satisfies IconSourceRequest[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icons used by core UI that can render after hydration, such as menus and popovers.
|
||||||
|
*/
|
||||||
|
export function getDefaultInlineIconSourceRequests(iconStyle: IconStyle): IconSourceRequest[] {
|
||||||
|
return [
|
||||||
|
...defaultIconSourceRequests.map((icon) => ({ icon, iconStyle })),
|
||||||
|
...customIconSourceRequests.map((icon) => ({ icon, iconStyle: IconStyle.Regular })),
|
||||||
|
...explicitStyleIconSourceRequests,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Font Awesome style configured for a site's customization.
|
||||||
|
*/
|
||||||
|
export function getCustomizationIconStyle(customization: SiteCustomizationSettings): IconStyle {
|
||||||
|
return (
|
||||||
|
('icons' in customization.styling ? apiToIconsStyles[customization.styling.icons] : null) ||
|
||||||
|
IconStyle.Regular
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect icon source requests from content data that can render icons outside the default UI set.
|
||||||
|
*/
|
||||||
|
export function getContentInlineIconSourceRequests(input: {
|
||||||
|
iconStyle: IconStyle;
|
||||||
|
pages?: RevisionPage[];
|
||||||
|
document?: JSONDocument | null;
|
||||||
|
tags?: RevisionTag[];
|
||||||
|
sections?: (SiteSection | SiteSectionGroup)[] | null;
|
||||||
|
}): IconSourceRequest[] {
|
||||||
|
const { iconStyle, pages = [], document = null, tags = [], sections = null } = input;
|
||||||
|
const requests: IconSourceRequest[] = [];
|
||||||
|
|
||||||
|
collectPageIconSourceRequests(pages, iconStyle, requests);
|
||||||
|
collectTagIconSourceRequests(tags, iconStyle, requests);
|
||||||
|
collectSiteSectionIconSourceRequests(sections ?? [], iconStyle, requests);
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
collectDocumentIconSourceRequests(document, iconStyle, requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve SVG markup for icons that should be available to client components at first paint.
|
||||||
|
*/
|
||||||
|
export async function getInlineIconSources(
|
||||||
|
requests: IconSourceRequest[]
|
||||||
|
): Promise<Record<string, InlineIconSource>> {
|
||||||
|
const resolved = new Map<string, { style: string; icon: IconName }>();
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
const [style, icon] = getIconStyle(request.iconStyle ?? IconStyle.Regular, request.icon);
|
||||||
|
resolved.set(getInlineIconSourceKey(style, icon), { style, icon });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = await Promise.all(
|
||||||
|
[...resolved.entries()].map(async ([key, request]) => {
|
||||||
|
const source = await getInlineIconSource(request.style, request.icon);
|
||||||
|
return source ? ([key, source] as const) : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
sources.filter((source): source is [string, InlineIconSource] => {
|
||||||
|
return source !== null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInlineIconSource(
|
||||||
|
style: string,
|
||||||
|
icon: IconName
|
||||||
|
): Promise<InlineIconSource | null> {
|
||||||
|
const cacheKey = getInlineIconSourceKey(style, icon);
|
||||||
|
const existing = rawSvgPromises.get(cacheKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = pRetry(
|
||||||
|
() =>
|
||||||
|
fetch(getIconAssetURL(style, icon), {
|
||||||
|
cache: 'force-cache',
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch icon');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRawSVG(await response.text());
|
||||||
|
}),
|
||||||
|
{ retries: 3 }
|
||||||
|
);
|
||||||
|
rawSvgPromises.set(cacheKey, request);
|
||||||
|
return await request;
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to fetch icon ${icon} with style ${style} after multiple attempts`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRawSVG(document: string): InlineIconSource | 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 viewBox = svgAttributes.match(viewBoxPattern)?.[2];
|
||||||
|
if (!viewBox) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewBox,
|
||||||
|
markup: rawMarkup.replace(commentPattern, '').replace(scriptPattern, '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconAssetURL(style: string, icon: string): string {
|
||||||
|
const url = new URL(
|
||||||
|
joinPathWithBaseURL(
|
||||||
|
getAbsoluteIconAssetBaseURL(style),
|
||||||
|
joinPath('svgs', style, `${icon}.svg`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
url.searchParams.set('v', GITBOOK_ICONS_ASSET_VERSION);
|
||||||
|
|
||||||
|
if (style !== 'custom-icons' && GITBOOK_ICONS_TOKEN) {
|
||||||
|
url.searchParams.set('token', GITBOOK_ICONS_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconAssetBaseURL(style: string): string {
|
||||||
|
if (style === 'custom-icons') {
|
||||||
|
return getAssetURL('icons');
|
||||||
|
}
|
||||||
|
|
||||||
|
return GITBOOK_ICONS_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAbsoluteIconAssetBaseURL(style: string): string {
|
||||||
|
const baseURL = getIconAssetBaseURL(style);
|
||||||
|
|
||||||
|
if (baseURL.startsWith('http://') || baseURL.startsWith('https://')) {
|
||||||
|
return baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinPathWithBaseURL(GITBOOK_URL, baseURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPageIconSourceRequests(
|
||||||
|
pages: RevisionPage[],
|
||||||
|
iconStyle: IconStyle,
|
||||||
|
requests: IconSourceRequest[]
|
||||||
|
) {
|
||||||
|
for (const page of pages) {
|
||||||
|
addIconSourceRequest(requests, page.icon, iconStyle);
|
||||||
|
|
||||||
|
if ('pages' in page) {
|
||||||
|
collectPageIconSourceRequests(page.pages, iconStyle, requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTagIconSourceRequests(
|
||||||
|
tags: RevisionTag[],
|
||||||
|
iconStyle: IconStyle,
|
||||||
|
requests: IconSourceRequest[]
|
||||||
|
) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
if ('icon' in tag) {
|
||||||
|
addIconSourceRequest(requests, tag.icon, iconStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSiteSectionIconSourceRequests(
|
||||||
|
sections: (SiteSection | SiteSectionGroup)[],
|
||||||
|
iconStyle: IconStyle,
|
||||||
|
requests: IconSourceRequest[]
|
||||||
|
) {
|
||||||
|
for (const section of sections) {
|
||||||
|
addIconSourceRequest(requests, section.icon, iconStyle);
|
||||||
|
|
||||||
|
if (section.object === 'site-section-group') {
|
||||||
|
collectSiteSectionIconSourceRequests(section.children, iconStyle, requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDocumentIconSourceRequests(
|
||||||
|
node: unknown,
|
||||||
|
iconStyle: IconStyle,
|
||||||
|
requests: IconSourceRequest[]
|
||||||
|
) {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = node as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (record.type === 'icon' || record.type === 'button') {
|
||||||
|
const data = record.data;
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
addIconSourceRequest(requests, (data as Record<string, unknown>).icon, iconStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of Object.values(record)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
collectDocumentIconSourceRequests(item, iconStyle, requests);
|
||||||
|
}
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
collectDocumentIconSourceRequests(value, iconStyle, requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIconSourceRequest(requests: IconSourceRequest[], icon: unknown, iconStyle: IconStyle) {
|
||||||
|
if (typeof icon === 'string' && validateIconName(icon)) {
|
||||||
|
requests.push({ icon, iconStyle });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,26 @@
|
|||||||
"types": "./dist/icons.d.ts",
|
"types": "./dist/icons.d.ts",
|
||||||
"development": "./src/icons.ts",
|
"development": "./src/icons.ts",
|
||||||
"default": "./dist/icons.js"
|
"default": "./dist/icons.js"
|
||||||
|
},
|
||||||
|
"./types": {
|
||||||
|
"types": "./dist/types.d.ts",
|
||||||
|
"development": "./src/types.ts",
|
||||||
|
"default": "./dist/types.js"
|
||||||
|
},
|
||||||
|
"./getIconStyle": {
|
||||||
|
"types": "./dist/getIconStyle.d.ts",
|
||||||
|
"development": "./src/getIconStyle.ts",
|
||||||
|
"default": "./dist/getIconStyle.js"
|
||||||
|
},
|
||||||
|
"./IconSources": {
|
||||||
|
"types": "./dist/IconSources.d.ts",
|
||||||
|
"development": "./src/IconSources.ts",
|
||||||
|
"default": "./dist/IconSources.js"
|
||||||
|
},
|
||||||
|
"./version": {
|
||||||
|
"types": "./dist/version.d.ts",
|
||||||
|
"development": "./src/version.ts",
|
||||||
|
"default": "./dist/version.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { getInlineIconSourceKey } from './IconSources';
|
||||||
import { getIconAssetURL, useIcons } from './IconsProvider';
|
import { getIconAssetURL, useIcons } from './IconsProvider';
|
||||||
import { getIconStyle } from './getIconStyle';
|
import { getIconStyle } from './getIconStyle';
|
||||||
import type { IconName, IconStyle } from './types';
|
import type { IconName, IconStyle } from './types';
|
||||||
@@ -50,9 +51,29 @@ export const Icon = React.forwardRef(function Icon(
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
|
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
|
||||||
const url = getIconAssetURL(context, iconStyle, icon);
|
const source = context.iconSources?.[getInlineIconSourceKey(iconStyle, icon)];
|
||||||
const maskId = React.useId();
|
const maskId = React.useId();
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...rest}
|
||||||
|
viewBox={source.viewBox}
|
||||||
|
fill="currentColor"
|
||||||
|
style={{
|
||||||
|
overflow: 'visible',
|
||||||
|
...(size ? { width: size, height: size } : {}),
|
||||||
|
...rest.style,
|
||||||
|
}}
|
||||||
|
className={`gb-icon ${className}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: source.markup }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getIconAssetURL(context, iconStyle, icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface InlineIconSource {
|
||||||
|
/** The SVG viewBox for the icon. */
|
||||||
|
viewBox: string;
|
||||||
|
/** Trusted inner SVG markup for the icon. */
|
||||||
|
markup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lookup key for a server-resolved inline SVG source.
|
||||||
|
*/
|
||||||
|
export function getInlineIconSourceKey(style: string, icon: string): string {
|
||||||
|
return `${style}/${icon}`;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import type { InlineIconSource } from './IconSources';
|
||||||
import { IconStyle } from './types';
|
import { IconStyle } from './types';
|
||||||
|
import { GITBOOK_ICONS_ASSET_VERSION } from './version';
|
||||||
const version = 2;
|
|
||||||
|
|
||||||
export interface IconsAssetsLocation {
|
export interface IconsAssetsLocation {
|
||||||
/** Rroot url where the icon assets are served */
|
/** Rroot url where the icon assets are served */
|
||||||
@@ -15,6 +15,8 @@ export interface IconsAssetsLocation {
|
|||||||
export type IconsContextType = Partial<IconsAssetsLocation> & {
|
export type IconsContextType = Partial<IconsAssetsLocation> & {
|
||||||
/** Assets location for special styles */
|
/** Assets location for special styles */
|
||||||
assetsByStyles?: Record<string, IconsAssetsLocation>;
|
assetsByStyles?: Record<string, IconsAssetsLocation>;
|
||||||
|
/** Server-resolved SVG sources keyed by style and icon. */
|
||||||
|
iconSources?: Record<string, InlineIconSource>;
|
||||||
/** Current default style for icons */
|
/** Current default style for icons */
|
||||||
iconStyle: IconStyle;
|
iconStyle: IconStyle;
|
||||||
};
|
};
|
||||||
@@ -34,10 +36,20 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
|
|||||||
assetsURLToken = parent.assetsURLToken,
|
assetsURLToken = parent.assetsURLToken,
|
||||||
iconStyle = parent.iconStyle,
|
iconStyle = parent.iconStyle,
|
||||||
assetsByStyles = parent.assetsByStyles,
|
assetsByStyles = parent.assetsByStyles,
|
||||||
|
iconSources = parent.iconSources,
|
||||||
} = props;
|
} = props;
|
||||||
const value = React.useMemo(() => {
|
const value = React.useMemo(() => {
|
||||||
return { assetsURL, assetsURLToken, iconStyle, assetsByStyles };
|
return {
|
||||||
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles]);
|
assetsURL,
|
||||||
|
assetsURLToken,
|
||||||
|
iconStyle,
|
||||||
|
assetsByStyles,
|
||||||
|
iconSources:
|
||||||
|
parent.iconSources && iconSources
|
||||||
|
? { ...parent.iconSources, ...iconSources }
|
||||||
|
: (iconSources ?? parent.iconSources),
|
||||||
|
};
|
||||||
|
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles, iconSources, parent.iconSources]);
|
||||||
|
|
||||||
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
|
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
|
||||||
}
|
}
|
||||||
@@ -56,7 +68,7 @@ export function getAssetURL(location: Partial<IconsAssetsLocation>, path: string
|
|||||||
if (!location.assetsURL) {
|
if (!location.assetsURL) {
|
||||||
throw new Error('You first need to pass a assetsURL to <IconsProvider>');
|
throw new Error('You first need to pass a assetsURL to <IconsProvider>');
|
||||||
}
|
}
|
||||||
const rawUrl = `${location.assetsURL + (location.assetsURL.endsWith('/') ? '' : '/') + path}?v=${version}`;
|
const rawUrl = `${location.assetsURL + (location.assetsURL.endsWith('/') ? '' : '/') + path}?v=${GITBOOK_ICONS_ASSET_VERSION}`;
|
||||||
|
|
||||||
if (location.assetsURLToken) {
|
if (location.assetsURLToken) {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export * from './Icon';
|
export * from './Icon';
|
||||||
|
export * from './IconSources';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './IconsProvider';
|
export * from './IconsProvider';
|
||||||
|
export * from './getIconStyle';
|
||||||
|
export * from './version';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Cache-busting version for the static icon SVG assets served by GitBook.
|
||||||
|
*
|
||||||
|
* This value must be shared by both the client-side icon renderer and any
|
||||||
|
* server-side code that fetches or inlines icon SVGs, otherwise the browser can
|
||||||
|
* receive different cache keys for the same icon asset.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_ICONS_ASSET_VERSION = '2';
|
||||||
@@ -2,7 +2,14 @@ import { defineConfig } from 'tsdown';
|
|||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
entry: ['src/index.ts', 'src/icons.ts'],
|
entry: [
|
||||||
|
'src/index.ts',
|
||||||
|
'src/icons.ts',
|
||||||
|
'src/types.ts',
|
||||||
|
'src/getIconStyle.ts',
|
||||||
|
'src/IconSources.ts',
|
||||||
|
'src/version.ts',
|
||||||
|
],
|
||||||
unbundle: true,
|
unbundle: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user