Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-bulldogs-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Improve default site icon
4 changes: 2 additions & 2 deletions packages/gitbook/src/components/Header/CurrentContentIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ export function CurrentContentIcon(
}
: {
light: {
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=light'),
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
size: { width: 256, height: 256 },
},
dark: {
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark'),
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
size: { width: 256, height: 256 },
},
}
Expand Down
36 changes: 35 additions & 1 deletion packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;

const faviconSize = 48;
const appIconSize = 180;

const icons = await Promise.all(
[
{
Expand Down Expand Up @@ -140,12 +142,44 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
}))
);

const appIcons = await Promise.all(
[
{
url: customIcon?.light
? getResizedImageURL(imageResizer, customIcon.light, {
width: appIconSize,
height: appIconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=medium&theme=light&border=false')
),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: customIcon?.dark
? getResizedImageURL(imageResizer, customIcon.dark, {
width: appIconSize,
height: appIconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark&border=false')
),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
].map(async (icon) => ({
...icon,
url: await icon.url,
}))
);

return {
title: site.title,
generator: `GitBook (${buildVersion()})`,
icons: {
icon: icons,
apple: icons,
apple: appIcons,
},
appleWebApp: {
capable: true,
Expand Down
123 changes: 123 additions & 0 deletions packages/gitbook/src/lib/imageFonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { CustomizationDefaultFont } from '@gitbook/api';
import { type FontWeight, getDefaultFont } from '@gitbook/fonts';

import { getFontSourcesToPreload } from '@/fonts/custom';
import type { GitBookSiteContext } from '@/lib/context';
import { filterOutNullable } from '@/lib/typescript';

type ComputeFontsInput = {
regularText: string;
boldText: string;
};

export async function computeImageFonts(
customization: GitBookSiteContext['customization'],
input: ComputeFontsInput
) {
// Google fonts
if (typeof customization.styling.font === 'string') {
const fontFamily = customization.styling.font ?? CustomizationDefaultFont.Inter;

const fonts = (
await Promise.all([
loadGoogleFont({ font: fontFamily, text: input.regularText, weight: 400 }),
loadGoogleFont({ font: fontFamily, text: input.boldText, weight: 700 }),
])
).filter(filterOutNullable);

return { fontFamily, fonts } as const;
}

// Custom fonts
// We only load the primary font weights for now
const primaryFontWeights = getFontSourcesToPreload(customization.styling.font);

const fonts = (
await Promise.all(
primaryFontWeights.map((face) => {
const { weight, sources } = face;
const source = sources[0];

// Satori doesn't support WOFF2, so we skip it
// https://github.com/vercel/satori?tab=readme-ov-file#fonts
if (!source || source.format === 'woff2' || source.url.endsWith('.woff2')) {
return null;
}

return loadCustomFont({ url: source.url, weight: weight as 400 | 700 });
})
)
).filter(filterOutNullable);

return { fontFamily: 'CustomFont', fonts } as const;
}

async function loadGoogleFont(input: {
font: CustomizationDefaultFont;
text: string;
weight: FontWeight;
}) {
const lookup = getDefaultFont({
font: input.font,
text: input.text,
weight: input.weight,
});

// If we found a font file, load it
if (lookup) {
return getWithCache(`google-font-files:${lookup.url}`, async () => {
const response = await fetch(lookup.url);
if (response.ok) {
const data = await response.arrayBuffer();
return {
name: lookup.font,
data,
style: 'normal' as const,
weight: input.weight,
};
}
});
}

// If for some reason we can't load the font, we'll just use the default one
return null;
}

async function loadCustomFont(input: { url: string; weight: 400 | 700 }) {
const { url, weight } = input;
const response = await fetch(url);
if (!response.ok) {
return null;
}

const data = await response.arrayBuffer();

return {
name: 'CustomFont',
data,
style: 'normal' as const,
weight,
} as const;
}

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const staticCache = new Map<string, any>();

async function getWithCache<T>(key: string, fn: () => Promise<T>) {
const cached = staticCache.get(key) as Promise<T>;
if (cached) {
return cached;
}

const promise = fn();
staticCache.set(key, promise);

try {
const result = await promise;
return result;
} catch (error) {
// Remove the failed promise from cache so it can be retried
staticCache.delete(key);
throw error;
}
}
97 changes: 76 additions & 21 deletions packages/gitbook/src/routes/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,51 @@ import { ImageResponse } from 'next/og';

import type { GitBookSiteContext } from '@/lib/context';
import { getEmojiForCode } from '@/lib/emojis';
import { computeImageFonts } from '@/lib/imageFonts';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { getCacheTag } from '@gitbook/cache-tags';
import { colorScale } from '@gitbook/colors';

const SIZES = {
/** Size for a favicon */
small: {
width: 48,
height: 48,
textSize: 'text-[32px]',
boxStyle: 'rounded-[8px]',
boxStyle: 8,
borderWidth: 1,
},
/** Size for display as an app icon or in the header */
/** Size for display as iOS app icon */
medium: {
width: 180,
height: 180,
textSize: 'text-[115px]',
boxStyle: 22.5,
borderWidth: 3,
},
/** Size for display in the site header */
large: {
width: 256,
height: 256,
textSize: 'text-[164px]',
boxStyle: 'rounded-[32px]',
boxStyle: 32,
borderWidth: 4,
},
/** Size for display as Android app icon */
xlarge: {
width: 512,
height: 512,
textSize: 'text-[328px]',
boxStyle: 64,
borderWidth: 8,
},
};

type RenderIconOptions = {
size: keyof typeof SIZES;
theme: 'light' | 'dark';
border: boolean;
};

/**
Expand All @@ -50,18 +71,33 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) {
);
}

return new ImageResponse(<SiteDefaultIcon context={context} options={options} />, {
width: size.width,
height: size.height,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: context.site.id,
}),
].join(','),
},
// Compute font loading based on site title or emoji
const regularText = '';
const boldText =
'emoji' in customization.favicon ? '' : context.site.title.slice(0, 1).toUpperCase();
const { fontFamily, fonts } = await computeImageFonts(customization, {
regularText,
boldText,
});

return new ImageResponse(
<div style={{ fontFamily, width: '100%', height: '100%', display: 'flex' }}>
<SiteDefaultIcon context={context} options={options} />
</div>,
{
width: size.width,
height: size.height,
fonts: fonts.length ? fonts : undefined,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: context.site.id,
}),
].join(','),
},
}
);
}

/**
Expand All @@ -79,25 +115,40 @@ export function SiteDefaultIcon(props: {
const { site, customization } = context;
const contentTitle = site.title;

const primaryScale = colorScale(customization.styling.primaryColor[options.theme], {
darkMode: options.theme === 'dark',
});

console.log('options', options);

return (
<div
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle, tw)}
tw={tcls(tw)}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: options.border ? size.borderWidth : 0,
borderColor: primaryScale[7],
borderRadius: options.border
? customization.styling.corners === 'rounded'
? `${size.boxStyle}px`
: customization.styling.corners === 'circular'
? '50%'
: '0px'
: 0,
background: `linear-gradient(to bottom, ${primaryScale[options.theme === 'light' ? 2 : 6]} 0%, ${primaryScale[4]} 100%)`,
...style,
}}
>
<h2
tw={tcls(
size.textSize,
'font-bold',
'tracking-tight',
options.theme === 'light' ? 'text-black' : 'text-white'
)}
tw={tcls(size.textSize, 'font-bold', 'tracking-tight')}
style={{
color: primaryScale[10],
textShadow: `0 .05em .1em ${primaryScale[7]}`,
}}
>
{'emoji' in customization.favicon
? getEmojiForCode(customization.favicon.emoji)
Expand All @@ -110,10 +161,12 @@ export function SiteDefaultIcon(props: {
function getOptions(inputUrl: string): {
size: keyof typeof SIZES;
theme: 'light' | 'dark';
border: boolean;
} {
const url = new URL(inputUrl);
const sizeParam = (url.searchParams.get('size') ?? 'small') as keyof typeof SIZES;
const themeParam = url.searchParams.get('theme') ?? 'light';
const borderParam = url.searchParams.get('border') !== 'false';

if (!SIZES[sizeParam] || !['light', 'dark'].includes(themeParam)) {
notFound();
Expand All @@ -124,5 +177,7 @@ function getOptions(inputUrl: string): {
size: sizeParam,
// @ts-ignore
theme: themeParam,
// @ts-ignore
border: borderParam,
};
}
Loading