Compare commits

..

2 Commits

Author SHA1 Message Date
Zeno Kapitein 5d846548c8 Update AIChat.tsx 2025-08-22 19:08:59 +02:00
Zeno Kapitein 8e4c94a4e5 First test 2025-08-22 19:00:09 +02:00
88 changed files with 432 additions and 4480 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": patch
---
Initial version of the embed SDK.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve `Button` and `ButtonGroup` styling
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix scroll of page outline
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support "objectivec" as alias for Objective C syntax
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support custom AI providers
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/browser-types": minor
---
First version of the public package for typing script integrations.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix AI Search follow-up question closing search
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Small fixes to search modal
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fixes to `PageAside`
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Tweaks to AIChatButton and AIChatInput
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow integrations to provide tools to the Docs Assistant
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve vertical alignment of site items and fix floating page aside
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search bar layout shift caused by ToC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Emit a <link rel="alternate"> for the markdown version of a page
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support new coverDefinitionDark for cards & image type
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": minor
---
Update icons set to the latest version
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clear AI chat properly
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bug in search highlight in GBO
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Start routes for embeddable version of the assistant and docs pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Page outline: scroll to active item
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/openapi-parser": patch
"gitbook": patch
---
Improve OpenAPI parsing errors
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/browser-types": patch
"gitbook": patch
---
Custom assistants followup
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Custom logo not rendering on the published site
-1
View File
@@ -18,7 +18,6 @@
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
+3 -34
View File
@@ -62,23 +62,6 @@
"emoji-assets": "^8.0.0",
},
},
"packages/expr": {
"name": "@gitbook/expr",
"version": "0.0.1",
"dependencies": {
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"assert-never": "^1.2.1",
"eval-estree-expression": "^2.0.3",
},
"devDependencies": {
"@babel/types": "^7.26.0",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"bun-types": "^1.1.20",
},
},
"packages/fonts": {
"name": "@gitbook/fonts",
"version": "0.1.0",
@@ -441,9 +424,9 @@
"@babel/code-frame": ["@babel/code-frame@7.25.7", "", { "dependencies": { "@babel/highlight": "^7.25.7", "picocolors": "^1.0.0" } }, "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.7", "", {}, "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="],
"@babel/highlight": ["@babel/highlight@7.25.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw=="],
@@ -451,7 +434,7 @@
"@babel/runtime": ["@babel/runtime@7.25.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w=="],
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@babel/types": ["@babel/types@7.25.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.7", "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" } }, "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
@@ -671,8 +654,6 @@
"@gitbook/emoji-codepoints": ["@gitbook/emoji-codepoints@workspace:packages/emoji-codepoints"],
"@gitbook/expr": ["@gitbook/expr@workspace:packages/expr"],
"@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.11", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^6.7.2" } }, "sha512-P+GZ/X7b2Uj4uxxFX6xez+QceK211SXgMBj54lXzgo3Py34QhOuYrh/2F3ChB1iK5SXDwjn2xAuQXI7HuM66Mg=="],
"@gitbook/fonts": ["@gitbook/fonts@workspace:packages/fonts"],
@@ -1495,8 +1476,6 @@
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
"acorn-loose": ["acorn-loose@8.4.0", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-M0EUka6rb+QC4l9Z3T0nJEzNOO7JcoJlYMrBlyBCiFSXRyxjLKayd4TbQs2FDRWQU1h9FR7QVNHt+PEaoNL5rQ=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
@@ -1847,8 +1826,6 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eval-estree-expression": ["eval-estree-expression@2.1.1", "", {}, "sha512-9kNUU4c+kUs5rKR7V5n81Ebp6fId1v01XSHshPuDIQ8N2VKAAzSzN3o/hfzERdNU6ZGh97LYFT7wWrL0cqhV3A=="],
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
"event-iterator": ["event-iterator@2.0.0", "", {}, "sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ=="],
@@ -3677,12 +3654,8 @@
"@aws-sdk/xml-builder/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@babel/highlight/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="],
"@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@babel/parser/@babel/types": ["@babel/types@7.25.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.7", "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" } }, "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg=="],
"@changesets/parse/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
@@ -4949,10 +4922,6 @@
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
"@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.7", "", {}, "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g=="],
"@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="],
"@changesets/parse/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"@codemirror/lang-json/@codemirror/language/@codemirror/view": ["@codemirror/view@6.34.1", "", { "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ=="],
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/browser-types
## 0.1.0
### Minor Changes
- cbc71a5: First version of the public package for typing script integrations.
### Patch Changes
- 854c448: Custom assistants followup
- Updated dependencies [25e2b40]
- @gitbook/icons@0.3.0
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.1.0",
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/embed
## 0.1.0
### Minor Changes
- 81a6bd7: Improve API to control the GitBook embed
### Patch Changes
- 8927e8f: Initial version of the embed SDK.
- Updated dependencies [25e2b40]
- @gitbook/icons@0.3.0
+1 -1
View File
@@ -10,7 +10,7 @@
"react": "./dist/react/index.js"
}
},
"version": "0.1.0",
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
+10 -15
View File
@@ -1,7 +1,8 @@
import { createChannel } from 'bidc';
import type {
FrameToParentMessage,
GitBookEmbeddableConfiguration,
GitBookPlaceholderSettings,
GitBookToolDefinition,
ParentToFrameMessage,
} from './protocol';
@@ -21,6 +22,11 @@ export type GitBookFrameClient = {
*/
postUserMessage: (message: string) => void;
/**
* Register a custom tool.
*/
registerTool: (tool: GitBookToolDefinition) => void;
/**
* Clear the chat.
*/
@@ -29,7 +35,7 @@ export type GitBookFrameClient = {
/**
* Set the placeholder settings.
*/
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
setPlaceholder: (placeholder: GitBookPlaceholderSettings) => void;
/**
* Register an event listener.
@@ -47,7 +53,6 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => {
console.log('[gitbook:embed] received message', message);
if (message.type === 'close') {
const listeners = events.get('close') || [];
if (listeners) {
@@ -57,19 +62,11 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
});
const sendToFrame = (message: ParentToFrameMessage) => {
console.log('[gitbook:embed] send message', message);
channel.send(message);
};
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
return {
navigateToPage: (pagePath) => {
sendToFrame({ type: 'navigateToPage', pagePath });
@@ -78,11 +75,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
sendToFrame({ type: 'navigateToAssistant' });
},
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
configure: (settings) => {
Object.assign(configuration, settings);
sendToFrame({ type: 'configure', settings: configuration });
},
registerTool: (tool) => sendToFrame({ type: 'registerTool', tool }),
clearChat: () => sendToFrame({ type: 'clearChat' }),
setPlaceholder: (settings) => sendToFrame({ type: 'setPlaceholder', settings }),
on: (event, listener) => {
const listeners = events.get(event) || [];
listeners.push(listener);
+12 -32
View File
@@ -21,42 +21,18 @@ export type GitBookToolDefinition = AIToolDefinition & {
};
/**
* Custom button definition to be passed to the embeddable GitBook.
* Placeholder settings.
*/
export type GitBookEmbeddableButtonDefinition = {
export type GitBookPlaceholderSettings = {
/**
* Icon to be displayed in the button.
* Welcome message to be displayed in the placeholder.
*/
icon: IconName;
/**
* Label to be displayed in the button.
*/
label: string;
/**
* Callback when the button is clicked.
*/
onClick: () => void | Promise<void>;
};
/**
* Overall configuration for the layout of the embeddable GitBook.
*/
export type GitBookEmbeddableConfiguration = {
/**
* Buttons to be displayed in the header of the embeddable GitBook.
*/
buttons: GitBookEmbeddableButtonDefinition[];
/** Message to be displayed in the welcome page. */
welcomeMessage: string;
/** Suggestions of questions to be displayed in the welcome page. */
/**
* Suggestions to be displayed in the placeholder.
*/
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
};
/**
@@ -67,12 +43,16 @@ export type ParentToFrameMessage =
type: 'postUserMessage';
message: string;
}
| {
type: 'registerTool';
tool: GitBookToolDefinition;
}
| {
type: 'clearChat';
}
| {
type: 'configure';
settings: GitBookEmbeddableConfiguration;
type: 'setPlaceholder';
settings: GitBookPlaceholderSettings;
}
| {
type: 'navigateToPage';
@@ -0,0 +1,38 @@
import React from 'react';
import type { GetFrameURLOptions, GitBookFrameClient } from '../client';
import { useGitBook } from './GitBookProvider';
export type GitBookAssistantFrameProps = {
title?: string;
className?: string;
} & GetFrameURLOptions;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookAssistantFrame(props: GitBookAssistantFrameProps) {
const { title, className, ...frameOptions } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbookFrameRef = React.useRef<GitBookFrameClient | null>(null);
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL(frameOptions);
React.useEffect(() => {
if (frameRef.current) {
gitbookFrameRef.current = gitbook.createFrame(frameRef.current);
}
}, [gitbook]);
return (
<div className={className}>
<iframe
title={title ?? 'GitBook Assistant'}
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
/>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import React from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
GitBookFrameClient,
} from '../client';
import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
GitBookEmbeddableConfiguration;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
React.useEffect(() => {
if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current));
}
}, [gitbook]);
React.useEffect(() => {
gitbookFrame?.configure({
buttons,
welcomeMessage,
suggestions,
tools,
});
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
return (
<iframe
title="GitBook"
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
className={className}
/>
);
}
-1
View File
@@ -1,2 +1 @@
export * from './GitBookProvider';
export * from './GitBookFrame';
+11 -31
View File
@@ -4,8 +4,9 @@ import {
type CreateGitBookOptions,
type GetFrameURLOptions,
type GitBookClient,
type GitBookEmbeddableConfiguration,
type GitBookFrameClient,
type GitBookPlaceholderSettings,
type GitBookToolDefinition,
createGitBook,
} from '../client';
@@ -28,10 +29,12 @@ type StandaloneCalls =
| ['toggle']
// Post a user message
| ['postUserMessage', string]
// Register a tool
| ['registerTool', GitBookToolDefinition]
// Clear the chat
| ['clearChat']
// Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration>]
// Configure the placeholder
| ['setPlaceholder', GitBookPlaceholderSettings]
// Navigate to a page
| ['navigateToPage', string]
// Navigate to the assistant
@@ -62,12 +65,6 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() {
if (!_client) {
@@ -135,30 +132,15 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]);
break;
case 'configure':
frameConfiguration = {
...frameConfiguration,
...args[1],
};
getIframe().frame.configure({
...frameConfiguration,
buttons: [
...frameConfiguration.buttons,
// Always include a close button
{
icon: 'close',
label: 'Close',
onClick: () => {
GitBook('close');
},
},
],
});
case 'registerTool':
getIframe().frame.registerTool(args[1]);
break;
case 'clearChat':
getIframe().frame.clearChat();
break;
case 'setPlaceholder':
getIframe().frame.setPlaceholder(args[1]);
break;
case 'navigateToPage':
getIframe().frame.navigateToPage(args[1]);
break;
@@ -174,5 +156,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
-1
View File
@@ -1 +0,0 @@
dist
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/expr
## 1.0.0
### Major Changes
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/expr`
Safely evaluate & parse user-defined GitBook expressions.
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"dependencies": {
"eval-estree-expression": "^2.0.3",
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"assert-never": "^1.2.1"
},
"devDependencies": {
"bun-types": "^1.1.20",
"@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"clean": "rm -rf ./dist"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
@@ -1,885 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionRuntime } from '../runtime';
import {
SymbolArray,
SymbolBoolean,
SymbolNumber,
SymbolObject,
SymbolString,
SymbolType,
SymbolsTable,
} from '../symbols';
import {
type AutocompleteSuggestions,
type AutocompleteSymbolSuggestion,
SUPPORTED_BINARY_OPERATORS,
SUPPORTED_CONDITIONAL_OPERATORS,
SUPPORTED_LOGICAL_OPERATORS,
} from '../types';
describe('autocomplete', () => {
const runtime = new ExpressionRuntime();
const visitorClaimsHelloArraySymbol = SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
});
const symbols = {
visitor: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: visitorClaimsHelloArraySymbol,
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
};
const context = new SymbolsTable(symbols);
const SCENARIOS: Array<{
expressionWithCursor: string;
expectedSuggestions: AutocompleteSuggestions;
}> = [
{
expressionWithCursor: 'visit<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
ref: 'visitor.claims',
parentRef: 'visitor',
childrenRefs: [
'visitor.claims.key',
'visitor.claims.flags',
'visitor.claims.hello',
'visitor.claims.role',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolString({ name: 'key' }),
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
ref: 'visitor.claims.role',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.role.length',
'visitor.claims.role.at',
'visitor.claims.role.endsWith',
'visitor.claims.role.includes',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.ke<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolString({ name: 'key' }),
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.h<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.hello.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolNumber({
name: 'length',
description: `The length data property of an Array instance represents the number of elements in that array.
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
}),
ref: 'visitor.claims.hello.length',
parentRef: 'visitor.claims.hello',
childrenRefs: [],
},
},
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
(method) => ({
type: 'symbol',
symbol: {
definition: method,
ref: `visitor.claims.hello.${method.name}`,
parentRef: 'visitor.claims.hello',
childrenRefs: [],
},
})
),
],
},
{
expressionWithCursor: 'visitor.claims.f<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.fl<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG1' }),
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG2' }),
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG3' }),
ref: 'visitor.claims.flags.FLAG3',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG4' }),
ref: 'visitor.claims.flags.FLAG4',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG1' }),
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG2' }),
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG3' }),
ref: 'visitor.claims.flags.FLAG3',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG4' }),
ref: 'visitor.claims.flags.FLAG4',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.key <cur>',
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
expectedSuggestions: [
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
),
...SUPPORTED_CONDITIONAL_OPERATORS,
].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
['==', '==='].includes(op.operator)
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
(op) => op.operator === '==='
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
['!=', '!=='].includes(op.operator)
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
(op) => op.operator === '!=='
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.role == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == ad<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == admin<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.role == edit<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == editor<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.hello == <cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: visitorClaimsHelloArraySymbol,
matchedLiteralString: '',
},
},
],
},
{
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: visitorClaimsHelloArraySymbol,
matchedLiteralString: '"test',
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
expectedSuggestions: [
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
...SUPPORTED_CONDITIONAL_OPERATORS,
].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
];
it.each(SCENARIOS)(
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
({ expressionWithCursor, expectedSuggestions }) => {
const { expression, cursorOffset } = extractCursorPosition(
expressionWithCursor,
'<cur>'
);
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
expect(suggestions).toStrictEqual(expectedSuggestions);
}
);
});
function extractCursorPosition(
expressionWithCursor: string,
cursorPlaceholder: string
): { expression: string; cursorOffset: number } {
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
if (cursorOffset === -1) {
throw new Error(
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
);
}
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
return { expression, cursorOffset };
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values';
describe('inferDefaultInputValuesFromObjectJSONSchema', () => {
it('should infer properly the default input value based on the JSON schema of an object', () => {
const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({
type: 'object',
properties: {
claims: {
type: 'object',
properties: {
key: {
type: 'string',
},
flags: {
type: 'object',
properties: {
FLAG1: { type: 'string' },
FLAG2: { type: 'string' },
FLAG3: { type: 'string' },
},
},
isAlphaUser: {
type: 'boolean',
},
hello: {
type: 'string',
enum: ['enumValue1', 'enumValue2', 'enumValue3'],
},
},
},
},
});
expect(defaultInputValues).toMatchObject({
claims: {
key: 'default',
flags: {
FLAG1: 'default',
FLAG2: 'default',
FLAG3: 'default',
},
isAlphaUser: true,
hello: 'enumValue1',
},
});
});
});
-160
View File
@@ -1,160 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionError } from '../errors';
import { ExpressionRuntime } from '../runtime';
import type { Logger } from '../types';
const SILENT_LOGGER: Logger = {
debug: () => {},
info: () => {},
error: () => {},
};
describe('ExpressionRuntime', () => {
const runtime = new ExpressionRuntime(SILENT_LOGGER);
describe('evaluate', () => {
it.each([
{
scenario: 'simple condition',
condition: 'isBetaUser === true',
inputs: { isBetaUser: false },
expectedResult: false,
},
{
scenario: 'simple condition with multiple inputs variables',
condition: 'useProductA && !isBetaUser',
inputs: {
useProductA: true,
isBetaUser: false,
},
expectedResult: true,
},
{
scenario: 'condition with objects in inputs variables',
condition: 'products.includes("productA") && userSegments.alpha',
inputs: {
products: ['productA', 'productB'],
userSegments: {
alpha: true,
beta: false,
},
},
expectedResult: true,
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult);
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult);
}
);
const INVALID_EXPRESSSIONS = [
{
scenario: 'invalid syntax',
condition: 't}=d',
inputs: {},
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
inputs: {},
},
{
scenario: 'unsafe expression',
condition: 'while (1) {}',
inputs: {},
},
{
scenario: 'unsafe expression',
condition: '[1, 2, 3].map(() => { while (1) {}})',
inputs: {},
},
];
it.each(INVALID_EXPRESSSIONS)(
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
({ condition, inputs }) => {
const result = runtime.safeEvaluate(condition, inputs);
expect(result.value).toBeUndefined();
expect(result.error instanceof ExpressionError).toBe(true);
}
);
it.each(INVALID_EXPRESSSIONS)(
'should throw an error when using evaluate with invalid expressions',
({ condition, inputs }) => {
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
}
);
});
describe('parse', () => {
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
const ast = runtime.parse('isBetaUser === true');
expect(ast.result).toEqual({
type: 'BinaryExpression',
start: 0,
end: 19,
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
left: {
type: 'Identifier',
start: 0,
end: 10,
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
name: 'isBetaUser',
},
operator: '===',
right: {
type: 'Literal',
start: 15,
end: 19,
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
value: true,
raw: 'true',
},
});
});
it.each([
{
scenario: 'invalid syntax',
condition: 't}=d',
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
},
])(
'should throw an error for non conditional expressions or syntax errors: $scenario',
({ condition }) => {
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
}
);
});
describe.skip('generate', () => {
it.each([
{
scenario: 'simple condition',
condition: 'isBetaUser === true',
},
{
scenario: 'simple condition with multiple inputs variables',
condition: 'useProductA && !isBetaUser',
},
{
scenario: 'condition with objects in inputs variables',
condition: 'products.includes("productA") && userSegments.alpha',
},
])(
'should produce the original expression using an AST node produced by parse: $scenario',
({ condition }) => {
const { result } = runtime.parse(condition);
expect(runtime.generate(result)).toStrictEqual(condition);
}
);
});
});
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionRuntime, parseTemplate } from '../';
describe('template expressions', () => {
it('should parse template into parts', () => {
const parts = parseTemplate('Hello {{ user.name }}!');
expect(parts).toEqual([
{ type: 'text', value: 'Hello ', start: 0, end: 6 },
{ type: 'expression', value: 'user.name', start: 8, end: 19 },
{ type: 'text', value: '!', start: 21, end: 22 },
]);
});
it('should parse template starting with an expression', () => {
const parts = parseTemplate('{{ user.name }} is cool');
expect(parts).toEqual([
{ type: 'expression', value: 'user.name', start: 2, end: 13 },
{ type: 'text', value: ' is cool', start: 15, end: 23 },
]);
});
it('should parse template without expressions', () => {
const parts = parseTemplate('Hello world');
expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]);
});
it('should evaluate template', () => {
const runtime = new ExpressionRuntime();
const result = runtime.evaluateTemplate('Hello {{ user.name }}!', {
user: { name: 'John' },
});
expect(result).toBe('Hello John!');
});
});
-568
View File
@@ -1,568 +0,0 @@
import type {
Node as AcornNode,
AnyNode,
BinaryExpression,
Expression,
Identifier,
Literal,
MemberExpression,
PrivateIdentifier,
Super,
} from 'acorn';
import { isDummy } from 'acorn-loose';
import * as walk from 'acorn-walk';
import assertNever from 'assert-never';
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
import {
type AutocompleteLiteralValueSuggestion,
type AutocompleteOperatorSuggestion,
type AutocompleteSuggestions,
type AutocompleteSymbolSuggestion,
type DirectLiteralValueSuggestion,
type ExpressionParserResult,
type Logger,
SUPPORTED_BINARY_OPERATORS,
SUPPORTED_CONDITIONAL_OPERATORS,
SUPPORTED_LOGICAL_OPERATORS,
} from './types';
interface ExpressionParser {
parse(expr: string, options: { loose?: boolean }): ExpressionParserResult;
}
export class AutoComplete {
#parser: ExpressionParser;
#logger: Logger;
constructor(parser: ExpressionParser, logger: Logger = console) {
this.#parser = parser;
this.#logger = logger;
}
/**
* Generates autocomplete suggestions based on the input expression and cursor offset position.
*/
public getSuggestions(
expr: string,
cursorOffset: number,
context: SymbolsTable
): AutocompleteSuggestions {
if (!expr.length) {
return [];
}
try {
const { result, invalidNodes } = this.#parser.parse(expr, { loose: true });
// Locate the node at the cursor position.
const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) =>
isNodeAtCursor(node, cursorOffset)
);
if (!nodeAtCursorFound) {
// When we can't find one we might be in the boundary of the program/expression.
// We could possibly be in a whitespace at the end of the expression or in a situation where the parsed
// tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser).
//
// In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either:
// - using the end position of the whole expression AST (e.g white space at the very end of the expression string)
// - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node.
if (cursorOffset > result.end) {
const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result;
if (!ast) {
return [];
}
const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) =>
isNodeAtCursor(node, ast.end)
);
if (!lastNodeFound) {
return [];
}
const { node } = lastNodeFound;
if (!isAnyNode(node)) {
throw Error(`Unexpected node type ${node.type}`);
}
return this.getOperatorSuggestionsForNode(ast, node, result.end, context);
}
return [];
}
const { node } = nodeAtCursorFound;
if (!isAnyNode(node)) {
throw Error(`Unexpected node type ${node.type}`);
}
return this.getSuggestionsForNode(result, node, expr, cursorOffset, context);
} catch (error) {
this.#logger.error('Error while computing autocomplete suggestions', error);
return [];
}
}
/**
* Provides autocomplete suggestions for a specific node in the AST.
*/
private getSuggestionsForNode(
ast: Expression,
node: AnyNode,
expr: string,
cursorOffset: number,
context: SymbolsTable
): AutocompleteSuggestions {
// When the node is an identifier look up the parent to get more context for the suggestions.
let inferNode: AnyNode = node;
if (node.type === 'Identifier' || node.type === 'Literal') {
const parent = findParentNode(node, ast);
inferNode =
parent &&
!['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes(
parent.type
)
? parent
: node;
}
switch (inferNode.type) {
case 'Identifier':
case 'MemberExpression': {
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
inferNode,
cursorOffset
);
// Fetch suggestions from the symbol table
const candidatesKeys = context.getMatchingSymbolsKeys(pathParts);
const suggestions: AutocompleteSymbolSuggestion[] = [];
for (const candidate of candidatesKeys) {
const symbolInfo = context.getSymbolInfo(candidate);
if (symbolInfo) {
suggestions.push({ type: 'symbol', symbol: symbolInfo });
}
}
if (suggestions.length === 1) {
const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, '');
// Return no suggestion when the only match is an exact match of the
// typed token.
return lastPathSegment !== suggestions[0]?.symbol.definition.name
? suggestions
: [];
}
return suggestions;
}
case 'AssignmentExpression':
case 'UnaryExpression': {
// Provide suggestions for binary or logical operators based on the parsed operator
// of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)).
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
}
case 'BinaryExpression': {
const { left, right } = inferNode;
const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some(
(op) => op.operator === inferNode.operator
);
const operatorIndex = expr.indexOf(inferNode.operator, left.end);
const operatorOffset = operatorIndex + inferNode.operator.length;
const isCursorAfterOperator = cursorOffset > operatorOffset;
const shouldSuggestValues =
isCursorAfterOperator &&
isOperatorBinaryOp &&
isNodeAtCursor(right, cursorOffset);
if (shouldSuggestValues) {
return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context);
}
// Provide suggestions for binary operators based on the parsed operator
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
}
case 'ConditionalExpression': {
const { consequent, alternate } = inferNode;
if (isNodeAtCursor(consequent, cursorOffset)) {
return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context);
}
if (isNodeAtCursor(alternate, cursorOffset)) {
return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context);
}
}
}
return [];
}
/**
* Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table.
*/
private getSymbolsPathPartsForMemberExpressionNode(
node: MemberExpression | Identifier,
cursorOffset: number,
options?: { withWildcardMatches: boolean }
): string[] {
const withWildcardMatches = options?.withWildcardMatches ?? true;
const pathParts: string[] = [];
switch (node.type) {
case 'MemberExpression':
{
const memberProperty = node.property;
// Only support identifier or literal expressions as member properties.
if (!isSupportedMemberProperty(memberProperty)) {
return [];
}
// Push the path part corresponding to the member property (e.g b in a.b or a['b'])
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
memberProperty,
cursorOffset
);
if (propertyPathPart) {
pathParts.push(
withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart
);
}
// Go through the parent(s) in the chain and add their path parts as well
let parent: Expression | Super | undefined = node.object;
while (parent) {
const parentProperty = 'property' in parent ? parent.property : parent;
// Only support identifier or literal expressions as parent property.
const parentPropertyPathPart = isSupportedMemberProperty(parentProperty)
? this.getSymbolsPathPartFromMemberProperty(
parentProperty,
cursorOffset
)
: undefined;
if (parentPropertyPathPart) {
pathParts.unshift(parentPropertyPathPart);
}
parent = 'object' in parent ? parent.object : undefined;
}
}
break;
case 'Identifier': {
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
node,
cursorOffset
);
if (propertyPathPart) {
pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart);
}
break;
}
default:
assertNever(node);
}
return pathParts;
}
/**
* Return a part of a symbol path corresponding to a MemberExpression node property.
*/
private getSymbolsPathPartFromMemberProperty(
node: Identifier | Literal,
cursorOffset: number
): string {
switch (node.type) {
case 'Identifier': {
if (isDummy(node)) {
return '*';
}
return isNodeAtCursor(node, cursorOffset)
? node.name.slice(0, cursorOffset)
: node.name;
}
case 'Literal': {
if (isDummy(node) || !node.value) {
return '*';
}
const value = String(node.value);
return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value;
}
default:
assertNever(node);
}
}
/**
* Provides autocomplete literal value suggestions for a specific node in the AST.
*/
private getLiteralValueSuggestionsForNode(
node: BinaryExpression,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteLiteralValueSuggestion> {
const { left, right } = node;
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
return [];
}
if (right.type !== 'Identifier' && right.type !== 'Literal') {
return [];
}
const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, {
withWildcardMatches: false,
});
const isLeftComputedMember = left.type === 'MemberExpression' && left.computed;
const leftSymbolInfo = context.getSymbolInfo(
isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath
);
if (!leftSymbolInfo) {
return [];
}
switch (leftSymbolInfo.definition.type) {
case SymbolType.Boolean: {
const suggestions: DirectLiteralValueSuggestion[] = [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
];
return isDummy(right)
? suggestions
: suggestions.filter((literalValue) => {
const literalValueString = String(literalValue.value.data);
const rightNodeValue =
right.type === 'Identifier' ? right.name : (right.raw ?? '');
return (
literalValueString !== rightNodeValue &&
literalValueString.startsWith(rightNodeValue)
);
});
}
case SymbolType.String: {
if (!leftSymbolInfo.definition.enum) {
return [];
}
const rightNodeValue = (() => {
if (right.type === 'Identifier') {
return right.name;
}
const nodeValue = right.raw ?? '';
if (/^(['"])(.*)\1$/.test(nodeValue)) {
return null;
}
return nodeValue.replaceAll(/["']/g, '');
})();
if (rightNodeValue === null) {
return [];
}
const suggestions = isDummy(right)
? leftSymbolInfo.definition.enum
: leftSymbolInfo.definition.enum.filter((enumValue) =>
enumValue.startsWith(rightNodeValue)
);
return suggestions.map((value) => ({
type: 'literal-value',
value: {
kind: 'direct',
type: SymbolType.String,
data: value,
},
}));
}
case SymbolType.Array: {
// Only return a literal in array value suggestion when the left hand side of the binary expression
// is computed, e.g myArray[1]
return isLeftComputedMember
? [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: leftSymbolInfo.definition,
matchedLiteralString: !isDummy(right)
? right.type === 'Identifier'
? right.name
: (right.raw ?? '')
: '',
},
},
]
: [];
}
default:
return [];
}
}
/**
* Provides autocomplete operator suggestions for a specific node in the AST.
*/
private getOperatorSuggestionsForNode(
ast: Expression,
node: AnyNode,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteOperatorSuggestion> {
if (node.type === 'Literal') {
const parent = findParentNode(node, ast);
if (parent?.type === 'BinaryExpression') {
return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map(
(op) => ({
type: 'operator',
...op,
})
);
}
const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw);
return this.getOperatorSuggestionsForSymbol(literalSymbol);
}
// When the node is an identifier look the parent to get more context for the suggestions
let inferNode: AnyNode = node;
if (node.type === 'Identifier') {
const parent = findParentNode(node, ast);
inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node;
}
switch (inferNode.type) {
case 'MemberExpression': {
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
inferNode,
cursorOffset,
{
withWildcardMatches: false,
}
);
const symbolInfo = context.getSymbolInfo(pathParts);
return symbolInfo
? this.getOperatorSuggestionsForSymbol(symbolInfo.definition)
: [];
}
case 'AssignmentExpression':
case 'BinaryExpression':
case 'UnaryExpression': {
// Starting to write a binary/logical operator so suggest operator matching the already
// typed character as operator.
const operator = inferNode.operator;
return (
[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS]
.filter((op) => op.operator.startsWith(operator))
// No need to include the operator that match exactly
.filter((op) => op.operator !== operator)
.map((op) => ({
type: 'operator',
...op,
}))
);
}
default:
return [];
}
}
/**
* Provides autocomplete operator suggestions based on the type of a symbol.
*/
private getOperatorSuggestionsForSymbol(
symbol: ExtractSymbolDef<SymbolType>
): Array<AutocompleteOperatorSuggestion> {
switch (symbol.type) {
case SymbolType.Number:
case SymbolType.Boolean:
case SymbolType.Null:
case SymbolType.Undefined:
case SymbolType.Object:
case SymbolType.Array: {
const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4);
const finalSuggestions =
symbol.type === SymbolType.Boolean
? [
...equalityOps,
...SUPPORTED_LOGICAL_OPERATORS,
...SUPPORTED_CONDITIONAL_OPERATORS,
]
: equalityOps;
return finalSuggestions.map((op) => ({
type: 'operator',
...op,
}));
}
case SymbolType.String:
return SUPPORTED_BINARY_OPERATORS.map((op) => ({
type: 'operator',
...op,
}));
case SymbolType.Function:
return this.getOperatorSuggestionsForSymbol(symbol.returns);
case SymbolType.Union: {
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
return prev;
}, []);
}
default:
assertNever(symbol);
}
}
}
/**
* Finds the parent of child node in the provided AST.
*/
function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined {
let foundParent: AnyNode | undefined;
walk.ancestor(ast, {
[child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => {
if (node.start === child.start && node.end === child.end) {
// The parent is the second last ancestor in the stack (last one is the actual node)
foundParent = ancestors[ancestors.length - 2];
}
},
});
return foundParent;
}
function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) {
return node.type === 'Identifier' || node.type === 'Literal';
}
function isNodeAtCursor(node: AcornNode, cursorOffset: number) {
return cursorOffset >= node.start && cursorOffset <= node.end;
}
function isAnyNode(node: AcornNode): node is AnyNode {
return 'type' in node;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Position, Token } from 'acorn';
export class ExpressionError extends Error {
/**
* The location of the error in the parsed expression.
*/
public location?: Position | null;
/**
* The expression token with the error
*/
public token?: Token;
constructor(message: string, loc?: Position | null, token?: Token) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ExpressionError);
}
this.name = 'ExpressionError';
this.location = loc;
this.token = token;
}
}
-8
View File
@@ -1,8 +0,0 @@
export * from './errors';
export * from './input-values';
export * from './input-values';
export * from './runtime';
export * from './symbols';
export * from './template';
export * from './types';
export * from './utils';
-77
View File
@@ -1,77 +0,0 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from './utils';
type InputValuesType =
| null
| string
| number
| boolean
| { [key: string]: InputValuesType }
| InputValuesType[];
/**
* Infers a default inputValues object based on the JSON schema of an object.
*/
export function inferDefaultInputValuesFromObjectJSONSchema(
schema: JSONSchema7
): Record<string, InputValuesType> {
if (schema.type !== 'object' || !schema.properties) {
throw new Error(`Expected schema of object to be provided: ${schema.type}`);
}
const result: Record<string, InputValuesType> = {};
for (const [key, propertySchema] of Object.entries(schema.properties)) {
if (typeof propertySchema === 'boolean') {
continue;
}
result[key] = inferDefaultInputValueFromJSONSchema(propertySchema);
}
return result;
}
function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType {
switch (schema.type) {
case 'object':
return inferDefaultInputValuesFromObjectJSONSchema(schema);
case 'array': {
if (schema.items && Array.isArray(schema.items)) {
return schema.items.map((itemSchema) => {
if (typeof itemSchema === 'boolean') {
return false;
}
return inferDefaultInputValueFromJSONSchema(itemSchema);
});
}
return [];
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
return inferDefaultInputValueFromPrimitive(schema);
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType {
switch (schema.type) {
case 'boolean':
return true;
case 'number':
case 'integer':
return 1234;
case 'string': {
const enumValues = schema.enum?.filter(filterOutNullable);
return enumValues?.[0] ?? 'default';
}
case 'null':
return null;
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
-286
View File
@@ -1,286 +0,0 @@
import {
type Options as AcornOptions,
type Expression,
type ExpressionStatement,
type Position,
type Program,
type Token,
parse,
tokenizer,
} from 'acorn';
import { parse as parseLoose } from 'acorn-loose';
import { evaluate } from 'eval-estree-expression';
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
import type { SymbolsTable } from './symbols';
import type { TemplatePart } from './template';
import { parseTemplate as parseTemplateParts } from './template';
import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types';
import { formatExpressionResult } from './utils';
export class ExpressionRuntime {
#parserOptions: AcornOptions;
#autocompleter: AutoComplete;
#logger: Logger;
constructor(logger: Logger = console) {
this.#parserOptions = {
ecmaVersion: 'latest',
sourceType: 'script',
allowHashBang: false,
locations: true,
};
this.#autocompleter = new AutoComplete(this, logger);
this.#logger = logger;
}
/**
* Evaluates an expression based on the given inputs/context.
*/
public evaluate(expr: string, inputs: object): unknown {
try {
const parsed = this.parse(expr);
if (parsed.invalidNodes.length > 0) {
throw new ExpressionError('Invalid nodes found when parsing');
}
return evaluate.sync<Expression>(parsed.result, inputs, {
functions: true,
withMembers: true,
});
} catch (error) {
throw error instanceof Error
? new ExpressionError(error.message)
: new ExpressionError('Unexpected error');
}
}
/**
* Evaluates an expression safely by returning the error instead of throwing when invalid.
*/
public safeEvaluate(
expr: string,
inputs: object
): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } {
try {
const value = this.evaluate(expr, inputs);
return {
value,
};
} catch (error) {
this.#logger.error(`Error while evaluating expression ${expr}`, error);
if (error instanceof ExpressionError) {
return {
value: undefined,
error,
};
}
return {
value: undefined,
error:
error instanceof Error
? new ExpressionError(error.message)
: new ExpressionError('Unexpected error'),
};
}
}
/**
* Evaluates a condition safely to a boolean.
*/
public evaluateBoolean(expr: string, inputs: object): boolean {
if (expr.trim().length === 0) {
return true;
}
const evalResult = this.safeEvaluate(expr, inputs);
if (typeof evalResult.error !== 'undefined') {
return false;
}
return Boolean(evalResult.value);
}
/**
* Evaluates an array of conditions as a single logical expression.
* The function treats the conditions as if they were joined by an AND operator,
* meaning the evaluation returns `true` only if all conditions are truthy.
*/
public evaluateBooleanAll(expressions: string[], inputs: object): boolean {
if (expressions.length === 0) {
return true;
}
return expressions.every((expression) => this.evaluateBoolean(expression, inputs));
}
/**
* Parse a template and validate all embedded expressions.
*/
public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } {
const parts = parseTemplateParts(template);
const errors: ExpressionError[] = [];
for (const part of parts) {
if (part.type === 'expression') {
try {
const { invalidNodes } = this.parse(part.value);
if (invalidNodes.length > 0) {
errors.push(new ExpressionError('Invalid expression'));
}
} catch (error) {
errors.push(error as ExpressionError);
}
}
}
return { parts, errors };
}
/**
* Evaluate a template string containing `{{ expression }}` placeholders.
*/
public evaluateTemplate(template: string, inputs: object): string {
const { parts } = this.parseTemplate(template);
return parts
.map((part) => {
if (part.type === 'text') {
return part.value;
}
const result = this.evaluate(part.value, inputs);
return formatExpressionResult(result, '');
})
.join('');
}
/**
* Parses a binary expression and returns an @ExpressionParserResult.
*/
public parse(
expr: string,
options: { loose?: boolean } = {
loose: false,
}
): ExpressionParserResult {
try {
const ast = options.loose
? parseLoose(expr, { ...this.#parserOptions })
: parse(expr, { ...this.#parserOptions });
if (!ast.body || ast.body.length === 0) {
throw new ExpressionError('Empty or invalid expression');
}
// Extract the first expression statement that we find
const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node));
const [statement] = ast.body.splice(firstExprIndex, 1);
if (!statement || !isParsedExpressionStatement(statement)) {
throw new ExpressionError('Empty or invalid expression');
}
// Return information on the other nodes as invalid nodes
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
return {
result: statement.expression,
invalidNodes,
};
} catch (error) {
if (error instanceof SyntaxError) {
throw createExpressionErrorFromSyntaxError(expr, error);
}
if (error instanceof ExpressionError) {
throw error;
}
throw new ExpressionError('Unexpected error');
}
}
/**
* Provides autocomplete suggestions for the given expression at the provided cursor offset.
*/
public autocomplete(
expr: string,
cursorOffset: number,
context: SymbolsTable
): ExpressionAutocompleteResults {
const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context);
return { suggestions };
}
public generate(_node: Expression): string {
throw new Error('Not yet implemented');
}
}
function createExpressionErrorFromSyntaxError(
code: string,
error: SyntaxError & { loc?: Position }
): ExpressionError {
const loc = error.loc;
if (!loc) {
return new ExpressionError(error.message);
}
const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`;
const token = getTokenAtLoc(code, loc);
if (!token) {
return new ExpressionError(errorMessage, loc);
}
return new ExpressionError(errorMessage, loc, token);
}
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
const tokens = tokenizer(code, {
ecmaVersion: 'latest',
locations: true,
});
try {
for (const token of tokens) {
if (!token.loc) {
continue;
}
const { start, end } = token.loc;
const onSameLine = errorLoc.line === start.line;
const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column;
if (onSameLine && inColumnRange) {
return token;
}
}
} catch (_error) {
return undefined;
}
return undefined;
}
function isParsedExpressionStatement(
statement: Program['body'][number]
): statement is ExpressionStatement {
return statement.type === 'ExpressionStatement';
}
export function filterOutModuleDeclarationStatement(
statement: Program['body'][number]
): statement is ExpressionStatement {
return ![
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportDefaultDeclaration',
'ExportAllDeclaration',
].includes(statement.type);
}
@@ -1,495 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
import { SymbolsTable } from '../symbols-table';
import type { SymbolType } from '../types';
describe('ExpressionRuntime', () => {
const initialSymbols = {
visitor: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolString({ name: 'FLAG1' }),
FLAG2: SymbolString({ name: 'FLAG2' }),
FLAG3: SymbolString({ name: 'FLAG3' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
},
methods: [],
}),
},
methods: [],
}),
};
describe('addSymbols', () => {
it('should the symbols matching the provided object to the table', () => {
const symbolsTable = new SymbolsTable(initialSymbols);
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
definition: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
ref: 'visitor',
childrenRefs: ['visitor.claims'],
});
symbolsTable.addSymbols({
space: SymbolObject({
name: 'space',
properties: {
id: SymbolString({ name: 'id' }),
title: SymbolString({ name: 'title' }),
},
methods: [],
}),
});
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
definition: {
type: 'object',
name: 'space',
properties: {
id: {
type: 'string',
name: 'id',
},
title: {
type: 'string',
name: 'title',
},
},
methods: [],
},
ref: 'space',
childrenRefs: ['space.id', 'space.title'],
});
});
});
describe('Symbols standard library', () => {
it('should allow to access methods & properties defined as part of the standard library', () => {
const symbolsTable = new SymbolsTable({
id: SymbolString({ name: 'id' }),
title: SymbolString({ name: 'title' }),
});
expect(
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
).toMatchObject({
type: 'number',
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
});
});
});
describe('getSymbolInfo', () => {
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
const symbolsTable = new SymbolsTable(initialSymbols);
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
definition: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
ref: 'visitor',
childrenRefs: ['visitor.claims'],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
definition: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
ref: 'visitor.claims',
parentRef: 'visitor',
childrenRefs: [
'visitor.claims.key',
'visitor.claims.flags',
'visitor.claims.hello',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
definition: {
type: 'string',
name: 'key',
},
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
definition: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
],
});
expect(
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
).toMatchObject({
definition: {
type: 'string',
name: 'FLAG1',
},
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [
'visitor.claims.flags.FLAG1.length',
'visitor.claims.flags.FLAG1.at',
'visitor.claims.flags.FLAG1.endsWith',
'visitor.claims.flags.FLAG1.includes',
],
});
expect(
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
).toMatchObject({
definition: {
type: 'string',
name: 'FLAG2',
},
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [
'visitor.claims.flags.FLAG2.length',
'visitor.claims.flags.FLAG2.at',
'visitor.claims.flags.FLAG2.endsWith',
'visitor.claims.flags.FLAG2.includes',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
definition: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
],
});
});
});
describe('inferSymbolFromValue', () => {
it('should infer properly a symbol based on a value', () => {
const symbolDef = SymbolsTable.inferSymbolFromValue(
{
visitor: {
claims: {
key: 'test',
flags: {
FLAG1: 'testflag1',
FLAG2: 'testflag2',
FLAG3: 'testflag3',
},
hello: ['test', 'test1', 'test2'],
},
},
},
'context'
);
expect(symbolDef).toMatchObject({
type: 'object',
name: 'context',
properties: {
visitor: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
},
methods: [],
});
});
});
describe('inferSymbolFromJSONSchema', () => {
it('should infer properly a symbol table based on a JSON schema', () => {
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
{
type: 'object',
description: `The attributes tied to a site's visitor.`,
properties: {
claims: {
type: 'object',
properties: {
key: {
type: 'string',
},
flags: {
type: 'object',
description: 'The user feature flags',
properties: {
FLAG1: { type: 'string' },
FLAG2: { type: 'string' },
FLAG3: { type: 'string' },
},
},
hello: {
type: 'string',
enum: ['test', 'test1', 'test2'],
},
},
},
},
},
'visitor'
);
expect(symbolDef).toMatchObject({
type: 'object',
name: 'visitor',
description: `The attributes tied to a site's visitor.`,
properties: {
claims: {
type: 'object',
name: 'claims',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
description: 'The user feature flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'string',
enum: ['test', 'test1', 'test2'],
},
},
methods: [],
},
},
methods: [],
});
});
});
});
-3
View File
@@ -1,3 +0,0 @@
export * from './symbols';
export * from './symbols-table';
export * from './types';
-350
View File
@@ -1,350 +0,0 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from '../utils';
import {
SymbolArray,
SymbolBoolean,
SymbolNull,
SymbolNumber,
SymbolObject,
SymbolString,
SymbolUndefined,
} from './symbols';
import {
type ExtractSymbolDef,
type GenericSymbolDef,
type ObjectSymbolDef,
SymbolType,
type SymbolWithMethods,
type SymbolWithProperties,
resolveSymbolDef,
} from './types';
export interface SymbolInfo<T extends SymbolType = SymbolType> {
/**
* Definition of the symbol.
*/
definition: ExtractSymbolDef<T>;
/**
* Reference of the symbol in the table of symbols.
*/
ref: string;
/**
* Stores the reference to the parent symbol.
*/
parentRef?: string;
/**
* Stores the reference to the children symbols.
*/
childrenRefs?: string[];
}
export class SymbolError extends Error {
constructor(message: string) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SymbolError);
}
this.name = 'SymbolError';
}
}
export class SymbolsTable {
/**
* Internal table that keeps track of all symbols reference.
*/
#table: Record<string, SymbolInfo>;
/**
* Internal table that keep track of the raw symbols definitions.
*/
#rawSymbols: Record<string, GenericSymbolDef>;
constructor(initialContext: Record<string, GenericSymbolDef> = {}) {
this.#table = {};
this.#rawSymbols = {};
this.addSymbols(initialContext);
}
/**
* Create a new symbols table by merging the current one with the provided one.
*/
merge(other: SymbolsTable): SymbolsTable {
return new SymbolsTable({
...this.#rawSymbols,
...other.#rawSymbols,
});
}
toString() {
return JSON.stringify(this.#table, null, 2);
}
/**
* Infer the symbol of a value and generate the appropriate symbol definition.
*/
static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef<SymbolType> {
if (Array.isArray(value)) {
if (value.length === 0) {
return SymbolArray({ items: SymbolUndefined() });
}
const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0));
// Check that the array is not a mixin of different items types.
if (value.length > 1) {
const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1));
if (firstItemSymbol.type !== secondItemSymbol.type) {
throw new SymbolError('Array with mixin items types are not supported');
}
}
return SymbolArray({ name, items: firstItemSymbol });
}
if (typeof value === 'undefined') {
return SymbolUndefined({ name });
}
if (value === null) {
return SymbolNull({ name });
}
const valueType = typeof value;
switch (valueType) {
case 'string':
return SymbolString({ name });
case 'number':
return SymbolNumber({ name });
case 'boolean':
return SymbolBoolean({ name });
case 'object': {
const properties = Object.entries(value).reduce<Record<string, GenericSymbolDef>>(
(prev, [name, val]) => {
prev[name] = SymbolsTable.inferSymbolFromValue(val, name);
return prev;
},
{}
);
return SymbolObject({ name, properties, methods: [] });
}
default:
throw new SymbolError(`Unsupported symbol type ${valueType}`);
}
}
/**
* Infer a table of symbol based on a JSON schema object describing it.
*/
static inferSymbolFromJSONSchema(
schema: JSONSchema7,
name?: string
): ExtractSymbolDef<SymbolType> {
switch (schema.type) {
case 'string':
return SymbolString({
name,
...(schema.description ? { description: schema.description } : {}),
...(schema.enum
? {
enum: schema.enum
.filter(filterOutNullable)
.map((enumValue) => enumValue.toString()),
}
: {}),
});
case 'number':
case 'integer':
return SymbolNumber({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'boolean':
return SymbolBoolean({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'null':
return SymbolNull({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'object':
return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name);
case 'array':
return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name);
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
static #buildObjectSymbolFromJSONSchemaObject(
schema: JSONSchema7,
name?: string
): ObjectSymbolDef {
const properties: Record<string, GenericSymbolDef> = {};
if (schema.properties) {
Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema(
propertySchema as JSONSchema7,
propertyName
);
});
}
return SymbolObject({
name,
properties,
...(schema.description ? { description: schema.description } : {}),
methods: [],
});
}
static #buildArraySymbolFromJSONSchemaArray(
schema: JSONSchema7,
name?: string
): ExtractSymbolDef<SymbolType.Array> {
if (schema.items) {
const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema(
schema.items as JSONSchema7,
`${name || ''}_item`
);
return SymbolArray({
name,
...(schema.description ? { description: schema.description } : {}),
items: itemSymbol,
});
}
return SymbolArray({
name,
...(schema.description ? { description: schema.description } : {}),
items: SymbolUndefined(),
});
}
private generateSymbolRefPath(path: string[]): string {
return path.join('.');
}
/**
* Add a single symbol to the table at the provided path.
*/
private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void {
const fullPath = this.generateSymbolRefPath(path);
const parentPath = path.slice(0, -1).join('.');
if (this.#table[fullPath]) {
throw new SymbolError(`Symbol "${fullPath}" already exists.`);
}
if (raw) {
this.#rawSymbols[fullPath] = definition;
}
// Add the new symbol linking it to its parent
this.#table[fullPath] = {
definition: resolveSymbolDef(definition),
ref: fullPath,
parentRef: parentPath || undefined,
childrenRefs: [],
};
if (parentPath && this.#table[parentPath]) {
if (this.#table[parentPath].childrenRefs) {
this.#table[parentPath].childrenRefs.push(fullPath);
}
}
// Add any nested symbols if the value is a symbol with properties...
if (isSymbolWithProperties(definition)) {
Object.entries(definition.properties).forEach(([propKey, propSymbol]) => {
if (isObjectSymbol(propSymbol)) {
this.addSymbols({ [propKey]: propSymbol }, path, false);
} else {
this.addSymbol([...path, propKey], propSymbol, false);
}
});
}
// ...or a symbol with methods
if (isSymbolWithMethods(definition)) {
definition.methods.forEach((methodSymbol) => {
this.addSymbol([...path, methodSymbol.name], methodSymbol, false);
});
}
}
/**
* Add the provided object of symbols definitions to the symbol table.
*/
public addSymbols(
symbols: Record<string, GenericSymbolDef>,
prefix: string[] = [],
raw = true
): void {
for (const [key, symbolDef] of Object.entries(symbols)) {
const path = [...prefix, key];
// Add the current symbol to the table.
this.addSymbol(path, symbolDef, raw);
}
}
/**
* Get a symbol's information using its path in the table.
*/
public getSymbolInfo<T extends SymbolType>(path: string | string[]): SymbolInfo<T> | undefined {
const key = Array.isArray(path) ? path.join('.') : path;
const info = this.#table[key];
return info ? typedSymbolInfo(info) : undefined;
}
/**
* Get all symbol keys matching the pattern defined by the provided path.
*/
public getMatchingSymbolsKeys(path: string[]): string[] {
const wildcardRegex = new RegExp(
`^${path
.map((segment) => {
if (segment.includes('*')) {
return `${segment.split('*')[0]}([^.]+)?`;
}
return segment;
})
.join('\\.')}$`
);
return Object.keys(this.#table)
.filter((key) => wildcardRegex.test(key))
.filter(filterOutNullable);
}
}
function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef {
return symbol.type === SymbolType.Object;
}
function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties {
return (
symbol.type === SymbolType.Object ||
symbol.type === SymbolType.Array ||
symbol.type === SymbolType.String
);
}
function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods {
return (
symbol.type === SymbolType.Object ||
symbol.type === SymbolType.Array ||
symbol.type === SymbolType.String
);
}
function typedSymbolInfo<T extends SymbolType>(info: SymbolInfo): SymbolInfo<T> {
const definition = resolveSymbolDef(info.definition);
return { ...info, definition } as SymbolInfo<T>;
}
-268
View File
@@ -1,268 +0,0 @@
import {
type ArraySymbolDef,
type BooleanSymbolDef,
type ExtractSymbolDef,
type FunctionSymbolDef,
type GenericSymbolDef,
type NullSymbolDef,
type NumberSymbolDef,
type ObjectSymbolDef,
type StringSymbolDef,
SymbolType,
type SymbolsWithPropertiesAndMethods,
type UndefinedSymbolDef,
type UnionSymbolDef,
} from './types';
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
return {
type: SymbolType.Boolean,
...args,
};
}
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
return {
type: SymbolType.Number,
...args,
};
}
export function SymbolString(
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
): StringSymbolDef {
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
}
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
return {
type: SymbolType.Object,
...args,
};
}
export function SymbolArray(
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
): ArraySymbolDef {
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
}
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
return {
type: SymbolType.Function,
...args,
};
}
export function OptionalFunctionArg(
optionalArg: ExtractSymbolDef<SymbolType>
): ExtractSymbolDef<SymbolType> & { optional: true } {
return {
...optionalArg,
optional: true,
};
}
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
return {
type: SymbolType.Union,
...args,
};
}
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
return {
type: SymbolType.Undefined,
...args,
};
}
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
return {
type: SymbolType.Null,
...args,
};
}
function createSymbolWithPropertiesAndMethods<
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
const symbol = { type, ...args } as T;
Object.defineProperty(symbol, 'properties', {
get() {
if (symbol.type === SymbolType.Array) {
return isArraySymbol(symbol)
? StandardLibrary[SymbolType.Array]?.(symbol).properties
: {};
}
return StandardLibrary[symbol.type]?.properties || {};
},
});
Object.defineProperty(symbol, 'methods', {
get() {
if (symbol.type === SymbolType.Array) {
return isArraySymbol(symbol)
? StandardLibrary[SymbolType.Array]?.(symbol).methods
: [];
}
return StandardLibrary[symbol.type]?.methods || [];
},
});
return symbol;
}
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
const StandardLibrary: Partial<
{
[key in Exclude<SymbolType, SymbolType.Array>]: {
properties: Record<string, GenericSymbolDef>;
methods: FunctionSymbolDef[];
};
} & {
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
properties: Record<string, GenericSymbolDef>;
methods: FunctionSymbolDef[];
};
}
> = {
[SymbolType.String]: {
properties: {
length: SymbolNumber({
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
}),
},
methods: [
SymbolFunction({
name: 'at',
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
Negative integers count back from the last item in the string.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
args: [
SymbolNumber({
name: 'index',
description: 'The index (position) of the string character to be returned',
}),
],
returns: SymbolUnion({
description: `A String consisting of the single UTF-16 code unit located at the specified position.
Returns undefined if the given index can not be found.`,
members: [SymbolString(), SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'endsWith',
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
elements of this object (converted to a String) starting at endPosition length(this). Otherwise returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
args: [
SymbolString({
name: 'searchString',
description: `The characters to be searched for at the end of str. Cannot be a regex.
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'endPosition',
description: `The end position at which searchString is expected to be found
(the index of searchString's last character plus 1). Defaults to str.length.`,
})
),
],
returns: SymbolBoolean({
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'includes',
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
that are greater than or equal to position; otherwise, returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
args: [
SymbolString({
name: 'searchString',
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'position',
description:
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
})
),
],
returns: SymbolBoolean({
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
],
},
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
properties: {
length: SymbolNumber({
name: 'length',
description: `The length data property of an Array instance represents the number of elements in that array.
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
}),
},
methods: [
SymbolFunction({
name: 'at',
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
Negative integers count back from the last item in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
args: [
SymbolNumber({
name: 'index',
description: `Zero-based index of the array element to be returned, converted to an integer.
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
}),
],
returns: SymbolUnion({
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
without attempting to access the corresponding property.`,
members: [arraySymbolDef.items, SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'includes',
description:
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
args: [
{
...arraySymbolDef.items,
name: 'searchElement',
description: 'The value to be searched for within the array.',
},
OptionalFunctionArg(
SymbolNumber({
name: 'fromIndex',
description:
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
})
),
],
returns: SymbolBoolean({
description:
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
}),
}),
],
}),
};
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
return symbol.type === SymbolType.Array;
}
-193
View File
@@ -1,193 +0,0 @@
import type { MandateProps } from '../utils';
export enum SymbolType {
Boolean = 'boolean',
Number = 'number',
String = 'string',
Object = 'object',
Array = 'array',
Function = 'function',
Union = 'union',
Undefined = 'undefined',
Null = 'null',
}
export interface SymbolMetadata {
/**
* Long description of the symbol.
*/
description?: string;
/**
* Link to a documentation/manual page.
*/
link?: string;
}
export interface GenericSymbolDef extends SymbolMetadata {
/**
* Type of the symbol.
*/
type: SymbolType;
/**
* Name of the symbol.
*/
name?: string;
}
export interface SymbolWithProperties extends GenericSymbolDef {
/**
* Properties on the symbol type.
*/
properties: Record<string, GenericSymbolDef>;
}
export interface SymbolWithMethods extends GenericSymbolDef {
/**
* Methods that can be called on the symbol type.
*/
methods: FunctionSymbolDef[];
}
export interface BooleanSymbolDef extends GenericSymbolDef {
type: SymbolType.Boolean;
}
export interface NumberSymbolDef extends GenericSymbolDef {
type: SymbolType.Number;
}
export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.String;
/**
* Set of enumerated values that the string symbol is retristred to.
*/
enum?: string[];
/**
* Properties on strings.
*/
properties: {
length: NumberSymbolDef;
};
}
export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.Object;
}
export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.Array;
/**
* Symbol representing the type of the items of the array
*/
items: ExtractSymbolDef<SymbolType>;
/**
* Properties on arrays.
*/
properties: {
length: NumberSymbolDef;
};
}
export interface FunctionSymbolDef extends MandateProps<GenericSymbolDef, 'name'> {
type: SymbolType.Function;
/**
* Symbols describing the arguments of the function.
*/
args: (ExtractSymbolDef<SymbolType> & { optional?: boolean })[];
/**
* Symbol describing the returned value of the function.
*/
returns: ExtractSymbolDef<SymbolType>;
}
export interface UnionSymbolDef extends GenericSymbolDef {
type: SymbolType.Union;
/**
* Symbols composing the union.
*/
members: ExtractSymbolDef<SymbolType>[];
}
export interface UndefinedSymbolDef extends GenericSymbolDef {
type: SymbolType.Undefined;
}
export interface NullSymbolDef extends GenericSymbolDef {
type: SymbolType.Null;
}
export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef;
export type ExtractSymbolDef<T extends SymbolType> = T extends SymbolType.String
? StringSymbolDef
: T extends SymbolType.Number
? NumberSymbolDef
: T extends SymbolType.Boolean
? BooleanSymbolDef
: T extends SymbolType.Array
? ArraySymbolDef
: T extends SymbolType.Object
? ObjectSymbolDef
: T extends SymbolType.Function
? FunctionSymbolDef
: T extends SymbolType.Union
? UnionSymbolDef
: T extends SymbolType.Undefined
? UndefinedSymbolDef
: T extends SymbolType.Null
? NullSymbolDef
: never;
export function resolveSymbolDef(
symbol: GenericSymbolDef
):
| StringSymbolDef
| NumberSymbolDef
| BooleanSymbolDef
| ArraySymbolDef
| ObjectSymbolDef
| FunctionSymbolDef
| UnionSymbolDef
| UndefinedSymbolDef
| NullSymbolDef {
switch (symbol.type) {
case SymbolType.String:
return symbol as StringSymbolDef;
case SymbolType.Number:
return symbol as NumberSymbolDef;
case SymbolType.Boolean:
return symbol as BooleanSymbolDef;
case SymbolType.Array:
return symbol as ArraySymbolDef;
case SymbolType.Object:
return symbol as ObjectSymbolDef;
case SymbolType.Function:
return symbol as FunctionSymbolDef;
case SymbolType.Union:
return symbol as UnionSymbolDef;
case SymbolType.Undefined:
return symbol as UndefinedSymbolDef;
case SymbolType.Null:
return symbol as NullSymbolDef;
default:
throw new Error(`Unknown symbol type: ${symbol.type}`);
}
}
-57
View File
@@ -1,57 +0,0 @@
export type TemplateText = {
type: 'text';
value: string;
start: number;
end: number;
};
export type TemplateExpression = {
type: 'expression';
value: string;
start: number; // Start index of the expression content (after `{{`)
end: number; // End index of the expression content (before `}}`)
};
export type TemplatePart = TemplateText | TemplateExpression;
/**
* Parse a template string containing `{{ expression }}` placeholders.
*/
export function parseTemplate(template: string): TemplatePart[] {
const parts: TemplatePart[] = [];
const regex = /\{\{(.*?)\}\}/gs;
let lastIndex = 0;
for (const match of template.matchAll(regex)) {
const matchStart = match.index ?? 0;
const matchEnd = matchStart + match[0].length;
if (matchStart > lastIndex) {
parts.push({
type: 'text',
value: template.slice(lastIndex, matchStart),
start: lastIndex,
end: matchStart,
});
}
parts.push({
type: 'expression',
value: (match[1] ?? '').trim(),
start: matchStart + 2,
end: matchEnd - 2,
});
lastIndex = matchEnd;
}
if (lastIndex < template.length) {
parts.push({
type: 'text',
value: template.slice(lastIndex),
start: lastIndex,
end: template.length,
});
}
return parts;
}
-176
View File
@@ -1,176 +0,0 @@
import type { BinaryOperator, Expression, ExpressionStatement, LogicalOperator } from 'acorn';
import type { ArraySymbolDef, SymbolInfo, SymbolType } from './symbols';
export interface ExpressionGenerator {
/**
* Converts an ESTree compatible AST node into a string representing the corresponding expression.
*/
generate(node: Expression): string;
}
export interface ExpressionParserResult {
/**
* The expression statement from the valid portion of the parsed expression.
*
* It is undefined when no valid expression statements could be found.
*/
result: Expression;
/**
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
*/
invalidNodes: Array<ExpressionStatement>;
}
export interface ExpressionAutocompleteResults {
suggestions: AutocompleteSuggestions;
}
type ConditionalOperator = '?';
export const SUPPORTED_BINARY_OPERATORS = [
{
operator: '==',
description: 'Checks whether two values are equal.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality',
},
{
operator: '!=',
description: 'Checks whether two values are unequal.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality',
},
{
operator: '===',
description: 'Checks whether two values are equal (strict comparison).',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality',
},
{
operator: '!==',
description: 'Checks whether two values are unequal (strict comparison).',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_inequality',
},
{
operator: '<',
description: 'Checks if the left value is less than the right value.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than',
},
{
operator: '<=',
description: 'Checks if the left value is less than or equal to the right value.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than_or_equal',
},
{
operator: '>',
description: 'Checks if the left value is greater than the right value.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than',
},
{
operator: '>=',
description: 'Checks if the left value is greater than or equal to the right value.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than_or_equal',
},
{
operator: 'in',
description: 'Checks if a property exists in an object or if a value is in an array.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in',
},
] as const;
export const SUPPORTED_LOGICAL_OPERATORS = [
{
operator: '&&',
description: 'Logical AND operator; returns true if both operands are true.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND',
},
{
operator: '||',
description: 'Logical OR operator; returns true if at least one operand is true.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR',
},
] as const;
export const SUPPORTED_CONDITIONAL_OPERATORS = [
{
operator: '?',
description:
'Conditional (ternary) operator; returns one of two values based on a condition.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator',
},
] as const;
type DirectLiteralValue = {
kind: 'direct';
} & (
| {
type: SymbolType.Boolean;
data: boolean;
}
| {
type: SymbolType.Number;
data: number;
}
| {
type: SymbolType.String;
data: string;
}
| {
type: SymbolType.Null;
data: null;
}
);
type InArrayLiteralValue = {
kind: 'in-array';
srcSymbol: ArraySymbolDef;
matchedLiteralString: string;
};
export type DirectLiteralValueSuggestion = {
type: 'literal-value';
value: DirectLiteralValue;
};
export type InArrayLiteralValueSuggestion = {
type: 'literal-value';
value: InArrayLiteralValue;
};
export type AutocompleteLiteralValueSuggestion =
| DirectLiteralValueSuggestion
| InArrayLiteralValueSuggestion;
export interface AutocompleteOperatorSuggestion {
type: 'operator';
operator:
| Extract<BinaryOperator, (typeof SUPPORTED_BINARY_OPERATORS)[number]['operator']>
| Extract<LogicalOperator, (typeof SUPPORTED_LOGICAL_OPERATORS)[number]['operator']>
| Extract<
ConditionalOperator,
(typeof SUPPORTED_CONDITIONAL_OPERATORS)[number]['operator']
>;
description: string;
link: string;
}
export interface AutocompleteSymbolSuggestion {
type: 'symbol';
symbol: SymbolInfo;
}
export type AutocompleteSuggestions = Array<
| AutocompleteSymbolSuggestion
| AutocompleteLiteralValueSuggestion
| AutocompleteOperatorSuggestion
>;
type LoggerFn = (message: string, ...args: any[]) => void;
/**
* A logger that can be passed to the runtime.
*/
export interface Logger {
debug: LoggerFn;
info: LoggerFn;
error: LoggerFn;
}
-40
View File
@@ -1,40 +0,0 @@
/**
* Format the result value of an expression for display as a string.
*/
export function formatExpressionResult(value: any, defaultValue = ''): string {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
return defaultValue;
}
/**
* Filter function to exclude `null` values
*/
export function filterOutNullable<T>(value: T): value is NonNullable<T> {
return !!value;
}
/**
* Type to make optional properties on a object mandatory.
*
* interface SomeObject {
* uid: string;
* price: number | null;
* location?: string;
* }
*
* type ValuableObject = MandateProps<SomeObject, 'price' | 'location'>;
*/
export type MandateProps<T extends {}, K extends keyof T> = T & {
[MK in K]-?: NonNullable<T[MK]>;
};
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["./tsconfig.json"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist"
}
}
-18
View File
@@ -1,18 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"],
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"noPropertyAccessFromIndexSignature": false,
"exactOptionalPropertyTypes": false,
"types": [
"bun-types" // add Bun global
]
},
"include": ["types/**/*.d.ts", "src/**/*.ts"]
}
-57
View File
@@ -1,57 +0,0 @@
declare module 'eval-estree-expression' {
/**
* Options for evaluation and compilation.
*/
export interface EvalESTreeExpressionOptions {
/**
* Force logical operators to return a boolean result. Default: undefined
*/
booleanLogicalOperators?: boolean;
/**
* Allow function calls to be evaluated. This is unsafe, please enable this option at your own risk. Default: false
*/
functions?: boolean;
/**
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
*/
generate?: boolean;
/**
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
*/
regexOperator?: boolean;
/**
* Throw an error when variables are undefined. Default: false
*/
strict?: boolean;
/**
* Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined
*/
withMembers?: boolean;
}
/**
* Evaluates an ESTree expression asynchronously against a given context.
* @param expression - An object representing an ESTree-compliant AST node.
* @param context - An object containing variables and values to be used during evaluation.
* @returns A promise resolving to the result of the evaluation.
*/
export function evaluate<ASTNode>(
ast: ASTNode,
context: object,
options?: EvalESTreeExpressionOptions
): Promise<any>;
/**
* Evaluates an ESTree expression synchronously against a given context.
* @param expression - An object representing an ESTree-compliant AST node.
* @param context - An object containing variables and values to be used during evaluation.
* @returns The result of the evaluation.
*/
export namespace evaluate {
function sync<ASTNode>(
expression: ASTNode,
context: object,
options?: EvalESTreeExpressionOptions
): any;
}
}
-39
View File
@@ -1,44 +1,5 @@
# gitbook
## 0.16.0
### Minor Changes
- 6830815: Support custom AI providers
- cbc71a5: Allow integrations to provide tools to the Docs Assistant
- cc2e615: Emit a <link rel="alternate"> for the markdown version of a page
- 81a6bd7: Support customization of buttons and tools through iframe API
- 8927e8f: Start routes for embeddable version of the assistant and docs pages.
### Patch Changes
- d30bcba: Improve `Button` and `ButtonGroup` styling
- e1b2cf6: Fix scroll of page outline
- d655d3e: Support "objectivec" as alias for Objective C syntax
- 13ff22b: Fix AI Search follow-up question closing search
- ffa866c: Small fixes to search modal
- 36af03f: Fixes to `PageAside`
- fb858a1: Tweaks to AIChatButton and AIChatInput
- bcfa8d8: Improve vertical alignment of site items and fix floating page aside
- ea7e94f: Fix search bar layout shift caused by ToC
- 2e6e28e: Fix: Long strings overflow out of message bubble in docs assistant
- ff96bb5: Support new coverDefinitionDark for cards & image type
- 388b20d: Clear AI chat properly
- ba7ec14: Fix bug in search highlight in GBO
- 6217a2e: Page outline: scroll to active item
- 42c17f5: Improve OpenAPI parsing errors
- 854c448: Custom assistants followup
- 43766d6: Fix Custom logo not rendering on the published site
- Updated dependencies [cbc71a5]
- Updated dependencies [25e2b40]
- Updated dependencies [42c17f5]
- Updated dependencies [854c448]
- @gitbook/browser-types@0.1.0
- @gitbook/icons@0.3.0
- @gitbook/openapi-parser@3.0.1
- @gitbook/react-contentkit@0.7.4
- @gitbook/react-openapi@1.3.6
## 0.15.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.16.0",
"version": "0.15.0",
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
@@ -167,7 +167,7 @@ async function DescriptionForSearchToolCall(props: {
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
)}
>
<div className="flex min-w-0 flex-col break-words leading-snug">
<div className="flex flex-col leading-snug">
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
<p className="mt-0.5 text-tint-subtle text-xs">
{hasResults
@@ -23,6 +23,8 @@ export type AIChatMessage = {
query?: string;
};
export type AIChatSize = 'default' | 'large';
export type AIChatPendingTool = {
icon?: IconName;
label: string;
@@ -85,6 +87,11 @@ export type AIChatState = {
* display an error alert. Clearing the conversation will reset this flag.
*/
error: boolean;
/**
* The size of the chat window.
*/
size: AIChatSize;
};
export type AIChatController = {
@@ -96,12 +103,14 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void;
/** Clear the conversation */
clear: () => void;
/** Toggle the size of the chat window */
setSize: (size: AIChatSize) => void;
};
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
// Global state store for AI chat
const globalState = zustand.create<AIChatState>(() => {
export const globalState = zustand.create<AIChatState>(() => {
return {
opened: false,
responseId: null,
@@ -112,6 +121,7 @@ const globalState = zustand.create<AIChatState>(() => {
loading: false,
error: false,
initialQuery: null,
size: 'default',
};
});
@@ -346,8 +356,7 @@ export function AIChatProvider(props: {
loading: false,
error: false,
}));
} catch (error) {
console.error('Error streaming AI response', error);
} catch {
globalState.setState((state) => ({
...state,
loading: false,
@@ -360,7 +369,6 @@ export function AIChatProvider(props: {
renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
]
);
@@ -440,14 +448,19 @@ export function AIChatProvider(props: {
}));
}, [setSearchState]);
const onSetSize = React.useCallback((size: AIChatSize) => {
globalState.setState((state) => ({ ...state, size }));
}, []);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
setSize: onSetSize,
};
}, [onOpen, onClose, onClear, onPostMessage]);
}, [onOpen, onClose, onClear, onPostMessage, onSetSize]);
return (
<AIChatControllerContext.Provider value={controller}>
+101 -69
View File
@@ -2,6 +2,7 @@
import { t, tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -11,18 +12,10 @@ import {
useAIChatController,
useAIChatState,
} from '../AI';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameSubtitle,
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { EmbeddableFrame } from '../Embeddable/EmbeddableFrame';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { AIChatControlButton } from './AIChatControlButton';
import { Button, DropdownMenuSeparator } from '../primitives';
import { DropdownMenu, DropdownMenuItem } from '../primitives';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
@@ -72,19 +65,77 @@ export function AIChat(props: { trademark: boolean }) {
return (
<div
data-testid="ai-chat"
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
className={tcls(
'ai-chat z-40 mx-auto flex max-h-full w-full max-w-3xl animate-present scroll-mt-36 p-4 transition-all duration-300 max-md:inset-x-0 max-md:bottom-0 lg:fixed lg:inset-y-0 lg:right-0 lg:animate-enter-from-right',
chat.size === 'large' ? 'lg:w-104 xl:w-156' : 'lg:w-80 xl:w-96'
)}
>
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<EmbeddableFrame
className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"
icon={<AIChatDynamicIcon trademark={trademark} />}
title={getAIChatName(language, trademark)}
subtitle={
chat.loading
? chat.messages[chat.messages.length - 1].content
? tString(language, 'ai_chat_working')
: tString(language, 'ai_chat_thinking')
: ''
}
buttons={
<>
{chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<DropdownMenuItem
onClick={() => {
chatController.setSize(
chat.size === 'default' ? 'large' : 'default'
);
}}
>
<Icon
icon={
chat.size === 'default'
? 'arrow-up-right-and-arrow-down-left-from-center'
: 'arrow-down-left-and-arrow-up-right-to-center'
}
className="size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-0.5">
<p className="font-medium">
{chat.size === 'default' ? 'Maximize' : 'Minimize'}
</p>
<p className="text-tint text-xs">
{chat.size === 'default'
? 'Longer, more detailed answers'
: 'Shorter, more concise answers'}
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
chatController.clear();
}}
>
<Icon
icon="broom-wide"
className="size-3 shrink-0 text-tint-subtle"
/>
{t(language, 'ai_chat_clear_conversation')}
</DropdownMenuItem>
</DropdownMenu>
) : null}
<Button
onClick={() => chatController.close()}
iconOnly
@@ -93,11 +144,10 @@ export function AIChat(props: { trademark: boolean }) {
variant="blank"
size="default"
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
</EmbeddableFrameBody>
</>
}
>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
</EmbeddableFrame>
</div>
);
@@ -120,7 +170,7 @@ export function AIChatDynamicIcon(props: {
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1]?.content
? chat.messages[chat.messages.length - 1].content
? 'working'
: 'thinking'
: chat.messages.length > 0
@@ -133,24 +183,6 @@ export function AIChatDynamicIcon(props: {
);
}
/**
* Subtitle of the AI chat window.
*/
export function AIChatSubtitle(props: {
chat: AIChatState;
}) {
const { chat } = props;
const language = useLanguage();
return (
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
{chat.messages[chat.messages.length - 1]?.content
? tString(language, 'ai_chat_working')
: tString(language, 'ai_chat_thinking')}
</EmbeddableFrameSubtitle>
);
}
/**
* Body of the AI chat window.
*/
@@ -158,10 +190,8 @@ export function AIChatBody(props: {
chatController: AIChatController;
chat: AIChatState;
trademark: boolean;
welcomeMessage?: string;
suggestions?: string[];
}) {
const { chatController, chat, trademark, suggestions } = props;
const { chatController, chat, trademark } = props;
const [input, setInput] = React.useState('');
@@ -224,33 +254,35 @@ export function AIChatBody(props: {
<>
<div
ref={scrollContainerRef}
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
className={tcls(
'flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4 transition-all',
chat.size === 'large' ? 'text-base' : 'text-sm'
)}
style={{
paddingBottom: `${inputHeight}px`,
}}
>
{isEmpty ? (
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
<AIChatIcon
state="intro"
trademark={trademark}
className="size-16 animate-[present_500ms_200ms_both]"
/>
</div>
<div className="animate-[fadeIn_500ms_400ms_both]">
<h5 className=" text-center font-bold text-lg text-tint-strong">
{timeGreeting}
</h5>
<p className="text-center text-tint">
{t(language, 'ai_chat_assistant_description')}
</p>
<div className="flex items-center gap-4 lg:flex-col">
<div className="flex animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle p-4 lg:p-8">
<AIChatIcon
state="intro"
trademark={trademark}
className="size-8 animate-[present_500ms_200ms_both] lg:size-16"
/>
</div>
<div className="animate-[fadeIn_500ms_400ms_both]">
<h5 className="font-bold text-lg text-tint-strong lg:text-center">
{timeGreeting}
</h5>
<p className="text-tint lg:text-center">
{t(language, 'ai_chat_assistant_description')}
</p>
</div>
</div>
{!chat.error ? (
<AIChatSuggestedQuestions
chatController={chatController}
suggestions={suggestions}
/>
<AIChatSuggestedQuestions chatController={chatController} />
) : null}
</div>
) : (
@@ -1,40 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '../AI';
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
/**
* Button to control the chat (clear, etc.)
*/
export function AIChatControlButton() {
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
return chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<DropdownMenuItem
onClick={() => {
chatController.clear();
}}
>
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
{t(language, 'ai_chat_clear_conversation')}
</DropdownMenuItem>
</DropdownMenu>
) : null;
}
@@ -140,7 +140,7 @@ export function AIChatInput(props: {
<span className="leading-none">
{t(language, 'ai_chat_context_title')}
</span>
<Icon icon="question-circle" className="size-3" />
<Icon icon="question-circle" className="size-3 shrink-0" />
</div>
</HoverCardTrigger>
</HoverCardRoot>
@@ -32,7 +32,6 @@ export function AIChatMessages(props: {
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
'break-words',
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
@@ -2,28 +2,24 @@ import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI';
import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: {
chatController: AIChatController;
suggestions?: string[];
}) {
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
const { chatController } = props;
const language = useLanguage();
const {
chatController,
suggestions = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
],
} = props;
const DEFAULT_SUGGESTED_QUESTIONS = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
return (
<div className="flex flex-col items-center gap-2">
{suggestions.map((question, index) => (
<div className="flex items-center gap-2 lg:flex-col">
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
<Button
key={question}
variant="secondary"
size="medium"
className="max-w-full animate-[present_500ms_both] whitespace-normal"
className="shrink grow animate-[present_500ms_both] whitespace-normal"
style={{
animationDelay: `${800 + index * 100}ms`,
}}
@@ -2,4 +2,3 @@ export * from './AIChat';
export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
@@ -1,21 +1,7 @@
'use client';
import { useAIChatController, useAIChatState } from '@/components/AI';
import {
AIChatBody,
AIChatControlButton,
AIChatDynamicIcon,
AIChatSubtitle,
} from '@/components/AIChat';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
import { AIChatBody } from '@/components/AIChat';
/**
* Embeddable AI chat window in an iframe.
@@ -26,29 +12,6 @@ export function EmbeddableAIChat(props: {
const { trademark } = props;
const chat = useAIChatState();
const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>GitBook Assistant</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
trademark={trademark}
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
/>
</EmbeddableFrameBody>
</EmbeddableFrame>
);
return <AIChatBody trademark={trademark} chatController={chatController} chat={chat} />;
}
@@ -3,15 +3,6 @@ import { type PagePathParams, getSitePageData } from '@/components/SitePage';
import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
import type { Metadata } from 'next';
import { Button } from '../primitives';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
export const dynamic = 'force-static';
@@ -31,33 +22,15 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
});
return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
<EmbeddableFrameHeaderMain>
<Button
href={context.linker.toPathInSite('~gitbook/embed/assistant')}
size="default"
variant="blank"
icon="arrow-left"
label="Back"
/>
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<div className="flex-1 overflow-auto p-6">
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
/>
</div>
</EmbeddableFrameBody>
</EmbeddableFrame>
<div className="flex-1 overflow-auto p-6">
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
/>
</div>
);
}
@@ -1,17 +1,27 @@
import { tcls } from '@/lib/tailwind';
import React from 'react';
export type EmbeddableFrameProps = React.ComponentProps<'div'> & {
children: React.ReactNode;
export type EmbeddableFrameHeaderProps = {
icon: React.ReactNode;
title: string;
subtitle?: string;
buttons?: React.ReactNode;
};
export type EmbeddableFrameProps = EmbeddableFrameHeaderProps &
React.ComponentProps<'div'> & {
children: React.ReactNode;
};
/**
* Presentation component to display an embeddable frame.
* It is used for the AI chat window in the docs, but also when embedded in another website.
*/
export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameProps>(
(props, ref) => {
const { children, ...divProps } = props;
const { icon, title, subtitle, buttons, children, ...divProps } = props;
return (
<div
@@ -22,70 +32,22 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
)}
ref={ref}
>
{children}
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
{icon}
<div className="flex flex-col">
<div className="font-bold">{title}</div>
<div
className={`text-tint text-xs leading-none transition-all duration-500 ${
subtitle ? 'h-3 opacity-11' : 'h-0 opacity-0'
}`}
>
{subtitle}
</div>
</div>
<div className="ml-auto flex gap-2">{buttons}</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden">{children}</div>
</div>
);
}
);
export function EmbeddableFrameHeader(props: {
children: React.ReactNode;
}) {
const { children } = props;
return (
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
{children}
</div>
);
}
export function EmbeddableFrameHeaderMain(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="flex flex-1 flex-col">{children}</div>;
}
export function EmbeddableFrameBody(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
}
export function EmbeddableFrameTitle(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="font-bold">{children}</div>;
}
export function EmbeddableFrameSubtitle(props: {
children: React.ReactNode;
className?: string;
}) {
const { children, className } = props;
return (
<div
className={tcls(
'text-tint text-xs leading-none transition-all duration-500',
className
)}
>
{children}
</div>
);
}
export function EmbeddableFrameButtons(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="ml-auto flex gap-2">{children}</div>;
}
@@ -1,31 +1,15 @@
'use client';
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import type { ParentToFrameMessage } from '@gitbook/embed';
import { createChannel } from 'bidc';
import React from 'react';
import { useAIChatController } from '@/components/AI';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
}));
/**
* Expose the API to communicate with the parent window.
*/
export function EmbeddableIframeAPI(props: {
baseURL: string;
}) {
const { baseURL } = props;
const router = useRouter();
export function EmbeddableIframeAPI() {
const chatController = useAIChatController();
React.useEffect(() => {
@@ -33,14 +17,11 @@ export function EmbeddableIframeAPI(props: {
return;
}
console.log('[gitbook] create channel with parent window');
const channel = createChannel();
channel.receive((payload) => {
const message = payload as ParentToFrameMessage;
console.log('[gitbook] received message', message);
switch (message.type) {
case 'clearChat': {
chatController.clear();
@@ -52,64 +33,12 @@ export function EmbeddableIframeAPI(props: {
});
break;
}
case 'configure': {
embeddableConfiguration.setState(message.settings);
integrationsAssistantTools.setState({
tools: message.settings.tools,
});
break;
}
case 'navigateToPage': {
router.push(`${baseURL}/page/${message.pagePath}`);
break;
}
case 'navigateToAssistant': {
router.push(`${baseURL}/assistant`);
break;
}
// TODO: Handle other messages
}
});
return () => {
console.log('[gitbook] cleanup');
channel.cleanup();
};
}, [chatController, router, baseURL]);
return channel.cleanup();
}, [chatController]);
return null;
}
/**
* Hook to get the configuration from the parent window.
*/
export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
// @ts-expect-error - This is a workaround to allow the function to be optional.
fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state
) {
return useStore(embeddableConfiguration, fn);
}
/**
* Display the buttons defined by the parent window.
*/
export function EmbeddableIframeButtons() {
const buttons = useEmbeddableConfiguration((state) => state.buttons);
return (
<>
{buttons.map((button) => (
<Button
key={button.label}
size="default"
variant="blank"
icon={button.icon}
label={button.label}
iconOnly
onClick={() => {
button.onClick();
}}
/>
))}
</>
);
}
@@ -1,4 +1,6 @@
import { AIChatProvider, AIContextProvider } from '@/components/AI';
import { AIChatDynamicIcon } from '@/components/AIChat';
import { EmbeddableFrame } from '@/components/Embeddable';
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayoutClientContexts,
@@ -27,22 +29,26 @@ export async function EmbeddableRootLayout({
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
>
<AIContextProvider
aiMode={CustomizationAIMode.Assistant}
trademark={context.customization.trademark.enabled}
<EmbeddableFrame
icon={<AIChatDynamicIcon trademark={context.customization.trademark.enabled} />}
title="Test"
className="fixed inset-0"
>
<AIChatProvider
renderMessageOptions={{
withLinkPreviews: false,
asEmbeddable: true,
}}
<AIContextProvider
aiMode={CustomizationAIMode.Assistant}
trademark={context.customization.trademark.enabled}
>
<div className="fixed inset-0 flex flex-col">{children}</div>
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
/>
</AIChatProvider>
</AIContextProvider>
<AIChatProvider
renderMessageOptions={{
withLinkPreviews: false,
asEmbeddable: true,
}}
>
{children}
<EmbeddableIframeAPI />
</AIChatProvider>
</AIContextProvider>
</EmbeddableFrame>
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -55,11 +55,11 @@ interface InsightsProviderProps {
/** If true, the visitor cookie tracking will be used */
visitorCookieTrackingEnabled: boolean;
/** The application URL. */
/** The URL of the app. */
appURL: string;
/** The url of the endpoint to send events to */
eventUrl: string;
/** The host of the API. */
apiHost: string;
/** The children of the provider. */
children: React.ReactNode;
@@ -69,7 +69,7 @@ interface InsightsProviderProps {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
const { enabled, appURL, apiHost, children, visitorCookieTrackingEnabled } = props;
const currentContent = useCurrentContent();
const visitorIdRef = React.useRef<string | null>(null);
@@ -126,7 +126,9 @@ export function InsightsProvider(props: InsightsProviderProps) {
if (allEvents.length > 0) {
if (enabled) {
sendEvents({
eventUrl,
apiHost,
organizationId: currentContent.organizationId,
siteId: currentContent.siteId,
events: allEvents,
});
} else {
@@ -188,7 +190,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
return () => {
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
}, [flushEventsSync, appURL, visitorCookieTrackingEnabled]);
return (
<InsightsContext.Provider value={trackEvent}>
@@ -214,12 +216,16 @@ export function useTrackEvent(): TrackEventCallback {
* Post the events to the server.
*/
function sendEvents(args: {
eventUrl: string;
apiHost: string;
organizationId: string;
siteId: string;
events: api.SiteInsightsEvent[];
}) {
const { eventUrl, events } = args;
const { apiHost, organizationId, siteId, events } = args;
const url = new URL(apiHost);
url.pathname = `/v1/orgs/${organizationId}/sites/${siteId}/insights/events`;
fetch(eventUrl, {
fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -53,7 +53,7 @@ async function fetchVisitorID(
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.pathname = '/__session';
url.searchParams.set('proposed', proposed);
try {
@@ -9,7 +9,7 @@ import type {
GitBookIntegrationEventCallback,
GitBookIntegrationTool,
} from '@gitbook/browser-types';
import type { Assistant } from '../AI';
import { type Assistant, globalState } from '../AI';
const events = new Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>();
@@ -67,6 +67,24 @@ if (typeof window !== 'undefined') {
},
};
window.GitBook = gitbookGlobal;
window.GitBook?.registerTool({
name: 'ExpandChatSize',
description:
'FREE TO USE: Expand the chat to a larger size. Use this tool before you start writing your final answer if you need to show a lot of content, like step-by-step instructions or codeblocks. You do not need to preface this tool with feedback to the user, just call it and the chat will expand.',
execute: async () => {
globalState.setState((state) => ({ ...state, size: 'large' }));
return {
output: {
expanded: true,
},
summary: {
icon: 'arrow-up-right-and-arrow-down-left-from-center',
text: 'Chat size expanded',
},
};
},
});
}
/**
@@ -13,7 +13,7 @@ import { CONTAINER_STYLE } from '@/components/layout';
import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
import { AIChat } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
@@ -69,12 +69,6 @@ export function SpaceLayout(props: {
</div>
);
const eventUrl = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('/~gitbook/__evt'))
);
eventUrl.searchParams.set('o', context.organizationId);
eventUrl.searchParams.set('s', context.site.id);
return (
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
<CurrentContentProvider
@@ -90,7 +84,7 @@ export function SpaceLayout(props: {
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
apiHost={GITBOOK_API_PUBLIC_URL}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<Announcement context={context} />
+1 -25
View File
@@ -1,5 +1,5 @@
import type { headers as nextHeaders } from 'next/headers';
import { GITBOOK_API_URL, GITBOOK_DISABLE_TRACKING } from './env';
import { GITBOOK_DISABLE_TRACKING } from './env';
/**
* Return true if events should be tracked on the site.
@@ -21,27 +21,3 @@ export function shouldTrackEvents(headers?: Awaited<ReturnType<typeof nextHeader
return true;
}
/**
* Serve as a proxy to the analytics endpoint, forwarding the request body and required parameters.
*/
export async function serveProxyAnalyticsEvent(req: Request) {
const requestURL = new URL(req.url);
const org = requestURL.searchParams.get('o');
const site = requestURL.searchParams.get('s');
if (!org || !site) {
return new Response('Missing required query parameters: o (org) and s (site)', {
status: 400,
headers: { 'content-type': 'text/plain' },
});
}
const url = new URL(`${GITBOOK_API_URL}/v1/orgs/${org}/sites/${site}/insights/events`);
return await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: req.body,
});
}
-6
View File
@@ -26,7 +26,6 @@ import {
import { serveResizedImage } from '@/routes/image';
import { cookies } from 'next/headers';
import type { SiteURLData } from './lib/context';
import { serveProxyAnalyticsEvent } from './lib/tracking';
export const config = {
matcher: [
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
@@ -141,11 +140,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
}
//Forwards analytics events
if (siteRequestURL.pathname.endsWith('/~gitbook/__evt')) {
return await serveProxyAnalyticsEvent(request);
}
// We want to filter hostnames that contains a port here as this is likely a malicious request.
if (shouldFilterMaliciousRequests(siteRequestURL)) {
return new Response('Invalid request', {
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/icons
## 0.3.0
### Minor Changes
- 25e2b40: Update icons set to the latest version
## 0.2.2
### Patch Changes
+1 -1
View File
@@ -16,7 +16,7 @@
"default": "./src/style.css"
}
},
"version": "0.3.0",
"version": "0.2.2",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/fontawesome-free": "^6.6.0"
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/openapi-parser
## 3.0.1
### Patch Changes
- 42c17f5: Improve OpenAPI parsing errors
## 3.0.0
### Major Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "3.0.1",
"version": "3.0.0",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.18.0",
-7
View File
@@ -1,12 +1,5 @@
# @gitbook/react-contentkit
## 0.7.4
### Patch Changes
- Updated dependencies [25e2b40]
- @gitbook/icons@0.3.0
## 0.7.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitbook/react-contentkit",
"version": "0.7.4",
"version": "0.7.3",
"exports": {
".": {
"types": "./dist/index.d.ts",
-7
View File
@@ -1,12 +1,5 @@
# @gitbook/react-openapi
## 1.3.6
### Patch Changes
- Updated dependencies [42c17f5]
- @gitbook/openapi-parser@3.0.1
## 1.3.5
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "1.3.6",
"version": "1.3.5",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",