Revert "Highlight code client-side" (#2796)

This commit is contained in:
Samy Pessé
2025-01-30 20:09:57 +01:00
committed by GitHub
parent d9c8d57e8e
commit 67f78368fb
16 changed files with 405 additions and 527 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Improve performances by highlighting code client-side if the code block is offscreen
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
import { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
const { inline, context, document, children } = props;
const fragment = getNodeFragmentByType(inline, 'annotation-body');
@@ -1,28 +0,0 @@
'use client';
import { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useState } from 'react';
import type { HighlightLine, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import './theme.css';
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
import { highlightAction } from './highlight-action';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block client-side by calling a server actions to highlight the code.
* 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, style, inlines } = props;
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block));
useEffect(() => {
highlightAction(block, inlines).then(setLines);
}, [block, inlines]);
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
}
@@ -1,43 +1,291 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block';
import { ClientCodeBlock } from './ClientCodeBlock';
import { getInlines, RenderedInline } from './highlight';
import { Blocks } from '../Blocks';
import { ServerCodeBlock } from './ServerCodeBlock';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
import './theme.css';
/**
* Render a code block, can be client-side or server-side.
* Render an entire code-block. The syntax highlighting is done server-side.
*/
export function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context, isEstimatedOffscreen } = props;
const inlines = getInlines(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) {
return null;
}
return (
<Blocks
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const lines = await highlight(block);
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
'items-center',
'justify-start',
'[grid-area:1/1]',
'text-sm',
'gap-2',
)}
>
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
document={document}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
context={context}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
document: JSONDocument;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
context: DocumentContext;
}) {
const { block, document, line, isLast, withLineNumbers, context } = props;
return (
<span
className={tcls(
'grid',
'[grid-template-columns:subgrid]',
'col-span-2',
'relative',
'ring-1',
'ring-transparent',
'hover:ring-dark-4/5',
'hover:z-[1]',
'dark:hover:ring-light-4/4',
'rounded',
//first child
'[&.highlighted:first-child]:rounded-t-md',
'[&.highlighted:first-child>*]:mt-1',
//last child
'[&.highlighted:last-child]:rounded-b-md',
'[&.highlighted:last-child>*]:mb-1',
//is only child, dont hover effect line
'[&:only-child]:hover:ring-transparent',
//select all highlighted
'[&.highlighted]:rounded-none',
//select first in group
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
//select last in group
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
//select if highlight is singular in group
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
)}
>
{withLineNumbers ? (
<span
className={tcls(
'text-sm',
'text-right',
'pr-3.5',
'rounded-l',
'pl-2',
'sticky',
'left-[-3px]',
'bg-gradient-to-r',
'from-80%',
'from-light-2',
'to-transparent',
'dark:from-dark-2',
'dark:to-transparent',
withLineNumbers
? [
'before:text-dark/5',
'before:content-[counter(line)]',
'[counter-increment:line]',
'dark:before:text-light/4',
line.highlighted
? [
'before:text-dark/6',
'dark:before:text-light/8',
'bg-gradient-to-r',
'from-80%',
'from-light-3',
'to-transparent',
'dark:from-dark-3',
'dark:to-transparent',
]
: null,
]
: [],
)}
></span>
) : null}
<span className={tcls('ml-3', 'block', 'text-sm')}>
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: {
tokens: HighlightToken[];
document: JSONDocument;
context: DocumentContext;
}) {
const { tokens, document, context } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken
key={index}
token={token}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['space-y-4']}
/>
);
})();
))}
</>
);
}
return { inline, body };
});
function CodeHighlightToken(props: {
token: HighlightToken;
document: JSONDocument;
context: DocumentContext;
}) {
const { token, document, context } = props;
if (isEstimatedOffscreen) {
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
if (token.type === 'inline') {
return (
<Inline
inline={token.inline}
document={document}
context={context}
ancestorInlines={[]}
>
<CodeHighlightTokens
tokens={token.children}
document={document}
context={context}
/>
</Inline>
);
}
return <ServerCodeBlock block={block} style={style} inlines={richInlines} />;
if (token.type === 'plain') {
return <>{token.content}</>;
}
if (!token.token.color) {
return <>{token.token.content}</>;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
}
@@ -1,36 +0,0 @@
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-dark-4/5 hover:z-[1] dark:hover:ring-light-4/4 rounded;
@apply only:hover:ring-transparent;
&.highlighted {
@apply bg-light-3 dark:bg-dark-3;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-light-2 to-transparent dark:from-dark-2 dark:to-transparent;
@apply before:text-dark/5 before:content-[counter(line)] [counter-increment:line] dark:before:text-light/4;
&.highlighted {
@apply before:text-dark/6 dark:before:text-light/8 bg-gradient-to-r from-80% from-light-3 to-transparent dark:from-dark-3 dark:to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -1,190 +0,0 @@
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import { BlockProps } from '../Block';
import './theme.css';
import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
};
/**
* The logic of rendering a code block from lines.
*/
export function ClientCodeBlockRenderer(props: CodeBlockRendererProps) {
const { block, style, lines } = props;
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock grid grid-flow-col', style)}>
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers ? (
<span
className={tcls('highlight-line-number', line.highlighted && 'highlighted')}
></span>
) : null}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
const { tokens } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken key={index} token={token} />
))}
</>
);
}
function CodeHighlightToken(props: { token: HighlightToken }) {
const { token } = props;
if (token.type === 'annotation') {
return (
<AnnotationPopover body={token.body}>
<CodeHighlightTokens tokens={token.children} />
</AnnotationPopover>
);
}
if (token.type === 'plain') {
return <>{token.content}</>;
}
if (!token.token.color) {
return <>{token.token.content}</>;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
}
@@ -53,8 +53,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}}
block={block}
ancestorBlocks={[]}
// We optimize perf by default
isEstimatedOffscreen
isEstimatedOffscreen={false}
/>
);
}
@@ -1,19 +0,0 @@
import { DocumentBlockCode } from '@gitbook/api';
import { highlight, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import './theme.css';
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block server-side.
*/
export async function ServerCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const lines = await highlight(block, inlines);
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
}
@@ -1,14 +0,0 @@
'use server';
import { DocumentBlockCode } from '@gitbook/api';
import { highlight, RenderedInline } from './highlight';
/**
* Server action to highlight a code block.
* By using a server action, we can avoid loading the highlighter on the client-side
* and increasing the bundle size.
*/
export async function highlightAction(block: DocumentBlockCode, inlines: RenderedInline[]) {
return highlight(block, inlines);
}
@@ -1,18 +1,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { it, expect } from 'bun:test';
import { getInlines, highlight, RenderedInline } from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
inline,
body: null,
}));
return highlight(block, inlines);
}
import { highlight } from './highlight';
it('should parse plain code', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
data: {},
@@ -47,35 +38,32 @@ it('should parse plain code', async () => {
it('should parse different code in parallel', async () => {
await Promise.all(
['shell', 'scss', 'markdown', 'less', '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' }],
},
],
},
],
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' }],
},
],
},
],
}),
),
);
});
it('should parse a multilines plain code', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
data: {},
@@ -150,7 +138,7 @@ it('should parse a multilines plain code', async () => {
});
it('should parse code with an inline on a single line', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
data: {
@@ -204,8 +192,10 @@ it('should parse code with an inline on a single line', async () => {
},
},
{
type: 'annotation',
body: null,
type: 'inline',
inline: {
type: 'annotation',
},
children: [
{
type: 'shiki',
@@ -239,7 +229,7 @@ it('should parse code with an inline on a single line', async () => {
});
it('should parse code with an inline on a multiple line', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
data: {
@@ -320,8 +310,10 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'annotation',
body: null,
type: 'inline',
inline: {
type: 'annotation',
},
children: [
{
type: 'shiki',
@@ -355,8 +347,10 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'annotation',
body: null,
type: 'inline',
inline: {
type: 'annotation',
},
children: [
{
type: 'shiki',
@@ -390,7 +384,7 @@ it('should parse code with an inline on a multiple line', async () => {
});
it('should support code token finishing before the end of the annotation', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
isVoid: false,
@@ -476,8 +470,10 @@ it('should support code token finishing before the end of the annotation', async
},
},
{
type: 'annotation',
body: null,
type: 'inline',
inline: {
type: 'annotation',
},
children: [
{
type: 'shiki',
@@ -511,7 +507,7 @@ it('should support code token finishing before the end of the annotation', async
});
it('should support multiple code tokens in an annotation', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
isVoid: false,
@@ -619,8 +615,11 @@ it('should support multiple code tokens in an annotation', async () => {
},
},
{
type: 'annotation',
body: null,
type: 'inline',
inline: {
object: 'inline',
type: 'annotation',
},
children: [
{
type: 'shiki',
@@ -654,7 +653,7 @@ it('should support multiple code tokens in an annotation', async () => {
});
it('should handle \\r', async () => {
const tokens = await highlightWithInlines({
const tokens = await highlight({
object: 'block',
type: 'code',
data: {
@@ -12,8 +12,6 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing';
import { plainHighlight } from './plain-highlight';
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -22,31 +20,28 @@ export type HighlightLine = {
export type HighlightToken =
| { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken }
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
| { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
export type InlineIndexed = { inline: any; start: number; end: number };
type InlineIndexed = { inline: any; start: number; end: number };
type PositionedToken = ThemedToken & { start: number; end: number };
export type RenderedInline = {
inline: InlineIndexed;
body: React.ReactNode;
};
/**
* Highlight a code block while preserving inline elements.
*/
export async function highlight(
block: DocumentBlockCode,
inlines: RenderedInline[],
): Promise<HighlightLine[]> {
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
if (!langName) {
// Language not found, fallback to plain highlighting
return plainHighlight(block, inlines);
return plainHighlighting(block);
}
const code = getPlainCodeBlock(block);
const inlines: InlineIndexed[] = [];
const code = getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => {
return a.start - b.start;
});
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(highlighter, langName);
@@ -121,25 +116,10 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
return null;
}
export function getInlines(block: DocumentBlockCode) {
const inlines: InlineIndexed[] = [];
getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => {
return a.start - b.start;
});
return inlines;
}
/**
* Parse a code block without highlighting it.
*/
export function plainHighlighting(
block: DocumentBlockCode,
inlines?: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines ?? []);
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = [];
@@ -150,10 +130,9 @@ export function plainHighlighting(
content: getNodeText(node),
});
} else {
const inline = inlinesCopy.shift();
tokens.push({
type: 'annotation',
body: inline?.body ?? null,
type: 'inline',
inline: node,
children: [
{
type: 'plain',
@@ -173,7 +152,7 @@ export function plainHighlighting(
function matchTokenAndInlines(
eat: () => PositionedToken | null,
allInlines: RenderedInline[],
allInlines: InlineIndexed[],
): HighlightToken[] {
const initialToken = eat();
if (!initialToken) {
@@ -181,7 +160,7 @@ function matchTokenAndInlines(
}
const inlines = allInlines.filter(
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
(inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
);
let token = initialToken;
const result: HighlightToken[] = [];
@@ -197,7 +176,7 @@ function matchTokenAndInlines(
return;
}
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
if (before) {
result.push({
type: 'shiki',
@@ -212,7 +191,7 @@ function matchTokenAndInlines(
const children: HighlightToken[] = [];
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
while (token.end < inline.inline.end) {
while (token.end < inline.end) {
children.push({
type: 'shiki',
token: token,
@@ -225,7 +204,7 @@ function matchTokenAndInlines(
token = next;
}
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
const [inside, after] = splitPositionedTokenAt(token, inline.end);
if (!inside) {
throw new Error(`expect inside to not be empty`);
}
@@ -236,8 +215,8 @@ function matchTokenAndInlines(
});
result.push({
type: 'annotation',
body: inline.body,
type: 'inline',
inline: inline.inline,
children,
});
@@ -251,11 +230,11 @@ function matchTokenAndInlines(
return result;
}
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
let content = '';
code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
content += lineContent;
if (index < code.nodes.length - 1) {
@@ -268,8 +247,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
inlines: InlineIndexed[],
index: number,
inlines?: InlineIndexed[],
): string {
let content = '';
@@ -278,16 +257,14 @@ function getPlainCodeBlockLine(
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
content += getPlainCodeBlockLine(node, inlines, index + content.length);
const end = index + content.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
inlines.push({
inline: node,
start,
end,
});
}
}
@@ -364,8 +341,7 @@ const loadHighlighter = singleton(async () => {
});
const loadLanguagesMutex = asyncMutexFunction();
const loadHighlighterLanguage = async function loadHighlighterLanguage(
async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<any, any>,
lang: keyof typeof bundledLanguages,
) {
@@ -379,4 +355,4 @@ const loadHighlighterLanguage = async function loadHighlighterLanguage(
async () => await highlighter.loadLanguage(lang),
);
});
};
}
@@ -1,44 +0,0 @@
import { DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
*/
export function plainHighlight(
block: DocumentBlockCode,
inlines?: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines ?? []);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = [];
for (const node of lineBlock.nodes) {
if (node.object === 'text') {
tokens.push({
type: 'plain',
content: getNodeText(node),
});
} else {
const inline = inlinesCopy.shift();
tokens.push({
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
});
}
}
return {
highlighted: !!lineBlock.data.highlighted,
tokens,
};
});
}
@@ -13,7 +13,7 @@ import React from 'react';
import urlJoin from 'url-join';
import { t, getSpaceLanguage } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { getDocumentSections } from '@/lib/document';
import { getAbsoluteHref } from '@/lib/links';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
import React from 'react';
import { useScrollActiveId } from '@/components/hooks';
import type { DocumentSection } from '@/lib/document-sections';
import { DocumentSection } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
import { AsideSectionHighlight } from './AsideSectionHighlight';
@@ -1,53 +0,0 @@
import { JSONDocument, ContentRef } from '@gitbook/api';
import { getNodeText } from './document';
import { fetchOpenAPIBlock } from './openapi';
import { ResolvedContentRef } from './references';
export interface DocumentSection {
id: string;
tag?: string;
title: string;
depth: number;
}
/**
* Extract a list of sections from a document.
*/
export async function getDocumentSections(
document: JSONDocument,
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<DocumentSection[]> {
const sections: DocumentSection[] = [];
let depth = 0;
for (const block of document.nodes) {
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
if (block.type === 'heading-1') {
depth = 1;
}
const title = getNodeText(block);
const id = block.meta.id;
sections.push({
id,
title,
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
});
}
if (block.type === 'swagger' && block.meta?.id) {
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
if (operation) {
sections.push({
id: block.meta.id,
tag: operation.method.toUpperCase(),
title: operation.operation.summary ?? operation.path,
depth: 1,
});
}
}
}
return sections;
}
+46 -1
View File
@@ -1,12 +1,16 @@
import type {
import {
DocumentText,
DocumentInline,
DocumentFragment,
JSONDocument,
DocumentBlock,
ContentRef,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { fetchOpenAPIBlock } from './openapi';
import { ResolvedContentRef } from './references';
export interface DocumentSection {
id: string;
tag?: string;
@@ -30,6 +34,47 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
return false;
}
/**
* Extract a list of sections from a document.
*/
export async function getDocumentSections(
document: JSONDocument,
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<DocumentSection[]> {
const sections: DocumentSection[] = [];
let depth = 0;
for (const block of document.nodes) {
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
if (block.type === 'heading-1') {
depth = 1;
}
const title = getNodeText(block);
const id = block.meta.id;
sections.push({
id,
title,
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
});
}
if (block.type === 'swagger' && block.meta?.id) {
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
if (operation) {
sections.push({
id: block.meta.id,
tag: operation.method.toUpperCase(),
title: operation.operation.summary ?? operation.path,
depth: 1,
});
}
}
}
return sections;
}
/**
* Get the text of a block/inline.
*/