Compare commits

..

2 Commits

Author SHA1 Message Date
Peter White 8c62a6a2b2 changeset 2026-10-28 20:15:55 +00:00
Peter White 7cd4fdee66 Load the inline expression runtime only for blocks that use it (RND-13530) 2026-10-28 19:35:06 +00:00
25 changed files with 127 additions and 124 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Follow site and space redirects when the MCP getPage tool resolves a page URL.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Let analytics tools like GA4 track outbound link clicks by using native link navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Resolve the published content with the full URL in a single lookup and always send the visitor country.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep lists, paragraphs and code blocks as HTML in Updates RSS feeds, instead of running all the text together
+1 -1
View File
@@ -110,7 +110,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.28.1",
"version": "0.28.0",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
-15
View File
@@ -1,20 +1,5 @@
# gitbook
## 0.28.1
### Patch Changes
- 7e096d5: Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
- f61578f: Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
- bd431f1: Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
- a5d58bc: Follow site and space redirects when the MCP getPage tool resolves a page URL.
- e745c88: Add a search input to filter models by name on OpenAPI models pages.
- 96c5683: Let analytics tools like GA4 track outbound link clicks by using native link navigation.
- c00a7b3: Resolve the published content with the full URL in a single lookup and always send the visitor country.
- b7b8ae6: Keep lists, paragraphs and code blocks as HTML in Updates RSS feeds, instead of running all the text together
- 38f60f2: Render soft line breaks in paragraphs that contain buttons.
- be658e1: Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
## 0.28.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.28.1",
"version": "0.28.0",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
@@ -6,11 +6,10 @@ import { useDebounceCallback } from 'usehooks-ts';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import type { InlineExpressionVariables } from '../InlineExpression/types';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightTheme, RenderedInline } from './highlight-tokens';
import { plainHighlight } from './plain-highlight';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button, ToggleChevron } from '@/components/primitives';
@@ -20,6 +19,8 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
/** Only set for blocks with inline expressions, keeping `@gitbook/expr` out of the bundle. */
evaluateInlineExpression?: (expression: string) => string;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
embedded?: boolean;
@@ -34,18 +35,12 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables, themes, embedded, commentKey } =
const { block, mode, style, inlines, evaluateInlineExpression, themes, embedded, commentKey } =
props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables: inlineExprVariables,
});
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
@@ -0,0 +1,18 @@
'use client';
import { ClientCodeBlock, type ClientBlockProps } from './ClientCodeBlock';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useEvaluateInlineExpression } from '@/components/DocumentView/InlineExpression/useEvaluateInlineExpression';
/**
* Code block with inline expressions; its own module because it pulls in `@gitbook/expr`.
*/
export function ClientCodeBlockWithExpressions(props: ClientBlockProps) {
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims: getAdaptiveVisitorClaims(),
variables: props.inlineExprVariables,
});
return <ClientCodeBlock {...props} evaluateInlineExpression={evaluateInlineExpression} />;
}
@@ -0,0 +1,18 @@
'use client';
import dynamic from 'next/dynamic';
import type { ClientBlockProps } from './ClientCodeBlock';
// Keeps `@gitbook/expr` out of the eager bundle; `ssr: true` keeps values in the HTML.
const ClientCodeBlockWithExpressions = dynamic(
() =>
import('./ClientCodeBlockWithExpressions').then(
(mod) => mod.ClientCodeBlockWithExpressions
),
{ ssr: true }
);
export function ClientCodeBlockWithExpressionsLazy(props: ClientBlockProps) {
return <ClientCodeBlockWithExpressions {...props} />;
}
@@ -10,6 +10,7 @@ import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { getCommentKeyProp, getCommentMarkerProps } from '../utils';
import { ClientCodeBlock } from './ClientCodeBlock';
import { ClientCodeBlockWithExpressionsLazy } from './ClientCodeBlockWithExpressionsLazy';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import { highlight } from './highlight';
import { type RenderedInline, getInlines } from './highlight-tokens';
@@ -130,8 +131,11 @@ export async function CodeBlock(
{isMermaid ? (
<MermaidCodeBlockLazy
{...clientProps}
hasInlineExpression={hasInlineExpression}
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
/>
) : hasInlineExpression ? (
<ClientCodeBlockWithExpressionsLazy {...clientProps} />
) : (
<ClientCodeBlock {...clientProps} />
)}
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from 'react-dom';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { ClientCodeBlockWithExpressions } from './ClientCodeBlockWithExpressions';
import { getPlainCodeBlock } from './highlight-tokens';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
@@ -20,6 +21,7 @@ import { tcls } from '@/lib/tailwind';
export function MermaidCodeBlock(
props: ClientBlockProps & {
mermaidRuntimeURL: string;
hasInlineExpression: boolean;
}
) {
const { block, mode, style, mermaidRuntimeURL } = props;
@@ -169,7 +171,11 @@ export function MermaidCodeBlock(
}, []);
if (error) {
return <ClientCodeBlock {...props} />;
return props.hasInlineExpression ? (
<ClientCodeBlockWithExpressions {...props} />
) : (
<ClientCodeBlock {...props} />
);
}
// The live diagram subtree. It is portaled into a stable host that moves between the
@@ -11,6 +11,8 @@ const MermaidCodeBlock = dynamic(
{ ssr: true }
);
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
export function MermaidCodeBlockLazy(
props: ClientBlockProps & { mermaidRuntimeURL: string; hasInlineExpression: boolean }
) {
return <MermaidCodeBlock {...props} />;
}
@@ -3,7 +3,7 @@ import * as React from 'react';
import type { DocumentInlineExpression } from '@gitbook/api';
import type { InlineProps } from '../Inline';
import { InlineExpressionValue } from './InlineExpressionValue';
import { InlineExpressionValueLazy } from './InlineExpressionValueLazy';
/**
* Render an inline expression.
@@ -25,7 +25,7 @@ export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
return (
<React.Suspense fallback={null}>
<InlineExpressionValue expression={data.expression} variables={variables} />
<InlineExpressionValueLazy expression={data.expression} variables={variables} />
</React.Suspense>
);
}
@@ -0,0 +1,18 @@
'use client';
import dynamic from 'next/dynamic';
import type { InlineExpressionVariables } from './types';
// Keeps `@gitbook/expr` out of the eager bundle; `ssr: true` keeps values in the HTML.
const InlineExpressionValue = dynamic(
() => import('./InlineExpressionValue').then((mod) => mod.InlineExpressionValue),
{ ssr: true }
);
export function InlineExpressionValueLazy(props: {
expression: string;
variables: InlineExpressionVariables;
}) {
return <InlineExpressionValue {...props} />;
}
@@ -1,3 +1,2 @@
export * from './InlineExpression';
export * from './types';
export * from './useEvaluateInlineExpression';
@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import type React from 'react';
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
import {
@@ -12,10 +12,6 @@ import {
import { ClientPlainCodeBlock } from '../CodeBlock/ClientPlainCodeBlock';
import type { DocumentContext } from '../DocumentView';
import { matchesText } from '../Table/searchMatch';
import { MIN_RECORDS_FOR_SEARCH } from '../Table/shouldShowSearch';
import { Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
@@ -78,51 +74,14 @@ export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
);
case 'webhook':
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
case 'schemas': {
const SchemasComponent =
mode !== 'print' && props.data.schemas.length >= MIN_RECORDS_FOR_SEARCH
? OpenAPISchemasWithSearch
: BaseOpenAPISchemas;
case 'schemas':
return (
<SchemasComponent
<BaseOpenAPISchemas
data={props.data}
grouped={props.grouped}
context={context}
className={className}
/>
);
}
}
}
function OpenAPISchemasWithSearch(props: React.ComponentProps<typeof BaseOpenAPISchemas>) {
const { data, ...rest } = props;
const language = useLanguage();
const [query, setQuery] = React.useState('');
// A models page can hold hundreds of schemas; keep typing responsive while the list re-renders.
const deferredQuery = React.useDeferredValue(query).trim();
const schemas = data.schemas.filter(({ name }) => matchesText(name, deferredQuery));
return (
<div className="flex min-w-0 flex-1 flex-col gap-4">
<Input
label={tString(language, 'search')}
value={query}
onValueChange={setQuery}
leading="magnifying-glass"
clearButton
sizing="small"
keyboardShortcut={false}
/>
{schemas.length > 0 ? (
// `grouped` keeps the list layout when the search narrows it down to one model.
<BaseOpenAPISchemas {...rest} data={{ ...data, schemas }} grouped />
) : (
<div className="mb-14 py-8 text-center text-sm text-tint">
{tString(language, 'search_no_results_for', deferredQuery)}
</div>
)}
</div>
);
}
@@ -141,8 +141,7 @@ const TabItemList = memo(function TabItemList(props: {
onSelect: (tabId: string) => void;
}) {
const { tabs, activeTabId, onSelect } = props;
// The active tab's width decides how many others fit, so a new selection re-measures.
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(activeTabId);
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
const overflowingTabs = useMemo(
() =>
Array.from(overflowing, (id) => {
@@ -182,7 +181,6 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
/>
@@ -241,11 +239,10 @@ function TabsDropdownMenu(props: {
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
isMeasuring: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, isMeasuring, onSelect } = props;
const { ref, tab, isActive, onSelect } = props;
return (
<TabButton
ref={ref}
@@ -253,20 +250,7 @@ const TabItem = memo(function TabItem(props: {
aria-selected={isActive}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
title={tab.title}
onClick={() => onSelect(tab.id)}
wrapperClassName={
isActive
? // Keeps room for the overflow menu button.
'max-w-[calc(100%-3rem)]'
: isMeasuring
? // Measured after the active tab, at their narrowest, so the menu only takes
// the tabs that can't fit even truncated.
'order-1 max-w-28'
: // Equal shares capped at their own width, so no tab drops below that
// narrowest size; the high grow leaves the strip's filler only the leftover.
'min-w-0 max-w-max basis-0 grow-[1000]'
}
>
{tab.icon && <Icon icon={tab.icon} className="size-4 shrink-0" />}
<span className="min-w-0 truncate">{tab.title}</span>
@@ -280,10 +264,9 @@ const TabItem = memo(function TabItem(props: {
function TabButton(
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
isActive?: boolean;
wrapperClassName?: string;
}
) {
const { isActive, wrapperClassName, ...rest } = props;
const { isActive, ...rest } = props;
return (
<div
className={tcls(
@@ -327,15 +310,14 @@ function TabButton(
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null,
wrapperClassName
: null
)}
>
<button
{...rest}
type="button"
className={tcls(
'relative inline-flex min-w-0 max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -38,21 +38,12 @@ export function Text(props: { text: DocumentText }) {
{children}
</Mark>
);
}, withSoftBreaks(leaf.text))}
}, leaf.text)}
</React.Fragment>
);
});
}
// Paragraphs with buttons are flex containers, where a `\n` alone can't move the next items to a new line.
function withSoftBreaks(text: string): React.ReactNode {
const lines = text.split('\n');
// Keep `\n` in the same text node as a preceding `\r`: split apart, the HTML parser reads the lone `\r` as an extra line.
return lines.flatMap((line, index) =>
index < lines.length - 1 ? [`${line}\n`, <span key={index} className="basis-full" />] : line
);
}
const MARK_STYLES = {
bold: Bold,
italic: Italic,
@@ -49,7 +49,7 @@ import { defaultCustomization } from '@/lib/utils';
function preloadFont(fontData: FontData) {
if (fontData.type === 'custom') {
ReactDOM.preconnect(GITBOOK_FONTS_URL, { crossOrigin: 'anonymous' });
ReactDOM.preconnect(GITBOOK_FONTS_URL);
fontData.preloadSources
.flatMap((face) => face.sources)
.forEach(({ url, format }) => {
@@ -64,12 +64,11 @@ export async function SiteLayout(props: {
// Scripts are disabled when tracking is disabled
const scripts = withTracking ? context.scripts : [];
// React dedupes preconnects by href, so reduce to the origin; relative URLs are same-origin.
[GITBOOK_API_PUBLIC_URL, GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
if (url && URL.canParse(url)) {
ReactDOM.preconnect(new URL(url).origin);
}
});
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
ReactDOM.preconnect(GITBOOK_ICONS_URL);
if (GITBOOK_ASSETS_URL) {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
@@ -30,10 +30,8 @@ interface OverflowState {
* shows *because* items overflow (a "more" menu, say) has to be rendered ahead of the items in that
* phase: the measurement discounts the space taken before the first item, so a list that fits on its
* own is not reported as overflowing merely because that menu was reserving room for itself.
*
* The list is measured again whenever `remeasureKey` changes.
*/
export function useListOverflow(remeasureKey?: unknown): OverflowState {
export function useListOverflow(): OverflowState {
const containerRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
// Measuring is a request/completed pair of counters rather than a boolean, because a boolean
@@ -60,12 +58,6 @@ export function useListOverflow(remeasureKey?: unknown): OverflowState {
const requestMeasure = useCallback(() => setMeasureRequest((request) => request + 1), []);
const [measuredKey, setMeasuredKey] = useState(remeasureKey);
if (measuredKey !== remeasureKey) {
setMeasuredKey(remeasureKey);
setMeasureRequest((request) => request + 1);
}
// Measure on mount and when container size changes
useEffect(() => {
if (!containerRef.current) {