mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 14:45:17 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d846548c8 | |||
| 8e4c94a4e5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Initial version of the embed SDK.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve `Button` and `ButtonGroup` styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix scroll of page outline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support "objectivec" as alias for Objective C syntax
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support custom AI providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/browser-types": minor
|
||||
---
|
||||
|
||||
First version of the public package for typing script integrations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix AI Search follow-up question closing search
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Small fixes to search modal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fixes to `PageAside`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Tweaks to AIChatButton and AIChatInput
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow integrations to provide tools to the Docs Assistant
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve vertical alignment of site items and fix floating page aside
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix search bar layout shift caused by ToC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Emit a <link rel="alternate"> for the markdown version of a page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support new coverDefinitionDark for cards & image type
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
---
|
||||
|
||||
Update icons set to the latest version
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Clear AI chat properly
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix bug in search highlight in GBO
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Start routes for embeddable version of the assistant and docs pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Page outline: scroll to active item
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI parsing errors
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/browser-types": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Custom assistants followup
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Custom logo not rendering on the published site
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.140.0",
|
||||
"@gitbook/api": "^0.136.0",
|
||||
"bidc": "^0.0.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -121,6 +121,17 @@ export function getCacheTag(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache tag for a given URL.
|
||||
*/
|
||||
export function getCacheTagForURL(url: string | URL) {
|
||||
const parsedURL = url instanceof URL ? url : new URL(url);
|
||||
return getCacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags for a computed content source.
|
||||
*/
|
||||
@@ -171,7 +182,7 @@ export function getComputedContentSourceCacheTags(
|
||||
break;
|
||||
default:
|
||||
// Do not throw for unknown dependency types
|
||||
// as it might mean we are lagging behind the API version
|
||||
// as it might mean we are lacking behind the API version
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -187,8 +198,9 @@ export function getComputedContentSourceCacheTags(
|
||||
}
|
||||
|
||||
// We invalidate the computed content when a new version of the integration is deployed.
|
||||
|
||||
if (source.type.startsWith('integration:')) {
|
||||
const integration = source.type.split(':')[1]!;
|
||||
const integration = source.type.split(':')[1];
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# @gitbook/colors
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 17dd382: Add `original` background color step
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 193d591: Fix return type for `colorContrast`
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.3",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
|
||||
type OKLABColor = { L: number; A: number; B: number };
|
||||
type OKLCHColor = { L: number; C: number; H: number };
|
||||
|
||||
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
|
||||
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
|
||||
|
||||
export enum ColorCategory {
|
||||
backgrounds = 'backgrounds',
|
||||
@@ -19,7 +19,7 @@ export enum ColorCategory {
|
||||
}
|
||||
|
||||
type ColorSubScale = {
|
||||
[key: string]: number | string;
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,8 +56,6 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
|
||||
solid: 9,
|
||||
/** Hovered solid backgrounds */
|
||||
'solid-hover': 10,
|
||||
/** Original color */
|
||||
original: 'original',
|
||||
},
|
||||
[ColorCategory.text]: {
|
||||
/** Very low-contrast text
|
||||
@@ -213,8 +211,8 @@ export function colorScale(
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < mapping.length; index++) {
|
||||
const step = mapping[index]!;
|
||||
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||
const targetL =
|
||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||
|
||||
if (
|
||||
index === 8 &&
|
||||
@@ -297,7 +295,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
|
||||
|
||||
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
||||
const rgb = end.map((channel, index) => {
|
||||
return Math.round(channel + percentage * (start[index]! - channel));
|
||||
return Math.round(channel + percentage * (start[index] - channel));
|
||||
});
|
||||
|
||||
return rgbArrayToHex(rgb as RGBColor);
|
||||
@@ -394,14 +392,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
|
||||
B: number;
|
||||
} {
|
||||
const [x, y, z] = xyz.map((v, i) => {
|
||||
const scaled = v / D65[i]!;
|
||||
const scaled = v / D65[i];
|
||||
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
||||
});
|
||||
|
||||
return {
|
||||
L: 116 * y! - 16,
|
||||
A: 500 * (x! - y!),
|
||||
B: 200 * (y! - z!),
|
||||
L: 116 * y - 16,
|
||||
A: 500 * (x - y),
|
||||
B: 200 * (y - z),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,10 +419,7 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
|
||||
return contrast < 7.5 ? 0 : contrast;
|
||||
}
|
||||
|
||||
export function colorContrast(
|
||||
background: string,
|
||||
foreground: string[] = [LIGHT_BASE, DARK_BASE]
|
||||
): string {
|
||||
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
|
||||
const bg = hexToRgbArray(background);
|
||||
|
||||
const best: { color?: RGBColor; contrast: number } = {
|
||||
@@ -441,5 +436,5 @@ export function colorContrast(
|
||||
}
|
||||
}
|
||||
|
||||
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
|
||||
return best.color ? rgbArrayToHex(best.color) : foreground[0];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6f368b5: Fix embed assistant window width on small screens
|
||||
|
||||
## 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
|
||||
@@ -10,7 +10,7 @@
|
||||
"react": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,2 +1 @@
|
||||
export * from './GitBookProvider';
|
||||
export * from './GitBookFrame';
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
--gitbook-widget-top: 1rem;
|
||||
--gitbook-widget-bottom: 1rem;
|
||||
--gitbook-widget-right: 1rem;
|
||||
--gitbook-widget-left: 1rem;
|
||||
|
||||
--gitbook-widget-button-height: 46px;
|
||||
|
||||
@@ -118,7 +117,7 @@
|
||||
bottom: var(--gitbook-widget-window-bottom);
|
||||
right: var(--gitbook-widget-right);
|
||||
z-index: 9998;
|
||||
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
||||
width: var(--gitbook-widget-window-width);
|
||||
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||
background-color: var(--gitbook-widget-background-solid);
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
dist
|
||||
@@ -1,17 +0,0 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e1ff17e: Fix bundling of gitbook/expr package
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8ff1e3b: Add support for every/some array methods
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
|
||||
@@ -1,3 +0,0 @@
|
||||
# `@gitbook/expr`
|
||||
|
||||
Safely evaluate & parse user-defined GitBook expressions.
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"escodegen": "^2.1.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.20",
|
||||
"tsdown": "^0.15.0",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@babel/types": "^7.26.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/escodegen": "^0.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown --project tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"clean": "rm -rf ./dist"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||
}
|
||||
@@ -1,889 +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',
|
||||
'visitor.claims.hello.some',
|
||||
'visitor.claims.hello.every',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
'visitor.claims.hello.some',
|
||||
'visitor.claims.hello.every',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,178 +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,
|
||||
},
|
||||
{
|
||||
scenario: 'array method',
|
||||
condition: 'reviews.every(review => !!review.status)',
|
||||
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'array every',
|
||||
condition: 'reviews.every(review => review.status === "approved")',
|
||||
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'array map',
|
||||
condition: '[1, 2, 3].map(n => n * x)',
|
||||
inputs: { x: 2 },
|
||||
expectedResult: [2, 4, 6],
|
||||
},
|
||||
])(
|
||||
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||
({ condition, inputs, expectedResult }) => {
|
||||
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
|
||||
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(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!');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './errors';
|
||||
export * from './input-values';
|
||||
export * from './runtime';
|
||||
export * from './symbols';
|
||||
export * from './template';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +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 escodegen from 'escodegen';
|
||||
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,
|
||||
generate: escodegen.generate,
|
||||
});
|
||||
} 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,497 +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',
|
||||
'visitor.claims.hello.some',
|
||||
'visitor.claims.hello.every',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './symbols';
|
||||
export * from './symbols-table';
|
||||
export * from './types';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,322 +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).',
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'some',
|
||||
description:
|
||||
'Tests whether at least one element in the array passes the test implemented by the provided function.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
|
||||
args: [
|
||||
SymbolFunction({
|
||||
name: 'callback',
|
||||
description: 'A function that tests each element of the array.',
|
||||
args: [
|
||||
{
|
||||
...arraySymbolDef.items,
|
||||
name: 'element',
|
||||
description: 'The current element being processed in the array.',
|
||||
},
|
||||
],
|
||||
returns: SymbolBoolean({
|
||||
description:
|
||||
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
returns: SymbolBoolean({
|
||||
description:
|
||||
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'every',
|
||||
description:
|
||||
'Tests whether all elements in the array pass the test implemented by the provided function.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
|
||||
args: [
|
||||
SymbolFunction({
|
||||
name: 'callback',
|
||||
description: 'A function that tests each element of the array.',
|
||||
args: [
|
||||
{
|
||||
...arraySymbolDef.items,
|
||||
name: 'element',
|
||||
description: 'The current element being processed in the array.',
|
||||
},
|
||||
],
|
||||
returns: SymbolBoolean({
|
||||
description:
|
||||
'true if the callback function returns a truthy value for all elements in the array.',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
returns: SymbolBoolean({
|
||||
description:
|
||||
'true if the callback function returns a truthy value for all elements in the array.',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
|
||||
return symbol.type === SymbolType.Array;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]>;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": ["./tsconfig.json"],
|
||||
"exclude": ["**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
format: ['esm'],
|
||||
},
|
||||
]);
|
||||
-57
@@ -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 | ((node: any) => string);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
||||
|
||||
const body = token.slice(2); // drop "U+"
|
||||
const [startHex, endHex] = body.split('-');
|
||||
const start = Number.parseInt(startHex!, 16);
|
||||
const start = Number.parseInt(startHex, 16);
|
||||
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
||||
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
||||
@@ -92,7 +92,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
||||
|
||||
for (const [label, rangesArr] of Object.entries(parsed)) {
|
||||
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
||||
hits[label]!++;
|
||||
hits[label]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"module": "ESNext",
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -1,94 +1,5 @@
|
||||
# gitbook
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 24f601d: Small optim in resolveTryItPrefillForOperation
|
||||
- aea5eb1: Persist language choice across sections if possible
|
||||
- 1165a81: Language selector edge cases
|
||||
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
|
||||
- Updated dependencies [24f601d]
|
||||
- @gitbook/react-openapi@1.4.1
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
|
||||
- 7911350: Add language selector to site header
|
||||
- 659be55: Track insight event when embedded assistant is displayed.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
|
||||
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
|
||||
- 1edc5d6: Add "hold message" to Assistant
|
||||
- 4520728: Support bold in headings
|
||||
- 61b8507: OpenAPI: Make responses without objects clickable
|
||||
- d1fdc13: Remove ligatures from Lato font
|
||||
- a8fca0e: Fix custom assistant keyboard shortcut
|
||||
- 262a9b1: Fix embed script and assets being cached for too long
|
||||
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
|
||||
- 1839ea2: Fix content min-height with sections
|
||||
- 9201e2c: Adds vertical align to column block
|
||||
- 193d591: Use space language as source of truth for UI locale
|
||||
- f08dd29: Fix Search results are not clickable on sites without header
|
||||
- 17dd382: Add `original` background color step
|
||||
- 4f35882: Fix event ask_question not being tracked
|
||||
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
|
||||
- 6f368b5: Fix embed assistant window width on small screens
|
||||
- Updated dependencies [193d591]
|
||||
- Updated dependencies [12c9d76]
|
||||
- Updated dependencies [4927e96]
|
||||
- Updated dependencies [61b8507]
|
||||
- Updated dependencies [7fefe49]
|
||||
- Updated dependencies [360aa1c]
|
||||
- Updated dependencies [98e42cf]
|
||||
- Updated dependencies [17dd382]
|
||||
- @gitbook/colors@0.4.0
|
||||
- @gitbook/react-openapi@1.4.0
|
||||
- @gitbook/openapi-parser@3.0.2
|
||||
|
||||
## 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
|
||||
|
||||
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
|
||||
const versionId = args[0];
|
||||
|
||||
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
|
||||
const previewHostname = `${versionId?.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
|
||||
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
|
||||
|
||||
let updatedFile = file.replace(
|
||||
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.17.1",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@opennextjs/cloudflare": "^1.6.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -27,7 +27,6 @@
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"bidc": "catalog:",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"direction": "^2.0.1",
|
||||
@@ -46,7 +45,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "15.3.5",
|
||||
"next": "^15.5.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -71,7 +70,8 @@
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.3",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
@@ -104,8 +104,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||
"build": "next build",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"start": "next start",
|
||||
"build:cloudflare": "opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export default async function SiteDynamicLayout({
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
+2
-13
@@ -5,8 +5,6 @@ import {
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -16,18 +14,9 @@ export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
);
|
||||
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout context={context}>
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveLLMsFullTxt } from '@/routes/llms-full';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { page: string }> }
|
||||
) {
|
||||
const awaitedParams = await params;
|
||||
const page = Number(awaitedParams.page);
|
||||
// If page is not a number, not an integer, or less than 0, return an error
|
||||
if (Number.isNaN(page) || !Number.isInteger(page) || page < 0) {
|
||||
return new Response('Invalid page', { status: 400 });
|
||||
}
|
||||
const { context } = await getStaticSiteContext(awaitedParams);
|
||||
return serveLLMsFullTxt(context, page);
|
||||
}
|
||||
+2
-12
@@ -5,7 +5,6 @@ import {
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -15,18 +14,9 @@ export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents();
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
);
|
||||
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
|
||||
+4
-4
@@ -1,6 +1,5 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import type { CreateGitBookOptions } from '@gitbook/embed';
|
||||
import type { NextRequest } from 'next/server';
|
||||
@@ -15,6 +14,7 @@ export async function GET(
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
const maxAge = 7 * 24 * 60 * 60;
|
||||
const initOptions: CreateGitBookOptions = {
|
||||
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
||||
};
|
||||
@@ -45,13 +45,13 @@ export async function GET(
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
style.rel = 'stylesheet';
|
||||
style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
|
||||
style.href = ${JSON.stringify(getAssetURL('embed/index.css'))};
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = d.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.async = true;
|
||||
script.src = ${JSON.stringify(getAssetURL(`embed/index.js?v=${buildVersion()}`))};
|
||||
script.src = ${JSON.stringify(getAssetURL('embed/index.js'))};
|
||||
|
||||
var latestScript = d.getElementsByTagName('script')[0];
|
||||
latestScript.parentNode.insertBefore(script, latestScript);
|
||||
@@ -70,7 +70,7 @@ export async function GET(
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
'Cache-Control': `public, max-age=${maxAge}, immutable`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './useAI';
|
||||
export * from './useAIChat';
|
||||
export type { RenderAIMessageOptions } from './server-actions';
|
||||
|
||||
@@ -72,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -102,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -130,7 +130,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
@@ -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
|
||||
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
) : null}
|
||||
</summary>
|
||||
{hasResults ? (
|
||||
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||
<div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
@@ -241,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
return (
|
||||
<p>
|
||||
|
||||
@@ -39,9 +39,7 @@ export async function streamRenderAIMessage(
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
// @ts-expect-error
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
// @ts-expect-error
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
@@ -54,7 +52,6 @@ export async function streamRenderAIMessage(
|
||||
},
|
||||
},
|
||||
];
|
||||
// @ts-expect-error
|
||||
callback(message.steps[stepIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+19
-71
@@ -2,32 +2,31 @@
|
||||
|
||||
import { useAIChatState } from '@/components/AI';
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { ChatGPTIcon } from '@/components/PageActions/assets/ChatGPTIcon';
|
||||
import { ClaudeIcon } from '@/components/PageActions/assets/ClaudeIcon';
|
||||
import { MarkdownIcon } from '@/components/PageActions/assets/MarkdownIcon';
|
||||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import type { GitSyncState } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import type React from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
type PageActionType = 'button' | 'dropdown-menu-item';
|
||||
type AIActionType = 'button' | 'dropdown-menu-item';
|
||||
|
||||
/**
|
||||
* Opens our AI Docs Assistant.
|
||||
*/
|
||||
export function OpenAIAssistant(props: { assistant: Assistant; type: PageActionType }) {
|
||||
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||
const { assistant, type } = props;
|
||||
const chat = useAIChatState();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={assistant.icon}
|
||||
label={assistant.label}
|
||||
@@ -91,7 +90,7 @@ const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
|
||||
*/
|
||||
export function CopyMarkdown(props: {
|
||||
markdownPageUrl: string;
|
||||
type: PageActionType;
|
||||
type: AIActionType;
|
||||
isDefaultAction?: boolean;
|
||||
}) {
|
||||
const { markdownPageUrl, type, isDefaultAction } = props;
|
||||
@@ -131,7 +130,7 @@ export function CopyMarkdown(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
|
||||
@@ -146,12 +145,12 @@ export function CopyMarkdown(props: {
|
||||
/**
|
||||
* Redirects to the markdown version of the page.
|
||||
*/
|
||||
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: PageActionType }) {
|
||||
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
|
||||
const { markdownPageUrl, type } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={<MarkdownIcon className="size-4 fill-current" />}
|
||||
label={tString(language, 'view_page_markdown')}
|
||||
@@ -167,7 +166,7 @@ export function ViewAsMarkdown(props: { markdownPageUrl: string; type: PageActio
|
||||
export function OpenInLLM(props: {
|
||||
provider: 'chatgpt' | 'claude';
|
||||
url: string;
|
||||
type: PageActionType;
|
||||
type: AIActionType;
|
||||
}) {
|
||||
const { provider, url, type } = props;
|
||||
const language = useLanguage();
|
||||
@@ -175,7 +174,7 @@ export function OpenInLLM(props: {
|
||||
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={
|
||||
provider === 'chatgpt' ? (
|
||||
@@ -192,48 +191,11 @@ export function OpenInLLM(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function GitEditLink(props: {
|
||||
type: PageActionType;
|
||||
provider: GitSyncState['installationProvider'];
|
||||
url: string;
|
||||
}) {
|
||||
const { type, provider, url } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const providerName =
|
||||
provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : 'Git';
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
type={type}
|
||||
icon={provider === 'gitlab' ? 'gitlab' : 'github'}
|
||||
label={tString(language, 'edit_on_git', providerName)}
|
||||
shortLabel={tString(language, 'edit')}
|
||||
href={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewAsPDF(props: { url: string; type: PageActionType }) {
|
||||
const { url, type } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
type={type}
|
||||
icon="file-pdf"
|
||||
label={tString(language, 'pdf_download')}
|
||||
href={url}
|
||||
target="_self"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an action in a button (for the default action) or dropdown menu item.
|
||||
*/
|
||||
function PageActionWrapper(props: {
|
||||
type: PageActionType;
|
||||
function AIActionWrapper(props: {
|
||||
type: AIActionType;
|
||||
icon: IconName | React.ReactNode;
|
||||
label: string;
|
||||
/**
|
||||
@@ -243,22 +205,10 @@ function PageActionWrapper(props: {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
description?: string;
|
||||
href?: string;
|
||||
target?: React.HTMLAttributeAnchorTarget;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
shortLabel,
|
||||
onClick,
|
||||
href,
|
||||
target = '_blank',
|
||||
description,
|
||||
disabled,
|
||||
loading,
|
||||
} = props;
|
||||
const { type, icon, label, shortLabel, onClick, href, description, disabled, loading } = props;
|
||||
|
||||
if (type === 'button') {
|
||||
return (
|
||||
@@ -272,7 +222,7 @@ function PageActionWrapper(props: {
|
||||
className="bg-tint-base text-sm"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? target : undefined}
|
||||
target={href ? '_blank' : undefined}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{shortLabel}
|
||||
@@ -284,7 +234,7 @@ function PageActionWrapper(props: {
|
||||
<DropdownMenuItem
|
||||
className="flex items-stretch gap-2.5 p-2"
|
||||
href={href}
|
||||
target={target}
|
||||
target="_blank"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
@@ -305,11 +255,9 @@ function PageActionWrapper(props: {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1 text-tint-strong">
|
||||
<span className="flex items-center gap-2 text-tint-strong">
|
||||
<span className="truncate font-medium text-sm">{label}</span>
|
||||
{href && target === '_blank' ? (
|
||||
<Icon icon="arrow-up-right" className="size-3 shrink-0 text-tint-subtle" />
|
||||
) : null}
|
||||
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
|
||||
</span>
|
||||
{description && <span className="text-tint text-xs">{description}</span>}
|
||||
</div>
|
||||
+23
-57
@@ -2,50 +2,38 @@
|
||||
|
||||
import {
|
||||
CopyMarkdown,
|
||||
GitEditLink,
|
||||
OpenAIAssistant,
|
||||
OpenInLLM,
|
||||
ViewAsMarkdown,
|
||||
ViewAsPDF,
|
||||
} from '@/components/PageActions/PageActions';
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
|
||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRef } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
|
||||
interface PageActionsDropdownProps {
|
||||
interface AIActionsDropdownProps {
|
||||
markdownPageUrl: string;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
editOnGit?: {
|
||||
provider: GitSyncState['installationProvider'];
|
||||
url: string;
|
||||
};
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||
*/
|
||||
export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
const language = useLanguage();
|
||||
|
||||
const defaultActions = [assistants.length > 0, props.actions.markdown, props.editOnGit].filter(
|
||||
Boolean
|
||||
);
|
||||
const dropdownActions = [props.actions.externalAI, props.pdfUrl].filter(Boolean);
|
||||
|
||||
return [...defaultActions, ...dropdownActions].length > 0 ? (
|
||||
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
||||
<ButtonGroup ref={ref} className={props.className}>
|
||||
{defaultActions.length > 0 ? <DefaultAction {...props} /> : null}
|
||||
{dropdownActions.length > 0 ? (
|
||||
<DefaultAction {...props} />
|
||||
{props.actions.markdown || props.actions.externalAI ? (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-60 max-w-max"
|
||||
@@ -57,18 +45,15 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||
/>
|
||||
}
|
||||
label={tString(
|
||||
language,
|
||||
defaultActions.some(Boolean) ? 'more' : 'actions'
|
||||
)}
|
||||
iconOnly={defaultActions.some(Boolean)}
|
||||
label={tString(language, 'more')}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="bg-tint-base text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PageActionsDropdownMenuContent {...props} />
|
||||
<AIActionsDropdownMenuContent {...props} />
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
@@ -78,7 +63,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
/**
|
||||
* The content of the dropdown menu.
|
||||
*/
|
||||
function PageActionsDropdownMenuContent(props: PageActionsDropdownProps) {
|
||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
@@ -113,22 +98,6 @@ function PageActionsDropdownMenuContent(props: PageActionsDropdownProps) {
|
||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{props.editOnGit || props.pdfUrl ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
{props.editOnGit ? (
|
||||
<GitEditLink
|
||||
type="dropdown-menu-item"
|
||||
provider={props.editOnGit.provider}
|
||||
url={props.editOnGit.url}
|
||||
/>
|
||||
) : null}
|
||||
{props.pdfUrl ? (
|
||||
<ViewAsPDF url={props.pdfUrl} type="dropdown-menu-item" />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -136,34 +105,31 @@ function PageActionsDropdownMenuContent(props: PageActionsDropdownProps) {
|
||||
/**
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: PageActionsDropdownProps) {
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
const assistant = assistants[0];
|
||||
if (assistant) {
|
||||
return <OpenAIAssistant assistant={assistant} type="button" />;
|
||||
}
|
||||
|
||||
if (props.editOnGit) {
|
||||
return (
|
||||
<GitEditLink
|
||||
type="button"
|
||||
provider={props.editOnGit.provider}
|
||||
url={props.editOnGit.url}
|
||||
/>
|
||||
);
|
||||
if (assistants.length) {
|
||||
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!assistant}
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.externalAI) {
|
||||
return (
|
||||
<>
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,19 +12,10 @@ import {
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
} from '../AI';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameBody,
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameSubtitle,
|
||||
EmbeddableFrameTitle,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
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';
|
||||
@@ -47,6 +39,17 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
}
|
||||
}, [chat.opened]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chatController.open();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
@@ -55,16 +58,6 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Track the view of the AI chat
|
||||
const trackEvent = useTrackEvent();
|
||||
React.useEffect(() => {
|
||||
if (chat.opened) {
|
||||
trackEvent({
|
||||
type: 'ask_view',
|
||||
});
|
||||
}
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
if (!chat.opened) {
|
||||
return null;
|
||||
}
|
||||
@@ -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="gutter-stable 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>
|
||||
) : (
|
||||
|
||||
@@ -12,9 +12,8 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
export function AIChatButton(props: {
|
||||
assistant: Assistant;
|
||||
showLabel?: boolean;
|
||||
withShortcut?: boolean;
|
||||
}) {
|
||||
const { assistant, showLabel = true, withShortcut = true } = props;
|
||||
const { assistant, showLabel = true } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
@@ -28,12 +27,7 @@ export function AIChatButton(props: {
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
{withShortcut ? (
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'i']}
|
||||
className="border-tint-11 text-tint-1"
|
||||
/>
|
||||
) : null}
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||
</div>
|
||||
}
|
||||
onClick={() => assistant.open()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type React from 'react';
|
||||
@@ -34,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'
|
||||
@@ -48,9 +45,17 @@ export function AIChatMessages(props: {
|
||||
{message.content ? message.content : null}
|
||||
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||
style={{
|
||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -80,55 +85,3 @@ export function AIChatMessages(props: {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldMessage() {
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
||||
{tString(language, 'ai_chat_hold_message_1')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${5000 + index * 200}ms`,
|
||||
}}
|
||||
>
|
||||
{word}{' '}
|
||||
</span>
|
||||
))}
|
||||
{tString(language, 'ai_chat_hold_message_2')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${10000 + index * 200}ms`,
|
||||
}}
|
||||
>
|
||||
{word}{' '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||
style={{
|
||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AdaptiveVisitorClaimsData = {
|
||||
visitor: {
|
||||
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory cache of visitor claim readers keyed by contextId.
|
||||
*/
|
||||
const adaptiveVisitorReaderCache = new Map<
|
||||
string,
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
|
||||
>();
|
||||
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
let result: T | null | undefined;
|
||||
|
||||
const suspender = (async () => {
|
||||
try {
|
||||
result = await promise;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
read() {
|
||||
if (result === undefined) {
|
||||
throw suspender;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an adaptive visitor claims cached reader for a given endpoint URL and contextId.
|
||||
*/
|
||||
function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
let reader = adaptiveVisitorReaderCache.get(contextId);
|
||||
if (!reader) {
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return await res.json<AdaptiveVisitorClaimsData>();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
reader = createResourceReader(promise);
|
||||
adaptiveVisitorReaderCache.set(contextId, reader);
|
||||
}
|
||||
return reader;
|
||||
}
|
||||
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
|
||||
|
||||
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
|
||||
|
||||
/**
|
||||
* Provide context to adapt site based on visitor claims.
|
||||
*/
|
||||
export function AdaptiveVisitorContextProvider(
|
||||
props: React.PropsWithChildren<{
|
||||
visitorClaimsURL: string;
|
||||
contextId: GitBookSiteContext['contextId'] | undefined;
|
||||
}>
|
||||
) {
|
||||
const { visitorClaimsURL, contextId, children } = props;
|
||||
|
||||
const getAdaptiveVisitorClaims = React.useCallback(() => {
|
||||
if (!contextId) {
|
||||
return null;
|
||||
}
|
||||
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
|
||||
}, [visitorClaimsURL, contextId]);
|
||||
|
||||
return (
|
||||
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
|
||||
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
|
||||
{children}
|
||||
</OpenAPIPrefillContextProvider>
|
||||
</AdaptiveVisitorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns a suspensable getter for adaptive visitor claims data.
|
||||
*/
|
||||
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
|
||||
return useContext(AdaptiveVisitorContext);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './AdaptiveVisitorContextProvider';
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
|
||||
@@ -46,21 +47,19 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
const mode = (await headers()).get('x-gitbook-mode');
|
||||
|
||||
if (mode === 'multi-id') {
|
||||
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
return <ChangeRequestToolbar context={context} />;
|
||||
}
|
||||
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<RevisionToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
return <RevisionToolbar context={context} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user