mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
Render headings with the site's heading font (#4531)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user