Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/little-months-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Add support for inline expressions rendering with visitor data on GBO side
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/expr": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { createContext, useContext } from 'react';

export type AdaptiveVisitorClaimsData = {
visitor: {
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
};
};
import type { AdaptiveVisitorClaims } from './types';

/**
* In-memory cache of visitor claim readers keyed by contextId.
*/
const adaptiveVisitorReaderCache = new Map<
string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
>();

function createResourceReader<T>(promise: Promise<T>) {
Expand Down Expand Up @@ -52,7 +47,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
if (!res.ok) {
return null;
}
return await res.json<AdaptiveVisitorClaimsData>();
return await res.json<AdaptiveVisitorClaims>();
} catch {
return null;
}
Expand All @@ -64,7 +59,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
return reader;
}

export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;

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

Expand Down
2 changes: 2 additions & 0 deletions packages/gitbook/src/components/Adaptive/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './types';
export * from './utils';
export * from './AdaptiveVisitorContextProvider';
9 changes: 9 additions & 0 deletions packages/gitbook/src/components/Adaptive/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
unsigned: Record<string, unknown>;
};

export type AdaptiveVisitorClaims = {
visitor: {
claims: AdaptiveVisitorClaimsData;
};
};
28 changes: 28 additions & 0 deletions packages/gitbook/src/components/Adaptive/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Variables } from '@gitbook/api';
import type { AdaptiveVisitorClaims } from './types';

/**
* Return an evaluation context to evaluate expressions.
*/
export function createExpressionEvaluationContext(args: {
visitorClaims: AdaptiveVisitorClaims | null;
variables: {
space?: Variables;
page?: Variables;
};
}) {
const { visitorClaims, variables } = args;
return {
...(visitorClaims ? visitorClaims : {}),
space: {
vars: variables.space ?? {},
},
...(variables.page
? {
page: {
vars: variables.page ?? {},
},
}
: {}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,41 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react';

import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight';

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

/**
* Render a code-block client-side by loading the highlighter asynchronously.
* 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 { block, style, inlines, inlineExprVariables } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
const plainLines = useMemo(() => plainHighlight(block, []), [block]);

const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false);

Expand Down Expand Up @@ -80,7 +93,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => {
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) {
return;
}
Expand All @@ -98,7 +111,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {

// Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null);
}, [isInViewport, block, inlines]);
}, [isInViewport, block, inlines, evaluateInlineExpression]);

return (
<CodeBlockRenderer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as React from 'react';

import type { DocumentBlockCode } from '@gitbook/api';

import { getNodeFragmentByType } from '@/lib/document';
Expand All @@ -14,32 +16,64 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = 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;

let hasInlineExpression = false;

const richInlines: RenderedInline[] = inlines
// Exclude inline expressions from rendered inline as they are rendered as code text once evaluated
// and so need to be treated as plain code tokens.
.filter((inline) => {
if (inline.inline.type === 'expression') {
hasInlineExpression = true;
return false;
}
return (
<Blocks
key={index}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
);
})();

return { inline, body };
});

if (!isEstimatedOffscreen) {
return true;
})
.map((inline, index) => {
const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) {
return null;
}
return (
<Blocks
key={index}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
);
})();

return { inline, body };
});

if (!isEstimatedOffscreen && !hasInlineExpression) {
// In v2, we render the code block server-side
const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
}

return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
const variables = context.contentContext
? {
space: context.contentContext?.revision.variables,
page:
'page' in context.contentContext
? context.contentContext.page.variables
: undefined,
}
: {};

return (
<React.Suspense fallback={null}>
<ClientCodeBlock
block={block}
style={style}
inlines={richInlines}
inlineExprVariables={variables}
/>
</React.Suspense>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export async function preloadHighlight(block: DocumentBlockCode) {
*/
export async function highlight(
block: DocumentBlockCode,
inlines: RenderedInline[]
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): Promise<HighlightLine[]> {
const langName = getBlockLang(block);

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

const code = getPlainCodeBlock(block);
const code = getPlainCodeBlock(block, undefined, options);

const highlighter = await getSingletonHighlighter({
langs: [langName],
Expand Down Expand Up @@ -255,11 +258,17 @@ function matchTokenAndInlines(
return result;
}

function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
function getPlainCodeBlock(
code: DocumentBlockCode,
inlines?: InlineIndexed[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string {
let content = '';

code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options);
content += lineContent;

if (index < code.nodes.length - 1) {
Expand All @@ -273,7 +282,10 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
index: number,
inlines?: InlineIndexed[]
inlines?: InlineIndexed[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string {
let content = '';

Expand All @@ -284,7 +296,12 @@ function getPlainCodeBlockLine(
switch (node.type) {
case 'annotation': {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
content += getPlainCodeBlockLine(
node,
index + content.length,
inlines,
options
);
const end = index + content.length;

if (inlines) {
Expand All @@ -297,6 +314,19 @@ function getPlainCodeBlockLine(
break;
}
case 'expression': {
const start = index + content.length;
const exprValue =
options?.evaluateInlineExpression?.(node.data.expression) ?? '';
content += exprValue;
const end = start + exprValue.length;

if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
break;
}
default: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'
*/
export function plainHighlight(
block: DocumentBlockCode,
inlines: RenderedInline[]
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): HighlightLine[] {
const inlinesCopy = Array.from(inlines);

return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
Expand All @@ -20,6 +24,14 @@ export function plainHighlight(
content: getNodeText(node),
};
}

if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}

const inline = inlinesCopy.shift();
return {
type: 'annotation',
Expand Down
5 changes: 2 additions & 3 deletions packages/gitbook/src/components/DocumentView/Inline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton';
import { InlineExpression } from './InlineExpression';
import { InlineIcon } from './InlineIcon';
import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink';
Expand Down Expand Up @@ -51,9 +52,7 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
case 'icon':
return <InlineIcon {...contextProps} inline={inline} />;
case 'expression':
// The GitBook API should take care of evaluating expressions.
// We should never need to render them.
return null;
return <InlineExpression {...contextProps} inline={inline} />;
default:
return nullIfNever(inline);
}
Expand Down
Loading