Merge branch 'main' into brett/font-clipping

This commit is contained in:
Brett Jephson
2026-04-29 17:42:05 +01:00
committed by GitHub
5 changed files with 194 additions and 87 deletions
+8 -6
View File
@@ -140,7 +140,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.2.1",
"@vercel/agent-readability": "^0.4.0",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -160,7 +160,7 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.12.3",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
@@ -910,7 +910,7 @@
"@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="],
@@ -1722,7 +1722,9 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
"@vercel/agent-readability": ["@vercel/agent-readability@0.2.1", "", { "peerDependencies": { "next": ">=14" }, "optionalPeers": ["next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"@vercel/agent-readability": ["@vercel/agent-readability@0.4.0", "", { "peerDependencies": { "@sveltejs/kit": ">=2", "h3": ">=1.8 <2", "next": ">=14" }, "optionalPeers": ["@sveltejs/kit", "h3", "next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-+qcRxDEbKKJzRlLqsZUnet63kepr8RikEOlT9pVEru/GNkiSinwZEJxyfUhdUpzav8El2v6DZybpyQuvlynAhQ=="],
"@vercel/backends": ["@vercel/backends@0.0.53", "", { "dependencies": { "@vercel/build-utils": "13.12.0", "@vercel/nft": "1.5.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-f8JK9Jatlco6kT/NsLYVR88iqS7oZxUXzXW8TofLmyTT5o+OEXSY2hzAQSjul6r0qz/9yAhtQkMhcHG8Ne01ZQ=="],
@@ -2122,7 +2124,7 @@
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
@@ -2744,7 +2746,7 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="],
"mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
+2 -2
View File
@@ -32,7 +32,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.2.1",
"@vercel/agent-readability": "^0.4.0",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -52,7 +52,7 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.12.3",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
@@ -3,9 +3,11 @@
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
@@ -14,8 +16,9 @@ import { getPlainCodeBlock } from './highlight';
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, style } = props;
const { block, mode, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
@@ -24,8 +27,14 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
const shouldRender = mode === 'print' || hasBeenInViewport;
useEffect(() => {
if (!shouldRender) {
return;
}
const container = diagramRef.current;
const wrapper = wrapperRef.current;
if (!container || !wrapper) {
@@ -36,46 +45,67 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
let cleanupPanZoom: (() => void) | undefined;
setError(false);
setIsLoading(true);
setPanZoom(null);
container.innerHTML = '';
const cancelScheduledRender = scheduleMermaidWork(() => {
enqueueMermaidRender(async () => {
if (cancelled) {
return null;
}
return renderMermaidDiagram({
source,
id,
darkMode,
});
})
.then((result) => {
if (!result || cancelled) {
return;
}
container.innerHTML = result.svg;
if (container.querySelector('svg')) {
result.bindFunctions?.(container);
}
renderMermaidDiagram({
container,
source,
id,
darkMode,
})
.then(() => {
if (!cancelled) {
cleanupPanZoom = initPanzoom({
container,
wrapper,
onInit: setPanZoom,
});
}
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
});
return () => {
cancelled = true;
cancelScheduledRender();
cleanupPanZoom?.();
setPanZoom(null);
};
}, [source, id, darkMode]);
}, [source, id, darkMode, shouldRender]);
if (error) {
return <ClientCodeBlock {...props} />;
}
return (
<div className={tcls('relative', style)} contentEditable={false}>
<div
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
>
<div
ref={wrapperRef}
className={
@@ -100,18 +130,12 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
}
async function renderMermaidDiagram(args: {
container: HTMLElement;
source: string;
id: string;
darkMode: boolean;
}) {
const { container, source, id, darkMode } = args;
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
import('mermaid'),
import('@mermaid-js/mermaid-zenuml'),
]);
await mermaid.registerExternalDiagrams([zenuml]);
}): Promise<RenderResult> {
const { source, id, darkMode } = args;
const { mermaid } = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
@@ -120,11 +144,115 @@ async function renderMermaidDiagram(args: {
theme: darkMode ? 'dark' : undefined,
});
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
container.innerHTML = svg;
bindFunctions?.(container);
const renderContainer = createMermaidRenderContainer();
try {
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
} finally {
renderContainer.remove();
}
}
/**
* Mermaid measures labels while rendering, so the temporary render target must be
* connected to the document. Keep it fixed and contained so those layout reads do
* not walk the visible document flow.
*/
function createMermaidRenderContainer() {
const container = document.createElement('div');
container.setAttribute('aria-hidden', 'true');
Object.assign(container.style, {
contain: 'strict',
height: '100vh',
isolation: 'isolate',
left: '0',
overflow: 'hidden',
pointerEvents: 'none',
position: 'fixed',
top: '0',
visibility: 'hidden',
width: '100vw',
zIndex: '-1',
});
document.body.appendChild(container);
return container;
}
let mermaidLoadPromise: Promise<{
mermaid: typeof import('mermaid')['default'];
}> | null = null;
async function loadMermaid() {
if (!mermaidLoadPromise) {
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
await mermaid.registerExternalDiagrams([zenuml]);
return { mermaid };
})
.catch((error) => {
mermaidLoadPromise = null;
throw error;
});
}
return mermaidLoadPromise;
}
let mermaidRenderQueue = Promise.resolve();
function enqueueMermaidRender<T>(task: () => Promise<T>) {
const result = mermaidRenderQueue.then(task, task);
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
return result;
}
function waitForNextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function scheduleMermaidWork(callback: () => void) {
const win = window as WindowWithIdleCallback;
let cancelled = false;
if (win.requestIdleCallback && win.cancelIdleCallback) {
const handle = win.requestIdleCallback(
() => {
if (!cancelled) {
callback();
}
},
{ timeout: 1500 }
);
return () => {
cancelled = true;
win.cancelIdleCallback?.(handle);
};
}
const handle = window.setTimeout(() => {
if (!cancelled) {
callback();
}
});
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}
type WindowWithIdleCallback = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (handle: number) => void;
};
/**
* Initialize panzoom on the diagram container.
*/
@@ -138,7 +266,6 @@ function initPanzoom(args: {
const instance = Panzoom(container, {
maxScale: 5,
minScale: 0.5,
contain: 'outside',
cursor: 'grab',
panOnlyWhenZoomed: true,
});
@@ -11,70 +11,42 @@ const PAN_STEP = 50;
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
};
return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
label="Pan up"
{...btnProps}
icon="chevron-up"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button
label="Zoom in"
icon="plus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomIn()}
/>
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
{/* Row 2: pan left, reset, pan right */}
<Button
label="Pan left"
{...btnProps}
icon="chevron-left"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
<Button
label="Reset view"
icon="refresh"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.reset()}
/>
<Button
label="Pan right"
{...btnProps}
icon="chevron-right"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
label="Pan down"
{...btnProps}
icon="chevron-down"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button
label="Zoom out"
icon="minus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomOut()}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
</div>
);
}
+8 -2
View File
@@ -3,7 +3,7 @@ import {
SiteInsightsDisplayContext,
SiteInsightsLLMSVariant,
} from '@gitbook/api';
import { shouldServeMarkdown } from '@vercel/agent-readability';
import { acceptsMarkdown, isAIAgent } from '@vercel/agent-readability';
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
@@ -775,7 +775,13 @@ function encodePathInSiteContent(
default: {
// If the pathname is a markdown file or the request is ing markdown,
// we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown(request).serve) {
const aiAgentDetection = isAIAgent(request);
// Using heuristic detection incorrectly detects some legitimate bot requests as AI agents (e.g. Slackbot)
// We don't want to serve markdown for these requests as it can cause issues like breaking slack unfurling.
const shouldServeMarkdown =
(aiAgentDetection.detected && aiAgentDetection.method !== 'heuristic') ||
acceptsMarkdown(request);
if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown) {
const pagePathWithoutMD = pathname.replace(MARKDOWN_PATH_REGEX, '');
const ask = new URL(request.url).searchParams.get('ask');
return {