diff --git a/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts b/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts index 482832301..39c0e2088 100644 --- a/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts +++ b/packages/gitbook/src/app/~gitbook/icons/symbol/[style]/[icon]/route.ts @@ -23,7 +23,7 @@ export async function GET( ); } - return new NextResponse(symbol.symbol, { + return new NextResponse(symbol.document, { headers: { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'public, max-age=31536000, immutable', diff --git a/packages/gitbook/src/lib/icons/symbols.test.tsx b/packages/gitbook/src/lib/icons/symbols.test.tsx deleted file mode 100644 index 2129c1a27..000000000 --- a/packages/gitbook/src/lib/icons/symbols.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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( - - <> - - - - - - - - ); - - 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"'); - }); -}); diff --git a/packages/gitbook/src/lib/icons/symbols.ts b/packages/gitbook/src/lib/icons/symbols.ts index 75a567c31..92c5aaae3 100644 --- a/packages/gitbook/src/lib/icons/symbols.ts +++ b/packages/gitbook/src/lib/icons/symbols.ts @@ -70,12 +70,15 @@ export async function getIconSymbol(style: string, icon: string, symbolId: strin return null; } + const symbol = `${entry.markup}`; + return { style, icon, symbolId, viewBox: entry.viewBox, markup: entry.markup, - symbol: `${entry.markup}`, + symbol, + document: `${symbol}`, }; } diff --git a/packages/icons/src/IconSymbolLoader.tsx b/packages/icons/src/IconSymbolLoader.tsx index ebc698123..e11453ee5 100644 --- a/packages/icons/src/IconSymbolLoader.tsx +++ b/packages/icons/src/IconSymbolLoader.tsx @@ -38,6 +38,26 @@ function buildSymbolURL(loaderURL: string, style: string, icon: string): string return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}`; } +function appendSymbolsFromDocument(markup: string): boolean { + const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml'); + const symbols = Array.from(parsed.querySelectorAll('symbol')); + if (symbols.length === 0) { + return false; + } + + const spriteRoot = getSpriteRoot(); + for (const symbol of symbols) { + const symbolId = symbol.getAttribute('id'); + if (!symbolId || hasSymbol(symbolId)) { + continue; + } + + spriteRoot.appendChild(document.importNode(symbol, true)); + } + + return true; +} + async function loadSymbol(symbolId: string, loaderURL: string, style: string, icon: string) { if (hasSymbol(symbolId)) { return true; @@ -61,7 +81,10 @@ async function loadSymbol(symbolId: string, loaderURL: string, style: string, ic return true; } - getSpriteRoot().insertAdjacentHTML('beforeend', symbolMarkup); + if (!appendSymbolsFromDocument(symbolMarkup)) { + return false; + } + return hasSymbol(symbolId); }) .catch(() => false)