{title ? (
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null}
({
- object: 'block',
- type: 'code-line',
- data: {},
- nodes: [
- {
- object: 'text',
- leaves: [
- {
- object: 'leaf',
- text: line,
- marks: [],
- },
- ],
- },
- ],
- })),
- };
+ const block = convertCodeStringToBlock({ key: id, code, syntax });
const document: JSONDocument = {
object: 'document',
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
index 315d072e60..d20578fade 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
@@ -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 {
const langName = getBlockLang(block);
@@ -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],
@@ -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) {
@@ -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 = '';
@@ -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) {
@@ -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: {
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
index 5247c14ddc..433742d5dc 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
@@ -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') {
@@ -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',
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts
new file mode 100644
index 0000000000..a071727855
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from 'bun:test';
+import { convertCodeStringToBlock } from './utils';
+
+describe('convertCodeStringToBlock', () => {
+ it('converts plain code string without placeholders', () => {
+ const result = convertCodeStringToBlock({
+ key: 'test1',
+ code: 'console.log("hello");',
+ syntax: 'javascript',
+ });
+
+ expect(result).toEqual({
+ key: 'test1',
+ object: 'block',
+ type: 'code',
+ data: { syntax: 'javascript' },
+ nodes: [
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: 'console.log("hello");', marks: [] }],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('converts a single placeholder into an expression node', () => {
+ const result = convertCodeStringToBlock({
+ key: 'test2',
+ code: 'const config = { API_KEY: "$$__X-GITBOOK-PREFILL[visitor.claims.apiKey ?? "YOUR_API_KEY"]__$$" }',
+ syntax: 'javascript',
+ });
+
+ expect(result).toEqual({
+ key: 'test2',
+ object: 'block',
+ type: 'code',
+ data: { syntax: 'javascript' },
+ nodes: [
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [
+ { object: 'leaf', text: 'const config = { API_KEY: "', marks: [] },
+ ],
+ },
+ {
+ object: 'inline',
+ type: 'expression',
+ data: { expression: 'visitor.claims.apiKey ?? "YOUR_API_KEY"' },
+ isVoid: true,
+ },
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: '" }', marks: [] }],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('handles multiple placeholders in one line', () => {
+ const result = convertCodeStringToBlock({
+ key: 'test3',
+ code: 'let a = $$__X-GITBOOK-PREFILL[valA]__$$, b = $$__X-GITBOOK-PREFILL[valB]__$$;',
+ syntax: 'javascript',
+ });
+
+ expect(result).toEqual({
+ key: 'test3',
+ object: 'block',
+ type: 'code',
+ data: { syntax: 'javascript' },
+ nodes: [
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: 'let a = ', marks: [] }],
+ },
+ {
+ object: 'inline',
+ type: 'expression',
+ data: { expression: 'valA' },
+ isVoid: true,
+ },
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: ', b = ', marks: [] }],
+ },
+ {
+ object: 'inline',
+ type: 'expression',
+ data: { expression: 'valB' },
+ isVoid: true,
+ },
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: ';', marks: [] }],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('handles multiple placeholders across different lines', () => {
+ const result = convertCodeStringToBlock({
+ key: 'test4',
+ code: [
+ 'const name = "$$__X-GITBOOK-PREFILL[visitor.claims.userName]__$$";',
+ 'const age = $$__X-GITBOOK-PREFILL[visitor.claims.userAge]__$$;',
+ 'console.log(name, age);',
+ ].join('\n'),
+ syntax: 'javascript',
+ });
+
+ expect(result).toEqual({
+ key: 'test4',
+ object: 'block',
+ type: 'code',
+ data: { syntax: 'javascript' },
+ nodes: [
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: 'const name = "', marks: [] }],
+ },
+ {
+ object: 'inline',
+ type: 'expression',
+ data: { expression: 'visitor.claims.userName' },
+ isVoid: true,
+ },
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: '";', marks: [] }],
+ },
+ ],
+ },
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: 'const age = ', marks: [] }],
+ },
+ {
+ object: 'inline',
+ type: 'expression',
+ data: { expression: 'visitor.claims.userAge' },
+ isVoid: true,
+ },
+ {
+ object: 'text',
+ leaves: [{ object: 'leaf', text: ';', marks: [] }],
+ },
+ ],
+ },
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [
+ {
+ object: 'text',
+ leaves: [
+ { object: 'leaf', text: 'console.log(name, age);', marks: [] },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('returns an empty code block for empty string', () => {
+ const result = convertCodeStringToBlock({
+ key: 'test5',
+ code: '',
+ syntax: 'javascript',
+ });
+
+ expect(result).toEqual({
+ key: 'test5',
+ object: 'block',
+ type: 'code',
+ data: { syntax: 'javascript' },
+ nodes: [
+ {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes: [],
+ },
+ ],
+ });
+ });
+});
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts
new file mode 100644
index 0000000000..d59df74f5a
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts
@@ -0,0 +1,67 @@
+import type { DocumentBlockCode, DocumentBlockCodeLine } from '@gitbook/api';
+
+const PREFILL_WITH_EXPR_REGEX = /\$\$__X-GITBOOK-PREFILL\[(.+?)\]__\$\$/g;
+
+/**
+ * Convert a raw code string into a `DocumentBlockCode` object representation.
+ *
+ * Any placeholder of the form `$$__X-GITBOOK-PREFILL[]__$$` inside the code
+ * string is transformed into a DocumentInlineExpression node with its `data.expression` set to the
+ * extracted ``.
+ */
+export function convertCodeStringToBlock(args: {
+ key: string;
+ code: string;
+ syntax: string;
+}): DocumentBlockCode {
+ const { key, code, syntax } = args;
+ const lines = code.split('\n').map((line) => {
+ const nodes: DocumentBlockCodeLine['nodes'] = [];
+ let lastIndex = 0;
+
+ for (const match of line.matchAll(PREFILL_WITH_EXPR_REGEX)) {
+ const [placeholder, expression] = match;
+ const start = match.index ?? 0;
+
+ if (start > lastIndex) {
+ nodes.push({
+ object: 'text',
+ leaves: [{ object: 'leaf', text: line.slice(lastIndex, start), marks: [] }],
+ });
+ }
+
+ if (expression) {
+ nodes.push({
+ object: 'inline',
+ type: 'expression',
+ data: { expression },
+ isVoid: true,
+ });
+ }
+
+ lastIndex = start + placeholder.length;
+ }
+
+ if (lastIndex < line.length) {
+ nodes.push({
+ object: 'text',
+ leaves: [{ object: 'leaf', text: line.slice(lastIndex), marks: [] }],
+ });
+ }
+
+ return {
+ object: 'block',
+ type: 'code-line',
+ data: {},
+ nodes,
+ };
+ });
+
+ return {
+ key,
+ object: 'block',
+ type: 'code',
+ data: { syntax },
+ nodes: lines,
+ };
+}
diff --git a/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx b/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx
index fc98d7548d..97f64a2ab4 100644
--- a/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx
+++ b/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx
@@ -1,17 +1,57 @@
import { tcls } from '@/lib/tailwind';
import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
+import React from 'react';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
export function Columns(props: BlockProps) {
const { block, style, ancestorBlocks, document, context } = props;
+
+ const columnWidths = React.useMemo(() => {
+ const widths = block.nodes.map((block) => {
+ const width = block.data.width;
+ return width ? getFractionalWidth(width) : 0;
+ });
+
+ const totalWidth = widths.reduce((acc, width) => acc + width, 0);
+ // If not all columns widths are set, distribute the remaining widths as equally as we can
+ if (totalWidth < 1.0 && widths.some((width) => width === 0)) {
+ const unsetWidths = widths.filter((width) => width === 0);
+ let remainingWidth = 1.0 - totalWidth;
+ let unsetWidthsLength = unsetWidths.length;
+ widths.forEach((width, index) => {
+ if (width === 0) {
+ const calculatedWidth =
+ Math.round((remainingWidth / unsetWidthsLength) * COLUMN_DIVISIONS) /
+ COLUMN_DIVISIONS;
+ widths[index] = calculatedWidth; // Assign width to empty columns
+ unsetWidthsLength--;
+ remainingWidth -= calculatedWidth;
+ }
+ });
+ }
+ return widths;
+ }, [block.nodes]);
+
return (
-
- {block.nodes.map((columnBlock) => {
+
+ {block.nodes.map((columnBlock, index) => {
+ const columnWidth = columnWidths[index];
return (
) {
export function Column(props: {
children?: React.ReactNode;
- width?: Length;
+ width: Length;
verticalAlignment?: VerticalAlignment;
}) {
const { width, verticalAlignment } = props;
- const { className, style } = transformLengthToCSS(width);
+ const { className, style } = transformLengthToCSS(width) ?? {};
return (
) {
alt="Drawing"
sizes={imageBlockSizes}
zoom
+ loading="lazy"
/>
);
diff --git a/packages/gitbook/src/components/DocumentView/Embed.tsx b/packages/gitbook/src/components/DocumentView/Embed.tsx
index 0b433b6892..3977d30af5 100644
--- a/packages/gitbook/src/components/DocumentView/Embed.tsx
+++ b/packages/gitbook/src/components/DocumentView/Embed.tsx
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps
) {
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
+ loading="lazy"
/>
) : null
}
diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
index f997225a21..8fd4d35708 100644
--- a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
+++ b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx
@@ -17,29 +17,34 @@ export function Details(props: {
}) {
const { children, id, className } = props;
- const detailsRef = React.useRef(null);
+ const ref = React.useRef(null);
const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash();
+
/**
* Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element.
*/
React.useEffect(() => {
- if (!hash || !detailsRef.current) {
+ if (!hash || !ref.current) {
return;
}
+
if (hash === id) {
setOpenFromHash(true);
+ return;
}
+
const activeElement = document.getElementById(hash);
- setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
+ const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
+ setOpenFromHash(isOpen);
}, [hash, id]);
return (
) {
id = context.getId ? context.getId(id) : id;
return (
-
+
) {
className={tcls(
'inline-block',
'size-3',
- 'mr-2',
+ 'mr-3',
'mb-1',
'transition-transform',
'shrink-0',
@@ -92,7 +96,7 @@ export function Expandable(props: BlockProps) {
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
context={context}
- style={['px-10', 'pb-5', 'space-y-4']}
+ style="space-y-4 px-10 pb-5"
/>
);
diff --git a/packages/gitbook/src/components/DocumentView/File.tsx b/packages/gitbook/src/components/DocumentView/File.tsx
index fe72164fc6..7ddf6ecaf6 100644
--- a/packages/gitbook/src/components/DocumentView/File.tsx
+++ b/packages/gitbook/src/components/DocumentView/File.tsx
@@ -1,10 +1,12 @@
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
+import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
-import { tcls } from '@/lib/tailwind';
-import { Link } from '../primitives';
+import { getSpaceLanguage } from '@/intl/server';
+import { Button, Link } from '../primitives';
+import { DownloadButton } from '../primitives/DownloadButton';
import type { BlockProps } from './Block';
import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
@@ -12,67 +14,70 @@ import { FileIcon } from './FileIcon';
export async function File(props: BlockProps) {
const { block, context } = props;
- const contentRef = context.contentContext
- ? await resolveContentRef(block.data.ref, context.contentContext)
- : null;
+ if (!context.contentContext) {
+ return null;
+ }
+
+ const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const file = contentRef?.file;
if (!file) {
return null;
}
+ const language = getSpaceLanguage(context.contentContext);
const contentType = getSimplifiedContentType(file.contentType);
+ const insights = {
+ type: 'link_click' as const,
+ link: {
+ target: block.data.ref,
+ position: SiteInsightsLinkPosition.Content,
+ },
+ };
return (
-
-
-
-
-
-
- {getHumanFileSize(file.size)}
-
+
+
+
+
{getHumanFileSize(file.size)}
-
-
{file.name}
-
- {contentType}
+
+
+
+ {file.name}
+
+
{contentType}
+
+
+
+ {t(language, 'download')}
+
+
-
+
);
}
diff --git a/packages/gitbook/src/components/DocumentView/FileIcon.tsx b/packages/gitbook/src/components/DocumentView/FileIcon.tsx
index 45ec588845..61d38700e5 100644
--- a/packages/gitbook/src/components/DocumentView/FileIcon.tsx
+++ b/packages/gitbook/src/components/DocumentView/FileIcon.tsx
@@ -9,7 +9,7 @@ export function FileIcon(props: { contentType: SimplifiedFileType | null; classN
const { contentType, className } = props;
switch (contentType) {
- case 'pdf':
+ case 'PDF':
return
;
case 'image':
return
;
diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx
index 66718ecef2..3f6f42f3d0 100644
--- a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx
+++ b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx
@@ -1,6 +1,7 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
+import { Link } from '../primitives';
import { getBlockTextStyle } from './spacing';
/**
@@ -28,6 +29,8 @@ export function HashLinkButton(props: {
'h-[1em]',
'border-0',
'opacity-0',
+ 'site-background',
+ 'rounded',
'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]',
'md:group-hover/hash:opacity-[1]',
@@ -35,10 +38,10 @@ export function HashLinkButton(props: {
className
)}
>
-
-
+
);
}
diff --git a/packages/gitbook/src/components/DocumentView/Heading.tsx b/packages/gitbook/src/components/DocumentView/Heading.tsx
index 9ecf494957..534d49186b 100644
--- a/packages/gitbook/src/components/DocumentView/Heading.tsx
+++ b/packages/gitbook/src/components/DocumentView/Heading.tsx
@@ -36,7 +36,10 @@ export function Heading(props: BlockProps
) {
diff --git a/packages/gitbook/src/components/DocumentView/Hint.tsx b/packages/gitbook/src/components/DocumentView/Hint.tsx
index 17181ad064..978dd441d1 100644
--- a/packages/gitbook/src/components/DocumentView/Hint.tsx
+++ b/packages/gitbook/src/components/DocumentView/Hint.tsx
@@ -3,6 +3,9 @@ import { Icon, type IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
+import { getSpaceLanguage, tString } from '@/intl/server';
+import { languages } from '@/intl/translations';
+import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
@@ -16,17 +19,22 @@ export function Hint({
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
- const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
+ const hasHeading = isHeadingBlock(firstNode);
+
+ const language = contextProps.context.contentContext
+ ? getSpaceLanguage(contextProps.context.contentContext)
+ : languages.en;
+
+ const label = tString(language, `hint_${block.data.style}`);
return (
) {
const { document, block, style, context, isEstimatedOffscreen } = props;
- const isMultipleImages = block.nodes.length > 1;
- const { align = 'center' } = block.data;
+ const hasMultipleImages = block.nodes.length > 1;
+ const { align = 'center', withFrame } = block.data;
return (
- {block.nodes.map((node: any, _i: number) => (
-
- ))}
+
+ {block.nodes.map((node: any, _i: number) => (
+
+ ))}
+
);
}
@@ -62,8 +79,9 @@ async function ImageBlock(props: {
context: DocumentContext;
siblings: number;
isEstimatedOffscreen: boolean;
+ withFrame?: boolean;
}) {
- const { block, context, isEstimatedOffscreen } = props;
+ const { block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
@@ -77,33 +95,65 @@ async function ImageBlock(props: {
}
return (
-
-
-
+
+ {/* Frame grid */}
+ {withFrame && (
+
+ )}
+
+ {/* Shadow overlay */}
+ {withFrame && (
+
+ )}
+
+
+
+
+
);
}
diff --git a/packages/gitbook/src/components/DocumentView/Inline.tsx b/packages/gitbook/src/components/DocumentView/Inline.tsx
index 84b56decfc..01a722a72f 100644
--- a/packages/gitbook/src/components/DocumentView/Inline.tsx
+++ b/packages/gitbook/src/components/DocumentView/Inline.tsx
@@ -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';
@@ -51,9 +52,7 @@ export function Inline
(props: InlineProps) {
case 'icon':
return ;
case 'expression':
- // The GitBook API should take care of evaluating expressions.
- // We should never need to render them.
- return null;
+ return ;
default:
return nullIfNever(inline);
}
diff --git a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx
new file mode 100644
index 0000000000..1d148a1edf
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx
@@ -0,0 +1,75 @@
+'use client';
+import { tString, useLanguage } from '@/intl/client';
+import { useAI, useAIChatController, useAIChatState } from '../AI';
+import { useSearch } from '../Search';
+import { Button, type ButtonProps, Input } from '../primitives';
+
+export function InlineActionButton(
+ props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick & { buttonProps: ButtonProps }
+) {
+ const { action, query, buttonProps } = props;
+
+ const { assistants } = useAI();
+ const chatController = useAIChatController();
+ const chatState = useAIChatState();
+ const [, setSearchState] = useSearch();
+ const language = useLanguage();
+
+ const handleSubmit = (value: string) => {
+ if (action === 'ask') {
+ chatController.open();
+ if (value ?? query) {
+ chatController.postMessage({ message: value ?? query });
+ }
+ } else if (action === 'search') {
+ setSearchState((prev) => ({
+ ...prev,
+ ask: null,
+ scope: 'default',
+ query: value ?? query,
+ open: true,
+ }));
+ }
+ };
+
+ const icon =
+ action === 'ask' && buttonProps.icon === 'gitbook-assistant' && assistants.length > 0
+ ? assistants[0]?.icon
+ : buttonProps.icon;
+
+ if (!query) {
+ return (
+ handleSubmit(value as string)}
+ containerStyle={{
+ width: `${buttonProps.label ? buttonProps.label.toString().length + 10 : 20}ch`,
+ }}
+ />
+ );
+ }
+
+ const label = action === 'ask' ? `Ask "${query}"` : `Search for "${query}"`;
+
+ const button = (
+
+ );
+
+ return button;
+}
diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx
index 0f2672ec9b..a35e9a1f30 100644
--- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx
@@ -1,40 +1,84 @@
-import { resolveContentRef } from '@/lib/references';
+import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
-import { Button } from '../primitives';
+import { Button, type ButtonProps } from '../primitives';
import type { InlineProps } from './Inline';
+import { InlineActionButton } from './InlineActionButton';
+import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
-export async function InlineButton(props: InlineProps) {
- const { inline, context } = props;
+export function InlineButton(props: InlineProps) {
+ const { inline } = props;
- if (!context.contentContext) {
- throw new Error('InlineButton requires a contentContext');
- }
+ const buttonProps: ButtonProps = {
+ label: inline.data.label,
+ variant: inline.data.kind,
+ icon: inline.data.icon as IconName | undefined,
+ size: 'medium',
+ className: 'leading-normal',
+ };
- const resolved = await resolveContentRef(inline.data.ref, context.contentContext);
+ const ButtonImplementation = () => {
+ if ('action' in inline.data && 'query' in inline.data.action) {
+ return (
+
+ );
+ }
- if (!resolved) {
- return null;
- }
+ if ('ref' in inline.data) {
+ return ;
+ }
- return (
+ return ;
+ };
+
+ const inlineElement = (
// Set the leading to have some vertical space between adjacent buttons
-
+
);
+
+ return inlineElement;
+}
+
+export async function InlineLinkButton(
+ props: InlineProps & { buttonProps: ButtonProps }
+) {
+ const { inline, context, buttonProps } = props;
+
+ if (!('ref' in inline.data)) return;
+
+ const resolved =
+ context.contentContext && inline.data.ref
+ ? await resolveContentRef(inline.data.ref, context.contentContext)
+ : null;
+
+ const href =
+ resolved?.href ??
+ (inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
+
+ const button = (
+
+ );
+
+ if (inline.data.ref && !resolved) {
+ return {button};
+ }
+
+ return button;
}
diff --git a/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpression.tsx b/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpression.tsx
new file mode 100644
index 0000000000..2c38f684b1
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpression.tsx
@@ -0,0 +1,30 @@
+import * as React from 'react';
+
+import type { DocumentInlineExpression } from '@gitbook/api';
+import type { InlineProps } from '../Inline';
+import { InlineExpressionValue } from './InlineExpressionValue';
+
+/**
+ * Render an inline expression.
+ */
+export function InlineExpression(props: InlineProps) {
+ const { context, inline } = props;
+
+ const { data } = inline;
+
+ const variables = context.contentContext
+ ? {
+ space: context.contentContext?.revision.variables,
+ page:
+ 'page' in context.contentContext
+ ? context.contentContext.page.variables
+ : undefined,
+ }
+ : {};
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpressionValue.tsx b/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpressionValue.tsx
new file mode 100644
index 0000000000..9684e667cd
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineExpression/InlineExpressionValue.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { useAdaptiveVisitor } from '@/components/Adaptive';
+import { useMemo } from 'react';
+import type { InlineExpressionVariables } from './types';
+import { useEvaluateInlineExpression } from './useEvaluateInlineExpression';
+
+export function InlineExpressionValue(props: {
+ expression: string;
+ variables: InlineExpressionVariables;
+}) {
+ const { expression, variables } = props;
+
+ const getAdaptiveVisitorClaims = useAdaptiveVisitor();
+ const visitorClaims = getAdaptiveVisitorClaims();
+ const evaluateInlineExpression = useEvaluateInlineExpression({
+ visitorClaims,
+ variables,
+ });
+
+ const result = useMemo(
+ () => evaluateInlineExpression(expression),
+ [expression, evaluateInlineExpression]
+ );
+
+ return <>{result}>;
+}
diff --git a/packages/gitbook/src/components/DocumentView/InlineExpression/index.ts b/packages/gitbook/src/components/DocumentView/InlineExpression/index.ts
new file mode 100644
index 0000000000..59dec96a78
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineExpression/index.ts
@@ -0,0 +1,3 @@
+export * from './InlineExpression';
+export * from './types';
+export * from './useEvaluateInlineExpression';
diff --git a/packages/gitbook/src/components/DocumentView/InlineExpression/types.ts b/packages/gitbook/src/components/DocumentView/InlineExpression/types.ts
new file mode 100644
index 0000000000..831831b8b5
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineExpression/types.ts
@@ -0,0 +1,6 @@
+import type { Variables } from '@gitbook/api';
+
+export interface InlineExpressionVariables {
+ space?: Variables;
+ page?: Variables;
+}
diff --git a/packages/gitbook/src/components/DocumentView/InlineExpression/useEvaluateInlineExpression.ts b/packages/gitbook/src/components/DocumentView/InlineExpression/useEvaluateInlineExpression.ts
new file mode 100644
index 0000000000..758c9b252a
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/InlineExpression/useEvaluateInlineExpression.ts
@@ -0,0 +1,42 @@
+import * as React from 'react';
+
+import {
+ type AdaptiveVisitorClaims,
+ createExpressionEvaluationContext,
+} from '@/components/Adaptive';
+import type { Variables } from '@gitbook/api';
+import { ExpressionRuntime, formatExpressionResult } from '@gitbook/expr';
+
+/**
+ * Hook that returns a callback to evaluate an inline expression with visitor data
+ * and space/page variables as context.
+ */
+export function useEvaluateInlineExpression(args: {
+ visitorClaims: AdaptiveVisitorClaims | null;
+ variables: {
+ space?: Variables;
+ page?: Variables;
+ };
+}) {
+ const { visitorClaims, variables } = args;
+ const evaluateInlineExpression = React.useMemo(() => {
+ const runtime = new ExpressionRuntime();
+ const evaluationContext = createExpressionEvaluationContext({
+ visitorClaims,
+ variables,
+ });
+
+ return (expression: string) => {
+ try {
+ return formatExpressionResult(
+ runtime.evaluate(expression, evaluationContext) ?? ''
+ );
+ } catch (err) {
+ console.error('Failed to evaluate expression:', expression, err);
+ return `{{${expression}}}`;
+ }
+ };
+ }, [variables, visitorClaims]);
+
+ return evaluateInlineExpression;
+}
diff --git a/packages/gitbook/src/components/DocumentView/InlineIcon.tsx b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx
index 0eca373f69..18ff08dff7 100644
--- a/packages/gitbook/src/components/DocumentView/InlineIcon.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx
@@ -1,10 +1,18 @@
import type { DocumentInlineIcon } from '@gitbook/api';
+import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline';
+import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps) {
const { inline } = props;
+ const { color, icon } = inline.data;
- return ;
+ return (
+
+ );
}
diff --git a/packages/gitbook/src/components/DocumentView/InlineImage.tsx b/packages/gitbook/src/components/DocumentView/InlineImage.tsx
index 50c359d969..2fe6d8111f 100644
--- a/packages/gitbook/src/components/DocumentView/InlineImage.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineImage.tsx
@@ -34,7 +34,7 @@ export async function InlineImage(props: InlineProps) {
)}
>
) {
}
: null,
}}
- priority="lazy"
- preload
+ loading="lazy"
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline
zoom={!isInLink}
diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
index 940b42e891..039434f6b9 100644
--- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
@@ -1,12 +1,17 @@
-import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
+import { type ContentRef, type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, languages } from '@/intl/translations';
-import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
+import {
+ type ResolvedContentRef,
+ resolveContentRef,
+ resolveContentRefFallback,
+} from '@/lib/references';
import { Icon } from '@gitbook/icons';
-import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
+import { StyledLink } from '../../primitives';
import type { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
+import { NotFoundRefHoverCard } from '../NotFoundRefHoverCard';
import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps) {
@@ -18,52 +23,75 @@ export async function InlineLink(props: InlineProps) {
resolveAnchorText: false,
})
: null;
+
const { contentContext } = context;
- const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
+ const inlinesElement = (
+
+ );
- if (!contentContext || !resolved) {
+ if (!resolved) {
+ const fallback = resolveContentRefFallback(inline.data.ref);
return (
-
-
-
-
-
-
-
-
-
-
{tString(language, 'notfound_title')}
-
- {tString(language, 'notfound_link')}
-
-
+
+ {fallback ? (
+
+ {inlinesElement}
+
+ ) : (
+ {inlinesElement}
+ )}
+
);
}
- const isExternal = inline.data.ref.kind === 'url';
- const isMailto = resolved.href.startsWith('mailto:');
- const content = (
-
+ {inlinesElement}
+
+ );
+
+ if (context.withLinkPreviews) {
+ const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
+
+ return (
+
+ {anchorElement}
+
+ );
+ }
+
+ return anchorElement;
+}
+
+function InlineLinkAnchor(props: {
+ href: string;
+ contentRef: ContentRef;
+ isExternal?: boolean;
+ children: React.ReactNode;
+}) {
+ const { href, isExternal, contentRef, children } = props;
+ const isMailto = href.startsWith('mailto:');
+ return (
+
-
+ {children}
{isMailto ? (
) {
) : null}
);
-
- if (context.withLinkPreviews) {
- return (
-
- {content}
-
- );
- }
-
- return content;
}
/**
diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx
index 15ec1c514a..5f548e085c 100644
--- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx
@@ -1,37 +1,9 @@
'use client';
-import dynamic from 'next/dynamic';
-import React from 'react';
+import { tcls } from '@/lib/tailwind';
+import { Icon } from '@gitbook/icons';
+import { Fragment } from 'react';
+import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
-const LoadingValueContext = React.createContext(null);
-
-// To avoid polluting the RSC payload with the tooltip implementation,
-// we lazily load it on the client side. This way, the tooltip is only loaded
-// when the user interacts with the link, and it doesn't block the initial render.
-
-const InlineLinkTooltipImpl = dynamic(
- () => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
- {
- // Disable server-side rendering for this component, it's only
- // visible on user interaction.
- ssr: false,
- loading: () => {
- // The fallback should be the children (the content of the link),
- // but as next/dynamic is aiming for feature parity with React.lazy,
- // it doesn't support passing children to the loading component.
- // https://github.com/vercel/next.js/issues/7906
- const children = React.useContext(LoadingValueContext);
- return <>{children}>;
- },
- }
-);
-
-/**
- * Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
- * and polluting the RSC payload.
- *
- * The link text and href have already been rendered on the server for good SEO,
- * so we can be as lazy as possible with the tooltip.
- */
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
@@ -45,28 +17,79 @@ export function InlineLinkTooltip(props: {
openInNewTabLabel: string;
children: React.ReactNode;
}) {
- const { children, ...rest } = props;
- const [shouldLoad, setShouldLoad] = React.useState(false);
+ const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
- // Once the browser is idle, we set shouldLoad to true.
- // NOTE: to be slightly more performant, we could load when a link is hovered.
- // But I found this was too much of a delay for the tooltip to appear.
- // Loading on idle is a good compromise, as it allows the initial render to be fast,
- // while still loading the tooltip in the background and not polluting the RSC payload.
- React.useEffect(() => {
- if ('requestIdleCallback' in window) {
- (window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
- } else {
- // fallback for old browsers
- setTimeout(() => setShouldLoad(true), 2000);
- }
- }, []);
+ return (
+
+ {children}
+
+
+
+ {breadcrumbs && breadcrumbs.length > 0 ? (
+
+ {breadcrumbs.map((crumb, index) => {
+ const Tag = crumb.href ? StyledLink : 'div';
- return shouldLoad ? (
-
- {children}
-
- ) : (
- children
+ return (
+
+ {index !== 0 ? (
+
+ ) : null}
+
+ {crumb.icon ? (
+
+ {crumb.icon}
+
+ ) : null}
+ {crumb.label}
+
+
+ );
+ })}
+
+ ) : null}
+
+ {target.icon ? (
+
+ {target.icon}
+
+ ) : null}
+
{target.text}
+
+
+ {!isSamePage && target.href ? (
+
+ ) : null}
+
+ {target.subText ? {target.subText}
: null}
+
+
);
}
diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx
deleted file mode 100644
index 5244c0a0b9..0000000000
--- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-'use client';
-import { tcls } from '@/lib/tailwind';
-import { Icon } from '@gitbook/icons';
-import { Fragment } from 'react';
-import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
-
-export function InlineLinkTooltipImpl(props: {
- isSamePage: boolean;
- isExternal: boolean;
- breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
- target: {
- href: string;
- text: string;
- subText?: string;
- icon?: React.ReactNode;
- };
- openInNewTabLabel: string;
- children: React.ReactNode;
-}) {
- const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
-
- return (
-
- {children}
-
-
-
- {breadcrumbs && breadcrumbs.length > 0 ? (
-
- {breadcrumbs.map((crumb, index) => {
- const Tag = crumb.href ? StyledLink : 'div';
-
- return (
-
- {index !== 0 ? (
-
- ) : null}
-
- {crumb.icon ? (
-
- {crumb.icon}
-
- ) : null}
- {crumb.label}
-
-
- );
- })}
-
- ) : null}
-
- {target.icon ? (
-
- {target.icon}
-
- ) : null}
-
{target.text}
-
-
- {!isSamePage && target.href ? (
-
- ) : null}
-
- {target.subText ? {target.subText}
: null}
-
-
- );
-}
diff --git a/packages/gitbook/src/components/DocumentView/Integration/contentkit.css b/packages/gitbook/src/components/DocumentView/Integration/contentkit.css
index 9002303ea0..818ea4da40 100644
--- a/packages/gitbook/src/components/DocumentView/Integration/contentkit.css
+++ b/packages/gitbook/src/components/DocumentView/Integration/contentkit.css
@@ -156,8 +156,10 @@
/** Text input */
.contentkit-textinput {
- @apply w-full rounded border border-tint text-tint-strong placeholder:text-tint flex resize-none flex-1 px-2 py-1.5 text-sm bg-transparent whitespace-pre-line;
- @apply focus:outline-primary focus:border-primary;
+ @apply w-full circular-corners:rounded-3xl ring-primary-hover rounded-corners:rounded-lg border border-tint text-tint-strong transition-all placeholder:text-tint/8 flex resize-none flex-1 px-2 py-1.5 text-sm bg-tint-base whitespace-pre-line;
+ @apply shadow-tint/6 depth-subtle:focus-within:-translate-y-px depth-subtle:shadow-sm depth-subtle:focus-within:shadow-lg dark:shadow-tint-1;
+ @apply focus:border-primary-hover focus:shadow-primary-subtle focus:ring-2 hover:border-tint-hover focus:hover:border-primary-hover;
+ @apply disabled:cursor-not-allowed disabled:border-tint-subtle disabled:bg-tint-subtle;
}
/** Form */
diff --git a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx
index 3d1f691116..56c360b891 100644
--- a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx
+++ b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx
@@ -27,7 +27,10 @@ export const contentKitServerContext: ContentKitServerContext = {
codeBlock: (props) => {
return ;
},
+ // For some reason, Next thinks that this function is used in a client component
+ // it's likely an issue with the compiler not being able to track the usage of this function properly
markdown: async ({ className, markdown }) => {
+ 'use server';
const parsed = await parseMarkdown(markdown);
return ;
},
diff --git a/packages/gitbook/src/components/DocumentView/ListItem.tsx b/packages/gitbook/src/components/DocumentView/ListItem.tsx
index dc76475325..fd24ab2f96 100644
--- a/packages/gitbook/src/components/DocumentView/ListItem.tsx
+++ b/packages/gitbook/src/components/DocumentView/ListItem.tsx
@@ -32,8 +32,6 @@ export function ListItem(props: BlockProps) {
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls(
'min-h-lh',
- // flip heading hash icon if list item is a heading
- 'flip-heading-hash',
// remove margin-top for the first heading in a list
'[h2]:pt-0',
'[h3]:pt-0',
diff --git a/packages/gitbook/src/components/DocumentView/NotFoundRefHoverCard.tsx b/packages/gitbook/src/components/DocumentView/NotFoundRefHoverCard.tsx
new file mode 100644
index 0000000000..52d00d5c2f
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/NotFoundRefHoverCard.tsx
@@ -0,0 +1,32 @@
+import { getSpaceLanguage, tString } from '@/intl/server';
+import { languages } from '@/intl/translations';
+import { Icon } from '@gitbook/icons';
+import type { DocumentContextProps } from '../DocumentView';
+import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
+
+/**
+ * Hover card displayed for a link not found.
+ */
+export function NotFoundRefHoverCard(
+ props: DocumentContextProps & {
+ children: React.ReactNode;
+ }
+) {
+ const {
+ context: { contentContext },
+ children,
+ } = props;
+ const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
+ return (
+
+ {children}
+
+
+
+
{tString(language, 'notfound_title')}
+
+ {tString(language, 'notfound_link')}
+
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
index 70be2eb457..e1b6b9a63f 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
@@ -39,6 +39,9 @@ export function getOpenAPIContext(args: {
chevronDown: ,
chevronRight: ,
plus: ,
+ copy: ,
+ check: ,
+ lock: ,
},
renderCodeBlock: (codeProps) => ,
renderDocument: (documentProps) => (
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
index 666e192d19..ca2eb3e9d6 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
@@ -34,7 +34,7 @@
.openapi-deprecated,
.openapi-stability {
- @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -72,7 +72,7 @@
}
.openapi-markdown code {
- @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -95,7 +95,7 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
- @apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
+ @apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get,
@@ -143,11 +143,11 @@
}
.openapi-column-preview {
- @apply flex flex-col flex-1;
+ @apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:py-6 sticky max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
}
.openapi-column-preview-body {
- @apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
+ @apply flex flex-col shrink overflow-hidden gap-4 print-mode:static;
}
.openapi-column-preview pre {
@@ -182,7 +182,7 @@
/* Schema Presentation */
.openapi-schema-presentation {
- @apply flex flex-col gap-1 font-normal;
+ @apply flex flex-col gap-1 font-normal scroll-mt-[calc(var(--toc-top-offset)+0.5rem)];
}
.openapi-schema-properties:last-child {
@@ -192,7 +192,7 @@
.openapi-schema-name {
/* To make double click on the property name select only the name,
we disable selection on the parent and re-enable it on the children. */
- @apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5;
+ @apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5 items-center;
}
.openapi-schema-name .openapi-deprecated {
@@ -207,6 +207,10 @@
@apply line-through opacity-9;
}
+.openapi-schema-discriminator {
+ @apply text-primary-subtle/9 text-[0.813rem] lowercase;
+}
+
.openapi-schema-required {
@apply text-warning-subtle text-[0.813rem] lowercase;
}
@@ -245,7 +249,7 @@
}
.openapi-schema-circular {
- @apply text-xs text-tint;
+ @apply text-sm text-tint;
}
.openapi-schema-circular a {
@@ -253,7 +257,7 @@
}
.openapi-schema-circular-glyph {
- @apply text-base;
+ @apply text-base mr-1;
}
/* Schema Enum */
@@ -266,11 +270,11 @@
}
.openapi-schema-enum-value:first-child {
- @apply rounded-l ml-0;
+ @apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
}
.openapi-schema-enum-value:last-child {
- @apply rounded-r;
+ @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
}
/* Schema Description */
@@ -304,7 +308,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
- @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -313,23 +317,28 @@
}
.openapi-securities-oauth-flows {
- @apply flex flex-col gap-2 divide-y divide-tint-subtle;
+ @apply flex flex-col gap-3;
}
-.openapi-securities-oauth-content {
+.openapi-securities-oauth-content,
+.openapi-securities-scopes {
@apply prose *:!prose-sm *:text-tint;
}
+.openapi-securities-oauth-content {
+ @apply flex flex-col gap-1 mt-1;
+}
+
.openapi-securities-oauth-content.openapi-markdown code {
@apply text-xs;
}
-.openapi-securities-oauth-content ul {
- @apply !my-0;
+.openapi-securities-scopes ul {
+ @apply !my-0 ml-4 pl-0;
}
.openapi-securities-url {
- @apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors;
+ @apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -389,15 +398,13 @@
@apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit;
}
-.openapi-disclosure-group-trigger[aria-expanded="false"] {
- .openapi-response-description.openapi-markdown {
- @apply truncate;
- @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
- }
+.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown {
+ @apply truncate;
+ @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
+}
- .openapi-response-tab-content {
- @apply basis-[60%]
- }
+.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content {
+ @apply basis-[60%];
}
.openapi-response-body {
@@ -449,6 +456,10 @@
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
+.openapi-codesample-header-content .openapi-path-title {
+ @apply block;
+}
+
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
@apply text-[0.813rem];
}
@@ -469,21 +480,26 @@
}
.openapi-path-variable {
- @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:content-none! after:!content-none;
+ @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
- @apply text-tint hidden md:inline;
+ @apply text-tint inline;
}
-.openapi-path .openapi-method {
- @apply m-0 mt-0.5 items-center flex px-1;
+.openapi-summary .openapi-path .openapi-method {
+ @apply m-0 items-center flex px-2 py-1 h-6;
}
.openapi-path-title {
- @apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
- @apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
+ @apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.openapi-path-title-row{
+ @apply flex flex-row items-center;
}
.openapi-path-title[data-deprecated="true"] {
@@ -510,13 +526,21 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
- @apply border rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
+ @apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
+}
+
+.openapi-response-examples-panel {
+ @apply flex flex-col shrink overflow-hidden;
+}
+
+.openapi-codesample-panel {
+ @apply flex flex-col shrink overflow-hidden;
}
.openapi-panel pre,
.openapi-codesample pre,
.openapi-response-examples pre {
- @apply bg-transparent border-none rounded-none shadow-none;
+ @apply bg-transparent border-none rounded-none shrink shadow-none;
}
.openapi-panel-heading {
@@ -579,18 +603,30 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
- @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong max-w-32 rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
- @apply hover:bg-tint-hover transition-all;
+ @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
+ @apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
+}
+
+.openapi-select:not(.openapi-select-unstyled) > button {
+ @apply border border-tint-subtle bg-tint text-xs;
+}
+
+.openapi-select-unstyled > button {
+ @apply p-1 *:truncate max-w-full;
}
.openapi-select > button[data-focused="true"] {
- @apply outline-primary -outline-offset-1 outline outline-1;
+ @apply outline-primary -outline-offset-1 outline;
}
.openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate flex items-center;
}
+.openapi-select > button > .react-aria-SelectValue [slot="description"] {
+ display: none;
+}
+
.openapi-select > button .openapi-markdown {
@apply *:leading-none;
}
@@ -600,7 +636,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
- @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
+ @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -613,10 +649,18 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
- @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 *:truncate text-tint ring-0 border-none rounded !outline-none;
+ @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
+.openapi-select-item.openapi-select-item-column {
+ @apply flex flex-col gap-1 justify-start items-start;
+}
+
+.openapi-select-item [slot="description"] {
+ @apply text-xs text-tint-subtle;
+}
+
.openapi-select button .openapi-markdown,
.openapi-select-item .openapi-markdown {
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
@@ -638,6 +682,14 @@ body:has(.openapi-select-popover) {
}
/* Section Components */
+.openapi-section {
+ @apply flex flex-col overflow-hidden;
+}
+
+.openapi-section-body {
+ @apply flex flex-col shrink overflow-hidden;
+}
+
.openapi-section-header {
@apply flex flex-row items-center;
}
@@ -693,7 +745,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
- @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded;
+ @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -764,12 +816,12 @@ body:has(.openapi-select-popover) {
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
- @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
+ @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
.openapi-schemas-disclosure .openapi-disclosure-panel {
- @apply straight-corners:!rounded-none;
+ @apply straight-corners:!rounded-none circular-corners:!rounded-md;
}
.openapi-disclosure-panel {
@@ -797,7 +849,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
- @apply rounded-xl;
+ @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -812,15 +864,15 @@ body:has(.openapi-select-popover) {
@apply ring-1 shadow-sm;
}
-.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) {
+.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) {
@apply mt-2;
}
-.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) {
+.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) {
@apply mb-2;
}
.openapi-disclosure-trigger-label {
- @apply absolute right-3 px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
+ @apply absolute right-3 mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -945,3 +997,43 @@ body:has(.openapi-select-popover) {
.openapi-copy-button[data-disabled="true"] {
@apply cursor-default;
}
+
+.openapi-path-copy-button {
+ @apply p-1 flex rounded-md straight-corners:rounded-none;
+ @apply hover:bg-tint dark:hover:bg-tint-hover;
+}
+
+.openapi-path-copy-button-icon {
+ @apply size-6 opacity-0 transition-all hidden sm:flex;
+}
+
+.openapi-path:hover .openapi-path-copy-button-icon {
+ @apply opacity-100;
+}
+
+.openapi-path-copy-button-icon svg {
+ @apply text-tint size-4;
+}
+
+.openapi-required-scopes {
+ @apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
+}
+
+.openapi-required-scopes .openapi-required-scopes-header {
+ @apply flex items-center gap-3;
+}
+
+.openapi-required-scopes .openapi-required-scopes-header svg {
+ @apply size-3.5 text-tint-subtle rotate-none;
+}
+
+.openapi-required-scopes .openapi-disclosure-group-panel {
+ @apply px-3 pb-3;
+}
+.openapi-required-scopes .openapi-securities-scopes {
+ @apply ml-6 font-normal *:!text-[0.8125rem];
+}
+
+.openapi-required-scopes .openapi-required-scopes-description {
+ @apply text-xs !text-tint font-normal mb-2;
+}
\ No newline at end of file
diff --git a/packages/gitbook/src/components/DocumentView/StepperStep.tsx b/packages/gitbook/src/components/DocumentView/StepperStep.tsx
index 2c16ab9237..4ce3191256 100644
--- a/packages/gitbook/src/components/DocumentView/StepperStep.tsx
+++ b/packages/gitbook/src/components/DocumentView/StepperStep.tsx
@@ -36,13 +36,13 @@ export function StepperStep(props: BlockProps) {
div:first-child]:hidden',
'[&_.heading>div]:text-[.8em]',
- 'md:[&_.heading>div]:text-[1em]',
+ '@xl:[&_.heading>div]:text-[1em]',
'[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
// On mobile, check if we can display the cover responsively or not:
@@ -69,10 +69,10 @@ export async function RecordCard(
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [
lightCoverIsSquareOrPortrait
- ? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
+ ? '@sm:grid-cols-none grid-cols-[40%__1fr] @sm:grid-rows-[auto_1fr]'
: '',
darkCoverIsSquareOrPortrait
- ? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]'
+ ? 'dark:@sm:grid-cols-none dark:grid-cols-[40%__1fr] dark:@sm:grid-rows-[auto_1fr]'
: '',
].filter(Boolean)
: 'grid-rows-[auto_1fr]'
@@ -85,18 +85,24 @@ export async function RecordCard(
light: {
src: lightCover.href,
size: lightCover.file?.dimensions,
+ alt: light.alt,
},
dark: darkCover
? {
src: darkCover.href,
size: darkCover.file?.dimensions,
+ alt: dark.alt,
}
: null,
}}
sizes={[
{
+ media: '(max-width: 640px)',
width: view.cardSize === 'medium' ? 245 : 376,
},
+ {
+ width: view.cardSize === 'medium' ? 490 : 752,
+ },
]}
resize={context.contentContext?.imageResizer}
className={tcls(
@@ -106,18 +112,15 @@ export async function RecordCard(
'bg-tint-subtle',
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [
- lightCoverIsSquareOrPortrait
- ? 'min-[432px]:aspect-video min-[432px]:h-auto'
- : '',
+ lightCoverIsSquareOrPortrait ? '@sm:aspect-video @sm:h-auto' : '',
darkCoverIsSquareOrPortrait
- ? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
+ ? 'dark:@sm:aspect-video dark:@sm:h-auto'
: '',
].filter(Boolean)
: ['h-auto', 'aspect-video'],
objectFits
)}
- priority={isOffscreen ? 'lazy' : 'high'}
- preload
+ loading={isOffscreen ? 'lazy' : 'eager'}
/>
) : null}
(
},
},
}}
- priority="lazy"
+ loading="lazy"
/>
) : (
(
size: image.file?.dimensions,
},
}}
- priority="lazy"
+ loading="lazy"
/>
{image.text}
diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
index b24ed146b2..761078f986 100644
--- a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
@@ -15,8 +15,8 @@ export function ViewCards(props: TableViewProps) {
'inline-grid',
'gap-4',
'grid-cols-1',
- 'min-[432px]:grid-cols-2',
- view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3',
+ '@sm:grid-cols-2',
+ view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3',
block.data.fullWidth ? 'large:flex-column' : null
)}
>
diff --git a/packages/gitbook/src/components/DocumentView/Table/utils.ts b/packages/gitbook/src/components/DocumentView/Table/utils.ts
index fbc930874a..8ada675ece 100644
--- a/packages/gitbook/src/components/DocumentView/Table/utils.ts
+++ b/packages/gitbook/src/components/DocumentView/Table/utils.ts
@@ -21,6 +21,12 @@ export function getRecordValue = {
- [Property in keyof Type]: Type[Property];
-};
-type TabsInput = {
- id: string;
- tabs: SelectorMapper[];
-};
-
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
@@ -66,16 +69,16 @@ interface TabsState {
/**
* Client side component for the tabs, taking care of interactions.
*/
-export function DynamicTabs(
- props: TabsInput & {
- tabsBody: React.ReactNode[];
- style: ClassValue;
- block: DocumentBlockTabs;
- }
-) {
- const { id, block, tabs, tabsBody, style } = props;
-
- const hash = useHash();
+export function DynamicTabs(props: {
+ id: string;
+ tabs: TabsItem[];
+ className?: string;
+}) {
+ const { id, tabs, className } = props;
+ const router = useRouter();
+
+ const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
+ const [initialized, setInitialized] = useState(false);
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
@@ -84,182 +87,334 @@ export function DynamicTabs(
);
}, [id, tabs, tabsState]);
+ // Track if the tab has been touched by the user.
+ const touchedRef = useRef(false);
+
// To avoid issue with hydration, we only use the state from localStorage
- // once the component has been mounted.
+ // once the component has been initialized (=mounted).
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
- const mounted = useIsMounted();
- const active = mounted ? activeState : tabs[0];
-
- /**
- * When clicking to select a tab, we:
- * - mark this specific ID as selected
- * - store the ID to auto-select other tabs with the same title
- */
- const onSelectTab = React.useCallback(
- (tab: TabsItem) => {
- setTabsState((prev) => ({
- activeIds: {
- ...prev.activeIds,
- [id]: tab.id,
- },
- activeTitles: tab.title
- ? prev.activeTitles
- .filter((t) => t !== tab.title)
- .concat([tab.title])
- .slice(-TITLES_MAX)
- : prev.activeTitles,
- }));
+ const active = initialized ? activeState : tabs[0];
+
+ // When clicking to select a tab, we:
+ // - update the URL hash
+ // - mark this specific ID as selected
+ // - store the ID to auto-select other tabs with the same title
+ const selectTab = useCallback(
+ (tabId: string, manual = true) => {
+ const tab = tabs.find((tab) => tab.id === tabId);
+
+ if (!tab) {
+ return;
+ }
+
+ if (manual) {
+ touchedRef.current = true;
+ const href = `#${tab.id}`;
+ if (window.location.hash !== href) {
+ onNavigationClick(href);
+ router.replace(href, { scroll: false });
+ }
+ }
+
+ setTabsState((prev) => {
+ if (prev.activeIds[id] === tab.id) {
+ return prev;
+ }
+ return {
+ activeIds: {
+ ...prev.activeIds,
+ [id]: tab.id,
+ },
+ activeTitles: tab.title
+ ? prev.activeTitles
+ .filter((t) => t !== tab.title)
+ .concat([tab.title])
+ .slice(-TITLES_MAX)
+ : prev.activeTitles,
+ };
+ });
},
- [id, setTabsState]
+ [router, setTabsState, tabs, id]
);
- /**
- * When the hash changes, we try to select the tab containing the targetted element.
- */
- React.useEffect(() => {
- if (!hash) {
+ // When the hash changes, we try to select the tab containing the targetted element.
+ React.useLayoutEffect(() => {
+ setInitialized(true);
+
+ if (hash) {
+ // First check if the hash matches a tab ID.
+ const hashIsTab = tabs.some((tab) => tab.id === hash);
+ if (hashIsTab) {
+ selectTab(hash, false);
+ return;
+ }
+
+ // Then check if the hash matches an element inside a tab.
+ const activeElement = document.getElementById(hash);
+ if (!activeElement) {
+ return;
+ }
+
+ const tabPanel = activeElement.closest('[role="tabpanel"]');
+ if (!tabPanel) {
+ return;
+ }
+
+ selectTab(tabPanel.id, false);
+ }
+ }, [selectTab, tabs, hash]);
+
+ // Scroll to active element in the tab.
+ React.useLayoutEffect(() => {
+ // If there is no hash or active tab, nothing to scroll.
+ if (!hash || hash !== '' || !active) {
return;
}
- const activeElement = document.getElementById(hash);
- if (!activeElement) {
+ // If the tab is touched, we don't want to scroll.
+ if (touchedRef.current) {
return;
}
- const tabAncestor = activeElement.closest('[role="tabpanel"]');
- if (!tabAncestor) {
+ // If the hash matches a tab, then the scroll is already done.
+ const hashIsTab = tabs.some((tab) => tab.id === hash);
+ if (hashIsTab) {
return;
}
- const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
- if (!tab) {
+ const activeElement = document.getElementById(hash);
+ if (!activeElement) {
return;
}
- onSelectTab(tab);
- }, [hash, tabs, onSelectTab]);
+ activeElement.scrollIntoView({
+ block: 'start',
+ behavior: 'instant',
+ });
+ }, [active, tabs, hash]);
return (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ );
+}
+
+const TabPanel = memo(function TabPanel(props: {
+ tab: TabsItem;
+ isActive: boolean;
+}) {
+ const { tab, isActive } = props;
+ return (
+
+ );
+});
+
+const TabItemList = memo(function TabItemList(props: {
+ tabs: TabsItem[];
+ activeTabId: string | null;
+ onSelect: (tabId: string) => void;
+}) {
+ const { tabs, activeTabId, onSelect } = props;
+ const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
+ const overflowingTabs = useMemo(
+ () =>
+ Array.from(overflowing, (id) => {
+ const tabId = getTabIdFromButtonId(id);
+ return tabs.find((tab) => tab.id === tabId);
+ }).filter((x) => x !== undefined),
+ [overflowing, tabs]
+ );
+ return (
+
-
- {tabs.map((tab) => (
-
+ ) : null}
+ {tabs.map((tab) => {
+ // Hide overflowing tabs when not measuring.
+ if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) {
+ return null;
+ }
+ return (
+
-
-
-
-
- ))}
-
- {tabs.map((tab, index) => (
-
+ );
+ })}
+ {/* Dropdown for overflowing tabs */}
+ {overflowingTabs.length > 0 && !isMeasuring ? (
+
+ ) : null}
+
+ );
+});
+
+function TabsDropdownMenu(props: {
+ tabs: TabsItem[];
+ activeTabId: string | null;
+ onSelect: (tabId: string) => void;
+}) {
+ const { tabs, onSelect, activeTabId } = props;
+ const language = useLanguage();
+ return (
+ tab.id === activeTabId)}
+ aria-label={tString(language, 'more')}
+ className="shrink-0"
>
- {tabsBody[index]}
-
- ))}
+
+
+ }
+ >
+ {tabs.map((tab) => {
+ return (
+
onSelect(tab.id)}
+ active={tab.id === activeTabId}
+ >
+ {tab.title}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Tab item that accepts a `tab` prop.
+ */
+const TabItem = memo(function TabItem(props: {
+ ref: React.Ref
;
+ isActive: boolean;
+ tab: TabsItem;
+ onSelect: (tabId: string) => void;
+}) {
+ const { ref, tab, isActive, onSelect } = props;
+ return (
+ onSelect(tab.id)}
+ >
+ {tab.title}
+
+ );
+});
+
+/**
+ * Generic tab button component, low-level.
+ */
+function TabButton(
+ props: Omit, 'type'> & {
+ isActive?: boolean;
+ }
+) {
+ const { isActive, ...rest } = props;
+ return (
+
+
);
}
@@ -272,17 +427,25 @@ function getTabButtonId(tabId: string) {
}
/**
- * Get the ID for a tab panel.
- * We use the ID of the tab itself as links can be pointing to this ID.
+ * Get the ID of a tab from a button ID.
*/
-function getTabPanelId(tabId: string) {
- return tabId;
+function getTabIdFromButtonId(buttonId: string) {
+ if (buttonId.startsWith('tab-')) {
+ return buttonId.slice(4);
+ }
+ return buttonId;
}
/**
* Get explicitly selected tab in a set of tabs.
*/
-function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
+function getTabBySelection(
+ input: {
+ id: string;
+ tabs: TabsItem[];
+ },
+ state: TabsState
+): TabsItem | null {
const activeId = state.activeIds[input.id];
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
}
@@ -290,7 +453,13 @@ function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null
/**
* Get the best selected tab in a set of tabs by taking only title into account.
*/
-function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
+function getTabByTitle(
+ input: {
+ id: string;
+ tabs: TabsItem[];
+ },
+ state: TabsState
+): TabsItem | null {
return (
input.tabs
.map((item) => {
diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
index d1ab244852..a7b85e23be 100644
--- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
+++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
@@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs';
export function Tabs(props: BlockProps) {
const { block, ancestorBlocks, document, style, context } = props;
- const tabs: TabsItem[] = [];
- const tabsBody: React.ReactNode[] = [];
+ if (!block.key) {
+ throw new Error('Tabs block is missing a key');
+ }
- block.nodes.forEach((tab, index) => {
- tabs.push({
- id: tab.meta?.id ?? tab.key!,
- title: tab.data.title ?? '',
- });
+ const id = block.key;
- tabsBody.push(
-
- );
+ const tabs: TabsItem[] = block.nodes.map((tab) => {
+ if (!tab.key) {
+ throw new Error('Tab block is missing a key');
+ }
+
+ return {
+ id: tab.meta?.id ?? tab.key,
+ title: tab.data.title ?? '',
+ body: (
+
+ ),
+ };
});
+ // When printing, we display the tab, one after the other
if (context.mode === 'print') {
- // When printing, we display the tab, one after the other
- return (
- <>
- {tabs.map((tab, index) => (
-
- ))}
- >
- );
+ return tabs.map((tab) => {
+ return ;
+ });
}
- return (
-
- );
+ return ;
}
diff --git a/packages/gitbook/src/components/DocumentView/Text.tsx b/packages/gitbook/src/components/DocumentView/Text.tsx
index 6d7acb217b..10def2d241 100644
--- a/packages/gitbook/src/components/DocumentView/Text.tsx
+++ b/packages/gitbook/src/components/DocumentView/Text.tsx
@@ -12,7 +12,8 @@ import type {
} from '@gitbook/api';
import React from 'react';
-import { type ClassValue, tcls } from '@/lib/tailwind';
+import { tcls } from '@/lib/tailwind';
+import { backgroundColorToStyle, textColorToStyle } from './utils/colors';
export function Text(props: { text: DocumentText }) {
const { text } = props;
@@ -121,51 +122,3 @@ function Color(props: MarkedLeafProps) {
);
}
-
-/**
- * @TODO replace by DocumentMarkColor['data']['text'] and DocumentMarkColor['data']['background']
- * once the API is updated.
- */
-type DocumentMarkColorValue =
- | 'default'
- | 'green'
- | 'blue'
- | 'red'
- | 'orange'
- | 'yellow'
- | 'purple'
- | '$primary'
- | '$info'
- | '$success'
- | '$warning'
- | '$danger';
-
-const textColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
- default: [],
- blue: ['text-blue-500'],
- red: ['text-red-500'],
- green: ['text-green-500'],
- yellow: ['text-yellow-600'],
- purple: ['text-purple-500'],
- orange: ['text-orange-500'],
- $primary: ['text-primary'],
- $info: ['text-info'],
- $success: ['text-success'],
- $warning: ['text-warning'],
- $danger: ['text-danger'],
-};
-
-const backgroundColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
- default: [],
- blue: ['bg-mark-blue'],
- red: ['bg-mark-red'],
- green: ['bg-mark-green'],
- yellow: ['bg-mark-yellow'],
- purple: ['bg-mark-purple'],
- orange: ['bg-mark-orange'],
- $primary: ['bg-primary'],
- $info: ['bg-info'],
- $success: ['bg-success'],
- $warning: ['bg-warning'],
- $danger: ['bg-danger'],
-};
diff --git a/packages/gitbook/src/components/DocumentView/Update.tsx b/packages/gitbook/src/components/DocumentView/Update.tsx
new file mode 100644
index 0000000000..dd5d9301d5
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/Update.tsx
@@ -0,0 +1,79 @@
+import { formatDateFull, formatDateShort, formatNumericDate } from '@/components/utils/dates';
+import { tcls } from '@/lib/tailwind';
+import type { DocumentBlockUpdate, DocumentBlockUpdates } from '@gitbook/api';
+import { assert } from 'ts-essentials';
+import type { BlockProps } from './Block';
+import { Blocks } from './Blocks';
+
+export function Update(props: BlockProps) {
+ const { block, style, ancestorBlocks, ...contextProps } = props;
+
+ // Get the parent Updates block to retrieve properties from
+ const parentUpdates = ancestorBlocks.find(
+ (ancestor): ancestor is DocumentBlockUpdates => ancestor.type === 'updates'
+ );
+
+ if (!parentUpdates) {
+ assert(parentUpdates, 'Parent updates block should exist');
+ return null;
+ }
+
+ // Get the date for this Update block and parse it to a Date object
+ const date = block.data.date;
+ const parsedDate = parseISODate(date);
+
+ if (!parsedDate) {
+ assert(date, 'Date should exist on Update block');
+ return null;
+ }
+
+ // Then get the format from the parent Updates block and use that format
+ const dateFormat = parentUpdates.data?.format ?? 'full';
+ const displayDate = {
+ numeric: formatNumericDate(parsedDate),
+ full: formatDateFull(parsedDate),
+ short: formatDateShort(parsedDate),
+ }[dateFormat];
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Parse an ISO date string into a Date object.
+ */
+function parseISODate(value: string): Date | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+}
diff --git a/packages/gitbook/src/components/DocumentView/Updates.tsx b/packages/gitbook/src/components/DocumentView/Updates.tsx
new file mode 100644
index 0000000000..84068f6852
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/Updates.tsx
@@ -0,0 +1,16 @@
+import type { DocumentBlockUpdates } from '@gitbook/api';
+import type { BlockProps } from './Block';
+import { Blocks } from './Blocks';
+
+export function Updates(props: BlockProps) {
+ const { block, style, ancestorBlocks, ...contextProps } = props;
+
+ return (
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/spacing.ts b/packages/gitbook/src/components/DocumentView/spacing.ts
index b77d18cb98..097a30e641 100644
--- a/packages/gitbook/src/components/DocumentView/spacing.ts
+++ b/packages/gitbook/src/components/DocumentView/spacing.ts
@@ -19,19 +19,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
};
case 'heading-1':
return {
- textSize: 'text-3xl font-semibold',
+ textSize: 'text-xl @xs:text-2xl @lg:text-3xl font-semibold',
lineHeight: 'leading-tight',
marginTop: 'column-first-of-type:pt-0 pt-[1em]',
};
case 'heading-2':
return {
- textSize: 'text-2xl font-semibold',
+ textSize: 'text-lg @xs:text-xl @lg:text-2xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
};
case 'heading-3':
return {
- textSize: 'text-xl font-semibold',
+ textSize: 'text-base @xs:text-lg @lg:text-xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
};
diff --git a/packages/gitbook/src/components/DocumentView/utils/colors.ts b/packages/gitbook/src/components/DocumentView/utils/colors.ts
new file mode 100644
index 0000000000..15d90d1581
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/utils/colors.ts
@@ -0,0 +1,34 @@
+import type { ClassValue } from '@/lib/tailwind';
+import type { DocumentMarkColor } from '@gitbook/api';
+
+export const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
+ default: [],
+ blue: ['text-blue-500 contrast-more:text-blue-800'],
+ red: ['text-red-500 contrast-more:text-red-800'],
+ green: ['text-green-500 contrast-more:text-green-800'],
+ yellow: ['text-yellow-600 contrast-more:text-yellow-800'],
+ purple: ['text-purple-500 contrast-more:text-purple-800'],
+ orange: ['text-orange-500 contrast-more:text-orange-800'],
+ $primary: ['text-primary-subtle contrast-more:text-primary'],
+ $info: ['text-info-subtle contrast-more:text-info'],
+ $success: ['text-success-subtle contrast-more:text-success'],
+ $warning: ['text-warning-subtle contrast-more:text-warning'],
+ $danger: ['text-danger-subtle contrast-more:text-danger'],
+};
+
+export const backgroundColorToStyle: {
+ [color in DocumentMarkColor['data']['background']]: ClassValue;
+} = {
+ default: [],
+ blue: ['bg-mark-blue'],
+ red: ['bg-mark-red'],
+ green: ['bg-mark-green'],
+ yellow: ['bg-mark-yellow'],
+ purple: ['bg-mark-purple'],
+ orange: ['bg-mark-orange'],
+ $primary: ['bg-primary'],
+ $info: ['bg-info'],
+ $success: ['bg-success'],
+ $warning: ['bg-warning'],
+ $danger: ['bg-danger'],
+};
diff --git a/packages/gitbook/src/components/DocumentView/utils/textAlignment.ts b/packages/gitbook/src/components/DocumentView/utils/textAlignment.ts
index 61798fc025..bbcb30cf9b 100644
--- a/packages/gitbook/src/components/DocumentView/utils/textAlignment.ts
+++ b/packages/gitbook/src/components/DocumentView/utils/textAlignment.ts
@@ -9,11 +9,11 @@ export function getTextAlignment(textAlignment: TextAlignment | undefined): Clas
switch (textAlignment) {
case undefined:
case TextAlignment.Start:
- return ['text-start', 'justify-self-start'];
+ return ['text-start', 'self-start'];
case TextAlignment.Center:
- return ['text-center', 'justify-self-center'];
+ return ['text-center', 'self-center'];
case TextAlignment.End:
- return ['text-end', 'justify-self-end'];
+ return ['text-end', 'self-end'];
default:
return nullIfNever(textAlignment);
}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx
index 969a00f283..54f63b08a6 100644
--- a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx
@@ -1,12 +1,14 @@
'use client';
-import { useAIChatController, useAIChatState } from '@/components/AI';
+import { useAI, useAIChatController, useAIChatState } from '@/components/AI';
import {
AIChatBody,
AIChatControlButton,
AIChatDynamicIcon,
AIChatSubtitle,
+ getAIChatName,
} from '@/components/AIChat';
+import { useLanguage } from '@/intl/client';
import * as api from '@gitbook/api';
import React from 'react';
import { useTrackEvent } from '../Insights';
@@ -16,20 +18,35 @@ import {
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
+ EmbeddableFrameMain,
+ EmbeddableFrameSidebar,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
-import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
+import {
+ EmbeddableIframeButtons,
+ EmbeddableIframeTabs,
+ useEmbeddableConfiguration,
+} from './EmbeddableIframeAPI';
+
+type EmbeddableAIChatProps = {
+ baseURL: string;
+ siteTitle: string;
+};
/**
* Embeddable AI chat window in an iframe.
*/
-export function EmbeddableAIChat(props: {
- trademark: boolean;
-}) {
- const { trademark } = props;
+export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
+ const { baseURL, siteTitle } = props;
const chat = useAIChatState();
+ const { config } = useAI();
const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
+ const language = useLanguage();
+
+ React.useEffect(() => {
+ chatController.open();
+ }, [chatController]);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
@@ -45,27 +62,46 @@ export function EmbeddableAIChat(props: {
);
}, [trackEvent]);
+ const tabsRef = React.useRef(null);
+
return (
-
-
-
- GitBook Assistant
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+ {!tabsRef.current ? (
+
+ ) : null}
+
+
+ {getAIChatName(language, config.trademark)}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAssistantPage.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAssistantPage.tsx
index b38c9cfbbe..9e3870a255 100644
--- a/packages/gitbook/src/components/Embeddable/EmbeddableAssistantPage.tsx
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableAssistantPage.tsx
@@ -1,13 +1,13 @@
-import type { GitBookSiteContext } from '@/lib/context';
import { EmbeddableAIChat } from './EmbeddableAIChat';
+type EmbeddableAssistantPageProps = {
+ baseURL: string;
+ siteTitle: string;
+};
+
/**
* Reusable page component for the embed assistant page.
*/
-export async function EmbeddableAssistantPage(props: {
- context: GitBookSiteContext;
-}) {
- const { context } = props;
-
- return ;
+export async function EmbeddableAssistantPage(props: EmbeddableAssistantPageProps) {
+ return ;
}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx
index 31d485aeb8..f33b1c56fe 100644
--- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx
@@ -1,18 +1,24 @@
import { type PagePathParams, getSitePageData } from '@/components/SitePage';
-
-import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import type { Metadata } from 'next';
-import { Button } from '../primitives';
+import { HeaderMobileMenu } from '../Header/HeaderMobileMenu';
+import { PageBody } from '../PageBody';
+import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
+import { TableOfContents } from '../TableOfContents';
+import { ScrollContainer } from '../primitives/ScrollContainer';
+import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
+ EmbeddableFrameMain,
+ EmbeddableFrameSidebar,
+ EmbeddableFrameTitle,
} from './EmbeddableFrame';
-import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
+import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
export const dynamic = 'force-static';
@@ -24,7 +30,9 @@ type EmbeddableDocsPageProps = {
/**
* Page component for the embed docs page.
*/
-export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
+export async function EmbeddableDocsPage(
+ props: EmbeddableDocsPageProps & { staticRoute: boolean }
+) {
const { context, pageParams } = props;
const { page, document, ancestors, withPageFeedback } = await getSitePageData({
context,
@@ -32,33 +40,60 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
});
return (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {context.site.title}
+
+
+
+
+
+ {context.sections ? (
+
+ ) : null}
-
+
+
+
+
+
+
+
);
}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPageControlButtons.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPageControlButtons.tsx
new file mode 100644
index 0000000000..3002648550
--- /dev/null
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPageControlButtons.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { tString, useLanguage } from '@/intl/client';
+import { Button } from '../primitives';
+
+export function EmbeddableDocsPageControlButtons(props: { href: string }) {
+ const { href } = props;
+ const language = useLanguage();
+
+ return (
+
+ );
+}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx
index 9b233da756..46d2ed2a21 100644
--- a/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableFrame.tsx
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef
{children} ;
+}
+
export function EmbeddableFrameHeader(props: {
children: React.ReactNode;
}) {
const { children } = props;
return (
-
+
{children}
);
@@ -45,7 +51,7 @@ export function EmbeddableFrameHeaderMain(props: {
}) {
const { children } = props;
- return
{children}
;
+ return
{children}
;
}
export function EmbeddableFrameBody(props: {
@@ -73,7 +79,7 @@ export function EmbeddableFrameSubtitle(props: {
return (
@@ -82,10 +88,21 @@ export function EmbeddableFrameSubtitle(props: {
);
}
+export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
+ const { children } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
export function EmbeddableFrameButtons(props: {
+ className?: string;
children: React.ReactNode;
}) {
- const { children } = props;
+ const { children, className } = props;
- return
{children}
;
+ return
{children}
;
}
diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx
index f37dd4c55f..cf618d8451 100644
--- a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx
+++ b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx
@@ -4,15 +4,17 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import { createChannel } from 'bidc';
import React from 'react';
-import { useAIChatController } from '@/components/AI';
+import { useAI, useAIChatController } from '@/components/AI';
+import { CustomizationAIMode } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
const embeddableConfiguration = createStore
(() => ({
- buttons: [],
- welcomeMessage: '',
+ tabs: [],
+ actions: [],
+ greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
}));
@@ -28,6 +30,12 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter();
const chatController = useAIChatController();
+ React.useEffect(() => {
+ return chatController.on('open', () => {
+ router.push(`${baseURL}/assistant`);
+ });
+ }, [router, baseURL, chatController]);
+
React.useEffect(() => {
if (window.parent === window) {
return;
@@ -93,23 +101,114 @@ export function useEmbeddableConfiguration(
* Display the buttons defined by the parent window.
*/
export function EmbeddableIframeButtons() {
- const buttons = useEmbeddableConfiguration((state) => state.buttons);
+ const { actions: configuredActions, buttons: configuredButtons = [] } =
+ useEmbeddableConfiguration((state) => state);
+ const actions = configuredActions.length > 0 ? configuredActions : configuredButtons;
return (
<>
- {buttons.map((button) => (
+ {actions.length > 0 && (
+
+ )}
+ {actions.map((action, index) => (