mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7375d3c597 | |||
| 3548fa6dff | |||
| cb73040e0f | |||
| 872d36b64f | |||
| e1ff17e655 | |||
| f3010bd28a | |||
| 8ff1e3b619 | |||
| d7596bf454 | |||
| aea5eb10ae | |||
| 1165a81cf5 | |||
| 61d1a0192e | |||
| f9a2977621 | |||
| 24f601d594 | |||
| 7b34537bbe | |||
| 145f385fdb | |||
| 360aa1c6d6 | |||
| ccc0975240 | |||
| 791135040a | |||
| 12c9d765ff | |||
| 45207288a1 | |||
| 177afa5828 | |||
| d51b79edbb | |||
| a2720ac49d | |||
| 5f6853d812 | |||
| 17dd382dc3 | |||
| 98e42cfe79 | |||
| c890e01004 | |||
| 659be551bb | |||
| 78a632b7fe | |||
| 4927e964b7 | |||
| 262a9b198b | |||
| 4f3588240c | |||
| abbae3ec4d | |||
| 1b8609ac60 | |||
| 193d591e9f | |||
| 1edc5d622a | |||
| 61b8507996 | |||
| 6f368b5cf3 | |||
| 9201e2cf52 | |||
| 2e0d706d43 | |||
| 7fefe4919c | |||
| 729921f338 | |||
| 1839ea2678 | |||
| f08dd29364 | |||
| ca2dfa3065 | |||
| 10ded437f0 | |||
| d1fdc13441 | |||
| a8fca0e033 | |||
| 9251b33959 | |||
| 81a6bd756a | |||
| ada195d329 | |||
| 1211ebea9b | |||
| 2e6e28eb54 | |||
| 854c448bad | |||
| 64a0b0f169 | |||
| 13ff22b6a8 | |||
| 8927e8fbbb | |||
| 25e2b40d47 | |||
| a60af8ff9c | |||
| 27b328cfd7 | |||
| fa6ef0c01c | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 | |||
| 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 | |||
| 2a3bb0eb22 | |||
| 6816f0fa5a | |||
| 17386776ec | |||
| 9ee9082614 | |||
| 334cfdd522 | |||
| 8c961a5dbd | |||
| 4708956e77 | |||
| 813dd03dbb | |||
| 0522dbc61e | |||
| acb9f53156 | |||
| 6016846156 | |||
| cf1fae5837 | |||
| 7212345466 | |||
| e38caf78ff | |||
| 0a8dd67541 | |||
| ed684c19a9 | |||
| 52ab3681e3 | |||
| 9cc5a787e9 | |||
| 0003030bf5 | |||
| 9ad8f45d38 | |||
| a3855257c8 | |||
| 9169c2f053 | |||
| abe028bd38 | |||
| 12c2451f4f | |||
| 8daede5427 | |||
| 4c89aa60eb | |||
| d0a3f64ae4 | |||
| 433d66482d | |||
| bd553bcf9e | |||
| f5894bcf74 | |||
| 1b59e7ce36 |
@@ -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.140.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,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -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,23 +115,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cache tag for a given URL.
|
|
||||||
*/
|
|
||||||
export function getCacheTagForURL(url: string | URL) {
|
|
||||||
const parsedURL = url instanceof URL ? url : new URL(url);
|
|
||||||
return getCacheTag({
|
|
||||||
tag: 'url',
|
|
||||||
hostname: parsedURL.hostname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the tags for a computed content source.
|
* Get the tags for a computed content source.
|
||||||
*/
|
*/
|
||||||
@@ -144,6 +133,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,18 +160,18 @@ 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;
|
||||||
default:
|
default:
|
||||||
// Do not throw for unknown dependency types
|
// Do not throw for unknown dependency types
|
||||||
// as it might mean we are lacking behind the API version
|
// as it might mean we are lagging behind the API version
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -194,9 +187,8 @@ export function getComputedContentSourceCacheTags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We invalidate the computed content when a new version of the integration is deployed.
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
|
|
||||||
if (source.type.startsWith('integration:')) {
|
if (source.type.startsWith('integration:')) {
|
||||||
const integration = source.type.split(':')[1];
|
const integration = source.type.split(':')[1]!;
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'integration',
|
tag: 'integration',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 17dd382: Add `original` background color step
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 193d591: Fix return type for `colorContrast`
|
||||||
|
|
||||||
## 0.3.3
|
## 0.3.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
|
|||||||
type OKLABColor = { L: number; A: number; B: number };
|
type OKLABColor = { L: number; A: number; B: number };
|
||||||
type OKLCHColor = { L: number; C: number; H: number };
|
type OKLCHColor = { L: number; C: number; H: number };
|
||||||
|
|
||||||
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
|
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
|
||||||
|
|
||||||
export enum ColorCategory {
|
export enum ColorCategory {
|
||||||
backgrounds = 'backgrounds',
|
backgrounds = 'backgrounds',
|
||||||
@@ -19,7 +19,7 @@ export enum ColorCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ColorSubScale = {
|
type ColorSubScale = {
|
||||||
[key: string]: number;
|
[key: string]: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
|
|||||||
solid: 9,
|
solid: 9,
|
||||||
/** Hovered solid backgrounds */
|
/** Hovered solid backgrounds */
|
||||||
'solid-hover': 10,
|
'solid-hover': 10,
|
||||||
|
/** Original color */
|
||||||
|
original: 'original',
|
||||||
},
|
},
|
||||||
[ColorCategory.text]: {
|
[ColorCategory.text]: {
|
||||||
/** Very low-contrast text
|
/** Very low-contrast text
|
||||||
@@ -211,8 +213,8 @@ export function colorScale(
|
|||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
for (let index = 0; index < mapping.length; index++) {
|
for (let index = 0; index < mapping.length; index++) {
|
||||||
const targetL =
|
const step = mapping[index]!;
|
||||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
index === 8 &&
|
index === 8 &&
|
||||||
@@ -295,7 +297,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
|
|||||||
|
|
||||||
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
||||||
const rgb = end.map((channel, index) => {
|
const rgb = end.map((channel, index) => {
|
||||||
return Math.round(channel + percentage * (start[index] - channel));
|
return Math.round(channel + percentage * (start[index]! - channel));
|
||||||
});
|
});
|
||||||
|
|
||||||
return rgbArrayToHex(rgb as RGBColor);
|
return rgbArrayToHex(rgb as RGBColor);
|
||||||
@@ -392,14 +394,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
|
|||||||
B: number;
|
B: number;
|
||||||
} {
|
} {
|
||||||
const [x, y, z] = xyz.map((v, i) => {
|
const [x, y, z] = xyz.map((v, i) => {
|
||||||
const scaled = v / D65[i];
|
const scaled = v / D65[i]!;
|
||||||
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
L: 116 * y - 16,
|
L: 116 * y! - 16,
|
||||||
A: 500 * (x - y),
|
A: 500 * (x! - y!),
|
||||||
B: 200 * (y - z),
|
B: 200 * (y! - z!),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
|
|||||||
return contrast < 7.5 ? 0 : contrast;
|
return contrast < 7.5 ? 0 : contrast;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
|
export function colorContrast(
|
||||||
|
background: string,
|
||||||
|
foreground: string[] = [LIGHT_BASE, DARK_BASE]
|
||||||
|
): string {
|
||||||
const bg = hexToRgbArray(background);
|
const bg = hexToRgbArray(background);
|
||||||
|
|
||||||
const best: { color?: RGBColor; contrast: number } = {
|
const best: { color?: RGBColor; contrast: number } = {
|
||||||
@@ -436,5 +441,5 @@ export function colorContrast(background: string, foreground: string[] = [LIGHT_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best.color ? rgbArrayToHex(best.color) : foreground[0];
|
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
standalone/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6f368b5: Fix embed assistant window width on small screens
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 81a6bd7: Improve API to control the GitBook embed
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8927e8f: Initial version of the embed SDK.
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# `@gitbook/embed`
|
||||||
|
|
||||||
|
Embed the GitBook Docs Assistant in your product or website.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
## As a script from your docs site
|
||||||
|
|
||||||
|
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||||
|
|
||||||
|
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||||
|
|
||||||
|
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||||
|
<script>
|
||||||
|
window.GitBook('show');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## As a package from NPM
|
||||||
|
|
||||||
|
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createGitBook } from '@gitbook/embed';
|
||||||
|
|
||||||
|
const gitbook = createGitBook({
|
||||||
|
siteURL: 'https://docs.company.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = gitbook.getFrameURL();
|
||||||
|
|
||||||
|
const frame = gitbook.createFrame(iframe);
|
||||||
|
```
|
||||||
|
|
||||||
|
## As React components
|
||||||
|
|
||||||
|
After installing the NPM package, you can import prebuilt React components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||||
|
|
||||||
|
<GitBookProvider siteURL="https://docs.company.com">
|
||||||
|
<GitBookAssistantFrame />
|
||||||
|
</GitBookProvider>
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/embed",
|
||||||
|
"description": "Embeddable components for GitBook",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js",
|
||||||
|
"standalone": "./dist/standalone/index.js",
|
||||||
|
"react": "./dist/react/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:",
|
||||||
|
"bidc": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"react": "^19.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
|
||||||
|
|
||||||
|
export type CreateGitBookOptions = {
|
||||||
|
/**
|
||||||
|
* URL of the GitBook site to embed.
|
||||||
|
*/
|
||||||
|
siteURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFrameURLOptions = {
|
||||||
|
/**
|
||||||
|
* Authentication to use for the frame.
|
||||||
|
*/
|
||||||
|
visitor?: {
|
||||||
|
/**
|
||||||
|
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned claims to pass to the frame.
|
||||||
|
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
|
||||||
|
*/
|
||||||
|
unsignedClaims?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookClient = {
|
||||||
|
/**
|
||||||
|
* Get the URL for a GitBook frame.
|
||||||
|
*/
|
||||||
|
getFrameURL: (options: GetFrameURLOptions) => string;
|
||||||
|
/**
|
||||||
|
* Create a new GitBook frame.
|
||||||
|
*/
|
||||||
|
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createGitBook(options: CreateGitBookOptions) {
|
||||||
|
const client: GitBookClient = {
|
||||||
|
getFrameURL: (frameOptions) => {
|
||||||
|
const url = new URL(options.siteURL);
|
||||||
|
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.token) {
|
||||||
|
url.searchParams.set('token', frameOptions.visitor.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.unsignedClaims) {
|
||||||
|
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(`visitor.${key}`, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
createFrame: (iframe) => createGitBookFrame(iframe),
|
||||||
|
};
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createChannel } from 'bidc';
|
||||||
|
import type {
|
||||||
|
FrameToParentMessage,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
ParentToFrameMessage,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
export type GitBookFrameClient = {
|
||||||
|
/**
|
||||||
|
* Navigate to a page by its path.
|
||||||
|
*/
|
||||||
|
navigateToPage: (path: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the assistant.
|
||||||
|
*/
|
||||||
|
navigateToAssistant: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a message to the chat.
|
||||||
|
*/
|
||||||
|
postUserMessage: (message: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the chat.
|
||||||
|
*/
|
||||||
|
clearChat: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the placeholder settings.
|
||||||
|
*/
|
||||||
|
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a client to communicate with the GitBook Assistant frame.
|
||||||
|
*/
|
||||||
|
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
|
||||||
|
if (!iframe.contentWindow) {
|
||||||
|
throw new Error('Iframe must have a content window');
|
||||||
|
}
|
||||||
|
const channel = createChannel(iframe.contentWindow);
|
||||||
|
|
||||||
|
channel.receive((message: FrameToParentMessage) => {
|
||||||
|
console.log('[gitbook:embed] received message', message);
|
||||||
|
if (message.type === 'close') {
|
||||||
|
const listeners = events.get('close') || [];
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendToFrame = (message: ParentToFrameMessage) => {
|
||||||
|
console.log('[gitbook:embed] send message', message);
|
||||||
|
channel.send(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
const configuration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToPage: (pagePath) => {
|
||||||
|
sendToFrame({ type: 'navigateToPage', pagePath });
|
||||||
|
},
|
||||||
|
navigateToAssistant: () => {
|
||||||
|
sendToFrame({ type: 'navigateToAssistant' });
|
||||||
|
},
|
||||||
|
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
|
||||||
|
configure: (settings) => {
|
||||||
|
Object.assign(configuration, settings);
|
||||||
|
sendToFrame({ type: 'configure', settings: configuration });
|
||||||
|
},
|
||||||
|
clearChat: () => sendToFrame({ type: 'clearChat' }),
|
||||||
|
on: (event, listener) => {
|
||||||
|
const listeners = events.get(event) || [];
|
||||||
|
listeners.push(listener);
|
||||||
|
events.set(event, listeners);
|
||||||
|
return () => {
|
||||||
|
events.set(
|
||||||
|
event,
|
||||||
|
listeners.filter((l) => l !== listener)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './createGitBook';
|
||||||
|
export * from './createGitBookFrame';
|
||||||
|
export * from './protocol';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tool definition to be passed to the AI assistant.
|
||||||
|
*/
|
||||||
|
export type GitBookToolDefinition = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom button definition to be passed to the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableButtonDefinition = {
|
||||||
|
/**
|
||||||
|
* Icon to be displayed in the button.
|
||||||
|
*/
|
||||||
|
icon: IconName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label to be displayed in the button.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the button is clicked.
|
||||||
|
*/
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall configuration for the layout of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableConfiguration = {
|
||||||
|
/**
|
||||||
|
* Buttons to be displayed in the header of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
buttons: GitBookEmbeddableButtonDefinition[];
|
||||||
|
|
||||||
|
/** Message to be displayed in the welcome page. */
|
||||||
|
welcomeMessage: string;
|
||||||
|
|
||||||
|
/** Suggestions of questions to be displayed in the welcome page. */
|
||||||
|
suggestions: string[];
|
||||||
|
|
||||||
|
/** Tools to be provided to the assistant. */
|
||||||
|
tools: GitBookToolDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the parent to the frame.
|
||||||
|
*/
|
||||||
|
export type ParentToFrameMessage =
|
||||||
|
| {
|
||||||
|
type: 'postUserMessage';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'clearChat';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'configure';
|
||||||
|
settings: GitBookEmbeddableConfiguration;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToPage';
|
||||||
|
pagePath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToAssistant';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the frame to the parent.
|
||||||
|
*/
|
||||||
|
export type FrameToParentMessage = {
|
||||||
|
type: 'close';
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './client';
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type {
|
||||||
|
GetFrameURLOptions,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
GitBookFrameClient,
|
||||||
|
} from '../client';
|
||||||
|
import { useGitBook } from './GitBookProvider';
|
||||||
|
|
||||||
|
export type GitBookFrameProps = {
|
||||||
|
className?: string;
|
||||||
|
} & GetFrameURLOptions &
|
||||||
|
GitBookEmbeddableConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a frame with the GitBook Assistant in it.
|
||||||
|
*/
|
||||||
|
export function GitBookFrame(props: GitBookFrameProps) {
|
||||||
|
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||||
|
|
||||||
|
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
|
const gitbook = useGitBook();
|
||||||
|
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
||||||
|
|
||||||
|
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (frameRef.current) {
|
||||||
|
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||||
|
}
|
||||||
|
}, [gitbook]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
gitbookFrame?.configure({
|
||||||
|
buttons,
|
||||||
|
welcomeMessage,
|
||||||
|
suggestions,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title="GitBook"
|
||||||
|
ref={frameRef}
|
||||||
|
src={frameURL}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||||
|
import { GitBookContext } from './context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for the GitBook client.
|
||||||
|
*/
|
||||||
|
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
|
||||||
|
const { siteURL, children } = props;
|
||||||
|
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
siteURL,
|
||||||
|
}),
|
||||||
|
[siteURL]
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = React.useMemo(() => createGitBook(options), [options]);
|
||||||
|
|
||||||
|
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the GitBook client.
|
||||||
|
*/
|
||||||
|
export function useGitBook() {
|
||||||
|
const context = React.useContext(GitBookContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('This component must be used within a <GitBookProvider />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { GitBookClient } from '../client';
|
||||||
|
|
||||||
|
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './GitBookProvider';
|
||||||
|
export * from './GitBookFrame';
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import './style.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CreateGitBookOptions,
|
||||||
|
type GetFrameURLOptions,
|
||||||
|
type GitBookClient,
|
||||||
|
type GitBookEmbeddableConfiguration,
|
||||||
|
type GitBookFrameClient,
|
||||||
|
createGitBook,
|
||||||
|
} from '../client';
|
||||||
|
|
||||||
|
export type GitBook = () => void;
|
||||||
|
|
||||||
|
type StandaloneCalls =
|
||||||
|
// Initialize the widget
|
||||||
|
| ['init', CreateGitBookOptions, GetFrameURLOptions]
|
||||||
|
// Unload the widget
|
||||||
|
| ['unload']
|
||||||
|
// Show the widget
|
||||||
|
| ['show']
|
||||||
|
// Hide the widget
|
||||||
|
| ['hide']
|
||||||
|
// Open the window
|
||||||
|
| ['open']
|
||||||
|
// Close the window
|
||||||
|
| ['close']
|
||||||
|
// Toggle the window
|
||||||
|
| ['toggle']
|
||||||
|
// Post a user message
|
||||||
|
| ['postUserMessage', string]
|
||||||
|
// Clear the chat
|
||||||
|
| ['clearChat']
|
||||||
|
// Configure the embed
|
||||||
|
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||||
|
// Navigate to a page
|
||||||
|
| ['navigateToPage', string]
|
||||||
|
// Navigate to the assistant
|
||||||
|
| ['navigateToAssistant'];
|
||||||
|
|
||||||
|
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||||
|
q?: StandaloneCalls[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetButton = document.createElement('button');
|
||||||
|
widgetButton.id = 'gitbook-widget-button';
|
||||||
|
widgetButton.addEventListener('click', () => {
|
||||||
|
GitBook('toggle');
|
||||||
|
});
|
||||||
|
widgetButton.innerHTML = `
|
||||||
|
<span id="gitbook-widget-button-icon"></span>
|
||||||
|
<span id="gitbook-widget-button-label">Ask</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const widgetWindow = document.createElement('div');
|
||||||
|
widgetWindow.id = 'gitbook-widget-window';
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
|
||||||
|
document.body.appendChild(widgetButton);
|
||||||
|
document.body.appendChild(widgetWindow);
|
||||||
|
|
||||||
|
let widgetIframe: HTMLIFrameElement | undefined;
|
||||||
|
let _client: GitBookClient | undefined;
|
||||||
|
let _frame: GitBookFrameClient | undefined;
|
||||||
|
let frameOptions: GetFrameURLOptions | undefined;
|
||||||
|
let frameConfiguration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
if (!_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIframe() {
|
||||||
|
if (!widgetIframe || !_frame) {
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetIframe = document.createElement('iframe');
|
||||||
|
widgetIframe.id = 'gitbook-widget-iframe';
|
||||||
|
widgetIframe.src = client.getFrameURL({
|
||||||
|
...frameOptions,
|
||||||
|
});
|
||||||
|
widgetWindow.appendChild(widgetIframe);
|
||||||
|
|
||||||
|
_frame = client.createFrame(widgetIframe);
|
||||||
|
}
|
||||||
|
return { iframe: widgetIframe, frame: _frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitBook = (...args: StandaloneCalls) => {
|
||||||
|
switch (args[0]) {
|
||||||
|
case 'init':
|
||||||
|
if (_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client already initialized. Call GitBook("unload") first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_client = createGitBook(args[1]);
|
||||||
|
frameOptions = args[2];
|
||||||
|
break;
|
||||||
|
case 'unload':
|
||||||
|
_client = undefined;
|
||||||
|
_frame = undefined;
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'show':
|
||||||
|
widgetButton.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
widgetButton.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'open':
|
||||||
|
widgetWindow.classList.remove('hidden');
|
||||||
|
widgetButton.classList.add('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'toggle':
|
||||||
|
widgetWindow.classList.toggle('hidden');
|
||||||
|
widgetButton.classList.toggle('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
widgetButton.classList.remove('open');
|
||||||
|
break;
|
||||||
|
case 'postUserMessage':
|
||||||
|
getIframe().frame.postUserMessage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'configure':
|
||||||
|
frameConfiguration = {
|
||||||
|
...frameConfiguration,
|
||||||
|
...args[1],
|
||||||
|
};
|
||||||
|
getIframe().frame.configure({
|
||||||
|
...frameConfiguration,
|
||||||
|
buttons: [
|
||||||
|
...frameConfiguration.buttons,
|
||||||
|
|
||||||
|
// Always include a close button
|
||||||
|
{
|
||||||
|
icon: 'close',
|
||||||
|
label: 'Close',
|
||||||
|
onClick: () => {
|
||||||
|
GitBook('close');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'clearChat':
|
||||||
|
getIframe().frame.clearChat();
|
||||||
|
break;
|
||||||
|
case 'navigateToPage':
|
||||||
|
getIframe().frame.navigateToPage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'navigateToAssistant':
|
||||||
|
getIframe().frame.navigateToAssistant();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
window.GitBook = GitBook;
|
||||||
|
precalls.forEach((call) => GitBook(...call));
|
||||||
|
|
||||||
|
GitBook('configure', {});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
:root {
|
||||||
|
--gitbook-widget-top: 1rem;
|
||||||
|
--gitbook-widget-bottom: 1rem;
|
||||||
|
--gitbook-widget-right: 1rem;
|
||||||
|
--gitbook-widget-left: 1rem;
|
||||||
|
|
||||||
|
--gitbook-widget-button-height: 46px;
|
||||||
|
|
||||||
|
--gitbook-widget-radius: .5rem;
|
||||||
|
--gitbook-widget-text-size: 1rem;
|
||||||
|
--gitbook-widget-text-color: #656973;
|
||||||
|
--gitbook-widget-border-color: #e5e5e5;
|
||||||
|
|
||||||
|
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||||
|
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||||
|
--gitbook-widget-background-solid: #FFFFFF;
|
||||||
|
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||||
|
|
||||||
|
--gitbook-widget-icon-size: 1.25rem;
|
||||||
|
|
||||||
|
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||||
|
--gitbook-widget-window-height: 40rem; /* 640px */
|
||||||
|
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
|
||||||
|
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
|
||||||
|
|
||||||
|
--gitbook-widget-transition-duration-fast: 0.2s;
|
||||||
|
--gitbook-widget-transition-duration-slow: 0.5s;
|
||||||
|
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
#gitbook-widget-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
height: var(--gitbook-widget-button-height);
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
background-color: var(--gitbook-widget-background-translucent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
|
||||||
|
font-size: var(--gitbook-widget-text-size);
|
||||||
|
color: var(--gitbook-widget-text-color);
|
||||||
|
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
|
||||||
|
|
||||||
|
cursor:pointer;
|
||||||
|
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-translucent-hover);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
#gitbook-widget-button {
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-solid-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open {
|
||||||
|
padding: .75rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Icon */
|
||||||
|
#gitbook-widget-button-icon {
|
||||||
|
width: var(--gitbook-widget-icon-size);
|
||||||
|
height: var(--gitbook-widget-icon-size);
|
||||||
|
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||||
|
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Label */
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window */
|
||||||
|
#gitbook-widget-window {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-window-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
z-index: 9998;
|
||||||
|
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
||||||
|
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
border-radius: var(--gitbook-widget-radius);
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-slow);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing-bounce);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
#gitbook-widget-window {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-window.hidden {
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-fast);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing);
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gitbook-widget-present {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3548fa6: Fix eval-estree-expr named import
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e1ff17e: Fix bundling of gitbook/expr package
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8ff1e3b: Add support for every/some array methods
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Major Changes
|
||||||
|
|
||||||
|
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/expr`
|
||||||
|
|
||||||
|
Safely evaluate & parse user-defined GitBook expressions.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/expr",
|
||||||
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"dependencies": {
|
||||||
|
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"acorn-loose": "8.4.0",
|
||||||
|
"acorn-walk": "^8.3.4",
|
||||||
|
"escodegen": "^2.1.0",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "^1.1.20",
|
||||||
|
"tsdown": "^0.15.0",
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
|
"@babel/types": "^7.26.0",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"@types/escodegen": "^0.0.10"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsdown --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"unit": "bun test",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolType,
|
||||||
|
SymbolsTable,
|
||||||
|
} from '../symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
describe('autocomplete', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const visitorClaimsHelloArraySymbol = SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
});
|
||||||
|
const symbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: visitorClaimsHelloArraySymbol,
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const context = new SymbolsTable(symbols);
|
||||||
|
const SCENARIOS: Array<{
|
||||||
|
expressionWithCursor: string;
|
||||||
|
expectedSuggestions: AutocompleteSuggestions;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
'visitor.claims.role',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.role',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.role.length',
|
||||||
|
'visitor.claims.role.at',
|
||||||
|
'visitor.claims.role.endsWith',
|
||||||
|
'visitor.claims.role.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.ke<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.h<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello.length',
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
|
||||||
|
(method) => ({
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: method,
|
||||||
|
ref: `visitor.claims.hello.${method.name}`,
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.key <cur>',
|
||||||
|
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
|
||||||
|
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
|
||||||
|
),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['==', '==='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '==='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['!=', '!=='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '!=='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == editor<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello == <cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '"test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
it.each(SCENARIOS)(
|
||||||
|
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
|
||||||
|
({ expressionWithCursor, expectedSuggestions }) => {
|
||||||
|
const { expression, cursorOffset } = extractCursorPosition(
|
||||||
|
expressionWithCursor,
|
||||||
|
'<cur>'
|
||||||
|
);
|
||||||
|
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
|
||||||
|
expect(suggestions).toStrictEqual(expectedSuggestions);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractCursorPosition(
|
||||||
|
expressionWithCursor: string,
|
||||||
|
cursorPlaceholder: string
|
||||||
|
): { expression: string; cursorOffset: number } {
|
||||||
|
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
|
||||||
|
if (cursorOffset === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
|
||||||
|
return { expression, cursorOffset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values';
|
||||||
|
|
||||||
|
describe('inferDefaultInputValuesFromObjectJSONSchema', () => {
|
||||||
|
it('should infer properly the default input value based on the JSON schema of an object', () => {
|
||||||
|
const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isAlphaUser: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['enumValue1', 'enumValue2', 'enumValue3'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(defaultInputValues).toMatchObject({
|
||||||
|
claims: {
|
||||||
|
key: 'default',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'default',
|
||||||
|
FLAG2: 'default',
|
||||||
|
FLAG3: 'default',
|
||||||
|
},
|
||||||
|
isAlphaUser: true,
|
||||||
|
hello: 'enumValue1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionError } from '../errors';
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import type { Logger } from '../types';
|
||||||
|
|
||||||
|
const SILENT_LOGGER: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const runtime = new ExpressionRuntime(SILENT_LOGGER);
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
inputs: { isBetaUser: false },
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
inputs: {
|
||||||
|
useProductA: true,
|
||||||
|
isBetaUser: false,
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
inputs: {
|
||||||
|
products: ['productA', 'productB'],
|
||||||
|
userSegments: {
|
||||||
|
alpha: true,
|
||||||
|
beta: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array method',
|
||||||
|
condition: 'reviews.every(review => !!review.status)',
|
||||||
|
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array every',
|
||||||
|
condition: 'reviews.every(review => review.status === "approved")',
|
||||||
|
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array map',
|
||||||
|
condition: '[1, 2, 3].map(n => n * x)',
|
||||||
|
inputs: { x: 2 },
|
||||||
|
expectedResult: [2, 4, 6],
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||||
|
({ condition, inputs, expectedResult }) => {
|
||||||
|
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
|
||||||
|
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const INVALID_EXPRESSSIONS = [
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: 'while (1) {}',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: '[1, 2, 3].map(() => { while (1) {}})',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
const result = runtime.safeEvaluate(condition, inputs);
|
||||||
|
expect(result.value).toBeUndefined();
|
||||||
|
expect(result.error instanceof ExpressionError).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should throw an error when using evaluate with invalid expressions',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse', () => {
|
||||||
|
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
|
||||||
|
const ast = runtime.parse('isBetaUser === true');
|
||||||
|
|
||||||
|
expect(ast.result).toEqual({
|
||||||
|
type: 'BinaryExpression',
|
||||||
|
start: 0,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
|
||||||
|
left: {
|
||||||
|
type: 'Identifier',
|
||||||
|
start: 0,
|
||||||
|
end: 10,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
|
||||||
|
name: 'isBetaUser',
|
||||||
|
},
|
||||||
|
operator: '===',
|
||||||
|
right: {
|
||||||
|
type: 'Literal',
|
||||||
|
start: 15,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
|
||||||
|
value: true,
|
||||||
|
raw: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should throw an error for non conditional expressions or syntax errors: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip('generate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should produce the original expression using an AST node produced by parse: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
const { result } = runtime.parse(condition);
|
||||||
|
expect(runtime.generate(result)).toStrictEqual(condition);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime, parseTemplate } from '../';
|
||||||
|
|
||||||
|
describe('template expressions', () => {
|
||||||
|
it('should parse template into parts', () => {
|
||||||
|
const parts = parseTemplate('Hello {{ user.name }}!');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'text', value: 'Hello ', start: 0, end: 6 },
|
||||||
|
{ type: 'expression', value: 'user.name', start: 8, end: 19 },
|
||||||
|
{ type: 'text', value: '!', start: 21, end: 22 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template starting with an expression', () => {
|
||||||
|
const parts = parseTemplate('{{ user.name }} is cool');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'expression', value: 'user.name', start: 2, end: 13 },
|
||||||
|
{ type: 'text', value: ' is cool', start: 15, end: 23 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template without expressions', () => {
|
||||||
|
const parts = parseTemplate('Hello world');
|
||||||
|
expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate template', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const result = runtime.evaluateTemplate('Hello {{ user.name }}!', {
|
||||||
|
user: { name: 'John' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('Hello John!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import type {
|
||||||
|
Node as AcornNode,
|
||||||
|
AnyNode,
|
||||||
|
BinaryExpression,
|
||||||
|
Expression,
|
||||||
|
Identifier,
|
||||||
|
Literal,
|
||||||
|
MemberExpression,
|
||||||
|
PrivateIdentifier,
|
||||||
|
Super,
|
||||||
|
} from 'acorn';
|
||||||
|
import { isDummy } from 'acorn-loose';
|
||||||
|
import * as walk from 'acorn-walk';
|
||||||
|
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
|
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteLiteralValueSuggestion,
|
||||||
|
type AutocompleteOperatorSuggestion,
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
type DirectLiteralValueSuggestion,
|
||||||
|
type ExpressionParserResult,
|
||||||
|
type Logger,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface ExpressionParser {
|
||||||
|
parse(expr: string, options: { loose?: boolean }): ExpressionParserResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutoComplete {
|
||||||
|
#parser: ExpressionParser;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(parser: ExpressionParser, logger: Logger = console) {
|
||||||
|
this.#parser = parser;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates autocomplete suggestions based on the input expression and cursor offset position.
|
||||||
|
*/
|
||||||
|
public getSuggestions(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
if (!expr.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, invalidNodes } = this.#parser.parse(expr, { loose: true });
|
||||||
|
|
||||||
|
// Locate the node at the cursor position.
|
||||||
|
const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, cursorOffset)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nodeAtCursorFound) {
|
||||||
|
// When we can't find one we might be in the boundary of the program/expression.
|
||||||
|
// We could possibly be in a whitespace at the end of the expression or in a situation where the parsed
|
||||||
|
// tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser).
|
||||||
|
//
|
||||||
|
// In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either:
|
||||||
|
// - using the end position of the whole expression AST (e.g white space at the very end of the expression string)
|
||||||
|
// - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node.
|
||||||
|
if (cursorOffset > result.end) {
|
||||||
|
const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result;
|
||||||
|
|
||||||
|
if (!ast) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, ast.end)
|
||||||
|
);
|
||||||
|
if (!lastNodeFound) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { node } = lastNodeFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, node, result.end, context);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node } = nodeAtCursorFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSuggestionsForNode(result, node, expr, cursorOffset, context);
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error('Error while computing autocomplete suggestions', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
// When the node is an identifier look up the parent to get more context for the suggestions.
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier' || node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode =
|
||||||
|
parent &&
|
||||||
|
!['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes(
|
||||||
|
parent.type
|
||||||
|
)
|
||||||
|
? parent
|
||||||
|
: node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'Identifier':
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch suggestions from the symbol table
|
||||||
|
const candidatesKeys = context.getMatchingSymbolsKeys(pathParts);
|
||||||
|
|
||||||
|
const suggestions: AutocompleteSymbolSuggestion[] = [];
|
||||||
|
for (const candidate of candidatesKeys) {
|
||||||
|
const symbolInfo = context.getSymbolInfo(candidate);
|
||||||
|
if (symbolInfo) {
|
||||||
|
suggestions.push({ type: 'symbol', symbol: symbolInfo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 1) {
|
||||||
|
const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, '');
|
||||||
|
|
||||||
|
// Return no suggestion when the only match is an exact match of the
|
||||||
|
// typed token.
|
||||||
|
return lastPathSegment !== suggestions[0]?.symbol.definition.name
|
||||||
|
? suggestions
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Provide suggestions for binary or logical operators based on the parsed operator
|
||||||
|
// of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)).
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'BinaryExpression': {
|
||||||
|
const { left, right } = inferNode;
|
||||||
|
|
||||||
|
const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some(
|
||||||
|
(op) => op.operator === inferNode.operator
|
||||||
|
);
|
||||||
|
|
||||||
|
const operatorIndex = expr.indexOf(inferNode.operator, left.end);
|
||||||
|
const operatorOffset = operatorIndex + inferNode.operator.length;
|
||||||
|
const isCursorAfterOperator = cursorOffset > operatorOffset;
|
||||||
|
|
||||||
|
const shouldSuggestValues =
|
||||||
|
isCursorAfterOperator &&
|
||||||
|
isOperatorBinaryOp &&
|
||||||
|
isNodeAtCursor(right, cursorOffset);
|
||||||
|
|
||||||
|
if (shouldSuggestValues) {
|
||||||
|
return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide suggestions for binary operators based on the parsed operator
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'ConditionalExpression': {
|
||||||
|
const { consequent, alternate } = inferNode;
|
||||||
|
|
||||||
|
if (isNodeAtCursor(consequent, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeAtCursor(alternate, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
node: MemberExpression | Identifier,
|
||||||
|
cursorOffset: number,
|
||||||
|
options?: { withWildcardMatches: boolean }
|
||||||
|
): string[] {
|
||||||
|
const withWildcardMatches = options?.withWildcardMatches ?? true;
|
||||||
|
const pathParts: string[] = [];
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'MemberExpression':
|
||||||
|
{
|
||||||
|
const memberProperty = node.property;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as member properties.
|
||||||
|
if (!isSupportedMemberProperty(memberProperty)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the path part corresponding to the member property (e.g b in a.b or a['b'])
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
memberProperty,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(
|
||||||
|
withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go through the parent(s) in the chain and add their path parts as well
|
||||||
|
let parent: Expression | Super | undefined = node.object;
|
||||||
|
while (parent) {
|
||||||
|
const parentProperty = 'property' in parent ? parent.property : parent;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as parent property.
|
||||||
|
const parentPropertyPathPart = isSupportedMemberProperty(parentProperty)
|
||||||
|
? this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
parentProperty,
|
||||||
|
cursorOffset
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (parentPropertyPathPart) {
|
||||||
|
pathParts.unshift(parentPropertyPathPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = 'object' in parent ? parent.object : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Identifier': {
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
node,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a part of a symbol path corresponding to a MemberExpression node property.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartFromMemberProperty(
|
||||||
|
node: Identifier | Literal,
|
||||||
|
cursorOffset: number
|
||||||
|
): string {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Identifier': {
|
||||||
|
if (isDummy(node)) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNodeAtCursor(node, cursorOffset)
|
||||||
|
? node.name.slice(0, cursorOffset)
|
||||||
|
: node.name;
|
||||||
|
}
|
||||||
|
case 'Literal': {
|
||||||
|
if (isDummy(node) || !node.value) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
const value = String(node.value);
|
||||||
|
return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete literal value suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getLiteralValueSuggestionsForNode(
|
||||||
|
node: BinaryExpression,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteLiteralValueSuggestion> {
|
||||||
|
const { left, right } = node;
|
||||||
|
|
||||||
|
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right.type !== 'Identifier' && right.type !== 'Literal') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, {
|
||||||
|
withWildcardMatches: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLeftComputedMember = left.type === 'MemberExpression' && left.computed;
|
||||||
|
const leftSymbolInfo = context.getSymbolInfo(
|
||||||
|
isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leftSymbolInfo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (leftSymbolInfo.definition.type) {
|
||||||
|
case SymbolType.Boolean: {
|
||||||
|
const suggestions: DirectLiteralValueSuggestion[] = [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return isDummy(right)
|
||||||
|
? suggestions
|
||||||
|
: suggestions.filter((literalValue) => {
|
||||||
|
const literalValueString = String(literalValue.value.data);
|
||||||
|
const rightNodeValue =
|
||||||
|
right.type === 'Identifier' ? right.name : (right.raw ?? '');
|
||||||
|
return (
|
||||||
|
literalValueString !== rightNodeValue &&
|
||||||
|
literalValueString.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case SymbolType.String: {
|
||||||
|
if (!leftSymbolInfo.definition.enum) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightNodeValue = (() => {
|
||||||
|
if (right.type === 'Identifier') {
|
||||||
|
return right.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeValue = right.raw ?? '';
|
||||||
|
if (/^(['"])(.*)\1$/.test(nodeValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeValue.replaceAll(/["']/g, '');
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (rightNodeValue === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = isDummy(right)
|
||||||
|
? leftSymbolInfo.definition.enum
|
||||||
|
: leftSymbolInfo.definition.enum.filter((enumValue) =>
|
||||||
|
enumValue.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
return suggestions.map((value) => ({
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'direct',
|
||||||
|
type: SymbolType.String,
|
||||||
|
data: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.Array: {
|
||||||
|
// Only return a literal in array value suggestion when the left hand side of the binary expression
|
||||||
|
// is computed, e.g myArray[1]
|
||||||
|
return isLeftComputedMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: leftSymbolInfo.definition,
|
||||||
|
matchedLiteralString: !isDummy(right)
|
||||||
|
? right.type === 'Identifier'
|
||||||
|
? right.name
|
||||||
|
: (right.raw ?? '')
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
if (node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
|
||||||
|
if (parent?.type === 'BinaryExpression') {
|
||||||
|
return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map(
|
||||||
|
(op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw);
|
||||||
|
return this.getOperatorSuggestionsForSymbol(literalSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the node is an identifier look the parent to get more context for the suggestions
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset,
|
||||||
|
{
|
||||||
|
withWildcardMatches: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const symbolInfo = context.getSymbolInfo(pathParts);
|
||||||
|
return symbolInfo
|
||||||
|
? this.getOperatorSuggestionsForSymbol(symbolInfo.definition)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'BinaryExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Starting to write a binary/logical operator so suggest operator matching the already
|
||||||
|
// typed character as operator.
|
||||||
|
const operator = inferNode.operator;
|
||||||
|
return (
|
||||||
|
[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS]
|
||||||
|
.filter((op) => op.operator.startsWith(operator))
|
||||||
|
// No need to include the operator that match exactly
|
||||||
|
.filter((op) => op.operator !== operator)
|
||||||
|
.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions based on the type of a symbol.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForSymbol(
|
||||||
|
symbol: ExtractSymbolDef<SymbolType>
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.Number:
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
case SymbolType.Null:
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
case SymbolType.Object:
|
||||||
|
case SymbolType.Array: {
|
||||||
|
const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4);
|
||||||
|
const finalSuggestions =
|
||||||
|
symbol.type === SymbolType.Boolean
|
||||||
|
? [
|
||||||
|
...equalityOps,
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
]
|
||||||
|
: equalityOps;
|
||||||
|
return finalSuggestions.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.String:
|
||||||
|
return SUPPORTED_BINARY_OPERATORS.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
case SymbolType.Function:
|
||||||
|
return this.getOperatorSuggestionsForSymbol(symbol.returns);
|
||||||
|
case SymbolType.Union: {
|
||||||
|
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
|
||||||
|
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
|
||||||
|
return prev;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the parent of child node in the provided AST.
|
||||||
|
*/
|
||||||
|
function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined {
|
||||||
|
let foundParent: AnyNode | undefined;
|
||||||
|
|
||||||
|
walk.ancestor(ast, {
|
||||||
|
[child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => {
|
||||||
|
if (node.start === child.start && node.end === child.end) {
|
||||||
|
// The parent is the second last ancestor in the stack (last one is the actual node)
|
||||||
|
foundParent = ancestors[ancestors.length - 2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) {
|
||||||
|
return node.type === 'Identifier' || node.type === 'Literal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeAtCursor(node: AcornNode, cursorOffset: number) {
|
||||||
|
return cursorOffset >= node.start && cursorOffset <= node.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyNode(node: AcornNode): node is AnyNode {
|
||||||
|
return 'type' in node;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Position, Token } from 'acorn';
|
||||||
|
|
||||||
|
export class ExpressionError extends Error {
|
||||||
|
/**
|
||||||
|
* The location of the error in the parsed expression.
|
||||||
|
*/
|
||||||
|
public location?: Position | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expression token with the error
|
||||||
|
*/
|
||||||
|
public token?: Token;
|
||||||
|
|
||||||
|
constructor(message: string, loc?: Position | null, token?: Token) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, ExpressionError);
|
||||||
|
}
|
||||||
|
this.name = 'ExpressionError';
|
||||||
|
this.location = loc;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './errors';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './runtime';
|
||||||
|
export * from './symbols';
|
||||||
|
export * from './template';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import { filterOutNullable } from './utils';
|
||||||
|
|
||||||
|
type InputValuesType =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| { [key: string]: InputValuesType }
|
||||||
|
| InputValuesType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers a default inputValues object based on the JSON schema of an object.
|
||||||
|
*/
|
||||||
|
export function inferDefaultInputValuesFromObjectJSONSchema(
|
||||||
|
schema: JSONSchema7
|
||||||
|
): Record<string, InputValuesType> {
|
||||||
|
if (schema.type !== 'object' || !schema.properties) {
|
||||||
|
throw new Error(`Expected schema of object to be provided: ${schema.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, InputValuesType> = {};
|
||||||
|
|
||||||
|
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
||||||
|
if (typeof propertySchema === 'boolean') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = inferDefaultInputValueFromJSONSchema(propertySchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'object':
|
||||||
|
return inferDefaultInputValuesFromObjectJSONSchema(schema);
|
||||||
|
case 'array': {
|
||||||
|
if (schema.items && Array.isArray(schema.items)) {
|
||||||
|
return schema.items.map((itemSchema) => {
|
||||||
|
if (typeof itemSchema === 'boolean') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return inferDefaultInputValueFromJSONSchema(itemSchema);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null':
|
||||||
|
return inferDefaultInputValueFromPrimitive(schema);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return true;
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return 1234;
|
||||||
|
case 'string': {
|
||||||
|
const enumValues = schema.enum?.filter(filterOutNullable);
|
||||||
|
return enumValues?.[0] ?? 'default';
|
||||||
|
}
|
||||||
|
case 'null':
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
type Options as AcornOptions,
|
||||||
|
type Expression,
|
||||||
|
type ExpressionStatement,
|
||||||
|
type Position,
|
||||||
|
type Program,
|
||||||
|
type Token,
|
||||||
|
parse,
|
||||||
|
tokenizer,
|
||||||
|
} from 'acorn';
|
||||||
|
import { parse as parseLoose } from 'acorn-loose';
|
||||||
|
import escodegen from 'escodegen';
|
||||||
|
import evalESTreeExpr from 'eval-estree-expression';
|
||||||
|
const { evaluate } = evalESTreeExpr;
|
||||||
|
|
||||||
|
import { AutoComplete } from './autocomplete';
|
||||||
|
import { ExpressionError } from './errors';
|
||||||
|
import type { SymbolsTable } from './symbols';
|
||||||
|
import type { TemplatePart } from './template';
|
||||||
|
import { parseTemplate as parseTemplateParts } from './template';
|
||||||
|
import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types';
|
||||||
|
import { formatExpressionResult } from './utils';
|
||||||
|
|
||||||
|
export class ExpressionRuntime {
|
||||||
|
#parserOptions: AcornOptions;
|
||||||
|
#autocompleter: AutoComplete;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(logger: Logger = console) {
|
||||||
|
this.#parserOptions = {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'script',
|
||||||
|
allowHashBang: false,
|
||||||
|
locations: true,
|
||||||
|
};
|
||||||
|
this.#autocompleter = new AutoComplete(this, logger);
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression based on the given inputs/context.
|
||||||
|
*/
|
||||||
|
public evaluate(expr: string, inputs: object): unknown {
|
||||||
|
try {
|
||||||
|
const parsed = this.parse(expr);
|
||||||
|
|
||||||
|
if (parsed.invalidNodes.length > 0) {
|
||||||
|
throw new ExpressionError('Invalid nodes found when parsing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluate.sync<Expression>(parsed.result, inputs, {
|
||||||
|
functions: true,
|
||||||
|
withMembers: true,
|
||||||
|
generate: escodegen.generate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression safely by returning the error instead of throwing when invalid.
|
||||||
|
*/
|
||||||
|
public safeEvaluate(
|
||||||
|
expr: string,
|
||||||
|
inputs: object
|
||||||
|
): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } {
|
||||||
|
try {
|
||||||
|
const value = this.evaluate(expr, inputs);
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error(`Error while evaluating expression ${expr}`, error);
|
||||||
|
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition safely to a boolean.
|
||||||
|
*/
|
||||||
|
public evaluateBoolean(expr: string, inputs: object): boolean {
|
||||||
|
if (expr.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = this.safeEvaluate(expr, inputs);
|
||||||
|
|
||||||
|
if (typeof evalResult.error !== 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(evalResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an array of conditions as a single logical expression.
|
||||||
|
* The function treats the conditions as if they were joined by an AND operator,
|
||||||
|
* meaning the evaluation returns `true` only if all conditions are truthy.
|
||||||
|
*/
|
||||||
|
public evaluateBooleanAll(expressions: string[], inputs: object): boolean {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressions.every((expression) => this.evaluateBoolean(expression, inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template and validate all embedded expressions.
|
||||||
|
*/
|
||||||
|
public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } {
|
||||||
|
const parts = parseTemplateParts(template);
|
||||||
|
const errors: ExpressionError[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'expression') {
|
||||||
|
try {
|
||||||
|
const { invalidNodes } = this.parse(part.value);
|
||||||
|
if (invalidNodes.length > 0) {
|
||||||
|
errors.push(new ExpressionError('Invalid expression'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error as ExpressionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parts, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
public evaluateTemplate(template: string, inputs: object): string {
|
||||||
|
const { parts } = this.parseTemplate(template);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return part.value;
|
||||||
|
}
|
||||||
|
const result = this.evaluate(part.value, inputs);
|
||||||
|
return formatExpressionResult(result, '');
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||||
|
*/
|
||||||
|
public parse(
|
||||||
|
expr: string,
|
||||||
|
options: { loose?: boolean } = {
|
||||||
|
loose: false,
|
||||||
|
}
|
||||||
|
): ExpressionParserResult {
|
||||||
|
try {
|
||||||
|
const ast = options.loose
|
||||||
|
? parseLoose(expr, { ...this.#parserOptions })
|
||||||
|
: parse(expr, { ...this.#parserOptions });
|
||||||
|
|
||||||
|
if (!ast.body || ast.body.length === 0) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first expression statement that we find
|
||||||
|
const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node));
|
||||||
|
const [statement] = ast.body.splice(firstExprIndex, 1);
|
||||||
|
|
||||||
|
if (!statement || !isParsedExpressionStatement(statement)) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return information on the other nodes as invalid nodes
|
||||||
|
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: statement.expression,
|
||||||
|
invalidNodes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw createExpressionErrorFromSyntaxError(expr, error);
|
||||||
|
}
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for the given expression at the provided cursor offset.
|
||||||
|
*/
|
||||||
|
public autocomplete(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): ExpressionAutocompleteResults {
|
||||||
|
const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context);
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(_node: Expression): string {
|
||||||
|
throw new Error('Not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpressionErrorFromSyntaxError(
|
||||||
|
code: string,
|
||||||
|
error: SyntaxError & { loc?: Position }
|
||||||
|
): ExpressionError {
|
||||||
|
const loc = error.loc;
|
||||||
|
|
||||||
|
if (!loc) {
|
||||||
|
return new ExpressionError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`;
|
||||||
|
const token = getTokenAtLoc(code, loc);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new ExpressionError(errorMessage, loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpressionError(errorMessage, loc, token);
|
||||||
|
}
|
||||||
|
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
|
||||||
|
const tokens = tokenizer(code, {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
locations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.loc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = token.loc;
|
||||||
|
|
||||||
|
const onSameLine = errorLoc.line === start.line;
|
||||||
|
const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column;
|
||||||
|
|
||||||
|
if (onSameLine && inColumnRange) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParsedExpressionStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return statement.type === 'ExpressionStatement';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutModuleDeclarationStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return ![
|
||||||
|
'ImportDeclaration',
|
||||||
|
'ExportNamedDeclaration',
|
||||||
|
'ExportDefaultDeclaration',
|
||||||
|
'ExportAllDeclaration',
|
||||||
|
].includes(statement.type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
|
||||||
|
import { SymbolsTable } from '../symbols-table';
|
||||||
|
import type { SymbolType } from '../types';
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const initialSymbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolString({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolString({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolString({ name: 'FLAG3' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
describe('addSymbols', () => {
|
||||||
|
it('should the symbols matching the provided object to the table', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
symbolsTable.addSymbols({
|
||||||
|
space: SymbolObject({
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'id',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'space',
|
||||||
|
childrenRefs: ['space.id', 'space.title'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Symbols standard library', () => {
|
||||||
|
it('should allow to access methods & properties defined as part of the standard library', () => {
|
||||||
|
const symbolsTable = new SymbolsTable({
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSymbolInfo', () => {
|
||||||
|
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1.length',
|
||||||
|
'visitor.claims.flags.FLAG1.at',
|
||||||
|
'visitor.claims.flags.FLAG1.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG1.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG2.length',
|
||||||
|
'visitor.claims.flags.FLAG2.at',
|
||||||
|
'visitor.claims.flags.FLAG2.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG2.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromValue', () => {
|
||||||
|
it('should infer properly a symbol based on a value', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromValue(
|
||||||
|
{
|
||||||
|
visitor: {
|
||||||
|
claims: {
|
||||||
|
key: 'test',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'testflag1',
|
||||||
|
FLAG2: 'testflag2',
|
||||||
|
FLAG3: 'testflag3',
|
||||||
|
},
|
||||||
|
hello: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'context'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'context',
|
||||||
|
properties: {
|
||||||
|
visitor: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromJSONSchema', () => {
|
||||||
|
it('should infer properly a symbol table based on a JSON schema', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'visitor'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './symbols';
|
||||||
|
export * from './symbols-table';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
|
||||||
|
import { filterOutNullable } from '../utils';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNull,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolUndefined,
|
||||||
|
} from './symbols';
|
||||||
|
import {
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolWithMethods,
|
||||||
|
type SymbolWithProperties,
|
||||||
|
resolveSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface SymbolInfo<T extends SymbolType = SymbolType> {
|
||||||
|
/**
|
||||||
|
* Definition of the symbol.
|
||||||
|
*/
|
||||||
|
definition: ExtractSymbolDef<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference of the symbol in the table of symbols.
|
||||||
|
*/
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the parent symbol.
|
||||||
|
*/
|
||||||
|
parentRef?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the children symbols.
|
||||||
|
*/
|
||||||
|
childrenRefs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, SymbolError);
|
||||||
|
}
|
||||||
|
this.name = 'SymbolError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolsTable {
|
||||||
|
/**
|
||||||
|
* Internal table that keeps track of all symbols reference.
|
||||||
|
*/
|
||||||
|
#table: Record<string, SymbolInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal table that keep track of the raw symbols definitions.
|
||||||
|
*/
|
||||||
|
#rawSymbols: Record<string, GenericSymbolDef>;
|
||||||
|
|
||||||
|
constructor(initialContext: Record<string, GenericSymbolDef> = {}) {
|
||||||
|
this.#table = {};
|
||||||
|
this.#rawSymbols = {};
|
||||||
|
this.addSymbols(initialContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new symbols table by merging the current one with the provided one.
|
||||||
|
*/
|
||||||
|
merge(other: SymbolsTable): SymbolsTable {
|
||||||
|
return new SymbolsTable({
|
||||||
|
...this.#rawSymbols,
|
||||||
|
...other.#rawSymbols,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return JSON.stringify(this.#table, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the symbol of a value and generate the appropriate symbol definition.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef<SymbolType> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return SymbolArray({ items: SymbolUndefined() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0));
|
||||||
|
// Check that the array is not a mixin of different items types.
|
||||||
|
if (value.length > 1) {
|
||||||
|
const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1));
|
||||||
|
if (firstItemSymbol.type !== secondItemSymbol.type) {
|
||||||
|
throw new SymbolError('Array with mixin items types are not supported');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SymbolArray({ name, items: firstItemSymbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
return SymbolUndefined({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return SymbolNull({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value;
|
||||||
|
switch (valueType) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({ name });
|
||||||
|
case 'number':
|
||||||
|
return SymbolNumber({ name });
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({ name });
|
||||||
|
case 'object': {
|
||||||
|
const properties = Object.entries(value).reduce<Record<string, GenericSymbolDef>>(
|
||||||
|
(prev, [name, val]) => {
|
||||||
|
prev[name] = SymbolsTable.inferSymbolFromValue(val, name);
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return SymbolObject({ name, properties, methods: [] });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new SymbolError(`Unsupported symbol type ${valueType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a table of symbol based on a JSON schema object describing it.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromJSONSchema(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType> {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
...(schema.enum
|
||||||
|
? {
|
||||||
|
enum: schema.enum
|
||||||
|
.filter(filterOutNullable)
|
||||||
|
.map((enumValue) => enumValue.toString()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return SymbolNumber({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'null':
|
||||||
|
return SymbolNull({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'object':
|
||||||
|
return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name);
|
||||||
|
case 'array':
|
||||||
|
return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildObjectSymbolFromJSONSchemaObject(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ObjectSymbolDef {
|
||||||
|
const properties: Record<string, GenericSymbolDef> = {};
|
||||||
|
|
||||||
|
if (schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
|
||||||
|
properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
propertySchema as JSONSchema7,
|
||||||
|
propertyName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolObject({
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildArraySymbolFromJSONSchemaArray(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType.Array> {
|
||||||
|
if (schema.items) {
|
||||||
|
const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
schema.items as JSONSchema7,
|
||||||
|
`${name || ''}_item`
|
||||||
|
);
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: itemSymbol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: SymbolUndefined(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSymbolRefPath(path: string[]): string {
|
||||||
|
return path.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single symbol to the table at the provided path.
|
||||||
|
*/
|
||||||
|
private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void {
|
||||||
|
const fullPath = this.generateSymbolRefPath(path);
|
||||||
|
const parentPath = path.slice(0, -1).join('.');
|
||||||
|
|
||||||
|
if (this.#table[fullPath]) {
|
||||||
|
throw new SymbolError(`Symbol "${fullPath}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
this.#rawSymbols[fullPath] = definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new symbol linking it to its parent
|
||||||
|
this.#table[fullPath] = {
|
||||||
|
definition: resolveSymbolDef(definition),
|
||||||
|
ref: fullPath,
|
||||||
|
parentRef: parentPath || undefined,
|
||||||
|
childrenRefs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentPath && this.#table[parentPath]) {
|
||||||
|
if (this.#table[parentPath].childrenRefs) {
|
||||||
|
this.#table[parentPath].childrenRefs.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any nested symbols if the value is a symbol with properties...
|
||||||
|
if (isSymbolWithProperties(definition)) {
|
||||||
|
Object.entries(definition.properties).forEach(([propKey, propSymbol]) => {
|
||||||
|
if (isObjectSymbol(propSymbol)) {
|
||||||
|
this.addSymbols({ [propKey]: propSymbol }, path, false);
|
||||||
|
} else {
|
||||||
|
this.addSymbol([...path, propKey], propSymbol, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...or a symbol with methods
|
||||||
|
if (isSymbolWithMethods(definition)) {
|
||||||
|
definition.methods.forEach((methodSymbol) => {
|
||||||
|
this.addSymbol([...path, methodSymbol.name], methodSymbol, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the provided object of symbols definitions to the symbol table.
|
||||||
|
*/
|
||||||
|
public addSymbols(
|
||||||
|
symbols: Record<string, GenericSymbolDef>,
|
||||||
|
prefix: string[] = [],
|
||||||
|
raw = true
|
||||||
|
): void {
|
||||||
|
for (const [key, symbolDef] of Object.entries(symbols)) {
|
||||||
|
const path = [...prefix, key];
|
||||||
|
|
||||||
|
// Add the current symbol to the table.
|
||||||
|
this.addSymbol(path, symbolDef, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a symbol's information using its path in the table.
|
||||||
|
*/
|
||||||
|
public getSymbolInfo<T extends SymbolType>(path: string | string[]): SymbolInfo<T> | undefined {
|
||||||
|
const key = Array.isArray(path) ? path.join('.') : path;
|
||||||
|
const info = this.#table[key];
|
||||||
|
return info ? typedSymbolInfo(info) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all symbol keys matching the pattern defined by the provided path.
|
||||||
|
*/
|
||||||
|
public getMatchingSymbolsKeys(path: string[]): string[] {
|
||||||
|
const wildcardRegex = new RegExp(
|
||||||
|
`^${path
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.includes('*')) {
|
||||||
|
return `${segment.split('*')[0]}([^.]+)?`;
|
||||||
|
}
|
||||||
|
return segment;
|
||||||
|
})
|
||||||
|
.join('\\.')}$`
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(this.#table)
|
||||||
|
.filter((key) => wildcardRegex.test(key))
|
||||||
|
.filter(filterOutNullable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef {
|
||||||
|
return symbol.type === SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typedSymbolInfo<T extends SymbolType>(info: SymbolInfo): SymbolInfo<T> {
|
||||||
|
const definition = resolveSymbolDef(info.definition);
|
||||||
|
return { ...info, definition } as SymbolInfo<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
type ArraySymbolDef,
|
||||||
|
type BooleanSymbolDef,
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type FunctionSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type NullSymbolDef,
|
||||||
|
type NumberSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
type StringSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolsWithPropertiesAndMethods,
|
||||||
|
type UndefinedSymbolDef,
|
||||||
|
type UnionSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Boolean,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Number,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolString(
|
||||||
|
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
|
||||||
|
): StringSymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Object,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolArray(
|
||||||
|
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
|
||||||
|
): ArraySymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Function,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionalFunctionArg(
|
||||||
|
optionalArg: ExtractSymbolDef<SymbolType>
|
||||||
|
): ExtractSymbolDef<SymbolType> & { optional: true } {
|
||||||
|
return {
|
||||||
|
...optionalArg,
|
||||||
|
optional: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Union,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Undefined,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Null,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSymbolWithPropertiesAndMethods<
|
||||||
|
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
|
||||||
|
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
|
||||||
|
const symbol = { type, ...args } as T;
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'properties', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).properties
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.properties || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'methods', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).methods
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.methods || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
|
||||||
|
|
||||||
|
const StandardLibrary: Partial<
|
||||||
|
{
|
||||||
|
[key in Exclude<SymbolType, SymbolType.Array>]: {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
} & {
|
||||||
|
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[SymbolType.String]: {
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the string.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: 'The index (position) of the string character to be returned',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `A String consisting of the single UTF-16 code unit located at the specified position.
|
||||||
|
Returns undefined if the given index can not be found.`,
|
||||||
|
members: [SymbolString(), SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'endsWith',
|
||||||
|
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
|
||||||
|
elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `The characters to be searched for at the end of str. Cannot be a regex.
|
||||||
|
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
|
||||||
|
the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'endPosition',
|
||||||
|
description: `The end position at which searchString is expected to be found
|
||||||
|
(the index of searchString's last character plus 1). Defaults to str.length.`,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
|
||||||
|
that are greater than or equal to position; otherwise, returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
|
||||||
|
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'position',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: `Zero-based index of the array element to be returned, converted to an integer.
|
||||||
|
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
|
||||||
|
without attempting to access the corresponding property.`,
|
||||||
|
members: [arraySymbolDef.items, SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description:
|
||||||
|
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'searchElement',
|
||||||
|
description: 'The value to be searched for within the array.',
|
||||||
|
},
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'fromIndex',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'some',
|
||||||
|
description:
|
||||||
|
'Tests whether at least one element in the array passes the test implemented by the provided function.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
|
||||||
|
args: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'callback',
|
||||||
|
description: 'A function that tests each element of the array.',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'element',
|
||||||
|
description: 'The current element being processed in the array.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'every',
|
||||||
|
description:
|
||||||
|
'Tests whether all elements in the array pass the test implemented by the provided function.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
|
||||||
|
args: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'callback',
|
||||||
|
description: 'A function that tests each element of the array.',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'element',
|
||||||
|
description: 'The current element being processed in the array.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for all elements in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for all elements in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
|
||||||
|
return symbol.type === SymbolType.Array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { MandateProps } from '../utils';
|
||||||
|
|
||||||
|
export enum SymbolType {
|
||||||
|
Boolean = 'boolean',
|
||||||
|
Number = 'number',
|
||||||
|
String = 'string',
|
||||||
|
Object = 'object',
|
||||||
|
Array = 'array',
|
||||||
|
Function = 'function',
|
||||||
|
Union = 'union',
|
||||||
|
Undefined = 'undefined',
|
||||||
|
Null = 'null',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Long description of the symbol.
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to a documentation/manual page.
|
||||||
|
*/
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSymbolDef extends SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Type of the symbol.
|
||||||
|
*/
|
||||||
|
type: SymbolType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the symbol.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithProperties extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Properties on the symbol type.
|
||||||
|
*/
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithMethods extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Methods that can be called on the symbol type.
|
||||||
|
*/
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.String;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of enumerated values that the string symbol is retristred to.
|
||||||
|
*/
|
||||||
|
enum?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on strings.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol representing the type of the items of the array
|
||||||
|
*/
|
||||||
|
items: ExtractSymbolDef<SymbolType>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on arrays.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionSymbolDef extends MandateProps<GenericSymbolDef, 'name'> {
|
||||||
|
type: SymbolType.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols describing the arguments of the function.
|
||||||
|
*/
|
||||||
|
args: (ExtractSymbolDef<SymbolType> & { optional?: boolean })[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol describing the returned value of the function.
|
||||||
|
*/
|
||||||
|
returns: ExtractSymbolDef<SymbolType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnionSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Union;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols composing the union.
|
||||||
|
*/
|
||||||
|
members: ExtractSymbolDef<SymbolType>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndefinedSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NullSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef;
|
||||||
|
|
||||||
|
export type ExtractSymbolDef<T extends SymbolType> = T extends SymbolType.String
|
||||||
|
? StringSymbolDef
|
||||||
|
: T extends SymbolType.Number
|
||||||
|
? NumberSymbolDef
|
||||||
|
: T extends SymbolType.Boolean
|
||||||
|
? BooleanSymbolDef
|
||||||
|
: T extends SymbolType.Array
|
||||||
|
? ArraySymbolDef
|
||||||
|
: T extends SymbolType.Object
|
||||||
|
? ObjectSymbolDef
|
||||||
|
: T extends SymbolType.Function
|
||||||
|
? FunctionSymbolDef
|
||||||
|
: T extends SymbolType.Union
|
||||||
|
? UnionSymbolDef
|
||||||
|
: T extends SymbolType.Undefined
|
||||||
|
? UndefinedSymbolDef
|
||||||
|
: T extends SymbolType.Null
|
||||||
|
? NullSymbolDef
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export function resolveSymbolDef(
|
||||||
|
symbol: GenericSymbolDef
|
||||||
|
):
|
||||||
|
| StringSymbolDef
|
||||||
|
| NumberSymbolDef
|
||||||
|
| BooleanSymbolDef
|
||||||
|
| ArraySymbolDef
|
||||||
|
| ObjectSymbolDef
|
||||||
|
| FunctionSymbolDef
|
||||||
|
| UnionSymbolDef
|
||||||
|
| UndefinedSymbolDef
|
||||||
|
| NullSymbolDef {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.String:
|
||||||
|
return symbol as StringSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Number:
|
||||||
|
return symbol as NumberSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
return symbol as BooleanSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Array:
|
||||||
|
return symbol as ArraySymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Object:
|
||||||
|
return symbol as ObjectSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Function:
|
||||||
|
return symbol as FunctionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Union:
|
||||||
|
return symbol as UnionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
return symbol as UndefinedSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Null:
|
||||||
|
return symbol as NullSymbolDef;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown symbol type: ${symbol.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type TemplateText = {
|
||||||
|
type: 'text';
|
||||||
|
value: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateExpression = {
|
||||||
|
type: 'expression';
|
||||||
|
value: string;
|
||||||
|
start: number; // Start index of the expression content (after `{{`)
|
||||||
|
end: number; // End index of the expression content (before `}}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplatePart = TemplateText | TemplateExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
export function parseTemplate(template: string): TemplatePart[] {
|
||||||
|
const parts: TemplatePart[] = [];
|
||||||
|
const regex = /\{\{(.*?)\}\}/gs;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of template.matchAll(regex)) {
|
||||||
|
const matchStart = match.index ?? 0;
|
||||||
|
const matchEnd = matchStart + match[0].length;
|
||||||
|
|
||||||
|
if (matchStart > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex, matchStart),
|
||||||
|
start: lastIndex,
|
||||||
|
end: matchStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
type: 'expression',
|
||||||
|
value: (match[1] ?? '').trim(),
|
||||||
|
start: matchStart + 2,
|
||||||
|
end: matchEnd - 2,
|
||||||
|
});
|
||||||
|
lastIndex = matchEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < template.length) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex),
|
||||||
|
start: lastIndex,
|
||||||
|
end: template.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { BinaryOperator, Expression, ExpressionStatement, LogicalOperator } from 'acorn';
|
||||||
|
|
||||||
|
import type { ArraySymbolDef, SymbolInfo, SymbolType } from './symbols';
|
||||||
|
|
||||||
|
export interface ExpressionGenerator {
|
||||||
|
/**
|
||||||
|
* Converts an ESTree compatible AST node into a string representing the corresponding expression.
|
||||||
|
*/
|
||||||
|
generate(node: Expression): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionParserResult {
|
||||||
|
/**
|
||||||
|
* The expression statement from the valid portion of the parsed expression.
|
||||||
|
*
|
||||||
|
* It is undefined when no valid expression statements could be found.
|
||||||
|
*/
|
||||||
|
result: Expression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
|
||||||
|
*/
|
||||||
|
invalidNodes: Array<ExpressionStatement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionAutocompleteResults {
|
||||||
|
suggestions: AutocompleteSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionalOperator = '?';
|
||||||
|
|
||||||
|
export const SUPPORTED_BINARY_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '==',
|
||||||
|
description: 'Checks whether two values are equal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!=',
|
||||||
|
description: 'Checks whether two values are unequal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '===',
|
||||||
|
description: 'Checks whether two values are equal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!==',
|
||||||
|
description: 'Checks whether two values are unequal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<',
|
||||||
|
description: 'Checks if the left value is less than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<=',
|
||||||
|
description: 'Checks if the left value is less than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>',
|
||||||
|
description: 'Checks if the left value is greater than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>=',
|
||||||
|
description: 'Checks if the left value is greater than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: 'in',
|
||||||
|
description: 'Checks if a property exists in an object or if a value is in an array.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_LOGICAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '&&',
|
||||||
|
description: 'Logical AND operator; returns true if both operands are true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '||',
|
||||||
|
description: 'Logical OR operator; returns true if at least one operand is true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_CONDITIONAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '?',
|
||||||
|
description:
|
||||||
|
'Conditional (ternary) operator; returns one of two values based on a condition.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type DirectLiteralValue = {
|
||||||
|
kind: 'direct';
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
data: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
data: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.String;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type InArrayLiteralValue = {
|
||||||
|
kind: 'in-array';
|
||||||
|
srcSymbol: ArraySymbolDef;
|
||||||
|
matchedLiteralString: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirectLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: DirectLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InArrayLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: InArrayLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutocompleteLiteralValueSuggestion =
|
||||||
|
| DirectLiteralValueSuggestion
|
||||||
|
| InArrayLiteralValueSuggestion;
|
||||||
|
|
||||||
|
export interface AutocompleteOperatorSuggestion {
|
||||||
|
type: 'operator';
|
||||||
|
operator:
|
||||||
|
| Extract<BinaryOperator, (typeof SUPPORTED_BINARY_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<LogicalOperator, (typeof SUPPORTED_LOGICAL_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<
|
||||||
|
ConditionalOperator,
|
||||||
|
(typeof SUPPORTED_CONDITIONAL_OPERATORS)[number]['operator']
|
||||||
|
>;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteSymbolSuggestion {
|
||||||
|
type: 'symbol';
|
||||||
|
symbol: SymbolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteSuggestions = Array<
|
||||||
|
| AutocompleteSymbolSuggestion
|
||||||
|
| AutocompleteLiteralValueSuggestion
|
||||||
|
| AutocompleteOperatorSuggestion
|
||||||
|
>;
|
||||||
|
|
||||||
|
type LoggerFn = (message: string, ...args: any[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A logger that can be passed to the runtime.
|
||||||
|
*/
|
||||||
|
export interface Logger {
|
||||||
|
debug: LoggerFn;
|
||||||
|
info: LoggerFn;
|
||||||
|
error: LoggerFn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Format the result value of an expression for display as a string.
|
||||||
|
*/
|
||||||
|
export function formatExpressionResult(value: any, defaultValue = ''): string {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter function to exclude `null` values
|
||||||
|
*/
|
||||||
|
export function filterOutNullable<T>(value: T): value is NonNullable<T> {
|
||||||
|
return !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type to make optional properties on a object mandatory.
|
||||||
|
*
|
||||||
|
* interface SomeObject {
|
||||||
|
* uid: string;
|
||||||
|
* price: number | null;
|
||||||
|
* location?: string;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* type ValuableObject = MandateProps<SomeObject, 'price' | 'location'>;
|
||||||
|
*/
|
||||||
|
export type MandateProps<T extends {}, K extends keyof T> = T & {
|
||||||
|
[MK in K]-?: NonNullable<T[MK]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["./tsconfig.json"],
|
||||||
|
"exclude": ["**/*.test.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"incremental": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["types/**/*.d.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
dts: true,
|
||||||
|
format: ['esm'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
declare module 'eval-estree-expression' {
|
||||||
|
/**
|
||||||
|
* Options for evaluation and compilation.
|
||||||
|
*/
|
||||||
|
export interface EvalESTreeExpressionOptions {
|
||||||
|
/**
|
||||||
|
* Force logical operators to return a boolean result. Default: undefined
|
||||||
|
*/
|
||||||
|
booleanLogicalOperators?: boolean;
|
||||||
|
/**
|
||||||
|
* Allow function calls to be evaluated. This is unsafe, please enable this option at your own risk. Default: false
|
||||||
|
*/
|
||||||
|
functions?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
|
||||||
|
*/
|
||||||
|
generate?: boolean | ((node: any) => string);
|
||||||
|
/**
|
||||||
|
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
|
||||||
|
*/
|
||||||
|
regexOperator?: boolean;
|
||||||
|
/**
|
||||||
|
* Throw an error when variables are undefined. Default: false
|
||||||
|
*/
|
||||||
|
strict?: boolean;
|
||||||
|
/**
|
||||||
|
* Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined
|
||||||
|
*/
|
||||||
|
withMembers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression asynchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns A promise resolving to the result of the evaluation.
|
||||||
|
*/
|
||||||
|
export function evaluate<ASTNode>(
|
||||||
|
ast: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression synchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns The result of the evaluation.
|
||||||
|
*/
|
||||||
|
export namespace evaluate {
|
||||||
|
function sync<ASTNode>(
|
||||||
|
expression: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): any;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
|||||||
|
|
||||||
const body = token.slice(2); // drop "U+"
|
const body = token.slice(2); // drop "U+"
|
||||||
const [startHex, endHex] = body.split('-');
|
const [startHex, endHex] = body.split('-');
|
||||||
const start = Number.parseInt(startHex, 16);
|
const start = Number.parseInt(startHex!, 16);
|
||||||
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
||||||
|
|
||||||
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
||||||
@@ -92,7 +92,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
|||||||
|
|
||||||
for (const [label, rangesArr] of Object.entries(parsed)) {
|
for (const [label, rangesArr] of Object.entries(parsed)) {
|
||||||
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
||||||
hits[label]++;
|
hits[label]!++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -1,5 +1,177 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.17.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @gitbook/react-openapi@1.4.2
|
||||||
|
|
||||||
|
## 0.17.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 24f601d: Small optim in resolveTryItPrefillForOperation
|
||||||
|
- aea5eb1: Persist language choice across sections if possible
|
||||||
|
- 1165a81: Language selector edge cases
|
||||||
|
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
|
||||||
|
- Updated dependencies [24f601d]
|
||||||
|
- @gitbook/react-openapi@1.4.1
|
||||||
|
|
||||||
|
## 0.17.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
|
||||||
|
- 7911350: Add language selector to site header
|
||||||
|
- 659be55: Track insight event when embedded assistant is displayed.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
|
||||||
|
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
|
||||||
|
- 1edc5d6: Add "hold message" to Assistant
|
||||||
|
- 4520728: Support bold in headings
|
||||||
|
- 61b8507: OpenAPI: Make responses without objects clickable
|
||||||
|
- d1fdc13: Remove ligatures from Lato font
|
||||||
|
- a8fca0e: Fix custom assistant keyboard shortcut
|
||||||
|
- 262a9b1: Fix embed script and assets being cached for too long
|
||||||
|
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
|
||||||
|
- 1839ea2: Fix content min-height with sections
|
||||||
|
- 9201e2c: Adds vertical align to column block
|
||||||
|
- 193d591: Use space language as source of truth for UI locale
|
||||||
|
- f08dd29: Fix Search results are not clickable on sites without header
|
||||||
|
- 17dd382: Add `original` background color step
|
||||||
|
- 4f35882: Fix event ask_question not being tracked
|
||||||
|
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
|
||||||
|
- 6f368b5: Fix embed assistant window width on small screens
|
||||||
|
- Updated dependencies [193d591]
|
||||||
|
- Updated dependencies [12c9d76]
|
||||||
|
- Updated dependencies [4927e96]
|
||||||
|
- Updated dependencies [61b8507]
|
||||||
|
- Updated dependencies [7fefe49]
|
||||||
|
- Updated dependencies [360aa1c]
|
||||||
|
- Updated dependencies [98e42cf]
|
||||||
|
- Updated dependencies [17dd382]
|
||||||
|
- @gitbook/colors@0.4.0
|
||||||
|
- @gitbook/react-openapi@1.4.0
|
||||||
|
- @gitbook/openapi-parser@3.0.2
|
||||||
|
|
||||||
|
## 0.16.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6830815: Support custom AI providers
|
||||||
|
- cbc71a5: Allow integrations to provide tools to the Docs Assistant
|
||||||
|
- cc2e615: Emit a <link rel="alternate"> for the markdown version of a page
|
||||||
|
- 81a6bd7: Support customization of buttons and tools through iframe API
|
||||||
|
- 8927e8f: Start routes for embeddable version of the assistant and docs pages.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d30bcba: Improve `Button` and `ButtonGroup` styling
|
||||||
|
- e1b2cf6: Fix scroll of page outline
|
||||||
|
- d655d3e: Support "objectivec" as alias for Objective C syntax
|
||||||
|
- 13ff22b: Fix AI Search follow-up question closing search
|
||||||
|
- ffa866c: Small fixes to search modal
|
||||||
|
- 36af03f: Fixes to `PageAside`
|
||||||
|
- fb858a1: Tweaks to AIChatButton and AIChatInput
|
||||||
|
- bcfa8d8: Improve vertical alignment of site items and fix floating page aside
|
||||||
|
- ea7e94f: Fix search bar layout shift caused by ToC
|
||||||
|
- 2e6e28e: Fix: Long strings overflow out of message bubble in docs assistant
|
||||||
|
- ff96bb5: Support new coverDefinitionDark for cards & image type
|
||||||
|
- 388b20d: Clear AI chat properly
|
||||||
|
- ba7ec14: Fix bug in search highlight in GBO
|
||||||
|
- 6217a2e: Page outline: scroll to active item
|
||||||
|
- 42c17f5: Improve OpenAPI parsing errors
|
||||||
|
- 854c448: Custom assistants followup
|
||||||
|
- 43766d6: Fix Custom logo not rendering on the published site
|
||||||
|
- Updated dependencies [cbc71a5]
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- Updated dependencies [42c17f5]
|
||||||
|
- Updated dependencies [854c448]
|
||||||
|
- @gitbook/browser-types@0.1.0
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
|
- @gitbook/openapi-parser@3.0.1
|
||||||
|
- @gitbook/react-contentkit@0.7.4
|
||||||
|
- @gitbook/react-openapi@1.3.6
|
||||||
|
|
||||||
|
## 0.15.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 0003030: Implement AI actions dropdown
|
||||||
|
- acb9f53: New search layout
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 334cfdd: Fix responsive class for SearchInput button
|
||||||
|
- 6816f0f: add browserlists and fix old browser css-masks
|
||||||
|
- 9ee9082: Refactor icon loading state in AIAction components
|
||||||
|
- 52ab368: Reposition AI Actions dropdown
|
||||||
|
- 7212345: Fix AI Actions dropdown and LLM integration
|
||||||
|
- 8daede5: Ensure all content links are resolved relatively to preview.
|
||||||
|
- ed684c1: Move AI Actions markdown fetching to client-side
|
||||||
|
- 9cc5a78: Generalise keyboard shortcuts, add Cmd+J to AI Chat
|
||||||
|
- Updated dependencies [6816f0f]
|
||||||
|
- Updated dependencies [1738677]
|
||||||
|
- @gitbook/icons@0.2.1
|
||||||
|
- @gitbook/openapi-parser@2.2.1
|
||||||
|
- @gitbook/react-contentkit@0.7.2
|
||||||
|
- @gitbook/react-openapi@1.3.3
|
||||||
|
|
||||||
|
## 0.13.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f5894bc: Fix incorrect revision ID in Markdown fetch logic
|
||||||
|
- 1b59e7c: Styling improvements to AI Chat Followup questions
|
||||||
|
- Updated dependencies [bd553bc]
|
||||||
|
- @gitbook/openapi-parser@2.2.0
|
||||||
|
- @gitbook/react-openapi@1.3.2
|
||||||
|
|
||||||
## 0.13.0
|
## 0.13.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',
|
||||||
@@ -415,6 +536,28 @@ const testCases: TestsCase[] = [
|
|||||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'With sections',
|
||||||
|
url: async () => {
|
||||||
|
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set('token', data.apiToken);
|
||||||
|
|
||||||
|
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||||
|
},
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
const sectionTabs = page.getByLabel('Sections');
|
||||||
|
await expect(sectionTabs).toBeVisible();
|
||||||
|
|
||||||
|
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||||
|
for (const link of await sectionTabLinks.all()) {
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -910,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;
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev"
|
"STAGE": "dev",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||||
|
"GITBOOK_URL": "http://localhost:8771"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
@@ -35,13 +37,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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -84,6 +90,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staging": {
|
"staging": {
|
||||||
|
"vars": {
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
@@ -125,7 +134,8 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||||
// This means that we'll have up to 100 durable objects instance running at the same time
|
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||||
"MAX_REVALIDATE_CONCURRENCY": "100"
|
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,12 +7,37 @@ export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js
|
|||||||
|
|
||||||
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,12 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev",
|
"STAGE": "dev",
|
||||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
"NEXT_PRIVATE_DEBUG_CACHE": "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": [
|
||||||
{
|
{
|
||||||
@@ -85,7 +90,8 @@
|
|||||||
"staging": {
|
"staging": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "staging",
|
"STAGE": "staging",
|
||||||
"WORKER_VERSION_ID": "TO_REPLACE"
|
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
@@ -153,7 +159,8 @@
|
|||||||
// TODO: remove this once the issue is fixed
|
// TODO: remove this once the issue is fixed
|
||||||
"DEBUG_CLOUDFLARE": "true",
|
"DEBUG_CLOUDFLARE": "true",
|
||||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||||
"STAGE": "production"
|
"STAGE": "production",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
|
|||||||
const versionId = args[0];
|
const versionId = args[0];
|
||||||
|
|
||||||
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
|
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
|
||||||
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
|
const previewHostname = `${versionId?.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
|
||||||
|
|
||||||
let updatedFile = file.replace(
|
let updatedFile = file.replace(
|
||||||
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { trace } from '@/lib/tracing';
|
|
||||||
import type {
|
import type {
|
||||||
CacheEntryType,
|
CacheEntryType,
|
||||||
CacheValue,
|
CacheValue,
|
||||||
@@ -34,41 +32,42 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
cacheType?: CacheType
|
cacheType?: CacheType
|
||||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||||
const cacheKey = this.getR2Key(key, cacheType);
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheGet',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
|
||||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
|
||||||
const localCache = await this.getCacheInstance();
|
|
||||||
if (!r2) throw new Error('No R2 bucket');
|
|
||||||
try {
|
|
||||||
// Check local cache first if available
|
|
||||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
|
||||||
if (localCacheEntry) {
|
|
||||||
span.setAttribute('cacheHit', 'local');
|
|
||||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
|
||||||
CacheValue<CacheType>
|
|
||||||
>;
|
|
||||||
return this.returnNullOn404(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
const r2Object = await r2.get(cacheKey);
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
if (!r2Object) return null;
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
span.setAttribute('cacheHit', 'r2');
|
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
|
||||||
return this.returnNullOn404({
|
// We are in a local middleware environment, we should bypass the cache
|
||||||
value: await r2Object.json(),
|
// and go directly to the server.
|
||||||
lastModified: r2Object.uploaded.getTime(),
|
return null;
|
||||||
});
|
}
|
||||||
} catch (e) {
|
try {
|
||||||
console.error('Failed to get from cache', e);
|
// Check local cache first if available
|
||||||
return null;
|
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||||
}
|
if (localCacheEntry) {
|
||||||
|
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||||
|
CacheValue<CacheType>
|
||||||
|
>;
|
||||||
|
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);
|
||||||
|
if (!r2Object) return null;
|
||||||
|
|
||||||
|
return this.returnNullOn404({
|
||||||
|
value: await r2Object.json(),
|
||||||
|
lastModified: r2Object.uploaded.getTime(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get from cache', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: This is a workaround to handle 404 responses in the cache.
|
//TODO: This is a workaround to handle 404 responses in the cache.
|
||||||
@@ -89,91 +88,84 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
cacheType?: CacheType
|
cacheType?: CacheType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cacheKey = this.getR2Key(key, cacheType);
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheSet',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
|
||||||
const localCache = await this.getCacheInstance();
|
|
||||||
|
|
||||||
try {
|
const localCache = await this.getCacheInstance();
|
||||||
await this.writeToR2(cacheKey, JSON.stringify(value));
|
|
||||||
|
|
||||||
//TODO: Check if there is any places where we don't have tags
|
try {
|
||||||
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
await this.writeToR2(cacheKey, JSON.stringify(value));
|
||||||
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
|
||||||
const tags = this.getTagsFromCacheEntry(value) ?? [
|
|
||||||
`build_id/${process.env.NEXT_BUILD_ID}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// We consider R2 as the source of truth, so we update the local cache
|
//TODO: Check if there is any places where we don't have tags
|
||||||
// only after a successful R2 write
|
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||||
await localCache.put(
|
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||||
this.getCacheUrlKey(cacheKey),
|
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||||
new Response(
|
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||||
JSON.stringify({
|
];
|
||||||
value,
|
|
||||||
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
// We consider R2 as the source of truth, so we update the local cache
|
||||||
// See https://developers.cloudflare.com/workers/reference/security-model/
|
// only after a successful R2 write
|
||||||
lastModified: Date.now(),
|
await localCache.put(
|
||||||
}),
|
this.getCacheUrlKey(cacheKey),
|
||||||
{
|
new Response(
|
||||||
headers: {
|
JSON.stringify({
|
||||||
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
value,
|
||||||
// In theory we should always get the `revalidate` value
|
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||||
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||||
'cache-tag': tags.join(','),
|
lastModified: Date.now(),
|
||||||
},
|
}),
|
||||||
}
|
{
|
||||||
)
|
headers: {
|
||||||
);
|
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||||
} catch (e) {
|
// In theory we should always get the `revalidate` value
|
||||||
console.error('Failed to set to cache', e);
|
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||||
}
|
'cache-tag': tags.join(','),
|
||||||
}
|
},
|
||||||
);
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set to cache', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
const cacheKey = this.getR2Key(key);
|
const cacheKey = this.getR2Key(key);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheDelete',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
|
||||||
const localCache = await this.getCacheInstance();
|
|
||||||
if (!r2) throw new Error('No R2 bucket');
|
|
||||||
|
|
||||||
try {
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
await r2.delete(cacheKey);
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
|
||||||
// Here again R2 is the source of truth, so we delete from local cache first
|
try {
|
||||||
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
await r2.delete(cacheKey);
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to delete from cache', e);
|
// Here again R2 is the source of truth, so we delete from local cache first
|
||||||
}
|
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||||
}
|
} catch (e) {
|
||||||
);
|
console.error('Failed to delete from cache', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeToR2(key: string, value: string): Promise<void> {
|
async writeToR2(key: string, value: string): Promise<void> {
|
||||||
const env = getCloudflareContext().env as {
|
try {
|
||||||
WRITE_BUFFER: DurableObjectNamespace<
|
const env = getCloudflareContext().env as {
|
||||||
Rpc.DurableObjectBranded & {
|
WRITE_BUFFER: DurableObjectNamespace<
|
||||||
write: (key: string, value: string) => Promise<void>;
|
Rpc.DurableObjectBranded & {
|
||||||
}
|
write: (key: string, value: string) => Promise<void>;
|
||||||
>;
|
}
|
||||||
};
|
>;
|
||||||
const id = env.WRITE_BUFFER.idFromName(key);
|
};
|
||||||
|
const id = env.WRITE_BUFFER.idFromName(key);
|
||||||
|
|
||||||
// A stub is a client used to invoke methods on the Durable Object
|
// A stub is a client used to invoke methods on the Durable Object
|
||||||
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,3 @@
|
|||||||
import { trace } from '@/lib/tracing';
|
|
||||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||||
@@ -6,9 +5,7 @@ import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
|||||||
export default {
|
export default {
|
||||||
name: 'GitbookISRQueue',
|
name: 'GitbookISRQueue',
|
||||||
send: async (msg) => {
|
send: async (msg) => {
|
||||||
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
|
const { ctx } = getCloudflareContext();
|
||||||
const { ctx } = getCloudflareContext();
|
ctx.waitUntil(doQueue.send(msg));
|
||||||
ctx.waitUntil(doQueue.send(msg));
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
} satisfies Queue;
|
} satisfies Queue;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { getLogger } from '@/lib/logger';
|
||||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GitbookISRQueue',
|
name: 'GitbookISRQueue',
|
||||||
send: async (msg) => {
|
send: async (msg) => {
|
||||||
|
const logger = getLogger().subLogger('GitbookISRQueue');
|
||||||
// We should never reach this point in the server. If that's the case, we should log it.
|
// We should never reach this point in the server. If that's the case, we should log it.
|
||||||
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
|
logger.warn('send called on server side, this should not happen.', msg);
|
||||||
},
|
},
|
||||||
} satisfies Queue;
|
} satisfies Queue;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { trace } from '@/lib/tracing';
|
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,62 +21,39 @@ 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) {
|
||||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
|
||||||
console.warn(
|
|
||||||
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
|
|
||||||
tags
|
|
||||||
);
|
|
||||||
return 0; // If no tags to check, return 0
|
return 0; // If no tags to check, return 0
|
||||||
}
|
}
|
||||||
return trace(
|
|
||||||
{
|
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||||
operation: 'gitbookTagCacheGetLastRevalidated',
|
|
||||||
name: tagsToCheck.join(', '),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||||
const tagsToCheck = tags.filter(softTagFilter);
|
try {
|
||||||
if (tagsToCheck.length === 0) {
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
if (tagsToCheck.length === 0) {
|
||||||
console.warn(
|
return false; // If no tags to check, return false
|
||||||
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
|
|
||||||
tags
|
|
||||||
);
|
|
||||||
return false; // If no tags to check, return false
|
|
||||||
}
|
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'gitbookTagCacheHasBeenRevalidated',
|
|
||||||
name: tagsToCheck.join(', '),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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[]) => {
|
||||||
return trace(
|
const tagsToWrite = tags.filter(softTagFilter);
|
||||||
{
|
if (tagsToWrite.length === 0) {
|
||||||
operation: 'gitbookTagCacheWriteTags',
|
const logger = getLogger().subLogger('gitbookTagCache');
|
||||||
name: tags.join(', '),
|
logger.warn('writeTags - No valid tags to write');
|
||||||
},
|
return; // If no tags to write, exit early
|
||||||
async () => {
|
}
|
||||||
const tagsToWrite = tags.filter(softTagFilter);
|
|
||||||
if (tagsToWrite.length === 0) {
|
await originalTagCache.writeTags(tagsToWrite);
|
||||||
console.warn('writeTags - No valid tags to write');
|
|
||||||
return; // If no tags to write, exit early
|
|
||||||
}
|
|
||||||
// Write only the filtered tags
|
|
||||||
await originalTagCache.writeTags(tagsToWrite);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
} satisfies NextModeTagCache;
|
} satisfies NextModeTagCache;
|
||||||
|
|||||||
@@ -1,79 +1,82 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.13.0",
|
"version": "0.17.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "1.2.1",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"assert-never": "^1.2.1",
|
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"next": "^15.3.2",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"rison": "^0.1.1",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"warn-once": "^0.1.1",
|
|
||||||
"object-identity": "^0.1.2",
|
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
|
"@gitbook/icons": "workspace:*",
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
"@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.7.1",
|
||||||
"@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",
|
||||||
"@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",
|
||||||
|
"bidc": "catalog:",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"direction": "^2.0.1",
|
||||||
"event-iterator": "^2.0.0",
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
"framer-motion": "^10.16.14",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
|
||||||
"mdast-util-from-markdown": "^2.0.2",
|
"mdast-util-from-markdown": "^2.0.2",
|
||||||
"mdast-util-frontmatter": "^2.0.1",
|
"mdast-util-frontmatter": "^2.0.1",
|
||||||
"mdast-util-gfm": "^3.1.0",
|
"mdast-util-gfm": "^3.1.0",
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"micromark-extension-frontmatter": "^2.0.0",
|
|
||||||
"unist-util-remove": "^4.0.0",
|
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
|
"micromark-extension-frontmatter": "^2.0.0",
|
||||||
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
|
"next": "15.4.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",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"parse-cache-control": "^1.0.1",
|
"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",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.1",
|
||||||
|
"rison": "^0.1.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"shiki": "^3.2.0",
|
"shiki": "^3.2.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwind-shades": "^1.1.2",
|
"tailwind-shades": "^1.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-remove": "^4.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zustand": "^5.0.3",
|
"warn-once": "^0.1.1",
|
||||||
"image-size": "^2.0.2",
|
"zustand": "^5.0.3"
|
||||||
"direction": "^2.0.1"
|
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -85,32 +88,34 @@
|
|||||||
"@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",
|
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||||
|
"start": "GITBOOK_URL=http://localhost:3000 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"
|
||||||
}
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+5
-3
@@ -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} context={context}>
|
||||||
<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} />;
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import {
|
||||||
|
EmbeddableRootLayout,
|
||||||
|
generateEmbeddableMetadata,
|
||||||
|
generateEmbeddableViewport,
|
||||||
|
} from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
interface SiteStaticLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
|
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||||
|
const withTracking = shouldTrackEvents(await headers());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableRootLayout
|
||||||
|
context={context}
|
||||||
|
withTracking={withTracking}
|
||||||
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EmbeddableRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
return generateEmbeddableViewport({ context });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
return generateEmbeddableMetadata({ context });
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||||
|
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||||
|
}
|
||||||
+4
-3
@@ -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 context={context}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
withTracking={withTracking}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||||
|
import { serveLLMsFullTxt } from '@/routes/llms-full';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams & { page: string }> }
|
||||||
|
) {
|
||||||
|
const awaitedParams = await params;
|
||||||
|
const page = Number(awaitedParams.page);
|
||||||
|
// If page is not a number, not an integer, or less than 0, return an error
|
||||||
|
if (Number.isNaN(page) || !Number.isInteger(page) || page < 0) {
|
||||||
|
return new Response('Invalid page', { status: 400 });
|
||||||
|
}
|
||||||
|
const { context } = await getStaticSiteContext(awaitedParams);
|
||||||
|
return serveLLMsFullTxt(context, page);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
|
||||||
|
return <EmbeddableAssistantPage context={context} />;
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This route serves a quick demo to test the script.js script.
|
||||||
|
* It is not used in production.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const { context } = await getStaticSiteContext(await params);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body></body>
|
||||||
|
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import {
|
||||||
|
EmbeddableRootLayout,
|
||||||
|
generateEmbeddableMetadata,
|
||||||
|
generateEmbeddableViewport,
|
||||||
|
} from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { shouldTrackEvents } from '@/lib/tracking';
|
||||||
|
|
||||||
|
interface SiteStaticLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
|
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||||
|
const withTracking = shouldTrackEvents();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableRootLayout
|
||||||
|
context={context}
|
||||||
|
withTracking={withTracking}
|
||||||
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EmbeddableRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
return generateEmbeddableViewport({ context });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
return generateEmbeddableMetadata({ context });
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||||
|
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { getAssetURL } from '@/lib/assets';
|
||||||
|
import { buildVersion } from '@/lib/build';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import type { CreateGitBookOptions } from '@gitbook/embed';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This route is used to serve the assistant.js script.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const { context } = await getEmbeddableStaticContext(await params);
|
||||||
|
const initOptions: CreateGitBookOptions = {
|
||||||
|
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
(function () {
|
||||||
|
const w = window;
|
||||||
|
const gb = w.GitBook;
|
||||||
|
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||||
|
|
||||||
|
if (typeof gb === "function") {
|
||||||
|
gb('init', initOptions);
|
||||||
|
} else {
|
||||||
|
var d = document;
|
||||||
|
|
||||||
|
var g = function () {
|
||||||
|
g.c(arguments);
|
||||||
|
};
|
||||||
|
g.q = [];
|
||||||
|
g.c = function (args) {
|
||||||
|
g.q.push(args);
|
||||||
|
};
|
||||||
|
w.GitBook = g;
|
||||||
|
|
||||||
|
g('init', initOptions);
|
||||||
|
|
||||||
|
const load = function () {
|
||||||
|
const style = document.createElement('link');
|
||||||
|
style.rel = 'stylesheet';
|
||||||
|
style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
const script = d.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
script.async = true;
|
||||||
|
script.src = ${JSON.stringify(getAssetURL(`embed/index.js?v=${buildVersion()}`))};
|
||||||
|
|
||||||
|
var latestScript = d.getElementsByTagName('script')[0];
|
||||||
|
latestScript.parentNode.insertBefore(script, latestScript);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
load();
|
||||||
|
} else if (w.attachEvent) {
|
||||||
|
w.attachEvent('onload', load);
|
||||||
|
} else {
|
||||||
|
w.addEventListener('load', load, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript',
|
||||||
|
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getLogger } from '@/lib/logger';
|
||||||
import { withVerifySignature } from '@/lib/routes';
|
import { withVerifySignature } from '@/lib/routes';
|
||||||
import { revalidateTag } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface JsonBody {
|
|||||||
* The body should be a JSON with { tags: string[] }
|
* The body should be a JSON with { tags: string[] }
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const logger = getLogger().subLogger('revalidate');
|
||||||
return withVerifySignature<JsonBody>(req, async (body) => {
|
return withVerifySignature<JsonBody>(req, async (body) => {
|
||||||
if (!body.tags || !Array.isArray(body.tags)) {
|
if (!body.tags || !Array.isArray(body.tags)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -23,8 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.tags.forEach((tag) => {
|
body.tags.forEach((tag) => {
|
||||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
logger.log(`Revalidating tag: ${tag}`);
|
||||||
console.log(`Revalidating tag: ${tag}`);
|
|
||||||
revalidateTag(tag);
|
revalidateTag(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from './useAI';
|
||||||
export * from './useAIChat';
|
export * from './useAIChat';
|
||||||
|
export type { RenderAIMessageOptions } from './server-actions';
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
}
|
}
|
||||||
@@ -68,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
@@ -98,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
@@ -111,13 +115,22 @@ function DescriptionForMCPToolCall(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DescriptionForOtherToolCall(props: {
|
||||||
|
toolCall: AIToolCallOther;
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
}) {
|
||||||
|
const { toolCall } = props;
|
||||||
|
|
||||||
|
return <p>{toolCall.summary.text}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
async function DescriptionForSearchToolCall(props: {
|
async function DescriptionForSearchToolCall(props: {
|
||||||
toolCall: AIToolCallSearch;
|
toolCall: AIToolCallSearch;
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
// Resolve all hrefs for search results in parallel
|
// Resolve all hrefs for search results in parallel
|
||||||
const searchResultsWithHrefs = await Promise.all(
|
const searchResultsWithHrefs = await Promise.all(
|
||||||
@@ -144,69 +157,80 @@ 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>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
{hasResults ? (
|
||||||
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
||||||
<Icon
|
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
||||||
icon="chevron-right"
|
<Icon
|
||||||
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
icon="chevron-right"
|
||||||
/>
|
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 ? (
|
||||||
<ol className="space-y-1">
|
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||||
{searchResultsWithHrefs.map((result, index) => (
|
<ol className="space-y-1">
|
||||||
<li
|
{searchResultsWithHrefs.map((result, index) => (
|
||||||
key={`${result.pageId}-${index}`}
|
<li
|
||||||
className="animate-fadeIn"
|
key={`${result.pageId}-${index}`}
|
||||||
style={{
|
className="animate-fade-in-slow"
|
||||||
animationDelay: `${index * 25}ms`,
|
style={{
|
||||||
}}
|
animationDelay: `${index * 25}ms`,
|
||||||
>
|
}}
|
||||||
<Link
|
|
||||||
href={result.href}
|
|
||||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Link
|
||||||
icon="memo"
|
href={result.href}
|
||||||
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||||
/>
|
>
|
||||||
<div className="flex flex-col gap-1 text-tint">
|
<Icon
|
||||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
icon="memo"
|
||||||
<HighlightQuery
|
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
||||||
query={toolCall.query}
|
/>
|
||||||
text={result.title}
|
<div className="flex flex-col gap-1 text-tint">
|
||||||
/>
|
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||||
</h3>
|
|
||||||
{result.description && (
|
|
||||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
|
||||||
<HighlightQuery
|
<HighlightQuery
|
||||||
query={toolCall.query}
|
query={toolCall.query}
|
||||||
text={result.description}
|
text={result.title}
|
||||||
/>
|
/>
|
||||||
</p>
|
</h3>
|
||||||
)}
|
{result.description && (
|
||||||
</div>
|
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||||
<Icon
|
<HighlightQuery
|
||||||
icon="chevron-right"
|
query={toolCall.query}
|
||||||
className="ml-auto size-3 shrink-0 self-center"
|
text={result.description}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</p>
|
||||||
</li>
|
)}
|
||||||
))}
|
</div>
|
||||||
</ol>
|
<Icon
|
||||||
</div>
|
icon="chevron-right"
|
||||||
|
className="ml-auto size-3 shrink-0 self-center"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(context.customization);
|
const language = getSpaceLanguage(context);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
@@ -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,97 +20,103 @@ export async function streamRenderAIMessage(
|
|||||||
rawStream: AsyncIterable<AIStreamResponse>,
|
rawStream: AsyncIterable<AIStreamResponse>,
|
||||||
options?: RenderAIMessageOptions
|
options?: RenderAIMessageOptions
|
||||||
) {
|
) {
|
||||||
const message: AIMessage = {
|
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||||
id: '',
|
const message: AIMessage = {
|
||||||
role: AIMessageRole.Assistant,
|
id: '',
|
||||||
steps: [],
|
role: AIMessageRole.Assistant,
|
||||||
};
|
steps: [],
|
||||||
|
|
||||||
const updateProcessingMessageStep = (
|
|
||||||
stepIndex: number,
|
|
||||||
callback: (step: AIMessageStep) => void
|
|
||||||
) => {
|
|
||||||
if (stepIndex > message.steps.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.steps[stepIndex]) {
|
|
||||||
message.steps = [...message.steps];
|
|
||||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
|
||||||
callback(message.steps[stepIndex]);
|
|
||||||
} else {
|
|
||||||
message.steps = [
|
|
||||||
...message.steps,
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
object: 'document',
|
|
||||||
data: {},
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
callback(message.steps[stepIndex]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the full-context in the background to avoid blocking the stream.
|
|
||||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
|
||||||
|
|
||||||
return parseResponse<{
|
|
||||||
content: React.ReactNode;
|
|
||||||
event: AIStreamResponse;
|
|
||||||
}>(rawStream, async (event) => {
|
|
||||||
switch (event.type) {
|
|
||||||
/**
|
|
||||||
* The agent is processing a tool call in a new message.
|
|
||||||
*/
|
|
||||||
case 'response_tool_call': {
|
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
|
||||||
step.toolCalls ??= [];
|
|
||||||
step.toolCalls.push(event.toolCall);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The agent is writing the content of a new message.
|
|
||||||
*/
|
|
||||||
case 'response_reasoning':
|
|
||||||
case 'response_document': {
|
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
|
||||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
|
||||||
|
|
||||||
step[container] ??= {
|
|
||||||
object: 'document',
|
|
||||||
data: {},
|
|
||||||
nodes: [],
|
|
||||||
};
|
|
||||||
step[container] = {
|
|
||||||
...step[container],
|
|
||||||
nodes: [...step[container].nodes],
|
|
||||||
};
|
|
||||||
if (event.operation === 'insert') {
|
|
||||||
step[container].nodes.push(...event.blocks);
|
|
||||||
} else {
|
|
||||||
step[container].nodes.splice(
|
|
||||||
-event.blocks.length,
|
|
||||||
event.blocks.length,
|
|
||||||
...event.blocks
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
content: (
|
|
||||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateProcessingMessageStep = (
|
||||||
|
stepIndex: number,
|
||||||
|
callback: (step: AIMessageStep) => void
|
||||||
|
) => {
|
||||||
|
if (stepIndex > message.steps.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.steps[stepIndex]) {
|
||||||
|
message.steps = [...message.steps];
|
||||||
|
// @ts-expect-error
|
||||||
|
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||||
|
// @ts-expect-error
|
||||||
|
callback(message.steps[stepIndex]);
|
||||||
|
} else {
|
||||||
|
message.steps = [
|
||||||
|
...message.steps,
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
object: 'document',
|
||||||
|
data: {},
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// @ts-expect-error
|
||||||
|
callback(message.steps[stepIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the full-context in the background to avoid blocking the stream.
|
||||||
|
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||||
|
|
||||||
|
return parseResponse<{
|
||||||
|
content: React.ReactNode;
|
||||||
|
event: AIStreamResponse;
|
||||||
|
}>(rawStream, async (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
/**
|
||||||
|
* The agent is processing a tool call in a new message.
|
||||||
|
*/
|
||||||
|
case 'response_tool_call': {
|
||||||
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
|
step.toolCalls ??= [];
|
||||||
|
step.toolCalls.push(event.toolCall);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent is writing the content of a new message.
|
||||||
|
*/
|
||||||
|
case 'response_reasoning':
|
||||||
|
case 'response_document': {
|
||||||
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
|
const container =
|
||||||
|
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||||
|
|
||||||
|
step[container] ??= {
|
||||||
|
object: 'document',
|
||||||
|
data: {},
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
step[container] = {
|
||||||
|
...step[container],
|
||||||
|
nodes: [...step[container].nodes],
|
||||||
|
};
|
||||||
|
if (event.operation === 'insert') {
|
||||||
|
step[container].nodes.push(...event.blocks);
|
||||||
|
} else {
|
||||||
|
step[container].nodes.splice(
|
||||||
|
-event.blocks.length,
|
||||||
|
event.blocks.length,
|
||||||
|
...event.blocks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
content: (
|
||||||
|
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
'use server';
|
'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 () => {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
let context = await getServerActionBaseContext();
|
||||||
|
if (options?.asEmbeddable) {
|
||||||
|
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||||
|
}
|
||||||
|
|
||||||
const api = await context.dataFetcher.api();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
|
||||||
mode: 'assistant',
|
const api = await context.dataFetcher.api();
|
||||||
input: [
|
const rawStream = api.orgs.streamAiResponseInSite(
|
||||||
|
siteURLData.organization,
|
||||||
|
siteURLData.site,
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
input: message
|
||||||
content: message,
|
? [
|
||||||
context: messageContext,
|
{
|
||||||
},
|
role: AIMessageRole.User,
|
||||||
],
|
content: message,
|
||||||
output: { type: 'document' },
|
context: messageContext,
|
||||||
model: AIModel.ReasoningLow,
|
},
|
||||||
previousResponseId,
|
]
|
||||||
});
|
: [],
|
||||||
|
model: AIModel.ReasoningLow,
|
||||||
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
return await streamRenderAIMessage(context, rawStream, options);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const output of stream) {
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user