From 713236937c7040c59c97185708ef439538bb4352 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 23 Sep 2025 20:17:05 +0200 Subject: [PATCH 1/3] Improve default site icon - Improve design of icon, using primary colour and optional border - Improve `apple-touch-icon` support - Move image font rendering to `lib` and use it across `ogimage` and `icon` routes --- .../components/Header/CurrentContentIcon.tsx | 4 +- .../src/components/SiteLayout/SiteLayout.tsx | 36 +++++- packages/gitbook/src/lib/imageFonts.ts | 115 ++++++++++++++++++ packages/gitbook/src/routes/icon.tsx | 97 +++++++++++---- packages/gitbook/src/routes/ogimage.tsx | 100 ++------------- 5 files changed, 235 insertions(+), 117 deletions(-) create mode 100644 packages/gitbook/src/lib/imageFonts.ts diff --git a/packages/gitbook/src/components/Header/CurrentContentIcon.tsx b/packages/gitbook/src/components/Header/CurrentContentIcon.tsx index ba99f7eff9..71bcd375cb 100644 --- a/packages/gitbook/src/components/Header/CurrentContentIcon.tsx +++ b/packages/gitbook/src/components/Header/CurrentContentIcon.tsx @@ -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 }, }, } diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 0e1460d44f..23464fc075 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -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( [ { @@ -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, diff --git a/packages/gitbook/src/lib/imageFonts.ts b/packages/gitbook/src/lib/imageFonts.ts new file mode 100644 index 0000000000..ea0ed2a3e1 --- /dev/null +++ b/packages/gitbook/src/lib/imageFonts.ts @@ -0,0 +1,115 @@ +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: +const staticCache = new Map(); + +async function getWithCache(key: string, fn: () => Promise) { + const cached = staticCache.get(key) as T; + if (cached) { + return Promise.resolve(cached); + } + + const result = await fn(); + staticCache.set(key, result); + return result; +} diff --git a/packages/gitbook/src/routes/icon.tsx b/packages/gitbook/src/routes/icon.tsx index 98c6198394..f31eec11db 100644 --- a/packages/gitbook/src/routes/icon.tsx +++ b/packages/gitbook/src/routes/icon.tsx @@ -3,9 +3,11 @@ 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 */ @@ -13,20 +15,39 @@ const SIZES = { 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; }; /** @@ -50,18 +71,33 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) { ); } - return new ImageResponse(, { - 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( +
+ +
, + { + width: size.width, + height: size.height, + fonts: fonts.length ? fonts : undefined, + headers: { + 'cache-tag': [ + getCacheTag({ + tag: 'site', + site: context.site.id, + }), + ].join(','), + }, + } + ); } /** @@ -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 (

{'emoji' in customization.favicon ? getEmojiForCode(customization.favicon.emoji) @@ -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(); @@ -124,5 +177,7 @@ function getOptions(inputUrl: string): { size: sizeParam, // @ts-ignore theme: themeParam, + // @ts-ignore + border: borderParam, }; } diff --git a/packages/gitbook/src/routes/ogimage.tsx b/packages/gitbook/src/routes/ogimage.tsx index 44cf934bab..04caf6aa43 100644 --- a/packages/gitbook/src/routes/ogimage.tsx +++ b/packages/gitbook/src/routes/ogimage.tsx @@ -1,15 +1,14 @@ -import { CustomizationDefaultFont, CustomizationHeaderPreset } from '@gitbook/api'; +import { CustomizationHeaderPreset } from '@gitbook/api'; import { colorContrast } from '@gitbook/colors'; -import { type FontWeight, getDefaultFont } from '@gitbook/fonts'; import { direction } from 'direction'; import { imageSize } from 'image-size'; import { redirect } from 'next/navigation'; import { ImageResponse } from 'next/og'; import { type PageParams, fetchPageData } from '@/components/SitePage'; -import { getFontSourcesToPreload } from '@/fonts/custom'; import { getAssetURL } from '@/lib/assets'; import type { GitBookSiteContext } from '@/lib/context'; +import { computeImageFonts } from '@/lib/imageFonts'; import { type ResizeImageOptions, SizableImageAction, @@ -18,7 +17,6 @@ import { resizeImage, } from '@/lib/images'; import { getExtension } from '@/lib/paths'; -import { filterOutNullable } from '@/lib/typescript'; import { getCacheTag } from '@gitbook/cache-tags'; import { SiteDefaultIcon } from './icon'; @@ -54,47 +52,11 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page : ''; // Load the fonts - const fontLoader = async () => { - // google fonts - if (typeof customization.styling.font === 'string') { - const fontFamily = customization.styling.font ?? CustomizationDefaultFont.Inter; - - const regularText = pageDescription; - const boldText = `${site.title} ${pageTitle}`; - - const fonts = ( - await Promise.all([ - loadGoogleFont({ font: fontFamily, text: regularText, weight: 400 }), - loadGoogleFont({ font: fontFamily, text: boldText, weight: 700 }), - ]) - ).filter(filterOutNullable); - - return { fontFamily, fonts }; - } - - // 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 }); - }) - ) - ).filter(filterOutNullable); - - return { fontFamily: 'CustomFont', fonts }; - }; + const fontLoader = async () => + computeImageFonts(customization, { + regularText: pageDescription, + boldText: `${site.title} ${pageTitle}`, + }); const theme = customization.themes.default; const useLightTheme = theme === 'light'; @@ -269,54 +231,6 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page ); } -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, - }; -} - // biome-ignore lint/suspicious/noExplicitAny: const staticCache = new Map(); From af3e3e3a1e53fae63e7b9fe2b1915ed64641657b Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 23 Sep 2025 20:22:06 +0200 Subject: [PATCH 2/3] Typecheck --- .changeset/fair-bulldogs-double.md | 5 +++++ packages/gitbook/src/routes/ogimage.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/fair-bulldogs-double.md diff --git a/.changeset/fair-bulldogs-double.md b/.changeset/fair-bulldogs-double.md new file mode 100644 index 0000000000..687d9a8757 --- /dev/null +++ b/.changeset/fair-bulldogs-double.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Improve default site icon diff --git a/packages/gitbook/src/routes/ogimage.tsx b/packages/gitbook/src/routes/ogimage.tsx index 04caf6aa43..448e9d9a9a 100644 --- a/packages/gitbook/src/routes/ogimage.tsx +++ b/packages/gitbook/src/routes/ogimage.tsx @@ -135,6 +135,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page options={{ size: 'small', theme, + border: true, }} style={faviconSize} /> From 49bf835b6256259374975820135f37db74dbb3fd Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 23 Sep 2025 21:36:57 +0200 Subject: [PATCH 3/3] Review --- packages/gitbook/src/lib/imageFonts.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/lib/imageFonts.ts b/packages/gitbook/src/lib/imageFonts.ts index ea0ed2a3e1..0c075dcd83 100644 --- a/packages/gitbook/src/lib/imageFonts.ts +++ b/packages/gitbook/src/lib/imageFonts.ts @@ -104,12 +104,20 @@ async function loadCustomFont(input: { url: string; weight: 400 | 700 }) { const staticCache = new Map(); async function getWithCache(key: string, fn: () => Promise) { - const cached = staticCache.get(key) as T; + const cached = staticCache.get(key) as Promise; if (cached) { - return Promise.resolve(cached); + return cached; } - const result = await fn(); - staticCache.set(key, result); - return result; + 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; + } }