Compare commits

..

3 Commits

Author SHA1 Message Date
Samy Pessé eac554014a Continue 2025-06-10 12:08:19 +02:00
Samy Pessé fef62b12b4 Continue 2025-06-09 00:45:31 +02:00
Samy Pessé 6a10b6185b Start 2025-06-09 00:24:27 +02:00
765 changed files with 18142 additions and 37919 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"gitbook": patch
---
Fix three small visual issues
- Fix sidebar showing on `no-toc` pages in the gradient theme
- Fix variant selector truncating incorrectly in header when sections are present
- Fix page cover alignment on `lg` screens without TOC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Respect fullWidth and defaultWidth for images
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
fix nested a tag causing hydration error
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
fix href being empty in TOC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix navigation between sections/variants when previewing a site in v2
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add circular corners and depth styling
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Add authorization header for OAuth2
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support dark-mode specific page cover image
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
add a force-revalidate api route to force bust the cache in case of errors
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
fix ISR on preview env
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Fix concurrent execution in Vercel causing pages to not be attached to the proper tags.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Generate a llms-full.txt version of the docs site
@@ -2,4 +2,4 @@
'@gitbook/react-openapi': patch
---
Fix OpenAPI basic auth placeholder
Indent JSON python code sample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix missing title on button to close the announcement banner.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Make TOC height dynamic based on visible header and footer elements
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix links to other spaces and root page in embeddable view.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bold header links hover color
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
cache fonts and static image used in OGImage in memory
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Handle nested deprecated properties in generateSchemaExample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Add `urlObject.hash` to `linker.toLinkForContent` to pass through URL fragment identifiers, used in search
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Remove hidden site spaces from navigation
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix hidden section not found
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Deduplicate path parameters from OpenAPI spec
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
remove trailing slash from linker
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI circular references
-3
View File
@@ -1,3 +0,0 @@
# Changes to the API data cache functions can invalidate all existing data cache
# causing a massive amount of revalidation, impacting our API.
packages/gitbook/src/lib/data/api.ts @SamyPesse
+5 -27
View File
@@ -53,39 +53,17 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally
##### Prerequisites:
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
You can easily do this by running the command `nvm use`.
##### Setup steps:
1. Ensure you are using the project's version of Node:
```bash
nvm use
```
2. Install dependencies using Bun:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
Additional development commands:
- `bun format`: Format the code using Biome
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
To start your local version of GitBook, run the command `bun dev`.
#### 4. Preview your changes
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/url/gitbook.com/docs` after running the development server.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
@@ -30,9 +30,9 @@ 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
command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
@@ -49,9 +49,9 @@ 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
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
@@ -61,9 +61,9 @@ 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
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
@@ -71,9 +71,9 @@ 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
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
@@ -0,0 +1,37 @@
name: 'Setup Playwright'
description: 'Install Playwright and dependencies'
runs:
using: 'composite'
steps:
# Run npm ci and get Playwright version
- name: 🏗 Prepare Playwright env
shell: bash
run: |
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
# Cache browser binaries, cache key is based on Playwright version and OS
- name: 🧰 Cache Playwright browser binaries
uses: actions/cache@v4
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}'
restore-keys: |
${{ runner.os }}-playwright-
# Install browser binaries & OS dependencies if cache missed
- name: 🏗 Install Playwright browser binaries & OS dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
working-directory: packages/gitbook
run: |
bun x playwright install --with-deps chromium
# Install only the OS dependencies if cache hit
- name: 🏗 Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
working-directory: packages/gitbook
run: |
bun x playwright install-deps
@@ -59,7 +59,7 @@ runs:
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:cloudflare
run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash
@@ -70,9 +70,9 @@ 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
command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc
- id: upload_server
name: Upload server to Cloudflare
@@ -81,9 +81,9 @@ 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
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Extract server version worker ID
shell: bash
@@ -95,7 +95,7 @@ runs:
- name: Run updateWrangler scripts
shell: bash
run: |
bun run ./packages/gitbook/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
- id: upload_middleware
name: Upload middleware to Cloudflare
@@ -104,9 +104,9 @@ 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
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
+2 -3
View File
@@ -4,9 +4,8 @@ on:
push:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -42,7 +41,7 @@ jobs:
name: Build (Open Source)
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+124 -46
View File
@@ -5,8 +5,55 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v1-cloudflare:
name: Deploy v1 to Cloudflare Pages
runs-on: ubuntu-latest
environment:
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
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: Sets env vars for production
if: github.ref == 'refs/heads/main'
run: |
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
GITBOOK_RUNTIME: cloudflare
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.112.0'
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
@@ -67,6 +114,7 @@ jobs:
name: Comment Deployments (preview)
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy-v1-cloudflare
- deploy-v2-vercel
- deploy-v2-cloudflare
steps:
@@ -86,6 +134,15 @@ jobs:
body: |
Summary of the deployments:
### Version 1
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
### Version 2
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
@@ -93,16 +150,16 @@ jobs:
### Test content
| Site | `2v` | `2c` |
| --- | --- | --- |
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
| Site | `v1` | `2v` | `2c` |
| --- | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v2-vercel:
visual-testing-v1:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
timeout-minutes: 15
name: Visual Testing v1
needs: deploy-v1-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -110,8 +167,27 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
PUPPETEER_SKIP_DOWNLOAD: 1
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
@@ -123,7 +199,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 15
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -131,8 +207,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
@@ -140,11 +216,11 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v2:
visual-testing-customers-v1:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 15
name: Visual Testing Customers v1
needs: deploy-v1-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -152,8 +228,28 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
PUPPETEER_SKIP_DOWNLOAD: 1
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v1'
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
@@ -165,7 +261,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 15
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -173,8 +269,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
@@ -182,10 +278,10 @@ 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-v1:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
name: PageSpeed Testing v1
needs: deploy-v1-cloudflare
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -195,26 +291,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/
BASE_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
+26 -17
View File
@@ -6,7 +6,7 @@ on:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -14,38 +14,47 @@ 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 }}
release-preview:
# For now it releases the cache-do to both preview and production
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
name: Release Preview
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Release preview packages
run: bun run release:preview
env:
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
+13 -8
View File
@@ -1,7 +1,7 @@
<h1 align="center">GitBook</h1>
<p align="center">
<a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a> - <a href="https://github.com/orgs/GitbookIO/discussions/categories/feature-requests">Feature requests</a>
<a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
</p>
<p align="center">
@@ -35,10 +35,10 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >= 22.3)
- 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
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
### Set up
@@ -59,10 +59,10 @@ bun install
4. Start your local development server.
```
bun dev
bun dev:v2
```
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/url`.
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
@@ -71,6 +71,11 @@ examples:
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
### Other development commands
- `bun format`: format the code
- `bun lint`: lint the code
### CI and testing
All pull-requests will be tested against both visual and performances testing to prevent regressions.
@@ -90,7 +95,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
+2 -3
View File
@@ -18,12 +18,10 @@
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"**/*.css"
"packages/cache-do/worker-configuration.d.ts"
]
},
"formatter": {
@@ -129,6 +127,7 @@
{
"include": [
"packages/gitbook/**/*",
"packages/gitbook-v2/**/*",
"packages/react-openapi/**/*",
"packages/react-math/**/*",
"packages/react-contentkit/**/*",
+1299 -1300
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+14 -35
View File
@@ -1,28 +1,25 @@
{
"name": "gitbook",
"version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.7",
"turbo": "^2.6.1",
"vercel": "^39.4.2"
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.2.11",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2"
"@gitbook/api": "^0.119.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"private": true,
"scripts": {
"dev": "turbo run dev --concurrency 20",
"dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
"format": "biome check --write ./",
@@ -31,31 +28,13 @@
"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",
"release:preview": "turbo run release:preview",
"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.151.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.0",
"react-dom": "^19.0.0",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
}
},
"workspaces": ["packages/*"],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
-1
View File
@@ -1 +0,0 @@
dist/
-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
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/browser-types`
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
-36
View File
@@ -1,36 +0,0 @@
{
"name": "@gitbook/browser-types",
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.1.3",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
},
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"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"
}
}
-84
View File
@@ -1,84 +0,0 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
export type GitBookIntegrationEvent = 'load' | 'unload';
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
export type GitBookIntegrationTool = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
*/
confirmation?: {
icon?: IconName;
label: string;
};
/**
* Callback when the tool is executed.
* The input is provided by the AI assistant following the input schema of the tool.
*/
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
};
export type GitBookAssistant = {
/**
* Name of the assistant displayed in the UI.
*/
label: string;
/**
* Icon of the assistant displayed in the UI.
* Any FontAwesome icon name is supported.
* @example 'sparkle'
*/
icon: string;
/**
* Callback when the assistant is opened.
*/
open: (query?: string) => void;
/**
* Whether to display the triggers for this assistant in the UI.
* @default true
*/
ui?: boolean;
};
export type GitBookGlobal = {
/**
* Register an event listener.
*/
addEventListener: (
type: GitBookIntegrationEvent,
func: GitBookIntegrationEventCallback
) => void;
/**
* Remove an event listener.
*/
removeEventListener: (
type: GitBookIntegrationEvent,
func: GitBookIntegrationEventCallback
) => void;
/**
* Register a custom tool to be exposed to the AI assistant.
*/
registerTool: (tool: GitBookIntegrationTool) => void;
/**
* Register a custom assistant to be available on the site.
*/
registerAssistant: (assistant: GitBookAssistant) => () => void;
};
declare global {
interface Window {
/**
* Global `window.GitBook` object accessible by integrations.
*/
GitBook?: GitBookGlobal;
}
}
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+3
View File
@@ -0,0 +1,3 @@
.wrangler
worker-configuration.d.ts
dist/
+18
View File
@@ -0,0 +1,18 @@
# @gitbook/cache-do
## 0.1.1
### Patch Changes
- b7a5106: Disable cloudflare observability in production
## 0.1.0
### Minor Changes
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
### Patch Changes
- 56f5fa1: Enable Workers observability with a sampling of 0.1
+22
View File
@@ -0,0 +1,22 @@
# `@gitbook/cache-do`
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
### Performances
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
### Geo-distribution
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
### Concepts
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
**Location**: cache is distributed over 7 unique locations, one for each continent.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@gitbook/cache-do",
"type": "module",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
},
"./api": {
"types": "./dist/api.d.ts",
"development": "./src/api.ts",
"default": "./dist/api.js"
}
},
"version": "0.1.1",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^4.10.0"
},
"scripts": {
"generate": "wrangler types",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"release": "wrangler deploy",
"release:preview": "wrangler deploy && wrangler deploy --env preview"
},
"files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"]
}
+292
View File
@@ -0,0 +1,292 @@
import { DurableObject } from 'cloudflare:workers';
import { decode, encode } from '@msgpack/msgpack';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
}
/**
* Value stored in a chunked binary msgpack format.
* Stored under the key `prop.${key}.${index}`.
*/
interface CacheObjectProp<Value = unknown> {
value: Value;
expiresAt: number;
}
/**
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
*/
interface CacheObjectExp {
/** Key of the property */
k: string;
/** Number of chunks */
c: number;
}
/**
* Durable Object class being deployed as a distributed cache.
*/
export class CacheObject extends DurableObject {
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
/**
* Open a descriptor to access the cache object.
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
* One session is opened per request on the client side and used to perform multiple operations.
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
*/
public open(): CacheObjectDescriptor {
return {
get: async <Value = unknown>(key: string) => {
return this.get<Value>(key);
},
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
await this.set(key, value, expiresAt);
},
};
}
/**
* Get the value of a property.
*/
public async get<Value = unknown>(key: string) {
return this.logOperation({ operation: 'get', key }, async (setLog) => {
// Try the memory state first.
const memoryEntry = this.lru.get(key);
if (memoryEntry) {
setLog({ memory: true });
setLog({ memoryMatch: !!memoryEntry.match });
if (!memoryEntry.match) {
return;
}
const isExpired = memoryEntry.match.expiresAt < Date.now();
setLog({ memoryExpired: isExpired });
if (!isExpired) {
return memoryEntry.match.value as Value;
}
}
return await this.getFromStorage<Value>(key);
});
}
/**
* Get the value of a property from the DO storage.
*/
public async getFromStorage<Value = unknown>(key: string) {
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
if (entry && entry.value.expiresAt > Date.now()) {
// Found
this.lru.set(key, { match: entry.value });
return entry.value.value;
}
}
// Not found
this.lru.set(key, { match: undefined });
});
}
/**
* Set a value in the cache object.
*/
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return this.logOperation({ operation: 'set', key }, async (setLog) => {
const prop: CacheObjectProp<Value> = {
value,
expiresAt,
};
this.lru.set(key, { match: prop });
await this.ctx.storage.transaction(async (tx) => {
const entries = encodeChunks(key, prop);
const chunks = Object.keys(entries).length;
setLog({ chunks });
const clockValue: CacheObjectExp = {
k: key,
c: chunks,
};
await tx.put(getGCClockKey(key, expiresAt), clockValue);
await tx.put(entries);
const currentAlarm = await tx.getAlarm();
if (!currentAlarm) {
// Set an alarm to garbage collect all entries that have expired in 12h.
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
});
});
}
/**
* Purge all keys in the cache object.
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
const result = new Set<string>();
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (_error) {}
await this.reset();
return Array.from(result);
});
}
/**
* Alarm to garbage collect all entries that have expired.
*/
async alarm() {
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
try {
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
const toDeleteSet = new Set<string>();
for (const [key, exp] of entries) {
const timestamp = Number.parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
}
}
}
// Delete the keys by batch of 128.
const toDelete = Array.from(toDeleteSet);
setLog({ toDelete: toDelete.length });
for (let i = 0; i < toDelete.length; i += 128) {
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
}
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (_error) {
await this.reset();
}
});
}
/**
* Reset the cache object.
*/
async reset() {
return this.logOperation({ operation: 'reset' }, async () => {
this.lru.clear();
await this.ctx.storage.deleteAll();
});
}
/**
* Time and log an operation.
*/
async logOperation<T>(
log: Record<string, unknown>,
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>
): Promise<T> {
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
const update: Record<string, unknown> = {};
const start = performance.now();
try {
return await fn((arg) => {
Object.assign(update, arg);
});
} finally {
const duration = performance.now() - start;
console.log({ ...log, ...update, objectId, duration });
}
}
}
function getStoragePropKey(key: string): string {
return `prop.${key}.`;
}
function getStoragePropChunkKey(key: string, index: number): string {
return `${getStoragePropKey(key)}${index}`;
}
function getGCClockRootKey(timestamp: number): string {
return `exp.${timestamp}.`;
}
function getGCClockKey(key: string, expiresAt: number): string {
return `${getGCClockRootKey(expiresAt)}${key}`;
}
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
const buf = encode(value);
const entries: Record<string, Uint8Array> = {};
const chunks = chunkUint8Array(buf, 128 * 1024);
for (let index = 0; index < chunks.length; index++) {
entries[getStoragePropChunkKey(key, index)] = chunks[index];
}
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = Number.parseInt(key.split('.').pop()!);
return [index, value] as const;
})
.sort(([a], [b]) => a - b)
.map(([, value]) => value);
if (chunks.length === 0) {
return;
}
const buf = mergeUint8Array(chunks);
return { value: decode(buf) as T, size: buf.length };
}
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
const chunks: Uint8Array[] = [];
for (let i = 0; i < input.length; i += chunkSize) {
chunks.push(input.slice(i, i + chunkSize));
}
return chunks;
}
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
+97
View File
@@ -0,0 +1,97 @@
import type { CacheObject } from './CacheObject';
export type CacheLocationId = ContinentCode;
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
/**
* Location hint for the CacheObject durable object.
*/
const doLocationHints: {
[key in CacheLocationId]: DurableObjectLocationHint;
} = {
AF: 'afr',
AS: 'apac',
NA: 'wnam',
SA: 'sam',
AN: 'oc',
EU: 'weur',
OC: 'oc',
};
/**
* Client to access a cache tag.
*/
export class CacheObjectStub {
private stub: DurableObjectStub<CacheObject>;
constructor(
/** Binding to the CacheObject durable object */
private doNamespace: DurableObjectNamespace<CacheObject>,
/** ID of the location to target */
private locationId: CacheLocationId,
/** Name of the tag */
private tag: string
) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
}
/**
* Open a descriptor to the cache object.
* It can be used to perform multiple operations in a single RPC session.
* Ex:
* ```ts
* using desc = cache.open();
* await desc.set('key', 'value', Date.now() + 1000);
* await desc.get('key');
* ```
*/
async open() {
return await this.stub.open();
}
/**
* Get a value from the cache.
*/
async get<Value = unknown>(key: string) {
return (await this.stub.get(key)) as Value | undefined;
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return await this.stub.set(key, value, expiresAt);
}
/**
* Purge all keys in the cache tag.
*/
async purge() {
const keys = new Set<string>();
await Promise.all(
allLocations.map(async (locationId) => {
const groupId = getCacheObjectIdName(locationId, this.tag);
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
const locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
})
);
return keys;
}
}
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
return `${locationId}:${tag}`;
}
+1
View File
@@ -0,0 +1 @@
export * from './CacheObjectStub';
+9
View File
@@ -0,0 +1,9 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
export * from './CacheObject';
export default class Worker extends WorkerEntrypoint {
fetch() {
return new Response('Hello, world!');
}
}
@@ -13,10 +13,9 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": []
"types": ["./worker-configuration.d.ts"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
+27
View File
@@ -0,0 +1,27 @@
main = "./src/index.ts"
name = "gitbook-open-cache"
compatibility_date = "2024-09-02"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[observability]
enabled = false
[env.preview]
name = "gitbook-open-cache-preview"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[env.preview.observability]
enabled = true
head_sampling_rate = 0.1
-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
+8 -18
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:"
"@gitbook/api": "^0.119.0",
"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"]
}
+18 -10
View File
@@ -90,7 +90,7 @@ export function getCacheTag(
| {
tag: 'translation';
organization: string;
translation: string;
translationSettings: string;
}
): string {
switch (spec.tag) {
@@ -115,12 +115,23 @@ export function getCacheTag(
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translation}`;
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default:
assertNever(spec);
}
}
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/**
* Get the tags for a computed content source.
*/
@@ -133,10 +144,6 @@ export function getComputedContentSourceCacheTags(
) {
const tags: string[] = [];
if (!('dependencies' in source)) {
return tags;
}
// We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {});
@@ -160,18 +167,18 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation':
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translation: dependency.ref.translation,
translationSettings: dependency.ref.translationSettings,
})
);
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 +194,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",
-2
View File
@@ -1,2 +0,0 @@
dist/
standalone/
-49
View File
@@ -1,49 +0,0 @@
# @gitbook/embed
## 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
-49
View File
@@ -1,49 +0,0 @@
# `@gitbook/embed`
Embed the GitBook Docs Assistant in your product or website.
# Usage
## As a script from your docs site
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script>
window.GitBook('show');
</script>
```
## As a package from NPM
Install the package: `npm install @gitbook/embed` and import it in your web application:
```tsx
import { createGitBook } from '@gitbook/embed';
const gitbook = createGitBook({
siteURL: 'https://docs.company.com'
});
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL();
const frame = gitbook.createFrame(iframe);
```
## As React components
After installing the NPM package, you can import prebuilt React components:
```tsx
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookAssistantFrame />
</GitBookProvider>
```
-48
View File
@@ -1,48 +0,0 @@
{
"name": "@gitbook/embed",
"description": "Embeddable components for GitBook",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
}
},
"sideEffects": false,
"version": "0.1.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
"react": "*"
},
"devDependencies": {
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"react": "catalog:"
},
"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",
"typecheck": "tsc --noEmit",
"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,61 +0,0 @@
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
export type CreateGitBookOptions = {
/**
* URL of the GitBook site to embed.
*/
siteURL: string;
};
export type GetFrameURLOptions = {
/**
* Authentication to use for the frame.
*/
visitor?: {
/**
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
*/
token?: string;
/**
* Unsigned claims to pass to the frame.
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
*/
unsignedClaims?: Record<string, unknown>;
};
};
export type GitBookClient = {
/**
* Get the URL for a GitBook frame.
*/
getFrameURL: (options: GetFrameURLOptions) => string;
/**
* Create a new GitBook frame.
*/
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
};
export function createGitBook(options: CreateGitBookOptions) {
const client: GitBookClient = {
getFrameURL: (frameOptions) => {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
if (frameOptions.visitor?.token) {
url.searchParams.set('token', frameOptions.visitor.token);
}
if (frameOptions.visitor?.unsignedClaims) {
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
url.searchParams.set(`visitor.${key}`, String(value));
});
}
return url.toString();
},
createFrame: (iframe) => createGitBookFrame(iframe),
};
return client;
}
@@ -1,98 +0,0 @@
import { createChannel } from 'bidc';
import type {
FrameToParentMessage,
GitBookEmbeddableConfiguration,
ParentToFrameMessage,
} from './protocol';
export type GitBookFrameClient = {
/**
* Navigate to a page by its path.
*/
navigateToPage: (path: string) => void;
/**
* Navigate to the assistant.
*/
navigateToAssistant: () => void;
/**
* Post a message to the chat.
*/
postUserMessage: (message: string) => void;
/**
* Clear the chat.
*/
clearChat: () => void;
/**
* Set the placeholder settings.
*/
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
/**
* Register an event listener.
*/
on: (event: string, listener: (...args: any[]) => void) => () => void;
};
/**
* Create a client to communicate with the GitBook Assistant frame.
*/
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
if (!iframe.contentWindow) {
throw new Error('Iframe must have a content window');
}
const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => {
console.log('[gitbook:embed] received message', message);
if (message.type === 'close') {
const listeners = events.get('close') || [];
if (listeners) {
listeners.forEach((listener) => listener());
}
}
});
const sendToFrame = (message: ParentToFrameMessage) => {
console.log('[gitbook:embed] send message', message);
channel.send(message);
};
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
return {
navigateToPage: (pagePath) => {
sendToFrame({ type: 'navigateToPage', pagePath });
},
navigateToAssistant: () => {
sendToFrame({ type: 'navigateToAssistant' });
},
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
configure: (settings) => {
Object.assign(configuration, settings);
sendToFrame({ type: 'configure', settings: configuration });
},
clearChat: () => sendToFrame({ type: 'clearChat' }),
on: (event, listener) => {
const listeners = events.get(event) || [];
listeners.push(listener);
events.set(event, listeners);
return () => {
events.set(
event,
listeners.filter((l) => l !== listener)
);
};
},
};
}
-3
View File
@@ -1,3 +0,0 @@
export * from './createGitBook';
export * from './createGitBookFrame';
export * from './protocol';
-90
View File
@@ -1,90 +0,0 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
/**
* Custom tool definition to be passed to the AI assistant.
*/
export type GitBookToolDefinition = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
*/
confirmation?: {
icon?: IconName;
label: string;
};
/**
* Callback when the tool is executed.
* The input is provided by the AI assistant following the input schema of the tool.
*/
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
};
/**
* Custom button definition to be passed to the embeddable GitBook.
*/
export type GitBookEmbeddableButtonDefinition = {
/**
* Icon to be displayed in the button.
*/
icon: IconName;
/**
* Label to be displayed in the button.
*/
label: string;
/**
* Callback when the button is clicked.
*/
onClick: () => void | Promise<void>;
};
/**
* Overall configuration for the layout of the embeddable GitBook.
*/
export type GitBookEmbeddableConfiguration = {
/**
* Buttons to be displayed in the header of the embeddable GitBook.
*/
buttons: GitBookEmbeddableButtonDefinition[];
/** Message to be displayed in the welcome page. */
welcomeMessage: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
};
/**
* Messages sent from the parent to the frame.
*/
export type ParentToFrameMessage =
| {
type: 'postUserMessage';
message: string;
}
| {
type: 'clearChat';
}
| {
type: 'configure';
settings: GitBookEmbeddableConfiguration;
}
| {
type: 'navigateToPage';
pagePath: string;
}
| {
type: 'navigateToAssistant';
};
/**
* Messages sent from the frame to the parent.
*/
export type FrameToParentMessage = {
type: 'close';
};
-1
View File
@@ -1 +0,0 @@
export * from './client';
-53
View File
@@ -1,53 +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, buttons, welcomeMessage, 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({
buttons,
welcomeMessage,
suggestions,
tools,
});
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
return (
<iframe
title="GitBook"
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
className={className}
/>
);
}
@@ -1,36 +0,0 @@
'use client';
import * as React from 'react';
import { type CreateGitBookOptions, createGitBook } from '../client';
import { GitBookContext } from './context';
/**
* Provider for the GitBook client.
*/
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
const { siteURL, children } = props;
const options = React.useMemo(
() => ({
siteURL,
}),
[siteURL]
);
const client = React.useMemo(() => createGitBook(options), [options]);
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
}
/**
* Hook to access the GitBook client.
*/
export function useGitBook() {
const context = React.useContext(GitBookContext);
if (!context) {
throw new Error('This component must be used within a <GitBookProvider />');
}
return context;
}
-6
View File
@@ -1,6 +0,0 @@
'use client';
import * as React from 'react';
import type { GitBookClient } from '../client';
export const GitBookContext = React.createContext<GitBookClient | null>(null);
-2
View File
@@ -1,2 +0,0 @@
export * from './GitBookProvider';
export * from './GitBookFrame';
-178
View File
@@ -1,178 +0,0 @@
import './style.css';
import {
type CreateGitBookOptions,
type GetFrameURLOptions,
type GitBookClient,
type GitBookEmbeddableConfiguration,
type GitBookFrameClient,
createGitBook,
} from '../client';
export type GitBook = () => void;
type StandaloneCalls =
// Initialize the widget
| ['init', CreateGitBookOptions, GetFrameURLOptions]
// Unload the widget
| ['unload']
// Show the widget
| ['show']
// Hide the widget
| ['hide']
// Open the window
| ['open']
// Close the window
| ['close']
// Toggle the window
| ['toggle']
// Post a user message
| ['postUserMessage', string]
// Clear the chat
| ['clearChat']
// Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration>]
// Navigate to a page
| ['navigateToPage', string]
// Navigate to the assistant
| ['navigateToAssistant'];
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
q?: StandaloneCalls[];
};
const widgetButton = document.createElement('button');
widgetButton.id = 'gitbook-widget-button';
widgetButton.addEventListener('click', () => {
GitBook('toggle');
});
widgetButton.innerHTML = `
<span id="gitbook-widget-button-icon"></span>
<span id="gitbook-widget-button-label">Ask</span>
`;
const widgetWindow = document.createElement('div');
widgetWindow.id = 'gitbook-widget-window';
widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() {
if (!_client) {
throw new Error(
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
);
}
return _client;
}
function getIframe() {
if (!widgetIframe || !_frame) {
const client = getClient();
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
}
return { iframe: widgetIframe, frame: _frame };
}
const GitBook = (...args: StandaloneCalls) => {
switch (args[0]) {
case 'init':
if (_client) {
throw new Error(
'GitBook client already initialized. Call GitBook("unload") first.'
);
}
_client = createGitBook(args[1]);
frameOptions = args[2];
break;
case 'unload':
_client = undefined;
_frame = undefined;
widgetIframe?.remove();
widgetWindow.classList.add('hidden');
break;
case 'show':
widgetButton.classList.remove('hidden');
break;
case 'hide':
widgetButton.classList.add('hidden');
break;
case 'open':
widgetWindow.classList.remove('hidden');
widgetButton.classList.add('open');
getIframe();
break;
case 'toggle':
widgetWindow.classList.toggle('hidden');
widgetButton.classList.toggle('open');
getIframe();
break;
case 'close':
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
break;
case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]);
break;
case 'configure':
frameConfiguration = {
...frameConfiguration,
...args[1],
};
getIframe().frame.configure({
...frameConfiguration,
buttons: [
...frameConfiguration.buttons,
// Always include a close button
{
icon: 'close',
label: 'Close',
onClick: () => {
GitBook('close');
},
},
],
});
break;
case 'clearChat':
getIframe().frame.clearChat();
break;
case 'navigateToPage':
getIframe().frame.navigateToPage(args[1]);
break;
case 'navigateToAssistant':
getIframe().frame.navigateToAssistant();
break;
}
};
// @ts-expect-error - GitBook is not defined in the global scope
const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
-182
View File
@@ -1,182 +0,0 @@
:root {
--gitbook-widget-top: 1rem;
--gitbook-widget-bottom: 1rem;
--gitbook-widget-right: 1rem;
--gitbook-widget-left: 1rem;
--gitbook-widget-button-height: 46px;
--gitbook-widget-radius: .5rem;
--gitbook-widget-text-size: 1rem;
--gitbook-widget-text-color: #656973;
--gitbook-widget-border-color: #e5e5e5;
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
--gitbook-widget-background-solid: #FFFFFF;
--gitbook-widget-background-solid-hover: #FBFBFB;
--gitbook-widget-icon-size: 1.25rem;
--gitbook-widget-window-width: 28rem; /* 448px */
--gitbook-widget-window-height: 40rem; /* 640px */
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
--gitbook-widget-transition-duration-fast: 0.2s;
--gitbook-widget-transition-duration-slow: 0.5s;
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@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;
}
/* Button */
#gitbook-widget-button {
position: fixed;
bottom: var(--gitbook-widget-bottom);
right: var(--gitbook-widget-right);
height: var(--gitbook-widget-button-height);
z-index: 9999;
display: flex;
align-items: center;
flex-direction: row-reverse;
gap: .5rem;
padding: .75rem 1rem;
border-radius: 100px;
border: 1px solid var(--gitbook-widget-border-color);
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(16px);
font-size: var(--gitbook-widget-text-size);
color: var(--gitbook-widget-text-color);
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
cursor:pointer;
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
}
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
background-color: var(--gitbook-widget-background-translucent-hover);
}
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
}
#gitbook-widget-button:active {
transform: translateY(0);
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
}
@media (prefers-contrast: more) {
#gitbook-widget-button {
background-color: var(--gitbook-widget-background-solid);
}
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
background-color: var(--gitbook-widget-background-solid-hover);
}
}
#gitbook-widget-button.hidden {
display: none;
}
#gitbook-widget-button.open {
padding: .75rem;
gap: 0;
}
/* Button: Icon */
#gitbook-widget-button-icon {
width: var(--gitbook-widget-icon-size);
height: var(--gitbook-widget-icon-size);
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
background-color: currentColor;
}
#gitbook-widget-button.open #gitbook-widget-button-icon {
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
}
/* Button: Label */
#gitbook-widget-button.open #gitbook-widget-button-label {
display: none;
}
/* Window */
#gitbook-widget-window {
position: fixed;
bottom: var(--gitbook-widget-window-bottom);
right: var(--gitbook-widget-right);
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
border-radius: var(--gitbook-widget-radius);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
overflow: hidden;
transition-property: transform, opacity, display;
transition-duration: var(--gitbook-widget-transition-duration-slow);
transition-timing-function: var(--gitbook-widget-easing-bounce);
transform-origin: bottom right;
transition-behavior: allow-discrete;
}
@starting-style {
#gitbook-widget-window {
opacity: 0;
transform: scale(0.9);
}
}
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
bottom: var(--gitbook-widget-bottom);
}
#gitbook-widget-window.hidden {
transition-property: transform, opacity, display;
transition-duration: var(--gitbook-widget-transition-duration-fast);
transition-timing-function: var(--gitbook-widget-easing);
transition-behavior: allow-discrete;
display: none;
opacity: 0;
transform: scale(0.9);
}
#gitbook-widget-iframe {
width: 100%;
height: 100%;
border: none;
}
@keyframes gitbook-widget-present {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
-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);
}

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