mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
348 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9251b33959 | |||
| 81a6bd756a | |||
| ada195d329 | |||
| 1211ebea9b | |||
| 2e6e28eb54 | |||
| 854c448bad | |||
| 64a0b0f169 | |||
| 13ff22b6a8 | |||
| 8927e8fbbb | |||
| 25e2b40d47 | |||
| a60af8ff9c | |||
| 27b328cfd7 | |||
| fa6ef0c01c | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 | |||
| 885264f132 | |||
| f1a6dec951 | |||
| 44f41510fa | |||
| f220229e5e | |||
| 61b4cb8d7c | |||
| 185cdb4883 | |||
| 2cdba53451 | |||
| 950f6c36cf | |||
| ea37977b1e | |||
| 81da82aed7 | |||
| 39a3518651 | |||
| d903273759 | |||
| d130532f69 | |||
| 94fcd21f34 | |||
| 1d2db4ae2b | |||
| d270b4af9b | |||
| ccfbd3c1a8 | |||
| 5c4923557f | |||
| b1f608b865 | |||
| c7993756dd | |||
| d02465823f | |||
| 6b9f6b5b79 | |||
| df7de8fb0c | |||
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a | |||
| 2a3bb0eb22 | |||
| 6816f0fa5a | |||
| 17386776ec | |||
| 9ee9082614 | |||
| 334cfdd522 | |||
| 8c961a5dbd | |||
| 4708956e77 | |||
| 813dd03dbb | |||
| 0522dbc61e | |||
| acb9f53156 | |||
| 6016846156 | |||
| cf1fae5837 | |||
| 7212345466 | |||
| e38caf78ff | |||
| 0a8dd67541 | |||
| ed684c19a9 | |||
| 52ab3681e3 | |||
| 9cc5a787e9 | |||
| 0003030bf5 | |||
| 9ad8f45d38 | |||
| a3855257c8 | |||
| 9169c2f053 | |||
| abe028bd38 | |||
| 12c2451f4f | |||
| 8daede5427 | |||
| 4c89aa60eb | |||
| d0a3f64ae4 | |||
| 433d66482d | |||
| bd553bcf9e | |||
| f5894bcf74 | |||
| 1b59e7ce36 | |||
| 32d6f5845d | |||
| 4f5cbfe732 | |||
| afba8fcb34 | |||
| 377a4899e4 | |||
| 938bdeb34b | |||
| d216eaa3f6 | |||
| 5ca5da0f21 | |||
| ebe6eb3b39 | |||
| 3bfe347096 | |||
| 7b38f89078 | |||
| 09b689b0ea | |||
| 2350baa75f | |||
| bc1eca815e | |||
| 78c10340e7 | |||
| c16890a3a6 | |||
| 3b4fe2827a | |||
| 7e807aabb5 | |||
| 973c74ee69 | |||
| efed0b0617 | |||
| ca3b9aca5d | |||
| e2afc07ab2 | |||
| e8fb84d362 | |||
| 57f951a7d9 | |||
| 28008667ed | |||
| f3affc3034 | |||
| 59da30f3c6 | |||
| 2db721112a | |||
| b60039b7d5 | |||
| 8fe9c9afea | |||
| 216ba7a556 | |||
| a2ff57081b | |||
| 40dbd9a14b | |||
| e5bac69c5c | |||
| 8d6598393f | |||
| 59b86eb9dc | |||
| 52c9f6da2f | |||
| dd65987c94 | |||
| ace61901a4 | |||
| e6c3c7635d | |||
| 5134d9e7f8 | |||
| 6821fb2937 | |||
| cfe4812045 | |||
| 14843ac343 | |||
| 8fb6d7465f | |||
| 0ef647586f | |||
| b4039627d9 | |||
| 4a295e6db4 | |||
| 5726999a5a | |||
| 58d7f3c140 | |||
| 07eab986d5 | |||
| 711cf38f9b | |||
| 4f7c0eea9f | |||
| f3448de2da | |||
| ec92075ef8 | |||
| 1e013cd87c | |||
| f033734308 | |||
| 50c05164f7 | |||
| 7fb9004b43 | |||
| 4721403986 | |||
| df848ef64e | |||
| 0b40ccd286 | |||
| caaa692f0b | |||
| 42d43e09c4 | |||
| 7ccbf55ba7 | |||
| 81f5bfc2f9 | |||
| a7a713bca2 | |||
| d6613c787f | |||
| 427f748e1c | |||
| 4f5fec7213 | |||
| 8c0a53ab40 | |||
| 2dfd5aab20 | |||
| 8d3c6562a2 | |||
| dae019c115 | |||
| 73e0cbb2d6 | |||
| 392f59450c | |||
| b4918f60ce | |||
| 8f7c304d58 | |||
| a3a944d7dd | |||
| 88a35ed057 | |||
| 67998b6f15 | |||
| ff6d1150a5 | |||
| 500c8cb649 | |||
| 6859f7d239 | |||
| dfa8a37be1 | |||
| 11a6511b7a | |||
| af98402655 | |||
| b7a0db3339 | |||
| 382a19885b | |||
| d99da6a3ae | |||
| 72cd0e59e6 | |||
| 42d88da73c | |||
| 2863fe0dc1 | |||
| 87218062be | |||
| 8a3910e208 | |||
| 6294bbb53c | |||
| a28a997851 | |||
| 28a9ee701e | |||
| 50e8b2ec52 | |||
| 315717fa2d | |||
| da67711c90 | |||
| fecdbe8dde | |||
| 9316ccd1f4 | |||
| c3b620e975 | |||
| 7a00880e5b | |||
| b6b597564d | |||
| d410381957 | |||
| 902c3c6c1a | |||
| 4fb2a4a6d7 | |||
| 2d64c78787 | |||
| fbfcca5dae | |||
| 664ae8bff6 | |||
| fa3d2aae08 | |||
| e7a591d9f6 | |||
| 2860beacdc | |||
| 231167d3e1 | |||
| c0ee60ec13 | |||
| f58b904ba6 | |||
| d5992f1a5c | |||
| fa12f9eeb6 | |||
| 33726c88a0 | |||
| 6aa3ff9c5e | |||
| 7d3fe23195 | |||
| 521052d84f | |||
| c730845e17 | |||
| 7b5a3616c4 | |||
| c9373efdb5 | |||
| 015615de8a | |||
| 0f867e533a | |||
| 7d5a6d2255 | |||
| a1da4f023d | |||
| 666e8426e1 | |||
| df2fa42bda | |||
| b3a7ad6d2c | |||
| 2932077bf9 | |||
| 957afd967f | |||
| 4c9a9d0416 | |||
| 40df91a363 | |||
| af66ff71ed | |||
| 6f8ecd6111 | |||
| fa3eb07617 | |||
| dc4268db64 | |||
| a0c06a72cc | |||
| 19c20c0be3 | |||
| fae34e56ac | |||
| ba0094a862 | |||
| b259009c9c | |||
| d88dd5ed8a | |||
| 57bb146075 | |||
| 4b67fe5317 | |||
| 938125e772 | |||
| 1c8d9febb3 | |||
| 223edae17d | |||
| 12a455dab5 | |||
| d00dc8ca72 | |||
| 2c85a302fc | |||
| 3460b78b45 | |||
| 95a1f652f9 | |||
| cc37e2ae15 | |||
| 08382ce62f | |||
| 80cb52a237 | |||
| c6637b09e2 | |||
| 778624af00 | |||
| e15757d01f | |||
| aed79fd6c3 | |||
| 0c973a3292 | |||
| 56c923c0ed | |||
| 7d7806df30 | |||
| cb5598dc19 | |||
| 47f01edcb8 | |||
| e6ddc0f07f | |||
| 373f18f451 | |||
| 3f292065cd | |||
| 5e975ab95b | |||
| a3ec264764 | |||
| 5d504ffa4c | |||
| 04999662db | |||
| f7a34706c7 | |||
| f328a41982 | |||
| 20ebecb114 | |||
| 5a69692f54 | |||
| 42ca7e1580 | |||
| c4ebb3fecd | |||
| 2a805cc082 | |||
| 89a5816ee4 | |||
| c3f6b8c003 | |||
| 0e201d5899 | |||
| ae5f1abc58 | |||
| 580101d04e | |||
| 81b4a4db53 | |||
| 8339e91e2b | |||
| 3119066728 | |||
| 326e28e9b0 | |||
| 90f0127ada | |||
| 0353a8186c | |||
| dd043df00f | |||
| 97b7c79bc6 | |||
| 634e0b4302 | |||
| 2a23f1f003 | |||
| 88ffdcc303 | |||
| b6b09d42af | |||
| ebc39e9db9 | |||
| 0df3849ea2 | |||
| 8ed1bda2f2 | |||
| d67699a1f1 | |||
| 3363a18856 | |||
| ad1dc0b914 | |||
| e4c7307c99 | |||
| f9c942c8ff | |||
| eeb977fc03 | |||
| 4b8a62122a | |||
| e44e947fc1 | |||
| 7588cfe1c5 | |||
| a9a643f222 | |||
| 77d6cb75be | |||
| 1942d221df | |||
| 041601ed65 | |||
| 5567281a85 | |||
| 4c3753046b | |||
| 89f8e16184 | |||
| a3e1125ced | |||
| 4dae8acfed | |||
| bc081c4344 | |||
| e4275b235d | |||
| 77397cac82 |
@@ -0,0 +1,3 @@
|
|||||||
|
# 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
|
||||||
+29
-6
@@ -53,19 +53,42 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
|||||||
|
|
||||||
#### 3. Install dependencies and run the project locally
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
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.
|
##### Prerequisites:
|
||||||
|
- Node.js (Version: >=20.6)
|
||||||
|
- Use `nvm` for easy Node management
|
||||||
|
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||||
|
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||||
|
|
||||||
You can easily do this by running the command `nvm use`.
|
##### Setup steps:
|
||||||
|
|
||||||
To start your local version of GitBook, run the command `bun dev`.
|
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
|
||||||
|
|
||||||
#### 4. Preview your changes
|
#### 4. Preview your changes
|
||||||
|
|
||||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
|
||||||
|
|
||||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
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.
|
||||||
|
|
||||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
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.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Gradual Deploy to Cloudflare
|
||||||
|
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
|
||||||
|
inputs:
|
||||||
|
apiToken:
|
||||||
|
description: 'Cloudflare API token'
|
||||||
|
required: true
|
||||||
|
accountId:
|
||||||
|
description: 'Cloudflare account ID'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Cloudflare environment to deploy to (staging, production, preview)'
|
||||||
|
required: true
|
||||||
|
middlewareVersionId:
|
||||||
|
description: 'Middleware version ID to deploy'
|
||||||
|
required: true
|
||||||
|
serverVersionId:
|
||||||
|
description: 'Server version ID to deploy'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- id: wrangler_status
|
||||||
|
name: Check wrangler deployment status
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
# This step is used to get the version ID that is currently deployed to Cloudflare.
|
||||||
|
- id: extract_current_version
|
||||||
|
name: Extract current version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- id: deploy_server
|
||||||
|
name: Deploy server to Cloudflare at 0%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
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
|
||||||
|
|
||||||
|
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
|
||||||
|
- id: deploy_middleware
|
||||||
|
name: Deploy middleware to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Deploy server to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: 'Setup Playwright'
|
|
||||||
description: 'Install Playwright and dependencies'
|
|
||||||
runs:
|
|
||||||
using: 'composite'
|
|
||||||
steps:
|
|
||||||
# Run npm ci and get Playwright version
|
|
||||||
- name: 🏗 Prepare Playwright env
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
|
||||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
|
||||||
- name: 🧰 Cache Playwright browser binaries
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: playwright-cache
|
|
||||||
with:
|
|
||||||
path: '~/.cache/ms-playwright'
|
|
||||||
key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}'
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
|
|
||||||
# Install browser binaries & OS dependencies if cache missed
|
|
||||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
shell: bash
|
|
||||||
working-directory: packages/gitbook
|
|
||||||
run: |
|
|
||||||
bun x playwright install --with-deps chromium
|
|
||||||
|
|
||||||
# Install only the OS dependencies if cache hit
|
|
||||||
- name: 🏗 Install Playwright OS dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
shell: bash
|
|
||||||
working-directory: packages/gitbook
|
|
||||||
run: |
|
|
||||||
bun x playwright install-deps
|
|
||||||
@@ -28,7 +28,7 @@ inputs:
|
|||||||
outputs:
|
outputs:
|
||||||
deployment-url:
|
deployment-url:
|
||||||
description: "Deployment URL"
|
description: "Deployment URL"
|
||||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
@@ -55,26 +55,84 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build worker
|
- name: Build worker
|
||||||
run: bun run turbo build:v2:cloudflare
|
run: bun run turbo build:cloudflare
|
||||||
env:
|
env:
|
||||||
GITBOOK_RUNTIME: cloudflare
|
GITBOOK_RUNTIME: cloudflare
|
||||||
shell: bash
|
shell: bash
|
||||||
- id: deploy
|
|
||||||
name: Deploy to Cloudflare
|
- name: Upload the DO worker
|
||||||
uses: cloudflare/wrangler-action@v3.14.0
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '3.112.0'
|
wranglerVersion: '4.10.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
|
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
|
||||||
|
|
||||||
|
- id: upload_server
|
||||||
|
name: Upload server to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Extract server version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_server_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Run updateWrangler scripts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bun run ./packages/gitbook/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
|
||||||
|
- id: upload_middleware
|
||||||
|
name: Upload middleware to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Extract middleware version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_middleware_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Deploy server and middleware to Cloudflare
|
||||||
|
if: ${{ inputs.deploy == 'true' }}
|
||||||
|
uses: ./.github/actions/gradual-deploy-cloudflare
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
opServiceAccount: ${{ inputs.opServiceAccount }}
|
||||||
|
opItem: ${{ inputs.opItem }}
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
|
||||||
|
deploy: ${{ inputs.deploy }}
|
||||||
|
|
||||||
|
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
|
||||||
|
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
|
||||||
@@ -54,6 +54,7 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build Project Artifacts
|
- name: Build Project Artifacts
|
||||||
|
|||||||
@@ -7,53 +7,6 @@ on:
|
|||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
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:
|
deploy-v2-vercel:
|
||||||
name: Deploy v2 to Vercel (preview)
|
name: Deploy v2 to Vercel (preview)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -114,7 +67,6 @@ jobs:
|
|||||||
name: Comment Deployments (preview)
|
name: Comment Deployments (preview)
|
||||||
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||||
needs:
|
needs:
|
||||||
- deploy-v1-cloudflare
|
|
||||||
- deploy-v2-vercel
|
- deploy-v2-vercel
|
||||||
- deploy-v2-cloudflare
|
- deploy-v2-cloudflare
|
||||||
steps:
|
steps:
|
||||||
@@ -134,15 +86,6 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
Summary of the deployments:
|
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 |
|
| Version | URL | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||||
@@ -150,35 +93,16 @@ jobs:
|
|||||||
|
|
||||||
### Test content
|
### Test content
|
||||||
|
|
||||||
| Site | `v1` | `2v` | `2c` |
|
| Site | `2v` | `2c` |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) |
|
| 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-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) |
|
| 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) |
|
||||||
edit-mode: replace
|
edit-mode: replace
|
||||||
visual-testing-v1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Visual Testing v1
|
|
||||||
needs: deploy-v1-cloudflare
|
|
||||||
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
|
|
||||||
env:
|
|
||||||
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
|
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
|
||||||
visual-testing-v2-vercel:
|
visual-testing-v2-vercel:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing v2
|
name: Visual Testing v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -186,8 +110,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
@@ -199,8 +123,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing v2 (Cloudflare)
|
name: Visual Testing v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
if: startsWith(github.ref_name, 'cloudflare/')
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -208,8 +131,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
@@ -217,31 +140,11 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||||
visual-testing-customers-v1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Visual Testing Customers v1
|
|
||||||
needs: deploy-v1-cloudflare
|
|
||||||
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:
|
|
||||||
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
|
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
|
||||||
ARGOS_BUILD_NAME: 'customers-v1'
|
|
||||||
visual-testing-customers-v2:
|
visual-testing-customers-v2:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2
|
name: Visual Testing Customers v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 8
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -249,8 +152,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e-customers
|
run: bun e2e-customers
|
||||||
env:
|
env:
|
||||||
@@ -262,8 +165,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2 (Cloudflare)
|
name: Visual Testing Customers v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 8
|
timeout-minutes: 15
|
||||||
if: startsWith(github.ref_name, 'cloudflare/')
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -271,8 +173,8 @@ jobs:
|
|||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
env:
|
||||||
uses: ./.github/actions/setup-playwright
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e-customers
|
run: bun e2e-customers
|
||||||
env:
|
env:
|
||||||
@@ -280,10 +182,10 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'customers-v2'
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
pagespeed-testing-v1:
|
pagespeed-testing-v2:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing v1
|
name: PageSpeed Testing v1
|
||||||
needs: deploy-v1-cloudflare
|
needs: deploy-v2-vercel
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -296,5 +198,5 @@ jobs:
|
|||||||
- name: Run pagespeed tests
|
- name: Run pagespeed tests
|
||||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
||||||
env:
|
env:
|
||||||
BASE_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
|
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||||
|
|||||||
@@ -39,22 +39,4 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
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 }}
|
|
||||||
|
|||||||
Vendored
+1
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports.biome": "explicit",
|
"source.organizeImports.biome": "explicit",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1 align="center">GitBook</h1>
|
<h1 align="center">GitBook</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.gitbook.com/">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://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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -35,10 +35,10 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (Version: >=20.6)
|
- Node.js (Version: >=20.6)
|
||||||
- Use nvm for easy Node management
|
- Use nvm for easy Node management
|
||||||
- Bun (Version: >=1.2.1)
|
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||||
- We use a text-based lockfile which isn't supported below 1.2.1
|
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||||
|
|
||||||
### Set up
|
### Set up
|
||||||
|
|
||||||
@@ -62,20 +62,15 @@ bun install
|
|||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/url`.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
|
|
||||||
- http://localhost:3000/docs.gitbook.com
|
- http://localhost:3000/url/gitbook.com/docs
|
||||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
- http://localhost:3000/url/open-source.gitbook.io/midjourney
|
||||||
|
|
||||||
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.
|
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
|
### CI and testing
|
||||||
|
|
||||||
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
||||||
@@ -150,11 +145,11 @@ See `LICENSE` for more information.
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
```md
|
```md
|
||||||
[](https://gitbook.com/)
|
[](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link)
|
||||||
```
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a href="https://gitbook.com">
|
<a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link">
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-2
@@ -18,9 +18,12 @@
|
|||||||
"**/.vercel/**/*",
|
"**/.vercel/**/*",
|
||||||
"**/.cache/**/*",
|
"**/.cache/**/*",
|
||||||
"**/.wrangler/**/*",
|
"**/.wrangler/**/*",
|
||||||
|
"packages/embed/standalone/**/*",
|
||||||
"packages/openapi-parser/src/fixtures/**/*",
|
"packages/openapi-parser/src/fixtures/**/*",
|
||||||
"packages/emoji-codepoints/index.ts",
|
"packages/emoji-codepoints/index.ts",
|
||||||
"packages/icons/src/data/*.json"
|
"packages/icons/src/data/*.json",
|
||||||
|
"packages/gitbook/worker-configuration.d.ts",
|
||||||
|
"**/*.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@@ -126,7 +129,6 @@
|
|||||||
{
|
{
|
||||||
"include": [
|
"include": [
|
||||||
"packages/gitbook/**/*",
|
"packages/gitbook/**/*",
|
||||||
"packages/gitbook-v2/**/*",
|
|
||||||
"packages/react-openapi/**/*",
|
"packages/react-openapi/**/*",
|
||||||
"packages/react-math/**/*",
|
"packages/react-math/**/*",
|
||||||
"packages/react-contentkit/**/*",
|
"packages/react-contentkit/**/*",
|
||||||
|
|||||||
+11
-8
@@ -7,19 +7,17 @@
|
|||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.8",
|
"packageManager": "bun@1.2.15",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "18.3.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "^19.0.0",
|
||||||
"@gitbook/api": "0.109.0"
|
"esbuild": "0.24.2"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"dev:v2": "turbo run dev:v2",
|
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:v2": "turbo run build:v2",
|
|
||||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "biome check --write ./",
|
"format": "biome check --write ./",
|
||||||
@@ -30,11 +28,16 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"changeset-version": "changeset version && bun run format",
|
"changeset-version": "changeset version && bun run format",
|
||||||
"release": "turbo run release && changeset publish",
|
"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",
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
"clean": "turbo run clean"
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": {
|
||||||
|
"packages": ["packages/*"],
|
||||||
|
"catalog": {
|
||||||
|
"@gitbook/api": "^0.136.0",
|
||||||
|
"bidc": "^0.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
"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"
|
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# @gitbook/browser-types
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- cbc71a5: First version of the public package for typing script integrations.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 854c448: Custom assistants followup
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/browser-types`
|
||||||
|
|
||||||
|
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/browser-types",
|
||||||
|
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||||
|
|
||||||
|
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookAssistant = {
|
||||||
|
/**
|
||||||
|
* Name of the assistant displayed in the UI.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon of the assistant displayed in the UI.
|
||||||
|
* Any FontAwesome icon name is supported.
|
||||||
|
* @example 'sparkle'
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the assistant is opened.
|
||||||
|
*/
|
||||||
|
open: (query?: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to display the triggers for this assistant in the UI.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
ui?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookGlobal = {
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
addEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event listener.
|
||||||
|
*/
|
||||||
|
removeEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom tool to be exposed to the AI assistant.
|
||||||
|
*/
|
||||||
|
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom assistant to be available on the site.
|
||||||
|
*/
|
||||||
|
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* Global `window.GitBook` object accessible by integrations.
|
||||||
|
*/
|
||||||
|
GitBook?: GitBookGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.wrangler
|
|
||||||
worker-configuration.d.ts
|
|
||||||
dist/
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# @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
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# `@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.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "^3.112.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"generate": "wrangler types --experimental-include-runtime",
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from './CacheObjectStub';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
||||||
|
|
||||||
export * from './CacheObject';
|
|
||||||
|
|
||||||
export default class Worker extends WorkerEntrypoint {
|
|
||||||
fetch() {
|
|
||||||
return new Response('Hello, world!');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["//"],
|
|
||||||
"tasks": {
|
|
||||||
"generate": {
|
|
||||||
"outputs": ["worker-configuration.d.ts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# @gitbook/cache-tags
|
# @gitbook/cache-tags
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 77397ca: Fix version of @gitbook/api referenced in package.json
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "catalog:",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function getCacheTag(
|
|||||||
| {
|
| {
|
||||||
tag: 'translation';
|
tag: 'translation';
|
||||||
organization: string;
|
organization: string;
|
||||||
translationSettings: string;
|
translation: string;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
@@ -115,7 +115,7 @@ export function getCacheTag(
|
|||||||
case 'openapi':
|
case 'openapi':
|
||||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
case 'translation':
|
case 'translation':
|
||||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
return `organization:${spec.organization}:translation:${spec.translation}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
|
|||||||
) {
|
) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
if (!('dependencies' in source)) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||||
// when the dependencies are updated.
|
// when the dependencies are updated.
|
||||||
const dependencies = Object.values(source.dependencies ?? {});
|
const dependencies = Object.values(source.dependencies ?? {});
|
||||||
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'translation-language':
|
case 'translation':
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'translation',
|
tag: 'translation',
|
||||||
organization: inContext.organizationId,
|
organization: inContext.organizationId,
|
||||||
translationSettings: dependency.ref.translationSettings,
|
translation: dependency.ref.translation,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c3f6b8c: Update chroma ratio per step
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||||
|
|
||||||
## 0.3.2
|
## 0.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,13 +214,31 @@ export function colorScale(
|
|||||||
const targetL =
|
const targetL =
|
||||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||||
|
|
||||||
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
|
if (
|
||||||
|
index === 8 &&
|
||||||
|
!mix &&
|
||||||
|
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
|
||||||
|
) {
|
||||||
// Original colour is close enough to target, so let's use the original colour as step 9.
|
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||||
result.push(hex);
|
result.push(hex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chromaRatio = index === 8 || index === 9 ? 1 : index * 0.05;
|
const chromaRatio = (() => {
|
||||||
|
switch (index) {
|
||||||
|
// Step 9 and 10 have max chroma, meaning they are fully saturated.
|
||||||
|
case 8:
|
||||||
|
case 9:
|
||||||
|
return 1;
|
||||||
|
// Step 11 and 12 have a reduced chroma
|
||||||
|
case 10:
|
||||||
|
return 0.4;
|
||||||
|
case 11:
|
||||||
|
return 0.1;
|
||||||
|
default:
|
||||||
|
return index * 0.05;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const shade = {
|
const shade = {
|
||||||
L: targetL, // Blend lightness
|
L: targetL, // Blend lightness
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
standalone/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 81a6bd7: Improve API to control the GitBook embed
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8927e8f: Initial version of the embed SDK.
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# `@gitbook/embed`
|
||||||
|
|
||||||
|
Embed the GitBook Docs Assistant in your product or website.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
## As a script from your docs site
|
||||||
|
|
||||||
|
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||||
|
|
||||||
|
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||||
|
|
||||||
|
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||||
|
<script>
|
||||||
|
window.GitBook('show');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## As a package from NPM
|
||||||
|
|
||||||
|
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createGitBook } from '@gitbook/embed';
|
||||||
|
|
||||||
|
const gitbook = createGitBook({
|
||||||
|
siteURL: 'https://docs.company.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = gitbook.getFrameURL();
|
||||||
|
|
||||||
|
const frame = gitbook.createFrame(iframe);
|
||||||
|
```
|
||||||
|
|
||||||
|
## As React components
|
||||||
|
|
||||||
|
After installing the NPM package, you can import prebuilt React components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||||
|
|
||||||
|
<GitBookProvider siteURL="https://docs.company.com">
|
||||||
|
<GitBookAssistantFrame />
|
||||||
|
</GitBookProvider>
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/embed",
|
||||||
|
"description": "Embeddable components for GitBook",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js",
|
||||||
|
"standalone": "./dist/standalone/index.js",
|
||||||
|
"react": "./dist/react/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:",
|
||||||
|
"bidc": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"react": "^19.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
|
||||||
|
|
||||||
|
export type CreateGitBookOptions = {
|
||||||
|
/**
|
||||||
|
* URL of the GitBook site to embed.
|
||||||
|
*/
|
||||||
|
siteURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFrameURLOptions = {
|
||||||
|
/**
|
||||||
|
* Authentication to use for the frame.
|
||||||
|
*/
|
||||||
|
visitor?: {
|
||||||
|
/**
|
||||||
|
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned claims to pass to the frame.
|
||||||
|
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
|
||||||
|
*/
|
||||||
|
unsignedClaims?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookClient = {
|
||||||
|
/**
|
||||||
|
* Get the URL for a GitBook frame.
|
||||||
|
*/
|
||||||
|
getFrameURL: (options: GetFrameURLOptions) => string;
|
||||||
|
/**
|
||||||
|
* Create a new GitBook frame.
|
||||||
|
*/
|
||||||
|
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createGitBook(options: CreateGitBookOptions) {
|
||||||
|
const client: GitBookClient = {
|
||||||
|
getFrameURL: (frameOptions) => {
|
||||||
|
const url = new URL(options.siteURL);
|
||||||
|
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.token) {
|
||||||
|
url.searchParams.set('token', frameOptions.visitor.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.unsignedClaims) {
|
||||||
|
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(`visitor.${key}`, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
createFrame: (iframe) => createGitBookFrame(iframe),
|
||||||
|
};
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createChannel } from 'bidc';
|
||||||
|
import type {
|
||||||
|
FrameToParentMessage,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
ParentToFrameMessage,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
export type GitBookFrameClient = {
|
||||||
|
/**
|
||||||
|
* Navigate to a page by its path.
|
||||||
|
*/
|
||||||
|
navigateToPage: (path: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the assistant.
|
||||||
|
*/
|
||||||
|
navigateToAssistant: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a message to the chat.
|
||||||
|
*/
|
||||||
|
postUserMessage: (message: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the chat.
|
||||||
|
*/
|
||||||
|
clearChat: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the placeholder settings.
|
||||||
|
*/
|
||||||
|
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a client to communicate with the GitBook Assistant frame.
|
||||||
|
*/
|
||||||
|
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
|
||||||
|
if (!iframe.contentWindow) {
|
||||||
|
throw new Error('Iframe must have a content window');
|
||||||
|
}
|
||||||
|
const channel = createChannel(iframe.contentWindow);
|
||||||
|
|
||||||
|
channel.receive((message: FrameToParentMessage) => {
|
||||||
|
console.log('[gitbook:embed] received message', message);
|
||||||
|
if (message.type === 'close') {
|
||||||
|
const listeners = events.get('close') || [];
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendToFrame = (message: ParentToFrameMessage) => {
|
||||||
|
console.log('[gitbook:embed] send message', message);
|
||||||
|
channel.send(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
const configuration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToPage: (pagePath) => {
|
||||||
|
sendToFrame({ type: 'navigateToPage', pagePath });
|
||||||
|
},
|
||||||
|
navigateToAssistant: () => {
|
||||||
|
sendToFrame({ type: 'navigateToAssistant' });
|
||||||
|
},
|
||||||
|
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
|
||||||
|
configure: (settings) => {
|
||||||
|
Object.assign(configuration, settings);
|
||||||
|
sendToFrame({ type: 'configure', settings: configuration });
|
||||||
|
},
|
||||||
|
clearChat: () => sendToFrame({ type: 'clearChat' }),
|
||||||
|
on: (event, listener) => {
|
||||||
|
const listeners = events.get(event) || [];
|
||||||
|
listeners.push(listener);
|
||||||
|
events.set(event, listeners);
|
||||||
|
return () => {
|
||||||
|
events.set(
|
||||||
|
event,
|
||||||
|
listeners.filter((l) => l !== listener)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './createGitBook';
|
||||||
|
export * from './createGitBookFrame';
|
||||||
|
export * from './protocol';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tool definition to be passed to the AI assistant.
|
||||||
|
*/
|
||||||
|
export type GitBookToolDefinition = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom button definition to be passed to the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableButtonDefinition = {
|
||||||
|
/**
|
||||||
|
* Icon to be displayed in the button.
|
||||||
|
*/
|
||||||
|
icon: IconName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label to be displayed in the button.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the button is clicked.
|
||||||
|
*/
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall configuration for the layout of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableConfiguration = {
|
||||||
|
/**
|
||||||
|
* Buttons to be displayed in the header of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
buttons: GitBookEmbeddableButtonDefinition[];
|
||||||
|
|
||||||
|
/** Message to be displayed in the welcome page. */
|
||||||
|
welcomeMessage: string;
|
||||||
|
|
||||||
|
/** Suggestions of questions to be displayed in the welcome page. */
|
||||||
|
suggestions: string[];
|
||||||
|
|
||||||
|
/** Tools to be provided to the assistant. */
|
||||||
|
tools: GitBookToolDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the parent to the frame.
|
||||||
|
*/
|
||||||
|
export type ParentToFrameMessage =
|
||||||
|
| {
|
||||||
|
type: 'postUserMessage';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'clearChat';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'configure';
|
||||||
|
settings: GitBookEmbeddableConfiguration;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToPage';
|
||||||
|
pagePath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToAssistant';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the frame to the parent.
|
||||||
|
*/
|
||||||
|
export type FrameToParentMessage = {
|
||||||
|
type: 'close';
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './client';
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type {
|
||||||
|
GetFrameURLOptions,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
GitBookFrameClient,
|
||||||
|
} from '../client';
|
||||||
|
import { useGitBook } from './GitBookProvider';
|
||||||
|
|
||||||
|
export type GitBookFrameProps = {
|
||||||
|
className?: string;
|
||||||
|
} & GetFrameURLOptions &
|
||||||
|
GitBookEmbeddableConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a frame with the GitBook Assistant in it.
|
||||||
|
*/
|
||||||
|
export function GitBookFrame(props: GitBookFrameProps) {
|
||||||
|
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||||
|
|
||||||
|
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
|
const gitbook = useGitBook();
|
||||||
|
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
||||||
|
|
||||||
|
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (frameRef.current) {
|
||||||
|
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||||
|
}
|
||||||
|
}, [gitbook]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
gitbookFrame?.configure({
|
||||||
|
buttons,
|
||||||
|
welcomeMessage,
|
||||||
|
suggestions,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title="GitBook"
|
||||||
|
ref={frameRef}
|
||||||
|
src={frameURL}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||||
|
import { GitBookContext } from './context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for the GitBook client.
|
||||||
|
*/
|
||||||
|
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
|
||||||
|
const { siteURL, children } = props;
|
||||||
|
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
siteURL,
|
||||||
|
}),
|
||||||
|
[siteURL]
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = React.useMemo(() => createGitBook(options), [options]);
|
||||||
|
|
||||||
|
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the GitBook client.
|
||||||
|
*/
|
||||||
|
export function useGitBook() {
|
||||||
|
const context = React.useContext(GitBookContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('This component must be used within a <GitBookProvider />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { GitBookClient } from '../client';
|
||||||
|
|
||||||
|
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './GitBookProvider';
|
||||||
|
export * from './GitBookFrame';
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import './style.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CreateGitBookOptions,
|
||||||
|
type GetFrameURLOptions,
|
||||||
|
type GitBookClient,
|
||||||
|
type GitBookEmbeddableConfiguration,
|
||||||
|
type GitBookFrameClient,
|
||||||
|
createGitBook,
|
||||||
|
} from '../client';
|
||||||
|
|
||||||
|
export type GitBook = () => void;
|
||||||
|
|
||||||
|
type StandaloneCalls =
|
||||||
|
// Initialize the widget
|
||||||
|
| ['init', CreateGitBookOptions, GetFrameURLOptions]
|
||||||
|
// Unload the widget
|
||||||
|
| ['unload']
|
||||||
|
// Show the widget
|
||||||
|
| ['show']
|
||||||
|
// Hide the widget
|
||||||
|
| ['hide']
|
||||||
|
// Open the window
|
||||||
|
| ['open']
|
||||||
|
// Close the window
|
||||||
|
| ['close']
|
||||||
|
// Toggle the window
|
||||||
|
| ['toggle']
|
||||||
|
// Post a user message
|
||||||
|
| ['postUserMessage', string]
|
||||||
|
// Clear the chat
|
||||||
|
| ['clearChat']
|
||||||
|
// Configure the embed
|
||||||
|
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||||
|
// Navigate to a page
|
||||||
|
| ['navigateToPage', string]
|
||||||
|
// Navigate to the assistant
|
||||||
|
| ['navigateToAssistant'];
|
||||||
|
|
||||||
|
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||||
|
q?: StandaloneCalls[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetButton = document.createElement('button');
|
||||||
|
widgetButton.id = 'gitbook-widget-button';
|
||||||
|
widgetButton.addEventListener('click', () => {
|
||||||
|
GitBook('toggle');
|
||||||
|
});
|
||||||
|
widgetButton.innerHTML = `
|
||||||
|
<span id="gitbook-widget-button-icon"></span>
|
||||||
|
<span id="gitbook-widget-button-label">Ask</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const widgetWindow = document.createElement('div');
|
||||||
|
widgetWindow.id = 'gitbook-widget-window';
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
|
||||||
|
document.body.appendChild(widgetButton);
|
||||||
|
document.body.appendChild(widgetWindow);
|
||||||
|
|
||||||
|
let widgetIframe: HTMLIFrameElement | undefined;
|
||||||
|
let _client: GitBookClient | undefined;
|
||||||
|
let _frame: GitBookFrameClient | undefined;
|
||||||
|
let frameOptions: GetFrameURLOptions | undefined;
|
||||||
|
let frameConfiguration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
if (!_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIframe() {
|
||||||
|
if (!widgetIframe || !_frame) {
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetIframe = document.createElement('iframe');
|
||||||
|
widgetIframe.id = 'gitbook-widget-iframe';
|
||||||
|
widgetIframe.src = client.getFrameURL({
|
||||||
|
...frameOptions,
|
||||||
|
});
|
||||||
|
widgetWindow.appendChild(widgetIframe);
|
||||||
|
|
||||||
|
_frame = client.createFrame(widgetIframe);
|
||||||
|
}
|
||||||
|
return { iframe: widgetIframe, frame: _frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitBook = (...args: StandaloneCalls) => {
|
||||||
|
switch (args[0]) {
|
||||||
|
case 'init':
|
||||||
|
if (_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client already initialized. Call GitBook("unload") first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_client = createGitBook(args[1]);
|
||||||
|
frameOptions = args[2];
|
||||||
|
break;
|
||||||
|
case 'unload':
|
||||||
|
_client = undefined;
|
||||||
|
_frame = undefined;
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'show':
|
||||||
|
widgetButton.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
widgetButton.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'open':
|
||||||
|
widgetWindow.classList.remove('hidden');
|
||||||
|
widgetButton.classList.add('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'toggle':
|
||||||
|
widgetWindow.classList.toggle('hidden');
|
||||||
|
widgetButton.classList.toggle('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
widgetButton.classList.remove('open');
|
||||||
|
break;
|
||||||
|
case 'postUserMessage':
|
||||||
|
getIframe().frame.postUserMessage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'configure':
|
||||||
|
frameConfiguration = {
|
||||||
|
...frameConfiguration,
|
||||||
|
...args[1],
|
||||||
|
};
|
||||||
|
getIframe().frame.configure({
|
||||||
|
...frameConfiguration,
|
||||||
|
buttons: [
|
||||||
|
...frameConfiguration.buttons,
|
||||||
|
|
||||||
|
// Always include a close button
|
||||||
|
{
|
||||||
|
icon: 'close',
|
||||||
|
label: 'Close',
|
||||||
|
onClick: () => {
|
||||||
|
GitBook('close');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'clearChat':
|
||||||
|
getIframe().frame.clearChat();
|
||||||
|
break;
|
||||||
|
case 'navigateToPage':
|
||||||
|
getIframe().frame.navigateToPage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'navigateToAssistant':
|
||||||
|
getIframe().frame.navigateToAssistant();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
window.GitBook = GitBook;
|
||||||
|
precalls.forEach((call) => GitBook(...call));
|
||||||
|
|
||||||
|
GitBook('configure', {});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
:root {
|
||||||
|
--gitbook-widget-top: 1rem;
|
||||||
|
--gitbook-widget-bottom: 1rem;
|
||||||
|
--gitbook-widget-right: 1rem;
|
||||||
|
|
||||||
|
--gitbook-widget-button-height: 46px;
|
||||||
|
|
||||||
|
--gitbook-widget-radius: .5rem;
|
||||||
|
--gitbook-widget-text-size: 1rem;
|
||||||
|
--gitbook-widget-text-color: #656973;
|
||||||
|
--gitbook-widget-border-color: #e5e5e5;
|
||||||
|
|
||||||
|
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||||
|
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||||
|
--gitbook-widget-background-solid: #FFFFFF;
|
||||||
|
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||||
|
|
||||||
|
--gitbook-widget-icon-size: 1.25rem;
|
||||||
|
|
||||||
|
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||||
|
--gitbook-widget-window-height: 40rem; /* 640px */
|
||||||
|
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
|
||||||
|
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
|
||||||
|
|
||||||
|
--gitbook-widget-transition-duration-fast: 0.2s;
|
||||||
|
--gitbook-widget-transition-duration-slow: 0.5s;
|
||||||
|
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
#gitbook-widget-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
height: var(--gitbook-widget-button-height);
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
background-color: var(--gitbook-widget-background-translucent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
|
||||||
|
font-size: var(--gitbook-widget-text-size);
|
||||||
|
color: var(--gitbook-widget-text-color);
|
||||||
|
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
|
||||||
|
|
||||||
|
cursor:pointer;
|
||||||
|
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-translucent-hover);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
#gitbook-widget-button {
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-solid-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open {
|
||||||
|
padding: .75rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Icon */
|
||||||
|
#gitbook-widget-button-icon {
|
||||||
|
width: var(--gitbook-widget-icon-size);
|
||||||
|
height: var(--gitbook-widget-icon-size);
|
||||||
|
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||||
|
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Label */
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window */
|
||||||
|
#gitbook-widget-window {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-window-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
z-index: 9998;
|
||||||
|
width: var(--gitbook-widget-window-width);
|
||||||
|
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
border-radius: var(--gitbook-widget-radius);
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-slow);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing-bounce);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
#gitbook-widget-window {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-window.hidden {
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-fast);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing);
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gitbook-widget-present {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Major Changes
|
||||||
|
|
||||||
|
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/expr`
|
||||||
|
|
||||||
|
Safely evaluate & parse user-defined GitBook expressions.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/expr",
|
||||||
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"dependencies": {
|
||||||
|
"eval-estree-expression": "^2.0.3",
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"acorn-loose": "8.4.0",
|
||||||
|
"acorn-walk": "^8.3.4",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "^1.1.20",
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
|
"@babel/types": "^7.26.0",
|
||||||
|
"@types/json-schema": "^7.0.15"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"unit": "bun test",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolType,
|
||||||
|
SymbolsTable,
|
||||||
|
} from '../symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
describe('autocomplete', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const visitorClaimsHelloArraySymbol = SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
});
|
||||||
|
const symbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: visitorClaimsHelloArraySymbol,
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const context = new SymbolsTable(symbols);
|
||||||
|
const SCENARIOS: Array<{
|
||||||
|
expressionWithCursor: string;
|
||||||
|
expectedSuggestions: AutocompleteSuggestions;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
'visitor.claims.role',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.role',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.role.length',
|
||||||
|
'visitor.claims.role.at',
|
||||||
|
'visitor.claims.role.endsWith',
|
||||||
|
'visitor.claims.role.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.ke<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.h<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello.length',
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
|
||||||
|
(method) => ({
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: method,
|
||||||
|
ref: `visitor.claims.hello.${method.name}`,
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.key <cur>',
|
||||||
|
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
|
||||||
|
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
|
||||||
|
),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['==', '==='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '==='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['!=', '!=='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '!=='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == editor<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello == <cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '"test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
it.each(SCENARIOS)(
|
||||||
|
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
|
||||||
|
({ expressionWithCursor, expectedSuggestions }) => {
|
||||||
|
const { expression, cursorOffset } = extractCursorPosition(
|
||||||
|
expressionWithCursor,
|
||||||
|
'<cur>'
|
||||||
|
);
|
||||||
|
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
|
||||||
|
expect(suggestions).toStrictEqual(expectedSuggestions);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractCursorPosition(
|
||||||
|
expressionWithCursor: string,
|
||||||
|
cursorPlaceholder: string
|
||||||
|
): { expression: string; cursorOffset: number } {
|
||||||
|
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
|
||||||
|
if (cursorOffset === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
|
||||||
|
return { expression, cursorOffset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values';
|
||||||
|
|
||||||
|
describe('inferDefaultInputValuesFromObjectJSONSchema', () => {
|
||||||
|
it('should infer properly the default input value based on the JSON schema of an object', () => {
|
||||||
|
const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isAlphaUser: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['enumValue1', 'enumValue2', 'enumValue3'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(defaultInputValues).toMatchObject({
|
||||||
|
claims: {
|
||||||
|
key: 'default',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'default',
|
||||||
|
FLAG2: 'default',
|
||||||
|
FLAG3: 'default',
|
||||||
|
},
|
||||||
|
isAlphaUser: true,
|
||||||
|
hello: 'enumValue1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionError } from '../errors';
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import type { Logger } from '../types';
|
||||||
|
|
||||||
|
const SILENT_LOGGER: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const runtime = new ExpressionRuntime(SILENT_LOGGER);
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
inputs: { isBetaUser: false },
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
inputs: {
|
||||||
|
useProductA: true,
|
||||||
|
isBetaUser: false,
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
inputs: {
|
||||||
|
products: ['productA', 'productB'],
|
||||||
|
userSegments: {
|
||||||
|
alpha: true,
|
||||||
|
beta: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||||
|
({ condition, inputs, expectedResult }) => {
|
||||||
|
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult);
|
||||||
|
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const INVALID_EXPRESSSIONS = [
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: 'while (1) {}',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: '[1, 2, 3].map(() => { while (1) {}})',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
const result = runtime.safeEvaluate(condition, inputs);
|
||||||
|
expect(result.value).toBeUndefined();
|
||||||
|
expect(result.error instanceof ExpressionError).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should throw an error when using evaluate with invalid expressions',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse', () => {
|
||||||
|
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
|
||||||
|
const ast = runtime.parse('isBetaUser === true');
|
||||||
|
|
||||||
|
expect(ast.result).toEqual({
|
||||||
|
type: 'BinaryExpression',
|
||||||
|
start: 0,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
|
||||||
|
left: {
|
||||||
|
type: 'Identifier',
|
||||||
|
start: 0,
|
||||||
|
end: 10,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
|
||||||
|
name: 'isBetaUser',
|
||||||
|
},
|
||||||
|
operator: '===',
|
||||||
|
right: {
|
||||||
|
type: 'Literal',
|
||||||
|
start: 15,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
|
||||||
|
value: true,
|
||||||
|
raw: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should throw an error for non conditional expressions or syntax errors: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip('generate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should produce the original expression using an AST node produced by parse: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
const { result } = runtime.parse(condition);
|
||||||
|
expect(runtime.generate(result)).toStrictEqual(condition);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime, parseTemplate } from '../';
|
||||||
|
|
||||||
|
describe('template expressions', () => {
|
||||||
|
it('should parse template into parts', () => {
|
||||||
|
const parts = parseTemplate('Hello {{ user.name }}!');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'text', value: 'Hello ', start: 0, end: 6 },
|
||||||
|
{ type: 'expression', value: 'user.name', start: 8, end: 19 },
|
||||||
|
{ type: 'text', value: '!', start: 21, end: 22 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template starting with an expression', () => {
|
||||||
|
const parts = parseTemplate('{{ user.name }} is cool');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'expression', value: 'user.name', start: 2, end: 13 },
|
||||||
|
{ type: 'text', value: ' is cool', start: 15, end: 23 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template without expressions', () => {
|
||||||
|
const parts = parseTemplate('Hello world');
|
||||||
|
expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate template', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const result = runtime.evaluateTemplate('Hello {{ user.name }}!', {
|
||||||
|
user: { name: 'John' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('Hello John!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import type {
|
||||||
|
Node as AcornNode,
|
||||||
|
AnyNode,
|
||||||
|
BinaryExpression,
|
||||||
|
Expression,
|
||||||
|
Identifier,
|
||||||
|
Literal,
|
||||||
|
MemberExpression,
|
||||||
|
PrivateIdentifier,
|
||||||
|
Super,
|
||||||
|
} from 'acorn';
|
||||||
|
import { isDummy } from 'acorn-loose';
|
||||||
|
import * as walk from 'acorn-walk';
|
||||||
|
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
|
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteLiteralValueSuggestion,
|
||||||
|
type AutocompleteOperatorSuggestion,
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
type DirectLiteralValueSuggestion,
|
||||||
|
type ExpressionParserResult,
|
||||||
|
type Logger,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface ExpressionParser {
|
||||||
|
parse(expr: string, options: { loose?: boolean }): ExpressionParserResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutoComplete {
|
||||||
|
#parser: ExpressionParser;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(parser: ExpressionParser, logger: Logger = console) {
|
||||||
|
this.#parser = parser;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates autocomplete suggestions based on the input expression and cursor offset position.
|
||||||
|
*/
|
||||||
|
public getSuggestions(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
if (!expr.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, invalidNodes } = this.#parser.parse(expr, { loose: true });
|
||||||
|
|
||||||
|
// Locate the node at the cursor position.
|
||||||
|
const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, cursorOffset)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nodeAtCursorFound) {
|
||||||
|
// When we can't find one we might be in the boundary of the program/expression.
|
||||||
|
// We could possibly be in a whitespace at the end of the expression or in a situation where the parsed
|
||||||
|
// tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser).
|
||||||
|
//
|
||||||
|
// In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either:
|
||||||
|
// - using the end position of the whole expression AST (e.g white space at the very end of the expression string)
|
||||||
|
// - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node.
|
||||||
|
if (cursorOffset > result.end) {
|
||||||
|
const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result;
|
||||||
|
|
||||||
|
if (!ast) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, ast.end)
|
||||||
|
);
|
||||||
|
if (!lastNodeFound) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { node } = lastNodeFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, node, result.end, context);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node } = nodeAtCursorFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSuggestionsForNode(result, node, expr, cursorOffset, context);
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error('Error while computing autocomplete suggestions', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
// When the node is an identifier look up the parent to get more context for the suggestions.
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier' || node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode =
|
||||||
|
parent &&
|
||||||
|
!['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes(
|
||||||
|
parent.type
|
||||||
|
)
|
||||||
|
? parent
|
||||||
|
: node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'Identifier':
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch suggestions from the symbol table
|
||||||
|
const candidatesKeys = context.getMatchingSymbolsKeys(pathParts);
|
||||||
|
|
||||||
|
const suggestions: AutocompleteSymbolSuggestion[] = [];
|
||||||
|
for (const candidate of candidatesKeys) {
|
||||||
|
const symbolInfo = context.getSymbolInfo(candidate);
|
||||||
|
if (symbolInfo) {
|
||||||
|
suggestions.push({ type: 'symbol', symbol: symbolInfo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 1) {
|
||||||
|
const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, '');
|
||||||
|
|
||||||
|
// Return no suggestion when the only match is an exact match of the
|
||||||
|
// typed token.
|
||||||
|
return lastPathSegment !== suggestions[0]?.symbol.definition.name
|
||||||
|
? suggestions
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Provide suggestions for binary or logical operators based on the parsed operator
|
||||||
|
// of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)).
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'BinaryExpression': {
|
||||||
|
const { left, right } = inferNode;
|
||||||
|
|
||||||
|
const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some(
|
||||||
|
(op) => op.operator === inferNode.operator
|
||||||
|
);
|
||||||
|
|
||||||
|
const operatorIndex = expr.indexOf(inferNode.operator, left.end);
|
||||||
|
const operatorOffset = operatorIndex + inferNode.operator.length;
|
||||||
|
const isCursorAfterOperator = cursorOffset > operatorOffset;
|
||||||
|
|
||||||
|
const shouldSuggestValues =
|
||||||
|
isCursorAfterOperator &&
|
||||||
|
isOperatorBinaryOp &&
|
||||||
|
isNodeAtCursor(right, cursorOffset);
|
||||||
|
|
||||||
|
if (shouldSuggestValues) {
|
||||||
|
return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide suggestions for binary operators based on the parsed operator
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'ConditionalExpression': {
|
||||||
|
const { consequent, alternate } = inferNode;
|
||||||
|
|
||||||
|
if (isNodeAtCursor(consequent, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeAtCursor(alternate, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
node: MemberExpression | Identifier,
|
||||||
|
cursorOffset: number,
|
||||||
|
options?: { withWildcardMatches: boolean }
|
||||||
|
): string[] {
|
||||||
|
const withWildcardMatches = options?.withWildcardMatches ?? true;
|
||||||
|
const pathParts: string[] = [];
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'MemberExpression':
|
||||||
|
{
|
||||||
|
const memberProperty = node.property;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as member properties.
|
||||||
|
if (!isSupportedMemberProperty(memberProperty)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the path part corresponding to the member property (e.g b in a.b or a['b'])
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
memberProperty,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(
|
||||||
|
withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go through the parent(s) in the chain and add their path parts as well
|
||||||
|
let parent: Expression | Super | undefined = node.object;
|
||||||
|
while (parent) {
|
||||||
|
const parentProperty = 'property' in parent ? parent.property : parent;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as parent property.
|
||||||
|
const parentPropertyPathPart = isSupportedMemberProperty(parentProperty)
|
||||||
|
? this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
parentProperty,
|
||||||
|
cursorOffset
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (parentPropertyPathPart) {
|
||||||
|
pathParts.unshift(parentPropertyPathPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = 'object' in parent ? parent.object : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Identifier': {
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
node,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a part of a symbol path corresponding to a MemberExpression node property.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartFromMemberProperty(
|
||||||
|
node: Identifier | Literal,
|
||||||
|
cursorOffset: number
|
||||||
|
): string {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Identifier': {
|
||||||
|
if (isDummy(node)) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNodeAtCursor(node, cursorOffset)
|
||||||
|
? node.name.slice(0, cursorOffset)
|
||||||
|
: node.name;
|
||||||
|
}
|
||||||
|
case 'Literal': {
|
||||||
|
if (isDummy(node) || !node.value) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
const value = String(node.value);
|
||||||
|
return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete literal value suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getLiteralValueSuggestionsForNode(
|
||||||
|
node: BinaryExpression,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteLiteralValueSuggestion> {
|
||||||
|
const { left, right } = node;
|
||||||
|
|
||||||
|
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right.type !== 'Identifier' && right.type !== 'Literal') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, {
|
||||||
|
withWildcardMatches: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLeftComputedMember = left.type === 'MemberExpression' && left.computed;
|
||||||
|
const leftSymbolInfo = context.getSymbolInfo(
|
||||||
|
isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leftSymbolInfo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (leftSymbolInfo.definition.type) {
|
||||||
|
case SymbolType.Boolean: {
|
||||||
|
const suggestions: DirectLiteralValueSuggestion[] = [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return isDummy(right)
|
||||||
|
? suggestions
|
||||||
|
: suggestions.filter((literalValue) => {
|
||||||
|
const literalValueString = String(literalValue.value.data);
|
||||||
|
const rightNodeValue =
|
||||||
|
right.type === 'Identifier' ? right.name : (right.raw ?? '');
|
||||||
|
return (
|
||||||
|
literalValueString !== rightNodeValue &&
|
||||||
|
literalValueString.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case SymbolType.String: {
|
||||||
|
if (!leftSymbolInfo.definition.enum) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightNodeValue = (() => {
|
||||||
|
if (right.type === 'Identifier') {
|
||||||
|
return right.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeValue = right.raw ?? '';
|
||||||
|
if (/^(['"])(.*)\1$/.test(nodeValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeValue.replaceAll(/["']/g, '');
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (rightNodeValue === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = isDummy(right)
|
||||||
|
? leftSymbolInfo.definition.enum
|
||||||
|
: leftSymbolInfo.definition.enum.filter((enumValue) =>
|
||||||
|
enumValue.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
return suggestions.map((value) => ({
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'direct',
|
||||||
|
type: SymbolType.String,
|
||||||
|
data: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.Array: {
|
||||||
|
// Only return a literal in array value suggestion when the left hand side of the binary expression
|
||||||
|
// is computed, e.g myArray[1]
|
||||||
|
return isLeftComputedMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: leftSymbolInfo.definition,
|
||||||
|
matchedLiteralString: !isDummy(right)
|
||||||
|
? right.type === 'Identifier'
|
||||||
|
? right.name
|
||||||
|
: (right.raw ?? '')
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
if (node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
|
||||||
|
if (parent?.type === 'BinaryExpression') {
|
||||||
|
return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map(
|
||||||
|
(op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw);
|
||||||
|
return this.getOperatorSuggestionsForSymbol(literalSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the node is an identifier look the parent to get more context for the suggestions
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset,
|
||||||
|
{
|
||||||
|
withWildcardMatches: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const symbolInfo = context.getSymbolInfo(pathParts);
|
||||||
|
return symbolInfo
|
||||||
|
? this.getOperatorSuggestionsForSymbol(symbolInfo.definition)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'BinaryExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Starting to write a binary/logical operator so suggest operator matching the already
|
||||||
|
// typed character as operator.
|
||||||
|
const operator = inferNode.operator;
|
||||||
|
return (
|
||||||
|
[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS]
|
||||||
|
.filter((op) => op.operator.startsWith(operator))
|
||||||
|
// No need to include the operator that match exactly
|
||||||
|
.filter((op) => op.operator !== operator)
|
||||||
|
.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions based on the type of a symbol.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForSymbol(
|
||||||
|
symbol: ExtractSymbolDef<SymbolType>
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.Number:
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
case SymbolType.Null:
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
case SymbolType.Object:
|
||||||
|
case SymbolType.Array: {
|
||||||
|
const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4);
|
||||||
|
const finalSuggestions =
|
||||||
|
symbol.type === SymbolType.Boolean
|
||||||
|
? [
|
||||||
|
...equalityOps,
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
]
|
||||||
|
: equalityOps;
|
||||||
|
return finalSuggestions.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.String:
|
||||||
|
return SUPPORTED_BINARY_OPERATORS.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
case SymbolType.Function:
|
||||||
|
return this.getOperatorSuggestionsForSymbol(symbol.returns);
|
||||||
|
case SymbolType.Union: {
|
||||||
|
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
|
||||||
|
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
|
||||||
|
return prev;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the parent of child node in the provided AST.
|
||||||
|
*/
|
||||||
|
function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined {
|
||||||
|
let foundParent: AnyNode | undefined;
|
||||||
|
|
||||||
|
walk.ancestor(ast, {
|
||||||
|
[child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => {
|
||||||
|
if (node.start === child.start && node.end === child.end) {
|
||||||
|
// The parent is the second last ancestor in the stack (last one is the actual node)
|
||||||
|
foundParent = ancestors[ancestors.length - 2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) {
|
||||||
|
return node.type === 'Identifier' || node.type === 'Literal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeAtCursor(node: AcornNode, cursorOffset: number) {
|
||||||
|
return cursorOffset >= node.start && cursorOffset <= node.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyNode(node: AcornNode): node is AnyNode {
|
||||||
|
return 'type' in node;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Position, Token } from 'acorn';
|
||||||
|
|
||||||
|
export class ExpressionError extends Error {
|
||||||
|
/**
|
||||||
|
* The location of the error in the parsed expression.
|
||||||
|
*/
|
||||||
|
public location?: Position | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expression token with the error
|
||||||
|
*/
|
||||||
|
public token?: Token;
|
||||||
|
|
||||||
|
constructor(message: string, loc?: Position | null, token?: Token) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, ExpressionError);
|
||||||
|
}
|
||||||
|
this.name = 'ExpressionError';
|
||||||
|
this.location = loc;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './errors';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './runtime';
|
||||||
|
export * from './symbols';
|
||||||
|
export * from './template';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import { filterOutNullable } from './utils';
|
||||||
|
|
||||||
|
type InputValuesType =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| { [key: string]: InputValuesType }
|
||||||
|
| InputValuesType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers a default inputValues object based on the JSON schema of an object.
|
||||||
|
*/
|
||||||
|
export function inferDefaultInputValuesFromObjectJSONSchema(
|
||||||
|
schema: JSONSchema7
|
||||||
|
): Record<string, InputValuesType> {
|
||||||
|
if (schema.type !== 'object' || !schema.properties) {
|
||||||
|
throw new Error(`Expected schema of object to be provided: ${schema.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, InputValuesType> = {};
|
||||||
|
|
||||||
|
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
||||||
|
if (typeof propertySchema === 'boolean') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = inferDefaultInputValueFromJSONSchema(propertySchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'object':
|
||||||
|
return inferDefaultInputValuesFromObjectJSONSchema(schema);
|
||||||
|
case 'array': {
|
||||||
|
if (schema.items && Array.isArray(schema.items)) {
|
||||||
|
return schema.items.map((itemSchema) => {
|
||||||
|
if (typeof itemSchema === 'boolean') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return inferDefaultInputValueFromJSONSchema(itemSchema);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null':
|
||||||
|
return inferDefaultInputValueFromPrimitive(schema);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return true;
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return 1234;
|
||||||
|
case 'string': {
|
||||||
|
const enumValues = schema.enum?.filter(filterOutNullable);
|
||||||
|
return enumValues?.[0] ?? 'default';
|
||||||
|
}
|
||||||
|
case 'null':
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import {
|
||||||
|
type Options as AcornOptions,
|
||||||
|
type Expression,
|
||||||
|
type ExpressionStatement,
|
||||||
|
type Position,
|
||||||
|
type Program,
|
||||||
|
type Token,
|
||||||
|
parse,
|
||||||
|
tokenizer,
|
||||||
|
} from 'acorn';
|
||||||
|
import { parse as parseLoose } from 'acorn-loose';
|
||||||
|
import { evaluate } from 'eval-estree-expression';
|
||||||
|
|
||||||
|
import { AutoComplete } from './autocomplete';
|
||||||
|
import { ExpressionError } from './errors';
|
||||||
|
import type { SymbolsTable } from './symbols';
|
||||||
|
import type { TemplatePart } from './template';
|
||||||
|
import { parseTemplate as parseTemplateParts } from './template';
|
||||||
|
import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types';
|
||||||
|
import { formatExpressionResult } from './utils';
|
||||||
|
|
||||||
|
export class ExpressionRuntime {
|
||||||
|
#parserOptions: AcornOptions;
|
||||||
|
#autocompleter: AutoComplete;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(logger: Logger = console) {
|
||||||
|
this.#parserOptions = {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'script',
|
||||||
|
allowHashBang: false,
|
||||||
|
locations: true,
|
||||||
|
};
|
||||||
|
this.#autocompleter = new AutoComplete(this, logger);
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression based on the given inputs/context.
|
||||||
|
*/
|
||||||
|
public evaluate(expr: string, inputs: object): unknown {
|
||||||
|
try {
|
||||||
|
const parsed = this.parse(expr);
|
||||||
|
|
||||||
|
if (parsed.invalidNodes.length > 0) {
|
||||||
|
throw new ExpressionError('Invalid nodes found when parsing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluate.sync<Expression>(parsed.result, inputs, {
|
||||||
|
functions: true,
|
||||||
|
withMembers: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression safely by returning the error instead of throwing when invalid.
|
||||||
|
*/
|
||||||
|
public safeEvaluate(
|
||||||
|
expr: string,
|
||||||
|
inputs: object
|
||||||
|
): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } {
|
||||||
|
try {
|
||||||
|
const value = this.evaluate(expr, inputs);
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error(`Error while evaluating expression ${expr}`, error);
|
||||||
|
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition safely to a boolean.
|
||||||
|
*/
|
||||||
|
public evaluateBoolean(expr: string, inputs: object): boolean {
|
||||||
|
if (expr.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = this.safeEvaluate(expr, inputs);
|
||||||
|
|
||||||
|
if (typeof evalResult.error !== 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(evalResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an array of conditions as a single logical expression.
|
||||||
|
* The function treats the conditions as if they were joined by an AND operator,
|
||||||
|
* meaning the evaluation returns `true` only if all conditions are truthy.
|
||||||
|
*/
|
||||||
|
public evaluateBooleanAll(expressions: string[], inputs: object): boolean {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressions.every((expression) => this.evaluateBoolean(expression, inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template and validate all embedded expressions.
|
||||||
|
*/
|
||||||
|
public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } {
|
||||||
|
const parts = parseTemplateParts(template);
|
||||||
|
const errors: ExpressionError[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'expression') {
|
||||||
|
try {
|
||||||
|
const { invalidNodes } = this.parse(part.value);
|
||||||
|
if (invalidNodes.length > 0) {
|
||||||
|
errors.push(new ExpressionError('Invalid expression'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error as ExpressionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parts, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
public evaluateTemplate(template: string, inputs: object): string {
|
||||||
|
const { parts } = this.parseTemplate(template);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return part.value;
|
||||||
|
}
|
||||||
|
const result = this.evaluate(part.value, inputs);
|
||||||
|
return formatExpressionResult(result, '');
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||||
|
*/
|
||||||
|
public parse(
|
||||||
|
expr: string,
|
||||||
|
options: { loose?: boolean } = {
|
||||||
|
loose: false,
|
||||||
|
}
|
||||||
|
): ExpressionParserResult {
|
||||||
|
try {
|
||||||
|
const ast = options.loose
|
||||||
|
? parseLoose(expr, { ...this.#parserOptions })
|
||||||
|
: parse(expr, { ...this.#parserOptions });
|
||||||
|
|
||||||
|
if (!ast.body || ast.body.length === 0) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first expression statement that we find
|
||||||
|
const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node));
|
||||||
|
const [statement] = ast.body.splice(firstExprIndex, 1);
|
||||||
|
|
||||||
|
if (!statement || !isParsedExpressionStatement(statement)) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return information on the other nodes as invalid nodes
|
||||||
|
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: statement.expression,
|
||||||
|
invalidNodes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw createExpressionErrorFromSyntaxError(expr, error);
|
||||||
|
}
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for the given expression at the provided cursor offset.
|
||||||
|
*/
|
||||||
|
public autocomplete(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): ExpressionAutocompleteResults {
|
||||||
|
const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context);
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(_node: Expression): string {
|
||||||
|
throw new Error('Not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpressionErrorFromSyntaxError(
|
||||||
|
code: string,
|
||||||
|
error: SyntaxError & { loc?: Position }
|
||||||
|
): ExpressionError {
|
||||||
|
const loc = error.loc;
|
||||||
|
|
||||||
|
if (!loc) {
|
||||||
|
return new ExpressionError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`;
|
||||||
|
const token = getTokenAtLoc(code, loc);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new ExpressionError(errorMessage, loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpressionError(errorMessage, loc, token);
|
||||||
|
}
|
||||||
|
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
|
||||||
|
const tokens = tokenizer(code, {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
locations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.loc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = token.loc;
|
||||||
|
|
||||||
|
const onSameLine = errorLoc.line === start.line;
|
||||||
|
const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column;
|
||||||
|
|
||||||
|
if (onSameLine && inColumnRange) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParsedExpressionStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return statement.type === 'ExpressionStatement';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutModuleDeclarationStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return ![
|
||||||
|
'ImportDeclaration',
|
||||||
|
'ExportNamedDeclaration',
|
||||||
|
'ExportDefaultDeclaration',
|
||||||
|
'ExportAllDeclaration',
|
||||||
|
].includes(statement.type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
|
||||||
|
import { SymbolsTable } from '../symbols-table';
|
||||||
|
import type { SymbolType } from '../types';
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const initialSymbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolString({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolString({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolString({ name: 'FLAG3' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
describe('addSymbols', () => {
|
||||||
|
it('should the symbols matching the provided object to the table', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
symbolsTable.addSymbols({
|
||||||
|
space: SymbolObject({
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'id',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'space',
|
||||||
|
childrenRefs: ['space.id', 'space.title'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Symbols standard library', () => {
|
||||||
|
it('should allow to access methods & properties defined as part of the standard library', () => {
|
||||||
|
const symbolsTable = new SymbolsTable({
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSymbolInfo', () => {
|
||||||
|
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1.length',
|
||||||
|
'visitor.claims.flags.FLAG1.at',
|
||||||
|
'visitor.claims.flags.FLAG1.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG1.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG2.length',
|
||||||
|
'visitor.claims.flags.FLAG2.at',
|
||||||
|
'visitor.claims.flags.FLAG2.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG2.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromValue', () => {
|
||||||
|
it('should infer properly a symbol based on a value', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromValue(
|
||||||
|
{
|
||||||
|
visitor: {
|
||||||
|
claims: {
|
||||||
|
key: 'test',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'testflag1',
|
||||||
|
FLAG2: 'testflag2',
|
||||||
|
FLAG3: 'testflag3',
|
||||||
|
},
|
||||||
|
hello: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'context'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'context',
|
||||||
|
properties: {
|
||||||
|
visitor: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromJSONSchema', () => {
|
||||||
|
it('should infer properly a symbol table based on a JSON schema', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'visitor'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './symbols';
|
||||||
|
export * from './symbols-table';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
|
||||||
|
import { filterOutNullable } from '../utils';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNull,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolUndefined,
|
||||||
|
} from './symbols';
|
||||||
|
import {
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolWithMethods,
|
||||||
|
type SymbolWithProperties,
|
||||||
|
resolveSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface SymbolInfo<T extends SymbolType = SymbolType> {
|
||||||
|
/**
|
||||||
|
* Definition of the symbol.
|
||||||
|
*/
|
||||||
|
definition: ExtractSymbolDef<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference of the symbol in the table of symbols.
|
||||||
|
*/
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the parent symbol.
|
||||||
|
*/
|
||||||
|
parentRef?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the children symbols.
|
||||||
|
*/
|
||||||
|
childrenRefs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, SymbolError);
|
||||||
|
}
|
||||||
|
this.name = 'SymbolError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolsTable {
|
||||||
|
/**
|
||||||
|
* Internal table that keeps track of all symbols reference.
|
||||||
|
*/
|
||||||
|
#table: Record<string, SymbolInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal table that keep track of the raw symbols definitions.
|
||||||
|
*/
|
||||||
|
#rawSymbols: Record<string, GenericSymbolDef>;
|
||||||
|
|
||||||
|
constructor(initialContext: Record<string, GenericSymbolDef> = {}) {
|
||||||
|
this.#table = {};
|
||||||
|
this.#rawSymbols = {};
|
||||||
|
this.addSymbols(initialContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new symbols table by merging the current one with the provided one.
|
||||||
|
*/
|
||||||
|
merge(other: SymbolsTable): SymbolsTable {
|
||||||
|
return new SymbolsTable({
|
||||||
|
...this.#rawSymbols,
|
||||||
|
...other.#rawSymbols,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return JSON.stringify(this.#table, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the symbol of a value and generate the appropriate symbol definition.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef<SymbolType> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return SymbolArray({ items: SymbolUndefined() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0));
|
||||||
|
// Check that the array is not a mixin of different items types.
|
||||||
|
if (value.length > 1) {
|
||||||
|
const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1));
|
||||||
|
if (firstItemSymbol.type !== secondItemSymbol.type) {
|
||||||
|
throw new SymbolError('Array with mixin items types are not supported');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SymbolArray({ name, items: firstItemSymbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
return SymbolUndefined({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return SymbolNull({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value;
|
||||||
|
switch (valueType) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({ name });
|
||||||
|
case 'number':
|
||||||
|
return SymbolNumber({ name });
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({ name });
|
||||||
|
case 'object': {
|
||||||
|
const properties = Object.entries(value).reduce<Record<string, GenericSymbolDef>>(
|
||||||
|
(prev, [name, val]) => {
|
||||||
|
prev[name] = SymbolsTable.inferSymbolFromValue(val, name);
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return SymbolObject({ name, properties, methods: [] });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new SymbolError(`Unsupported symbol type ${valueType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a table of symbol based on a JSON schema object describing it.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromJSONSchema(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType> {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
...(schema.enum
|
||||||
|
? {
|
||||||
|
enum: schema.enum
|
||||||
|
.filter(filterOutNullable)
|
||||||
|
.map((enumValue) => enumValue.toString()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return SymbolNumber({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'null':
|
||||||
|
return SymbolNull({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'object':
|
||||||
|
return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name);
|
||||||
|
case 'array':
|
||||||
|
return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildObjectSymbolFromJSONSchemaObject(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ObjectSymbolDef {
|
||||||
|
const properties: Record<string, GenericSymbolDef> = {};
|
||||||
|
|
||||||
|
if (schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
|
||||||
|
properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
propertySchema as JSONSchema7,
|
||||||
|
propertyName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolObject({
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildArraySymbolFromJSONSchemaArray(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType.Array> {
|
||||||
|
if (schema.items) {
|
||||||
|
const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
schema.items as JSONSchema7,
|
||||||
|
`${name || ''}_item`
|
||||||
|
);
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: itemSymbol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: SymbolUndefined(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSymbolRefPath(path: string[]): string {
|
||||||
|
return path.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single symbol to the table at the provided path.
|
||||||
|
*/
|
||||||
|
private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void {
|
||||||
|
const fullPath = this.generateSymbolRefPath(path);
|
||||||
|
const parentPath = path.slice(0, -1).join('.');
|
||||||
|
|
||||||
|
if (this.#table[fullPath]) {
|
||||||
|
throw new SymbolError(`Symbol "${fullPath}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
this.#rawSymbols[fullPath] = definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new symbol linking it to its parent
|
||||||
|
this.#table[fullPath] = {
|
||||||
|
definition: resolveSymbolDef(definition),
|
||||||
|
ref: fullPath,
|
||||||
|
parentRef: parentPath || undefined,
|
||||||
|
childrenRefs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentPath && this.#table[parentPath]) {
|
||||||
|
if (this.#table[parentPath].childrenRefs) {
|
||||||
|
this.#table[parentPath].childrenRefs.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any nested symbols if the value is a symbol with properties...
|
||||||
|
if (isSymbolWithProperties(definition)) {
|
||||||
|
Object.entries(definition.properties).forEach(([propKey, propSymbol]) => {
|
||||||
|
if (isObjectSymbol(propSymbol)) {
|
||||||
|
this.addSymbols({ [propKey]: propSymbol }, path, false);
|
||||||
|
} else {
|
||||||
|
this.addSymbol([...path, propKey], propSymbol, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...or a symbol with methods
|
||||||
|
if (isSymbolWithMethods(definition)) {
|
||||||
|
definition.methods.forEach((methodSymbol) => {
|
||||||
|
this.addSymbol([...path, methodSymbol.name], methodSymbol, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the provided object of symbols definitions to the symbol table.
|
||||||
|
*/
|
||||||
|
public addSymbols(
|
||||||
|
symbols: Record<string, GenericSymbolDef>,
|
||||||
|
prefix: string[] = [],
|
||||||
|
raw = true
|
||||||
|
): void {
|
||||||
|
for (const [key, symbolDef] of Object.entries(symbols)) {
|
||||||
|
const path = [...prefix, key];
|
||||||
|
|
||||||
|
// Add the current symbol to the table.
|
||||||
|
this.addSymbol(path, symbolDef, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a symbol's information using its path in the table.
|
||||||
|
*/
|
||||||
|
public getSymbolInfo<T extends SymbolType>(path: string | string[]): SymbolInfo<T> | undefined {
|
||||||
|
const key = Array.isArray(path) ? path.join('.') : path;
|
||||||
|
const info = this.#table[key];
|
||||||
|
return info ? typedSymbolInfo(info) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all symbol keys matching the pattern defined by the provided path.
|
||||||
|
*/
|
||||||
|
public getMatchingSymbolsKeys(path: string[]): string[] {
|
||||||
|
const wildcardRegex = new RegExp(
|
||||||
|
`^${path
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.includes('*')) {
|
||||||
|
return `${segment.split('*')[0]}([^.]+)?`;
|
||||||
|
}
|
||||||
|
return segment;
|
||||||
|
})
|
||||||
|
.join('\\.')}$`
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(this.#table)
|
||||||
|
.filter((key) => wildcardRegex.test(key))
|
||||||
|
.filter(filterOutNullable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef {
|
||||||
|
return symbol.type === SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typedSymbolInfo<T extends SymbolType>(info: SymbolInfo): SymbolInfo<T> {
|
||||||
|
const definition = resolveSymbolDef(info.definition);
|
||||||
|
return { ...info, definition } as SymbolInfo<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
type ArraySymbolDef,
|
||||||
|
type BooleanSymbolDef,
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type FunctionSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type NullSymbolDef,
|
||||||
|
type NumberSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
type StringSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolsWithPropertiesAndMethods,
|
||||||
|
type UndefinedSymbolDef,
|
||||||
|
type UnionSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Boolean,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Number,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolString(
|
||||||
|
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
|
||||||
|
): StringSymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Object,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolArray(
|
||||||
|
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
|
||||||
|
): ArraySymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Function,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionalFunctionArg(
|
||||||
|
optionalArg: ExtractSymbolDef<SymbolType>
|
||||||
|
): ExtractSymbolDef<SymbolType> & { optional: true } {
|
||||||
|
return {
|
||||||
|
...optionalArg,
|
||||||
|
optional: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Union,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Undefined,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Null,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSymbolWithPropertiesAndMethods<
|
||||||
|
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
|
||||||
|
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
|
||||||
|
const symbol = { type, ...args } as T;
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'properties', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).properties
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.properties || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'methods', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).methods
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.methods || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
|
||||||
|
|
||||||
|
const StandardLibrary: Partial<
|
||||||
|
{
|
||||||
|
[key in Exclude<SymbolType, SymbolType.Array>]: {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
} & {
|
||||||
|
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[SymbolType.String]: {
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the string.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: 'The index (position) of the string character to be returned',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `A String consisting of the single UTF-16 code unit located at the specified position.
|
||||||
|
Returns undefined if the given index can not be found.`,
|
||||||
|
members: [SymbolString(), SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'endsWith',
|
||||||
|
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
|
||||||
|
elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `The characters to be searched for at the end of str. Cannot be a regex.
|
||||||
|
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
|
||||||
|
the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'endPosition',
|
||||||
|
description: `The end position at which searchString is expected to be found
|
||||||
|
(the index of searchString's last character plus 1). Defaults to str.length.`,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
|
||||||
|
that are greater than or equal to position; otherwise, returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
|
||||||
|
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'position',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: `Zero-based index of the array element to be returned, converted to an integer.
|
||||||
|
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
|
||||||
|
without attempting to access the corresponding property.`,
|
||||||
|
members: [arraySymbolDef.items, SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description:
|
||||||
|
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'searchElement',
|
||||||
|
description: 'The value to be searched for within the array.',
|
||||||
|
},
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'fromIndex',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
|
||||||
|
return symbol.type === SymbolType.Array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { MandateProps } from '../utils';
|
||||||
|
|
||||||
|
export enum SymbolType {
|
||||||
|
Boolean = 'boolean',
|
||||||
|
Number = 'number',
|
||||||
|
String = 'string',
|
||||||
|
Object = 'object',
|
||||||
|
Array = 'array',
|
||||||
|
Function = 'function',
|
||||||
|
Union = 'union',
|
||||||
|
Undefined = 'undefined',
|
||||||
|
Null = 'null',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Long description of the symbol.
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to a documentation/manual page.
|
||||||
|
*/
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSymbolDef extends SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Type of the symbol.
|
||||||
|
*/
|
||||||
|
type: SymbolType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the symbol.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithProperties extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Properties on the symbol type.
|
||||||
|
*/
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithMethods extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Methods that can be called on the symbol type.
|
||||||
|
*/
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.String;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of enumerated values that the string symbol is retristred to.
|
||||||
|
*/
|
||||||
|
enum?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on strings.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol representing the type of the items of the array
|
||||||
|
*/
|
||||||
|
items: ExtractSymbolDef<SymbolType>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on arrays.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionSymbolDef extends MandateProps<GenericSymbolDef, 'name'> {
|
||||||
|
type: SymbolType.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols describing the arguments of the function.
|
||||||
|
*/
|
||||||
|
args: (ExtractSymbolDef<SymbolType> & { optional?: boolean })[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol describing the returned value of the function.
|
||||||
|
*/
|
||||||
|
returns: ExtractSymbolDef<SymbolType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnionSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Union;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols composing the union.
|
||||||
|
*/
|
||||||
|
members: ExtractSymbolDef<SymbolType>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndefinedSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NullSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef;
|
||||||
|
|
||||||
|
export type ExtractSymbolDef<T extends SymbolType> = T extends SymbolType.String
|
||||||
|
? StringSymbolDef
|
||||||
|
: T extends SymbolType.Number
|
||||||
|
? NumberSymbolDef
|
||||||
|
: T extends SymbolType.Boolean
|
||||||
|
? BooleanSymbolDef
|
||||||
|
: T extends SymbolType.Array
|
||||||
|
? ArraySymbolDef
|
||||||
|
: T extends SymbolType.Object
|
||||||
|
? ObjectSymbolDef
|
||||||
|
: T extends SymbolType.Function
|
||||||
|
? FunctionSymbolDef
|
||||||
|
: T extends SymbolType.Union
|
||||||
|
? UnionSymbolDef
|
||||||
|
: T extends SymbolType.Undefined
|
||||||
|
? UndefinedSymbolDef
|
||||||
|
: T extends SymbolType.Null
|
||||||
|
? NullSymbolDef
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export function resolveSymbolDef(
|
||||||
|
symbol: GenericSymbolDef
|
||||||
|
):
|
||||||
|
| StringSymbolDef
|
||||||
|
| NumberSymbolDef
|
||||||
|
| BooleanSymbolDef
|
||||||
|
| ArraySymbolDef
|
||||||
|
| ObjectSymbolDef
|
||||||
|
| FunctionSymbolDef
|
||||||
|
| UnionSymbolDef
|
||||||
|
| UndefinedSymbolDef
|
||||||
|
| NullSymbolDef {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.String:
|
||||||
|
return symbol as StringSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Number:
|
||||||
|
return symbol as NumberSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
return symbol as BooleanSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Array:
|
||||||
|
return symbol as ArraySymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Object:
|
||||||
|
return symbol as ObjectSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Function:
|
||||||
|
return symbol as FunctionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Union:
|
||||||
|
return symbol as UnionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
return symbol as UndefinedSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Null:
|
||||||
|
return symbol as NullSymbolDef;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown symbol type: ${symbol.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type TemplateText = {
|
||||||
|
type: 'text';
|
||||||
|
value: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateExpression = {
|
||||||
|
type: 'expression';
|
||||||
|
value: string;
|
||||||
|
start: number; // Start index of the expression content (after `{{`)
|
||||||
|
end: number; // End index of the expression content (before `}}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplatePart = TemplateText | TemplateExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
export function parseTemplate(template: string): TemplatePart[] {
|
||||||
|
const parts: TemplatePart[] = [];
|
||||||
|
const regex = /\{\{(.*?)\}\}/gs;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of template.matchAll(regex)) {
|
||||||
|
const matchStart = match.index ?? 0;
|
||||||
|
const matchEnd = matchStart + match[0].length;
|
||||||
|
|
||||||
|
if (matchStart > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex, matchStart),
|
||||||
|
start: lastIndex,
|
||||||
|
end: matchStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
type: 'expression',
|
||||||
|
value: (match[1] ?? '').trim(),
|
||||||
|
start: matchStart + 2,
|
||||||
|
end: matchEnd - 2,
|
||||||
|
});
|
||||||
|
lastIndex = matchEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < template.length) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex),
|
||||||
|
start: lastIndex,
|
||||||
|
end: template.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { BinaryOperator, Expression, ExpressionStatement, LogicalOperator } from 'acorn';
|
||||||
|
|
||||||
|
import type { ArraySymbolDef, SymbolInfo, SymbolType } from './symbols';
|
||||||
|
|
||||||
|
export interface ExpressionGenerator {
|
||||||
|
/**
|
||||||
|
* Converts an ESTree compatible AST node into a string representing the corresponding expression.
|
||||||
|
*/
|
||||||
|
generate(node: Expression): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionParserResult {
|
||||||
|
/**
|
||||||
|
* The expression statement from the valid portion of the parsed expression.
|
||||||
|
*
|
||||||
|
* It is undefined when no valid expression statements could be found.
|
||||||
|
*/
|
||||||
|
result: Expression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
|
||||||
|
*/
|
||||||
|
invalidNodes: Array<ExpressionStatement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionAutocompleteResults {
|
||||||
|
suggestions: AutocompleteSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionalOperator = '?';
|
||||||
|
|
||||||
|
export const SUPPORTED_BINARY_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '==',
|
||||||
|
description: 'Checks whether two values are equal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!=',
|
||||||
|
description: 'Checks whether two values are unequal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '===',
|
||||||
|
description: 'Checks whether two values are equal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!==',
|
||||||
|
description: 'Checks whether two values are unequal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<',
|
||||||
|
description: 'Checks if the left value is less than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<=',
|
||||||
|
description: 'Checks if the left value is less than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>',
|
||||||
|
description: 'Checks if the left value is greater than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>=',
|
||||||
|
description: 'Checks if the left value is greater than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: 'in',
|
||||||
|
description: 'Checks if a property exists in an object or if a value is in an array.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_LOGICAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '&&',
|
||||||
|
description: 'Logical AND operator; returns true if both operands are true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '||',
|
||||||
|
description: 'Logical OR operator; returns true if at least one operand is true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_CONDITIONAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '?',
|
||||||
|
description:
|
||||||
|
'Conditional (ternary) operator; returns one of two values based on a condition.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type DirectLiteralValue = {
|
||||||
|
kind: 'direct';
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
data: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
data: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.String;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type InArrayLiteralValue = {
|
||||||
|
kind: 'in-array';
|
||||||
|
srcSymbol: ArraySymbolDef;
|
||||||
|
matchedLiteralString: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirectLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: DirectLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InArrayLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: InArrayLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutocompleteLiteralValueSuggestion =
|
||||||
|
| DirectLiteralValueSuggestion
|
||||||
|
| InArrayLiteralValueSuggestion;
|
||||||
|
|
||||||
|
export interface AutocompleteOperatorSuggestion {
|
||||||
|
type: 'operator';
|
||||||
|
operator:
|
||||||
|
| Extract<BinaryOperator, (typeof SUPPORTED_BINARY_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<LogicalOperator, (typeof SUPPORTED_LOGICAL_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<
|
||||||
|
ConditionalOperator,
|
||||||
|
(typeof SUPPORTED_CONDITIONAL_OPERATORS)[number]['operator']
|
||||||
|
>;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteSymbolSuggestion {
|
||||||
|
type: 'symbol';
|
||||||
|
symbol: SymbolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteSuggestions = Array<
|
||||||
|
| AutocompleteSymbolSuggestion
|
||||||
|
| AutocompleteLiteralValueSuggestion
|
||||||
|
| AutocompleteOperatorSuggestion
|
||||||
|
>;
|
||||||
|
|
||||||
|
type LoggerFn = (message: string, ...args: any[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A logger that can be passed to the runtime.
|
||||||
|
*/
|
||||||
|
export interface Logger {
|
||||||
|
debug: LoggerFn;
|
||||||
|
info: LoggerFn;
|
||||||
|
error: LoggerFn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Format the result value of an expression for display as a string.
|
||||||
|
*/
|
||||||
|
export function formatExpressionResult(value: any, defaultValue = ''): string {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter function to exclude `null` values
|
||||||
|
*/
|
||||||
|
export function filterOutNullable<T>(value: T): value is NonNullable<T> {
|
||||||
|
return !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type to make optional properties on a object mandatory.
|
||||||
|
*
|
||||||
|
* interface SomeObject {
|
||||||
|
* uid: string;
|
||||||
|
* price: number | null;
|
||||||
|
* location?: string;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* type ValuableObject = MandateProps<SomeObject, 'price' | 'location'>;
|
||||||
|
*/
|
||||||
|
export type MandateProps<T extends {}, K extends keyof T> = T & {
|
||||||
|
[MK in K]-?: NonNullable<T[MK]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["./tsconfig.json"],
|
||||||
|
"exclude": ["**/*.test.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"incremental": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["types/**/*.d.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
declare module 'eval-estree-expression' {
|
||||||
|
/**
|
||||||
|
* Options for evaluation and compilation.
|
||||||
|
*/
|
||||||
|
export interface EvalESTreeExpressionOptions {
|
||||||
|
/**
|
||||||
|
* Force logical operators to return a boolean result. Default: undefined
|
||||||
|
*/
|
||||||
|
booleanLogicalOperators?: boolean;
|
||||||
|
/**
|
||||||
|
* Allow function calls to be evaluated. This is unsafe, please enable this option at your own risk. Default: false
|
||||||
|
*/
|
||||||
|
functions?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
|
||||||
|
*/
|
||||||
|
generate?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
|
||||||
|
*/
|
||||||
|
regexOperator?: boolean;
|
||||||
|
/**
|
||||||
|
* Throw an error when variables are undefined. Default: false
|
||||||
|
*/
|
||||||
|
strict?: boolean;
|
||||||
|
/**
|
||||||
|
* Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined
|
||||||
|
*/
|
||||||
|
withMembers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression asynchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns A promise resolving to the result of the evaluation.
|
||||||
|
*/
|
||||||
|
export function evaluate<ASTNode>(
|
||||||
|
ast: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression synchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns The result of the evaluation.
|
||||||
|
*/
|
||||||
|
export namespace evaluate {
|
||||||
|
function sync<ASTNode>(
|
||||||
|
expression: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): any;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
src/data/*.json
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @gitbook/fonts
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- fbfcca5: Initial version of the package
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/fonts`
|
||||||
|
|
||||||
|
Utilities to lookup default fonts supported by GitBook.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { APIv2 } from 'google-font-metadata';
|
||||||
|
|
||||||
|
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
|
||||||
|
import type { FontDefinitions } from '../src/types';
|
||||||
|
|
||||||
|
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
|
||||||
|
[CustomizationDefaultFont.Inter]: 'inter',
|
||||||
|
[CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed',
|
||||||
|
[CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif',
|
||||||
|
[CustomizationDefaultFont.Lato]: 'lato',
|
||||||
|
[CustomizationDefaultFont.Merriweather]: 'merriweather',
|
||||||
|
[CustomizationDefaultFont.NotoSans]: 'noto-sans',
|
||||||
|
[CustomizationDefaultFont.OpenSans]: 'open-sans',
|
||||||
|
[CustomizationDefaultFont.Overpass]: 'overpass',
|
||||||
|
[CustomizationDefaultFont.Poppins]: 'poppins',
|
||||||
|
[CustomizationDefaultFont.Raleway]: 'raleway',
|
||||||
|
[CustomizationDefaultFont.Roboto]: 'roboto',
|
||||||
|
[CustomizationDefaultFont.RobotoSlab]: 'roboto-slab',
|
||||||
|
[CustomizationDefaultFont.SourceSansPro]: 'source-sans-3',
|
||||||
|
[CustomizationDefaultFont.Ubuntu]: 'ubuntu',
|
||||||
|
[CustomizationDefaultFont.ABCFavorit]: 'inter',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scripts to generate the list of all icons.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
// @ts-expect-error - we build the object
|
||||||
|
const output: FontDefinitions = {};
|
||||||
|
|
||||||
|
for (const font of Object.values(CustomizationDefaultFont)) {
|
||||||
|
const googleFontName = googleFontsMap[font];
|
||||||
|
const fontMetadata = APIv2[googleFontName.toLowerCase()];
|
||||||
|
if (!fontMetadata) {
|
||||||
|
throw new Error(`Font ${googleFontName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font] = {
|
||||||
|
font: googleFontName,
|
||||||
|
unicodeRange: fontMetadata.unicodeRange,
|
||||||
|
variants: {
|
||||||
|
'400': {},
|
||||||
|
'700': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(output[font].variants).forEach((weight) => {
|
||||||
|
const variants = fontMetadata.variants[weight];
|
||||||
|
const normalVariant = variants.normal;
|
||||||
|
if (!normalVariant) {
|
||||||
|
throw new Error(`Font ${googleFontName} has no normal variant`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font].variants[weight] = {};
|
||||||
|
Object.entries(normalVariant).forEach(([script, url]) => {
|
||||||
|
output[font].variants[weight][script] = url.url.woff;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeDataFile('fonts', JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We write both in dist and src as the build process might have happen already
|
||||||
|
* and tsc doesn't copy the files.
|
||||||
|
*/
|
||||||
|
async function writeDataFile(name, content) {
|
||||||
|
const srcData = path.resolve(__dirname, '../src/data');
|
||||||
|
const distData = path.resolve(__dirname, '../dist/data');
|
||||||
|
|
||||||
|
// Ensure the directories exists
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(srcData, { recursive: true }),
|
||||||
|
fs.mkdir(distData, { recursive: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
|
||||||
|
fs.writeFile(path.resolve(distData, `${name}.json`), content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`Error generating icons list: ${error}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/fonts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"google-font-metadata": "^6.0.3",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "bun ./bin/generate.js",
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||||
|
"unit": "bun test"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Bun Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle mixed script text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Roboto",
|
||||||
|
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { FontDefinitions } from './types';
|
||||||
|
|
||||||
|
import rawFonts from './data/fonts.json' with { type: 'json' };
|
||||||
|
|
||||||
|
export const fonts: FontDefinitions = rawFonts;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
import { getDefaultFont } from './getDefaultFont';
|
||||||
|
|
||||||
|
describe('getDefaultFont', () => {
|
||||||
|
it('should return null for invalid font', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: 'invalid-font' as CustomizationDefaultFont,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid weight', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 999 as any,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for text not supported by any script', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: '😀', // Emoji not supported by Inter
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Latin text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello World',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Cyrillic text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Привет мир',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Greek text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Γεια σας',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed script text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello Привет',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different font weights', () => {
|
||||||
|
const regular = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
const bold = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 700,
|
||||||
|
});
|
||||||
|
expect(regular).not.toBeNull();
|
||||||
|
expect(bold).not.toBeNull();
|
||||||
|
expect(regular).toMatchSnapshot('regular');
|
||||||
|
expect(bold).toMatchSnapshot('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: '',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different fonts', () => {
|
||||||
|
const inter = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
const roboto = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Roboto,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(inter).not.toBeNull();
|
||||||
|
expect(roboto).not.toBeNull();
|
||||||
|
expect(inter).toMatchSnapshot('inter');
|
||||||
|
expect(roboto).toMatchSnapshot('roboto');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
import { fonts } from './fonts';
|
||||||
|
import type { FontWeight } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL to load a font for a text.
|
||||||
|
*/
|
||||||
|
export function getDefaultFont(input: {
|
||||||
|
/**
|
||||||
|
* GitBook font to use.
|
||||||
|
*/
|
||||||
|
font: CustomizationDefaultFont;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text to display with the font.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weight to use.
|
||||||
|
*/
|
||||||
|
weight: FontWeight;
|
||||||
|
}): { font: string; url: string } | null {
|
||||||
|
if (!input.text.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontDefinition = fonts[input.font];
|
||||||
|
if (!fontDefinition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = fontDefinition.variants[`${input.weight}`];
|
||||||
|
if (!variant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = getBestUnicodeRange(input.text, fontDefinition.unicodeRange);
|
||||||
|
if (!script) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant[script]
|
||||||
|
? {
|
||||||
|
font: input.font,
|
||||||
|
url: variant[script],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which named @font-face unicode-range covers
|
||||||
|
* the greatest share of the characters in `text`.
|
||||||
|
*
|
||||||
|
* @param text The text you want to inspect.
|
||||||
|
* @param ranges An object whose keys are range names and whose
|
||||||
|
* values are CSS-style comma-separated unicode-range
|
||||||
|
* declarations (e.g. "U+0370-03FF,U+1F00-1FFF").
|
||||||
|
* @returns The key of the best-matching range, or `null`
|
||||||
|
* when nothing matches at all.
|
||||||
|
*/
|
||||||
|
function getBestUnicodeRange(text: string, ranges: Record<string, string>): string | null {
|
||||||
|
// ---------- helper: parse "U+XXXX" or "U+XXXX-YYYY" ----------
|
||||||
|
const parseOne = (token: string): [number, number] | null => {
|
||||||
|
token = token.trim().toUpperCase();
|
||||||
|
if (!token.startsWith('U+')) return null;
|
||||||
|
|
||||||
|
const body = token.slice(2); // drop "U+"
|
||||||
|
const [startHex, endHex] = body.split('-');
|
||||||
|
const start = Number.parseInt(startHex, 16);
|
||||||
|
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
||||||
|
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- helper: build lookup table ----------
|
||||||
|
const parsed: Record<string, [number, number][]> = {};
|
||||||
|
for (const [label, list] of Object.entries(ranges)) {
|
||||||
|
parsed[label] = list
|
||||||
|
.split(',')
|
||||||
|
.map(parseOne)
|
||||||
|
.filter((x): x is [number, number] => x !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tally code-point hits ----------
|
||||||
|
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; ) {
|
||||||
|
const cp = text.codePointAt(i)!;
|
||||||
|
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
|
||||||
|
|
||||||
|
for (const [label, rangesArr] of Object.entries(parsed)) {
|
||||||
|
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
||||||
|
hits[label]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- choose the "best" ----------
|
||||||
|
let winner: string | null = null;
|
||||||
|
let maxCount = 0;
|
||||||
|
|
||||||
|
for (const [label, count] of Object.entries(hits)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
winner = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxCount > 0 ? winner : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './getDefaultFont';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
|
||||||
|
export type FontWeight = 400 | 700;
|
||||||
|
|
||||||
|
export type FontDefinition = {
|
||||||
|
font: string;
|
||||||
|
unicodeRange: {
|
||||||
|
[script: string]: string;
|
||||||
|
};
|
||||||
|
variants: {
|
||||||
|
[weight in string]: {
|
||||||
|
[script: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FontDefinitions = { [fontName in CustomizationDefaultFont]: FontDefinition };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "es2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"generate": {
|
||||||
|
"inputs": ["bin/**/*", "package.json"],
|
||||||
|
"outputs": ["src/data/*.json", "dist/data/*.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# cloudflare
|
|
||||||
.open-next
|
|
||||||
|
|
||||||
# Symbolic links
|
|
||||||
public
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# gitbook-v2
|
|
||||||
|
|
||||||
## 0.2.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 4234289: Fix incoming URL for requests that were proxied
|
|
||||||
- Updated dependencies [116575c]
|
|
||||||
- @gitbook/cache-tags@0.3.0
|
|
||||||
|
|
||||||
## 0.2.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
|
|
||||||
|
|
||||||
## 0.2.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 54ee014: Add initial support for loading custom fonts
|
|
||||||
- bba2e52: Fix site redirects when it includes a section/variant path
|
|
||||||
|
|
||||||
## 0.2.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [f32bf1f]
|
|
||||||
- @gitbook/cache-tags@0.2.0
|
|
||||||
|
|
||||||
## 0.2.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 76c7974: Add route to revalidate cached data
|
|
||||||
|
|
||||||
## 0.1.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 05ffd0e: Improving data cache management for computed content
|
|
||||||
- Updated dependencies [05ffd0e]
|
|
||||||
- @gitbook/cache-tags@0.1.0
|
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 3e11678: fix: lost section groups
|
|
||||||
|
|
||||||
## 0.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- cfccc44: Setup structure and deployment for new version
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
|
||||||
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
|
|
||||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
|
||||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
|
||||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
|
|
||||||
tagCache: doShardedTagCache({
|
|
||||||
baseShardSize: 12,
|
|
||||||
regionalCache: true,
|
|
||||||
shardReplication: {
|
|
||||||
numberOfSoftReplicas: 2,
|
|
||||||
numberOfHardReplicas: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
queue: doQueue,
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "gitbook-v2",
|
|
||||||
"version": "0.2.4",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"next": "canary",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"@gitbook/api": "*",
|
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"warn-once": "^0.1.1",
|
|
||||||
"rison": "^0.1.1",
|
|
||||||
"jwt-decode": "^4.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"gitbook": "*",
|
|
||||||
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305",
|
|
||||||
"@types/rison": "^0.0.9",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
|
|
||||||
"dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack",
|
|
||||||
"build": "next build",
|
|
||||||
"build:v2": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
|
||||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
|
||||||
"unit": "bun test",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
|
|
||||||
export default RootLayout;
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
|
|
||||||
|
|
||||||
export default PDFPage;
|
|
||||||
export { generateMetadata };
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
|
|
||||||
export default RootLayout;
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
|
|
||||||
|
|
||||||
export default PDFPage;
|
|
||||||
export { generateMetadata };
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user