mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229f2ba9d5 | |||
| 6da36558f6 | |||
| ca71c1fad6 | |||
| 262afa357b | |||
| 7375d3c597 | |||
| 3548fa6dff | |||
| cb73040e0f | |||
| 872d36b64f | |||
| e1ff17e655 | |||
| f3010bd28a | |||
| 8ff1e3b619 | |||
| d7596bf454 | |||
| aea5eb10ae | |||
| 1165a81cf5 | |||
| 61d1a0192e | |||
| f9a2977621 | |||
| 24f601d594 | |||
| 7b34537bbe | |||
| 145f385fdb | |||
| 360aa1c6d6 | |||
| ccc0975240 | |||
| 791135040a | |||
| 12c9d765ff | |||
| 45207288a1 | |||
| 177afa5828 | |||
| d51b79edbb | |||
| a2720ac49d | |||
| 5f6853d812 | |||
| 17dd382dc3 | |||
| 98e42cfe79 | |||
| c890e01004 | |||
| 659be551bb | |||
| 78a632b7fe | |||
| 4927e964b7 | |||
| 262a9b198b | |||
| 4f3588240c | |||
| abbae3ec4d | |||
| 1b8609ac60 | |||
| 193d591e9f | |||
| 1edc5d622a | |||
| 61b8507996 | |||
| 6f368b5cf3 | |||
| 9201e2cf52 | |||
| 2e0d706d43 | |||
| 7fefe4919c | |||
| 729921f338 | |||
| 1839ea2678 | |||
| f08dd29364 | |||
| ca2dfa3065 | |||
| 10ded437f0 | |||
| d1fdc13441 | |||
| a8fca0e033 | |||
| 9251b33959 | |||
| 81a6bd756a | |||
| ada195d329 | |||
| 1211ebea9b | |||
| 2e6e28eb54 | |||
| 854c448bad | |||
| 64a0b0f169 | |||
| 13ff22b6a8 | |||
| 8927e8fbbb | |||
| 25e2b40d47 | |||
| a60af8ff9c | |||
| 27b328cfd7 | |||
| fa6ef0c01c | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 |
@@ -1,37 +0,0 @@
|
||||
name: 'Setup Playwright'
|
||||
description: 'Install Playwright and dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Run npm ci and get Playwright version
|
||||
- name: 🏗 Prepare Playwright env
|
||||
shell: bash
|
||||
run: |
|
||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||
|
||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||
- name: 🧰 Cache Playwright browser binaries
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: '~/.cache/ms-playwright'
|
||||
key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}'
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
# Install browser binaries & OS dependencies if cache missed
|
||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install --with-deps chromium
|
||||
|
||||
# Install only the OS dependencies if cache hit
|
||||
- name: 🏗 Install Playwright OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install-deps
|
||||
@@ -110,8 +110,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
@@ -131,8 +131,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
@@ -152,8 +152,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
@@ -173,8 +173,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
@@ -182,9 +182,9 @@ jobs:
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
pagespeed-testing-v2:
|
||||
browserless-testing-v2-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing v1
|
||||
name: Browserless Testing v2 (Vercel)
|
||||
needs: deploy-v2-vercel
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -195,8 +195,26 @@ jobs:
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
||||
- name: Run tests
|
||||
run: cd ./packages/gitbook && bun e2e-browserless
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
# needs: deploy-v2-cloudflare
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# - name: Setup Bun
|
||||
# uses: ./.github/composite/setup-bun
|
||||
# - name: Install dependencies
|
||||
# run: bun install --frozen-lockfile
|
||||
# env:
|
||||
# PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
# - name: Run tests
|
||||
# run: cd ./packages/gitbook && bun e2e-browserless
|
||||
# env:
|
||||
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
|
||||
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
@@ -18,6 +18,7 @@
|
||||
"**/.vercel/**/*",
|
||||
"**/.cache/**/*",
|
||||
"**/.wrangler/**/*",
|
||||
"packages/embed/standalone/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.130.0"
|
||||
"@gitbook/api": "^0.141.0",
|
||||
"bidc": "^0.0.2"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,13 @@
|
||||
# @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
|
||||
@@ -0,0 +1,3 @@
|
||||
# `@gitbook/browser-types`
|
||||
|
||||
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@gitbook/browser-types",
|
||||
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||
|
||||
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||
|
||||
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
* The input is provided by the AI assistant following the input schema of the tool.
|
||||
*/
|
||||
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||
};
|
||||
|
||||
export type GitBookAssistant = {
|
||||
/**
|
||||
* Name of the assistant displayed in the UI.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
* Any FontAwesome icon name is supported.
|
||||
* @example 'sparkle'
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* Callback when the assistant is opened.
|
||||
*/
|
||||
open: (query?: string) => void;
|
||||
|
||||
/**
|
||||
* Whether to display the triggers for this assistant in the UI.
|
||||
* @default true
|
||||
*/
|
||||
ui?: boolean;
|
||||
};
|
||||
|
||||
export type GitBookGlobal = {
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
addEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Remove an event listener.
|
||||
*/
|
||||
removeEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Register a custom tool to be exposed to the AI assistant.
|
||||
*/
|
||||
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||
|
||||
/**
|
||||
* Register a custom assistant to be available on the site.
|
||||
*/
|
||||
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Global `window.GitBook` object accessible by integrations.
|
||||
*/
|
||||
GitBook?: GitBookGlobal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -121,17 +121,6 @@ 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.
|
||||
*/
|
||||
@@ -182,7 +171,7 @@ export function getComputedContentSourceCacheTags(
|
||||
break;
|
||||
default:
|
||||
// Do not throw for unknown dependency types
|
||||
// as it might mean we are lacking behind the API version
|
||||
// as it might mean we are lagging behind the API version
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -198,9 +187,8 @@ 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,6 +5,7 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @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.3.3",
|
||||
"version": "0.4.0",
|
||||
"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]; // Reference white (D65)
|
||||
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
|
||||
|
||||
export enum ColorCategory {
|
||||
backgrounds = 'backgrounds',
|
||||
@@ -19,7 +19,7 @@ export enum ColorCategory {
|
||||
}
|
||||
|
||||
type ColorSubScale = {
|
||||
[key: string]: number;
|
||||
[key: string]: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
|
||||
solid: 9,
|
||||
/** Hovered solid backgrounds */
|
||||
'solid-hover': 10,
|
||||
/** Original color */
|
||||
original: 'original',
|
||||
},
|
||||
[ColorCategory.text]: {
|
||||
/** Very low-contrast text
|
||||
@@ -211,8 +213,8 @@ export function colorScale(
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < mapping.length; index++) {
|
||||
const targetL =
|
||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||
const step = mapping[index]!;
|
||||
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||
|
||||
if (
|
||||
index === 8 &&
|
||||
@@ -295,7 +297,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);
|
||||
@@ -392,14 +394,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!),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
|
||||
return contrast < 7.5 ? 0 : contrast;
|
||||
}
|
||||
|
||||
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
|
||||
export function colorContrast(
|
||||
background: string,
|
||||
foreground: string[] = [LIGHT_BASE, DARK_BASE]
|
||||
): string {
|
||||
const bg = hexToRgbArray(background);
|
||||
|
||||
const best: { color?: RGBColor; contrast: number } = {
|
||||
@@ -436,5 +441,5 @@ export function colorContrast(background: string, foreground: string[] = [LIGHT_
|
||||
}
|
||||
}
|
||||
|
||||
return best.color ? rgbArrayToHex(best.color) : foreground[0];
|
||||
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
standalone/
|
||||
@@ -0,0 +1,19 @@
|
||||
# @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
|
||||
@@ -0,0 +1,49 @@
|
||||
# `@gitbook/embed`
|
||||
|
||||
Embed the GitBook Docs Assistant in your product or website.
|
||||
|
||||
# Usage
|
||||
|
||||
## As a script from your docs site
|
||||
|
||||
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||
|
||||
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||
|
||||
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||
<script>
|
||||
window.GitBook('show');
|
||||
</script>
|
||||
```
|
||||
|
||||
## As a package from NPM
|
||||
|
||||
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||
|
||||
```tsx
|
||||
import { createGitBook } from '@gitbook/embed';
|
||||
|
||||
const gitbook = createGitBook({
|
||||
siteURL: 'https://docs.company.com'
|
||||
});
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = gitbook.getFrameURL();
|
||||
|
||||
const frame = gitbook.createFrame(iframe);
|
||||
```
|
||||
|
||||
## As React components
|
||||
|
||||
After installing the NPM package, you can import prebuilt React components:
|
||||
|
||||
```tsx
|
||||
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||
|
||||
<GitBookProvider siteURL="https://docs.company.com">
|
||||
<GitBookAssistantFrame />
|
||||
</GitBookProvider>
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@gitbook/embed",
|
||||
"description": "Embeddable components for GitBook",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js",
|
||||
"standalone": "./dist/standalone/index.js",
|
||||
"react": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
|
||||
|
||||
export type CreateGitBookOptions = {
|
||||
/**
|
||||
* URL of the GitBook site to embed.
|
||||
*/
|
||||
siteURL: string;
|
||||
};
|
||||
|
||||
export type GetFrameURLOptions = {
|
||||
/**
|
||||
* Authentication to use for the frame.
|
||||
*/
|
||||
visitor?: {
|
||||
/**
|
||||
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* Unsigned claims to pass to the frame.
|
||||
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
|
||||
*/
|
||||
unsignedClaims?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GitBookClient = {
|
||||
/**
|
||||
* Get the URL for a GitBook frame.
|
||||
*/
|
||||
getFrameURL: (options: GetFrameURLOptions) => string;
|
||||
/**
|
||||
* Create a new GitBook frame.
|
||||
*/
|
||||
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
|
||||
};
|
||||
|
||||
export function createGitBook(options: CreateGitBookOptions) {
|
||||
const client: GitBookClient = {
|
||||
getFrameURL: (frameOptions) => {
|
||||
const url = new URL(options.siteURL);
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
}
|
||||
|
||||
if (frameOptions.visitor?.unsignedClaims) {
|
||||
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
|
||||
url.searchParams.set(`visitor.${key}`, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
createFrame: (iframe) => createGitBookFrame(iframe),
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createChannel } from 'bidc';
|
||||
import type {
|
||||
FrameToParentMessage,
|
||||
GitBookEmbeddableConfiguration,
|
||||
ParentToFrameMessage,
|
||||
} from './protocol';
|
||||
|
||||
export type GitBookFrameClient = {
|
||||
/**
|
||||
* Navigate to a page by its path.
|
||||
*/
|
||||
navigateToPage: (path: string) => void;
|
||||
|
||||
/**
|
||||
* Navigate to the assistant.
|
||||
*/
|
||||
navigateToAssistant: () => void;
|
||||
|
||||
/**
|
||||
* Post a message to the chat.
|
||||
*/
|
||||
postUserMessage: (message: string) => void;
|
||||
|
||||
/**
|
||||
* Clear the chat.
|
||||
*/
|
||||
clearChat: () => void;
|
||||
|
||||
/**
|
||||
* Set the placeholder settings.
|
||||
*/
|
||||
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
|
||||
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
on: (event: string, listener: (...args: any[]) => void) => () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a client to communicate with the GitBook Assistant frame.
|
||||
*/
|
||||
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Iframe must have a content window');
|
||||
}
|
||||
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) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
},
|
||||
navigateToAssistant: () => {
|
||||
sendToFrame({ type: 'navigateToAssistant' });
|
||||
},
|
||||
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
|
||||
configure: (settings) => {
|
||||
Object.assign(configuration, settings);
|
||||
sendToFrame({ type: 'configure', settings: configuration });
|
||||
},
|
||||
clearChat: () => sendToFrame({ type: 'clearChat' }),
|
||||
on: (event, listener) => {
|
||||
const listeners = events.get(event) || [];
|
||||
listeners.push(listener);
|
||||
events.set(event, listeners);
|
||||
return () => {
|
||||
events.set(
|
||||
event,
|
||||
listeners.filter((l) => l !== listener)
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './createGitBook';
|
||||
export * from './createGitBookFrame';
|
||||
export * from './protocol';
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Custom tool definition to be passed to the AI assistant.
|
||||
*/
|
||||
export type GitBookToolDefinition = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
* The input is provided by the AI assistant following the input schema of the tool.
|
||||
*/
|
||||
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom button definition to be passed to the embeddable GitBook.
|
||||
*/
|
||||
export type GitBookEmbeddableButtonDefinition = {
|
||||
/**
|
||||
* Icon to be displayed in the button.
|
||||
*/
|
||||
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: string[];
|
||||
|
||||
/** Tools to be provided to the assistant. */
|
||||
tools: GitBookToolDefinition[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages sent from the parent to the frame.
|
||||
*/
|
||||
export type ParentToFrameMessage =
|
||||
| {
|
||||
type: 'postUserMessage';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'clearChat';
|
||||
}
|
||||
| {
|
||||
type: 'configure';
|
||||
settings: GitBookEmbeddableConfiguration;
|
||||
}
|
||||
| {
|
||||
type: 'navigateToPage';
|
||||
pagePath: string;
|
||||
}
|
||||
| {
|
||||
type: 'navigateToAssistant';
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages sent from the frame to the parent.
|
||||
*/
|
||||
export type FrameToParentMessage = {
|
||||
type: 'close';
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './client';
|
||||
@@ -0,0 +1,51 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||
import { GitBookContext } from './context';
|
||||
|
||||
/**
|
||||
* Provider for the GitBook client.
|
||||
*/
|
||||
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
|
||||
const { siteURL, children } = props;
|
||||
|
||||
const options = React.useMemo(
|
||||
() => ({
|
||||
siteURL,
|
||||
}),
|
||||
[siteURL]
|
||||
);
|
||||
|
||||
const client = React.useMemo(() => createGitBook(options), [options]);
|
||||
|
||||
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the GitBook client.
|
||||
*/
|
||||
export function useGitBook() {
|
||||
const context = React.useContext(GitBookContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('This component must be used within a <GitBookProvider />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { GitBookClient } from '../client';
|
||||
|
||||
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './GitBookProvider';
|
||||
export * from './GitBookFrame';
|
||||
@@ -0,0 +1,178 @@
|
||||
import './style.css';
|
||||
|
||||
import {
|
||||
type CreateGitBookOptions,
|
||||
type GetFrameURLOptions,
|
||||
type GitBookClient,
|
||||
type GitBookEmbeddableConfiguration,
|
||||
type GitBookFrameClient,
|
||||
createGitBook,
|
||||
} from '../client';
|
||||
|
||||
export type GitBook = () => void;
|
||||
|
||||
type StandaloneCalls =
|
||||
// Initialize the widget
|
||||
| ['init', CreateGitBookOptions, GetFrameURLOptions]
|
||||
// Unload the widget
|
||||
| ['unload']
|
||||
// Show the widget
|
||||
| ['show']
|
||||
// Hide the widget
|
||||
| ['hide']
|
||||
// Open the window
|
||||
| ['open']
|
||||
// Close the window
|
||||
| ['close']
|
||||
// Toggle the window
|
||||
| ['toggle']
|
||||
// Post a user message
|
||||
| ['postUserMessage', string]
|
||||
// Clear the chat
|
||||
| ['clearChat']
|
||||
// Configure the embed
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||
// Navigate to a page
|
||||
| ['navigateToPage', string]
|
||||
// Navigate to the assistant
|
||||
| ['navigateToAssistant'];
|
||||
|
||||
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||
q?: StandaloneCalls[];
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
widgetButton.id = 'gitbook-widget-button';
|
||||
widgetButton.addEventListener('click', () => {
|
||||
GitBook('toggle');
|
||||
});
|
||||
widgetButton.innerHTML = `
|
||||
<span id="gitbook-widget-button-icon"></span>
|
||||
<span id="gitbook-widget-button-label">Ask</span>
|
||||
`;
|
||||
|
||||
const widgetWindow = document.createElement('div');
|
||||
widgetWindow.id = 'gitbook-widget-window';
|
||||
widgetWindow.classList.add('hidden');
|
||||
|
||||
document.body.appendChild(widgetButton);
|
||||
document.body.appendChild(widgetWindow);
|
||||
|
||||
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) {
|
||||
throw new Error(
|
||||
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
|
||||
);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getIframe() {
|
||||
if (!widgetIframe || !_frame) {
|
||||
const client = getClient();
|
||||
|
||||
widgetIframe?.remove();
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.src = client.getFrameURL({
|
||||
...frameOptions,
|
||||
});
|
||||
widgetWindow.appendChild(widgetIframe);
|
||||
|
||||
_frame = client.createFrame(widgetIframe);
|
||||
}
|
||||
return { iframe: widgetIframe, frame: _frame };
|
||||
}
|
||||
|
||||
const GitBook = (...args: StandaloneCalls) => {
|
||||
switch (args[0]) {
|
||||
case 'init':
|
||||
if (_client) {
|
||||
throw new Error(
|
||||
'GitBook client already initialized. Call GitBook("unload") first.'
|
||||
);
|
||||
}
|
||||
_client = createGitBook(args[1]);
|
||||
frameOptions = args[2];
|
||||
break;
|
||||
case 'unload':
|
||||
_client = undefined;
|
||||
_frame = undefined;
|
||||
widgetIframe?.remove();
|
||||
widgetWindow.classList.add('hidden');
|
||||
break;
|
||||
case 'show':
|
||||
widgetButton.classList.remove('hidden');
|
||||
break;
|
||||
case 'hide':
|
||||
widgetButton.classList.add('hidden');
|
||||
break;
|
||||
case 'open':
|
||||
widgetWindow.classList.remove('hidden');
|
||||
widgetButton.classList.add('open');
|
||||
getIframe();
|
||||
break;
|
||||
case 'toggle':
|
||||
widgetWindow.classList.toggle('hidden');
|
||||
widgetButton.classList.toggle('open');
|
||||
getIframe();
|
||||
break;
|
||||
case 'close':
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
break;
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
case 'clearChat':
|
||||
getIframe().frame.clearChat();
|
||||
break;
|
||||
case 'navigateToPage':
|
||||
getIframe().frame.navigateToPage(args[1]);
|
||||
break;
|
||||
case 'navigateToAssistant':
|
||||
getIframe().frame.navigateToAssistant();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
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', {});
|
||||
@@ -0,0 +1,171 @@
|
||||
:root {
|
||||
--gitbook-widget-top: 1rem;
|
||||
--gitbook-widget-bottom: 1rem;
|
||||
--gitbook-widget-right: 1rem;
|
||||
--gitbook-widget-left: 1rem;
|
||||
|
||||
--gitbook-widget-button-height: 46px;
|
||||
|
||||
--gitbook-widget-radius: .5rem;
|
||||
--gitbook-widget-text-size: 1rem;
|
||||
--gitbook-widget-text-color: #656973;
|
||||
--gitbook-widget-border-color: #e5e5e5;
|
||||
|
||||
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||
--gitbook-widget-background-solid: #FFFFFF;
|
||||
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||
|
||||
--gitbook-widget-icon-size: 1.25rem;
|
||||
|
||||
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||
--gitbook-widget-window-height: 40rem; /* 640px */
|
||||
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
|
||||
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
|
||||
|
||||
--gitbook-widget-transition-duration-fast: 0.2s;
|
||||
--gitbook-widget-transition-duration-slow: 0.5s;
|
||||
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
#gitbook-widget-button {
|
||||
position: fixed;
|
||||
bottom: var(--gitbook-widget-bottom);
|
||||
right: var(--gitbook-widget-right);
|
||||
height: var(--gitbook-widget-button-height);
|
||||
z-index: 9999;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: .5rem;
|
||||
padding: .75rem 1rem;
|
||||
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
background-color: var(--gitbook-widget-background-translucent);
|
||||
backdrop-filter: blur(16px);
|
||||
|
||||
font-size: var(--gitbook-widget-text-size);
|
||||
color: var(--gitbook-widget-text-color);
|
||||
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
|
||||
|
||||
cursor:pointer;
|
||||
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
|
||||
}
|
||||
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
background-color: var(--gitbook-widget-background-translucent-hover);
|
||||
}
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||
}
|
||||
#gitbook-widget-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
#gitbook-widget-button {
|
||||
background-color: var(--gitbook-widget-background-solid);
|
||||
}
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
background-color: var(--gitbook-widget-background-solid-hover);
|
||||
}
|
||||
}
|
||||
|
||||
#gitbook-widget-button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#gitbook-widget-button.open {
|
||||
padding: .75rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Button: Icon */
|
||||
#gitbook-widget-button-icon {
|
||||
width: var(--gitbook-widget-icon-size);
|
||||
height: var(--gitbook-widget-icon-size);
|
||||
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||
}
|
||||
|
||||
/* Button: Label */
|
||||
#gitbook-widget-button.open #gitbook-widget-button-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Window */
|
||||
#gitbook-widget-window {
|
||||
position: fixed;
|
||||
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))));
|
||||
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);
|
||||
border-radius: var(--gitbook-widget-radius);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
transition-property: transform, opacity, display;
|
||||
transition-duration: var(--gitbook-widget-transition-duration-slow);
|
||||
transition-timing-function: var(--gitbook-widget-easing-bounce);
|
||||
transform-origin: bottom right;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#gitbook-widget-window {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
|
||||
bottom: var(--gitbook-widget-bottom);
|
||||
}
|
||||
|
||||
#gitbook-widget-window.hidden {
|
||||
transition-property: transform, opacity, display;
|
||||
transition-duration: var(--gitbook-widget-transition-duration-fast);
|
||||
transition-timing-function: var(--gitbook-widget-easing);
|
||||
transition-behavior: allow-discrete;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
#gitbook-widget-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@keyframes gitbook-widget-present {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,29 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6da3655: Fix exports in gitbook/expr package.json
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3548fa6: Fix eval-estree-expr named import
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# `@gitbook/expr`
|
||||
|
||||
Safely evaluate & parse user-defined GitBook expressions.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './errors';
|
||||
export * from './input-values';
|
||||
export * from './runtime';
|
||||
export * from './symbols';
|
||||
export * from './template';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,77 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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 evalESTreeExpr from 'eval-estree-expression';
|
||||
const { evaluate } = evalESTreeExpr;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './symbols';
|
||||
export * from './symbols-table';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,350 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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]>;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": ["./tsconfig.json"],
|
||||
"exclude": ["**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
format: ['esm'],
|
||||
},
|
||||
]);
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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,6 +4,7 @@
|
||||
"module": "ESNext",
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -1,5 +1,113 @@
|
||||
# gitbook
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [262afa3]
|
||||
- @gitbook/icons@0.3.1
|
||||
- @gitbook/react-openapi@1.4.3
|
||||
- @gitbook/react-contentkit@0.7.5
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @gitbook/react-openapi@1.4.2
|
||||
|
||||
## 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
|
||||
|
||||
@@ -54,9 +54,9 @@ const searchTestCases: Test[] = [
|
||||
await searchInput.fill('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThan(2);
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThan(2);
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -97,53 +97,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: Search - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||
await expect(aiSearchResult).toBeVisible();
|
||||
await aiSearchResult.click();
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: Search - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&q=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Search - AI Mode: Search - Complete flow
|
||||
// - Search - AI Mode: Search - URL query (Initial)
|
||||
{
|
||||
name: 'Search - AI Mode: Search - URL query (Results)',
|
||||
url: `${getCustomizationURL({
|
||||
@@ -158,37 +114,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Search - URL query (Ask initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Search - URL query (Ask results)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&ask=What+is+GitBook%3F`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Ask - AI Mode: Search - URL query (Ask initial)
|
||||
// - Ask - AI Mode: Search - URL query (Ask results)
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
@@ -202,11 +130,12 @@ const searchTestCases: Test[] = [
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
||||
// await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
// const recommendedQuestions = await page
|
||||
// .getByTestId('search-recommended-question')
|
||||
// .all();
|
||||
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
@@ -225,7 +154,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.keyboard.press('ControlOrMeta+J');
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
|
||||
@@ -336,6 +336,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
pageActions: {
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
mcp: true,
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"dev": {
|
||||
"vars": {
|
||||
"STAGE": "dev",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -7,12 +7,37 @@ export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js
|
||||
|
||||
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||
|
||||
//Format used by the tail logger
|
||||
const formatLog = (requestDuration, responseCode, responseCache, responseRoute) =>
|
||||
`%${JSON.stringify({ requestDuration, responseCode, responseCache, responseRoute })}%[main] Response Done`;
|
||||
|
||||
const getResolvedRoute = (reqOrResp, defaultValue) => {
|
||||
try {
|
||||
const resolvedRoutes = JSON.parse(
|
||||
reqOrResp.headers.get('x-opennext-resolved-routes') ?? ''
|
||||
)[0].route;
|
||||
return resolvedRoutes ?? defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export default class extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||
const startTime = Date.now();
|
||||
// - `Request`s are handled by the Next server
|
||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||
if (reqOrResp instanceof Response) {
|
||||
const duration = Date.now() - startTime;
|
||||
const logMessage = formatLog(
|
||||
duration,
|
||||
reqOrResp.status,
|
||||
reqOrResp.headers.get('x-opennext-cache') ?? 'MISS',
|
||||
getResolvedRoute(reqOrResp, 'middleware')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.log(logMessage);
|
||||
return reqOrResp;
|
||||
}
|
||||
|
||||
@@ -22,17 +47,31 @@ export default class extends WorkerEntrypoint {
|
||||
'Cloudflare-Workers-Version-Overrides',
|
||||
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
||||
);
|
||||
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||
const response = await this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
});
|
||||
const formatedLog = formatLog(
|
||||
Date.now() - startTime,
|
||||
response.status,
|
||||
`SERVER-${response.headers.get('x-nextjs-cache') ?? 'MISS'}`,
|
||||
getResolvedRoute(reqOrResp, 'unresolved')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.log(formatedLog);
|
||||
|
||||
return response;
|
||||
}
|
||||
// If we are in preview mode, we need to send the request to the preview URL
|
||||
const modifiedUrl = new URL(reqOrResp.url);
|
||||
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||
return fetch(nextRequest, {
|
||||
// We never want to follow the redirects here.
|
||||
// Redirects are supposed to happen from the client.
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
// When deployed locally, we don't have access to the tag cache here,
|
||||
// we should just bypass the cache to go to the server directly
|
||||
"SHOULD_BYPASS_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -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"/,
|
||||
|
||||
@@ -48,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404(result);
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.15.0",
|
||||
"version": "0.18.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
@@ -13,9 +14,10 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.6.2",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
@@ -25,6 +27,7 @@
|
||||
"@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",
|
||||
@@ -43,7 +46,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^15.3.2",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -68,7 +71,10 @@
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.3",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"zod": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
@@ -81,7 +87,6 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/psi": "^4.1.6",
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/rison": "^0.0.9",
|
||||
@@ -89,7 +94,6 @@
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
@@ -97,19 +101,20 @@
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math && wrangler types",
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||
"generate": "./scripts/generate.sh",
|
||||
"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",
|
||||
"start": "next start",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 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",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -10,9 +10,15 @@ export default defineConfig({
|
||||
['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }],
|
||||
],
|
||||
projects: [
|
||||
// To speed up CI, we only test on Chrome.
|
||||
// https://github.com/microsoft/playwright/issues/14434
|
||||
// https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
},
|
||||
},
|
||||
],
|
||||
use: {
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
rm -rf ./.next
|
||||
rm -rf ./public/~gitbook/static/icons
|
||||
rm -rf ./public/~gitbook/static/math
|
||||
rm -rf ./public/~gitbook/static/embed
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Copy the assets
|
||||
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
||||
gitbook-math ./public/~gitbook/static/math
|
||||
cp -r ../embed/standalone/ ./public/~gitbook/static/embed
|
||||
|
||||
# Generate the types
|
||||
wrangler types
|
||||
+1
-1
@@ -22,7 +22,7 @@ export default async function SiteDynamicLayout({
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import {
|
||||
EmbeddableRootLayout,
|
||||
generateEmbeddableMetadata,
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableViewport({ context });
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableMetadata({ context });
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
<CustomizationRootLayout context={context}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* This route serves a quick demo to test the script.js script.
|
||||
* It is not used in production.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
|
||||
return new Response(
|
||||
`
|
||||
<html>
|
||||
<head></head>
|
||||
<body></body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import {
|
||||
EmbeddableRootLayout,
|
||||
generateEmbeddableMetadata,
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableViewport({ context });
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableMetadata({ context });
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
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';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* This route is used to serve the assistant.js script.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
const initOptions: CreateGitBookOptions = {
|
||||
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
||||
};
|
||||
|
||||
return new Response(
|
||||
`
|
||||
(function () {
|
||||
const w = window;
|
||||
const gb = w.GitBook;
|
||||
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||
|
||||
if (typeof gb === "function") {
|
||||
gb('init', initOptions);
|
||||
} else {
|
||||
var d = document;
|
||||
|
||||
var g = function () {
|
||||
g.c(arguments);
|
||||
};
|
||||
g.q = [];
|
||||
g.c = function (args) {
|
||||
g.q.push(args);
|
||||
};
|
||||
w.GitBook = g;
|
||||
|
||||
g('init', initOptions);
|
||||
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
style.rel = 'stylesheet';
|
||||
style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
|
||||
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()}`))};
|
||||
|
||||
var latestScript = d.getElementsByTagName('script')[0];
|
||||
latestScript.parentNode.insertBefore(script, latestScript);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
load();
|
||||
} else if (w.attachEvent) {
|
||||
w.attachEvent('onload', load);
|
||||
} else {
|
||||
w.addEventListener('load', load, false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
async function handler(
|
||||
nextRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((spaceResult) => {
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceResult.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceResult.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
basePath: context.linker.toPathInSite('~gitbook/'),
|
||||
streamableHttpEndpoint: '/mcp',
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(
|
||||
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
|
||||
);
|
||||
requestURL.search = nextRequest.nextUrl.search;
|
||||
|
||||
const request = new Request(requestURL, nextRequest);
|
||||
return mcpHandler(request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1 +1,3 @@
|
||||
export * from './useAI';
|
||||
export * from './useAIChat';
|
||||
export type { RenderAIMessageOptions } from './server-actions';
|
||||
|
||||
@@ -14,7 +14,7 @@ export function AIMessageView(
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
) {
|
||||
const { message, context, renderToolCalls = true } = props;
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -33,12 +33,12 @@ export function AIMessageView(
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
shouldRenderLinkPreviews: true,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style="mt-2 space-y-4 empty:hidden"
|
||||
/>
|
||||
|
||||
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AIToolCallGetPageContent,
|
||||
AIToolCallGetPages,
|
||||
AIToolCallMCP,
|
||||
AIToolCallOther,
|
||||
AIToolCallSearch,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
@@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||
case 'mcp':
|
||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||
case 'other':
|
||||
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||
default:
|
||||
return <>{toolCall.tool}</>;
|
||||
}
|
||||
@@ -69,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -99,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -112,13 +115,22 @@ function DescriptionForMCPToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForOtherToolCall(props: {
|
||||
toolCall: AIToolCallOther;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall } = props;
|
||||
|
||||
return <p>{toolCall.summary.text}</p>;
|
||||
}
|
||||
|
||||
async function DescriptionForSearchToolCall(props: {
|
||||
toolCall: AIToolCallSearch;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
@@ -155,7 +167,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col leading-snug">
|
||||
<div className="flex min-w-0 flex-col break-words leading-snug">
|
||||
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{hasResults
|
||||
@@ -175,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
) : null}
|
||||
</summary>
|
||||
{hasResults ? (
|
||||
<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">
|
||||
<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">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
@@ -229,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -247,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
||||
return 'magnifying-glass';
|
||||
case 'getPages':
|
||||
return 'files';
|
||||
case 'other':
|
||||
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||
default:
|
||||
return 'hammer';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessage,
|
||||
AIMessageRole,
|
||||
@@ -19,97 +20,103 @@ export async function streamRenderAIMessage(
|
||||
rawStream: AsyncIterable<AIStreamResponse>,
|
||||
options?: RenderAIMessageOptions
|
||||
) {
|
||||
const message: AIMessage = {
|
||||
id: '',
|
||||
role: AIMessageRole.Assistant,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const updateProcessingMessageStep = (
|
||||
stepIndex: number,
|
||||
callback: (step: AIMessageStep) => void
|
||||
) => {
|
||||
if (stepIndex > message.steps.length) {
|
||||
throw new Error(
|
||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
...message.steps,
|
||||
{
|
||||
content: {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
callback(message.steps[stepIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
case 'response_tool_call': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.toolCalls ??= [];
|
||||
step.toolCalls.push(event.toolCall);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is writing the content of a new message.
|
||||
*/
|
||||
case 'response_reasoning':
|
||||
case 'response_document': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||
|
||||
step[container] ??= {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
};
|
||||
step[container] = {
|
||||
...step[container],
|
||||
nodes: [...step[container].nodes],
|
||||
};
|
||||
if (event.operation === 'insert') {
|
||||
step[container].nodes.push(...event.blocks);
|
||||
} else {
|
||||
step[container].nodes.splice(
|
||||
-event.blocks.length,
|
||||
event.blocks.length,
|
||||
...event.blocks
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||
const message: AIMessage = {
|
||||
id: '',
|
||||
role: AIMessageRole.Assistant,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const updateProcessingMessageStep = (
|
||||
stepIndex: number,
|
||||
callback: (step: AIMessageStep) => void
|
||||
) => {
|
||||
if (stepIndex > message.steps.length) {
|
||||
throw new Error(
|
||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||
);
|
||||
}
|
||||
|
||||
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 = [
|
||||
...message.steps,
|
||||
{
|
||||
content: {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
// @ts-expect-error
|
||||
callback(message.steps[stepIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
case 'response_tool_call': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.toolCalls ??= [];
|
||||
step.toolCalls.push(event.toolCall);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is writing the content of a new message.
|
||||
*/
|
||||
case 'response_reasoning':
|
||||
case 'response_document': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
const container =
|
||||
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||
|
||||
step[container] ??= {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
};
|
||||
step[container] = {
|
||||
...step[container],
|
||||
nodes: [...step[container].nodes],
|
||||
};
|
||||
if (event.operation === 'insert') {
|
||||
step[container].nodes.push(...event.blocks);
|
||||
} else {
|
||||
step[container].nodes.splice(
|
||||
-event.blocks.length,
|
||||
event.blocks.length,
|
||||
...event.blocks
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
'use server';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessageContext,
|
||||
AIMessageRole,
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
@@ -12,32 +20,48 @@ export async function* streamAIChatResponse({
|
||||
message,
|
||||
messageContext,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
options,
|
||||
}: {
|
||||
message: string;
|
||||
message?: string;
|
||||
messageContext: AIMessageContext;
|
||||
previousResponseId?: string;
|
||||
toolCall?: AIToolCallResult;
|
||||
tools?: AIToolDefinition[];
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
||||
mode: 'assistant',
|
||||
input: [
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
],
|
||||
output: { type: 'document' },
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
});
|
||||
input: message
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
}
|
||||
);
|
||||
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
});
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './responses';
|
||||
export * from './chat';
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
'use server';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
/**
|
||||
* Stream an existing AI responses.
|
||||
*/
|
||||
export async function* streamAIResponseById({
|
||||
responseId,
|
||||
options,
|
||||
}: {
|
||||
responseId: string;
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
responseId
|
||||
);
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,17 @@ export type RenderAIMessageOptions = {
|
||||
* Render the tool calls.
|
||||
* @default true
|
||||
*/
|
||||
renderToolCalls?: boolean;
|
||||
withToolCalls?: boolean;
|
||||
|
||||
/**
|
||||
* Render the link previews.
|
||||
* @default true
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* Generate links in the context of an embeddable view.
|
||||
* @default false
|
||||
*/
|
||||
asEmbeddable?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
// Unify assistants configuration context with the assistants hook in one place
|
||||
export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
trademark: boolean;
|
||||
};
|
||||
|
||||
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||
/**
|
||||
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
|
||||
* @example '123e4567-e89b-12d3-a456-426614174000'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display mode for the assistant. Currently, only `overlay` is supported for custom assistants.
|
||||
* - `overlay`: Display the assistant in an overlay on top of the page.
|
||||
* - `sidebar`: Display the assistant in a sidebar next to the page. Only supported for the GitBook Assistant.
|
||||
* - `search`: Display the assistant inside the search container. Only supported for GitBook AI Search.
|
||||
* @default 'overlay'
|
||||
*/
|
||||
mode?: 'overlay' | 'sidebar' | 'search';
|
||||
|
||||
/**
|
||||
* Whether the assistant is displayed in the page action menu.
|
||||
* @default false
|
||||
*/
|
||||
pageAction: boolean;
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
*/
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const AIContext = React.createContext<AIConfig | null>(null);
|
||||
|
||||
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||
const { aiMode, trademark, children } = props;
|
||||
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
type AIContext = {
|
||||
config: AIConfig;
|
||||
assistants: Assistant[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified assistants list combining the built-in GitBook Assistant (when enabled)
|
||||
* with any custom assistants registered at runtime.
|
||||
*/
|
||||
export function useAI(): AIContext {
|
||||
const config = useAIConfig();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
const assistants: Assistant[] = [];
|
||||
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
chatController.open();
|
||||
if (query) {
|
||||
chatController.postMessage({ message: query });
|
||||
}
|
||||
},
|
||||
pageAction: true,
|
||||
ui: true,
|
||||
mode: 'sidebar',
|
||||
});
|
||||
} else if (config.aiMode === CustomizationAIMode.Search) {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<Icon icon="search" className="size-4" />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||
);
|
||||
}
|
||||
},
|
||||
pageAction: false,
|
||||
ui: false,
|
||||
mode: 'search',
|
||||
});
|
||||
}
|
||||
|
||||
const integrationAssistants = useIntegrationAssistants();
|
||||
if (integrationAssistants.length > 0) {
|
||||
assistants.push(
|
||||
...integrationAssistants.map((assistant) => ({
|
||||
...assistant,
|
||||
icon: <Icon icon={assistant.icon as IconName} className="size-4" />,
|
||||
open: (query?: string) => {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
assistants,
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { useSearch } from '../Search';
|
||||
import { streamAIChatResponse } from './server-actions';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
|
||||
export type AIChatMessage = {
|
||||
@@ -15,6 +23,21 @@ export type AIChatMessage = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Confirm the tool call by calling this function.
|
||||
*/
|
||||
confirm: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tool call result to cancel it.
|
||||
*/
|
||||
cancelToolCall: AIToolCallResult;
|
||||
};
|
||||
|
||||
export type AIChatState = {
|
||||
/**
|
||||
* If true, the chat is open.
|
||||
@@ -46,6 +69,11 @@ export type AIChatState = {
|
||||
*/
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Tools that are pending confirmation to be executed.
|
||||
*/
|
||||
pendingTools: AIChatPendingTool[];
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
*/
|
||||
@@ -70,23 +98,20 @@ export type AIChatController = {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
|
||||
// Global state store for AI chat
|
||||
const globalState = zustand.create<{
|
||||
state: AIChatState;
|
||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
||||
}>((set) => {
|
||||
const globalState = zustand.create<AIChatState>(() => {
|
||||
return {
|
||||
state: {
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
},
|
||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -94,24 +119,28 @@ const globalState = zustand.create<{
|
||||
* Get the current state of the AI chat.
|
||||
*/
|
||||
export function useAIChatState(): AIChatState {
|
||||
const state = zustand.useStore(globalState, (state) => state.state);
|
||||
const state = zustand.useStore(globalState);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the controller to interact with the AI chat.
|
||||
* Integrates with search state to synchronize ?ask= parameter.
|
||||
* Provide the controller to interact with the AI chat.
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
export function AIChatProvider(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { renderMessageOptions, children } = props;
|
||||
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
||||
const trackEvent = useTrackEvent();
|
||||
const [searchState, setSearchState] = useSearch(true);
|
||||
const [, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState().state;
|
||||
setState((state) => ({ ...state, opened: true }));
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
@@ -120,11 +149,11 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
setState((state) => ({ ...state, opened: false }));
|
||||
globalState.setState((state) => ({ ...state, opened: false }));
|
||||
|
||||
// Clear ask parameter but keep other search state
|
||||
setSearchState((prev) => ({
|
||||
@@ -133,65 +162,103 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
}, [setSearchState]);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { messages } = globalState.getState().state;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
setState((state) => {
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
responseId: null,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().state.responseId ?? undefined,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
tools: integrationTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.responseId,
|
||||
// Mark as not loading when the response is finished
|
||||
@@ -202,7 +269,7 @@ export function useAIChatController(): AIChatController {
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
@@ -211,10 +278,53 @@ export function useAIChatController(): AIChatController {
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
const confirmation = toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
pendingTools: [
|
||||
...state.pendingTools,
|
||||
{
|
||||
icon: confirmation.icon,
|
||||
label: confirmation.label,
|
||||
cancelToolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
cancelled: 'User did not confirm the tool call',
|
||||
},
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
confirm: async () => {
|
||||
await executeToolCall(event);
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
@@ -226,30 +336,96 @@ export function useAIChatController(): AIChatController {
|
||||
}));
|
||||
}
|
||||
|
||||
setState((state) => ({
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
}
|
||||
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch {
|
||||
setState((state) => ({
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[messageContextRef.current, setState, setSearchState, trackEvent]
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (query === input.message) {
|
||||
// Return early if the message is the same as the previous message
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
};
|
||||
});
|
||||
|
||||
const pendingTool = pendingTools[0];
|
||||
streamResponse({
|
||||
message: input.message,
|
||||
// If we had a pending tool call, we need to send it as being cancelled
|
||||
// otherwise the AI will fail to process the message
|
||||
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
|
||||
});
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -262,49 +438,9 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
}, [setSearchState]);
|
||||
|
||||
// Auto-trigger AI chat when ?ask= parameter appears in URL (only once)
|
||||
React.useEffect(() => {
|
||||
const hasNoAsk = searchState?.ask === undefined || searchState?.ask === null;
|
||||
const hasQuery = searchState?.query !== null;
|
||||
|
||||
// Don't trigger if we have a regular search query active
|
||||
if (hasNoAsk) return;
|
||||
if (hasQuery && searchState.open === false) return;
|
||||
|
||||
// Open the chat when ask parameter appears
|
||||
onOpen();
|
||||
|
||||
// Auto-post the message if ask has content
|
||||
if (searchState?.ask?.trim()) {
|
||||
const trimmedAsk = searchState.ask.trim();
|
||||
const { loading, initialQuery } = globalState.getState().state;
|
||||
|
||||
// Don't trigger if we're already posting a message
|
||||
if (loading) return;
|
||||
|
||||
// Only initialize once per URL ask value
|
||||
if (initialQuery === trimmedAsk) return;
|
||||
|
||||
// Wait for messageContextRef to be defined before proceeding
|
||||
if (!messageContextRef.current?.location) return;
|
||||
|
||||
// Mark this ask value as processed
|
||||
setState((state) => ({ ...state, initialQuery: trimmedAsk }));
|
||||
onPostMessage({ message: trimmedAsk });
|
||||
}
|
||||
}, [
|
||||
searchState?.ask,
|
||||
searchState?.query,
|
||||
searchState?.open,
|
||||
messageContextRef,
|
||||
onOpen,
|
||||
setState,
|
||||
onPostMessage,
|
||||
]);
|
||||
|
||||
return React.useMemo(() => {
|
||||
const controller = React.useMemo(() => {
|
||||
return {
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
@@ -312,4 +448,22 @@ export function useAIChatController(): AIChatController {
|
||||
postMessage: onPostMessage,
|
||||
};
|
||||
}, [onOpen, onClose, onClear, onPostMessage]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</AIChatControllerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the controller to interact with the AI chat.
|
||||
* Integrates with search state to synchronize ?ask= parameter.
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
const controller = React.useContext(AIChatControllerContext);
|
||||
if (!controller) {
|
||||
throw new Error('useAIChatController must be used within an AIChatProvider');
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CopyMarkdown,
|
||||
OpenDocsAssistant,
|
||||
OpenInLLM,
|
||||
ViewAsMarkdown,
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface AIActionsDropdownProps {
|
||||
markdownPageUrl: string;
|
||||
withAIChat?: boolean;
|
||||
trademark: boolean;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||
*/
|
||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<ButtonGroup ref={ref}>
|
||||
<DefaultAction {...props} />
|
||||
{props.actions.markdown || props.actions.externalAI ? (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-60 max-w-max"
|
||||
button={
|
||||
<Button
|
||||
icon={
|
||||
<Icon
|
||||
icon="chevron-down"
|
||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||
/>
|
||||
}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="bg-tint-base text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIActionsDropdownMenuContent {...props} />
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the dropdown menu.
|
||||
*/
|
||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{withAIChat ? (
|
||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
||||
) : null}
|
||||
|
||||
{actions.markdown ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{actions.externalAI ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
|
||||
if (withAIChat) {
|
||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.externalAI) {
|
||||
return (
|
||||
<>
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
|
||||
<title>ChatGPT</title>
|
||||
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 256 257"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
{...props}
|
||||
>
|
||||
<title>Claude</title>
|
||||
<g>
|
||||
<path
|
||||
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
|
||||
<title>Markdown</title>
|
||||
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
|
||||
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user