diff --git a/.changeset/shaggy-pumas-rush.md b/.changeset/shaggy-pumas-rush.md new file mode 100644 index 0000000000..0be76c3e58 --- /dev/null +++ b/.changeset/shaggy-pumas-rush.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": patch +"gitbook": patch +--- + +Introduce the concept of slim documents diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 20e495656e..137ff158be 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -1,3 +1,4 @@ +import { getSlimJSONDocument } from '@/lib/slim-document'; import { trace } from '@/lib/tracing'; import { type ComputedContentSource, @@ -779,7 +780,7 @@ const getDocumentUncached = async ( cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); } - return res.data; + return getSlimJSONDocument(res.data); }); }); }; @@ -872,7 +873,7 @@ const getComputedDocumentUncached = async ( cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); } - return res.data; + return getSlimJSONDocument(res.data); }); } ); diff --git a/packages/gitbook-v2/src/lib/data/pages.ts b/packages/gitbook-v2/src/lib/data/pages.ts index 4324254d9b..388fcff7c7 100644 --- a/packages/gitbook-v2/src/lib/data/pages.ts +++ b/packages/gitbook-v2/src/lib/data/pages.ts @@ -1,4 +1,5 @@ -import type { JSONDocument, RevisionPageDocument, Space } from '@gitbook/api'; +import type { SlimJSONDocument } from '@/lib/slim-document'; +import type { RevisionPageDocument, Space } from '@gitbook/api'; import { getDataOrNull } from './errors'; import type { GitBookDataFetcher } from './types'; @@ -9,7 +10,7 @@ export async function getPageDocument( dataFetcher: GitBookDataFetcher, space: Space, page: RevisionPageDocument -): Promise { +): Promise { if (page.documentId) { return getDataOrNull( dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId }) diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..ba8f3eca5f 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -1,3 +1,4 @@ +import type { SlimJSONDocument } from '@/lib/slim-document'; import type * as api from '@gitbook/api'; export type DataFetcherErrorData = { @@ -110,7 +111,7 @@ export interface GitBookDataFetcher { * Get a document by its space ID and document ID. */ getDocument(params: { spaceId: string; documentId: string }): Promise< - DataFetcherResponse + DataFetcherResponse >; /** @@ -121,7 +122,7 @@ export interface GitBookDataFetcher { spaceId: string; source: api.ComputedContentSource; seed: string; - }): Promise>; + }): Promise>; /** * Get a reusable content by its space ID, revision ID and reusable content ID. diff --git a/packages/gitbook/src/components/DocumentView/Block.tsx b/packages/gitbook/src/components/DocumentView/Block.tsx index f3acafce0d..c842a45916 100644 --- a/packages/gitbook/src/components/DocumentView/Block.tsx +++ b/packages/gitbook/src/components/DocumentView/Block.tsx @@ -1,4 +1,3 @@ -import type { DocumentBlock, JSONDocument } from '@gitbook/api'; import React from 'react'; import { @@ -10,6 +9,7 @@ import { } from '@/components/primitives'; import type { ClassValue } from '@/lib/tailwind'; +import type { SlimDocumentBlock, SlimJSONDocument } from '@/lib/slim-document'; import { BlockContentRef } from './BlockContentRef'; import { CodeBlock } from './CodeBlock'; import { Divider } from './Divider'; @@ -34,10 +34,10 @@ import { StepperStep } from './StepperStep'; import { Table } from './Table'; import { Tabs } from './Tabs'; -export interface BlockProps extends DocumentContextProps { +export interface BlockProps extends DocumentContextProps { block: Block; - document: JSONDocument; - ancestorBlocks: DocumentBlock[]; + document: SlimJSONDocument; + ancestorBlocks: SlimDocumentBlock[]; /** If true, we estimate that the block will be outside the initial viewport */ isEstimatedOffscreen: boolean; /** Class names to be passed to the underlying DOM element */ @@ -51,7 +51,7 @@ function nullIfNever(_value: never): null { return null; } -export function Block(props: BlockProps) { +export function Block(props: BlockProps) { const { block, style, isEstimatedOffscreen, context } = props; const content = (() => { @@ -132,7 +132,7 @@ export function Block(props: BlockProps) { /** * Skeleton for a block while it is being loaded. */ -export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }) { +export function BlockSkeleton(props: { block: SlimDocumentBlock; style: ClassValue }) { const { block, style } = props; const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined; diff --git a/packages/gitbook/src/components/DocumentView/Blocks.tsx b/packages/gitbook/src/components/DocumentView/Blocks.tsx index 903acd2955..f2346b3316 100644 --- a/packages/gitbook/src/components/DocumentView/Blocks.tsx +++ b/packages/gitbook/src/components/DocumentView/Blocks.tsx @@ -1,7 +1,6 @@ -import type { DocumentBlock, JSONDocument } from '@gitbook/api'; - import { type ClassValue, tcls } from '@/lib/tailwind'; +import type { SlimDocumentBlock, SlimJSONDocument } from '@/lib/slim-document'; import { Block } from './Block'; import type { DocumentContextProps } from './DocumentView'; import { isBlockOffscreen } from './utils'; @@ -9,7 +8,7 @@ import { isBlockOffscreen } from './utils'; /** * Renders a list of blocks with a wrapper element. */ -export function Blocks( +export function Blocks( props: UnwrappedBlocksProps & { /** HTML tag to use for the wrapper */ tag?: Tag; @@ -30,15 +29,15 @@ export function Blocks = DocumentContextProps & { +type UnwrappedBlocksProps = DocumentContextProps & { /** Blocks to render */ nodes: TBlock[]; /** Document being rendered */ - document: JSONDocument; + document: SlimJSONDocument; /** Ancestors of the blocks */ - ancestorBlocks: DocumentBlock[]; + ancestorBlocks: SlimDocumentBlock[]; /** Style passed to all blocks */ blockStyle?: ClassValue; @@ -50,7 +49,9 @@ type UnwrappedBlocksProps = DocumentContextProps & /** * Renders a list of blocks without a wrapper element. */ -export function UnwrappedBlocks(props: UnwrappedBlocksProps) { +export function UnwrappedBlocks( + props: UnwrappedBlocksProps +) { const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props; let isOffscreen = defaultIsOffscreen; @@ -65,11 +66,11 @@ export function UnwrappedBlocks(props: UnwrappedBl return ( , 'block' | 'style'> & { +interface ClientBlockProps extends Omit { + block: SlimDocumentBlockCode; inlines: RenderedInline[]; -}; +} /** * Render a code-block client-side by loading the highlighter asynchronously. @@ -24,14 +24,14 @@ export function ClientCodeBlock(props: ClientBlockProps) { const blockRef = useRef(null); const isInViewportRef = useRef(false); const [isInViewport, setIsInViewport] = useState(false); - const plainLines = useMemo(() => plainHighlight(block, []), [block]); + const plainLines = useMemo(() => plainHighlight({ block, inlines: [] }), [block]); const [lines, setLines] = useState(null); const [highlighting, setHighlighting] = useState(false); // Preload the highlighter when the block is mounted. useEffect(() => { - import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block)); - }, [block]); + import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block.data.syntax)); + }, [block.data.syntax]); // When user scrolls, we need to wait for the scroll to finish before running the highlight const isScrollingRef = useRef(false); @@ -80,7 +80,7 @@ export function ClientCodeBlock(props: ClientBlockProps) { if (typeof window !== 'undefined') { setHighlighting(true); import('./highlight').then(({ highlight }) => { - highlight(block, inlines).then((lines) => { + highlight({ block, inlines }).then((lines) => { if (cancelled) { return; } @@ -104,7 +104,9 @@ export function ClientCodeBlock(props: ClientBlockProps) { diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx index 2516c8d4ff..aa8f74ec50 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx @@ -1,8 +1,7 @@ -import type { DocumentBlockCode } from '@gitbook/api'; - import { getNodeFragmentByType } from '@/lib/document'; import { isV2 } from '@/lib/v2'; +import type { SlimDocumentBlockCode } from '@/lib/slim-document'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; import { ClientCodeBlock } from './ClientCodeBlock'; @@ -12,8 +11,11 @@ import { type RenderedInline, getInlines, highlight } from './highlight'; /** * Render a code block, can be client-side or server-side. */ -export async function CodeBlock(props: BlockProps) { +export async function CodeBlock(props: BlockProps) { const { block, document, style, isEstimatedOffscreen, context } = props; + const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; + const withWrap = block.data.overflow === 'wrap'; + const title = block.data.title ?? ''; const inlines = getInlines(block); const richInlines: RenderedInline[] = inlines.map((inline, index) => { const body = (() => { @@ -38,9 +40,29 @@ export async function CodeBlock(props: BlockProps) { if (isV2() && !isEstimatedOffscreen) { // In v2, we render the code block server-side - const lines = await highlight(block, richInlines); - return ; + const lines = await highlight({ + inlines: richInlines, + block, + }); + return ( + + ); } - return ; + return ( + + ); } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index f1f8cb8f63..2ae28f7f1b 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -12,10 +12,13 @@ import type { HighlightLine, HighlightToken } from './highlight'; import './theme.css'; import './CodeBlockRenderer.css'; -type CodeBlockRendererProps = Pick, 'block' | 'style'> & { +export interface CodeBlockRendererProps extends Pick, 'style'> { lines: HighlightLine[]; 'aria-busy'?: boolean; -}; + withLineNumbers: boolean; + withWrap: boolean; + title: string; +} /** * The logic of rendering a code block from lines. @@ -24,12 +27,8 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( props: CodeBlockRendererProps, ref: React.ForwardedRef ) { - const { block, style, lines, 'aria-busy': ariaBusy } = props; - + const { style, lines, withLineNumbers, withWrap, title, 'aria-busy': ariaBusy } = props; const id = useId(); - const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; - const withWrap = block.data.overflow === 'wrap'; - const title = block.data.title; return (
({ + const slimBlock = getSlimDocumentBlock(block); + const inlines: RenderedInline[] = getInlines(slimBlock).map((inline) => ({ inline, body: null, })); - return highlight(block, inlines); + return highlight({ + inlines, + block: slimBlock, + }); } it('should parse plain code', async () => { @@ -46,31 +51,29 @@ it('should parse plain code', async () => { it('should parse different code in parallel', async () => { await Promise.all( - ['shell', 'scss', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) => - highlight( - { - object: 'block', - type: 'code', - data: { - syntax: syntax, - }, - nodes: [ - { - object: 'block', - type: 'code-line', - data: {}, - nodes: [ - { - object: 'text', - leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }], - }, - ], - }, - ], + ['shell', 'scss', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) => { + const block: DocumentBlockCode = { + object: 'block', + type: 'code', + data: { + syntax, }, - [] - ) - ) + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }], + }, + ], + }, + ], + }; + highlight({ block: getSlimDocumentBlock(block), inlines: [] }); + }) ); }); diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts index eb02b5b928..084b79eac5 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts @@ -1,8 +1,4 @@ -import type { - DocumentBlockCode, - DocumentBlockCodeLine, - DocumentInlineAnnotation, -} from '@gitbook/api'; +import type { DocumentInlineAnnotation } from '@gitbook/api'; import { type ThemedToken, createCssVariablesTheme, @@ -12,6 +8,7 @@ import { import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import { type BundledLanguage, bundledLanguages } from 'shiki/langs'; +import type { SlimDocumentBlockCode, SlimDocumentBlockCodeLine } from '@/lib/slim-document'; import { plainHighlight } from './plain-highlight'; export type HighlightLine = { @@ -46,8 +43,8 @@ const { getSingletonHighlighter } = createSingletonShorthands( /** * Preload the highlighter for a code block. */ -export async function preloadHighlight(block: DocumentBlockCode) { - const langName = getBlockLang(block); +export async function preloadHighlight(syntax: string | undefined): Promise { + const langName = getLanguageFromSyntax(syntax); if (langName) { await getSingletonHighlighter({ langs: [langName], @@ -59,14 +56,16 @@ export async function preloadHighlight(block: DocumentBlockCode) { /** * Highlight a code block while preserving inline elements. */ -export async function highlight( - block: DocumentBlockCode, - inlines: RenderedInline[] -): Promise { - const langName = getBlockLang(block); +export async function highlight(args: { + inlines: RenderedInline[]; + block: SlimDocumentBlockCode; + // block: DocumentBlockCode, +}): Promise { + const { inlines, block } = args; + const langName = getLanguageFromSyntax(block.data.syntax); if (!langName) { // Language not found, fallback to plain highlighting - return plainHighlight(block, inlines); + return plainHighlight({ inlines, block }); } const code = getPlainCodeBlock(block); @@ -84,7 +83,7 @@ export async function highlight( let currentIndex = 0; return lines.map((tokens, index) => { - const lineBlock = block.nodes[index]; + const node = block.nodes[index]; const result: HighlightToken[] = []; const eatToken = (): PositionedToken | null => { @@ -104,7 +103,7 @@ export async function highlight( currentIndex += 1; // for the \n return { - highlighted: Boolean(lineBlock.data.highlighted), + highlighted: Boolean(node.highlighted), tokens: result, }; }); @@ -113,8 +112,8 @@ export async function highlight( /** * Get the language of a code block. */ -function getBlockLang(block: DocumentBlockCode): string | null { - return block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null; +function getLanguageFromSyntax(syntax: string | undefined): string | null { + return syntax ? getLanguageForSyntax(syntax) : null; } const syntaxAliases: Record = { @@ -148,7 +147,7 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null { return null; } -export function getInlines(block: DocumentBlockCode) { +export function getInlines(block: SlimDocumentBlockCode): InlineIndexed[] { const inlines: InlineIndexed[] = []; getPlainCodeBlock(block, inlines); @@ -237,14 +236,14 @@ function matchTokenAndInlines( return result; } -function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string { +function getPlainCodeBlock(block: SlimDocumentBlockCode, inlines?: InlineIndexed[]): string { let content = ''; - code.nodes.forEach((node, index) => { + block.nodes.forEach((node, index) => { const lineContent = getPlainCodeBlockLine(node, content.length, inlines); content += lineContent; - if (index < code.nodes.length - 1) { + if (index < block.nodes.length - 1) { content += '\n'; } }); @@ -253,26 +252,32 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): } function getPlainCodeBlockLine( - parent: DocumentBlockCodeLine | DocumentInlineAnnotation, + parent: SlimDocumentBlockCodeLine | DocumentInlineAnnotation, index: number, inlines?: InlineIndexed[] ): string { let content = ''; - for (const node of parent.nodes) { - if (node.object === 'text') { - content += cleanupLine(node.leaves.map((leaf) => leaf.text).join('')); - } else { - const start = index + content.length; - content += getPlainCodeBlockLine(node, index + content.length, inlines); - const end = index + content.length; - - if (inlines) { - inlines.push({ - inline: node, - start, - end, - }); + if ('text' in parent && parent.text) { + return cleanupLine(parent.text); + } + + if ('nodes' in parent && parent.nodes) { + for (const node of parent.nodes) { + if (node.object === 'text') { + content += cleanupLine(node.leaves.map((leaf) => leaf.text).join('')); + } else { + const start = index + content.length; + content += getPlainCodeBlockLine(node, index + content.length, inlines); + const end = index + content.length; + + if (inlines) { + inlines.push({ + inline: node, + start, + end, + }); + } } } } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts index 5247c14ddc..fa3622bc78 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts @@ -1,40 +1,54 @@ -import type { DocumentBlockCode } from '@gitbook/api'; - import { getNodeText } from '@/lib/document'; +import type { SlimDocumentBlockCode } from '@/lib/slim-document'; +import assertNever from 'assert-never'; import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'; /** * Parse a code block without highlighting it. */ -export function plainHighlight( - block: DocumentBlockCode, - inlines: RenderedInline[] -): HighlightLine[] { +export function plainHighlight(args: { + inlines: RenderedInline[]; + block: SlimDocumentBlockCode; +}): HighlightLine[] { + const { inlines, block } = args; const inlinesCopy = Array.from(inlines); - return block.nodes.map((lineBlock) => { - const tokens: HighlightToken[] = lineBlock.nodes.map((node) => { - if (node.object === 'text') { - return { - type: 'plain', - content: getNodeText(node), - }; - } - const inline = inlinesCopy.shift(); - return { - type: 'annotation', - body: inline?.body ?? null, - children: [ + return block.nodes.map((node) => { + const tokens: HighlightToken[] = (() => { + if ('text' in node) { + return [ { type: 'plain', - content: getNodeText(node), + content: node.text, }, - ], - }; - }); + ]; + } + if ('nodes' in node) { + return node.nodes.map((node) => { + if (node.object === 'text') { + return { + type: 'plain', + content: getNodeText(node), + }; + } + const inline = inlinesCopy.shift(); + return { + type: 'annotation', + body: inline?.body ?? null, + children: [ + { + type: 'plain', + content: getNodeText(node), + }, + ], + }; + }); + } + assertNever(node); + })(); return { - highlighted: Boolean(lineBlock.data.highlighted), + highlighted: Boolean(node.highlighted), tokens, }; }); diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index 724a7f2b05..e5153bfb44 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -1,7 +1,7 @@ import type { ClassValue } from '@/lib/tailwind'; -import type { JSONDocument } from '@gitbook/api'; import type { GitBookAnyContext } from '@v2/lib/context'; +import type { SlimJSONDocument } from '@/lib/slim-document'; import { BlockSkeleton } from './Block'; import { Blocks } from './Blocks'; @@ -39,7 +39,7 @@ export interface DocumentContextProps { */ export function DocumentView( props: DocumentContextProps & { - document: JSONDocument; + document: SlimJSONDocument; /** Style passed to the container */ style?: ClassValue; @@ -73,14 +73,17 @@ export function DocumentView( /** * Placeholder for the entire document layout. */ -export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle: ClassValue }) { +export function DocumentViewSkeleton(props: { + document: SlimJSONDocument; + blockStyle: ClassValue; +}) { const { document, blockStyle } = props; return (
- {document.nodes.map((block) => ( + {document.nodes.map((block, index) => ( extends DocumentContextPr /** * Document being rendered. */ - document: JSONDocument; + document: SlimJSONDocument; /** * Inline ancestors of the current inline. diff --git a/packages/gitbook/src/components/DocumentView/Inlines.tsx b/packages/gitbook/src/components/DocumentView/Inlines.tsx index 45200414ce..863d5175e9 100644 --- a/packages/gitbook/src/components/DocumentView/Inlines.tsx +++ b/packages/gitbook/src/components/DocumentView/Inlines.tsx @@ -1,5 +1,6 @@ -import type { DocumentInline, DocumentText, JSONDocument } from '@gitbook/api'; +import type { DocumentInline, DocumentText } from '@gitbook/api'; +import type { SlimJSONDocument } from '@/lib/slim-document'; import type { DocumentContextProps } from './DocumentView'; import { Inline } from './Inline'; import { Text } from './Text'; @@ -9,7 +10,7 @@ export function Inlines( /** * Document being rendered. */ - document: JSONDocument; + document: SlimJSONDocument; /** * Ancestors of the current inline. diff --git a/packages/gitbook/src/components/DocumentView/ListItem.tsx b/packages/gitbook/src/components/DocumentView/ListItem.tsx index d290de8074..352bbae04a 100644 --- a/packages/gitbook/src/components/DocumentView/ListItem.tsx +++ b/packages/gitbook/src/components/DocumentView/ListItem.tsx @@ -1,5 +1,4 @@ import type { - DocumentBlock, DocumentBlockListItem, DocumentBlockListOrdered, DocumentBlockListUnordered, @@ -10,6 +9,7 @@ import { assert } from 'ts-essentials'; import { Checkbox } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; +import type { SlimDocumentBlock } from '@/lib/slim-document'; import type { BlockProps } from './Block'; import { Blocks } from './Blocks'; import { getBlockTextStyle } from './spacing'; @@ -103,7 +103,7 @@ export function ListItem(props: BlockProps) { } function getListItemDepth(input: { - ancestorBlocks: DocumentBlock[]; + ancestorBlocks: SlimDocumentBlock[]; type: DocumentBlockListOrdered['type'] | DocumentBlockListUnordered['type']; }): number { const { ancestorBlocks, type } = input; diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index a40411fa6a..b88bd858e2 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -1,4 +1,3 @@ -import type { JSONDocument } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import type { OpenAPIContext } from '@gitbook/react-openapi'; @@ -12,6 +11,7 @@ import { Heading } from '../Heading'; import './scalar.css'; import './style.css'; import type { AnyOpenAPIOperationsBlock, OpenAPISchemasBlock } from '@/lib/openapi/types'; +import type { SlimJSONDocument } from '@/lib/slim-document'; /** * Get the OpenAPI context to render a block. @@ -32,7 +32,7 @@ export function getOpenAPIContext(args: { renderCodeBlock: (codeProps) => , renderDocument: (documentProps) => ( { +const getBlockHeight = memoize((block: SlimDocumentBlock | undefined): number => { if (!block) { return 0; } @@ -115,6 +115,6 @@ const getBlockHeight = memoize((block: DocumentBlock | undefined): number => { } }); -const getBlockHeights = memoize((blocks: DocumentBlock[]): number => { +const getBlockHeights = memoize((blocks: SlimDocumentBlock[]): number => { return blocks.reduce((total, block) => total + getBlockHeight(block), 0); }); diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index a51410cbc0..02687ab8d8 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -1,5 +1,4 @@ import { - type JSONDocument, type RevisionPageDocument, SiteAdsStatus, SiteInsightsAdPlacement, @@ -14,6 +13,7 @@ import { getSpaceLanguage, t } from '@/intl/server'; import { getDocumentSections } from '@/lib/document-sections'; import { tcls } from '@/lib/tailwind'; +import type { SlimJSONDocument } from '@/lib/slim-document'; import { Ad } from '../Ads'; import { getPDFURLSearchParams } from '../PDF'; import { PageFeedbackForm } from '../PageFeedback'; @@ -25,7 +25,7 @@ import { ScrollSectionsList } from './ScrollSectionsList'; */ export function PageAside(props: { page: RevisionPageDocument; - document: JSONDocument | null; + document: SlimJSONDocument | null; context: GitBookSiteContext; withHeaderOffset: { sectionsHeader: boolean; topHeader: boolean }; withFullPageCover: boolean; @@ -255,7 +255,10 @@ export function PageAside(props: { ); } -async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { +async function PageAsideSections(props: { + document: SlimJSONDocument; + context: GitBookSiteContext; +}) { const { document, context } = props; const sections = await getDocumentSections(context, document); diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 6d548dec95..358f45ba18 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -1,4 +1,4 @@ -import type { JSONDocument, RevisionPageDocument } from '@gitbook/api'; +import type { RevisionPageDocument } from '@gitbook/api'; import type { GitBookSiteContext } from '@v2/lib/context'; import React from 'react'; @@ -6,6 +6,7 @@ import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document'; import type { AncestorRevisionPage } from '@/lib/pages'; +import type { SlimJSONDocument } from '@/lib/slim-document'; import { tcls } from '@/lib/tailwind'; import { DocumentView, DocumentViewSkeleton } from '../DocumentView'; import { TrackPageViewEvent } from '../Insights'; @@ -21,7 +22,7 @@ export function PageBody(props: { context: GitBookSiteContext; page: RevisionPageDocument; ancestors: AncestorRevisionPage[]; - document: JSONDocument | null; + document: SlimJSONDocument | null; withPageFeedback: boolean; }) { const { page, context, ancestors, document, withPageFeedback } = props; diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 2bb2039bc3..98d45c3b8f 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -29,6 +29,7 @@ import { noCacheFetchOptions, parseCacheResponse, } from './cache'; +import { type SlimJSONDocument, getSlimJSONDocument } from './slim-document'; /** * Pointer to a relative content, it might change overtime, the pointer is relative in the content history. @@ -692,7 +693,7 @@ export const getReusableContent = async ( * Get a document by its ID. */ export const getDocument = cache({ - name: 'api.getDocument.v2', + name: 'api.getDocument.v3', tag: (spaceId, documentId) => getCacheTag({ tag: 'document', space: spaceId, document: documentId }), tagImmutable: true, @@ -710,7 +711,9 @@ export const getDocument = cache({ ...noCacheFetchOptions, } ); - return cacheResponse(response, cacheTtl_7days); + const slimResponse = response as unknown as HttpResponse; + slimResponse.data = getSlimJSONDocument(response.data); + return cacheResponse(slimResponse, cacheTtl_7days); }, // Temporarily allow for a longer timeout than the default 10s // because GitBook's API currently re-normalizes all documents @@ -722,7 +725,7 @@ export const getDocument = cache({ * Get a computed document. */ export const getComputedDocument = cache({ - name: 'api.getComputedDocument', + name: 'api.getComputedDocument.v2', tag: (organizationId, spaceId, source) => getComputedContentSourceCacheTags( { @@ -752,6 +755,8 @@ export const getComputedDocument = cache({ ...noCacheFetchOptions, } ); + const slimResponse = response as unknown as HttpResponse; + slimResponse.data = getSlimJSONDocument(response.data); return cacheResponse(response, cacheTtl_7days); }, // Temporarily allow for a longer timeout than the default 10s diff --git a/packages/gitbook/src/lib/document-sections.ts b/packages/gitbook/src/lib/document-sections.ts index 38619a5881..d0acb51b6a 100644 --- a/packages/gitbook/src/lib/document-sections.ts +++ b/packages/gitbook/src/lib/document-sections.ts @@ -1,9 +1,9 @@ -import type { JSONDocument } from '@gitbook/api'; import type { GitBookAnyContext } from '@v2/lib/context'; import { getNodeText } from './document'; import { resolveOpenAPIOperationBlock } from './openapi/resolveOpenAPIOperationBlock'; import { resolveOpenAPISchemasBlock } from './openapi/resolveOpenAPISchemasBlock'; +import type { SlimJSONDocument } from './slim-document'; export interface DocumentSection { id: string; @@ -18,7 +18,7 @@ export interface DocumentSection { */ export async function getDocumentSections( context: GitBookAnyContext, - document: JSONDocument + document: SlimJSONDocument ): Promise { const sections: DocumentSection[] = []; let depth = 0; diff --git a/packages/gitbook/src/lib/document.ts b/packages/gitbook/src/lib/document.ts index 33618f76c1..eef025f6e1 100644 --- a/packages/gitbook/src/lib/document.ts +++ b/packages/gitbook/src/lib/document.ts @@ -1,11 +1,6 @@ -import type { - DocumentBlock, - DocumentFragment, - DocumentInline, - DocumentText, - JSONDocument, -} from '@gitbook/api'; +import type { DocumentFragment, DocumentInline, DocumentText } from '@gitbook/api'; import assertNever from 'assert-never'; +import type { SlimDocumentBlock, SlimJSONDocument } from './slim-document'; export interface DocumentSection { id: string; @@ -17,7 +12,7 @@ export interface DocumentSection { /** * Check if the document contains one block that should be rendered in full-width mode. */ -export function hasFullWidthBlock(document: JSONDocument): boolean { +export function hasFullWidthBlock(document: SlimJSONDocument): boolean { for (const node of document.nodes) { if (node.data && 'fullWidth' in node.data && node.data.fullWidth) { return true; @@ -34,7 +29,7 @@ export function hasFullWidthBlock(document: JSONDocument): boolean { * Get the text of a block/inline. */ export function getNodeText( - node: JSONDocument | DocumentText | DocumentFragment | DocumentInline | DocumentBlock + node: DocumentText | DocumentFragment | DocumentInline | SlimDocumentBlock | SlimJSONDocument ): string { switch (node.object) { case 'text': @@ -57,7 +52,7 @@ export function getNodeText( * Get a fragment by its type in a node. */ export function getNodeFragmentByType( - node: DocumentInline | DocumentBlock, + node: DocumentInline | SlimDocumentBlock, type: string ): DocumentFragment | null { if (!('fragments' in node)) { @@ -71,7 +66,7 @@ export function getNodeFragmentByType( * Get a fragment by its `fragment` name in a node. */ export function getNodeFragmentByName( - node: DocumentInline | DocumentBlock, + node: DocumentInline | SlimDocumentBlock, name: string ): DocumentFragment | null { if (!('fragments' in node)) { @@ -85,7 +80,7 @@ export function getNodeFragmentByName( * Test if a node is empty. */ export function isNodeEmpty( - node: DocumentText | DocumentFragment | DocumentInline | DocumentBlock | JSONDocument + node: DocumentText | DocumentFragment | DocumentInline | SlimJSONDocument | SlimDocumentBlock ): boolean { if ((node.object === 'block' || node.object === 'inline') && node.isVoid) { return false; @@ -106,7 +101,7 @@ export function isNodeEmpty( /** * Get the title for a node. */ -export function getBlockTitle(block: DocumentBlock): string { +export function getBlockTitle(block: SlimDocumentBlock): string { switch (block.type) { case 'expandable': { const titleFragment = getNodeFragmentByType(block, 'expandable-title'); @@ -132,7 +127,7 @@ export function getBlockTitle(block: DocumentBlock): string { /** * Get a block by its ID in the document. */ -export function getBlockById(document: JSONDocument, id: string): DocumentBlock | null { +export function getBlockById(document: SlimJSONDocument, id: string): SlimDocumentBlock | null { return findBlock(document, (block) => { if ('meta' in block && block.meta && 'id' in block.meta) { return block.meta.id === id; @@ -145,9 +140,9 @@ export function getBlockById(document: JSONDocument, id: string): DocumentBlock * Find a block by a predicate in the document. */ function findBlock( - container: JSONDocument | DocumentBlock | DocumentFragment, - test: (block: DocumentBlock) => boolean -): DocumentBlock | null { + container: SlimJSONDocument | SlimDocumentBlock | DocumentFragment, + test: (block: SlimDocumentBlock) => boolean +): SlimDocumentBlock | null { if (!('nodes' in container)) { return null; } diff --git a/packages/gitbook/src/lib/slim-document/blocks/code-line.ts b/packages/gitbook/src/lib/slim-document/blocks/code-line.ts new file mode 100644 index 0000000000..8b41ebfa3d --- /dev/null +++ b/packages/gitbook/src/lib/slim-document/blocks/code-line.ts @@ -0,0 +1,56 @@ +import type { DocumentBlockCodeLine } from '@gitbook/api'; + +/** + * Common interface for a slim code line. + */ +interface SlimDocumentBlockCodeLineCommon + extends Omit { + highlighted?: true; +} + +/** + * Code line with text only. + */ +export interface SlimDocumentBlockCodeLineText extends SlimDocumentBlockCodeLineCommon { + text: string; +} + +/** + * Code line with annotations. + */ +export interface SlimDocumentBlockCodeLineAnnotated extends SlimDocumentBlockCodeLineCommon { + nodes: DocumentBlockCodeLine['nodes']; +} + +/** + * Slim version of a DocumentBlockCodeLine. + */ +export type SlimDocumentBlockCodeLine = + | SlimDocumentBlockCodeLineText + | SlimDocumentBlockCodeLineAnnotated; + +/** + * Transform a DocumentBlockCodeLine into a slim version. + */ +export function transform(block: DocumentBlockCodeLine): SlimDocumentBlockCodeLine { + let hasTextOnly = true; + const text = block.nodes.reduce((acc, node) => { + if (node.object === 'text') { + return acc + node.leaves.map((leaf) => leaf.text).join(''); + } + hasTextOnly = false; + return acc; + }, ''); + const slimNode: SlimDocumentBlockCodeLine = hasTextOnly + ? { type: block.type, object: block.object, text, isVoid: block.isVoid } + : { + nodes: block.nodes, + type: block.type, + object: block.object, + isVoid: block.isVoid, + }; + if (block.data.highlighted) { + slimNode.highlighted = true; + } + return slimNode; +} diff --git a/packages/gitbook/src/lib/slim-document/blocks/code.ts b/packages/gitbook/src/lib/slim-document/blocks/code.ts new file mode 100644 index 0000000000..48536682f2 --- /dev/null +++ b/packages/gitbook/src/lib/slim-document/blocks/code.ts @@ -0,0 +1,20 @@ +import type { DocumentBlockCode } from '@gitbook/api'; +import { type SlimifyDocumentBlocks, all } from '../util'; + +/** + * Slim version of a DocumentBlock. + * We transform the document into a slim version to reduce the size of the data + * stored in cache and sent to the client. + */ +export interface SlimDocumentBlockCode extends Omit { + nodes: SlimifyDocumentBlocks; +} +/** + * Transform a code block into a slim version. + */ +export function transform(block: DocumentBlockCode): SlimDocumentBlockCode { + return { + ...block, + nodes: all(block.nodes), + }; +} diff --git a/packages/gitbook/src/lib/slim-document/index.ts b/packages/gitbook/src/lib/slim-document/index.ts new file mode 100644 index 0000000000..f8c399189c --- /dev/null +++ b/packages/gitbook/src/lib/slim-document/index.ts @@ -0,0 +1,35 @@ +import type { DocumentBlock, DocumentBlocksTopLevels, JSONDocument } from '@gitbook/api'; +import { type SlimifyDocumentBlock, all } from './util'; + +/** + * Slim version of DocumentBlocksTopLevels. + */ +export type SlimDocumentBlocksTopLevels = SlimifyDocumentBlock; + +/** + * Slim version of a DocumentBlock. + */ +export type SlimDocumentBlock = SlimifyDocumentBlock; + +/** + * Slim version of a JSON document. + */ +export interface SlimJSONDocument extends Omit { + nodes: SlimDocumentBlocksTopLevels[]; +} + +// Re-export all slim block types. +export type { SlimDocumentBlockCode } from './blocks/code'; +export type { SlimDocumentBlockCodeLine } from './blocks/code-line'; + +/** + * Transform a document to a slim version. + */ +export function getSlimJSONDocument(document: JSONDocument): SlimJSONDocument { + return { + ...document, + nodes: all(document.nodes), + }; +} + +export { one as getSlimDocumentBlock } from './util'; diff --git a/packages/gitbook/src/lib/slim-document/util.ts b/packages/gitbook/src/lib/slim-document/util.ts new file mode 100644 index 0000000000..776ea6e7fa --- /dev/null +++ b/packages/gitbook/src/lib/slim-document/util.ts @@ -0,0 +1,50 @@ +import type { DocumentBlock, DocumentBlockCode, DocumentBlockCodeLine } from '@gitbook/api'; +import * as codeBlock from './blocks/code'; +import * as codeLineBlock from './blocks/code-line'; + +type TransformMap = { + [T in DocumentBlock as T['type']]: (block: T) => SlimifyDocumentBlock; +}; + +/** + * Contains all block transformations. + * The key is the block type, and the value is the transform function. + */ +const transforms: Partial = { + code: codeBlock.transform, + 'code-line': codeLineBlock.transform, +}; + +/** + * Transform any DocumentBlock into a slim version. + */ +export type SlimifyDocumentBlock = T extends DocumentBlockCode + ? codeBlock.SlimDocumentBlockCode + : T extends DocumentBlockCodeLine + ? codeLineBlock.SlimDocumentBlockCodeLine + : T; + +/** + * Transform all blocks to a slim version. + */ +export function all(blocks: T): SlimifyDocumentBlocks { + return blocks.map((block) => one(block)) as SlimifyDocumentBlocks; +} + +/** + * Transform a block to a slim version. + */ +export function one(block: T): SlimifyDocumentBlock { + const transform = transforms[block.type]; + if (transform) { + return transform(block as any) as SlimifyDocumentBlock; + } + return block as SlimifyDocumentBlock; +} + +/** + * Transform an array of DocumentBlock into a slim version. + */ +export type SlimifyDocumentBlocks = { + [K in keyof T]: T[K] extends DocumentBlock ? SlimifyDocumentBlock : T[K]; +};