mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 10:13:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b1d083554 | |||
| fd6a22a0b3 | |||
| 5fb928a149 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Define a page's repeated icons once in an SVG sprite that each icon references, instead of repeating the full SVG at every heading anchor, expandable and embed placeholder, and pack the class lists every expandable repeats into shared CSS classes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix encoded or non-normalized links on proxied sites redirecting to the internal proxy path instead of the public URL.
|
||||
@@ -50,27 +50,8 @@ export function Details(props: {
|
||||
id={id}
|
||||
data-block-key={props.commentKey}
|
||||
open={props.open || openFromHash}
|
||||
className={tcls(
|
||||
className,
|
||||
'group/expandable',
|
||||
'bg-tint-base',
|
||||
'border',
|
||||
'border-b-0',
|
||||
'border-tint-subtle',
|
||||
//all
|
||||
'mt-0!',
|
||||
'mb-0',
|
||||
//select first child
|
||||
'first:mt-5!',
|
||||
'first:rounded-t-lg',
|
||||
//select first in group
|
||||
'[:not(&)_+_&]:mt-5!',
|
||||
'[:not(&)_+_&]:rounded-t-lg',
|
||||
//select last in group
|
||||
'[&:not(:has(+_&))]:mb-5',
|
||||
'[&:not(:has(+_&))]:rounded-b-lg',
|
||||
'[&:not(:has(+_&))]:border-b'
|
||||
)}
|
||||
// Styles live on `.expandable` in `Expandable.css`; the group name is matched literally.
|
||||
className={tcls(className, 'expandable', 'group/expandable')}
|
||||
>
|
||||
{children}
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@reference "../../RootLayout/globals.css";
|
||||
|
||||
/* This file can be the first stylesheet a route loads, and layer order follows first
|
||||
appearance. Without this, `components` would be created here and Tailwind's `base`
|
||||
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
/* A page can stack a hundred expandables, so their styles live here rather than in each one's
|
||||
class attributes. They sit in `components` so the utilities passed as props still override. */
|
||||
@layer components {
|
||||
/* Consecutive expandables join into one bordered group: only the first of a run gets the top
|
||||
margin and corners, and only the last the bottom border, margin and corners. */
|
||||
.expandable {
|
||||
@apply scroll-mt-(--content-scroll-margin) border border-b-0 border-tint-subtle bg-tint-base;
|
||||
@apply mt-0! mb-0 first:mt-5! first:rounded-t-lg;
|
||||
@apply [:not(&)_+_&]:mt-5! [:not(&)_+_&]:rounded-t-lg;
|
||||
@apply [&:not(:has(+_&))]:mb-5 [&:not(:has(+_&))]:rounded-b-lg [&:not(:has(+_&))]:border-b;
|
||||
}
|
||||
|
||||
.expandable-summary {
|
||||
@apply relative cursor-pointer list-none select-none p-4 text-tint transition-colors;
|
||||
@apply group-hover/expandable:text-tint-strong group-open/expandable:text-tint-strong;
|
||||
@apply [&::-webkit-details-marker]:hidden;
|
||||
}
|
||||
|
||||
/* Room for the anchor icon pinned to the right edge. */
|
||||
.expandable-summary-anchored {
|
||||
@apply pr-10;
|
||||
}
|
||||
|
||||
.expandable-chevron {
|
||||
@apply mr-3 mb-1 inline-block group-open/expandable:rotate-90;
|
||||
}
|
||||
|
||||
.expandable-anchor {
|
||||
@apply absolute top-2 right-4 bottom-2 flex items-center dark:shadow-none dark:ring-0;
|
||||
}
|
||||
|
||||
.expandable-anchor-icon {
|
||||
@apply inline-block size-3 text-transparent transition-colors;
|
||||
@apply group-hover/expandable:text-tint-subtle contrast-more:group-hover/expandable:text-tint-strong;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { shouldShowHashLinks } from '../HashLinkButton';
|
||||
import { Inlines } from '../Inlines';
|
||||
import { getCommentAnchorProps, getCommentKeyProp } from '../utils';
|
||||
import { Details } from './Details';
|
||||
import './Expandable.css';
|
||||
import { ToggleChevron } from '@/components/primitives';
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { defaultLanguage } from '@/intl/translations';
|
||||
@@ -40,37 +41,19 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
id={id}
|
||||
{...getCommentKeyProp(context, block.key)}
|
||||
open={context.mode === 'print' || isDefaultExpanded}
|
||||
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
|
||||
className={style}
|
||||
>
|
||||
<summary
|
||||
{...getCommentAnchorProps(context, titleParagraph.key)}
|
||||
className={tcls(
|
||||
'cursor-pointer',
|
||||
'px-4',
|
||||
// Reserve room for the anchor icon pinned to the right edge
|
||||
showHashLink && 'pr-10',
|
||||
'py-4',
|
||||
'relative',
|
||||
'list-none',
|
||||
'select-none',
|
||||
'transition-colors',
|
||||
'text-tint',
|
||||
'group-hover/expandable:text-tint-strong',
|
||||
'group-open/expandable:text-tint-strong',
|
||||
'[&::-webkit-details-marker]:hidden'
|
||||
'expandable-summary',
|
||||
showHashLink && 'expandable-summary-anchored'
|
||||
)}
|
||||
>
|
||||
<ToggleChevron
|
||||
orientation="right-to-down"
|
||||
open={context.mode === 'print'}
|
||||
className={tcls(
|
||||
'inline-block',
|
||||
'size-3',
|
||||
'mr-3',
|
||||
'mb-1',
|
||||
'shrink-0',
|
||||
'group-open/expandable:rotate-90'
|
||||
)}
|
||||
className="expandable-chevron"
|
||||
/>
|
||||
<Inlines
|
||||
nodes={titleParagraph.nodes}
|
||||
@@ -82,28 +65,9 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
<a
|
||||
href={`#${id}`}
|
||||
aria-label={tString(language, 'direct_link_to_heading')}
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'top-2',
|
||||
'bottom-2',
|
||||
'right-4',
|
||||
'flex',
|
||||
'items-center',
|
||||
'dark:shadow-none',
|
||||
'dark:ring-0'
|
||||
)}
|
||||
className="expandable-anchor"
|
||||
>
|
||||
<Icon
|
||||
icon="hashtag"
|
||||
className={tcls(
|
||||
'inline-block',
|
||||
'size-3',
|
||||
'transition-colors',
|
||||
'text-transparent',
|
||||
'group-hover/expandable:text-tint-subtle',
|
||||
'contrast-more:group-hover/expandable:text-tint-strong'
|
||||
)}
|
||||
/>
|
||||
<Icon icon="hashtag" className="expandable-anchor-icon" />
|
||||
</a>
|
||||
) : null}
|
||||
</summary>
|
||||
|
||||
@@ -140,7 +140,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
);
|
||||
|
||||
return (
|
||||
<IconsProvider iconSources={iconSources}>
|
||||
<IconsProvider iconSources={iconSources} sprite>
|
||||
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
||||
{filterableTagSlugs.length > 0 ? (
|
||||
<UpdatesFilterProvider tagSlugs={filterableTagSlugs}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getURLLookup, getURLLookupPathname, normalizeRequestURL, normalizeURL } from './urls';
|
||||
import { getURLLookup, getURLLookupPathname, normalizeURL } from './urls';
|
||||
|
||||
describe('getURLLookupPathname', () => {
|
||||
const previewRoot = 'https://sites.gitbook.com/preview/site_example/section';
|
||||
@@ -162,51 +162,6 @@ describe('getURLLookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRequestURL', () => {
|
||||
it('does not redirect a normalized URL', () => {
|
||||
const url = new URL('https://docs.mycompany.com/hello');
|
||||
expect(normalizeRequestURL(url, 'url-host')).toEqual({ isNormalized: true });
|
||||
});
|
||||
|
||||
it('redirects a non-normalized URL to the normalized one', () => {
|
||||
const result = normalizeRequestURL(
|
||||
new URL('https://docs.mycompany.com/hello/tes%74'),
|
||||
'url-host'
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe('https://docs.mycompany.com/hello/test');
|
||||
});
|
||||
|
||||
it('does not redirect a non-normalized proxied URL to the internal proxy URL', () => {
|
||||
const url = new URL(
|
||||
'https://proxy.gitbook.site/sites/site_abc/getting-started/quickstar%74'
|
||||
);
|
||||
expect(normalizeRequestURL(url, 'url-host')).toEqual({ isNormalized: false });
|
||||
});
|
||||
|
||||
it('redirects a non-normalized proxied URL in url mode', () => {
|
||||
const result = normalizeRequestURL(
|
||||
new URL('https://proxy.gitbook.site/sites/site_abc/quickstar%74'),
|
||||
'url'
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).headers.get('location')).toBe(
|
||||
'https://proxy.gitbook.site/sites/site_abc/quickstart'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://docs.mycompany.com/hello/%ZZ',
|
||||
'https://proxy.gitbook.site/sites/site_abc/%ZZ',
|
||||
])('returns a 400 for an invalid URL %s', (url) => {
|
||||
const result = normalizeRequestURL(new URL(url), 'url-host');
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeURL', () => {
|
||||
it('should remove trailing slashes', () => {
|
||||
expect(normalizeURL(new URL('https://docs.mycompany.com/hello/'))).toEqual(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { joinPath, removeTrailingSlash } from '../paths';
|
||||
import { isProxyRequest, isProxyRootRequest } from '../proxy';
|
||||
import { isProxyRootRequest } from '../proxy';
|
||||
import { DataFetcherError, getExposableError } from './errors';
|
||||
|
||||
/**
|
||||
@@ -105,28 +105,21 @@ export function getURLLookupPathname(
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL in a request before the lookup. Returns an error response if it is invalid,
|
||||
* or a redirect to the normalized URL if it can be built from the request URL.
|
||||
* Normalize the URL in a request and redirect if the normalized URL is different from the original one.
|
||||
*/
|
||||
export function normalizeRequestURL(
|
||||
url: URL,
|
||||
mode: 'url' | 'url-host'
|
||||
): Response | { isNormalized: boolean } {
|
||||
let normalizedURL: URL;
|
||||
export function normalizeRequestURL(url: URL): Response | null {
|
||||
try {
|
||||
normalizedURL = normalizeURL(url);
|
||||
const normalizedURL = normalizeURL(url);
|
||||
|
||||
if (normalizedURL.toString() !== url.toString()) {
|
||||
return Response.redirect(normalizedURL.toString(), 302);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
const sanitized = getExposableError(error);
|
||||
return new Response(sanitized.message, { status: sanitized.code });
|
||||
}
|
||||
|
||||
const isNormalized = normalizedURL.toString() === url.toString();
|
||||
// A proxied site's public URL is only known after the lookup, which redirects to the canonical URL.
|
||||
if (!isNormalized && !(mode !== 'url' && isProxyRequest(url))) {
|
||||
return Response.redirect(normalizedURL.toString(), 302);
|
||||
}
|
||||
|
||||
return { isNormalized };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,6 +132,27 @@ describe('getContentInlineIconSourceRequests', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects the icons that blocks repeat once per instance', () => {
|
||||
const document = {
|
||||
object: 'document',
|
||||
nodes: [
|
||||
{ object: 'block', type: 'heading-2', nodes: [] },
|
||||
{ object: 'block', type: 'expandable', nodes: [] },
|
||||
{ object: 'block', type: 'integration', nodes: [] },
|
||||
{ object: 'block', type: 'paragraph', nodes: [] },
|
||||
],
|
||||
} as unknown as JSONDocument;
|
||||
|
||||
expect(
|
||||
getContentInlineIconSourceRequests({ iconStyle: IconStyle.Regular, document })
|
||||
).toEqual([
|
||||
{ icon: 'hashtag', iconStyle: IconStyle.Regular },
|
||||
{ icon: 'hashtag', iconStyle: IconStyle.Regular },
|
||||
{ icon: 'chevron-right', iconStyle: IconStyle.Regular },
|
||||
{ icon: 'spinner', iconStyle: IconStyle.Regular },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores invalid, missing, and non-string icon values', () => {
|
||||
const pages = [
|
||||
{ icon: 'not-a-real-icon' },
|
||||
|
||||
@@ -138,6 +138,17 @@ const customIconSourceRequests = [
|
||||
'vscode',
|
||||
] satisfies IconName[];
|
||||
|
||||
/**
|
||||
* Icons a block renders once per instance, so a document with many such blocks repeats them.
|
||||
*/
|
||||
const blockIconSourceRequests: Partial<Record<string, IconName[]>> = {
|
||||
'heading-1': ['hashtag'],
|
||||
'heading-2': ['hashtag'],
|
||||
'heading-3': ['hashtag'],
|
||||
expandable: ['hashtag', 'chevron-right'],
|
||||
integration: ['spinner'],
|
||||
};
|
||||
|
||||
const explicitStyleIconSourceRequests = [
|
||||
{ icon: 'check', iconStyle: IconStyle.Solid },
|
||||
{ icon: 'chevron-right', iconStyle: IconStyle.Solid },
|
||||
@@ -380,6 +391,12 @@ function collectDocumentIconSourceRequests(
|
||||
}
|
||||
}
|
||||
|
||||
if (record.object === 'block' && typeof record.type === 'string') {
|
||||
for (const icon of blockIconSourceRequests[record.type] ?? []) {
|
||||
addIconSourceRequest(requests, icon, iconStyle);
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { Icon, IconStyle, IconsProvider } from '@gitbook/icons';
|
||||
|
||||
const hashtag = { viewBox: '0 0 448 512', markup: '<path d="M1 1h2"></path>' };
|
||||
const star = { viewBox: '0 0 576 512', markup: '<path d="M3 3h4"></path>' };
|
||||
|
||||
describe('icon sprite', () => {
|
||||
it('defines each source once and references it from every icon', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider
|
||||
iconStyle={IconStyle.Regular}
|
||||
iconSources={{ 'regular/hashtag': hashtag }}
|
||||
sprite
|
||||
>
|
||||
<Icon icon="hashtag" />
|
||||
<Icon icon="hashtag" className="size-4" />
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html.split(hashtag.markup)).toHaveLength(2);
|
||||
expect(html).toContain('<g id="gb-icon-regular-hashtag">');
|
||||
expect(html.match(/<use href="#gb-icon-regular-hashtag"><\/use>/g)).toHaveLength(2);
|
||||
expect(html).toContain('viewBox="0 0 448 512"');
|
||||
});
|
||||
|
||||
it('inlines the markup without a sprite', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider
|
||||
iconStyle={IconStyle.Regular}
|
||||
iconSources={{ 'regular/hashtag': hashtag }}
|
||||
>
|
||||
<Icon icon="hashtag" />
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).not.toContain('<use');
|
||||
expect(html).not.toContain('data-gb-icon-sprite');
|
||||
expect(html).toContain(hashtag.markup);
|
||||
});
|
||||
|
||||
it('inlines sources that only an outer provider defines', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider iconStyle={IconStyle.Regular} iconSources={{ 'regular/star': star }}>
|
||||
<IconsProvider iconSources={{ 'regular/hashtag': hashtag }} sprite>
|
||||
<Icon icon="star" />
|
||||
<Icon icon="hashtag" />
|
||||
</IconsProvider>
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).toContain(`>${star.markup}</svg>`);
|
||||
expect(html).not.toContain('gb-icon-regular-star');
|
||||
expect(html).toContain('<use href="#gb-icon-regular-hashtag"></use>');
|
||||
});
|
||||
});
|
||||
@@ -169,11 +169,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
}
|
||||
|
||||
// Normalize URL after extracting the URL from the request to make sure the client is redirected to the proper one
|
||||
const normalization = normalizeRequestURL(siteRequestURL, mode);
|
||||
if (normalization instanceof Response) {
|
||||
return normalization;
|
||||
const normalizationResponse = normalizeRequestURL(siteRequestURL);
|
||||
if (normalizationResponse) {
|
||||
return normalizationResponse;
|
||||
}
|
||||
const isNormalizedURL = normalization.isNormalized;
|
||||
|
||||
const imagesContextId = getImageResizingContextId(siteRequestURL);
|
||||
/**
|
||||
@@ -339,7 +338,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup,
|
||||
// and of any visitor.* params that may have been passed to the URL.
|
||||
// Proxied requests that were not normalized are also redirected here, to the canonical URL.
|
||||
//
|
||||
// We only redirect if the request is not coming from the revalidation worker, as we don't
|
||||
// want to redirect in that case. It can carry unsigned claims without any token, so we rely
|
||||
@@ -350,7 +348,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
const normalizedVisitorURL = normalizeVisitorURL(incomingURL);
|
||||
if (
|
||||
(!isNormalizedURL || normalizedVisitorURL.toString() !== incomingURL.toString()) &&
|
||||
normalizedVisitorURL.toString() !== incomingURL.toString() &&
|
||||
!isRevalidationRequest(request.headers)
|
||||
) {
|
||||
if (visitorToken?.source === 'url') {
|
||||
|
||||
+26
-17
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { getIconStyle } from './getIconStyle';
|
||||
import { getInlineIconSourceKey } from './IconSources';
|
||||
import { getIconSpriteId, getInlineIconSourceKey } from './IconSources';
|
||||
import { getIconAssetURL, useIcons } from './IconsProvider';
|
||||
import type { IconName, IconStyle } from './types';
|
||||
|
||||
@@ -51,25 +51,34 @@ export const Icon = React.forwardRef(function Icon(
|
||||
} = props;
|
||||
|
||||
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
|
||||
const source = context.iconSources?.[getInlineIconSourceKey(iconStyle, icon)];
|
||||
const sourceKey = getInlineIconSourceKey(iconStyle, icon);
|
||||
const source = context.iconSources?.[sourceKey];
|
||||
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 svgProps = {
|
||||
ref,
|
||||
...rest,
|
||||
viewBox: source.viewBox,
|
||||
fill: 'currentColor',
|
||||
style: {
|
||||
overflow: 'visible',
|
||||
...(size ? { width: size, height: size } : {}),
|
||||
...rest.style,
|
||||
},
|
||||
className: `gb-icon ${className}`,
|
||||
};
|
||||
|
||||
// A page can repeat one icon hundreds of times (heading anchors), so reference the sprite.
|
||||
if (context.spriteKeys?.has(sourceKey)) {
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<use href={`#${getIconSpriteId(sourceKey)}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return <svg {...svgProps} dangerouslySetInnerHTML={{ __html: source.markup }} />;
|
||||
}
|
||||
|
||||
const url = getIconAssetURL(context, iconStyle, icon);
|
||||
|
||||
@@ -11,3 +11,10 @@ export interface InlineIconSource {
|
||||
export function getInlineIconSourceKey(style: string, icon: string): string {
|
||||
return `${style}/${icon}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element id an icon's sprite entry is defined under, from its source key.
|
||||
*/
|
||||
export function getIconSpriteId(key: string): string {
|
||||
return `gb-icon-${key.replace(/[^a-z0-9-]/gi, '-')}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { InlineIconSource } from './IconSources';
|
||||
import { type InlineIconSource, getIconSpriteId } from './IconSources';
|
||||
import { IconStyle } from './types';
|
||||
import { GITBOOK_ICONS_ASSET_VERSION } from './version';
|
||||
|
||||
@@ -18,6 +18,8 @@ export type IconsContextType = Partial<IconsAssetsLocation> & {
|
||||
assetsByStyles?: Record<string, IconsAssetsLocation>;
|
||||
/** Server-resolved SVG sources keyed by style and icon. */
|
||||
iconSources?: Record<string, InlineIconSource>;
|
||||
/** Source keys defined in an on-page sprite, which icons reference instead of repeating the markup. */
|
||||
spriteKeys?: ReadonlySet<string>;
|
||||
/** Current default style for icons */
|
||||
iconStyle: IconStyle;
|
||||
};
|
||||
@@ -29,7 +31,17 @@ const IconsContext = React.createContext<IconsContextType>({
|
||||
/**
|
||||
* Provider to control the loading of icons.
|
||||
*/
|
||||
export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContextType>>) {
|
||||
export function IconsProvider(
|
||||
props: React.PropsWithChildren<
|
||||
Partial<Omit<IconsContextType, 'spriteKeys'>> & {
|
||||
/**
|
||||
* Define this provider's own `iconSources` once in a hidden sprite, so icons below
|
||||
* reference them rather than each repeating the SVG markup.
|
||||
*/
|
||||
sprite?: boolean;
|
||||
}
|
||||
>
|
||||
) {
|
||||
const parent = React.useContext(IconsContext);
|
||||
const {
|
||||
children,
|
||||
@@ -38,7 +50,9 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
|
||||
iconStyle = parent.iconStyle,
|
||||
assetsByStyles = parent.assetsByStyles,
|
||||
iconSources = parent.iconSources,
|
||||
sprite = false,
|
||||
} = props;
|
||||
const spriteSources = sprite ? props.iconSources : undefined;
|
||||
const value = React.useMemo(() => {
|
||||
return {
|
||||
assetsURL,
|
||||
@@ -49,10 +63,53 @@ export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContex
|
||||
parent.iconSources && iconSources
|
||||
? { ...parent.iconSources, ...iconSources }
|
||||
: (iconSources ?? parent.iconSources),
|
||||
spriteKeys: spriteSources
|
||||
? new Set([...(parent.spriteKeys ?? []), ...Object.keys(spriteSources)])
|
||||
: parent.spriteKeys,
|
||||
};
|
||||
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles, iconSources, parent.iconSources]);
|
||||
}, [
|
||||
assetsURL,
|
||||
assetsURLToken,
|
||||
iconStyle,
|
||||
assetsByStyles,
|
||||
iconSources,
|
||||
spriteSources,
|
||||
parent.iconSources,
|
||||
parent.spriteKeys,
|
||||
]);
|
||||
|
||||
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
|
||||
return (
|
||||
<IconsContext.Provider value={value}>
|
||||
{spriteSources ? <IconSprite sources={spriteSources} /> : null}
|
||||
{children}
|
||||
</IconsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden SVG holding one copy of each source, for `<use>` references from icons.
|
||||
* Entries are groups rather than symbols so they draw in the referencing icon's own viewBox.
|
||||
*/
|
||||
function IconSprite(props: { sources: Record<string, InlineIconSource> }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-gb-icon-sprite=""
|
||||
width="0"
|
||||
height="0"
|
||||
style={{ position: 'absolute' }}
|
||||
>
|
||||
<defs>
|
||||
{Object.entries(props.sources).map(([key, source]) => (
|
||||
<g
|
||||
key={key}
|
||||
id={getIconSpriteId(key)}
|
||||
dangerouslySetInnerHTML={{ __html: source.markup }}
|
||||
/>
|
||||
))}
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user