Render headings with the site's heading font (#4531)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zeno Kapitein
2026-08-25 12:08:03 +02:00
committed by GitHub
parent 8d7c3edda8
commit e0bd04f641
25 changed files with 166 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render headings with the site's heading font when one is configured, falling back to the main font otherwise.
+2 -2
View File
@@ -354,7 +354,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.195.0",
"@gitbook/api": "0.196.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +726,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.195.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-66OGIiiKUfHB7dF8fb9Ai8PkhQldD4SSQXHZ+X+SSEki2WXpgn6ze+PEXRQvDsJj2JKEvD9A7FfaGzUYiYn9hA=="],
"@gitbook/api": ["@gitbook/api@0.196.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-14f3LXiZljPuxFtYbsdH9mkXN4jucRBnF3QpSACuACDdvJHpTqGsCe52chg3nj72XBbMjD57BsXGklY+IYz9gw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -48,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.195.0",
"@gitbook/api": "0.196.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
-5
View File
@@ -308,11 +308,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.triumpharcade.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.nats.io',
contentBaseURL: 'https://docs.nats.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'help.glpi-project.org',
contentBaseURL: 'https://help.glpi-project.org',
@@ -260,7 +260,7 @@ export function AIChatBody(props: {
</div>
<div className="flex flex-col items-start gap-1 [@container(min-height:400px)]:items-center">
<h5
className="animate-blur-in-slow text-lg font-bold leading-tight text-tint-strong [@container(min-height:400px)]:text-center"
className="animate-blur-in-slow font-heading text-lg font-bold leading-tight text-tint-strong [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-greeting-title"
>
@@ -31,6 +31,7 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls(
textStyle.textSize,
'heading',
'font-heading',
'pdf-heading',
'block',
'pr-6',
@@ -87,7 +87,7 @@ export function EmbeddableFrameBody(props: { children: React.ReactNode; classNam
export function EmbeddableFrameTitle(props: { children: React.ReactNode }) {
const { children } = props;
return <div className="font-bold">{children}</div>;
return <div className="font-heading font-bold">{children}</div>;
}
export function EmbeddableFrameClose() {
@@ -19,7 +19,7 @@ export function FooterLinksGroup(props: {
return (
<nav className="flex flex-col gap-4 text-sm">
<h4 className="font-semibold">{title}</h4>
<h4 className="font-heading font-semibold">{title}</h4>
<ul className="flex flex-col items-start gap-4">
{group.links.map((link, index) => {
return (
@@ -91,6 +91,7 @@ function LogoFallback(props: HeaderLogoProps) {
'tracking-tight',
'max-w-[18ch]',
'lg:max-w-[24ch]',
'font-heading',
'font-semibold',
'ms-3',
'text-base/tight',
@@ -180,7 +180,9 @@ async function PDFSpaceIntro(props: { space: Space; customization: SiteCustomiza
return (
<PrintPage isFirst>
<div className="flex items-center justify-center py-12">
<h1 className="text-6xl font-bold">{customization.title ?? space.title}</h1>
<h1 className="font-heading text-6xl font-bold">
{customization.title ?? space.title}
</h1>
</div>
</PrintPage>
);
@@ -192,7 +194,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<div className="mt-10 flex break-before-page items-center justify-center py-12 print:mt-0">
<h1 className="text-5xl font-bold">{page.title}</h1>
<h1 className="font-heading text-5xl font-bold">{page.title}</h1>
</div>
</PrintPage>
);
@@ -207,7 +209,7 @@ async function PDFPageDocument(props: {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<h1 className="text-4xl font-bold">{page.title}</h1>
<h1 className="font-heading text-4xl font-bold">{page.title}</h1>
{page.description ? (
<p className="mb-3 mt-2 decoration-primary/6">{page.description}</p>
) : null}
@@ -242,6 +242,7 @@ export async function PageHeader(props: {
'@xs:text-3xl',
'@lg:text-4xl',
'leading-tight',
'font-heading',
'font-bold',
'flex',
'items-center',
@@ -31,6 +31,7 @@ import {
type FontData,
generateEmojiFontFacesCSS,
getFontData,
getHeadingFont,
} from '@/fonts';
import './globals.css';
import { getContentLocale, getSpaceLanguage } from '@/intl/server';
@@ -99,10 +100,16 @@ export async function CustomizationRootLayout(props: {
customization.styling.monospaceFont ?? DEFAULT_MONOSPACE_FONT,
'mono'
);
// Only set when headings use a font of their own; otherwise they inherit `--font-content`.
const headingFont = getHeadingFont(customization);
const headingFontData = headingFont ? getFontData(headingFont, 'heading') : null;
// Preconnect and preload custom fonts if needed
preloadFont(fontData);
preloadFont(monospaceFontData);
if (headingFontData) {
preloadFont(headingFontData);
}
const iconStyle = getCustomizationIconStyle(customization);
const iconSources = await getInlineIconSources([
...getDefaultInlineIconSourceRequests(iconStyle),
@@ -132,6 +139,8 @@ export async function CustomizationRootLayout(props: {
typeof customization.styling.font === 'string'
? `font-${customization.styling.font}`
: null,
headingFont ? 'has-heading-font' : null,
typeof headingFont === 'string' ? `heading-font-${headingFont}` : null,
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationDefaultThemeMode.Dark
@@ -154,6 +163,7 @@ export async function CustomizationRootLayout(props: {
<style>{generateEmojiFontFacesCSS()}</style>
<style>{fontData.fontFaceRules}</style>
<style>{monospaceFontData.fontFaceRules}</style>
{headingFontData ? <style>{headingFontData.fontFaceRules}</style> : null}
{/* Inject a script to detect if the announcmeent banner has been dismissed */}
{'announcement' in customization && customization.announcement?.enabled ? (
@@ -375,6 +375,17 @@ html.font-Lato {
font-variant-ligatures: no-common-ligatures;
}
/* Headings with a font of their own opt out of the content font's ligature workaround. */
html.has-heading-font .font-heading {
font-variant-ligatures: normal;
}
/* Lato needs the workaround wherever it is used, the heading font included. Declared after the
opt-out above so it wins at equal specificity. */
html.heading-font-Lato .font-heading {
font-variant-ligatures: no-common-ligatures;
}
/* Code blocks */
/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
* These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
@@ -81,7 +81,7 @@ function LocalSearchResultCard({
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate text-sm font-semibold leading-snug text-tint-strong">
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
@@ -72,7 +72,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
{...rest}
>
<Breadcrumbs breadcrumbs={item.breadcrumbs} />
<p className="line-clamp-1 text-base font-semibold leading-snug text-tint-strong">
<p className="line-clamp-1 font-heading text-base font-semibold leading-snug text-tint-strong">
<HighlightQuery query={query} text={item.title} />
</p>
<div
@@ -37,7 +37,7 @@ export async function ConsentScreen(props: {
</div>
{/* Request statement — toned down, with the client and site names emphasized. */}
<h1 className="text-xl leading-snug text-tint sm:text-2xl">
<h1 className="font-heading text-xl leading-snug text-tint sm:text-2xl">
{t(
language,
'auth_consent_request',
@@ -69,7 +69,7 @@ export async function ConsentScreen(props: {
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-x-2 gap-y-1">
<h2 className="text-base font-semibold text-tint-strong">
<h2 className="font-heading text-base font-semibold text-tint-strong">
{client.name}
</h2>
<ClientTrustBadge
@@ -219,7 +219,7 @@ export async function ConsentError(props: {
<Icon icon="circle-exclamation" className="size-6" />
</span>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-tint-strong">{title}</h1>
<h1 className="font-heading text-lg font-semibold text-tint-strong">{title}</h1>
<p className="text-tint">{message}</p>
</div>
</ConsentCard>
@@ -117,7 +117,13 @@ export function SitePageNotFound() {
<div className="flex w-full flex-col items-center gap-8">
<div className="flex w-full flex-col items-center gap-2 rounded-3xl">
<h1 className={tcls('text-3xl', 'font-semibold', 'text-tint-strong')}>
<h1
className={tcls(
'text-3xl',
'font-semibold font-heading',
'text-tint-strong'
)}
>
{t(
language,
adaptiveAuthLoginHref
@@ -197,7 +203,9 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
return loading || hasResults ? (
<div className="-mt-4 flex w-full flex-col gap-2 border-tint-subtle bg-tint-subtle p-8 pt-10 theme-muted:bg-tint theme-gradient:border rounded-corners:rounded-b-xl circular-corners:rounded-b-3xl">
<h2 className="font-medium text-tint">{t(language, 'notfound_suggestions_title')}</h2>
<h2 className="font-heading font-medium text-tint">
{t(language, 'notfound_suggestions_title')}
</h2>
<ul className="flex flex-col gap-2">
{loading ? (
<SkeletonList items={RELATED_PAGES_COUNT} className="my-0.75 max-w-md" />
@@ -58,7 +58,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
className={tcls(
ToCButtonItemStyles,
'toc-group min-h-8 w-full border-0 text-left',
'font-semibold text-xs uppercase tracking-wide',
'font-heading font-semibold text-xs uppercase tracking-wide',
'appearance-none',
'[&_.toc-group-chevron]:transition-opacity',
'hover:[&_.toc-group-chevron]:opacity-11',
+5 -3
View File
@@ -1,17 +1,19 @@
import type { CustomizationFontDefinitionInput } from '@gitbook/api';
import type { FontRole } from './types';
/**
* Define the custom font faces and set the --font-content or --font-mono variable
* Define the custom font faces and set the --font-content, --font-heading or --font-mono variable
* to the custom font name.
*/
export function generateFontFacesCSS(
customFont: CustomizationFontDefinitionInput,
type: 'content' | 'mono'
type: FontRole
): string {
const { fontFaces } = customFont;
const fontFamilyName = `CustomFont_${type}`;
const fontVariableName = `--font-${type}`;
const fallbackFont = type === 'content' ? 'sans-serif' : 'monospace';
const fallbackFont = type === 'mono' ? 'monospace' : 'sans-serif';
// Generate font face declarations for all weights
const fontFaceDeclarations = fontFaces
+8 -7
View File
@@ -2,21 +2,22 @@ import { CustomizationDefaultMonospaceFont } from '@gitbook/api';
import { EMOJI_FONT, type FontName } from './definitions';
import faces from './generated/faces.json';
import type { FontFacesData, FontFamilyData } from './types';
import type { FontFacesData, FontFamilyData, FontRole } from './types';
import { getAssetURL } from '@/lib/assets';
const fontFaces = faces as FontFacesData;
// The rules only vary by font, and every page renders a few dozen of them.
const cache = new Map<FontName, string>();
// The rules only vary by font and the variable they bind, and every page renders a few dozen.
const cache = new Map<string, string>();
/** Used until the cache has warmed up for sites saved before the setting existed. */
export const DEFAULT_MONOSPACE_FONT = CustomizationDefaultMonospaceFont.IBMPlexMono;
// Emitted per picked family and inlined in the head, so a page never carries the other 20 families
// the way a shared `next/font` stylesheet did.
export function generateDefaultFontFacesCSS(font: FontName): string {
const cached = cache.get(font);
export function generateDefaultFontFacesCSS(font: FontName, role?: FontRole): string {
const key = role ? `${font}:${role}` : font;
const cached = cache.get(key);
if (cached !== undefined) {
return cached;
}
@@ -33,12 +34,12 @@ export function generateDefaultFontFacesCSS(font: FontName): string {
)
),
generateFallbackFace(family),
`:root { ${family.variable}: ${family.fontFamilyValue}; }`,
`:root { ${role ? `--font-${role}` : family.variable}: ${family.fontFamilyValue}; }`,
]
.filter(Boolean)
.join('\n');
cache.set(font, css);
cache.set(key, css);
return css;
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont, type SiteCustomizationSettings } from '@gitbook/api';
import { getHeadingFont } from './heading';
import { defaultCustomization } from '@/lib/utils';
const CUSTOM_FONT = {
id: 'font_1',
custom: true,
fontFamily: 'Segoe UI',
fontFaces: [{ weight: 400 as const, sources: [{ url: 'https://example.com/segoe.woff2' }] }],
};
function customization(
styling: Partial<SiteCustomizationSettings['styling']>
): SiteCustomizationSettings {
const base = defaultCustomization();
return { ...base, styling: { ...base.styling, ...styling } };
}
describe('getHeadingFont', () => {
it('returns null when no heading font is set', () => {
expect(getHeadingFont(customization({}))).toBeNull();
});
it('returns the heading font when it differs from the content font', () => {
const settings = customization({
font: CustomizationDefaultFont.Inter,
headingFont: CustomizationDefaultFont.Merriweather,
});
expect(getHeadingFont(settings)).toBe(CustomizationDefaultFont.Merriweather);
});
it('returns null when the heading font is the content font', () => {
const settings = customization({
font: CustomizationDefaultFont.Inter,
headingFont: CustomizationDefaultFont.Inter,
});
expect(getHeadingFont(settings)).toBeNull();
});
it('compares custom fonts by id', () => {
expect(
getHeadingFont(customization({ font: CUSTOM_FONT, headingFont: CUSTOM_FONT }))
).toBeNull();
expect(
getHeadingFont(
customization({
font: CUSTOM_FONT,
headingFont: { ...CUSTOM_FONT, id: 'font_2' },
})
)
).toMatchObject({ id: 'font_2' });
});
it('ignores a custom heading font when the content font is a default one', () => {
const settings = customization({
font: CustomizationDefaultFont.Inter,
headingFont: CUSTOM_FONT,
});
expect(getHeadingFont(settings)).toMatchObject({ id: 'font_1' });
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { CustomizationFont, SiteCustomizationSettings } from '@gitbook/api';
/**
* Font to render headings with, or `null` when they should use the content font.
*/
export function getHeadingFont(customization: SiteCustomizationSettings): CustomizationFont | null {
const { headingFont, font } = customization.styling;
if (!headingFont) {
return null;
}
// Headings only need their own faces when they don't already come with the content font.
return isSameFont(headingFont, font) ? null : headingFont;
}
function isSameFont(a: CustomizationFont, b: CustomizationFont): boolean {
if (typeof a === 'string' || typeof b === 'string') {
return a === b;
}
return a.id === b.id;
}
+7 -2
View File
@@ -6,8 +6,11 @@ import type {
import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
import { generateDefaultFontFacesCSS } from './default';
import type { FontRole } from './types';
export { DEFAULT_MONOSPACE_FONT, generateEmojiFontFacesCSS } from './default';
export type { FontRole } from './types';
export { getHeadingFont } from './heading';
/**
* Represents font data for either a default font or a custom font
@@ -39,12 +42,14 @@ interface CustomFontData extends BaseFontData {
*/
export function getFontData(
font: CustomizationFont | CustomizationMonospaceFont,
type: 'content' | 'mono'
type: FontRole
): FontData {
if (typeof font === 'string') {
return {
type: 'default',
fontFaceRules: generateDefaultFontFacesCSS(font),
// The heading font shares the default families with the content font, so it has to bind
// the family to `--font-heading` rather than the family's own variable.
fontFaceRules: generateDefaultFontFacesCSS(font, type === 'heading' ? type : undefined),
};
}
+3
View File
@@ -1,3 +1,6 @@
/** Which of the document's font variables a picked family is bound to. */
export type FontRole = 'content' | 'heading' | 'mono';
export interface FontVariantData {
weight: string;
style: string;
+2
View File
@@ -71,6 +71,8 @@ const config: Config = {
},
fontFamily: {
sans: ['var(--font-content)'],
// Sites without a heading font of their own fall back to the content font.
heading: ['var(--font-heading, var(--font-content))'],
mono: ['var(--font-mono)'],
emoji: [
'Apple Color Emoji',