Compare commits

...

11 Commits

61 changed files with 792 additions and 151 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Declare the Tailwind layer order in each component stylesheet, so a route whose first stylesheet is one of them still puts `base` before `components` and preflight does not strip their padding and borders.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
+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/react-contentkit": patch
---
Load a webframe only once it is near the viewport. A page can hold hundreds of them inside collapsed expandables, and loading every one on mount held up the first paint.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and carry a table cell's width once as a custom property instead of spelling it out as both width and min-width, so a large page carries far less markup.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a search input to filter models by name on OpenAPI models pages.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Only apply a site preview customization override to the section or variant it was made for.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render soft line breaks in paragraphs that contain buttons.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix visitors being logged out right after signing in on sites with a logout link in the header.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
+2 -2
View File
@@ -356,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.206.0",
"@gitbook/api": "0.208.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -724,7 +724,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.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="],
"@gitbook/api": ["@gitbook/api@0.208.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-i5rnGYNvgp8PDGd9FRLUjOx5gS/D7mFfikwp0Lsem4pgEG8cqBtZsDaYbLBdAdupOuZBLJtmEut51zhttok6OQ=="],
"@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.206.0",
"@gitbook/api": "0.208.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -1,4 +1,3 @@
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
@@ -9,7 +8,7 @@ import {
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { resolveUpstreamAuthURL } from '@/lib/site-auth-urls';
import { getVisitorAuthCookieName } from '@/lib/visitors';
import { getVisitorAuthClearCookieHeaders } from '@/lib/visitors';
/**
* Clear the site-scoped auth session cookies and redirect to the upstream auth provider
@@ -19,19 +18,18 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
// A router prefetch is not a visitor intent to log out, so it must not clear the session.
// Next.js sends different values depending on the prefetch kind, hence the presence check.
if (request.headers.has('next-router-prefetch')) {
return new NextResponse(null, { status: 204 });
}
const resolvedParams = await params;
const [{ context }, siteURLData, cookieStore] = await Promise.all([
const [{ context }, siteURLData] = await Promise.all([
getDynamicSiteContext(resolvedParams),
getSiteURLDataFromParams(resolvedParams),
cookies(),
]);
cookieStore.delete(
getVisitorAuthCookieName(
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
)
);
const noLogoutFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
const logoutURL = resolveUpstreamAuthURL({
siteAuthURL: context.site.urls.logout,
@@ -41,5 +39,12 @@ export async function GET(
location: request.nextUrl.searchParams.get('location') ?? '/',
});
return NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
const response = NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
// Appended as raw headers: `cookies()` keeps one cookie per name.
for (const header of getVisitorAuthClearCookieHeaders(
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
)) {
response.headers.append('set-cookie', header);
}
return response;
}
@@ -1,6 +1,10 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -18,8 +22,9 @@ export async function GET(
) {
const { question: encodedQuestion, goal: encodedGoal } = await params;
const { context } = await getStaticSiteContext(await params);
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
const question = decodeURIComponent(encodedQuestion);
const goal = decodeURIComponent(encodedGoal);
return serveAskMarkdown(context, question, { goal });
return serveAskMarkdown(context, question, { goal, visitorAgent: askVisitorAgent });
}
@@ -1,6 +1,10 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -11,7 +15,8 @@ export async function GET(
) {
const { question: encodedQuestion } = await params;
const { context } = await getStaticSiteContext(await params);
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
const question = decodeURIComponent(encodedQuestion);
return serveAskMarkdown(context, question);
return serveAskMarkdown(context, question, { visitorAgent: askVisitorAgent });
}
@@ -1,4 +1,6 @@
'use server';
import { headers } from 'next/headers';
import {
type AIMessageContext,
AIMessageRole,
@@ -12,6 +14,7 @@ import {
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -72,7 +75,8 @@ export async function* streamAIChatResponse({
? SiteCoreChannelType.Embed
: SiteCoreChannelType.Site,
},
}
},
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
);
return await streamRenderAIMessage(context, rawStream, options);
@@ -0,0 +1,18 @@
@reference "../RootLayout/globals.css";
/* This file can be the first stylesheet a route loads, and layer order follows first
appearance. Without this, `components` would be created here and Tailwind's `base`
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
@layer theme, base, components, utilities;
/* Every block on every page carries these, so they live here rather than in each class attribute. */
@layer components {
.gb-block {
@apply decoration-primary/6;
}
/* Lists opt out: a long list is expected to break across printed pages. */
.gb-block:not(.gb-block-list) {
@apply print:break-inside-avoid;
}
}
@@ -2,6 +2,7 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
import { Block } from './Block';
import './Blocks.css';
import type { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -93,10 +94,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'decoration-primary/6',
'gb-block',
FULL_WIDTH_BLOCKS.has(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
!LIST_BLOCKS.has(node.type) && 'print:break-inside-avoid',
LIST_BLOCKS.has(node.type) && 'gb-block-list',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -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} />;
}
@@ -0,0 +1,30 @@
@reference "../RootLayout/globals.css";
/* This file can be the first stylesheet a route loads, and layer order follows first
appearance. Without this, `components` would be created here and Tailwind's `base`
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
@layer theme, base, components, utilities;
/* A page carries one of these per heading, so the shared utilities live here instead of in a class
attribute repeated hundreds of times — on a large page that repetition is most of the HTML.
They sit in `components` so the utilities passed as props still override them. */
@layer components {
.hash {
@apply relative inline-grid grid-area-1-1 border-0 rounded site-background;
@apply pointer-events-none opacity-0 transition-opacity;
@apply pointer-fine:h-[1em] pointer-fine:pointer-events-auto;
@apply pointer-fine:group-hover/hash:opacity-100 pointer-fine:group-focus-within/hash:opacity-100;
@apply [.hash-revealed_&]:pointer-events-auto [.hash-revealed_&]:opacity-100;
}
.hash-link {
@apply inline-flex items-center p-1 pointer-fine:h-full pointer-fine:p-0;
}
.hash-icon {
@apply size-3 self-center text-transparent transition-colors;
@apply pointer-fine:group-hover/hash:text-tint-subtle contrast-more:pointer-fine:group-hover/hash:text-tint-strong;
@apply pointer-fine:group-focus-within/hash:text-tint-subtle contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong;
@apply [.hash-revealed_&]:text-tint-subtle contrast-more:[.hash-revealed_&]:text-tint-strong;
}
}
@@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import type { DocumentContext } from './DocumentView';
import './HashLinkButton.css';
import { getBlockTextStyle } from './spacing';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -35,53 +36,13 @@ export function HashLinkButton(props: {
const textStyle = getBlockTextStyle(block);
return (
<span
className={tcls(
'relative',
'hash',
'inline-grid',
'grid-area-1-1',
'pointer-fine:h-[1em]',
'border-0',
'opacity-0',
'pointer-events-none',
'site-background',
'rounded',
'transition-opacity',
'pointer-fine:pointer-events-auto',
'pointer-fine:group-hover/hash:opacity-100',
'pointer-fine:group-focus-within/hash:opacity-100',
'[.hash-revealed_&]:opacity-100',
'[.hash-revealed_&]:pointer-events-auto',
className
)}
>
<span className={tcls('hash', className)}>
<Link
href={`#${id}`}
aria-label={label}
className={tcls(
'inline-flex items-center',
'p-1',
'pointer-fine:h-full pointer-fine:p-0',
textStyle.lineHeight
)}
className={tcls('hash-link', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'size-3',
'self-center',
'transition-colors',
'text-transparent',
'pointer-fine:group-hover/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-hover/hash:text-tint-strong',
'pointer-fine:group-focus-within/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong',
'[.hash-revealed_&]:text-tint-subtle',
'contrast-more:[.hash-revealed_&]:text-tint-strong',
iconClassName
)}
/>
<Icon icon="hashtag" className={tcls('hash-icon', iconClassName)} />
</Link>
</span>
);
@@ -12,6 +12,7 @@ import { getSelectAction } from './selectAction';
import { SelectActionButton } from './SelectActionButton';
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
const BUTTON_SIZE_MAP: Record<
@@ -23,6 +24,28 @@ const BUTTON_SIZE_MAP: Record<
large: 'medium',
};
// Only content buttons take the site's secondary colour; the `secondary` variant also styles site chrome.
const SECONDARY_COLOR_CLASSES = tcls(
'bg-secondary',
'depth-flat:bg-secondary-base',
'text-secondary',
'hover:bg-secondary-hover',
'hover:not-disabled:depth-flat:bg-secondary-hover',
'hover:not-disabled:text-secondary',
'contrast-more:bg-secondary-subtle',
'disabled:text-secondary/8',
'border-secondary',
'hover:border-secondary-hover',
'disabled:border-secondary',
'shadow-secondary/6',
'dark:shadow-secondary-1',
'contrast-more:border-secondary-12',
'contrast-more:hover:outline-secondary-12',
'contrast-more:hover:border-secondary-12',
'contrast-more:focus-visible:border-secondary-12',
'contrast-more:focus-visible:outline-secondary-12'
);
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props;
@@ -31,6 +54,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
variant: inline.data.kind,
icon: inline.data.icon as IconName | undefined,
size: BUTTON_SIZE_MAP[inline.data.size ?? 'large'],
className: inline.data.kind === 'secondary' ? SECONDARY_COLOR_CLASSES : undefined,
};
const ButtonImplementation = () => {
@@ -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 type React from 'react';
import React from 'react';
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
import {
@@ -12,6 +12,10 @@ 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.
@@ -74,14 +78,51 @@ export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
);
case 'webhook':
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
case 'schemas':
case 'schemas': {
const SchemasComponent =
mode !== 'print' && props.data.schemas.length >= MIN_RECORDS_FOR_SEARCH
? OpenAPISchemasWithSearch
: BaseOpenAPISchemas;
return (
<BaseOpenAPISchemas
<SchemasComponent
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>
);
}
@@ -4,6 +4,7 @@ import { getColumnWidth } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableGridViewProps, TableRecordKV } from './Table';
import { TableSearchRecord } from './TableSearch';
import './styles.css';
import { getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
@@ -23,12 +24,7 @@ export function RecordRow(
role="row"
recordId={record[0]}
visibleClassName="flex"
className={tcls(
'group/row',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
className={tcls('group/row', 'table-grid-row')}
>
{view.columns.map((column) => {
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
@@ -58,16 +54,16 @@ export function RecordRow(
{...getCommentCellProps(context, record[0], column)}
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
isStickyFirstColumnCell
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
: undefined
'table-grid-cell',
isStickyFirstColumnCell ? 'table-grid-cell-sticky' : undefined
)}
style={{
width: columnWidth,
minWidth: columnWidth || '100px',
}}
// The cell's width and min-width both come from this, so it is carried once
// rather than spelled out twice in every cell's style attribute.
style={
{
'--table-column-width': columnWidth || '100px',
} as React.CSSProperties
}
>
<RecordColumnValue
{...props}
@@ -0,0 +1,28 @@
@reference "../../RootLayout/globals.css";
/* This file can be the first stylesheet a route loads, and layer order follows first
appearance. Without this, `components` would be created here and Tailwind's `base`
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
@layer theme, base, components, utilities;
/* A grid view repeats these on every row and every cell, so a table of any size pays for the class
lists many times over in the HTML. They sit in `components` so the utilities passed alongside
them still override. */
@layer components {
.table-grid-row {
@apply border-tint-subtle transition-colors hover:bg-tint-hover;
}
.table-grid-cell {
@apply relative flex flex-1 border-r border-tint-subtle px-3 py-2 align-middle text-sm;
@apply last:border-r-0;
/* Set per cell by the row; the column's width serves as both the width and the floor. */
width: var(--table-column-width);
min-width: var(--table-column-width);
}
.table-grid-cell-sticky {
@apply sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover;
}
}
@@ -141,7 +141,8 @@ const TabItemList = memo(function TabItemList(props: {
onSelect: (tabId: string) => void;
}) {
const { tabs, activeTabId, onSelect } = props;
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
// The active tab's width decides how many others fit, so a new selection re-measures.
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(activeTabId);
const overflowingTabs = useMemo(
() =>
Array.from(overflowing, (id) => {
@@ -181,6 +182,7 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
/>
@@ -239,10 +241,11 @@ 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, onSelect } = props;
const { ref, tab, isActive, isMeasuring, onSelect } = props;
return (
<TabButton
ref={ref}
@@ -250,7 +253,20 @@ 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>
@@ -264,9 +280,10 @@ const TabItem = memo(function TabItem(props: {
function TabButton(
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
isActive?: boolean;
wrapperClassName?: string;
}
) {
const { isActive, ...rest } = props;
const { isActive, wrapperClassName, ...rest } = props;
return (
<div
className={tcls(
@@ -310,14 +327,15 @@ function TabButton(
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null
: null,
wrapperClassName
)}
>
<button
{...rest}
type="button"
className={tcls(
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
'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]',
props.className
)}
/>
@@ -38,12 +38,21 @@ export function Text(props: { text: DocumentText }) {
{children}
</Mark>
);
}, leaf.text)}
}, withSoftBreaks(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,
@@ -0,0 +1,23 @@
@reference "../../RootLayout/globals.css";
/* This file can be the first stylesheet a route loads, and layer order follows first
appearance. Without this, `components` would be created here and Tailwind's `base`
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
@layer theme, base, components, utilities;
/* Blocks repeat their alignment on every paragraph, heading and cell, so the three utilities each
alignment needs are packed into one class. They sit in `components`, so a utility passed
alongside one still wins. */
@layer components {
.gb-align-start {
@apply justify-start self-start text-start;
}
.gb-align-center {
@apply justify-center self-center text-center;
}
.gb-align-end {
@apply justify-end self-end text-end;
}
}
@@ -1,20 +1,22 @@
import { TextAlignment } from '@gitbook/api';
import './textAlignment.css';
import type { ClassValue } from '@/lib/tailwind';
import { nullIfNever } from '@/lib/typescript';
/**
* Get the tailwind class for a text alignment.
* Each alignment packs three utilities, so it is a single class from `textAlignment.css`.
*/
export function getTextAlignment(textAlignment: TextAlignment | undefined): ClassValue {
switch (textAlignment) {
case undefined:
case TextAlignment.Start:
return ['text-start', 'self-start', 'justify-start'];
return 'gb-align-start';
case TextAlignment.Center:
return ['text-center', 'self-center', 'justify-center'];
return 'gb-align-center';
case TextAlignment.End:
return ['text-end', 'self-end', 'justify-end'];
return 'gb-align-end';
default:
return nullIfNever(textAlignment);
}
@@ -49,7 +49,7 @@ import { defaultCustomization } from '@/lib/utils';
function preloadFont(fontData: FontData) {
if (fontData.type === 'custom') {
ReactDOM.preconnect(GITBOOK_FONTS_URL);
ReactDOM.preconnect(GITBOOK_FONTS_URL, { crossOrigin: 'anonymous' });
fontData.preloadSources
.flatMap((face) => face.sources)
.forEach(({ url, format }) => {
@@ -92,6 +92,30 @@ export async function CustomizationRootLayout(props: {
// (undefined) and keeps a neutral page background.
const tintBaseStep =
theme === CustomizationTheme.Bold ? undefined : theme === CustomizationTheme.Muted ? 2 : 1;
const tintScale = {
light: {
color: tintColor ? tintColor.light : DEFAULT_TINT_COLOR,
options: {
baseStep: tintBaseStep,
mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light },
},
},
dark: {
color: tintColor ? tintColor.dark : DEFAULT_TINT_COLOR,
options: {
darkMode: true,
baseStep: tintBaseStep,
mix: mixColor && { color: mixColor.color.dark, ratio: mixColor.ratio.dark },
},
},
};
const secondaryColor = customization.styling.secondaryColor;
const secondaryScale = secondaryColor
? {
light: { color: secondaryColor.light, options: {} },
dark: { color: secondaryColor.dark, options: { darkMode: true } },
}
: tintScale;
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
const fontData = getFontData(customization.styling.font, 'content');
// Temporarily add a if here while the cache is being warmed up.
@@ -178,7 +202,8 @@ export async function CustomizationRootLayout(props: {
>{`
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
${generateColorVariable('primary', customization.styling.primaryColor.light)}
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { baseStep: tintBaseStep, mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
${generateColorVariable('tint', tintScale.light.color, tintScale.light.options)}
${generateColorVariable('secondary', secondaryScale.light.color, secondaryScale.light.options)}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
--header-background: ${
@@ -205,7 +230,8 @@ export async function CustomizationRootLayout(props: {
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, baseStep: tintBaseStep, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
${generateColorVariable('tint', tintScale.dark.color, tintScale.dark.options)}
${generateColorVariable('secondary', secondaryScale.dark.color, secondaryScale.dark.options)}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
--header-background: ${hexToRgb(customization.header.backgroundColor?.dark ?? tintColor?.dark ?? customization.styling.primaryColor.dark)};
@@ -1,6 +1,7 @@
'use server';
import { createStreamableValue } from 'ai/rsc';
import { headers } from 'next/headers';
import type * as React from 'react';
import type {
@@ -13,6 +14,7 @@ import type {
import { DocumentView } from '../DocumentView';
import { isAIEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
@@ -77,7 +79,8 @@ export async function streamAskQuestion({
},
session,
},
{ format: 'document' }
{ format: 'document' },
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
);
const spacePromises = new Map<string, Promise<Revision>>();
@@ -64,11 +64,12 @@ export async function SiteLayout(props: {
// Scripts are disabled when tracking is disabled
const scripts = withTracking ? context.scripts : [];
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
ReactDOM.preconnect(GITBOOK_ICONS_URL);
if (GITBOOK_ASSETS_URL) {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// 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);
}
});
// 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,8 +30,10 @@ 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(): OverflowState {
export function useListOverflow(remeasureKey?: unknown): 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
@@ -58,6 +60,12 @@ export function useListOverflow(): 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) {
@@ -5,7 +5,7 @@ import React from 'react';
import { NavigationStatusContext, scrollToHash } from '../hooks';
import { type TrackEventInput, useTrackEvent } from '../Insights';
import { isExternalLink, toNonEmbedLink } from '../utils/link';
import { isExternalLink, isSiteAuthLink, toNonEmbedLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
import { tcls } from '@/lib/tailwind';
import { getSamePageAnchor, resolveAnchorURL } from '@/lib/urls';
@@ -93,6 +93,9 @@ export function Link(props: LinkProps) {
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalServer(href);
// Auth routes must not be prefetched (it would log the visitor out) and always leave the
// page for the upstream provider, so they get a plain anchor like external links.
const isAuthLink = isSiteAuthLink(href);
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
@@ -114,7 +117,7 @@ export function Link(props: LinkProps) {
const isExternalOnClient = isExternalClient(href);
if (insights) {
trackEvent(insights, undefined, { immediate: isExternalOnClient });
trackEvent(insights, undefined, { immediate: isExternalOnClient || isAuthLink });
}
const isInIframe = window.self !== window.top;
@@ -133,7 +136,7 @@ export function Link(props: LinkProps) {
// We test if the link is external, without comparing to the origin
// as this will be rendered on the server and it could result in a mismatch.
if (isExternal) {
if (isExternal || isAuthLink) {
return (
<a
ref={ref}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { isExternalLink, toNonEmbedLink } from './link';
import { isExternalLink, isSiteAuthLink, toNonEmbedLink } from './link';
describe('isExternalLink', () => {
it('treats anchor links as internal', () => {
@@ -74,3 +74,22 @@ describe('toNonEmbedLink', () => {
expect(toNonEmbedLink('/docs')).toBe('/docs');
});
});
describe('isSiteAuthLink', () => {
it('matches absolute links to the auth routes', () => {
expect(isSiteAuthLink('https://example.com/docs/~gitbook/auth/logout')).toBe(true);
expect(isSiteAuthLink('https://example.com/~gitbook/auth/login?location=%2F')).toBe(true);
});
it('matches relative links to the auth routes', () => {
expect(isSiteAuthLink('/docs/~gitbook/auth/logout/')).toBe(true);
expect(isSiteAuthLink('~gitbook/auth/login')).toBe(true);
});
it('does not match other links', () => {
expect(isSiteAuthLink('https://example.com/docs/~gitbook/pdf')).toBe(false);
expect(isSiteAuthLink('/docs/logout')).toBe(false);
expect(isSiteAuthLink('/docs/~gitbook/auth/logout-page')).toBe(false);
expect(isSiteAuthLink('#section')).toBe(false);
});
});
@@ -49,3 +49,18 @@ export function isExternalLink(href: string, origin: string | null = null) {
export function toNonEmbedLink(href: string) {
return href.replace(/~gitbook\/embed(\/page)?\/?/, '');
}
/**
* Check if a link points to the site's `~gitbook/auth/login` or `~gitbook/auth/logout` route.
*
* Deliberately loose: these routes only exist at the site root, but the site's base path isn't
* known here, so any prefix matches. A false positive only costs the link its prefetch.
*/
export function isSiteAuthLink(href: string) {
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
if (!URL.canParse?.(href, baseURL)) {
return false;
}
return /\/~gitbook\/auth\/(login|logout)\/?$/.test(new URL(href, baseURL).pathname);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'bun:test';
import { getAskForwardedHeaders, getAskVisitorAgent } from './ask-visitor';
describe('getAskVisitorAgent', () => {
it('should read the User-Agent and Signature-Agent headers', () => {
expect(
getAskVisitorAgent(
new Headers({
'user-agent': 'claude-code/2.0.0',
'signature-agent': '"https://chatgpt.com"',
})
)
).toEqual({ userAgent: 'claude-code/2.0.0', signatureAgent: '"https://chatgpt.com"' });
});
it('should omit missing or empty headers', () => {
const agent = getAskVisitorAgent(new Headers({ 'user-agent': '' }));
expect(agent).toEqual({});
expect('userAgent' in agent).toBe(false);
});
it('should truncate overly long headers', () => {
const agent = getAskVisitorAgent(new Headers({ 'user-agent': 'a'.repeat(2000) }));
expect(agent.userAgent).toHaveLength(512);
});
});
describe('getAskForwardedHeaders', () => {
it('should forward the visitor agent in X-Original headers', () => {
expect(
getAskForwardedHeaders({
userAgent: 'claude-code/2.0.0',
signatureAgent: '"https://chatgpt.com"',
})
).toEqual({
'X-Original-User-Agent': 'claude-code/2.0.0',
'X-Original-Signature-Agent': '"https://chatgpt.com"',
});
});
it('should omit headers the visitor did not send', () => {
expect(getAskForwardedHeaders({ userAgent: 'curl/8.7.1' })).toEqual({
'X-Original-User-Agent': 'curl/8.7.1',
});
expect(getAskForwardedHeaders()).toEqual({});
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Headers identifying who asked a question, forwarded so the API can classify the asker
* (human, agent, bot) the same way it classifies insights events.
*/
export interface AskVisitorAgent {
userAgent?: string;
signatureAgent?: string;
}
// Bounds the header values, as they end up in the static route path of `?ask=`.
const MAX_HEADER_LENGTH = 512;
/**
* Read the visitor's `User-Agent` and `Signature-Agent` from the request headers.
* Missing headers are omitted rather than set to undefined, so the result can be rison-encoded.
*/
export function getAskVisitorAgent(headers: Pick<Headers, 'get'>): AskVisitorAgent {
const userAgent = headers.get('user-agent')?.slice(0, MAX_HEADER_LENGTH);
const signatureAgent = headers.get('signature-agent')?.slice(0, MAX_HEADER_LENGTH);
return {
...(userAgent ? { userAgent } : {}),
...(signatureAgent ? { signatureAgent } : {}),
};
}
/**
* Request headers forwarding the visitor's agent on ask and Assistant API calls.
* Our own `User-Agent` is left as is: the API relies on it to know the published site is calling.
*/
export function getAskForwardedHeaders(agent: AskVisitorAgent = {}): Record<string, string> {
return {
...(agent.userAgent ? { 'X-Original-User-Agent': agent.userAgent } : {}),
...(agent.signatureAgent ? { 'X-Original-Signature-Agent': agent.signatureAgent } : {}),
};
}
+8 -1
View File
@@ -1,5 +1,6 @@
import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api';
import { type AskVisitorAgent, getAskForwardedHeaders } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { resolvePageId } from '@/lib/pages';
@@ -15,6 +16,11 @@ export interface StreamSiteAskOptions {
* Used by the backend to steer the answer.
*/
goal?: string;
/**
* Headers of the visitor asking the question, used by the backend to classify the asker.
*/
visitorAgent?: AskVisitorAgent;
}
/**
@@ -45,7 +51,8 @@ export async function streamSiteAskAnswer(
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'markdown' }
{ format: 'markdown' },
{ headers: getAskForwardedHeaders(options.visitorAgent) }
);
let latestAnswer: SearchAIAnswer | null = null;
+2
View File
@@ -14,6 +14,7 @@ export const textColorToStyle = {
cyan: ['text-[#007c7c] dark:text-[#6bcac9]'],
orange: ['text-[#ae4300] dark:text-[#eea471]'],
$primary: ['text-primary-subtle contrast-more:text-primary'],
$secondary: ['text-secondary-subtle contrast-more:text-secondary'],
$info: ['text-info-subtle contrast-more:text-info'],
$success: ['text-success-subtle contrast-more:text-success'],
$warning: ['text-warning-subtle contrast-more:text-warning'],
@@ -33,6 +34,7 @@ export const backgroundColorToStyle = {
cyan: ['bg-[#ddf8f8] dark:bg-[#093b3b]'],
orange: ['bg-[#ffecdc] dark:bg-[#492a13]'],
$primary: ['bg-primary'],
$secondary: ['bg-secondary'],
$info: ['bg-info'],
$success: ['bg-success'],
$warning: ['bg-warning'],
+7
View File
@@ -23,6 +23,7 @@ import type {
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import {
type GitBookDataFetcher,
createDataFetcher,
@@ -104,6 +105,12 @@ export type SiteURLData = Pick<
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
isChatGPT?: boolean;
/**
* Headers of the visitor asking through `?ask=`, forwarded to the API to classify the asker.
* Only set for the ask route, which is static and can't read the request headers.
*/
askVisitorAgent?: AskVisitorAgent;
};
/**
+4
View File
@@ -15,6 +15,7 @@ import {
} from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import { getAskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
@@ -261,8 +262,11 @@ export function createSiteMcpTools(
const trimmedGoal = goal?.trim() || undefined;
// The MCP server is stateless, so the `clientInfo` from `initialize` isn't
// available here: rely on the HTTP user agent, as the `ask_question` event does.
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
goal: trimmedGoal,
visitorAgent: getAskVisitorAgent(request.headers),
});
trackMcpEvent({
+27 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'bun:test';
import type { PublishedSiteContent } from '@gitbook/api';
import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview';
import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
isPreviewRequest,
parsePreviewCustomizationCookie,
serializePreviewCustomizationCookie,
} from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
@@ -65,3 +71,23 @@ describe('getPreviewCookieResponse', () => {
expect(cookie.options?.path).toBe('/docs/');
});
});
describe('parsePreviewCustomizationCookie', () => {
const customization = "(styling:(primaryColor:(dark:'#ff0000',light:'#ff0000')))";
const value = serializePreviewCustomizationCookie({ customization, siteSpace: 'sitesp_a' });
it('returns the override for the site space it was created for', () => {
expect(parsePreviewCustomizationCookie(value, 'sitesp_a')).toBe(customization);
});
it('ignores the override for another site space', () => {
expect(parsePreviewCustomizationCookie(value, 'sitesp_b')).toBeUndefined();
expect(parsePreviewCustomizationCookie(value, 'sitesp')).toBeUndefined();
});
it('ignores a cookie not bound to a site space', () => {
expect(
parsePreviewCustomizationCookie(encodeURIComponent(customization), 'sitesp_a')
).toBeUndefined();
});
});
+28
View File
@@ -30,6 +30,34 @@ export function getPreviewRequestIdentifier(requestURL: URL): string {
throw new Error('Not a preview request');
}
/**
* Serialize a customization override into the preview cookie value.
* The override holds the full settings of the site space it was created for,
* so it is bound to that site space to not leak into the other ones of the preview.
*/
export function serializePreviewCustomizationCookie(args: {
customization: string;
siteSpace: string;
}): string {
return `${args.siteSpace}:${encodeURIComponent(args.customization)}`;
}
/**
* Get the customization override from the preview cookie value,
* only if it was created for the given site space.
*/
export function parsePreviewCustomizationCookie(
value: string,
siteSpace: string
): string | undefined {
const prefix = `${siteSpace}:`;
if (!value.startsWith(prefix)) {
return undefined;
}
return decodeURIComponent(value.slice(prefix.length));
}
/**
* Get a cookie for the preview request.
*/
+57 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import type { JwtPayload } from 'jwt-decode';
import { NextResponse } from 'next/server';
import {
type ResponseCookies,
@@ -234,21 +235,66 @@ describe('getResponseCookiesForVisitorAuth write policy', () => {
const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
const token = `${base64url({ alg: 'none' })}.${base64url({ exp: 2_000_000_000 })}.sig`;
const requestHeaders = new Headers();
it('persists a token that arrived in the URL', () => {
expect(getResponseCookiesForVisitorAuth('/', { source: 'url', token })).toHaveLength(1);
const visitorTokenLookup = { source: 'url', token } as const;
expect(
getResponseCookiesForVisitorAuth({ basePath: '/', visitorTokenLookup, requestHeaders })
).toHaveLength(1);
});
it('only accepts a token that arrived in the URL', () => {
const fromVACookie = { source: 'visitor-auth-cookie', basePath: '/', token } as const;
const fromCustomCookie = { source: 'gitbook-visitor-cookie', token } as const;
// @ts-expect-error a token read back from a VA cookie must never be persisted again
const buildFromVACookie = () => getResponseCookiesForVisitorAuth('/', fromVACookie);
// @ts-expect-error a custom visitor cookie is owned by the customer's backend
const buildFromCustom = () => getResponseCookiesForVisitorAuth('/', fromCustomCookie);
const buildFromVACookie = () =>
getResponseCookiesForVisitorAuth({
basePath: '/',
// @ts-expect-error a token read back from a VA cookie must never be persisted again
visitorTokenLookup: fromVACookie,
requestHeaders,
});
const buildFromCustom = () =>
getResponseCookiesForVisitorAuth({
basePath: '/',
// @ts-expect-error a custom visitor cookie is owned by the customer's backend
visitorTokenLookup: fromCustomCookie,
requestHeaders,
});
expect([buildFromVACookie, buildFromCustom]).toHaveLength(2);
});
it('partitions the cookie only inside an iframe, and clears both kinds', async () => {
const env = process.env as Record<string, string | undefined>;
const previousEnv = env.NODE_ENV;
env.NODE_ENV = 'production';
// A fresh module instance, as `NODE_ENV` is read once at import.
const visitors = (await import(`./visitors?${'production'}`).finally(() => {
env.NODE_ENV = previousEnv;
})) as typeof import('./visitors');
const setCookie = (secFetchDest: string) => {
const response = new NextResponse();
for (const cookie of visitors.getResponseCookiesForVisitorAuth({
basePath: '/',
visitorTokenLookup: { source: 'url', token },
requestHeaders: new Headers({ 'sec-fetch-dest': secFetchDest }),
})) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
return response.headers.get('set-cookie');
};
expect(setCookie('iframe')).toContain('Partitioned');
expect(setCookie('document')).not.toContain('Partitioned');
const clear = `${getVisitorAuthCookieName('/')}=; Path=/; Max-Age=0; Secure; SameSite=None`;
expect(visitors.getVisitorAuthClearCookieHeaders('/')).toEqual([
`${clear}; Partitioned`,
clear,
]);
});
});
describe('getResponseCookiesForVisitorAuth chunking', () => {
@@ -261,7 +307,12 @@ describe('getResponseCookiesForVisitorAuth chunking', () => {
};
const write = (basePath: string, token: string, previous: ResponseCookies = []) =>
getResponseCookiesForVisitorAuth(basePath, { source: 'url', token }, previous);
getResponseCookiesForVisitorAuth({
basePath,
visitorTokenLookup: { source: 'url', token },
requestCookies: previous,
requestHeaders: new Headers(),
});
const read = (cookies: ResponseCookies, urlPath = '/') =>
getVisitorToken({
+48 -15
View File
@@ -15,6 +15,7 @@ const VISITOR_AUTH_PARAM = 'jwt_token';
const VISITOR_PARAM_PREFIX = 'visitor.';
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
const VISITOR_UNSIGNED_CLAIMS_PREFIX = 'gitbook-visitor-public';
const IS_SECURE = process.env.NODE_ENV === 'production';
/**
* Typing for a cookie, matching the internal type of Next.js.
@@ -28,6 +29,7 @@ export type ResponseCookie = {
secure: boolean;
maxAge: number;
path: string;
partitioned: boolean;
}>;
};
@@ -146,7 +148,10 @@ export function getVisitorData({
}): VisitorDataLookup {
const visitorToken = getVisitorToken({ cookies, headers, url });
const unsignedClaims = getVisitorUnsignedClaims({ cookies, url });
const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams);
const visitorParamsCookie = getResponseCookieForVisitorParams({
visitorParamsClaims: unsignedClaims.fromVisitorParams,
requestHeaders: headers,
});
return {
visitorToken,
@@ -318,9 +323,13 @@ function parseVisitorQueryParamValue(value: string): ClaimPrimitive {
/**
* Returns to cookie response to use in order to persist visitor params that were passed to the URL.
*/
function getResponseCookieForVisitorParams(
visitorParamsClaims: Record<string, ClaimPrimitive>
): ResponseCookie | undefined {
function getResponseCookieForVisitorParams({
visitorParamsClaims,
requestHeaders,
}: {
visitorParamsClaims: Record<string, ClaimPrimitive>;
requestHeaders: Headers;
}): ResponseCookie | undefined {
if (Object.keys(visitorParamsClaims).length === 0) {
return undefined;
}
@@ -328,10 +337,20 @@ function getResponseCookieForVisitorParams(
return {
name: VISITOR_UNSIGNED_CLAIMS_PREFIX,
value: JSON.stringify(visitorParamsClaims),
options: {
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
},
options: getVisitorCookieCrossSiteOptions(requestHeaders),
};
}
/**
* Attributes letting visitor cookies be stored inside a cross-site iframe (authenticated embeds).
* `Partitioned` (CHIPS) is kept to iframes: Safari drops partitioned cookies after cross-site
* redirect chains (WebKit 306194), which top-level login flows go through.
*/
function getVisitorCookieCrossSiteOptions(requestHeaders: Headers) {
return {
sameSite: IS_SECURE ? ('none' as const) : undefined,
secure: IS_SECURE,
partitioned: IS_SECURE && requestHeaders.get('sec-fetch-dest') === 'iframe',
};
}
@@ -344,11 +363,17 @@ function getResponseCookieForVisitorParams(
* custom `gitbook-visitor-token` cookie is owned by the customer's backend and is never copied,
* to keep a single source of truth.
*/
export function getResponseCookiesForVisitorAuth(
basePath: string,
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>,
requestCookies: RequestCookies = []
): ResponseCookies {
export function getResponseCookiesForVisitorAuth({
basePath,
visitorTokenLookup,
requestCookies = [],
requestHeaders,
}: {
basePath: string;
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>;
requestCookies?: RequestCookies;
requestHeaders: Headers;
}): ResponseCookies {
let decoded: JwtPayload;
try {
decoded = jwtDecode(visitorTokenLookup.token);
@@ -361,8 +386,7 @@ export function getResponseCookiesForVisitorAuth(
const value = getVisitorAuthCookieValue(basePath, visitorTokenLookup.token);
const options = {
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : undefined,
secure: process.env.NODE_ENV === 'production',
...getVisitorCookieCrossSiteOptions(requestHeaders),
maxAge: getVisitorAuthCookieMaxAge(decoded),
};
@@ -392,6 +416,15 @@ export function getVisitorAuthCookieName(basePath: string): string {
return getPathScopedCookieName(VISITOR_TOKEN_COOKIE, basePath);
}
/**
* `Set-Cookie` values clearing the visitor authentication cookie. Browsers keep partitioned and
* unpartitioned cookies apart, so both are cleared whenever `Partitioned` can be set.
*/
export function getVisitorAuthClearCookieHeaders(basePath: string): string[] {
const header = `${getVisitorAuthCookieName(basePath)}=; Path=/; Max-Age=0${IS_SECURE ? '; Secure; SameSite=None' : ''}`;
return IS_SECURE ? [`${header}; Partitioned`, header] : [header];
}
/**
* Get the value of the visitor authentication cookie for a given base path and token.
*/
+23 -7
View File
@@ -23,6 +23,7 @@ import {
} from './lib/tracking';
import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths';
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
import { type AskVisitorAgent, getAskVisitorAgent } from '@/lib/ask-visitor';
import { isChatGPTRequest } from '@/lib/chatgpt';
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
import type { SiteURLData } from '@/lib/context';
@@ -51,6 +52,8 @@ import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
isPreviewRequest,
parsePreviewCustomizationCookie,
serializePreviewCustomizationCookie,
} from '@/lib/preview';
import {
type ResponseCookies,
@@ -350,11 +353,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
) {
if (visitorToken?.source === 'url') {
cookies.push(
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorToken,
request.cookies.getAll()
)
...getResponseCookiesForVisitorAuth({
basePath: getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorTokenLookup: visitorToken,
requestCookies: request.cookies.getAll(),
requestHeaders: request.headers,
})
);
}
@@ -421,7 +425,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const customizationCookie = request.cookies.get(MiddlewareHeaders.Customization);
const customization =
siteRequestURL.searchParams.get('customization') ??
(customizationCookie ? decodeURIComponent(customizationCookie.value) : undefined);
(customizationCookie
? parsePreviewCustomizationCookie(customizationCookie.value, siteURLData.siteSpace)
: undefined);
if (
allowCustomizationOverride &&
customization &&
@@ -434,7 +440,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
cookies.push(
getPreviewCookieResponse({
name: MiddlewareHeaders.Customization,
value: encodeURIComponent(customization),
value: serializePreviewCustomizationCookie({
customization,
siteSpace: siteURLData.siteSpace,
}),
mode,
siteRequestURL,
siteURLData,
@@ -470,11 +479,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
events,
isAiAgent,
isChatGPT,
askVisitorAgent,
} = encodePathInSiteContent(siteURLData, request);
routeType = routeTypeFromPathname ?? routeType;
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
stableSiteURLData.isAiAgent = isAiAgent;
stableSiteURLData.isChatGPT = isChatGPT;
// Splits the cache of `?ask=` answers per user agent, so each asker is classified correctly.
stableSiteURLData.askVisitorAgent = askVisitorAgent;
// Apply a forced theme (`?theme=`/cookie). For the docs embed we thread it through the
// route context (`embedTheme`) so those routes stay statically rendered — it becomes part
@@ -800,6 +812,8 @@ function encodePathInSiteContent(
isAiAgent?: boolean;
/** Only set for Markdown and LLM routes, where the output content type depends on ChatGPT. */
isChatGPT?: boolean;
/** Only set for the ask route, where the API classifies the asker from these headers. */
askVisitorAgent?: AskVisitorAgent;
} {
let pathname = removeLeadingSlash(removeTrailingSlash(siteURLData.pathname));
@@ -941,6 +955,8 @@ function encodePathInSiteContent(
// Left undefined for non-agents to avoid splitting the static cache for them.
isAiAgent: isAiAgent || undefined,
isChatGPT: isChatGPTRequest(request) || undefined,
askVisitorAgent:
typeof ask === 'string' ? getAskVisitorAgent(request.headers) : undefined,
// TODO: track pageId / spaceId when possible
// We don't do it at the moment as we can't easily extract it from the URL.
events: ask
+10 -1
View File
@@ -1,5 +1,6 @@
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { linkerWithMarkdownPages } from '@/lib/links';
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
@@ -14,6 +15,11 @@ export interface ServeAskMarkdownOptions {
* passed via the `?goal=` search parameter. Used by the backend to steer the answer.
*/
goal?: string;
/**
* Headers of the visitor asking the question, forwarded to the backend to classify the asker.
*/
visitorAgent?: AskVisitorAgent;
}
/**
@@ -41,7 +47,10 @@ export async function serveAskMarkdown(
return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=<question>` search parameter to get a complete answer and associated sources.';
}
const latestAnswer = await streamSiteAskAnswer(context, question, { goal });
const latestAnswer = await streamSiteAskAnswer(context, question, {
goal,
visitorAgent: options.visitorAgent,
});
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
return `We couldn't answer this question.`;
+1 -1
View File
@@ -7,7 +7,7 @@ import { ColorCategory, hexToRgb, scale, shadesOfColor } from '@gitbook/colors';
export const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
export const opacities = [0, 4, 8, 12, 16, 24, 40, 64, 72, 88, 96, 100];
export const semanticColors = ['info', 'warning', 'danger', 'success'];
export const semanticColors = ['secondary', 'info', 'warning', 'danger', 'success'];
/**
* Generate a Tailwind color shades from a variable.
@@ -196,6 +196,10 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
// Rendered without a source before mount so it reserves its size in the SSR layout.
src={mounted ? element.source.url : undefined}
title={element.source.url}
// A page can hold hundreds of these, most of them inside collapsed expandables.
// Loading them all on mount delays the first paint by seconds, so each one waits
// until it is near the viewport.
loading="lazy"
allowFullScreen
allow="clipboard-write"
className="contentkit-webframe block h-full w-full max-w-full border-none"