mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Revert "Highlight code client-side" (#2796)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user