Skip to content

Commit 469b332

Browse files
authored
Add support for inline expressions rendering with visitor data on GBO side (#3660)
1 parent e434442 commit 469b332

File tree

18 files changed

+286
-47
lines changed

18 files changed

+286
-47
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Add support for inline expressions rendering with visitor data on GBO side

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"@gitbook/cache-tags": "workspace:*",
103103
"@gitbook/colors": "workspace:*",
104104
"@gitbook/emoji-codepoints": "workspace:*",
105+
"@gitbook/expr": "workspace:*",
105106
"@gitbook/fonts": "workspace:*",
106107
"@gitbook/icons": "workspace:*",
107108
"@gitbook/openapi-parser": "workspace:*",

packages/gitbook/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"private": true,
55
"dependencies": {
66
"@gitbook/api": "catalog:",
7+
"@gitbook/expr": "workspace:*",
78
"@gitbook/browser-types": "workspace:*",
89
"@gitbook/cache-tags": "workspace:*",
910
"@gitbook/colors": "workspace:*",

packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,14 @@ import type { GitBookSiteContext } from '@/lib/context';
44
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
55
import * as React from 'react';
66
import { createContext, useContext } from 'react';
7-
8-
export type AdaptiveVisitorClaimsData = {
9-
visitor: {
10-
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
11-
};
12-
};
7+
import type { AdaptiveVisitorClaims } from './types';
138

149
/**
1510
* In-memory cache of visitor claim readers keyed by contextId.
1611
*/
1712
const adaptiveVisitorReaderCache = new Map<
1813
string,
19-
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
14+
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
2015
>();
2116

2217
function createResourceReader<T>(promise: Promise<T>) {
@@ -52,7 +47,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
5247
if (!res.ok) {
5348
return null;
5449
}
55-
return await res.json<AdaptiveVisitorClaimsData>();
50+
return await res.json<AdaptiveVisitorClaims>();
5651
} catch {
5752
return null;
5853
}
@@ -64,7 +59,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
6459
return reader;
6560
}
6661

67-
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
62+
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
6863

6964
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
7065

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
export * from './types';
2+
export * from './utils';
13
export * from './AdaptiveVisitorContextProvider';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
2+
unsigned: Record<string, unknown>;
3+
};
4+
5+
export type AdaptiveVisitorClaims = {
6+
visitor: {
7+
claims: AdaptiveVisitorClaimsData;
8+
};
9+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Variables } from '@gitbook/api';
2+
import type { AdaptiveVisitorClaims } from './types';
3+
4+
/**
5+
* Return an evaluation context to evaluate expressions.
6+
*/
7+
export function createExpressionEvaluationContext(args: {
8+
visitorClaims: AdaptiveVisitorClaims | null;
9+
variables: {
10+
space?: Variables;
11+
page?: Variables;
12+
};
13+
}) {
14+
const { visitorClaims, variables } = args;
15+
return {
16+
...(visitorClaims ? visitorClaims : {}),
17+
space: {
18+
vars: variables.space ?? {},
19+
},
20+
...(variables.page
21+
? {
22+
page: {
23+
vars: variables.page ?? {},
24+
},
25+
}
26+
: {}),
27+
};
28+
}

packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,41 @@
33
import type { DocumentBlockCode } from '@gitbook/api';
44
import { useEffect, useMemo, useRef, useState } from 'react';
55

6+
import { useAdaptiveVisitor } from '@/components/Adaptive';
67
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
78
import { useScrollListener } from '@/components/hooks/useScrollListener';
89
import { useDebounceCallback } from 'usehooks-ts';
910
import type { BlockProps } from '../Block';
11+
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
1012
import { CodeBlockRenderer } from './CodeBlockRenderer';
1113
import type { HighlightLine, RenderedInline } from './highlight';
1214
import { plainHighlight } from './plain-highlight';
1315

1416
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
1517
inlines: RenderedInline[];
18+
inlineExprVariables: InlineExpressionVariables;
1619
};
1720

1821
/**
1922
* Render a code-block client-side by loading the highlighter asynchronously.
2023
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
2124
*/
2225
export function ClientCodeBlock(props: ClientBlockProps) {
23-
const { block, style, inlines } = props;
26+
const { block, style, inlines, inlineExprVariables } = props;
2427
const blockRef = useRef<HTMLDivElement>(null);
2528
const isInViewportRef = useRef(false);
2629
const [isInViewport, setIsInViewport] = useState(false);
27-
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
30+
31+
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
32+
const visitorClaims = getAdaptiveVisitorClaims();
33+
const evaluateInlineExpression = useEvaluateInlineExpression({
34+
visitorClaims,
35+
variables: inlineExprVariables,
36+
});
37+
const plainLines = useMemo(
38+
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
39+
[block, inlines, evaluateInlineExpression]
40+
);
2841
const [lines, setLines] = useState<null | HighlightLine[]>(null);
2942
const [highlighting, setHighlighting] = useState(false);
3043

@@ -80,7 +93,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
8093
if (typeof window !== 'undefined') {
8194
setHighlighting(true);
8295
import('./highlight').then(({ highlight }) => {
83-
highlight(block, inlines).then((lines) => {
96+
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
8497
if (cancelled) {
8598
return;
8699
}
@@ -98,7 +111,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
98111

99112
// Otherwise if the block is not in viewport, we reset to the plain lines
100113
setLines(null);
101-
}, [isInViewport, block, inlines]);
114+
}, [isInViewport, block, inlines, evaluateInlineExpression]);
102115

103116
return (
104117
<CodeBlockRenderer
Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as React from 'react';
2+
13
import type { DocumentBlockCode } from '@gitbook/api';
24

35
import { getNodeFragmentByType } from '@/lib/document';
@@ -14,32 +16,64 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
1416
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
1517
const { block, document, style, isEstimatedOffscreen, context } = props;
1618
const inlines = getInlines(block);
17-
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
18-
const body = (() => {
19-
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
20-
if (!fragment) {
21-
return null;
19+
20+
let hasInlineExpression = false;
21+
22+
const richInlines: RenderedInline[] = inlines
23+
// Exclude inline expressions from rendered inline as they are rendered as code text once evaluated
24+
// and so need to be treated as plain code tokens.
25+
.filter((inline) => {
26+
if (inline.inline.type === 'expression') {
27+
hasInlineExpression = true;
28+
return false;
2229
}
23-
return (
24-
<Blocks
25-
key={index}
26-
document={document}
27-
ancestorBlocks={[]}
28-
context={context}
29-
nodes={fragment.nodes}
30-
style="space-y-4"
31-
/>
32-
);
33-
})();
34-
35-
return { inline, body };
36-
});
37-
38-
if (!isEstimatedOffscreen) {
30+
return true;
31+
})
32+
.map((inline, index) => {
33+
const body = (() => {
34+
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
35+
if (!fragment) {
36+
return null;
37+
}
38+
return (
39+
<Blocks
40+
key={index}
41+
document={document}
42+
ancestorBlocks={[]}
43+
context={context}
44+
nodes={fragment.nodes}
45+
style="space-y-4"
46+
/>
47+
);
48+
})();
49+
50+
return { inline, body };
51+
});
52+
53+
if (!isEstimatedOffscreen && !hasInlineExpression) {
3954
// In v2, we render the code block server-side
4055
const lines = await highlight(block, richInlines);
4156
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
4257
}
4358

44-
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
59+
const variables = context.contentContext
60+
? {
61+
space: context.contentContext?.revision.variables,
62+
page:
63+
'page' in context.contentContext
64+
? context.contentContext.page.variables
65+
: undefined,
66+
}
67+
: {};
68+
69+
return (
70+
<React.Suspense fallback={null}>
71+
<ClientCodeBlock
72+
block={block}
73+
style={style}
74+
inlines={richInlines}
75+
inlineExprVariables={variables}
76+
/>
77+
</React.Suspense>
78+
);
4579
}

packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ export async function preloadHighlight(block: DocumentBlockCode) {
6464
*/
6565
export async function highlight(
6666
block: DocumentBlockCode,
67-
inlines: RenderedInline[]
67+
inlines: RenderedInline[],
68+
options?: {
69+
evaluateInlineExpression?: (expr: string) => string;
70+
}
6871
): Promise<HighlightLine[]> {
6972
const langName = getBlockLang(block);
7073

@@ -74,10 +77,10 @@ export async function highlight(
7477
// - TEMP : language is PowerShell or C++ and browser is Safari:
7578
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isn’t writable
7679
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
77-
return plainHighlight(block, inlines);
80+
return plainHighlight(block, inlines, options);
7881
}
7982

80-
const code = getPlainCodeBlock(block);
83+
const code = getPlainCodeBlock(block, undefined, options);
8184

8285
const highlighter = await getSingletonHighlighter({
8386
langs: [langName],
@@ -255,11 +258,17 @@ function matchTokenAndInlines(
255258
return result;
256259
}
257260

258-
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
261+
function getPlainCodeBlock(
262+
code: DocumentBlockCode,
263+
inlines?: InlineIndexed[],
264+
options?: {
265+
evaluateInlineExpression?: (expr: string) => string;
266+
}
267+
): string {
259268
let content = '';
260269

261270
code.nodes.forEach((node, index) => {
262-
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
271+
const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options);
263272
content += lineContent;
264273

265274
if (index < code.nodes.length - 1) {
@@ -273,7 +282,10 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
273282
function getPlainCodeBlockLine(
274283
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
275284
index: number,
276-
inlines?: InlineIndexed[]
285+
inlines?: InlineIndexed[],
286+
options?: {
287+
evaluateInlineExpression?: (expr: string) => string;
288+
}
277289
): string {
278290
let content = '';
279291

@@ -284,7 +296,12 @@ function getPlainCodeBlockLine(
284296
switch (node.type) {
285297
case 'annotation': {
286298
const start = index + content.length;
287-
content += getPlainCodeBlockLine(node, index + content.length, inlines);
299+
content += getPlainCodeBlockLine(
300+
node,
301+
index + content.length,
302+
inlines,
303+
options
304+
);
288305
const end = index + content.length;
289306

290307
if (inlines) {
@@ -297,6 +314,19 @@ function getPlainCodeBlockLine(
297314
break;
298315
}
299316
case 'expression': {
317+
const start = index + content.length;
318+
const exprValue =
319+
options?.evaluateInlineExpression?.(node.data.expression) ?? '';
320+
content += exprValue;
321+
const end = start + exprValue.length;
322+
323+
if (inlines) {
324+
inlines.push({
325+
inline: node,
326+
start,
327+
end,
328+
});
329+
}
300330
break;
301331
}
302332
default: {

0 commit comments

Comments
 (0)