This commit is contained in:
Brett Jephson
2026-04-29 18:02:02 +01:00
parent ab96ee806b
commit c765394914
4 changed files with 29 additions and 113 deletions
@@ -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',
@@ -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(
<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"');
});
});
+4 -1
View File
@@ -70,12 +70,15 @@ export async function getIconSymbol(style: string, icon: string, symbolId: strin
return null;
}
const symbol = `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(entry.viewBox)}" overflow="visible">${entry.markup}</symbol>`;
return {
style,
icon,
symbolId,
viewBox: entry.viewBox,
markup: entry.markup,
symbol: `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(entry.viewBox)}" overflow="visible">${entry.markup}</symbol>`,
symbol,
document: `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbol}</defs><use href="#${escapeAttribute(symbolId)}"/></svg>`,
};
}
+24 -1
View File
@@ -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)