mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Load the OpenAPI renderer only on pages that use it (#4472)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/react-openapi": minor
|
||||
---
|
||||
|
||||
Keep the OpenAPI renderer out of the initial bundle of pages that have no OpenAPI block, by building its context on the client behind a dynamic boundary.
|
||||
@@ -59,6 +59,10 @@ const nextConfig = {
|
||||
optimisticClientCache: false,
|
||||
// Disable splitting the RSC in like 5 chunks
|
||||
prefetchInlining: true,
|
||||
|
||||
// Rewrites barrel imports into deep ones: without it, importing a single helper from
|
||||
// react-openapi drags its whole client renderer into every page's entry.
|
||||
optimizePackageImports: ['@gitbook/react-openapi'],
|
||||
},
|
||||
|
||||
env: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { DocumentContext } from '../DocumentView';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { convertCodeStringToBlock } from './utils';
|
||||
|
||||
// Client counterpart of `PlainCodeBlock`, for callers that build code from client state and so
|
||||
// cannot go through the async server `CodeBlock`.
|
||||
export function ClientPlainCodeBlock(props: {
|
||||
code: string;
|
||||
syntax: string;
|
||||
mode?: DocumentContext['mode'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
}) {
|
||||
const { code, syntax, mode = 'default', themes } = props;
|
||||
const id = useId();
|
||||
|
||||
const block = convertCodeStringToBlock({ key: id, code, syntax });
|
||||
|
||||
return (
|
||||
<ClientCodeBlock
|
||||
block={block}
|
||||
inlines={[]}
|
||||
inlineExprVariables={{}}
|
||||
mode={mode}
|
||||
themes={themes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
|
||||
import {
|
||||
OpenAPIOperation as BaseOpenAPIOperation,
|
||||
OpenAPISchemas as BaseOpenAPISchemas,
|
||||
OpenAPIWebhook as BaseOpenAPIWebhook,
|
||||
type OpenAPIContextInput,
|
||||
} from '@gitbook/react-openapi';
|
||||
import type React from 'react';
|
||||
|
||||
import { ClientPlainCodeBlock } from '../CodeBlock/ClientPlainCodeBlock';
|
||||
import type { DocumentContext } from '../DocumentView';
|
||||
|
||||
// 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.
|
||||
export type OpenAPIBlockClientContextProps = {
|
||||
className?: string;
|
||||
mode: DocumentContext['mode'];
|
||||
codeTheme?: CustomizationThemedCodeTheme;
|
||||
icons: OpenAPIContextInput['icons'];
|
||||
specUrl: string | null;
|
||||
/** Pre-signed server-side: signing needs a secret the client must never see. */
|
||||
proxyUrl?: string;
|
||||
locale?: OpenAPIContextInput['locale'];
|
||||
expandAllResponses?: boolean;
|
||||
expandAllModelSections?: boolean;
|
||||
headless?: boolean;
|
||||
id?: string;
|
||||
blockKey?: string;
|
||||
/** Rendered on the server: both go through the async document pipeline. */
|
||||
headingNode: React.ReactNode;
|
||||
descriptionNode: React.ReactNode;
|
||||
scalarRuntimeURL: string;
|
||||
};
|
||||
|
||||
type OpenAPIBlockVariant =
|
||||
| { variant: 'operation'; data: React.ComponentProps<typeof BaseOpenAPIOperation>['data'] }
|
||||
| { variant: 'webhook'; data: React.ComponentProps<typeof BaseOpenAPIWebhook>['data'] }
|
||||
| {
|
||||
variant: 'schemas';
|
||||
data: React.ComponentProps<typeof BaseOpenAPISchemas>['data'];
|
||||
grouped?: boolean;
|
||||
};
|
||||
|
||||
export type OpenAPIBlockClientProps = OpenAPIBlockClientContextProps & OpenAPIBlockVariant;
|
||||
|
||||
export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
|
||||
const { className, mode, codeTheme, headingNode, descriptionNode, proxyUrl } = props;
|
||||
|
||||
const context: OpenAPIContextInput = {
|
||||
icons: props.icons,
|
||||
specUrl: props.specUrl,
|
||||
locale: props.locale,
|
||||
expandAllResponses: props.expandAllResponses,
|
||||
expandAllModelSections: props.expandAllModelSections,
|
||||
headless: props.headless,
|
||||
id: props.id,
|
||||
blockKey: props.blockKey,
|
||||
renderCodeBlock: ({ code, syntax }) => (
|
||||
<ClientPlainCodeBlock code={code} syntax={syntax} mode={mode} themes={codeTheme} />
|
||||
),
|
||||
renderDocument: () => descriptionNode,
|
||||
renderHeading: () => headingNode,
|
||||
resolveProxyUrl: proxyUrl ? () => proxyUrl : undefined,
|
||||
scalarRuntimeURL: props.scalarRuntimeURL,
|
||||
};
|
||||
|
||||
switch (props.variant) {
|
||||
case 'operation':
|
||||
return (
|
||||
<BaseOpenAPIOperation data={props.data} context={context} className={className} />
|
||||
);
|
||||
case 'webhook':
|
||||
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
|
||||
case 'schemas':
|
||||
return (
|
||||
<BaseOpenAPISchemas
|
||||
data={props.data}
|
||||
grouped={props.grouped}
|
||||
context={context}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { OpenAPIBlockClientProps } from './OpenAPIBlockClient';
|
||||
|
||||
// `ssr: true` keeps the operation in the server-rendered HTML — API reference content is the whole
|
||||
// SEO point of these pages.
|
||||
const OpenAPIBlockClient = dynamic(
|
||||
() => import('./OpenAPIBlockClient').then((mod) => mod.OpenAPIBlockClient),
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function OpenAPIBlockLazy(props: OpenAPIBlockClientProps) {
|
||||
return <OpenAPIBlockClient {...props} />;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { OpenAPIOperation as BaseOpenAPIOperation } from '@gitbook/react-openapi';
|
||||
|
||||
import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
|
||||
import { getOpenAPIBlockClientProps } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-operation block.
|
||||
@@ -44,10 +43,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseOpenAPIOperation
|
||||
<OpenAPIBlockLazy
|
||||
variant="operation"
|
||||
data={data}
|
||||
context={getOpenAPIContext({
|
||||
{...getOpenAPIBlockClientProps({
|
||||
props,
|
||||
data,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllResponses:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { resolveOpenAPISchemasBlock } from '@/lib/openapi/resolveOpenAPISchemasBlock';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
||||
|
||||
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
|
||||
import { getOpenAPIBlockClientProps } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi-schemas block.
|
||||
@@ -45,11 +45,13 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseOpenAPISchemas
|
||||
<OpenAPIBlockLazy
|
||||
variant="schemas"
|
||||
data={data}
|
||||
grouped={block.data.grouped}
|
||||
context={getOpenAPIContext({
|
||||
{...getOpenAPIBlockClientProps({
|
||||
props,
|
||||
schemas: { data, grouped: block.data.grouped },
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllModelSections: data['x-expandAllModelSections'],
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { OpenAPIWebhook as BaseOpenAPIWebhook } from '@gitbook/react-openapi';
|
||||
|
||||
import { resolveOpenAPIWebhookBlock } from '@/lib/openapi/resolveOpenAPIWebhookBlock';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
|
||||
import { getOpenAPIBlockClientProps } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-webhook block.
|
||||
@@ -46,10 +45,12 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseOpenAPIWebhook
|
||||
<OpenAPIBlockLazy
|
||||
variant="webhook"
|
||||
data={data}
|
||||
context={getOpenAPIContext({
|
||||
{...getOpenAPIBlockClientProps({
|
||||
props,
|
||||
data,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllResponses:
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { JSONDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||
import {
|
||||
type OpenAPIContextInput,
|
||||
type OpenAPIOperationData,
|
||||
type OpenAPISchemasData,
|
||||
type OpenAPIWebhookData,
|
||||
checkIsValidLocale,
|
||||
extractOrigin,
|
||||
getAllServerOrigins,
|
||||
getOperationTitle,
|
||||
getSchemasHeading,
|
||||
} from '@gitbook/react-openapi';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { Heading } from '../Heading';
|
||||
import type { OpenAPIBlockClientContextProps } from './OpenAPIBlockClient';
|
||||
|
||||
import './style.css';
|
||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||
@@ -121,3 +132,67 @@ export function getOpenAPIContext(args: {
|
||||
locale,
|
||||
};
|
||||
}
|
||||
|
||||
// Same inputs as `getOpenAPIContext`, but anything that can't cross the RSC boundary is pre-rendered
|
||||
// here or rebuilt on the client, so the block can sit behind a `next/dynamic` chunk.
|
||||
export function getOpenAPIBlockClientProps(args: {
|
||||
props: BlockProps<AnyOpenAPIOperationsBlock | OpenAPISchemasBlock | OpenAPIWebhookBlock>;
|
||||
/** Omitted for schemas blocks, which have no operation nor servers. */
|
||||
data?: OpenAPIOperationData | OpenAPIWebhookData;
|
||||
/** Schemas blocks render their own heading, from a different shape. */
|
||||
schemas?: { data: OpenAPISchemasData; grouped: boolean | undefined };
|
||||
specUrl: string | null;
|
||||
context: GitBookAnyContext | undefined;
|
||||
expandAllResponses?: boolean;
|
||||
expandAllModelSections?: boolean;
|
||||
headless?: boolean;
|
||||
}): OpenAPIBlockClientContextProps {
|
||||
const { props, data, schemas, specUrl, context } = args;
|
||||
const serverContext = getOpenAPIContext(args);
|
||||
const { renderCodeBlock, renderDocument, renderHeading, resolveProxyUrl, ...serializable } =
|
||||
serverContext;
|
||||
|
||||
const heading = data
|
||||
? getOperationHeading(data, serializable.headless)
|
||||
: schemas
|
||||
? getSchemasHeading(schemas.data, schemas.grouped)
|
||||
: null;
|
||||
const descriptionDocument = data?.operation['x-gitbook-description-document'];
|
||||
|
||||
const origins = data ? getAllServerOrigins(data.servers) : [];
|
||||
const specOrigin = specUrl ? extractOrigin(specUrl) : null;
|
||||
if (specOrigin) {
|
||||
origins.push(specOrigin);
|
||||
}
|
||||
|
||||
return {
|
||||
...serializable,
|
||||
mode: props.context.mode,
|
||||
codeTheme:
|
||||
context && 'customization' in context
|
||||
? context.customization.styling.codeTheme.openapi
|
||||
: undefined,
|
||||
proxyUrl: resolveProxyUrl?.(origins) ?? undefined,
|
||||
headingNode: heading ? renderHeading(heading) : null,
|
||||
descriptionNode: descriptionDocument
|
||||
? renderDocument({ document: descriptionDocument })
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the condition in `OpenAPISummary`, which is the only place asking for this heading.
|
||||
function getOperationHeading(
|
||||
data: OpenAPIOperationData | OpenAPIWebhookData,
|
||||
headless: boolean | undefined
|
||||
) {
|
||||
const title = getOperationTitle(data);
|
||||
if (headless || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
deprecated: data.operation.deprecated ?? false,
|
||||
stability: data.operation['x-stability'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OpenAPIPath } from '../OpenAPIPath';
|
||||
import type { OpenAPIContext } from '../context';
|
||||
import type { OpenAPIOperationData, OpenAPIWebhookData } from '../types';
|
||||
import { getOperationTitle } from '../utils';
|
||||
import { OpenAPIMcpBadge } from './OpenAPIMcpBadge';
|
||||
import { OpenAPIStability } from './OpenAPIStability';
|
||||
|
||||
@@ -11,17 +12,7 @@ export function OpenAPISummary(props: {
|
||||
const { data, context } = props;
|
||||
const { operation } = data;
|
||||
|
||||
const title = (() => {
|
||||
if (operation.summary) {
|
||||
return operation.summary;
|
||||
}
|
||||
|
||||
if ('name' in data) {
|
||||
return data.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
const title = getOperationTitle(data);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -64,9 +64,13 @@ export function generateMediaTypeExamples(
|
||||
/** Hard limit for rendering circular references */
|
||||
const MAX_LEVELS_DEEP = 5;
|
||||
|
||||
// Fixed, not `new Date()`: the block is rendered on the server and hydrated in the browser, so any
|
||||
// value read from the clock differs between the two and fails hydration.
|
||||
const EXAMPLE_DATE = '2026-01-01T00:00:00.000Z';
|
||||
|
||||
const genericExampleValues: Record<string, string> = {
|
||||
'date-time': new Date().toISOString(),
|
||||
date: new Date().toISOString().split('T')[0] ?? '1970-01-01',
|
||||
'date-time': EXAMPLE_DATE,
|
||||
date: EXAMPLE_DATE.split('T')[0] ?? '1970-01-01',
|
||||
email: 'name@gmail.com',
|
||||
hostname: 'example.com',
|
||||
ipv4: '0.0.0.0',
|
||||
@@ -86,7 +90,7 @@ const genericExampleValues: Record<string, string> = {
|
||||
// https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01
|
||||
'relative-json-pointer': '1/nested/objects',
|
||||
// full-time in https://tools.ietf.org/html/rfc3339#section-5.6
|
||||
time: new Date().toISOString().split('T')[1]?.split('.')[0] ?? '00:00:00Z',
|
||||
time: EXAMPLE_DATE.split('T')[1]?.split('.')[0] ?? '00:00:00Z',
|
||||
// either a URI or relative-reference https://tools.ietf.org/html/rfc3986#section-4.1
|
||||
'uri-reference': '../folder',
|
||||
'uri-template': 'https://example.com/{id}',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './schemas';
|
||||
export * from './formatOpenAPIMethod';
|
||||
export { getOperationTitle } from './utils';
|
||||
export * from './OpenAPIMethodBadge';
|
||||
export * from './OpenAPIOperation';
|
||||
export * from './OpenAPIWebhook';
|
||||
@@ -10,4 +11,4 @@ export * from './resolveOpenAPIWebhook';
|
||||
export type { OpenAPIOperationData, OpenAPIWebhookData } from './types';
|
||||
export type { OpenAPIContextInput } from './context';
|
||||
export { checkIsValidLocale } from './translations';
|
||||
export { extractOrigin } from './util/server';
|
||||
export { extractOrigin, getAllServerOrigins } from './util/server';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { t } from '../translate';
|
||||
import { getExampleFromSchema } from '../util/example';
|
||||
import { OpenAPISchemaItem } from './OpenAPISchemaItem';
|
||||
import type { OpenAPISchemasData } from './resolveOpenAPISchemas';
|
||||
import { type OpenAPISchemasData, getSchemasHeading } from './resolveOpenAPISchemas';
|
||||
|
||||
/**
|
||||
* OpenAPI Schemas component.
|
||||
@@ -38,19 +38,14 @@ export function OpenAPISchemas(props: {
|
||||
const clientContext = getOpenAPIClientContext(context);
|
||||
|
||||
// If there is only one model and we are not grouping, we show it directly.
|
||||
if (schemas.length === 1 && !grouped) {
|
||||
const title = `The ${firstSchema.name} object`;
|
||||
const heading = getSchemasHeading(data, grouped);
|
||||
if (heading) {
|
||||
const { title } = heading;
|
||||
return (
|
||||
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
|
||||
{/* The heading rendered below already carries the anchor id; a second id here would
|
||||
win the fragment target and scroll to the wrong margin. */}
|
||||
<div className="openapi-summary">
|
||||
{context.renderHeading({
|
||||
title,
|
||||
deprecated: Boolean(firstSchema.schema.deprecated),
|
||||
stability: firstSchema.schema['x-stability'],
|
||||
})}
|
||||
</div>
|
||||
<div className="openapi-summary">{context.renderHeading(heading)}</div>
|
||||
<div className="openapi-columns">
|
||||
<div className="openapi-column-spec">
|
||||
<StaticSection
|
||||
|
||||
@@ -11,6 +11,24 @@ export type OpenAPISchemasData = Pick<OpenAPICustomSpecProperties, 'x-expandAllM
|
||||
schemas: OpenAPISchema[];
|
||||
};
|
||||
|
||||
// Hosts that pre-render the heading must reach the same answer as `OpenAPISchemas`, so both read it
|
||||
// from here rather than duplicating the condition.
|
||||
export function getSchemasHeading(
|
||||
data: OpenAPISchemasData,
|
||||
grouped: boolean | undefined
|
||||
): { title: string; deprecated: boolean; stability?: string } | null {
|
||||
const firstSchema = data.schemas[0];
|
||||
if (!firstSchema || data.schemas.length !== 1 || grouped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: `The ${firstSchema.name} object`,
|
||||
deprecated: Boolean(firstSchema.schema.deprecated),
|
||||
stability: firstSchema.schema['x-stability'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an OpenAPI schemas from a file and compile it to a more usable format.
|
||||
* Schemas are extracted from the OpenAPI components.schemas
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
OpenAPICustomSecurityScheme,
|
||||
OpenAPIOperationData,
|
||||
OpenAPISecurityScope,
|
||||
OpenAPIWebhookData,
|
||||
} from './types';
|
||||
|
||||
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
|
||||
@@ -483,3 +484,16 @@ function resolveRequiredScopesForScheme(
|
||||
|
||||
return operationScopes.map((scope) => [scope, undefined]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Title shown for an operation or webhook. Exported so hosts that pre-render the heading
|
||||
* server-side derive it the same way this package does.
|
||||
*/
|
||||
export function getOperationTitle(
|
||||
data: OpenAPIOperationData | OpenAPIWebhookData
|
||||
): string | undefined {
|
||||
if (data.operation.summary) {
|
||||
return data.operation.summary;
|
||||
}
|
||||
return 'name' in data ? data.name : undefined;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown';
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: 'src/index.ts',
|
||||
// One file per module, so consumers can deep-import past the barrel.
|
||||
unbundle: true,
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user