Compare commits

..

2 Commits

Author SHA1 Message Date
Zeno Kapitein 5d846548c8 Update AIChat.tsx 2025-08-22 19:08:59 +02:00
Zeno Kapitein 8e4c94a4e5 First test 2025-08-22 19:00:09 +02:00
542 changed files with 8061 additions and 28092 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": patch
---
Initial version of the embed SDK.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve `Button` and `ButtonGroup` styling
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix scroll of page outline
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support "objectivec" as alias for Objective C syntax
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": minor
"@gitbook/react-openapi": patch
---
Support code themes customization
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support custom AI providers
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/browser-types": minor
---
First version of the public package for typing script integrations.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix AI Search follow-up question closing search
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Set <html lang> based on translated language
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Small fixes to search modal
@@ -2,4 +2,4 @@
"gitbook": patch
---
Support social links
Fixes to `PageAside`
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/embed": patch
---
Fix signed JWT token usage
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Fix OpenAPI example not showing
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Align styling of PageLinkItem and PageDocumentItem
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/openapi-parser": patch
---
Update scalar dependencies.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Better conditional display of page actions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Tweaks to AIChatButton and AIChatInput
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Improve OpenAPI server URL validation
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow integrations to provide tools to the Docs Assistant
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve vertical alignment of site items and fix floating page aside
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search bar layout shift caused by ToC
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix ordered list item index calculation
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Standardise toggle chevrons across the app
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Emit a <link rel="alternate"> for the markdown version of a page
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support new coverDefinitionDark for cards & image type
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": minor
---
Update icons set to the latest version
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clear AI chat properly
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Grow InlineActionButton to take available width on line
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bug in search highlight in GBO
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Start routes for embeddable version of the assistant and docs pages.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/icons": patch
---
Simplify installation by using public fontawesome mirror.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Page outline: scroll to active item
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/openapi-parser": patch
"gitbook": patch
---
Improve OpenAPI parsing errors
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/browser-types": patch
"gitbook": patch
---
Custom assistants followup
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Custom logo not rendering on the published site
+4 -3
View File
@@ -54,9 +54,10 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally
##### Prerequisites:
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
- Node.js (Version: >=20.6)
- Use `nvm` for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15
##### Setup steps:
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,7 +71,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -70,7 +70,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
@@ -81,7 +81,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -104,7 +104,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+7 -3
View File
@@ -4,7 +4,8 @@ on:
push:
branches:
- main
- changeset-release/main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -34,10 +35,13 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build:
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build
name: Build (Open Source)
timeout-minutes: 6
env:
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+7 -23
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
@@ -180,9 +182,9 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
browserless-testing-v2-vercel:
pagespeed-testing-v2:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
name: PageSpeed Testing v1
needs: deploy-v2-vercel
steps:
- name: Checkout
@@ -193,26 +195,8 @@ jobs:
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run tests
run: cd ./packages/gitbook && bun e2e-browserless
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
# needs: deploy-v2-cloudflare
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# - name: Setup Bun
# uses: ./.github/composite/setup-bun
# - name: Install dependencies
# run: bun install --frozen-lockfile
# env:
# PUPPETEER_SKIP_DOWNLOAD: 1
# - name: Run tests
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
+9 -15
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -11,38 +14,29 @@ jobs:
publish:
name: Publish
runs-on: ubuntu-latest
permissions:
id-token: write
pull-requests: write
contents: write
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: bun run publish-all-packages
version: bun run changeset-version
publish: npm run release
version: npm run changeset-version
env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-4
View File
@@ -25,7 +25,3 @@ yarn-error.log*
# TypeScript
*.tsbuildinfo
.npmrc
# Bun pack artifacts
packages/*/*.tgz
+1 -1
View File
@@ -1 +1 @@
v22.3
v20.6
+18 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >= 22.3)
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15
@@ -77,7 +77,23 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 622 KiB

-1
View File
@@ -18,7 +18,6 @@
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
+1049 -1023
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
[install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+11 -29
View File
@@ -1,27 +1,22 @@
{
"name": "gitbook",
"version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.6",
"vercel": "^39.4.2"
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.3.7",
"packageManager": "bun@1.2.15",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"esbuild": "0.24.2"
},
"private": true,
"scripts": {
"dev": "turbo run dev --concurrency 20",
"dev": "turbo run dev",
"build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
@@ -31,29 +26,16 @@
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update",
"publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
"changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
},
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.159.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"assert-never": "^1.4.0",
"bidc": "^0.0.2",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
"@gitbook/api": "^0.136.0",
"bidc": "^0.0.2"
}
},
"patchedDependencies": {
-37
View File
@@ -1,37 +0,0 @@
# @gitbook/browser-types
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.1
### Patch Changes
- 295f03d: Republish packages
- Updated dependencies [295f03d]
- @gitbook/icons@0.3.2
## 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
+5 -17
View File
@@ -8,29 +8,17 @@
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.1.3",
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
},
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "README.md", "CHANGELOG.md"]
}
-1
View File
@@ -5,7 +5,6 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
-18
View File
@@ -1,23 +1,5 @@
# @gitbook/cache-tags
## 0.3.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.3.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.3.2
### Patch Changes
- 295f03d: Republish packages
## 0.3.1
### Patch Changes
+7 -17
View File
@@ -4,32 +4,22 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.3.4",
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "catalog:",
"assert-never": "catalog:"
"assert-never": "^1.2.1"
},
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"dev": "tsc -w"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
+14 -2
View File
@@ -121,6 +121,17 @@ export function getCacheTag(
}
}
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/**
* Get the tags for a computed content source.
*/
@@ -171,7 +182,7 @@ export function getComputedContentSourceCacheTags(
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lagging behind the API version
// as it might mean we are lacking behind the API version
break;
}
});
@@ -187,8 +198,9 @@ export function getComputedContentSourceCacheTags(
}
// We invalidate the computed content when a new version of the integration is deployed.
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1]!;
const integration = source.type.split(':')[1];
tags.push(
getCacheTag({
tag: 'integration',
-1
View File
@@ -5,7 +5,6 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
-28
View File
@@ -1,33 +1,5 @@
# @gitbook/colors
## 0.4.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.4.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.4.1
### Patch Changes
- 295f03d: Republish packages
## 0.4.0
### Minor Changes
- 17dd382: Add `original` background color step
### Patch Changes
- 193d591: Fix return type for `colorContrast`
## 0.3.3
### Patch Changes
+6 -16
View File
@@ -4,28 +4,18 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.4.3",
"version": "0.3.3",
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"dev": "tsc -w"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
+11 -16
View File
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
@@ -19,7 +19,7 @@ export enum ColorCategory {
}
type ColorSubScale = {
[key: string]: number | string;
[key: string]: number;
};
/**
@@ -56,8 +56,6 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
/** Original color */
original: 'original',
},
[ColorCategory.text]: {
/** Very low-contrast text
@@ -213,8 +211,8 @@ export function colorScale(
const result = [];
for (let index = 0; index < mapping.length; index++) {
const step = mapping[index]!;
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if (
index === 8 &&
@@ -297,7 +295,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index]! - channel));
return Math.round(channel + percentage * (start[index] - channel));
});
return rgbArrayToHex(rgb as RGBColor);
@@ -394,14 +392,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i]!;
const scaled = v / D65[i];
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
});
return {
L: 116 * y! - 16,
A: 500 * (x! - y!),
B: 200 * (y! - z!),
L: 116 * y - 16,
A: 500 * (x - y),
B: 200 * (y - z),
};
}
@@ -421,10 +419,7 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(
background: string,
foreground: string[] = [LIGHT_BASE, DARK_BASE]
): string {
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
@@ -441,5 +436,5 @@ export function colorContrast(
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
return best.color ? rgbArrayToHex(best.color) : foreground[0];
}
-1
View File
@@ -5,7 +5,6 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
-60
View File
@@ -1,60 +0,0 @@
# @gitbook/embed
## 0.2.0
### Minor Changes
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
### Patch Changes
- Updated dependencies [62438f4]
- @gitbook/icons@0.4.0
## 0.1.5
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.4
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.3
### Patch Changes
- 295f03d: Republish packages
- Updated dependencies [295f03d]
- @gitbook/icons@0.3.2
## 0.1.2
### Patch Changes
- 7508674: Fix bundling of packages
## 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
+8 -370
View File
@@ -1,61 +1,24 @@
# GitBook Docs Embed (`@gitbook/embed`)
# `@gitbook/embed`
Embed your GitBook docs in your product or website.
The Docs Embed can contain two tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **Docs**: A browser for navigating your documentation site
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
Embed the GitBook Docs Assistant in your product or website.
# Usage
## As a standalone script from your docs site
## As a script from your docs site
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
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 `docs.company.com` with your docs site hostname.
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>
// Initialize with Authenticated Access (optional)
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show');
</script>
```
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
### Example: Configuring the widget
```javascript
GitBook('configure', {
button: {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
```
See the [Configuration](#configuration) section for all available options.
## As a package from NPM
Install the package: `npm install @gitbook/embed` and import it in your web application:
@@ -67,46 +30,10 @@ const gitbook = createGitBook({
siteURL: 'https://docs.company.com'
});
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
userId: '123',
plan: 'premium'
}
}
});
iframe.src = gitbook.getFrameURL();
// Create a frame client to communicate with the iframe
const frame = gitbook.createFrame(iframe);
// Use the frame client methods
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
frame.navigateToAssistant(); // Switch to the assistant tab
frame.postUserMessage('How do I get started?');
frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
// Listen to events
frame.on('close', () => {
console.log('Frame closed');
});
```
## As React components
@@ -114,298 +41,9 @@ frame.on('close', () => {
After installing the NPM package, you can import prebuilt React components:
```tsx
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
]}
tools={[/* ... */]}
/>
<GitBookAssistantFrame />
</GitBookProvider>
```
You can also use the `useGitBook` hook to access the client:
```tsx
import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
# API Reference
## Method Comparison
| Method | Standalone Script | NPM Package | React Components |
|--------|------------------|-------------|------------------|
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
## Method Signatures
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
- `GitBook('close')` - Close widget window
- `GitBook('toggle')` - Toggle widget window
- `GitBook('navigateToPage', path: string)` - Navigate to page
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
- `GitBook('postUserMessage', message: string)` - Post message to chat
- `GitBook('clearChat')` - Clear chat history
- `GitBook('configure', settings: {...})` - Configure widget
- `GitBook('unload')` - Unload widget
### NPM Package
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
- `frame.navigateToPage(path: string)``void`
- `frame.navigateToAssistant()``void`
- `frame.postUserMessage(message: string)``void`
- `frame.clearChat()``void`
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)``void`
- `frame.on(event: string, listener: Function)``() => void` (unsubscribe)
### React Components
**Components:**
- `<GitBookProvider siteURL: string>` - Provider component
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
**Hooks:**
- `useGitBook()``GitBookClient` (must be used within `<GitBookProvider>`)
# Configuration
Configuration options are available across usage methods as follows:
- **Standalone script**: via `GitBook('configure', {...})`
- **NPM package**: via `frame.configure({...})`
- **React components**: via props on `<GitBookFrame>`
### `tabs`
Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `actions`
Available in: Standalone script, NPM package, React components
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
- **Type**: `GitBookEmbeddableActionDefinition[]`
- **Properties**:
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
- `label`: `string` - Button label text
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
```javascript
actions: [
{
icon: 'comments',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
},
{
icon: 'rocket',
label: 'Get started',
onClick: () => {
GitBook('navigateToPage', '/getting-started');
}
}
]
```
### `greeting`
Available in: Standalone script, NPM package, React components
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
- **Type**: `{ title: string, subtitle: string }`
```javascript
greeting: {
title: 'Welcome!',
subtitle: 'How can I help you today?'
}
```
### `suggestions`
Available in: Standalone script, NPM package, React components
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
- **Type**: `string[]`
```javascript
suggestions: [
'What is GitBook?',
'How do I get started?',
'What can you do?'
]
```
### `tools`
Available in: Standalone script, NPM package, React components
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
- **Type**: `GitBookToolDefinition[]`
- **Properties**:
- `name`: `string` - Unique tool identifier
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
```javascript
tools: [
{
name: 'get_user_info',
description: 'Get information about the current user',
inputSchema: {
type: 'object',
properties: {
userId: {
type: 'string',
description: 'The user ID to look up'
}
},
required: ['userId']
},
execute: async (input) => {
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
return {
output: { name: user.name, plan: user.plan },
summary: `Retrieved info for user ${user.name}`
};
}
},
{
name: 'create_ticket',
description: 'Create a support ticket',
confirmation: {
icon: 'circle-question',
label: 'Create support ticket?'
},
inputSchema: {
type: 'object',
properties: {
subject: { type: 'string' },
description: { type: 'string' }
},
required: ['subject', 'description']
},
execute: async (input) => {
const ticket = await fetch('/api/tickets', {
method: 'POST',
body: JSON.stringify(input)
}).then(r => r.json());
return {
output: { ticketId: ticket.id },
summary: `Created ticket #${ticket.id}`
};
}
}
]
```
### `visitor` (Authenticated Access)
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ visitor: {...} })`
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
- **Properties**:
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
```javascript
visitor: {
token: 'your-jwt-token',
unsignedClaims: {
userId: '123',
plan: 'premium',
role: 'admin'
}
}
```
### `button`
Available in: Standalone script only
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
- **Properties**:
- `label`: `string` - Button label text
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
```javascript
button: {
label: 'Ask',
icon: 'assistant'
}
```
+10 -29
View File
@@ -5,46 +5,27 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
"default": "./dist/index.js",
"standalone": "./dist/standalone/index.js",
"react": "./dist/react/index.js"
}
},
"sideEffects": false,
"version": "0.2.0",
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
"react": "*"
"react": "^18.0.0"
},
"devDependencies": {
"bun-types": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"react": "catalog:"
"typescript": "^5.5.3",
"react": "^19.0.0"
},
"scripts": {
"build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"typecheck": "tsc --noEmit"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
}
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { createGitBook } from './createGitBook';
describe('createGitBook.getFrameURL', () => {
it('builds the embed URL when the site URL has no trailing slash', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs' });
const url = new URL(client.getFrameURL({}));
expect(url.origin).toBe('https://example.com');
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.toString()).toBe('');
});
it('handles a trailing slash and adds visitor parameters', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
visitor: {
token: 'signed-token',
unsignedClaims: {
role: 'editor',
count: 3,
enabled: false,
},
},
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
expect(url.searchParams.get('visitor.role')).toBe('editor');
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
});
+2 -2
View File
@@ -40,10 +40,10 @@ 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`;
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
url.searchParams.set('token', frameOptions.visitor.token);
}
if (frameOptions.visitor?.unsignedClaims) {
+10 -16
View File
@@ -1,7 +1,8 @@
import { createChannel } from 'bidc';
import type {
FrameToParentMessage,
GitBookEmbeddableConfiguration,
GitBookPlaceholderSettings,
GitBookToolDefinition,
ParentToFrameMessage,
} from './protocol';
@@ -21,6 +22,11 @@ export type GitBookFrameClient = {
*/
postUserMessage: (message: string) => void;
/**
* Register a custom tool.
*/
registerTool: (tool: GitBookToolDefinition) => void;
/**
* Clear the chat.
*/
@@ -29,7 +35,7 @@ export type GitBookFrameClient = {
/**
* Set the placeholder settings.
*/
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
setPlaceholder: (placeholder: GitBookPlaceholderSettings) => void;
/**
* Register an event listener.
@@ -47,7 +53,6 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => {
console.log('[gitbook:embed] received message', message);
if (message.type === 'close') {
const listeners = events.get('close') || [];
if (listeners) {
@@ -57,20 +62,11 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
});
const sendToFrame = (message: ParentToFrameMessage) => {
console.log('[gitbook:embed] send message', message);
channel.send(message);
};
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
};
return {
navigateToPage: (pagePath) => {
sendToFrame({ type: 'navigateToPage', pagePath });
@@ -79,11 +75,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
sendToFrame({ type: 'navigateToAssistant' });
},
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
configure: (settings) => {
Object.assign(configuration, settings);
sendToFrame({ type: 'configure', settings: configuration });
},
registerTool: (tool) => sendToFrame({ type: 'registerTool', tool }),
clearChat: () => sendToFrame({ type: 'clearChat' }),
setPlaceholder: (settings) => sendToFrame({ type: 'setPlaceholder', settings }),
on: (event, listener) => {
const listeners = events.get(event) || [];
listeners.push(listener);
+11 -41
View File
@@ -21,52 +21,18 @@ export type GitBookToolDefinition = AIToolDefinition & {
};
/**
* Custom button definition to be passed to the embeddable GitBook.
* Placeholder settings.
*/
export type GitBookEmbeddableActionDefinition = {
export type GitBookPlaceholderSettings = {
/**
* Icon to be displayed in the button.
* Welcome message to be displayed in the placeholder.
*/
icon: IconName;
welcomeMessage: string;
/**
* Label to be displayed in the button.
* Suggestions to be displayed in the placeholder.
*/
label: string;
/**
* Callback when the button is clicked.
*/
onClick: () => void | Promise<void>;
};
/**
* Overall configuration for the layout of the GitBook embed.
*/
export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
/**
* Additional buttons to be displayed in the header of the GitBook embed.
* @deprecated Use `actions` instead.
*/
buttons?: GitBookEmbeddableActionDefinition[];
/** Message to be displayed in the welcome page. */
greeting: {
title: string;
subtitle: string;
};
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
};
/**
@@ -77,12 +43,16 @@ export type ParentToFrameMessage =
type: 'postUserMessage';
message: string;
}
| {
type: 'registerTool';
tool: GitBookToolDefinition;
}
| {
type: 'clearChat';
}
| {
type: 'configure';
settings: GitBookEmbeddableConfiguration;
type: 'setPlaceholder';
settings: GitBookPlaceholderSettings;
}
| {
type: 'navigateToPage';
-1
View File
@@ -1,2 +1 @@
export * from './client';
export * from './standalone';
@@ -0,0 +1,38 @@
import React from 'react';
import type { GetFrameURLOptions, GitBookFrameClient } from '../client';
import { useGitBook } from './GitBookProvider';
export type GitBookAssistantFrameProps = {
title?: string;
className?: string;
} & GetFrameURLOptions;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookAssistantFrame(props: GitBookAssistantFrameProps) {
const { title, className, ...frameOptions } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbookFrameRef = React.useRef<GitBookFrameClient | null>(null);
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL(frameOptions);
React.useEffect(() => {
if (frameRef.current) {
gitbookFrameRef.current = gitbook.createFrame(frameRef.current);
}
}, [gitbook]);
return (
<div className={className}>
<iframe
title={title ?? 'GitBook Assistant'}
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
/>
</div>
);
}
-54
View File
@@ -1,54 +0,0 @@
'use client';
import { useEffect, useMemo, useRef, useState } 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, actions, greeting, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => {
if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current));
}
}, [gitbook]);
useEffect(() => {
gitbookFrame?.configure({
tabs: ['assistant', 'docs'],
actions,
greeting,
suggestions,
tools,
});
}, [gitbookFrame, actions, greeting, suggestions, tools]);
return (
<iframe
title="GitBook"
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
className={className}
/>
);
}
-1
View File
@@ -1,2 +1 @@
export * from './GitBookProvider';
export * from './GitBookFrame';
+18 -57
View File
@@ -4,8 +4,9 @@ import {
type CreateGitBookOptions,
type GetFrameURLOptions,
type GitBookClient,
type GitBookEmbeddableConfiguration,
type GitBookFrameClient,
type GitBookPlaceholderSettings,
type GitBookToolDefinition,
createGitBook,
} from '../client';
@@ -28,53 +29,29 @@ type StandaloneCalls =
| ['toggle']
// Post a user message
| ['postUserMessage', string]
// Register a tool
| ['registerTool', GitBookToolDefinition]
// Clear the chat
| ['clearChat']
// Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
// Configure the placeholder
| ['setPlaceholder', GitBookPlaceholderSettings]
// Navigate to a page
| ['navigateToPage', string]
// Navigate to the assistant
| ['navigateToAssistant'];
type StandaloneConfiguration = {
/** Configure the button to open the embed */
button: {
/** Label to be displayed in the button. */
label: string;
/** Icon to be displayed in the button. */
icon: 'assistant' | 'sparkle' | 'help' | 'book';
};
};
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
q?: StandaloneCalls[];
};
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
icon: 'assistant',
},
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
};
const widgetButton = document.createElement('button');
widgetButton.id = 'gitbook-widget-button';
widgetButton.addEventListener('click', () => {
GitBook('toggle');
});
widgetButton.innerHTML = `
<span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span>
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
<span id="gitbook-widget-button-icon"></span>
<span id="gitbook-widget-button-label">Ask</span>
`;
const widgetWindow = document.createElement('div');
@@ -84,6 +61,11 @@ 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;
function getClient() {
if (!_client) {
throw new Error(
@@ -150,34 +132,15 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]);
break;
case 'configure': {
const settings = args[1];
frameConfiguration = {
...frameConfiguration,
...settings,
};
// Update the button label and icon
if (settings.button?.label) {
const label = widgetButton.querySelector('#gitbook-widget-button-label');
if (label) {
label.textContent = settings.button.label;
}
}
if (settings.button?.icon) {
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
if (icon) {
icon.setAttribute('data-icon', settings.button.icon);
}
}
getIframe().frame.configure({
...frameConfiguration,
});
case 'registerTool':
getIframe().frame.registerTool(args[1]);
break;
}
case 'clearChat':
getIframe().frame.clearChat();
break;
case 'setPlaceholder':
getIframe().frame.setPlaceholder(args[1]);
break;
case 'navigateToPage':
getIframe().frame.navigateToPage(args[1]);
break;
@@ -193,5 +156,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
+2 -26
View File
@@ -2,7 +2,6 @@
--gitbook-widget-top: 1rem;
--gitbook-widget-bottom: 1rem;
--gitbook-widget-right: 1rem;
--gitbook-widget-left: 1rem;
--gitbook-widget-button-height: 46px;
@@ -29,16 +28,6 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
* {
box-sizing: border-box;
}
@@ -113,18 +102,6 @@
background-color: currentColor;
}
#gitbook-widget-button-icon[data-icon="sparkle"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="help"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="book"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
}
#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');
}
@@ -140,10 +117,9 @@
bottom: var(--gitbook-widget-window-bottom);
right: var(--gitbook-widget-right);
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
width: var(--gitbook-widget-window-width);
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
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);
+1 -3
View File
@@ -15,9 +15,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: 'src/index.ts',
outDir: 'dist',
},
{
entry: 'src/react/index.ts',
outDir: 'dist/react',
unbundle: true,
},
]);
+2 -14
View File
@@ -1,25 +1,13 @@
# @gitbook/emoji-codepoints
## 0.2.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.2.1
### Patch Changes
- 295f03d: Republish packages
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
- 5f8a8fe: Initial release
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.2",
"version": "0.2.0",
"private": true,
"exports": "./dist/index.ts",
"sideEffects": false,
"dependencies": {},
"devDependencies": {
"emoji-assets": "^9.0.0"
"emoji-assets": "^8.0.0"
},
"scripts": {
"generate": "bun ./build.ts",
-1
View File
@@ -1 +0,0 @@
dist
-54
View File
@@ -1,54 +0,0 @@
# @gitbook/expr
## 1.2.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- 10995e0: Use NPM dependency for eval-estree-expression
## 1.2.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 1.2.2
### Patch Changes
- 295f03d: Republish packages
## 1.2.1
### Patch Changes
- a629900: Add dev script for @gitbook/expr
## 1.2.0
### Minor Changes
- 6da3655: Fix exports in gitbook/expr package.json
## 1.1.1
### Patch Changes
- 3548fa6: Fix eval-estree-expr named import
## 1.1.0
### Minor Changes
- e1ff17e: Fix bundling of gitbook/expr package
### Patch Changes
- 8ff1e3b: Add support for every/some array methods
## 1.0.0
### Major Changes
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/expr`
Safely evaluate & parse user-defined GitBook expressions.
-47
View File
@@ -1,47 +0,0 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.4",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"dependencies": {
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
"@tsconfig/node20": "catalog:",
"@types/escodegen": "^0.0.10",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"clean": "rm -rf ./dist",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
}
@@ -1,889 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionRuntime } from '../runtime';
import {
SymbolArray,
SymbolBoolean,
SymbolNumber,
SymbolObject,
SymbolString,
SymbolType,
SymbolsTable,
} from '../symbols';
import {
type AutocompleteSuggestions,
type AutocompleteSymbolSuggestion,
SUPPORTED_BINARY_OPERATORS,
SUPPORTED_CONDITIONAL_OPERATORS,
SUPPORTED_LOGICAL_OPERATORS,
} from '../types';
describe('autocomplete', () => {
const runtime = new ExpressionRuntime();
const visitorClaimsHelloArraySymbol = SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
});
const symbols = {
visitor: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: visitorClaimsHelloArraySymbol,
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
};
const context = new SymbolsTable(symbols);
const SCENARIOS: Array<{
expressionWithCursor: string;
expectedSuggestions: AutocompleteSuggestions;
}> = [
{
expressionWithCursor: 'visit<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
ref: 'visitor.claims',
parentRef: 'visitor',
childrenRefs: [
'visitor.claims.key',
'visitor.claims.flags',
'visitor.claims.hello',
'visitor.claims.role',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolString({ name: 'key' }),
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
ref: 'visitor.claims.role',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.role.length',
'visitor.claims.role.at',
'visitor.claims.role.endsWith',
'visitor.claims.role.includes',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.ke<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolString({ name: 'key' }),
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.h<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.hello.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolNumber({
name: 'length',
description: `The length data property of an Array instance represents the number of elements in that array.
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
}),
ref: 'visitor.claims.hello.length',
parentRef: 'visitor.claims.hello',
childrenRefs: [],
},
},
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
(method) => ({
type: 'symbol',
symbol: {
definition: method,
ref: `visitor.claims.hello.${method.name}`,
parentRef: 'visitor.claims.hello',
childrenRefs: [],
},
})
),
],
},
{
expressionWithCursor: 'visitor.claims.f<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.fl<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG1' }),
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG2' }),
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG3' }),
ref: 'visitor.claims.flags.FLAG3',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG4' }),
ref: 'visitor.claims.flags.FLAG4',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG1' }),
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG2' }),
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG3' }),
ref: 'visitor.claims.flags.FLAG3',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
{
type: 'symbol',
symbol: {
definition: SymbolBoolean({ name: 'FLAG4' }),
ref: 'visitor.claims.flags.FLAG4',
parentRef: 'visitor.claims.flags',
childrenRefs: [],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.key <cur>',
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
expectedSuggestions: [
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
),
...SUPPORTED_CONDITIONAL_OPERATORS,
].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
['==', '==='].includes(op.operator)
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
(op) => op.operator === '==='
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
['!=', '!=='].includes(op.operator)
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
(op) => op.operator === '!=='
).map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.role == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == ad<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == admin<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.role == edit<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == editor<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
},
],
},
{
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.hello == <cur>',
expectedSuggestions: [],
},
{
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: visitorClaimsHelloArraySymbol,
matchedLiteralString: '',
},
},
],
},
{
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
expectedSuggestions: [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: visitorClaimsHelloArraySymbol,
matchedLiteralString: '"test',
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
expectedSuggestions: [
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
...SUPPORTED_CONDITIONAL_OPERATORS,
].map((op) => ({
type: 'operator',
...op,
})),
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
role: SymbolString({
name: 'role',
enum: ['admin', 'editor', 'reader'],
}),
},
methods: [],
}),
},
methods: [],
}),
ref: 'visitor',
parentRef: undefined,
childrenRefs: ['visitor.claims'],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
},
methods: [],
}),
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
'visitor.claims.flags.FLAG4',
],
},
},
],
},
];
it.each(SCENARIOS)(
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
({ expressionWithCursor, expectedSuggestions }) => {
const { expression, cursorOffset } = extractCursorPosition(
expressionWithCursor,
'<cur>'
);
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
expect(suggestions).toStrictEqual(expectedSuggestions);
}
);
});
function extractCursorPosition(
expressionWithCursor: string,
cursorPlaceholder: string
): { expression: string; cursorOffset: number } {
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
if (cursorOffset === -1) {
throw new Error(
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
);
}
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
return { expression, cursorOffset };
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values';
describe('inferDefaultInputValuesFromObjectJSONSchema', () => {
it('should infer properly the default input value based on the JSON schema of an object', () => {
const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({
type: 'object',
properties: {
claims: {
type: 'object',
properties: {
key: {
type: 'string',
},
flags: {
type: 'object',
properties: {
FLAG1: { type: 'string' },
FLAG2: { type: 'string' },
FLAG3: { type: 'string' },
},
},
isAlphaUser: {
type: 'boolean',
},
hello: {
type: 'string',
enum: ['enumValue1', 'enumValue2', 'enumValue3'],
},
},
},
},
});
expect(defaultInputValues).toMatchObject({
claims: {
key: 'default',
flags: {
FLAG1: 'default',
FLAG2: 'default',
FLAG3: 'default',
},
isAlphaUser: true,
hello: 'enumValue1',
},
});
});
});
-178
View File
@@ -1,178 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionError } from '../errors';
import { ExpressionRuntime } from '../runtime';
import type { Logger } from '../types';
const SILENT_LOGGER: Logger = {
debug: () => {},
info: () => {},
error: () => {},
};
describe('ExpressionRuntime', () => {
const runtime = new ExpressionRuntime(SILENT_LOGGER);
describe('evaluate', () => {
it.each([
{
scenario: 'simple condition',
condition: 'isBetaUser === true',
inputs: { isBetaUser: false },
expectedResult: false,
},
{
scenario: 'simple condition with multiple inputs variables',
condition: 'useProductA && !isBetaUser',
inputs: {
useProductA: true,
isBetaUser: false,
},
expectedResult: true,
},
{
scenario: 'condition with objects in inputs variables',
condition: 'products.includes("productA") && userSegments.alpha',
inputs: {
products: ['productA', 'productB'],
userSegments: {
alpha: true,
beta: false,
},
},
expectedResult: true,
},
{
scenario: 'array method',
condition: 'reviews.every(review => !!review.status)',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array every',
condition: 'reviews.every(review => review.status === "approved")',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
}
);
const INVALID_EXPRESSSIONS = [
{
scenario: 'invalid syntax',
condition: 't}=d',
inputs: {},
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
inputs: {},
},
{
scenario: 'unsafe expression',
condition: 'while (1) {}',
inputs: {},
},
{
scenario: 'unsafe expression',
condition: '[1, 2, 3].map(() => { while (1) {}})',
inputs: {},
},
];
it.each(INVALID_EXPRESSSIONS)(
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
({ condition, inputs }) => {
const result = runtime.safeEvaluate(condition, inputs);
expect(result.value).toBeUndefined();
expect(result.error instanceof ExpressionError).toBe(true);
}
);
it.each(INVALID_EXPRESSSIONS)(
'should throw an error when using evaluate with invalid expressions',
({ condition, inputs }) => {
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
}
);
});
describe('parse', () => {
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
const ast = runtime.parse('isBetaUser === true');
expect(ast.result).toEqual({
type: 'BinaryExpression',
start: 0,
end: 19,
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
left: {
type: 'Identifier',
start: 0,
end: 10,
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
name: 'isBetaUser',
},
operator: '===',
right: {
type: 'Literal',
start: 15,
end: 19,
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
value: true,
raw: 'true',
},
});
});
it.each([
{
scenario: 'invalid syntax',
condition: 't}=d',
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
},
])(
'should throw an error for non conditional expressions or syntax errors: $scenario',
({ condition }) => {
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
}
);
});
describe.skip('generate', () => {
it.each([
{
scenario: 'simple condition',
condition: 'isBetaUser === true',
},
{
scenario: 'simple condition with multiple inputs variables',
condition: 'useProductA && !isBetaUser',
},
{
scenario: 'condition with objects in inputs variables',
condition: 'products.includes("productA") && userSegments.alpha',
},
])(
'should produce the original expression using an AST node produced by parse: $scenario',
({ condition }) => {
const { result } = runtime.parse(condition);
expect(runtime.generate(result)).toStrictEqual(condition);
}
);
});
});
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { ExpressionRuntime, parseTemplate } from '../';
describe('template expressions', () => {
it('should parse template into parts', () => {
const parts = parseTemplate('Hello {{ user.name }}!');
expect(parts).toEqual([
{ type: 'text', value: 'Hello ', start: 0, end: 6 },
{ type: 'expression', value: 'user.name', start: 8, end: 19 },
{ type: 'text', value: '!', start: 21, end: 22 },
]);
});
it('should parse template starting with an expression', () => {
const parts = parseTemplate('{{ user.name }} is cool');
expect(parts).toEqual([
{ type: 'expression', value: 'user.name', start: 2, end: 13 },
{ type: 'text', value: ' is cool', start: 15, end: 23 },
]);
});
it('should parse template without expressions', () => {
const parts = parseTemplate('Hello world');
expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]);
});
it('should evaluate template', () => {
const runtime = new ExpressionRuntime();
const result = runtime.evaluateTemplate('Hello {{ user.name }}!', {
user: { name: 'John' },
});
expect(result).toBe('Hello John!');
});
});
-568
View File
@@ -1,568 +0,0 @@
import type {
Node as AcornNode,
AnyNode,
BinaryExpression,
Expression,
Identifier,
Literal,
MemberExpression,
PrivateIdentifier,
Super,
} from 'acorn';
import { isDummy } from 'acorn-loose';
import * as walk from 'acorn-walk';
import assertNever from 'assert-never';
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
import {
type AutocompleteLiteralValueSuggestion,
type AutocompleteOperatorSuggestion,
type AutocompleteSuggestions,
type AutocompleteSymbolSuggestion,
type DirectLiteralValueSuggestion,
type ExpressionParserResult,
type Logger,
SUPPORTED_BINARY_OPERATORS,
SUPPORTED_CONDITIONAL_OPERATORS,
SUPPORTED_LOGICAL_OPERATORS,
} from './types';
interface ExpressionParser {
parse(expr: string, options: { loose?: boolean }): ExpressionParserResult;
}
export class AutoComplete {
#parser: ExpressionParser;
#logger: Logger;
constructor(parser: ExpressionParser, logger: Logger = console) {
this.#parser = parser;
this.#logger = logger;
}
/**
* Generates autocomplete suggestions based on the input expression and cursor offset position.
*/
public getSuggestions(
expr: string,
cursorOffset: number,
context: SymbolsTable
): AutocompleteSuggestions {
if (!expr.length) {
return [];
}
try {
const { result, invalidNodes } = this.#parser.parse(expr, { loose: true });
// Locate the node at the cursor position.
const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) =>
isNodeAtCursor(node, cursorOffset)
);
if (!nodeAtCursorFound) {
// When we can't find one we might be in the boundary of the program/expression.
// We could possibly be in a whitespace at the end of the expression or in a situation where the parsed
// tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser).
//
// In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either:
// - using the end position of the whole expression AST (e.g white space at the very end of the expression string)
// - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node.
if (cursorOffset > result.end) {
const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result;
if (!ast) {
return [];
}
const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) =>
isNodeAtCursor(node, ast.end)
);
if (!lastNodeFound) {
return [];
}
const { node } = lastNodeFound;
if (!isAnyNode(node)) {
throw Error(`Unexpected node type ${node.type}`);
}
return this.getOperatorSuggestionsForNode(ast, node, result.end, context);
}
return [];
}
const { node } = nodeAtCursorFound;
if (!isAnyNode(node)) {
throw Error(`Unexpected node type ${node.type}`);
}
return this.getSuggestionsForNode(result, node, expr, cursorOffset, context);
} catch (error) {
this.#logger.error('Error while computing autocomplete suggestions', error);
return [];
}
}
/**
* Provides autocomplete suggestions for a specific node in the AST.
*/
private getSuggestionsForNode(
ast: Expression,
node: AnyNode,
expr: string,
cursorOffset: number,
context: SymbolsTable
): AutocompleteSuggestions {
// When the node is an identifier look up the parent to get more context for the suggestions.
let inferNode: AnyNode = node;
if (node.type === 'Identifier' || node.type === 'Literal') {
const parent = findParentNode(node, ast);
inferNode =
parent &&
!['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes(
parent.type
)
? parent
: node;
}
switch (inferNode.type) {
case 'Identifier':
case 'MemberExpression': {
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
inferNode,
cursorOffset
);
// Fetch suggestions from the symbol table
const candidatesKeys = context.getMatchingSymbolsKeys(pathParts);
const suggestions: AutocompleteSymbolSuggestion[] = [];
for (const candidate of candidatesKeys) {
const symbolInfo = context.getSymbolInfo(candidate);
if (symbolInfo) {
suggestions.push({ type: 'symbol', symbol: symbolInfo });
}
}
if (suggestions.length === 1) {
const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, '');
// Return no suggestion when the only match is an exact match of the
// typed token.
return lastPathSegment !== suggestions[0]?.symbol.definition.name
? suggestions
: [];
}
return suggestions;
}
case 'AssignmentExpression':
case 'UnaryExpression': {
// Provide suggestions for binary or logical operators based on the parsed operator
// of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)).
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
}
case 'BinaryExpression': {
const { left, right } = inferNode;
const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some(
(op) => op.operator === inferNode.operator
);
const operatorIndex = expr.indexOf(inferNode.operator, left.end);
const operatorOffset = operatorIndex + inferNode.operator.length;
const isCursorAfterOperator = cursorOffset > operatorOffset;
const shouldSuggestValues =
isCursorAfterOperator &&
isOperatorBinaryOp &&
isNodeAtCursor(right, cursorOffset);
if (shouldSuggestValues) {
return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context);
}
// Provide suggestions for binary operators based on the parsed operator
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
}
case 'ConditionalExpression': {
const { consequent, alternate } = inferNode;
if (isNodeAtCursor(consequent, cursorOffset)) {
return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context);
}
if (isNodeAtCursor(alternate, cursorOffset)) {
return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context);
}
}
}
return [];
}
/**
* Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table.
*/
private getSymbolsPathPartsForMemberExpressionNode(
node: MemberExpression | Identifier,
cursorOffset: number,
options?: { withWildcardMatches: boolean }
): string[] {
const withWildcardMatches = options?.withWildcardMatches ?? true;
const pathParts: string[] = [];
switch (node.type) {
case 'MemberExpression':
{
const memberProperty = node.property;
// Only support identifier or literal expressions as member properties.
if (!isSupportedMemberProperty(memberProperty)) {
return [];
}
// Push the path part corresponding to the member property (e.g b in a.b or a['b'])
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
memberProperty,
cursorOffset
);
if (propertyPathPart) {
pathParts.push(
withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart
);
}
// Go through the parent(s) in the chain and add their path parts as well
let parent: Expression | Super | undefined = node.object;
while (parent) {
const parentProperty = 'property' in parent ? parent.property : parent;
// Only support identifier or literal expressions as parent property.
const parentPropertyPathPart = isSupportedMemberProperty(parentProperty)
? this.getSymbolsPathPartFromMemberProperty(
parentProperty,
cursorOffset
)
: undefined;
if (parentPropertyPathPart) {
pathParts.unshift(parentPropertyPathPart);
}
parent = 'object' in parent ? parent.object : undefined;
}
}
break;
case 'Identifier': {
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
node,
cursorOffset
);
if (propertyPathPart) {
pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart);
}
break;
}
default:
assertNever(node);
}
return pathParts;
}
/**
* Return a part of a symbol path corresponding to a MemberExpression node property.
*/
private getSymbolsPathPartFromMemberProperty(
node: Identifier | Literal,
cursorOffset: number
): string {
switch (node.type) {
case 'Identifier': {
if (isDummy(node)) {
return '*';
}
return isNodeAtCursor(node, cursorOffset)
? node.name.slice(0, cursorOffset)
: node.name;
}
case 'Literal': {
if (isDummy(node) || !node.value) {
return '*';
}
const value = String(node.value);
return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value;
}
default:
assertNever(node);
}
}
/**
* Provides autocomplete literal value suggestions for a specific node in the AST.
*/
private getLiteralValueSuggestionsForNode(
node: BinaryExpression,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteLiteralValueSuggestion> {
const { left, right } = node;
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
return [];
}
if (right.type !== 'Identifier' && right.type !== 'Literal') {
return [];
}
const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, {
withWildcardMatches: false,
});
const isLeftComputedMember = left.type === 'MemberExpression' && left.computed;
const leftSymbolInfo = context.getSymbolInfo(
isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath
);
if (!leftSymbolInfo) {
return [];
}
switch (leftSymbolInfo.definition.type) {
case SymbolType.Boolean: {
const suggestions: DirectLiteralValueSuggestion[] = [
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
},
{
type: 'literal-value',
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
},
];
return isDummy(right)
? suggestions
: suggestions.filter((literalValue) => {
const literalValueString = String(literalValue.value.data);
const rightNodeValue =
right.type === 'Identifier' ? right.name : (right.raw ?? '');
return (
literalValueString !== rightNodeValue &&
literalValueString.startsWith(rightNodeValue)
);
});
}
case SymbolType.String: {
if (!leftSymbolInfo.definition.enum) {
return [];
}
const rightNodeValue = (() => {
if (right.type === 'Identifier') {
return right.name;
}
const nodeValue = right.raw ?? '';
if (/^(['"])(.*)\1$/.test(nodeValue)) {
return null;
}
return nodeValue.replaceAll(/["']/g, '');
})();
if (rightNodeValue === null) {
return [];
}
const suggestions = isDummy(right)
? leftSymbolInfo.definition.enum
: leftSymbolInfo.definition.enum.filter((enumValue) =>
enumValue.startsWith(rightNodeValue)
);
return suggestions.map((value) => ({
type: 'literal-value',
value: {
kind: 'direct',
type: SymbolType.String,
data: value,
},
}));
}
case SymbolType.Array: {
// Only return a literal in array value suggestion when the left hand side of the binary expression
// is computed, e.g myArray[1]
return isLeftComputedMember
? [
{
type: 'literal-value',
value: {
kind: 'in-array',
srcSymbol: leftSymbolInfo.definition,
matchedLiteralString: !isDummy(right)
? right.type === 'Identifier'
? right.name
: (right.raw ?? '')
: '',
},
},
]
: [];
}
default:
return [];
}
}
/**
* Provides autocomplete operator suggestions for a specific node in the AST.
*/
private getOperatorSuggestionsForNode(
ast: Expression,
node: AnyNode,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteOperatorSuggestion> {
if (node.type === 'Literal') {
const parent = findParentNode(node, ast);
if (parent?.type === 'BinaryExpression') {
return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map(
(op) => ({
type: 'operator',
...op,
})
);
}
const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw);
return this.getOperatorSuggestionsForSymbol(literalSymbol);
}
// When the node is an identifier look the parent to get more context for the suggestions
let inferNode: AnyNode = node;
if (node.type === 'Identifier') {
const parent = findParentNode(node, ast);
inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node;
}
switch (inferNode.type) {
case 'MemberExpression': {
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
inferNode,
cursorOffset,
{
withWildcardMatches: false,
}
);
const symbolInfo = context.getSymbolInfo(pathParts);
return symbolInfo
? this.getOperatorSuggestionsForSymbol(symbolInfo.definition)
: [];
}
case 'AssignmentExpression':
case 'BinaryExpression':
case 'UnaryExpression': {
// Starting to write a binary/logical operator so suggest operator matching the already
// typed character as operator.
const operator = inferNode.operator;
return (
[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS]
.filter((op) => op.operator.startsWith(operator))
// No need to include the operator that match exactly
.filter((op) => op.operator !== operator)
.map((op) => ({
type: 'operator',
...op,
}))
);
}
default:
return [];
}
}
/**
* Provides autocomplete operator suggestions based on the type of a symbol.
*/
private getOperatorSuggestionsForSymbol(
symbol: ExtractSymbolDef<SymbolType>
): Array<AutocompleteOperatorSuggestion> {
switch (symbol.type) {
case SymbolType.Number:
case SymbolType.Boolean:
case SymbolType.Null:
case SymbolType.Undefined:
case SymbolType.Object:
case SymbolType.Array: {
const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4);
const finalSuggestions =
symbol.type === SymbolType.Boolean
? [
...equalityOps,
...SUPPORTED_LOGICAL_OPERATORS,
...SUPPORTED_CONDITIONAL_OPERATORS,
]
: equalityOps;
return finalSuggestions.map((op) => ({
type: 'operator',
...op,
}));
}
case SymbolType.String:
return SUPPORTED_BINARY_OPERATORS.map((op) => ({
type: 'operator',
...op,
}));
case SymbolType.Function:
return this.getOperatorSuggestionsForSymbol(symbol.returns);
case SymbolType.Union: {
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
return prev;
}, []);
}
default:
assertNever(symbol);
}
}
}
/**
* Finds the parent of child node in the provided AST.
*/
function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined {
let foundParent: AnyNode | undefined;
walk.ancestor(ast, {
[child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => {
if (node.start === child.start && node.end === child.end) {
// The parent is the second last ancestor in the stack (last one is the actual node)
foundParent = ancestors[ancestors.length - 2];
}
},
});
return foundParent;
}
function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) {
return node.type === 'Identifier' || node.type === 'Literal';
}
function isNodeAtCursor(node: AcornNode, cursorOffset: number) {
return cursorOffset >= node.start && cursorOffset <= node.end;
}
function isAnyNode(node: AcornNode): node is AnyNode {
return 'type' in node;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Position, Token } from 'acorn';
export class ExpressionError extends Error {
/**
* The location of the error in the parsed expression.
*/
public location?: Position | null;
/**
* The expression token with the error
*/
public token?: Token;
constructor(message: string, loc?: Position | null, token?: Token) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ExpressionError);
}
this.name = 'ExpressionError';
this.location = loc;
this.token = token;
}
}
-7
View File
@@ -1,7 +0,0 @@
export * from './errors';
export * from './input-values';
export * from './runtime';
export * from './symbols';
export * from './template';
export * from './types';
export * from './utils';
-77
View File
@@ -1,77 +0,0 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from './utils';
type InputValuesType =
| null
| string
| number
| boolean
| { [key: string]: InputValuesType }
| InputValuesType[];
/**
* Infers a default inputValues object based on the JSON schema of an object.
*/
export function inferDefaultInputValuesFromObjectJSONSchema(
schema: JSONSchema7
): Record<string, InputValuesType> {
if (schema.type !== 'object' || !schema.properties) {
throw new Error(`Expected schema of object to be provided: ${schema.type}`);
}
const result: Record<string, InputValuesType> = {};
for (const [key, propertySchema] of Object.entries(schema.properties)) {
if (typeof propertySchema === 'boolean') {
continue;
}
result[key] = inferDefaultInputValueFromJSONSchema(propertySchema);
}
return result;
}
function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType {
switch (schema.type) {
case 'object':
return inferDefaultInputValuesFromObjectJSONSchema(schema);
case 'array': {
if (schema.items && Array.isArray(schema.items)) {
return schema.items.map((itemSchema) => {
if (typeof itemSchema === 'boolean') {
return false;
}
return inferDefaultInputValueFromJSONSchema(itemSchema);
});
}
return [];
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
return inferDefaultInputValueFromPrimitive(schema);
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType {
switch (schema.type) {
case 'boolean':
return true;
case 'number':
case 'integer':
return 1234;
case 'string': {
const enumValues = schema.enum?.filter(filterOutNullable);
return enumValues?.[0] ?? 'default';
}
case 'null':
return null;
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
-289
View File
@@ -1,289 +0,0 @@
import {
type Options as AcornOptions,
type Expression,
type ExpressionStatement,
type Position,
type Program,
type Token,
parse,
tokenizer,
} from 'acorn';
import { parse as parseLoose } from 'acorn-loose';
import escodegen from 'escodegen';
import 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);
}
@@ -1,497 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
import { SymbolsTable } from '../symbols-table';
import type { SymbolType } from '../types';
describe('ExpressionRuntime', () => {
const initialSymbols = {
visitor: SymbolObject({
name: 'visitor',
properties: {
claims: SymbolObject({
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
FLAG1: SymbolString({ name: 'FLAG1' }),
FLAG2: SymbolString({ name: 'FLAG2' }),
FLAG3: SymbolString({ name: 'FLAG3' }),
},
methods: [],
}),
hello: SymbolArray({
name: 'hello',
description: 'An array of string',
items: SymbolString(),
}),
},
methods: [],
}),
},
methods: [],
}),
};
describe('addSymbols', () => {
it('should the symbols matching the provided object to the table', () => {
const symbolsTable = new SymbolsTable(initialSymbols);
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
definition: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
ref: 'visitor',
childrenRefs: ['visitor.claims'],
});
symbolsTable.addSymbols({
space: SymbolObject({
name: 'space',
properties: {
id: SymbolString({ name: 'id' }),
title: SymbolString({ name: 'title' }),
},
methods: [],
}),
});
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
definition: {
type: 'object',
name: 'space',
properties: {
id: {
type: 'string',
name: 'id',
},
title: {
type: 'string',
name: 'title',
},
},
methods: [],
},
ref: 'space',
childrenRefs: ['space.id', 'space.title'],
});
});
});
describe('Symbols standard library', () => {
it('should allow to access methods & properties defined as part of the standard library', () => {
const symbolsTable = new SymbolsTable({
id: SymbolString({ name: 'id' }),
title: SymbolString({ name: 'title' }),
});
expect(
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
).toMatchObject({
type: 'number',
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
});
});
});
describe('getSymbolInfo', () => {
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
const symbolsTable = new SymbolsTable(initialSymbols);
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
definition: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
ref: 'visitor',
childrenRefs: ['visitor.claims'],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
definition: {
type: 'object',
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
},
methods: [],
},
ref: 'visitor.claims',
parentRef: 'visitor',
childrenRefs: [
'visitor.claims.key',
'visitor.claims.flags',
'visitor.claims.hello',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
definition: {
type: 'string',
name: 'key',
},
ref: 'visitor.claims.key',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
definition: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
ref: 'visitor.claims.flags',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.flags.FLAG1',
'visitor.claims.flags.FLAG2',
'visitor.claims.flags.FLAG3',
],
});
expect(
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
).toMatchObject({
definition: {
type: 'string',
name: 'FLAG1',
},
ref: 'visitor.claims.flags.FLAG1',
parentRef: 'visitor.claims.flags',
childrenRefs: [
'visitor.claims.flags.FLAG1.length',
'visitor.claims.flags.FLAG1.at',
'visitor.claims.flags.FLAG1.endsWith',
'visitor.claims.flags.FLAG1.includes',
],
});
expect(
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
).toMatchObject({
definition: {
type: 'string',
name: 'FLAG2',
},
ref: 'visitor.claims.flags.FLAG2',
parentRef: 'visitor.claims.flags',
childrenRefs: [
'visitor.claims.flags.FLAG2.length',
'visitor.claims.flags.FLAG2.at',
'visitor.claims.flags.FLAG2.endsWith',
'visitor.claims.flags.FLAG2.includes',
],
});
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
definition: {
type: 'array',
name: 'hello',
description: 'An array of string',
items: {
type: 'string',
},
},
ref: 'visitor.claims.hello',
parentRef: 'visitor.claims',
childrenRefs: [
'visitor.claims.hello.length',
'visitor.claims.hello.at',
'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
],
});
});
});
describe('inferSymbolFromValue', () => {
it('should infer properly a symbol based on a value', () => {
const symbolDef = SymbolsTable.inferSymbolFromValue(
{
visitor: {
claims: {
key: 'test',
flags: {
FLAG1: 'testflag1',
FLAG2: 'testflag2',
FLAG3: 'testflag3',
},
hello: ['test', 'test1', 'test2'],
},
},
},
'context'
);
expect(symbolDef).toMatchObject({
type: 'object',
name: 'context',
properties: {
visitor: {
type: 'object',
name: 'visitor',
properties: {
claims: {
type: 'object',
name: 'claims',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'array',
name: 'hello',
items: {
type: 'string',
},
},
},
methods: [],
},
},
methods: [],
},
},
methods: [],
});
});
});
describe('inferSymbolFromJSONSchema', () => {
it('should infer properly a symbol table based on a JSON schema', () => {
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
{
type: 'object',
description: `The attributes tied to a site's visitor.`,
properties: {
claims: {
type: 'object',
properties: {
key: {
type: 'string',
},
flags: {
type: 'object',
description: 'The user feature flags',
properties: {
FLAG1: { type: 'string' },
FLAG2: { type: 'string' },
FLAG3: { type: 'string' },
},
},
hello: {
type: 'string',
enum: ['test', 'test1', 'test2'],
},
},
},
},
},
'visitor'
);
expect(symbolDef).toMatchObject({
type: 'object',
name: 'visitor',
description: `The attributes tied to a site's visitor.`,
properties: {
claims: {
type: 'object',
name: 'claims',
properties: {
key: {
type: 'string',
name: 'key',
},
flags: {
type: 'object',
name: 'flags',
description: 'The user feature flags',
properties: {
FLAG1: {
type: 'string',
name: 'FLAG1',
},
FLAG2: {
type: 'string',
name: 'FLAG2',
},
FLAG3: {
type: 'string',
name: 'FLAG3',
},
},
methods: [],
},
hello: {
type: 'string',
enum: ['test', 'test1', 'test2'],
},
},
methods: [],
},
},
methods: [],
});
});
});
});
-3
View File
@@ -1,3 +0,0 @@
export * from './symbols';
export * from './symbols-table';
export * from './types';
-350
View File
@@ -1,350 +0,0 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from '../utils';
import {
SymbolArray,
SymbolBoolean,
SymbolNull,
SymbolNumber,
SymbolObject,
SymbolString,
SymbolUndefined,
} from './symbols';
import {
type ExtractSymbolDef,
type GenericSymbolDef,
type ObjectSymbolDef,
SymbolType,
type SymbolWithMethods,
type SymbolWithProperties,
resolveSymbolDef,
} from './types';
export interface SymbolInfo<T extends SymbolType = SymbolType> {
/**
* Definition of the symbol.
*/
definition: ExtractSymbolDef<T>;
/**
* Reference of the symbol in the table of symbols.
*/
ref: string;
/**
* Stores the reference to the parent symbol.
*/
parentRef?: string;
/**
* Stores the reference to the children symbols.
*/
childrenRefs?: string[];
}
export class SymbolError extends Error {
constructor(message: string) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SymbolError);
}
this.name = 'SymbolError';
}
}
export class SymbolsTable {
/**
* Internal table that keeps track of all symbols reference.
*/
#table: Record<string, SymbolInfo>;
/**
* Internal table that keep track of the raw symbols definitions.
*/
#rawSymbols: Record<string, GenericSymbolDef>;
constructor(initialContext: Record<string, GenericSymbolDef> = {}) {
this.#table = {};
this.#rawSymbols = {};
this.addSymbols(initialContext);
}
/**
* Create a new symbols table by merging the current one with the provided one.
*/
merge(other: SymbolsTable): SymbolsTable {
return new SymbolsTable({
...this.#rawSymbols,
...other.#rawSymbols,
});
}
toString() {
return JSON.stringify(this.#table, null, 2);
}
/**
* Infer the symbol of a value and generate the appropriate symbol definition.
*/
static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef<SymbolType> {
if (Array.isArray(value)) {
if (value.length === 0) {
return SymbolArray({ items: SymbolUndefined() });
}
const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0));
// Check that the array is not a mixin of different items types.
if (value.length > 1) {
const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1));
if (firstItemSymbol.type !== secondItemSymbol.type) {
throw new SymbolError('Array with mixin items types are not supported');
}
}
return SymbolArray({ name, items: firstItemSymbol });
}
if (typeof value === 'undefined') {
return SymbolUndefined({ name });
}
if (value === null) {
return SymbolNull({ name });
}
const valueType = typeof value;
switch (valueType) {
case 'string':
return SymbolString({ name });
case 'number':
return SymbolNumber({ name });
case 'boolean':
return SymbolBoolean({ name });
case 'object': {
const properties = Object.entries(value).reduce<Record<string, GenericSymbolDef>>(
(prev, [name, val]) => {
prev[name] = SymbolsTable.inferSymbolFromValue(val, name);
return prev;
},
{}
);
return SymbolObject({ name, properties, methods: [] });
}
default:
throw new SymbolError(`Unsupported symbol type ${valueType}`);
}
}
/**
* Infer a table of symbol based on a JSON schema object describing it.
*/
static inferSymbolFromJSONSchema(
schema: JSONSchema7,
name?: string
): ExtractSymbolDef<SymbolType> {
switch (schema.type) {
case 'string':
return SymbolString({
name,
...(schema.description ? { description: schema.description } : {}),
...(schema.enum
? {
enum: schema.enum
.filter(filterOutNullable)
.map((enumValue) => enumValue.toString()),
}
: {}),
});
case 'number':
case 'integer':
return SymbolNumber({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'boolean':
return SymbolBoolean({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'null':
return SymbolNull({
name,
...(schema.description ? { description: schema.description } : {}),
});
case 'object':
return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name);
case 'array':
return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name);
default:
throw new Error(`Unsupported schema type: ${schema.type}`);
}
}
static #buildObjectSymbolFromJSONSchemaObject(
schema: JSONSchema7,
name?: string
): ObjectSymbolDef {
const properties: Record<string, GenericSymbolDef> = {};
if (schema.properties) {
Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema(
propertySchema as JSONSchema7,
propertyName
);
});
}
return SymbolObject({
name,
properties,
...(schema.description ? { description: schema.description } : {}),
methods: [],
});
}
static #buildArraySymbolFromJSONSchemaArray(
schema: JSONSchema7,
name?: string
): ExtractSymbolDef<SymbolType.Array> {
if (schema.items) {
const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema(
schema.items as JSONSchema7,
`${name || ''}_item`
);
return SymbolArray({
name,
...(schema.description ? { description: schema.description } : {}),
items: itemSymbol,
});
}
return SymbolArray({
name,
...(schema.description ? { description: schema.description } : {}),
items: SymbolUndefined(),
});
}
private generateSymbolRefPath(path: string[]): string {
return path.join('.');
}
/**
* Add a single symbol to the table at the provided path.
*/
private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void {
const fullPath = this.generateSymbolRefPath(path);
const parentPath = path.slice(0, -1).join('.');
if (this.#table[fullPath]) {
throw new SymbolError(`Symbol "${fullPath}" already exists.`);
}
if (raw) {
this.#rawSymbols[fullPath] = definition;
}
// Add the new symbol linking it to its parent
this.#table[fullPath] = {
definition: resolveSymbolDef(definition),
ref: fullPath,
parentRef: parentPath || undefined,
childrenRefs: [],
};
if (parentPath && this.#table[parentPath]) {
if (this.#table[parentPath].childrenRefs) {
this.#table[parentPath].childrenRefs.push(fullPath);
}
}
// Add any nested symbols if the value is a symbol with properties...
if (isSymbolWithProperties(definition)) {
Object.entries(definition.properties).forEach(([propKey, propSymbol]) => {
if (isObjectSymbol(propSymbol)) {
this.addSymbols({ [propKey]: propSymbol }, path, false);
} else {
this.addSymbol([...path, propKey], propSymbol, false);
}
});
}
// ...or a symbol with methods
if (isSymbolWithMethods(definition)) {
definition.methods.forEach((methodSymbol) => {
this.addSymbol([...path, methodSymbol.name], methodSymbol, false);
});
}
}
/**
* Add the provided object of symbols definitions to the symbol table.
*/
public addSymbols(
symbols: Record<string, GenericSymbolDef>,
prefix: string[] = [],
raw = true
): void {
for (const [key, symbolDef] of Object.entries(symbols)) {
const path = [...prefix, key];
// Add the current symbol to the table.
this.addSymbol(path, symbolDef, raw);
}
}
/**
* Get a symbol's information using its path in the table.
*/
public getSymbolInfo<T extends SymbolType>(path: string | string[]): SymbolInfo<T> | undefined {
const key = Array.isArray(path) ? path.join('.') : path;
const info = this.#table[key];
return info ? typedSymbolInfo(info) : undefined;
}
/**
* Get all symbol keys matching the pattern defined by the provided path.
*/
public getMatchingSymbolsKeys(path: string[]): string[] {
const wildcardRegex = new RegExp(
`^${path
.map((segment) => {
if (segment.includes('*')) {
return `${segment.split('*')[0]}([^.]+)?`;
}
return segment;
})
.join('\\.')}$`
);
return Object.keys(this.#table)
.filter((key) => wildcardRegex.test(key))
.filter(filterOutNullable);
}
}
function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef {
return symbol.type === SymbolType.Object;
}
function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties {
return (
symbol.type === SymbolType.Object ||
symbol.type === SymbolType.Array ||
symbol.type === SymbolType.String
);
}
function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods {
return (
symbol.type === SymbolType.Object ||
symbol.type === SymbolType.Array ||
symbol.type === SymbolType.String
);
}
function typedSymbolInfo<T extends SymbolType>(info: SymbolInfo): SymbolInfo<T> {
const definition = resolveSymbolDef(info.definition);
return { ...info, definition } as SymbolInfo<T>;
}
-322
View File
@@ -1,322 +0,0 @@
import {
type ArraySymbolDef,
type BooleanSymbolDef,
type ExtractSymbolDef,
type FunctionSymbolDef,
type GenericSymbolDef,
type NullSymbolDef,
type NumberSymbolDef,
type ObjectSymbolDef,
type StringSymbolDef,
SymbolType,
type SymbolsWithPropertiesAndMethods,
type UndefinedSymbolDef,
type UnionSymbolDef,
} from './types';
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
return {
type: SymbolType.Boolean,
...args,
};
}
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
return {
type: SymbolType.Number,
...args,
};
}
export function SymbolString(
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
): StringSymbolDef {
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
}
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
return {
type: SymbolType.Object,
...args,
};
}
export function SymbolArray(
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
): ArraySymbolDef {
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
}
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
return {
type: SymbolType.Function,
...args,
};
}
export function OptionalFunctionArg(
optionalArg: ExtractSymbolDef<SymbolType>
): ExtractSymbolDef<SymbolType> & { optional: true } {
return {
...optionalArg,
optional: true,
};
}
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
return {
type: SymbolType.Union,
...args,
};
}
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
return {
type: SymbolType.Undefined,
...args,
};
}
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
return {
type: SymbolType.Null,
...args,
};
}
function createSymbolWithPropertiesAndMethods<
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
const symbol = { type, ...args } as T;
Object.defineProperty(symbol, 'properties', {
get() {
if (symbol.type === SymbolType.Array) {
return isArraySymbol(symbol)
? StandardLibrary[SymbolType.Array]?.(symbol).properties
: {};
}
return StandardLibrary[symbol.type]?.properties || {};
},
});
Object.defineProperty(symbol, 'methods', {
get() {
if (symbol.type === SymbolType.Array) {
return isArraySymbol(symbol)
? StandardLibrary[SymbolType.Array]?.(symbol).methods
: [];
}
return StandardLibrary[symbol.type]?.methods || [];
},
});
return symbol;
}
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
const StandardLibrary: Partial<
{
[key in Exclude<SymbolType, SymbolType.Array>]: {
properties: Record<string, GenericSymbolDef>;
methods: FunctionSymbolDef[];
};
} & {
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
properties: Record<string, GenericSymbolDef>;
methods: FunctionSymbolDef[];
};
}
> = {
[SymbolType.String]: {
properties: {
length: SymbolNumber({
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
}),
},
methods: [
SymbolFunction({
name: 'at',
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
Negative integers count back from the last item in the string.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
args: [
SymbolNumber({
name: 'index',
description: 'The index (position) of the string character to be returned',
}),
],
returns: SymbolUnion({
description: `A String consisting of the single UTF-16 code unit located at the specified position.
Returns undefined if the given index can not be found.`,
members: [SymbolString(), SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'endsWith',
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
elements of this object (converted to a String) starting at endPosition length(this). Otherwise returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
args: [
SymbolString({
name: 'searchString',
description: `The characters to be searched for at the end of str. Cannot be a regex.
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'endPosition',
description: `The end position at which searchString is expected to be found
(the index of searchString's last character plus 1). Defaults to str.length.`,
})
),
],
returns: SymbolBoolean({
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'includes',
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
that are greater than or equal to position; otherwise, returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
args: [
SymbolString({
name: 'searchString',
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'position',
description:
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
})
),
],
returns: SymbolBoolean({
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
],
},
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
properties: {
length: SymbolNumber({
name: 'length',
description: `The length data property of an Array instance represents the number of elements in that array.
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
}),
},
methods: [
SymbolFunction({
name: 'at',
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
Negative integers count back from the last item in the array.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
args: [
SymbolNumber({
name: 'index',
description: `Zero-based index of the array element to be returned, converted to an integer.
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
}),
],
returns: SymbolUnion({
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
without attempting to access the corresponding property.`,
members: [arraySymbolDef.items, SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'includes',
description:
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
args: [
{
...arraySymbolDef.items,
name: 'searchElement',
description: 'The value to be searched for within the array.',
},
OptionalFunctionArg(
SymbolNumber({
name: 'fromIndex',
description:
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
})
),
],
returns: SymbolBoolean({
description:
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
}),
}),
SymbolFunction({
name: 'some',
description:
'Tests whether at least one element in the array passes the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
SymbolFunction({
name: 'every',
description:
'Tests whether all elements in the array pass the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
],
}),
};
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
return symbol.type === SymbolType.Array;
}
-193
View File
@@ -1,193 +0,0 @@
import type { MandateProps } from '../utils';
export enum SymbolType {
Boolean = 'boolean',
Number = 'number',
String = 'string',
Object = 'object',
Array = 'array',
Function = 'function',
Union = 'union',
Undefined = 'undefined',
Null = 'null',
}
export interface SymbolMetadata {
/**
* Long description of the symbol.
*/
description?: string;
/**
* Link to a documentation/manual page.
*/
link?: string;
}
export interface GenericSymbolDef extends SymbolMetadata {
/**
* Type of the symbol.
*/
type: SymbolType;
/**
* Name of the symbol.
*/
name?: string;
}
export interface SymbolWithProperties extends GenericSymbolDef {
/**
* Properties on the symbol type.
*/
properties: Record<string, GenericSymbolDef>;
}
export interface SymbolWithMethods extends GenericSymbolDef {
/**
* Methods that can be called on the symbol type.
*/
methods: FunctionSymbolDef[];
}
export interface BooleanSymbolDef extends GenericSymbolDef {
type: SymbolType.Boolean;
}
export interface NumberSymbolDef extends GenericSymbolDef {
type: SymbolType.Number;
}
export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.String;
/**
* Set of enumerated values that the string symbol is retristred to.
*/
enum?: string[];
/**
* Properties on strings.
*/
properties: {
length: NumberSymbolDef;
};
}
export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.Object;
}
export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods {
type: SymbolType.Array;
/**
* Symbol representing the type of the items of the array
*/
items: ExtractSymbolDef<SymbolType>;
/**
* Properties on arrays.
*/
properties: {
length: NumberSymbolDef;
};
}
export interface FunctionSymbolDef extends MandateProps<GenericSymbolDef, 'name'> {
type: SymbolType.Function;
/**
* Symbols describing the arguments of the function.
*/
args: (ExtractSymbolDef<SymbolType> & { optional?: boolean })[];
/**
* Symbol describing the returned value of the function.
*/
returns: ExtractSymbolDef<SymbolType>;
}
export interface UnionSymbolDef extends GenericSymbolDef {
type: SymbolType.Union;
/**
* Symbols composing the union.
*/
members: ExtractSymbolDef<SymbolType>[];
}
export interface UndefinedSymbolDef extends GenericSymbolDef {
type: SymbolType.Undefined;
}
export interface NullSymbolDef extends GenericSymbolDef {
type: SymbolType.Null;
}
export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef;
export type ExtractSymbolDef<T extends SymbolType> = T extends SymbolType.String
? StringSymbolDef
: T extends SymbolType.Number
? NumberSymbolDef
: T extends SymbolType.Boolean
? BooleanSymbolDef
: T extends SymbolType.Array
? ArraySymbolDef
: T extends SymbolType.Object
? ObjectSymbolDef
: T extends SymbolType.Function
? FunctionSymbolDef
: T extends SymbolType.Union
? UnionSymbolDef
: T extends SymbolType.Undefined
? UndefinedSymbolDef
: T extends SymbolType.Null
? NullSymbolDef
: never;
export function resolveSymbolDef(
symbol: GenericSymbolDef
):
| StringSymbolDef
| NumberSymbolDef
| BooleanSymbolDef
| ArraySymbolDef
| ObjectSymbolDef
| FunctionSymbolDef
| UnionSymbolDef
| UndefinedSymbolDef
| NullSymbolDef {
switch (symbol.type) {
case SymbolType.String:
return symbol as StringSymbolDef;
case SymbolType.Number:
return symbol as NumberSymbolDef;
case SymbolType.Boolean:
return symbol as BooleanSymbolDef;
case SymbolType.Array:
return symbol as ArraySymbolDef;
case SymbolType.Object:
return symbol as ObjectSymbolDef;
case SymbolType.Function:
return symbol as FunctionSymbolDef;
case SymbolType.Union:
return symbol as UnionSymbolDef;
case SymbolType.Undefined:
return symbol as UndefinedSymbolDef;
case SymbolType.Null:
return symbol as NullSymbolDef;
default:
throw new Error(`Unknown symbol type: ${symbol.type}`);
}
}

Some files were not shown because too many files have changed in this diff Show More