mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 11:03:39 +00:00
Improve default site icon (#3679)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve default site icon
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(<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(','),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -173,6 +135,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
options={{
|
||||
size: 'small',
|
||||
theme,
|
||||
border: true,
|
||||
}}
|
||||
style={faviconSize}
|
||||
/>
|
||||
@@ -269,54 +232,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: <explanation>
|
||||
const staticCache = new Map<string, any>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user