mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 22:09:04 +00:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 885264f132 | |||
| f1a6dec951 | |||
| 44f41510fa | |||
| f220229e5e | |||
| 61b4cb8d7c | |||
| 185cdb4883 | |||
| 2cdba53451 | |||
| 950f6c36cf | |||
| ea37977b1e | |||
| 81da82aed7 | |||
| 39a3518651 | |||
| d903273759 | |||
| d130532f69 | |||
| 94fcd21f34 | |||
| 1d2db4ae2b | |||
| d270b4af9b | |||
| ccfbd3c1a8 | |||
| 5c4923557f | |||
| b1f608b865 | |||
| c7993756dd | |||
| d02465823f | |||
| 6b9f6b5b79 | |||
| df7de8fb0c | |||
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a |
@@ -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
|
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing v2
|
name: Visual Testing v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -110,8 +110,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing v2 (Cloudflare)
|
name: Visual Testing v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -131,8 +131,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2
|
name: Visual Testing Customers v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -152,8 +152,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e-customers
|
run: bun e2e-customers
|
||||||
env:
|
env:
|
||||||
@@ -165,7 +165,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2 (Cloudflare)
|
name: Visual Testing Customers v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -173,8 +173,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e-customers
|
run: bun e2e-customers
|
||||||
env:
|
env:
|
||||||
|
|||||||
+3
-1
@@ -18,10 +18,12 @@
|
|||||||
"**/.vercel/**/*",
|
"**/.vercel/**/*",
|
||||||
"**/.cache/**/*",
|
"**/.cache/**/*",
|
||||||
"**/.wrangler/**/*",
|
"**/.wrangler/**/*",
|
||||||
|
"packages/embed/standalone/**/*",
|
||||||
"packages/openapi-parser/src/fixtures/**/*",
|
"packages/openapi-parser/src/fixtures/**/*",
|
||||||
"packages/emoji-codepoints/index.ts",
|
"packages/emoji-codepoints/index.ts",
|
||||||
"packages/icons/src/data/*.json",
|
"packages/icons/src/data/*.json",
|
||||||
"packages/gitbook/worker-configuration.d.ts"
|
"packages/gitbook/worker-configuration.d.ts",
|
||||||
|
"**/*.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.128.0"
|
"@gitbook/api": "^0.136.0",
|
||||||
|
"bidc": "^0.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"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,24 @@
|
|||||||
|
{
|
||||||
|
"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": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ export function getCacheTag(
|
|||||||
| {
|
| {
|
||||||
tag: 'translation';
|
tag: 'translation';
|
||||||
organization: string;
|
organization: string;
|
||||||
translationSettings: string;
|
translation: string;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
@@ -115,7 +115,7 @@ export function getCacheTag(
|
|||||||
case 'openapi':
|
case 'openapi':
|
||||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
case 'translation':
|
case 'translation':
|
||||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
return `organization:${spec.organization}:translation:${spec.translation}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
|
|||||||
) {
|
) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
if (!('dependencies' in source)) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||||
// when the dependencies are updated.
|
// when the dependencies are updated.
|
||||||
const dependencies = Object.values(source.dependencies ?? {});
|
const dependencies = Object.values(source.dependencies ?? {});
|
||||||
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'translation-language':
|
case 'translation':
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'translation',
|
tag: 'translation',
|
||||||
organization: inContext.organizationId,
|
organization: inContext.organizationId,
|
||||||
translationSettings: dependency.ref.translationSettings,
|
translation: dependency.ref.translation,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
standalone/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 81a6bd7: Improve API to control the GitBook embed
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8927e8f: Initial version of the embed SDK.
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -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.0",
|
||||||
|
"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,170 @@
|
|||||||
|
:root {
|
||||||
|
--gitbook-widget-top: 1rem;
|
||||||
|
--gitbook-widget-bottom: 1rem;
|
||||||
|
--gitbook-widget-right: 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: var(--gitbook-widget-window-width);
|
||||||
|
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
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,7 @@
|
|||||||
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 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,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/expr",
|
||||||
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"dependencies": {
|
||||||
|
"eval-estree-expression": "^2.0.3",
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"acorn-loose": "8.4.0",
|
||||||
|
"acorn-walk": "^8.3.4",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "^1.1.20",
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
|
"@babel/types": "^7.26.0",
|
||||||
|
"@types/json-schema": "^7.0.15"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"unit": "bun test",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolType,
|
||||||
|
SymbolsTable,
|
||||||
|
} from '../symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
describe('autocomplete', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const visitorClaimsHelloArraySymbol = SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
});
|
||||||
|
const symbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: visitorClaimsHelloArraySymbol,
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const context = new SymbolsTable(symbols);
|
||||||
|
const SCENARIOS: Array<{
|
||||||
|
expressionWithCursor: string;
|
||||||
|
expectedSuggestions: AutocompleteSuggestions;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
'visitor.claims.role',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.role',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.role.length',
|
||||||
|
'visitor.claims.role.at',
|
||||||
|
'visitor.claims.role.endsWith',
|
||||||
|
'visitor.claims.role.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.ke<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.h<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello.length',
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
|
||||||
|
(method) => ({
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: method,
|
||||||
|
ref: `visitor.claims.hello.${method.name}`,
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.key <cur>',
|
||||||
|
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
|
||||||
|
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
|
||||||
|
),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['==', '==='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '==='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['!=', '!=='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '!=='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == editor<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello == <cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '"test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
it.each(SCENARIOS)(
|
||||||
|
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
|
||||||
|
({ expressionWithCursor, expectedSuggestions }) => {
|
||||||
|
const { expression, cursorOffset } = extractCursorPosition(
|
||||||
|
expressionWithCursor,
|
||||||
|
'<cur>'
|
||||||
|
);
|
||||||
|
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
|
||||||
|
expect(suggestions).toStrictEqual(expectedSuggestions);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractCursorPosition(
|
||||||
|
expressionWithCursor: string,
|
||||||
|
cursorPlaceholder: string
|
||||||
|
): { expression: string; cursorOffset: number } {
|
||||||
|
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
|
||||||
|
if (cursorOffset === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
|
||||||
|
return { expression, cursorOffset };
|
||||||
|
}
|
||||||
@@ -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,160 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionError } from '../errors';
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import type { Logger } from '../types';
|
||||||
|
|
||||||
|
const SILENT_LOGGER: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const runtime = new ExpressionRuntime(SILENT_LOGGER);
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
inputs: { isBetaUser: false },
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
inputs: {
|
||||||
|
useProductA: true,
|
||||||
|
isBetaUser: false,
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
inputs: {
|
||||||
|
products: ['productA', 'productB'],
|
||||||
|
userSegments: {
|
||||||
|
alpha: true,
|
||||||
|
beta: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||||
|
({ condition, inputs, expectedResult }) => {
|
||||||
|
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult);
|
||||||
|
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const INVALID_EXPRESSSIONS = [
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: 'while (1) {}',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: '[1, 2, 3].map(() => { while (1) {}})',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
const result = runtime.safeEvaluate(condition, inputs);
|
||||||
|
expect(result.value).toBeUndefined();
|
||||||
|
expect(result.error instanceof ExpressionError).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should throw an error when using evaluate with invalid expressions',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse', () => {
|
||||||
|
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
|
||||||
|
const ast = runtime.parse('isBetaUser === true');
|
||||||
|
|
||||||
|
expect(ast.result).toEqual({
|
||||||
|
type: 'BinaryExpression',
|
||||||
|
start: 0,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
|
||||||
|
left: {
|
||||||
|
type: 'Identifier',
|
||||||
|
start: 0,
|
||||||
|
end: 10,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
|
||||||
|
name: 'isBetaUser',
|
||||||
|
},
|
||||||
|
operator: '===',
|
||||||
|
right: {
|
||||||
|
type: 'Literal',
|
||||||
|
start: 15,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
|
||||||
|
value: true,
|
||||||
|
raw: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should throw an error for non conditional expressions or syntax errors: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip('generate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should produce the original expression using an AST node produced by parse: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
const { result } = runtime.parse(condition);
|
||||||
|
expect(runtime.generate(result)).toStrictEqual(condition);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,8 @@
|
|||||||
|
export * from './errors';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './runtime';
|
||||||
|
export * from './symbols';
|
||||||
|
export * from './template';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
@@ -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,286 @@
|
|||||||
|
import {
|
||||||
|
type Options as AcornOptions,
|
||||||
|
type Expression,
|
||||||
|
type ExpressionStatement,
|
||||||
|
type Position,
|
||||||
|
type Program,
|
||||||
|
type Token,
|
||||||
|
parse,
|
||||||
|
tokenizer,
|
||||||
|
} from 'acorn';
|
||||||
|
import { parse as parseLoose } from 'acorn-loose';
|
||||||
|
import { evaluate } from 'eval-estree-expression';
|
||||||
|
|
||||||
|
import { AutoComplete } from './autocomplete';
|
||||||
|
import { ExpressionError } from './errors';
|
||||||
|
import type { SymbolsTable } from './symbols';
|
||||||
|
import type { TemplatePart } from './template';
|
||||||
|
import { parseTemplate as parseTemplateParts } from './template';
|
||||||
|
import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types';
|
||||||
|
import { formatExpressionResult } from './utils';
|
||||||
|
|
||||||
|
export class ExpressionRuntime {
|
||||||
|
#parserOptions: AcornOptions;
|
||||||
|
#autocompleter: AutoComplete;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(logger: Logger = console) {
|
||||||
|
this.#parserOptions = {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'script',
|
||||||
|
allowHashBang: false,
|
||||||
|
locations: true,
|
||||||
|
};
|
||||||
|
this.#autocompleter = new AutoComplete(this, logger);
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression based on the given inputs/context.
|
||||||
|
*/
|
||||||
|
public evaluate(expr: string, inputs: object): unknown {
|
||||||
|
try {
|
||||||
|
const parsed = this.parse(expr);
|
||||||
|
|
||||||
|
if (parsed.invalidNodes.length > 0) {
|
||||||
|
throw new ExpressionError('Invalid nodes found when parsing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluate.sync<Expression>(parsed.result, inputs, {
|
||||||
|
functions: true,
|
||||||
|
withMembers: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression safely by returning the error instead of throwing when invalid.
|
||||||
|
*/
|
||||||
|
public safeEvaluate(
|
||||||
|
expr: string,
|
||||||
|
inputs: object
|
||||||
|
): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } {
|
||||||
|
try {
|
||||||
|
const value = this.evaluate(expr, inputs);
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error(`Error while evaluating expression ${expr}`, error);
|
||||||
|
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition safely to a boolean.
|
||||||
|
*/
|
||||||
|
public evaluateBoolean(expr: string, inputs: object): boolean {
|
||||||
|
if (expr.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = this.safeEvaluate(expr, inputs);
|
||||||
|
|
||||||
|
if (typeof evalResult.error !== 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(evalResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an array of conditions as a single logical expression.
|
||||||
|
* The function treats the conditions as if they were joined by an AND operator,
|
||||||
|
* meaning the evaluation returns `true` only if all conditions are truthy.
|
||||||
|
*/
|
||||||
|
public evaluateBooleanAll(expressions: string[], inputs: object): boolean {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressions.every((expression) => this.evaluateBoolean(expression, inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template and validate all embedded expressions.
|
||||||
|
*/
|
||||||
|
public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } {
|
||||||
|
const parts = parseTemplateParts(template);
|
||||||
|
const errors: ExpressionError[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'expression') {
|
||||||
|
try {
|
||||||
|
const { invalidNodes } = this.parse(part.value);
|
||||||
|
if (invalidNodes.length > 0) {
|
||||||
|
errors.push(new ExpressionError('Invalid expression'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error as ExpressionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parts, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
public evaluateTemplate(template: string, inputs: object): string {
|
||||||
|
const { parts } = this.parseTemplate(template);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return part.value;
|
||||||
|
}
|
||||||
|
const result = this.evaluate(part.value, inputs);
|
||||||
|
return formatExpressionResult(result, '');
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||||
|
*/
|
||||||
|
public parse(
|
||||||
|
expr: string,
|
||||||
|
options: { loose?: boolean } = {
|
||||||
|
loose: false,
|
||||||
|
}
|
||||||
|
): ExpressionParserResult {
|
||||||
|
try {
|
||||||
|
const ast = options.loose
|
||||||
|
? parseLoose(expr, { ...this.#parserOptions })
|
||||||
|
: parse(expr, { ...this.#parserOptions });
|
||||||
|
|
||||||
|
if (!ast.body || ast.body.length === 0) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first expression statement that we find
|
||||||
|
const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node));
|
||||||
|
const [statement] = ast.body.splice(firstExprIndex, 1);
|
||||||
|
|
||||||
|
if (!statement || !isParsedExpressionStatement(statement)) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return information on the other nodes as invalid nodes
|
||||||
|
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: statement.expression,
|
||||||
|
invalidNodes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw createExpressionErrorFromSyntaxError(expr, error);
|
||||||
|
}
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for the given expression at the provided cursor offset.
|
||||||
|
*/
|
||||||
|
public autocomplete(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): ExpressionAutocompleteResults {
|
||||||
|
const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context);
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(_node: Expression): string {
|
||||||
|
throw new Error('Not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpressionErrorFromSyntaxError(
|
||||||
|
code: string,
|
||||||
|
error: SyntaxError & { loc?: Position }
|
||||||
|
): ExpressionError {
|
||||||
|
const loc = error.loc;
|
||||||
|
|
||||||
|
if (!loc) {
|
||||||
|
return new ExpressionError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`;
|
||||||
|
const token = getTokenAtLoc(code, loc);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new ExpressionError(errorMessage, loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpressionError(errorMessage, loc, token);
|
||||||
|
}
|
||||||
|
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
|
||||||
|
const tokens = tokenizer(code, {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
locations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.loc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = token.loc;
|
||||||
|
|
||||||
|
const onSameLine = errorLoc.line === start.line;
|
||||||
|
const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column;
|
||||||
|
|
||||||
|
if (onSameLine && inColumnRange) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParsedExpressionStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return statement.type === 'ExpressionStatement';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutModuleDeclarationStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return ![
|
||||||
|
'ImportDeclaration',
|
||||||
|
'ExportNamedDeclaration',
|
||||||
|
'ExportDefaultDeclaration',
|
||||||
|
'ExportAllDeclaration',
|
||||||
|
].includes(statement.type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
|
||||||
|
import { SymbolsTable } from '../symbols-table';
|
||||||
|
import type { SymbolType } from '../types';
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const initialSymbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolString({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolString({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolString({ name: 'FLAG3' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
describe('addSymbols', () => {
|
||||||
|
it('should the symbols matching the provided object to the table', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
symbolsTable.addSymbols({
|
||||||
|
space: SymbolObject({
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'id',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'space',
|
||||||
|
childrenRefs: ['space.id', 'space.title'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Symbols standard library', () => {
|
||||||
|
it('should allow to access methods & properties defined as part of the standard library', () => {
|
||||||
|
const symbolsTable = new SymbolsTable({
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSymbolInfo', () => {
|
||||||
|
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1.length',
|
||||||
|
'visitor.claims.flags.FLAG1.at',
|
||||||
|
'visitor.claims.flags.FLAG1.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG1.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG2.length',
|
||||||
|
'visitor.claims.flags.FLAG2.at',
|
||||||
|
'visitor.claims.flags.FLAG2.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG2.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromValue', () => {
|
||||||
|
it('should infer properly a symbol based on a value', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromValue(
|
||||||
|
{
|
||||||
|
visitor: {
|
||||||
|
claims: {
|
||||||
|
key: 'test',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'testflag1',
|
||||||
|
FLAG2: 'testflag2',
|
||||||
|
FLAG3: 'testflag3',
|
||||||
|
},
|
||||||
|
hello: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'context'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'context',
|
||||||
|
properties: {
|
||||||
|
visitor: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromJSONSchema', () => {
|
||||||
|
it('should infer properly a symbol table based on a JSON schema', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'visitor'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,268 @@
|
|||||||
|
import {
|
||||||
|
type ArraySymbolDef,
|
||||||
|
type BooleanSymbolDef,
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type FunctionSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type NullSymbolDef,
|
||||||
|
type NumberSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
type StringSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolsWithPropertiesAndMethods,
|
||||||
|
type UndefinedSymbolDef,
|
||||||
|
type UnionSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Boolean,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Number,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolString(
|
||||||
|
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
|
||||||
|
): StringSymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Object,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolArray(
|
||||||
|
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
|
||||||
|
): ArraySymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Function,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionalFunctionArg(
|
||||||
|
optionalArg: ExtractSymbolDef<SymbolType>
|
||||||
|
): ExtractSymbolDef<SymbolType> & { optional: true } {
|
||||||
|
return {
|
||||||
|
...optionalArg,
|
||||||
|
optional: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Union,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Undefined,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Null,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSymbolWithPropertiesAndMethods<
|
||||||
|
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
|
||||||
|
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
|
||||||
|
const symbol = { type, ...args } as T;
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'properties', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).properties
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.properties || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'methods', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).methods
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.methods || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
|
||||||
|
|
||||||
|
const StandardLibrary: Partial<
|
||||||
|
{
|
||||||
|
[key in Exclude<SymbolType, SymbolType.Array>]: {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
} & {
|
||||||
|
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[SymbolType.String]: {
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the string.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: 'The index (position) of the string character to be returned',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `A String consisting of the single UTF-16 code unit located at the specified position.
|
||||||
|
Returns undefined if the given index can not be found.`,
|
||||||
|
members: [SymbolString(), SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'endsWith',
|
||||||
|
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
|
||||||
|
elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `The characters to be searched for at the end of str. Cannot be a regex.
|
||||||
|
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
|
||||||
|
the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'endPosition',
|
||||||
|
description: `The end position at which searchString is expected to be found
|
||||||
|
(the index of searchString's last character plus 1). Defaults to str.length.`,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
|
||||||
|
that are greater than or equal to position; otherwise, returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
|
||||||
|
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'position',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: `Zero-based index of the array element to be returned, converted to an integer.
|
||||||
|
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
|
||||||
|
without attempting to access the corresponding property.`,
|
||||||
|
members: [arraySymbolDef.items, SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description:
|
||||||
|
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'searchElement',
|
||||||
|
description: 'The value to be searched for within the array.',
|
||||||
|
},
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'fromIndex',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
|
||||||
|
return symbol.type === SymbolType.Array;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
+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;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,87 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.16.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6830815: Support custom AI providers
|
||||||
|
- cbc71a5: Allow integrations to provide tools to the Docs Assistant
|
||||||
|
- cc2e615: Emit a <link rel="alternate"> for the markdown version of a page
|
||||||
|
- 81a6bd7: Support customization of buttons and tools through iframe API
|
||||||
|
- 8927e8f: Start routes for embeddable version of the assistant and docs pages.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d30bcba: Improve `Button` and `ButtonGroup` styling
|
||||||
|
- e1b2cf6: Fix scroll of page outline
|
||||||
|
- d655d3e: Support "objectivec" as alias for Objective C syntax
|
||||||
|
- 13ff22b: Fix AI Search follow-up question closing search
|
||||||
|
- ffa866c: Small fixes to search modal
|
||||||
|
- 36af03f: Fixes to `PageAside`
|
||||||
|
- fb858a1: Tweaks to AIChatButton and AIChatInput
|
||||||
|
- bcfa8d8: Improve vertical alignment of site items and fix floating page aside
|
||||||
|
- ea7e94f: Fix search bar layout shift caused by ToC
|
||||||
|
- 2e6e28e: Fix: Long strings overflow out of message bubble in docs assistant
|
||||||
|
- ff96bb5: Support new coverDefinitionDark for cards & image type
|
||||||
|
- 388b20d: Clear AI chat properly
|
||||||
|
- ba7ec14: Fix bug in search highlight in GBO
|
||||||
|
- 6217a2e: Page outline: scroll to active item
|
||||||
|
- 42c17f5: Improve OpenAPI parsing errors
|
||||||
|
- 854c448: Custom assistants followup
|
||||||
|
- 43766d6: Fix Custom logo not rendering on the published site
|
||||||
|
- Updated dependencies [cbc71a5]
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- Updated dependencies [42c17f5]
|
||||||
|
- Updated dependencies [854c448]
|
||||||
|
- @gitbook/browser-types@0.1.0
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
|
- @gitbook/openapi-parser@3.0.1
|
||||||
|
- @gitbook/react-contentkit@0.7.4
|
||||||
|
- @gitbook/react-openapi@1.3.6
|
||||||
|
|
||||||
|
## 0.15.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- d130532: Suggest questions in the current space context
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 44f4151: Fix close buttons tooltips
|
||||||
|
- d903273: Strip visitor params from URL
|
||||||
|
- df7de8f: Fix an issue where links were not rendering properly in Ask AI answers
|
||||||
|
- d270b4a: All tracking is now disabled for dynamic routes such as the site preview.
|
||||||
|
- b1f608b: Implement basic URL scheme for assistant with ask
|
||||||
|
- d024658: Prevent page breaks inside blocks when printing
|
||||||
|
- 185cdb4: Fix scrolling to anchor positionning
|
||||||
|
- 5c49235: Remove highlighting in Safari for PowerShell and C++ to avoid page crash until next version with bug fix is released
|
||||||
|
- f1a6dec: Update OpenAPI parser
|
||||||
|
- 2cdba53: Upgrade to Tailwind v4
|
||||||
|
- Updated dependencies [2cdba53]
|
||||||
|
- Updated dependencies [f1a6dec]
|
||||||
|
- @gitbook/react-math@0.6.1
|
||||||
|
- @gitbook/icons@0.2.2
|
||||||
|
- @gitbook/openapi-parser@3.0.0
|
||||||
|
- @gitbook/react-contentkit@0.7.3
|
||||||
|
- @gitbook/react-openapi@1.3.5
|
||||||
|
|
||||||
|
## 0.14.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 7c951ef: 'Support new colors in text formatting
|
||||||
|
- 39c4f76: Add fullwidth page option
|
||||||
|
- eb1bd3a: AI response feedback buttons
|
||||||
|
- 2ba7e54: Fix headings styles in hint block
|
||||||
|
- 250c194: Add nosnippet to announcement banner.
|
||||||
|
- 4aeb81b: Fix search input styling & text cutoff
|
||||||
|
- 68f0dbc: Add confirmation modal to contentkit button
|
||||||
|
- 611e286: Remove padding on first Heading in Columns
|
||||||
|
- b0c534f: Tweaks to AI Chat and AI Actions Dropdown
|
||||||
|
- Updated dependencies [b5ad0ce]
|
||||||
|
- @gitbook/openapi-parser@2.2.2
|
||||||
|
- @gitbook/react-openapi@1.3.4
|
||||||
|
|
||||||
## 0.14.0
|
## 0.14.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomizationAIMode,
|
||||||
CustomizationBackground,
|
CustomizationBackground,
|
||||||
CustomizationCorners,
|
CustomizationCorners,
|
||||||
CustomizationDefaultMonospaceFont,
|
CustomizationDefaultMonospaceFont,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
|
|
||||||
import { getSiteAPIToken } from '../tests/utils';
|
import { getSiteAPIToken } from '../tests/utils';
|
||||||
import {
|
import {
|
||||||
|
type Test,
|
||||||
type TestsCase,
|
type TestsCase,
|
||||||
allDeprecatedThemePresets,
|
allDeprecatedThemePresets,
|
||||||
allLocales,
|
allLocales,
|
||||||
@@ -34,6 +36,179 @@ import {
|
|||||||
waitForNotFound,
|
waitForNotFound,
|
||||||
} from './util';
|
} from './util';
|
||||||
|
|
||||||
|
const searchTestCases: Test[] = [
|
||||||
|
{
|
||||||
|
name: 'Search - AI Mode: None - Complete flow',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
const searchInput = page.getByTestId('search-input');
|
||||||
|
await searchInput.focus();
|
||||||
|
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||||
|
|
||||||
|
// Fill search input, expecting search results
|
||||||
|
await searchInput.fill('gitbook');
|
||||||
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
|
const pageResults = await page.getByTestId('search-page-result').all();
|
||||||
|
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||||
|
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||||
|
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search - AI Mode: None - Keyboard shortcut',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await page.keyboard.press('ControlOrMeta+K');
|
||||||
|
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search - AI Mode: None - URL query (Initial)',
|
||||||
|
url: `${getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.None,
|
||||||
|
},
|
||||||
|
})}&q=`,
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search - AI Mode: None - URL query (Results)',
|
||||||
|
url: `${getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.None,
|
||||||
|
},
|
||||||
|
})}&q=gitbook`,
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||||
|
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||||
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 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({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Search,
|
||||||
|
},
|
||||||
|
})}&q=gitbook`,
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||||
|
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||||
|
await expect(page.getByTestId('search-results')).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({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Assistant,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||||
|
|
||||||
|
// Focus search input, expecting recommended questions
|
||||||
|
await searchInput.focus();
|
||||||
|
// 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?');
|
||||||
|
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||||
|
await expect(aiSearchResult).toBeVisible();
|
||||||
|
await aiSearchResult.click();
|
||||||
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Assistant,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await page.keyboard.press('ControlOrMeta+I');
|
||||||
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ask - AI Mode: Assistant - Button',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Assistant,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await page.getByTestId('ai-chat-button').click();
|
||||||
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||||
|
url: `${getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Assistant,
|
||||||
|
},
|
||||||
|
})}&ask=`,
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||||
|
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||||
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||||||
|
url: `${getCustomizationURL({
|
||||||
|
ai: {
|
||||||
|
mode: CustomizationAIMode.Assistant,
|
||||||
|
},
|
||||||
|
})}&ask=What+is+GitBook%3F`,
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||||
|
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||||
|
await expect(page.getByTestId('ai-chat')).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
|
||||||
|
'What is GitBook?'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const testCases: TestsCase[] = [
|
const testCases: TestsCase[] = [
|
||||||
{
|
{
|
||||||
name: 'GitBook Site (Single Variant)',
|
name: 'GitBook Site (Single Variant)',
|
||||||
@@ -53,34 +228,7 @@ const testCases: TestsCase[] = [
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...searchTestCases,
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
|
||||||
const allItems = await page.getByTestId('search-result-item').all();
|
|
||||||
// Expect at least 3 questions
|
|
||||||
await expect(allItems.length).toBeGreaterThan(2);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Not found',
|
name: 'Not found',
|
||||||
url: 'content-not-found',
|
url: 'content-not-found',
|
||||||
@@ -279,34 +427,7 @@ const testCases: TestsCase[] = [
|
|||||||
url: '',
|
url: '',
|
||||||
run: waitForCookiesDialog,
|
run: waitForCookiesDialog,
|
||||||
},
|
},
|
||||||
{
|
...searchTestCases,
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
|
||||||
const allItems = await page.getByTestId('search-result-item').all();
|
|
||||||
// Expect at least 3 questions
|
|
||||||
await expect(allItems.length).toBeGreaterThan(2);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Not found',
|
name: 'Not found',
|
||||||
url: 'content-not-found',
|
url: 'content-not-found',
|
||||||
@@ -932,6 +1053,22 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Page actions',
|
||||||
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Without page actions',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
pageActions: {
|
||||||
|
markdown: false,
|
||||||
|
externalAI: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
run: waitForCookiesDialog,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Ads',
|
name: 'Ads',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
|||||||
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
feedback: {
|
feedback: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
|
|
||||||
aiSearch: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
ai: {
|
ai: {
|
||||||
mode: CustomizationAIMode.None,
|
mode: CustomizationAIMode.None,
|
||||||
},
|
},
|
||||||
@@ -337,6 +333,10 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
pagination: {
|
pagination: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
pageActions: {
|
||||||
|
externalAI: true,
|
||||||
|
markdown: true,
|
||||||
|
},
|
||||||
trademark: {
|
trademark: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const nextConfig = {
|
|||||||
dynamic: 3600, // 1 hour
|
dynamic: 3600, // 1 hour
|
||||||
static: 3600, // 1 hour
|
static: 3600, // 1 hour
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Since content is fully static, we don't want to fetch on hover again
|
||||||
|
optimisticClientCache: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/ini
|
|||||||
|
|
||||||
import { DurableObject } from 'cloudflare:workers';
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
//Only needed to run locally, in prod we'll use the one from do.js
|
||||||
|
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||||
|
|
||||||
// Only needed to run locally, in prod we'll use the one from do.js
|
// Only needed to run locally, in prod we'll use the one from do.js
|
||||||
export class R2WriteBuffer extends DurableObject {
|
export class R2WriteBuffer extends DurableObject {
|
||||||
writePromise;
|
writePromise;
|
||||||
|
|||||||
@@ -36,13 +36,17 @@
|
|||||||
{
|
{
|
||||||
"name": "WRITE_BUFFER",
|
"name": "WRITE_BUFFER",
|
||||||
"class_name": "R2WriteBuffer"
|
"class_name": "R2WriteBuffer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"migrations": [
|
"migrations": [
|
||||||
{
|
{
|
||||||
"tag": "v1",
|
"tag": "v1",
|
||||||
"new_sqlite_classes": ["R2WriteBuffer"]
|
"new_sqlite_classes": ["R2WriteBuffer", "DOShardedTagCache"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
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 {
|
export default class extends WorkerEntrypoint {
|
||||||
async fetch(request) {
|
async fetch(request) {
|
||||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
// - `Request`s are handled by the Next server
|
// - `Request`s are handled by the Next server
|
||||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||||
if (reqOrResp instanceof Response) {
|
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;
|
return reqOrResp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,17 +47,31 @@ export default class extends WorkerEntrypoint {
|
|||||||
'Cloudflare-Workers-Version-Overrides',
|
'Cloudflare-Workers-Version-Overrides',
|
||||||
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
`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: {
|
cf: {
|
||||||
cacheEverything: false,
|
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
|
// If we are in preview mode, we need to send the request to the preview URL
|
||||||
const modifiedUrl = new URL(reqOrResp.url);
|
const modifiedUrl = new URL(reqOrResp.url);
|
||||||
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||||
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||||
return fetch(nextRequest, {
|
return fetch(nextRequest, {
|
||||||
|
// We never want to follow the redirects here.
|
||||||
|
// Redirects are supposed to happen from the client.
|
||||||
|
redirect: 'manual',
|
||||||
cf: {
|
cf: {
|
||||||
cacheEverything: false,
|
cacheEverything: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev",
|
"STAGE": "dev",
|
||||||
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
// 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",
|
||||||
|
"GITBOOK_URL": "http://localhost:8771"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
const localCache = await this.getCacheInstance();
|
const localCache = await this.getCacheInstance();
|
||||||
if (!r2) throw new Error('No R2 bucket');
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
|
||||||
|
// We are in a local middleware environment, we should bypass the cache
|
||||||
|
// and go directly to the server.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Check local cache first if available
|
// Check local cache first if available
|
||||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||||
@@ -43,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||||
CacheValue<CacheType>
|
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);
|
const r2Object = await r2.get(cacheKey);
|
||||||
@@ -134,6 +145,7 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeToR2(key: string, value: string): Promise<void> {
|
async writeToR2(key: string, value: string): Promise<void> {
|
||||||
|
try {
|
||||||
const env = getCloudflareContext().env as {
|
const env = getCloudflareContext().env as {
|
||||||
WRITE_BUFFER: DurableObjectNamespace<
|
WRITE_BUFFER: DurableObjectNamespace<
|
||||||
Rpc.DurableObjectBranded & {
|
Rpc.DurableObjectBranded & {
|
||||||
@@ -147,6 +159,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
const stub = env.WRITE_BUFFER.get(id);
|
const stub = env.WRITE_BUFFER.get(id);
|
||||||
|
|
||||||
await stub.write(key, value);
|
await stub.write(key, value);
|
||||||
|
} catch {
|
||||||
|
// We fallback to writing directly to R2
|
||||||
|
// it can fail locally because the limit is 1Mb per args
|
||||||
|
// It is 32Mb in production, so we should be fine
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
r2?.put(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCacheInstance(): Promise<Cache> {
|
async getCacheInstance(): Promise<Cache> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogger } from '@/lib/logger';
|
import { createLogger, getLogger } from '@/lib/logger';
|
||||||
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||||
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||||
@@ -6,8 +6,9 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
|
|||||||
const originalTagCache = doShardedTagCache({
|
const originalTagCache = doShardedTagCache({
|
||||||
baseShardSize: 12,
|
baseShardSize: 12,
|
||||||
regionalCache: true,
|
regionalCache: true,
|
||||||
// We can probably increase this value even further
|
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
|
||||||
regionalCacheTtlSec: 60,
|
// Because we invalidate the Cache API on update, we can safely set this to true
|
||||||
|
regionalCacheDangerouslyPersistMissingTags: true,
|
||||||
shardReplication: {
|
shardReplication: {
|
||||||
numberOfSoftReplicas: 2,
|
numberOfSoftReplicas: 2,
|
||||||
numberOfHardReplicas: 1,
|
numberOfHardReplicas: 1,
|
||||||
@@ -20,6 +21,7 @@ const originalTagCache = doShardedTagCache({
|
|||||||
export default {
|
export default {
|
||||||
name: 'GitbookTagCache',
|
name: 'GitbookTagCache',
|
||||||
mode: 'nextMode',
|
mode: 'nextMode',
|
||||||
|
// We don't really use this one, as of now it is only used for soft tags
|
||||||
getLastRevalidated: async (tags: string[]) => {
|
getLastRevalidated: async (tags: string[]) => {
|
||||||
const tagsToCheck = tags.filter(softTagFilter);
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
if (tagsToCheck.length === 0) {
|
if (tagsToCheck.length === 0) {
|
||||||
@@ -29,12 +31,20 @@ export default {
|
|||||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||||
},
|
},
|
||||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||||
|
try {
|
||||||
const tagsToCheck = tags.filter(softTagFilter);
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
if (tagsToCheck.length === 0) {
|
if (tagsToCheck.length === 0) {
|
||||||
return false; // If no tags to check, return false
|
return false; // If no tags to check, return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||||
|
} catch (e) {
|
||||||
|
createLogger('gitbookTagCache', {}).error(
|
||||||
|
`hasBeenRevalidated - Error checking tags ${tags.join(', ')}`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return false; // In case of error, return false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
writeTags: async (tags: string[]) => {
|
writeTags: async (tags: string[]) => {
|
||||||
const tagsToWrite = tags.filter(softTagFilter);
|
const tagsToWrite = tags.filter(softTagFilter);
|
||||||
@@ -43,7 +53,7 @@ export default {
|
|||||||
logger.warn('writeTags - No valid tags to write');
|
logger.warn('writeTags - No valid tags to write');
|
||||||
return; // If no tags to write, exit early
|
return; // If no tags to write, exit early
|
||||||
}
|
}
|
||||||
// Write only the filtered tags
|
|
||||||
await originalTagCache.writeTags(tagsToWrite);
|
await originalTagCache.writeTags(tagsToWrite);
|
||||||
},
|
},
|
||||||
} satisfies NextModeTagCache;
|
} satisfies NextModeTagCache;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.14.0",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
@@ -13,15 +14,17 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "^1.4.0",
|
"@opennextjs/cloudflare": "^1.6.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@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-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
"ai": "^4.2.2",
|
"ai": "^4.2.2",
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
@@ -42,14 +45,14 @@
|
|||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"micromark-extension-frontmatter": "^2.0.0",
|
"micromark-extension-frontmatter": "^2.0.0",
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
"next": "^15.3.2",
|
"next": "^15.5.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"object-identity": "^0.1.2",
|
"object-identity": "^0.1.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"@tusbar/cache-control": "^1.0.2",
|
"quick-lru": "^7.0.1",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -68,11 +71,12 @@
|
|||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"warn-once": "^0.1.1",
|
"warn-once": "^0.1.1",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3",
|
||||||
"quick-lru": "^7.0.1"
|
"bidc": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^5.0.5",
|
"@argos-ci/playwright": "^5.0.9",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.54.2",
|
||||||
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/jsontoxml": "^1.0.5",
|
"@types/jsontoxml": "^1.0.5",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
@@ -84,31 +88,29 @@
|
|||||||
"@types/react": "18.3.13",
|
"@types/react": "18.3.13",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
"autoprefixer": "^10",
|
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"psi": "^4.1.0",
|
"psi": "^4.1.0",
|
||||||
"stylelint": "^16.16.0",
|
"stylelint": "^16.16.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^4.1.11",
|
||||||
"ts-essentials": "^10.0.1",
|
"ts-essentials": "^10.0.1",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math && wrangler types",
|
"generate": "./scripts/generate.sh",
|
||||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
|
||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next --turbopack",
|
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build:cloudflare": "opennextjs-cloudflare build",
|
"build:cloudflare": "opennextjs-cloudflare build",
|
||||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
"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: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",
|
"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": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||||
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ export default defineConfig({
|
|||||||
['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }],
|
['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }],
|
||||||
],
|
],
|
||||||
projects: [
|
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',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
channel: 'chrome',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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
|
||||||
+4
-2
@@ -5,8 +5,9 @@ import {
|
|||||||
generateSiteLayoutMetadata,
|
generateSiteLayoutMetadata,
|
||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
|
|
||||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||||
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
interface SiteDynamicLayoutProps {
|
interface SiteDynamicLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -18,13 +19,14 @@ export default async function SiteDynamicLayout({
|
|||||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||||
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
||||||
const forcedTheme = await getThemeFromMiddleware();
|
const forcedTheme = await getThemeFromMiddleware();
|
||||||
|
const withTracking = shouldTrackEvents(await headers());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
withTracking={withTracking}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+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} />;
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import {
|
||||||
|
EmbeddableRootLayout,
|
||||||
|
generateEmbeddableMetadata,
|
||||||
|
generateEmbeddableViewport,
|
||||||
|
} from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
|
||||||
|
interface SiteStaticLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
|
||||||
|
return <EmbeddableRootLayout context={context}>{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 } });
|
||||||
|
}
|
||||||
+3
-2
@@ -5,7 +5,7 @@ import {
|
|||||||
generateSiteLayoutMetadata,
|
generateSiteLayoutMetadata,
|
||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -16,12 +16,13 @@ export default async function SiteStaticLayout({
|
|||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||||
|
const withTracking = shouldTrackEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
withTracking={withTracking}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
+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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+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} />;
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import {
|
||||||
|
EmbeddableRootLayout,
|
||||||
|
generateEmbeddableMetadata,
|
||||||
|
generateEmbeddableViewport,
|
||||||
|
} from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
|
||||||
|
interface SiteStaticLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
|
||||||
|
return <EmbeddableRootLayout context={context}>{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 { 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 maxAge = 7 * 24 * 60 * 60;
|
||||||
|
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'))};
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
const script = d.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
script.async = true;
|
||||||
|
script.src = ${JSON.stringify(getAssetURL('embed/index.js'))};
|
||||||
|
|
||||||
|
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=${maxAge}, immutable`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './useAI';
|
||||||
export * from './useAIChat';
|
export * from './useAIChat';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type { AIMessage } from '@gitbook/api';
|
import type { AIMessage } from '@gitbook/api';
|
||||||
import { DocumentView } from '../../DocumentView';
|
import { DocumentView } from '../../DocumentView';
|
||||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||||
@@ -13,24 +14,31 @@ export function AIMessageView(
|
|||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { message, context, renderToolCalls = true } = props;
|
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{message.steps.map((step, index) => {
|
{message.steps.map((step, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex animate-[fadeIn_500ms_both] flex-col gap-2">
|
<div
|
||||||
|
key={index}
|
||||||
|
className={tcls(
|
||||||
|
'flex animate-fade-in-slow flex-col gap-2',
|
||||||
|
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DocumentView
|
<DocumentView
|
||||||
document={step.content}
|
document={step.content}
|
||||||
context={{
|
context={{
|
||||||
mode: 'default',
|
mode: 'default',
|
||||||
contentContext: context,
|
contentContext: context,
|
||||||
wrapBlocksInSuspense: false,
|
wrapBlocksInSuspense: false,
|
||||||
shouldRenderLinkPreviews: true,
|
withLinkPreviews,
|
||||||
}}
|
}}
|
||||||
style={['space-y-4']}
|
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} />
|
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { getSpaceLanguage } from '@/intl/server';
|
|||||||
import { t } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type {
|
import type {
|
||||||
AIToolCall,
|
AIToolCall,
|
||||||
AIToolCallGetPageContent,
|
AIToolCallGetPageContent,
|
||||||
AIToolCallGetPages,
|
AIToolCallGetPages,
|
||||||
AIToolCallMCP,
|
AIToolCallMCP,
|
||||||
|
AIToolCallOther,
|
||||||
AIToolCallSearch,
|
AIToolCallSearch,
|
||||||
ContentRef,
|
ContentRef,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
@@ -37,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
|
|||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 text-sm text-tint-subtle">
|
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
|
||||||
<Icon
|
<Icon
|
||||||
icon={getIconForToolCall(toolCall)}
|
icon={getIconForToolCall(toolCall)}
|
||||||
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
||||||
@@ -57,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
|||||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||||
case 'mcp':
|
case 'mcp':
|
||||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||||
|
case 'other':
|
||||||
|
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||||
default:
|
default:
|
||||||
return <>{toolCall.tool}</>;
|
return <>{toolCall.tool}</>;
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,15 @@ function DescriptionForMCPToolCall(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DescriptionForOtherToolCall(props: {
|
||||||
|
toolCall: AIToolCallOther;
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
}) {
|
||||||
|
const { toolCall } = props;
|
||||||
|
|
||||||
|
return <p>{toolCall.summary.text}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
async function DescriptionForSearchToolCall(props: {
|
async function DescriptionForSearchToolCall(props: {
|
||||||
toolCall: AIToolCallSearch;
|
toolCall: AIToolCallSearch;
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
@@ -144,17 +157,25 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasResults = toolCall.results.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="-ml-5 group flex w-full flex-col gap-2">
|
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
|
||||||
<summary className="-mx-2 -mt-2 flex cursor-pointer list-none items-center gap-2 rounded-corners:rounded-md py-2 pr-4 pl-7 transition-colors marker:hidden hover:bg-primary-hover">
|
<summary
|
||||||
<div className="flex flex-col">
|
className={tcls(
|
||||||
|
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
|
||||||
|
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col break-words leading-snug">
|
||||||
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
||||||
<p className="text-tint-subtle text-xs">
|
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||||
{toolCall.results.length
|
{hasResults
|
||||||
? t(language, 'search_results_count', toolCall.results.length)
|
? t(language, 'search_results_count', toolCall.results.length)
|
||||||
: t(language, 'search_no_results')}
|
: t(language, 'search_no_results')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasResults ? (
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
||||||
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
||||||
@@ -163,13 +184,15 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
|
{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">
|
||||||
<ol className="space-y-1">
|
<ol className="space-y-1">
|
||||||
{searchResultsWithHrefs.map((result, index) => (
|
{searchResultsWithHrefs.map((result, index) => (
|
||||||
<li
|
<li
|
||||||
key={`${result.pageId}-${index}`}
|
key={`${result.pageId}-${index}`}
|
||||||
className="animate-fadeIn"
|
className="animate-fade-in-slow"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${index * 25}ms`,
|
animationDelay: `${index * 25}ms`,
|
||||||
}}
|
}}
|
||||||
@@ -207,6 +230,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -235,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
|||||||
return 'magnifying-glass';
|
return 'magnifying-glass';
|
||||||
case 'getPages':
|
case 'getPages':
|
||||||
return 'files';
|
return 'files';
|
||||||
|
case 'other':
|
||||||
|
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||||
default:
|
default:
|
||||||
return 'hammer';
|
return 'hammer';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import type { GitBookBaseContext } from '@/lib/context';
|
import type { GitBookBaseContext } from '@/lib/context';
|
||||||
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import {
|
import {
|
||||||
type AIMessage,
|
type AIMessage,
|
||||||
AIMessageRole,
|
AIMessageRole,
|
||||||
@@ -19,6 +20,7 @@ export async function streamRenderAIMessage(
|
|||||||
rawStream: AsyncIterable<AIStreamResponse>,
|
rawStream: AsyncIterable<AIStreamResponse>,
|
||||||
options?: RenderAIMessageOptions
|
options?: RenderAIMessageOptions
|
||||||
) {
|
) {
|
||||||
|
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||||
const message: AIMessage = {
|
const message: AIMessage = {
|
||||||
id: '',
|
id: '',
|
||||||
role: AIMessageRole.Assistant,
|
role: AIMessageRole.Assistant,
|
||||||
@@ -79,7 +81,8 @@ export async function streamRenderAIMessage(
|
|||||||
case 'response_reasoning':
|
case 'response_reasoning':
|
||||||
case 'response_document': {
|
case 'response_document': {
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
const container =
|
||||||
|
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||||
|
|
||||||
step[container] ??= {
|
step[container] ??= {
|
||||||
object: 'document',
|
object: 'document',
|
||||||
@@ -111,6 +114,7 @@ export async function streamRenderAIMessage(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
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 { streamRenderAIMessage } from './api';
|
||||||
import type { RenderAIMessageOptions } from './types';
|
import type { RenderAIMessageOptions } from './types';
|
||||||
|
|
||||||
@@ -12,32 +20,48 @@ export async function* streamAIChatResponse({
|
|||||||
message,
|
message,
|
||||||
messageContext,
|
messageContext,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message?: string;
|
||||||
messageContext: AIMessageContext;
|
messageContext: AIMessageContext;
|
||||||
previousResponseId?: string;
|
previousResponseId?: string;
|
||||||
|
toolCall?: AIToolCallResult;
|
||||||
|
tools?: AIToolDefinition[];
|
||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
const context = await getServerActionBaseContext();
|
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||||
|
let context = await getServerActionBaseContext();
|
||||||
|
if (options?.asEmbeddable) {
|
||||||
|
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||||
|
}
|
||||||
|
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
const api = await context.dataFetcher.api();
|
const api = await context.dataFetcher.api();
|
||||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
const rawStream = api.orgs.streamAiResponseInSite(
|
||||||
mode: 'assistant',
|
siteURLData.organization,
|
||||||
input: [
|
siteURLData.site,
|
||||||
|
{
|
||||||
|
input: message
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
role: AIMessageRole.User,
|
||||||
content: message,
|
content: message,
|
||||||
context: messageContext,
|
context: messageContext,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
output: { type: 'document' },
|
: [],
|
||||||
model: AIModel.ReasoningLow,
|
model: AIModel.ReasoningLow,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
});
|
toolCall,
|
||||||
|
tools,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
return await streamRenderAIMessage(context, rawStream, options);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const output of stream) {
|
for await (const output of stream) {
|
||||||
yield output;
|
yield output;
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './responses';
|
|
||||||
export * from './chat';
|
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.
|
* Render the tool calls.
|
||||||
* @default true
|
* @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,15 +2,40 @@
|
|||||||
|
|
||||||
import * as zustand from 'zustand';
|
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 * as React from 'react';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { streamAIChatResponse } from './server-actions';
|
import { integrationsAssistantTools } from '../Integrations';
|
||||||
|
import { useSearch } from '../Search';
|
||||||
|
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||||
|
|
||||||
export type AIChatMessage = {
|
export type AIChatMessage = {
|
||||||
role: AIMessageRole;
|
role: AIMessageRole;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
|
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 = {
|
export type AIChatState = {
|
||||||
@@ -24,6 +49,16 @@ export type AIChatState = {
|
|||||||
*/
|
*/
|
||||||
responseId: string | null;
|
responseId: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest query sent to the AI.
|
||||||
|
*/
|
||||||
|
query: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first query sent to the AI. This is appended to the URL when the AI chat is opened.
|
||||||
|
*/
|
||||||
|
initialQuery: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages in the session.
|
* Messages in the session.
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +69,11 @@ export type AIChatState = {
|
|||||||
*/
|
*/
|
||||||
followUpSuggestions: string[];
|
followUpSuggestions: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools that are pending confirmation to be executed.
|
||||||
|
*/
|
||||||
|
pendingTools: AIChatPendingTool[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, the session is in progress.
|
* If true, the session is in progress.
|
||||||
*/
|
*/
|
||||||
@@ -50,34 +90,28 @@ export type AIChatState = {
|
|||||||
export type AIChatController = {
|
export type AIChatController = {
|
||||||
/** Open the dialog */
|
/** Open the dialog */
|
||||||
open: () => void;
|
open: () => void;
|
||||||
|
|
||||||
/** Close the dialog */
|
/** Close the dialog */
|
||||||
close: () => void;
|
close: () => void;
|
||||||
|
|
||||||
/** Post a message to the session */
|
/** Post a message to the session */
|
||||||
postMessage: (input: {
|
postMessage: (input: { message: string }) => void;
|
||||||
/** The message to post to the session. it can be markdown formatted. */
|
|
||||||
message: string;
|
|
||||||
}) => void;
|
|
||||||
|
|
||||||
/** Clear the conversation */
|
/** Clear the conversation */
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globalState = zustand.create<{
|
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||||
state: AIChatState;
|
|
||||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
// Global state store for AI chat
|
||||||
}>((set) => {
|
const globalState = zustand.create<AIChatState>(() => {
|
||||||
return {
|
return {
|
||||||
state: {
|
|
||||||
opened: false,
|
opened: false,
|
||||||
responseId: null,
|
responseId: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
},
|
initialQuery: null,
|
||||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,70 +119,146 @@ const globalState = zustand.create<{
|
|||||||
* Get the current state of the AI chat.
|
* Get the current state of the AI chat.
|
||||||
*/
|
*/
|
||||||
export function useAIChatState(): AIChatState {
|
export function useAIChatState(): AIChatState {
|
||||||
const state = zustand.useStore(globalState, (state) => state.state);
|
const state = zustand.useStore(globalState);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the controller to interact with the AI chat.
|
* Provide the controller to interact with the AI chat.
|
||||||
*/
|
*/
|
||||||
export function useAIChatController(): AIChatController {
|
export function AIChatProvider(props: {
|
||||||
const messageContextRef = useAIMessageContextRef();
|
renderMessageOptions?: RenderAIMessageOptions;
|
||||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
children: React.ReactNode;
|
||||||
const trackEvent = useTrackEvent();
|
}) {
|
||||||
|
const { renderMessageOptions, children } = props;
|
||||||
|
|
||||||
return React.useMemo(() => {
|
const messageContextRef = useAIMessageContextRef();
|
||||||
return {
|
const trackEvent = useTrackEvent();
|
||||||
open: () => setState((state) => ({ ...state, opened: true })),
|
const [, setSearchState] = useSearch();
|
||||||
close: () => setState((state) => ({ ...state, opened: false })),
|
const language = useLanguage();
|
||||||
clear: () =>
|
|
||||||
setState((state) => ({
|
// Open AI chat and sync with search state
|
||||||
opened: state.opened,
|
const onOpen = React.useCallback(() => {
|
||||||
loading: false,
|
const { initialQuery } = globalState.getState();
|
||||||
messages: [],
|
globalState.setState((state) => ({ ...state, opened: true }));
|
||||||
followUpSuggestions: [],
|
|
||||||
responseId: null,
|
// Update search state to show ask mode with first message or current ask value
|
||||||
error: false,
|
setSearchState((prev) => ({
|
||||||
})),
|
ask: prev?.ask ?? initialQuery ?? '',
|
||||||
postMessage: async (input: { message: string }) => {
|
query: prev?.query ?? null,
|
||||||
trackEvent({ type: 'ask_question', query: input.message });
|
global: prev?.global ?? false,
|
||||||
setState((state) => {
|
open: false, // Close search popover when opening chat
|
||||||
|
}));
|
||||||
|
}, [setSearchState]);
|
||||||
|
|
||||||
|
// Close AI chat and clear ask parameter
|
||||||
|
const onClose = React.useCallback(() => {
|
||||||
|
globalState.setState((state) => ({ ...state, opened: false }));
|
||||||
|
|
||||||
|
// Clear ask parameter but keep other search state
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: null,
|
||||||
|
query: prev?.query ?? null,
|
||||||
|
global: prev?.global ?? false,
|
||||||
|
open: false,
|
||||||
|
}));
|
||||||
|
}, [setSearchState]);
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages,
|
...state.messages,
|
||||||
{
|
|
||||||
// TODO: how to handle markdown here?
|
|
||||||
// to avoid rendering as plain text
|
|
||||||
role: AIMessageRole.User,
|
|
||||||
content: input.message,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
role: AIMessageRole.Assistant,
|
role: AIMessageRole.Assistant,
|
||||||
content: null,
|
content: null, // Placeholder for streaming response
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
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 {
|
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({
|
const stream = await streamAIChatResponse({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
|
toolCall: input.toolCall,
|
||||||
messageContext: messageContextRef.current,
|
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) {
|
for await (const data of stream) {
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
|
|
||||||
|
if (input.message && globalState.getState().query !== input.message) {
|
||||||
|
// Chat was cleared, stop processing the stream
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const event = data.event;
|
const event = data.event;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'response_finish': {
|
case 'response_finish': {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
responseId: event.responseId,
|
responseId: event.responseId,
|
||||||
// Mark as not loading when the response is finished
|
// Mark as not loading when the response is finished
|
||||||
@@ -159,7 +269,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'response_followup_suggestion': {
|
case 'response_followup_suggestion': {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
followUpSuggestions: [
|
followUpSuggestions: [
|
||||||
...state.followUpSuggestions,
|
...state.followUpSuggestions,
|
||||||
@@ -168,9 +278,53 @@ export function useAIChatController(): AIChatController {
|
|||||||
}));
|
}));
|
||||||
break;
|
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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((state) => ({
|
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
|
||||||
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages.slice(0, -1),
|
...state.messages.slice(0, -1),
|
||||||
@@ -182,19 +336,134 @@ 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,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch (error) {
|
||||||
setState((state) => ({
|
console.error('Error streaming AI response', error);
|
||||||
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: true,
|
error: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
[
|
||||||
}, [messageContextRef, setState, 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(() => {
|
||||||
|
globalState.setState((state) => ({
|
||||||
|
opened: state.opened,
|
||||||
|
loading: false,
|
||||||
|
messages: [],
|
||||||
|
query: null,
|
||||||
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
|
responseId: null,
|
||||||
|
error: false,
|
||||||
|
initialQuery: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: '',
|
||||||
|
query: prev?.query ?? null,
|
||||||
|
global: prev?.global ?? false,
|
||||||
|
open: false,
|
||||||
|
}));
|
||||||
|
}, [setSearchState]);
|
||||||
|
|
||||||
|
const controller = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
open: onOpen,
|
||||||
|
close: onClose,
|
||||||
|
clear: onClear,
|
||||||
|
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,12 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAIChatController } from '@/components/AI/useAIChat';
|
import { useAIChatState } from '@/components/AI';
|
||||||
import { useAIChatState } from '@/components/AI/useAIChat';
|
import type { Assistant } from '@/components/AI';
|
||||||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||||
import { getAIChatName } from '@/components/AIChat';
|
|
||||||
import { AIChatIcon } from '@/components/AIChat';
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
@@ -22,32 +20,21 @@ type AIActionType = 'button' | 'dropdown-menu-item';
|
|||||||
/**
|
/**
|
||||||
* Opens our AI Docs Assistant.
|
* Opens our AI Docs Assistant.
|
||||||
*/
|
*/
|
||||||
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
|
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||||
const { type, trademark } = props;
|
const { assistant, type } = props;
|
||||||
const chatController = useAIChatController();
|
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIActionWrapper
|
<AIActionWrapper
|
||||||
type={type}
|
type={type}
|
||||||
icon={
|
icon={assistant.icon}
|
||||||
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
|
label={assistant.label}
|
||||||
}
|
|
||||||
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
|
|
||||||
shortLabel={tString(language, 'ask')}
|
shortLabel={tString(language, 'ask')}
|
||||||
description={tString(language, 'ai_chat_ask_about_page', getAIChatName(trademark))}
|
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
|
||||||
disabled={chat.loading}
|
disabled={chat.loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Open the chat if it's not already open
|
assistant.open(tString(language, 'ai_chat_suggested_questions_about_this_page'));
|
||||||
if (!chat.opened) {
|
|
||||||
chatController.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the "What is this page about?" message
|
|
||||||
chatController.postMessage({
|
|
||||||
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -231,13 +218,15 @@ function AIActionWrapper(props: {
|
|||||||
}
|
}
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
label={shortLabel || label}
|
label={label ?? shortLabel}
|
||||||
className="hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm"
|
className="bg-tint-base text-sm"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
href={href}
|
href={href}
|
||||||
target={href ? '_blank' : undefined}
|
target={href ? '_blank' : undefined}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
/>
|
>
|
||||||
|
{shortLabel}
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,13 +240,13 @@ function AIActionWrapper(props: {
|
|||||||
>
|
>
|
||||||
<div className="flex size-5 items-center justify-center text-tint">
|
<div className="flex size-5 items-center justify-center text-tint">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Icon icon="spinner-third" className="size-4 animate-spin" />
|
<Icon icon="spinner-third" className="size-4 shrink-0 animate-spin" />
|
||||||
) : icon ? (
|
) : icon ? (
|
||||||
typeof icon === 'string' ? (
|
typeof icon === 'string' ? (
|
||||||
<Icon
|
<Icon
|
||||||
icon={icon as IconName}
|
icon={icon as IconName}
|
||||||
iconStyle={IconStyle.Regular}
|
iconStyle={IconStyle.Regular}
|
||||||
className="size-4 fill-transparent stroke-current"
|
className="size-4 shrink-0 fill-transparent stroke-current"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
icon
|
icon
|
||||||
@@ -268,9 +257,9 @@ function AIActionWrapper(props: {
|
|||||||
<div className="flex flex-1 flex-col gap-0.5">
|
<div className="flex flex-1 flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-2 text-tint-strong">
|
<span className="flex items-center gap-2 text-tint-strong">
|
||||||
<span className="truncate font-medium text-sm">{label}</span>
|
<span className="truncate font-medium text-sm">{label}</span>
|
||||||
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
|
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
|
||||||
</span>
|
</span>
|
||||||
{description && <span className="truncate text-tint text-xs">{description}</span>}
|
{description && <span className="text-tint text-xs">{description}</span>}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,24 +2,22 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CopyMarkdown,
|
CopyMarkdown,
|
||||||
OpenDocsAssistant,
|
OpenAIAssistant,
|
||||||
OpenInLLM,
|
OpenInLLM,
|
||||||
ViewAsMarkdown,
|
ViewAsMarkdown,
|
||||||
} from '@/components/AIActions/AIActions';
|
} from '@/components/AIActions/AIActions';
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||||
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
|
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useAI } from '../AI';
|
||||||
|
|
||||||
interface AIActionsDropdownProps {
|
interface AIActionsDropdownProps {
|
||||||
markdownPageUrl: string;
|
markdownPageUrl: string;
|
||||||
withAIChat?: boolean;
|
className?: string;
|
||||||
trademark: boolean;
|
actions: SiteCustomizationSettings['pageActions'];
|
||||||
/**
|
|
||||||
* Whether to include the "Open in LLM" entries in the dropdown menu.
|
|
||||||
*/
|
|
||||||
withLLMActions?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +25,15 @@ interface AIActionsDropdownProps {
|
|||||||
*/
|
*/
|
||||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const assistants = useAI().assistants.filter(
|
||||||
|
(assistant) => assistant.ui === true && assistant.pageAction
|
||||||
|
);
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
||||||
<div ref={ref} className="flex h-fit items-stretch justify-start">
|
<ButtonGroup ref={ref} className={props.className}>
|
||||||
<DefaultAction {...props} />
|
<DefaultAction {...props} />
|
||||||
|
{props.actions.markdown || props.actions.externalAI ? (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
align="end"
|
align="end"
|
||||||
className="!min-w-60 max-w-max"
|
className="!min-w-60 max-w-max"
|
||||||
@@ -42,40 +45,55 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
|||||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
label={tString(language, 'more')}
|
||||||
iconOnly
|
iconOnly
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm"
|
className="bg-tint-base text-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AIActionsDropdownMenuContent {...props} />
|
<AIActionsDropdownMenuContent {...props} />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
) : null}
|
||||||
);
|
</ButtonGroup>
|
||||||
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The content of the dropdown menu.
|
* The content of the dropdown menu.
|
||||||
*/
|
*/
|
||||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||||
const { markdownPageUrl, withAIChat, trademark, withLLMActions } = props;
|
const { markdownPageUrl, actions } = props;
|
||||||
|
const assistants = useAI().assistants.filter(
|
||||||
|
(assistant) => assistant.ui === true && assistant.pageAction
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withAIChat ? (
|
{assistants.map((assistant) => (
|
||||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
<OpenAIAssistant
|
||||||
) : null}
|
key={assistant.label}
|
||||||
|
assistant={assistant}
|
||||||
|
type="dropdown-menu-item"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{actions.markdown ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="first:hidden" />
|
||||||
<CopyMarkdown
|
<CopyMarkdown
|
||||||
isDefaultAction={!withAIChat}
|
isDefaultAction={!assistants.length}
|
||||||
markdownPageUrl={markdownPageUrl}
|
markdownPageUrl={markdownPageUrl}
|
||||||
type="dropdown-menu-item"
|
type="dropdown-menu-item"
|
||||||
/>
|
/>
|
||||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{withLLMActions ? (
|
{actions.externalAI ? (
|
||||||
<>
|
<>
|
||||||
|
<DropdownMenuSeparator className="first:hidden" />
|
||||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
</>
|
</>
|
||||||
@@ -88,17 +106,30 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
|||||||
* A default action shown as a quick-access button beside the dropdown menu
|
* A default action shown as a quick-access button beside the dropdown menu
|
||||||
*/
|
*/
|
||||||
function DefaultAction(props: AIActionsDropdownProps) {
|
function DefaultAction(props: AIActionsDropdownProps) {
|
||||||
const { markdownPageUrl, withAIChat, trademark } = props;
|
const { markdownPageUrl, actions } = props;
|
||||||
|
const assistants = useAI().assistants.filter(
|
||||||
|
(assistant) => assistant.ui === true && assistant.pageAction
|
||||||
|
);
|
||||||
|
|
||||||
if (withAIChat) {
|
if (assistants.length) {
|
||||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actions.markdown) {
|
||||||
return (
|
return (
|
||||||
<CopyMarkdown
|
<CopyMarkdown
|
||||||
isDefaultAction={!withAIChat}
|
isDefaultAction={!assistants.length}
|
||||||
markdownPageUrl={markdownPageUrl}
|
markdownPageUrl={markdownPageUrl}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actions.externalAI) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@@ -9,10 +10,19 @@ import {
|
|||||||
type AIChatState,
|
type AIChatState,
|
||||||
useAIChatController,
|
useAIChatController,
|
||||||
useAIChatState,
|
useAIChatState,
|
||||||
} from '../AI/useAIChat';
|
} from '../AI';
|
||||||
|
import {
|
||||||
|
EmbeddableFrame,
|
||||||
|
EmbeddableFrameBody,
|
||||||
|
EmbeddableFrameButtons,
|
||||||
|
EmbeddableFrameHeader,
|
||||||
|
EmbeddableFrameHeaderMain,
|
||||||
|
EmbeddableFrameSubtitle,
|
||||||
|
EmbeddableFrameTitle,
|
||||||
|
} from '../Embeddable/EmbeddableFrame';
|
||||||
import { useNow } from '../hooks';
|
import { useNow } from '../hooks';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
import { AIChatControlButton } from './AIChatControlButton';
|
||||||
import { AIChatIcon } from './AIChatIcon';
|
import { AIChatIcon } from './AIChatIcon';
|
||||||
import { AIChatInput } from './AIChatInput';
|
import { AIChatInput } from './AIChatInput';
|
||||||
import { AIChatMessages } from './AIChatMessages';
|
import { AIChatMessages } from './AIChatMessages';
|
||||||
@@ -20,16 +30,31 @@ import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
|||||||
|
|
||||||
export function AIChat(props: { trademark: boolean }) {
|
export function AIChat(props: { trademark: boolean }) {
|
||||||
const { trademark } = props;
|
const { trademark } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// When the chat is opened, scroll to it (applicable on mobile, where it's displayed above the content)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (chat.opened) {
|
||||||
|
containerRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [chat.opened]);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+j',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chatController.open();
|
chatController.open();
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
@@ -44,19 +69,102 @@ export function AIChat(props: { trademark: boolean }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AIChatWindow trademark={trademark} chatController={chatController} chat={chat} />;
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="ai-chat"
|
||||||
|
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
|
||||||
|
>
|
||||||
|
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||||
|
<EmbeddableFrameHeader>
|
||||||
|
<AIChatDynamicIcon trademark={trademark} />
|
||||||
|
<EmbeddableFrameHeaderMain>
|
||||||
|
<EmbeddableFrameTitle>
|
||||||
|
{getAIChatName(language, trademark)}
|
||||||
|
</EmbeddableFrameTitle>
|
||||||
|
<AIChatSubtitle chat={chat} />
|
||||||
|
</EmbeddableFrameHeaderMain>
|
||||||
|
<EmbeddableFrameButtons>
|
||||||
|
<AIChatControlButton />
|
||||||
|
<Button
|
||||||
|
onClick={() => chatController.close()}
|
||||||
|
iconOnly
|
||||||
|
icon="close"
|
||||||
|
label={tString(language, 'close')}
|
||||||
|
variant="blank"
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
</EmbeddableFrameButtons>
|
||||||
|
</EmbeddableFrameHeader>
|
||||||
|
<EmbeddableFrameBody>
|
||||||
|
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
||||||
|
</EmbeddableFrameBody>
|
||||||
|
</EmbeddableFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatWindow(props: {
|
/**
|
||||||
|
* Dynamic icon to indicate the AI chat state.
|
||||||
|
*/
|
||||||
|
export function AIChatDynamicIcon(props: {
|
||||||
|
trademark: boolean;
|
||||||
|
}) {
|
||||||
|
const { trademark } = props;
|
||||||
|
const chat = useAIChatState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIChatIcon
|
||||||
|
className="size-5 text-tint"
|
||||||
|
trademark={trademark}
|
||||||
|
state={
|
||||||
|
chat.error
|
||||||
|
? 'error'
|
||||||
|
: chat.loading
|
||||||
|
? chat.messages[chat.messages.length - 1]?.content
|
||||||
|
? 'working'
|
||||||
|
: 'thinking'
|
||||||
|
: chat.messages.length > 0
|
||||||
|
? chat.pendingTools.length > 0
|
||||||
|
? 'confirm'
|
||||||
|
: 'done'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitle of the AI chat window.
|
||||||
|
*/
|
||||||
|
export function AIChatSubtitle(props: {
|
||||||
|
chat: AIChatState;
|
||||||
|
}) {
|
||||||
|
const { chat } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
|
||||||
|
{chat.messages[chat.messages.length - 1]?.content
|
||||||
|
? tString(language, 'ai_chat_working')
|
||||||
|
: tString(language, 'ai_chat_thinking')}
|
||||||
|
</EmbeddableFrameSubtitle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the AI chat window.
|
||||||
|
*/
|
||||||
|
export function AIChatBody(props: {
|
||||||
chatController: AIChatController;
|
chatController: AIChatController;
|
||||||
chat: AIChatState;
|
chat: AIChatState;
|
||||||
trademark: boolean;
|
trademark: boolean;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
suggestions?: string[];
|
||||||
}) {
|
}) {
|
||||||
const { chatController, chat, trademark } = props;
|
const { chatController, chat, trademark, suggestions } = props;
|
||||||
|
|
||||||
const [input, setInput] = React.useState('');
|
const [input, setInput] = React.useState('');
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
// Ref for the last user message element
|
// Ref for the last user message element
|
||||||
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
|
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -87,11 +195,14 @@ export function AIChatWindow(props: {
|
|||||||
}, [chat.messages.length]);
|
}, [chat.messages.length]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// When the chat is opened, scroll to it (applicable on mobile, where it's displayed above the content)
|
const timeout = setTimeout(() => {
|
||||||
containerRef.current?.scrollIntoView({
|
if (lastUserMessageRef.current) {
|
||||||
|
lastUserMessageRef.current.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
|
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
|
||||||
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
|
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
|
||||||
@@ -103,80 +214,14 @@ export function AIChatWindow(props: {
|
|||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
observer.observe(inputRef.current);
|
observer.observe(inputRef.current);
|
||||||
}
|
}
|
||||||
return () => observer.disconnect();
|
return () => {
|
||||||
}, [chat.opened]);
|
observer.disconnect();
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
|
||||||
<div className="flex items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
|
|
||||||
<AIChatIcon
|
|
||||||
className="size-5 text-tint"
|
|
||||||
trademark={trademark}
|
|
||||||
state={
|
|
||||||
chat.error
|
|
||||||
? 'error'
|
|
||||||
: chat.loading
|
|
||||||
? chat.messages[chat.messages.length - 1].content
|
|
||||||
? 'working'
|
|
||||||
: 'thinking'
|
|
||||||
: chat.messages.length > 0
|
|
||||||
? 'done'
|
|
||||||
: 'default'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="font-bold">{getAIChatName(trademark)}</div>
|
|
||||||
<div
|
|
||||||
className={`text-tint text-xs leading-none transition-all duration-500 ${
|
|
||||||
chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{chat.loading
|
|
||||||
? chat.messages[chat.messages.length - 1].content
|
|
||||||
? t(language, 'ai_chat_working')
|
|
||||||
: t(language, 'ai_chat_thinking')
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex gap-2">
|
|
||||||
<DropdownMenu
|
|
||||||
button={
|
|
||||||
<Button
|
|
||||||
onClick={() => {}}
|
|
||||||
iconOnly
|
|
||||||
icon="ellipsis"
|
|
||||||
label={tString(language, 'more')}
|
|
||||||
className="!px-2"
|
|
||||||
variant="blank"
|
|
||||||
size="default"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
chatController.clear();
|
|
||||||
}}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
<Icon icon="broom-wide" className="size-3 text-tint-subtle" />
|
|
||||||
{t(language, 'ai_chat_clear_conversation')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
onClick={() => chatController.close()}
|
|
||||||
iconOnly
|
|
||||||
icon="close"
|
|
||||||
label={tString(language, 'close')}
|
|
||||||
className="!px-2"
|
|
||||||
variant="blank"
|
|
||||||
size="default"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
|
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
|
||||||
@@ -186,7 +231,7 @@ export function AIChatWindow(props: {
|
|||||||
>
|
>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
||||||
<div className="flex size-32 animate-[fadeIn_500ms_both] items-center justify-center rounded-full bg-tint-subtle">
|
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
|
||||||
<AIChatIcon
|
<AIChatIcon
|
||||||
state="intro"
|
state="intro"
|
||||||
trademark={trademark}
|
trademark={trademark}
|
||||||
@@ -202,7 +247,10 @@ export function AIChatWindow(props: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!chat.error ? (
|
{!chat.error ? (
|
||||||
<AIChatSuggestedQuestions chatController={chatController} />
|
<AIChatSuggestedQuestions
|
||||||
|
chatController={chatController}
|
||||||
|
suggestions={suggestions}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -215,7 +263,7 @@ export function AIChatWindow(props: {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
||||||
>
|
>
|
||||||
{/* Display an error banner when something went wrong. */}
|
{/* Display an error banner when something went wrong. */}
|
||||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||||
@@ -231,8 +279,7 @@ export function AIChatWindow(props: {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +288,7 @@ function AIChatError(props: { chatController: AIChatController }) {
|
|||||||
const { chatController } = props;
|
const { chatController } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-between gap-2 rounded-md bg-danger p-3 text-danger text-sm ring-1 ring-danger">
|
<div className="flex animate-scale-in flex-wrap justify-between gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md bg-danger px-3 py-2 text-danger text-sm ring-1 ring-danger">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon icon="exclamation-triangle" className="size-3.5" />
|
<Icon icon="exclamation-triangle" className="size-3.5" />
|
||||||
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
|
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
|
||||||
@@ -255,16 +302,14 @@ function AIChatError(props: { chatController: AIChatController }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
chatController.clear();
|
chatController.clear();
|
||||||
}}
|
}}
|
||||||
className="!text-danger hover:bg-danger-5"
|
className="text-danger! hover:bg-danger-5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIChatName(trademark: boolean) {
|
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
|
||||||
const language = useLanguage();
|
|
||||||
|
|
||||||
return trademark
|
return trademark
|
||||||
? tString(language, 'ai_chat_assistant_name')
|
? tString(language, 'ai_chat_assistant_name')
|
||||||
: tString(language, 'ai_chat_assistant_name_unbranded');
|
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
import type { Assistant } from '../AI';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { getAIChatName } from './AIChat';
|
|
||||||
import { AIChatIcon } from './AIChatIcon';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button to open/close the AI chat.
|
* Button to open/close the AI chat.
|
||||||
*/
|
*/
|
||||||
export function AIChatButton(props: { trademark: boolean }) {
|
export function AIChatButton(props: {
|
||||||
const { trademark } = props;
|
assistant: Assistant;
|
||||||
const chatController = useAIChatController();
|
showLabel?: boolean;
|
||||||
const chat = useAIChatState();
|
}) {
|
||||||
|
const { assistant, showLabel = true } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
icon={<AIChatIcon trademark={trademark} />}
|
icon={assistant.icon}
|
||||||
iconOnly
|
data-testid="ai-chat-button"
|
||||||
size="default"
|
iconOnly={!showLabel}
|
||||||
|
size="medium"
|
||||||
variant="header"
|
variant="header"
|
||||||
className={tcls('h-9 px-2.5')}
|
className={tcls('h-9 px-2.5')}
|
||||||
label={
|
label={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getAIChatName(trademark)}
|
{t(language, 'ai_chat_ask', assistant.label)}
|
||||||
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => assistant.open()}
|
||||||
if (chat.opened) {
|
>
|
||||||
chatController.close();
|
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||||
} else {
|
</Button>
|
||||||
chatController.open();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t, tString } from '@/intl/translate';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { useAIChatController, useAIChatState } from '../AI';
|
||||||
|
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button to control the chat (clear, etc.)
|
||||||
|
*/
|
||||||
|
export function AIChatControlButton() {
|
||||||
|
const language = useLanguage();
|
||||||
|
const chat = useAIChatState();
|
||||||
|
const chatController = useAIChatController();
|
||||||
|
|
||||||
|
return chat.messages.length > 0 ? (
|
||||||
|
<DropdownMenu
|
||||||
|
button={
|
||||||
|
<Button
|
||||||
|
onClick={() => {}}
|
||||||
|
iconOnly
|
||||||
|
icon="ellipsis"
|
||||||
|
label={tString(language, 'actions')}
|
||||||
|
variant="blank"
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
chatController.clear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
|
||||||
|
{t(language, 'ai_chat_clear_conversation')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type React from 'react';
|
|||||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
|
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||||
trademark?: boolean;
|
trademark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export const AIChatIcon = ({
|
|||||||
preserveAspectRatio="xMaxYMid meet"
|
preserveAspectRatio="xMaxYMid meet"
|
||||||
className={className}
|
className={className}
|
||||||
aria-busy={state === 'thinking'}
|
aria-busy={state === 'thinking'}
|
||||||
|
overflow="visible"
|
||||||
{...props}
|
{...props}
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -54,8 +55,9 @@ export const AIChatIcon = ({
|
|||||||
state === 'intro' &&
|
state === 'intro' &&
|
||||||
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
|
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
|
||||||
(state === 'working' || state === 'thinking') &&
|
(state === 'working' || state === 'thinking') &&
|
||||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||||
|
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
||||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||||
state === 'error' && 'hidden'
|
state === 'error' && 'hidden'
|
||||||
)}
|
)}
|
||||||
@@ -95,10 +97,24 @@ export const AIChatIcon = ({
|
|||||||
state === 'done'
|
state === 'done'
|
||||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||||
: 'animate-[fadeOut_.5s_both]',
|
: 'animate-[fadeOut_.5s_both]',
|
||||||
state === 'intro' && 'hidden'
|
state === 'intro' && 'hidden',
|
||||||
|
state === 'confirm' && 'hidden'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Confirm */}
|
||||||
|
<path
|
||||||
|
className={tcls(
|
||||||
|
'fill-primary-9',
|
||||||
|
state === 'confirm'
|
||||||
|
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
||||||
|
: state === 'thinking'
|
||||||
|
? 'animate-[fadeOut_.5s_both]'
|
||||||
|
: 'hidden'
|
||||||
|
)}
|
||||||
|
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<path
|
<path
|
||||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { Button } from '../primitives';
|
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { Tooltip } from '../primitives/Tooltip';
|
|
||||||
|
|
||||||
export function AIChatInput(props: {
|
export function AIChatInput(props: {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -38,33 +37,36 @@ export function AIChatInput(props: {
|
|||||||
// This fixes inconsistent focus behaviour across browsers
|
// This fixes inconsistent focus behaviour across browsers
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 50);
|
}, 150);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [disabled, loading]);
|
}, [disabled, loading]);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+j',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
data-loading={loading}
|
data-loading={loading}
|
||||||
|
data-testid="ai-chat-input"
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'resize-none',
|
'resize-none',
|
||||||
'focus:outline-none',
|
'focus:outline-hidden',
|
||||||
'focus:ring-0',
|
'focus:ring-0',
|
||||||
'w-full',
|
'w-full',
|
||||||
'px-4',
|
'px-3',
|
||||||
'py-3',
|
'py-3',
|
||||||
'pb-12',
|
'pb-12',
|
||||||
'h-auto',
|
'h-auto',
|
||||||
@@ -98,14 +100,21 @@ export function AIChatInput(props: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2.5 right-4 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
{!disabled ? (
|
||||||
<KeyboardShortcut keys={['mod', 'j']} />
|
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||||
|
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
) : null}
|
||||||
<Tooltip
|
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||||
label={
|
<HoverCardRoot>
|
||||||
|
<HoverCard
|
||||||
|
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
||||||
|
arrow={{ className: 'fill-tint-3' }}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-3 p-2">
|
<div className="flex flex-col gap-3 p-2">
|
||||||
<p>{t(language, 'ai_chat_context_description')}</p>
|
<p className="font-semibold">
|
||||||
|
{t(language, 'ai_chat_context_description')}
|
||||||
|
</p>
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<Icon icon="memo" className="size-3.5 opacity-7" />
|
<Icon icon="memo" className="size-3.5 opacity-7" />
|
||||||
@@ -122,16 +131,19 @@ export function AIChatInput(props: {
|
|||||||
</ul>
|
</ul>
|
||||||
<p>{t(language, 'ai_chat_context_disclaimer')}</p>
|
<p>{t(language, 'ai_chat_context_disclaimer')}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
</HoverCard>
|
||||||
arrow
|
<HoverCardTrigger>
|
||||||
>
|
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
|
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||||
<span className="-ml-1 rounded-corners:rounded circular-corners:rounded-2xl bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
|
||||||
{t(language, 'ai_chat_context_badge')}
|
{t(language, 'ai_chat_context_badge')}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span>{t(language, 'ai_chat_context_title')}</span>
|
<span className="leading-none">
|
||||||
|
{t(language, 'ai_chat_context_title')}
|
||||||
|
</span>
|
||||||
|
<Icon icon="question-circle" className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</HoverCardTrigger>
|
||||||
|
</HoverCardRoot>
|
||||||
<Button
|
<Button
|
||||||
label={tString(language, 'send')}
|
label={tString(language, 'send')}
|
||||||
size="medium"
|
size="medium"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { AIChatController, AIChatState } from '../AI/useAIChat';
|
import type { AIChatController, AIChatState } from '../AI';
|
||||||
|
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||||
|
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||||
|
|
||||||
export function AIChatMessages(props: {
|
export function AIChatMessages(props: {
|
||||||
@@ -22,30 +24,33 @@ export function AIChatMessages(props: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||||
|
data-testid="ai-chat-message"
|
||||||
className={tcls(
|
className={tcls(
|
||||||
message.content ? 'animate-[fadeIn_500ms_both]' : '',
|
message.content ? 'animate-fade-in-slow' : '',
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
'last:min-h-[calc(100%-5rem)]',
|
'last:min-h-[calc(100%-5rem)]',
|
||||||
'scroll-mt-36',
|
'scroll-mt-36',
|
||||||
'lg:scroll-mt-0',
|
'lg:scroll-mt-0',
|
||||||
'flex flex-col gap-6',
|
'flex flex-col gap-6',
|
||||||
|
'break-words',
|
||||||
|
'group/message',
|
||||||
message.role === AIMessageRole.User
|
message.role === AIMessageRole.User
|
||||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||||
: ''
|
: 'text-tint-strong'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||||
}}
|
}}
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
{message.content ? (
|
{message.content ? message.content : null}
|
||||||
message.content
|
|
||||||
) : chat.loading ? (
|
{isLastMessage && chat.loading ? (
|
||||||
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2">
|
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
|
||||||
{Array.from({ length: 7 }).map((_, index) => (
|
{Array.from({ length: 7 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="h-4 animate-[fadeIn_0.5s_ease_both,pulse_1.5s_infinite] rounded-md bg-tint-4"
|
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||||
style={{
|
style={{
|
||||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||||
animationDelay: `${index * 0.1}s`,
|
animationDelay: `${index * 0.1}s`,
|
||||||
@@ -54,11 +59,26 @@ export function AIChatMessages(props: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLastMessage ? (
|
{isLastMessage ? (
|
||||||
|
<>
|
||||||
|
{!chat.loading &&
|
||||||
|
!chat.error &&
|
||||||
|
chat.query &&
|
||||||
|
chat.responseId &&
|
||||||
|
chat.pendingTools.length === 0 ? (
|
||||||
|
<AIResponseFeedback
|
||||||
|
responseId={chat.responseId}
|
||||||
|
query={chat.query}
|
||||||
|
className="-ml-1 -mt-4"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AIChatToolConfirmations chat={chat} />
|
||||||
<AIChatFollowupSuggestions
|
<AIChatFollowupSuggestions
|
||||||
chat={chat}
|
chat={chat}
|
||||||
chatController={chatController}
|
chatController={chatController}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import type { AIChatController } from '../AI/useAIChat';
|
import type { AIChatController } from '../AI';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
export default function AIChatSuggestedQuestions(props: {
|
||||||
const { chatController } = props;
|
chatController: AIChatController;
|
||||||
|
suggestions?: string[];
|
||||||
|
}) {
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
const {
|
||||||
const DEFAULT_SUGGESTED_QUESTIONS = [
|
chatController,
|
||||||
|
suggestions = [
|
||||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||||
tString(language, 'ai_chat_suggested_questions_example'),
|
tString(language, 'ai_chat_suggested_questions_example'),
|
||||||
];
|
],
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
{suggestions.map((question, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={question}
|
key={question}
|
||||||
label={question}
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="medium"
|
size="medium"
|
||||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import type { AIChatState } from '../AI';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display buttons to confirm tool calls.
|
||||||
|
*/
|
||||||
|
export function AIChatToolConfirmations(props: {
|
||||||
|
chat: AIChatState;
|
||||||
|
}) {
|
||||||
|
const { chat } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'mod+enter',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
chat.pendingTools[0]?.confirm();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
},
|
||||||
|
[chat.pendingTools]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
{chat.pendingTools.map((tool, index) => (
|
||||||
|
<div
|
||||||
|
className="flex w-full animate-present-slow flex-col gap-1"
|
||||||
|
key={index}
|
||||||
|
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
tool.confirm();
|
||||||
|
}}
|
||||||
|
tabIndex={index}
|
||||||
|
label={tool.label}
|
||||||
|
className="w-full justify-center"
|
||||||
|
size={index === 0 ? 'default' : 'medium'}
|
||||||
|
variant={index === 0 ? 'primary' : 'secondary'}
|
||||||
|
icon={tool.icon}
|
||||||
|
/>
|
||||||
|
{index === 0 && (
|
||||||
|
<div
|
||||||
|
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||||
|
style={{ animationDelay: '1000ms' }}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
language,
|
||||||
|
'press_to_confirm',
|
||||||
|
<KeyboardShortcut
|
||||||
|
keys={['mod', 'enter']}
|
||||||
|
className="mx-0 text-tint"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t, tString } from '@/intl/translate';
|
||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
|
export function AIResponseFeedback(props: {
|
||||||
|
className?: ClassValue;
|
||||||
|
responseId: string;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
const { className, responseId, query } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const [rating, setRating] = useState<1 | -1 | null>(null);
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
|
const handleRating = (rating: 1 | -1) => {
|
||||||
|
setRating(rating);
|
||||||
|
trackEvent({ type: 'ask_rate_response', query, responseId, rating });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={tcls('flex h-fit items-center', className)}>
|
||||||
|
<Button
|
||||||
|
icon="thumbs-up"
|
||||||
|
iconOnly
|
||||||
|
label={tString(language, 'was_this_helpful_positive_label')}
|
||||||
|
variant="blank"
|
||||||
|
className={tcls(
|
||||||
|
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||||
|
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
|
||||||
|
)}
|
||||||
|
size="medium"
|
||||||
|
style={{ animationDuration: '.5s' }}
|
||||||
|
onClick={() => handleRating(1)}
|
||||||
|
disabled={rating !== null}
|
||||||
|
active={rating === 1}
|
||||||
|
key="positive"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="thumbs-down"
|
||||||
|
iconOnly
|
||||||
|
label={tString(language, 'was_this_helpful_negative_label')}
|
||||||
|
variant="blank"
|
||||||
|
className={tcls(
|
||||||
|
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||||
|
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
|
||||||
|
)}
|
||||||
|
size="medium"
|
||||||
|
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
|
||||||
|
onClick={() => handleRating(-1)}
|
||||||
|
disabled={rating !== null}
|
||||||
|
active={rating === -1}
|
||||||
|
key="negative"
|
||||||
|
/>
|
||||||
|
{rating !== null ? (
|
||||||
|
<span
|
||||||
|
className="ml-2 animate-fade-in-slow text-tint-subtle"
|
||||||
|
style={{ animationDelay: '.3s' }}
|
||||||
|
>
|
||||||
|
{t(language, 'was_this_helpful_thank_you')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user