mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 06:09:21 +00:00
Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a878dd451 | |||
| c08d05ade5 | |||
| b9453a92f8 | |||
| 2c4d40ad97 | |||
| 688515efcf | |||
| 931cbe717e | |||
| 3ef1802e7e | |||
| 8a0e0df84e | |||
| a9041a309a | |||
| 625c108196 | |||
| 8e87856501 | |||
| 9b822abbb6 | |||
| 81dba6455b | |||
| 1faa57b812 | |||
| 40150f0a6c | |||
| bf29570bd5 | |||
| e8e979b6c0 | |||
| 1f250d1fa8 | |||
| 46b3a18b25 | |||
| 2ff77e15b5 | |||
| d7c867aa63 | |||
| bcd1c41aa1 | |||
| 77058e3f17 | |||
| 8ad7465801 | |||
| 1b7ac0e41b | |||
| 5f88f4e680 | |||
| 7009bb9311 | |||
| e460ec381e | |||
| 0a82628298 | |||
| 18ff11b68b | |||
| 3bf55cef10 | |||
| e041e49f11 | |||
| c1b5be05eb | |||
| 7543eb83ad | |||
| 523b7cd4b6 | |||
| af698a49f5 | |||
| db67585ee2 | |||
| a566153d98 | |||
| 530f98a9c4 | |||
| 39391259c8 | |||
| 35be3835f3 | |||
| 4452fd793c | |||
| 90566879d2 | |||
| 40a879ad5f | |||
| cc441c99c6 | |||
| 64e143a15b | |||
| 8c10d92f77 | |||
| 0a22eb5340 | |||
| d9fc4608e3 | |||
| ded2f560e3 | |||
| a9c5d1b546 | |||
| 0ff21b7ae2 | |||
| 94a496c561 | |||
| 148a43ac01 | |||
| 5da854f09c | |||
| b772f74c2b | |||
| e0bd04f641 | |||
| 8d7c3edda8 | |||
| 80951e9889 | |||
| 185dd8337e | |||
| da7fb13d83 | |||
| 49c993f181 | |||
| 90b54682e5 | |||
| 1dccf8f06c | |||
| 810244e8a9 | |||
| 0dd2f4fbcd | |||
| 8a700222e6 | |||
| b13fd91afc | |||
| 8a6baaba6a | |||
| 48fba7cb49 | |||
| 6928a9b00a | |||
| 7bab574c63 | |||
| 75bdff3ff3 | |||
| 56c25587db | |||
| cf4efc7213 | |||
| 1ef71609e7 | |||
| 195c9e6b84 | |||
| 09f39f8300 | |||
| 9002f6598a | |||
| 01c9b059c6 | |||
| 6d02b8ab72 | |||
| 177ef8582a | |||
| 78c589ffba | |||
| e4b214e6bd | |||
| 64ce9e180d | |||
| c64d3a50e8 | |||
| fd070ce9ca | |||
| 048c4e4c70 | |||
| fb01dc9ecb | |||
| d33e570bc7 | |||
| 87fd234d55 | |||
| 0f32eb17d1 | |||
| 3e29680792 | |||
| b3db1c58c8 | |||
| 0f994016f4 | |||
| 419094b79d | |||
| 95d23775ce | |||
| aa98859560 | |||
| ea19801cfa | |||
| 13059b843c | |||
| 3db14ad950 | |||
| 5fac9e37ee | |||
| 1424c566a0 | |||
| 65f99eafe5 | |||
| 7594a334d8 | |||
| 827e4f9856 | |||
| a9a1a609c5 | |||
| 61405a3036 | |||
| 634a24ff03 | |||
| 2ccd43ee5e | |||
| cd506e3f79 | |||
| a6644074cb | |||
| 4be043d96e | |||
| 673f4b6076 | |||
| d0a63bab91 | |||
| ca9a1dd396 | |||
| 996d7ec021 | |||
| f9ad9b5356 | |||
| 332089eca9 | |||
| 6ac4cf4b76 | |||
| 5c74eef602 | |||
| 1ee1853995 | |||
| fa9c9b38b7 | |||
| b4d9c6b95e | |||
| 588964279e | |||
| a80d41200a | |||
| ccb9d7bda4 | |||
| 14562e9e5b | |||
| 580d186ddd | |||
| 038008c853 | |||
| 8c890522ed | |||
| e9558a6df7 | |||
| b5f3c1416a | |||
| b9d383bed3 | |||
| b6e7f2d2db | |||
| 94ef1769ec | |||
| ae9367dafe | |||
| cef18701be | |||
| bf674a47d9 | |||
| 1eb763f9c5 | |||
| db176ba0ea | |||
| 03bbacf319 | |||
| 484cc11627 | |||
| 0dee4155a2 | |||
| 4d7c01587e | |||
| 6083a88845 | |||
| bf6a7af72b | |||
| 703e654a37 | |||
| 1b571aeaa9 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/colors": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add cover image background mode and masks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask <assistant> about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Hide unfocusable unlabelled button from screen readers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
a11y screen reader fixes
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix an issue where certain keywords could cause an exception when rendering emojis
|
||||
@@ -0,0 +1,10 @@
|
||||
# Stop editors from looking for .editorconfig files in parent directories.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
@@ -1,3 +0,0 @@
|
||||
# Changes to the API data cache functions can invalidate all existing data cache
|
||||
# causing a massive amount of revalidation, impacting our API.
|
||||
packages/gitbook/src/lib/data/api.ts @SamyPesse
|
||||
@@ -76,7 +76,10 @@ bun dev
|
||||
```
|
||||
|
||||
Additional development commands:
|
||||
- `bun format`: Format the code using Biome
|
||||
- `bun lint`: Lint the code using Oxlint
|
||||
- `bun lint:fix`: Automatically fix lint issues using Oxlint
|
||||
- `bun format`: Format the code using Oxfmt
|
||||
- `bun format:check`: Check formatting without changing files
|
||||
- `bun typecheck`: Run TypeScript type checking
|
||||
- `bun unit`: Run unit tests
|
||||
- `bun e2e`: Run end-to-end tests
|
||||
|
||||
@@ -1,83 +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
|
||||
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 }}
|
||||
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.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||
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.43.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
|
||||
# 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.43.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
|
||||
- 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.43.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.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.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.43.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.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.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.43.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 }}"
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
name: 'Deploy cloudflare'
|
||||
description: 'Deploy GitBook to Cloudflare'
|
||||
inputs:
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
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
|
||||
deploy:
|
||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||
required: true
|
||||
commitTag:
|
||||
description: 'Commit branch to associate with the deployment'
|
||||
required: true
|
||||
commitMessage:
|
||||
description: 'Commit message to associate with the deployment'
|
||||
required: true
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
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
|
||||
deploy:
|
||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||
required: true
|
||||
commitTag:
|
||||
description: 'Commit branch to associate with the deployment'
|
||||
required: true
|
||||
commitMessage:
|
||||
description: 'Commit message to associate with the deployment'
|
||||
required: true
|
||||
outputs:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
description: 'Deployment URL'
|
||||
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -42,32 +42,33 @@ runs:
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v2
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build worker
|
||||
run: bun run turbo build:cloudflare
|
||||
env:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload the DO worker
|
||||
@@ -134,11 +135,10 @@ runs:
|
||||
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
|
||||
deploy: ${{ inputs.deploy }}
|
||||
|
||||
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
run: |
|
||||
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
|
||||
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
|
||||
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
name: 'Deploy vercel'
|
||||
description: 'Deploy GitBook to Vercel'
|
||||
inputs:
|
||||
vercelOrg:
|
||||
description: 'Vercel organization'
|
||||
required: true
|
||||
vercelProject:
|
||||
description: 'Vercel project'
|
||||
required: true
|
||||
vercelToken:
|
||||
description: 'Vercel token'
|
||||
required: true
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
headSha:
|
||||
description: 'Git ref to deploy, used for the deploymentId'
|
||||
required: false
|
||||
vercelOrg:
|
||||
description: 'Vercel organization'
|
||||
required: true
|
||||
vercelProject:
|
||||
description: 'Vercel project'
|
||||
required: true
|
||||
vercelToken:
|
||||
description: 'Vercel token'
|
||||
required: true
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
headSha:
|
||||
description: 'Git ref to deploy, used for the deploymentId'
|
||||
required: false
|
||||
outputs:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||
description: 'Deployment URL'
|
||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -40,50 +40,51 @@ runs:
|
||||
run: bun run vercel pull --yes --environment=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v2
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Inject build env vars
|
||||
if: ${{ inputs.environment == 'preview' }}
|
||||
shell: bash
|
||||
run: |
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "resolved HEAD_SHA: $HEAD_SHA"
|
||||
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
|
||||
cat .vercel/.env.${{ inputs.environment }}.local
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "resolved HEAD_SHA: $HEAD_SHA"
|
||||
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
|
||||
cat .vercel/.env.${{ inputs.environment }}.local
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
GITBOOK_RUNTIME: vercel
|
||||
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
GITBOOK_RUNTIME: vercel
|
||||
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
shell: bash
|
||||
@@ -91,10 +92,9 @@ runs:
|
||||
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }})
|
||||
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
|
||||
@@ -20,6 +20,20 @@ jobs:
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun format:check
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint
|
||||
timeout-minutes: 6
|
||||
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
|
||||
- run: bun lint
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: CSS browser compatibility
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: css-browser-compatibility-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check newly added CSS declarations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
steps:
|
||||
# pull_request_target checks out the trusted base branch by default. Never use the PR ref here.
|
||||
- name: Checkout trusted checker
|
||||
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: Check CSS browser compatibility
|
||||
working-directory: packages/gitbook
|
||||
run: bun run check:css-browser-compatibility
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
|
||||
environment:
|
||||
name: preview-approval
|
||||
name: preview-approval
|
||||
steps:
|
||||
- name: Approval gate
|
||||
run: echo "Preview deployment approved."
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
@@ -47,8 +47,8 @@ jobs:
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
|
||||
steps:
|
||||
@@ -72,10 +72,10 @@ jobs:
|
||||
id: extract-worker-id
|
||||
if: ${{ !steps.deploy.outputs.deployment-url }}
|
||||
run: |
|
||||
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
|
||||
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
|
||||
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
|
||||
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
|
||||
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
|
||||
@@ -187,6 +187,54 @@ jobs:
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
|
||||
# Style recalc depends on the client bundle, not the host, so this runs here only and
|
||||
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
|
||||
- name: Run style invalidation tests
|
||||
if: always()
|
||||
working-directory: packages/gitbook
|
||||
run: bun run e2e-style-perf
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
|
||||
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
|
||||
- name: Build style invalidation report
|
||||
if: always() && github.event_name != 'push'
|
||||
id: style-perf
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
rows=$(cat test-results/style-perf.md 2>/dev/null || true)
|
||||
if [ -z "$rows" ]; then exit 0; fi
|
||||
{
|
||||
echo 'body<<STYLE_PERF_EOF'
|
||||
echo '### Style invalidation on a large API reference'
|
||||
echo
|
||||
echo 'Elements restyled by opening one popup on [the Snyk API reference](https://docs.snyk.io/snyk-api/reference/apps). A share near or above 100% means the insertion restyles the whole document.'
|
||||
echo
|
||||
echo '| interaction | restyled | page | share | budget | |'
|
||||
echo '| --- | ---: | ---: | ---: | ---: | :-: |'
|
||||
echo "$rows"
|
||||
echo 'STYLE_PERF_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find style invalidation comment
|
||||
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc-style-perf
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: 'Style invalidation on a large API reference'
|
||||
|
||||
- name: Create or update style invalidation comment
|
||||
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: ${{ steps.style-perf.outputs.body }}
|
||||
edit-mode: replace
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2v-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -19,16 +19,16 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: production
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
environment: production
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2c-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -50,4 +50,4 @@ jobs:
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2v-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -19,16 +19,16 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: staging
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
environment: staging
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2c-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -50,4 +50,4 @@ jobs:
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"sortImports": {
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "workspace",
|
||||
"elementNamePattern": ["@gitbook/**"]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
["value-builtin", "value-external", "type-builtin", "type-external"],
|
||||
{ "newlinesBetween": true },
|
||||
"workspace",
|
||||
{ "newlinesBetween": true },
|
||||
[
|
||||
"value-internal",
|
||||
"type-internal",
|
||||
"value-parent",
|
||||
"type-parent",
|
||||
"value-sibling",
|
||||
"type-sibling",
|
||||
"value-index",
|
||||
"type-index"
|
||||
],
|
||||
"type-import",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"sortPackageJson": false,
|
||||
"sortTailwindcss": {
|
||||
"config": "./packages/gitbook/tailwind.config.ts",
|
||||
"attributes": ["class", "className", "style"],
|
||||
"functions": ["clsx", "tw"]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/public/**",
|
||||
"**/.next/**",
|
||||
"**/.open-next/**",
|
||||
"**/.turbo/**",
|
||||
"**/.vercel/**",
|
||||
"**/.cache/**",
|
||||
"**/.wrangler/**",
|
||||
"**/*.log",
|
||||
"**/*.MD",
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
"**/*.html",
|
||||
"**/*.css",
|
||||
"packages/embed/standalone/**",
|
||||
"packages/openapi-parser/src/fixtures/**",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "warn",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y", "vitest"],
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"shared-node-browser": true,
|
||||
"worker": true
|
||||
},
|
||||
"globals": {
|
||||
"Bun": "readonly",
|
||||
"GitBookIntegrationEvent": "readonly",
|
||||
"React": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"eslint/no-console": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["assert", "error", "warn"]
|
||||
}
|
||||
],
|
||||
"eslint/no-control-regex": "warn",
|
||||
"eslint/no-prototype-builtins": "warn",
|
||||
"eslint/no-cond-assign": "warn",
|
||||
"eslint/no-undef": "error",
|
||||
"eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true,
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"react/button-has-type": "warn",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/jsx-key": "warn",
|
||||
"react/jsx-no-useless-fragment": "warn",
|
||||
"react/no-array-index-key": "warn",
|
||||
"react/void-dom-elements-no-children": "warn",
|
||||
"react/rules-of-hooks": "error",
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-is-valid": "warn",
|
||||
"jsx-a11y/click-events-have-key-events": "warn",
|
||||
"jsx-a11y/iframe-has-title": "warn",
|
||||
"jsx-a11y/interactive-supports-focus": "warn",
|
||||
"jsx-a11y/label-has-associated-control": "warn",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "warn",
|
||||
"jsx-a11y/role-has-required-aria-props": "warn",
|
||||
"jsx-a11y/tabindex-no-positive": "warn",
|
||||
"typescript/array-type": "error",
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-non-null-assertion": "warn",
|
||||
"typescript/no-confusing-void-expression": "warn",
|
||||
"typescript/only-throw-error": "error",
|
||||
"vitest/no-focused-tests": "error",
|
||||
"no-delete-var": "warn",
|
||||
"no-debugger": "error",
|
||||
"no-throw-literal": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/public/**",
|
||||
"**/.next/**",
|
||||
"**/.open-next/**",
|
||||
"**/.turbo/**",
|
||||
"**/.vercel/**",
|
||||
"**/.cache/**",
|
||||
"**/.wrangler/**",
|
||||
"packages/embed/standalone/**",
|
||||
"packages/openapi-parser/src/fixtures/**",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"**/*.css"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome"]
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
|
||||
Vendored
+3
-5
@@ -8,14 +8,12 @@
|
||||
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
|
||||
],
|
||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
"source.fixAll.oxc": "always"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
bun install # Install dependencies
|
||||
bun dev # Start dev server (all packages)
|
||||
bun run build # Build all packages
|
||||
bun run format # Format with Biome (run after every change)
|
||||
bun run lint # Lint with Oxlint
|
||||
bun run format # Format with Oxfmt (run after every change)
|
||||
bun run typecheck # Type-check all packages
|
||||
bun run unit # Run unit tests
|
||||
```
|
||||
@@ -74,7 +75,7 @@ Save as `.changeset/<name>.md`, then commit it separately with message: `changes
|
||||
|
||||
## Formatting
|
||||
|
||||
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
|
||||
Linting uses Oxlint and formatting uses Oxfmt. Always run `bun run format` before committing.
|
||||
|
||||
## Comments
|
||||
|
||||
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"**/node_modules/**/*",
|
||||
"**/dist/**/*",
|
||||
"**/build/**/*",
|
||||
"**/public/**/*",
|
||||
"**/.next/**/*",
|
||||
"**/.open-next/**/*",
|
||||
"**/.turbo/**/*",
|
||||
"**/.vercel/**/*",
|
||||
"**/.cache/**/*",
|
||||
"**/.wrangler/**/*",
|
||||
"packages/embed/standalone/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"useEditorconfig": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"performance": {
|
||||
"noDelete": "warn"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noUselessFragments": "warn",
|
||||
"noBannedTypes": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noUnusedImports": "error",
|
||||
"noVoidElementsWithChildren": "warn",
|
||||
"useJsxKeyInIterable": "warn",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"noUnknownFunction": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn",
|
||||
"noParameterAssign": "off",
|
||||
"useThrowOnlyError": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"allow": ["assert", "error", "warn"]
|
||||
}
|
||||
},
|
||||
"noExplicitAny": "warn",
|
||||
"noImplicitAnyLet": "warn",
|
||||
"noConfusingVoidType": "warn",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noPrototypeBuiltins": "warn",
|
||||
"noAssignInExpressions": "warn",
|
||||
"noArrayIndexKey": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"useSemanticElements": "warn",
|
||||
"useKeyWithClickEvents": "warn",
|
||||
"noSvgWithoutTitle": "warn",
|
||||
"useButtonType": "warn",
|
||||
"useIframeTitle": "warn",
|
||||
"useAltText": "warn",
|
||||
"noPositiveTabindex": "warn",
|
||||
"useFocusableInteractive": "warn",
|
||||
"useAriaPropsForRole": "warn",
|
||||
"useValidAnchor": "warn",
|
||||
"noLabelWithoutControl": "warn",
|
||||
"noNoninteractiveTabindex": "warn"
|
||||
},
|
||||
"nursery": {
|
||||
"useSortedClasses": {
|
||||
"level": "error",
|
||||
"fix": "safe",
|
||||
"options": {
|
||||
"attributes": ["class", "className", "style"],
|
||||
"functions": ["clsx", "tw"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "single",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": [
|
||||
"packages/gitbook/**/*",
|
||||
"packages/react-openapi/**/*",
|
||||
"packages/react-math/**/*",
|
||||
"packages/react-contentkit/**/*",
|
||||
"packages/icons/**/*"
|
||||
],
|
||||
"javascript": {
|
||||
"globals": ["React"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["packages/gitbook/**/*"],
|
||||
"javascript": {
|
||||
"globals": ["React", "GitBookIntegrationEvent"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["*.css"],
|
||||
"javascript": {
|
||||
"globals": ["theme"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
|
||||
"javascript": {
|
||||
"globals": ["Bun"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": [
|
||||
"packages/cache-do/**/*",
|
||||
"packages/gitbook/cf-env.d.ts",
|
||||
"packages/gitbook/src/cloudflare-entrypoint.ts"
|
||||
],
|
||||
"javascript": {
|
||||
"globals": [
|
||||
"DurableObjectLocationHint",
|
||||
"DurableObjectNamespace",
|
||||
"DurableObjectStub",
|
||||
"ContinentCode",
|
||||
"Fetcher",
|
||||
"ExportedHandler"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-10
@@ -5,9 +5,10 @@
|
||||
"node": "^22.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.10.3",
|
||||
"@changesets/cli": "^2.31.1",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.82.0",
|
||||
"turbo": "^2.10.12",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
@@ -18,8 +19,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
"axios": "1.8.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -27,8 +27,10 @@
|
||||
"build": "turbo run build",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"format": "biome check --write ./",
|
||||
"format:check": "biome check --diagnostic-level=error ./",
|
||||
"lint": "oxlint --quiet",
|
||||
"lint:fix": "oxlint --fix --quiet",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check",
|
||||
"unit": "turbo run unit",
|
||||
"e2e": "turbo run e2e",
|
||||
"e2e-customers": "turbo run e2e-customers",
|
||||
@@ -39,11 +41,14 @@
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.201.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -62,6 +67,6 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.6": "patches/next@16.2.6.patch"
|
||||
"next@16.3.3": "patches/next@16.3.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/browser-types
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
@@ -25,7 +25,11 @@
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -5,14 +5,28 @@ export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||
|
||||
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||
|
||||
export type GitBookIntegrationToolConfirmation = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Supporting context displayed to the user above the confirmation dialog,
|
||||
* to help them understand what they are approving or rejecting.
|
||||
* Limited to 512 characters.
|
||||
*/
|
||||
context?: string;
|
||||
};
|
||||
|
||||
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
* Provide a static object, or a function that receives the input provided by
|
||||
* the AI assistant and returns the confirmation — useful to display dynamic
|
||||
* context based on the arguments the tool is about to be executed with.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
confirmation?:
|
||||
| GitBookIntegrationToolConfirmation
|
||||
| ((input: object) => GitBookIntegrationToolConfirmation);
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { ComputedContentSource } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import type { ComputedContentSource } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Get a stringified cache tag for a given object.
|
||||
*/
|
||||
export function getCacheTag(
|
||||
spec: /**
|
||||
* All data related to a user
|
||||
* @deprecated - in v2, no tag as this is an immutable data
|
||||
*/
|
||||
* All data related to a user
|
||||
* @deprecated - in v2, no tag as this is an immutable data
|
||||
*/
|
||||
| {
|
||||
tag: 'user';
|
||||
user: string;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/colors
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a69a307: A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"devDependencies": {
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
@@ -21,7 +21,11 @@
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 94a496c: Make the Docs Embed widget match the page it is embedded in rather than the visitor's OS: a widget on a light page stays light even when the visitor's system is in dark mode, and the widget's own chrome and the docs inside it always render in the same scheme. Sites published with a single theme impose it on the widget too, since they render in it regardless. The standalone script takes `?theme=light` on its URL, and calling `init` twice now updates the options instead of throwing.
|
||||
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -19,15 +19,27 @@ The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||
|
||||
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||
```
|
||||
|
||||
The script initializes the widget itself, so there is nothing to call. To pin the embed to one color
|
||||
scheme, put it on the script URL — it has to be known before the widget renders:
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
|
||||
```
|
||||
|
||||
To authenticate the visitor, call `init` with their token. Keep tokens out of the script URL: it is
|
||||
publicly cacheable and ends up in server logs.
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||
<script>
|
||||
// Initialize with Authenticated Access (optional)
|
||||
window.GitBook('init',
|
||||
window.GitBook('init',
|
||||
{ siteURL: 'https://docs.company.com' },
|
||||
{ visitor: { token: 'your-jwt-token' } }
|
||||
);
|
||||
window.GitBook('show');
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -464,20 +476,25 @@ visitor: {
|
||||
|
||||
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
|
||||
|
||||
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
|
||||
Override the embed's color scheme.
|
||||
|
||||
When omitted, the standalone widget follows the page it is embedded in — its `color-scheme`, falling back to the visitor's OS preference only when that page declares support for both. With the NPM package or the React components you own the iframe, so the embed follows the visitor's OS preference unless you pass this.
|
||||
|
||||
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
|
||||
|
||||
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
|
||||
**Standalone script**: `?theme=light` on the script URL
|
||||
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
|
||||
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
|
||||
|
||||
- **Type**: `'light' | 'dark'`
|
||||
|
||||
```javascript
|
||||
colorScheme: 'dark'
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
|
||||
```
|
||||
|
||||
Sites published with a single theme always render in that theme, so `colorScheme` has no effect on
|
||||
them — the widget follows the site instead, to keep its chrome and the docs inside it consistent.
|
||||
|
||||
### `button`
|
||||
|
||||
Available in: Standalone script only
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -39,7 +39,11 @@
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -10,7 +10,9 @@ export type CreateGitBookOptions = {
|
||||
export type GetFrameURLOptions = {
|
||||
/**
|
||||
* Override the color scheme used by the embedded docs.
|
||||
* When omitted, the embed follows the iframe's CSS `color-scheme`.
|
||||
* When omitted, the standalone widget follows the page it is embedded in, and only falls back
|
||||
* to the visitor's OS preference when that page supports both schemes. Building the iframe
|
||||
* yourself, the embed follows the visitor's OS preference unless you pass this.
|
||||
*/
|
||||
colorScheme?: 'light' | 'dark';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createChannel } from 'bidc';
|
||||
|
||||
import type {
|
||||
FrameToParentMessage,
|
||||
GitBookEmbeddableConfiguration,
|
||||
@@ -76,7 +77,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
channel.send(message);
|
||||
};
|
||||
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
const events = new Map<string, ((...args: any[]) => void)[]>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing a tool.
|
||||
*/
|
||||
export type GitBookToolConfirmation = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Supporting context displayed to the user above the confirmation dialog,
|
||||
* to help them understand what they are approving or rejecting.
|
||||
* Limited to 512 characters.
|
||||
*/
|
||||
context?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Provide a static object, or a function that receives the input provided by
|
||||
* the AI assistant and returns the confirmation — useful to display dynamic
|
||||
* context based on the arguments the tool is about to be executed with.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
confirmation?: GitBookToolConfirmation | ((input: object) => GitBookToolConfirmation);
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
GetFrameURLOptions,
|
||||
GitBookEmbeddableConfiguration,
|
||||
@@ -78,7 +79,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||
import { GitBookContext } from './context';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { GitBookClient } from '../client';
|
||||
|
||||
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||
|
||||
@@ -55,6 +55,7 @@ let widgetIframe: HTMLIFrameElement | undefined;
|
||||
let _client: GitBookClient | undefined;
|
||||
let _frame: GitBookFrameClient | undefined;
|
||||
let frameOptions: GetFrameURLOptions | undefined;
|
||||
let frameConfigured = false;
|
||||
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
||||
button: {
|
||||
label: 'Ask',
|
||||
@@ -85,6 +86,47 @@ widgetWindow.classList.add('hidden');
|
||||
document.body.appendChild(widgetButton);
|
||||
document.body.appendChild(widgetWindow);
|
||||
|
||||
/**
|
||||
* The one scheme everything follows: the widget's chrome, the frame's URL and the docs inside it.
|
||||
* Either it was configured, or we match the page we are embedded in (RND-12558).
|
||||
*/
|
||||
function resolveColorScheme(): 'light' | 'dark' {
|
||||
const configured = frameOptions?.colorScheme;
|
||||
// Callers are plain JS, so anything else — a typo, a `system` — falls back to the page rather
|
||||
// than reaching the CSS and the frame's URL, where the two would disagree.
|
||||
return configured === 'light' || configured === 'dark' ? configured : hostColorScheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme the embedding page renders in, which is not the visitor's OS preference: a page that
|
||||
* never opted into dark stays light however the OS is set.
|
||||
*
|
||||
* Resolving a `light-dark()` is the only way to read it. A page's *used* color scheme isn't exposed
|
||||
* anywhere — the CSSOM gives computed values, and a `<meta name="color-scheme">` (the common way to
|
||||
* declare it) never even reaches those.
|
||||
*/
|
||||
function hostColorScheme(): 'light' | 'dark' {
|
||||
const probe = document.createElement('div');
|
||||
// The first `color` is the fallback where `light-dark()` is unsupported: without it the probe
|
||||
// would inherit the page's own text colour and a white one would read as dark.
|
||||
probe.style.cssText =
|
||||
'display:none;color:rgb(0,0,0);color:light-dark(rgb(0,0,0), rgb(255,255,255))';
|
||||
document.body.appendChild(probe);
|
||||
const used = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return used === 'rgb(255, 255, 255)' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/** Mirror the resolved scheme onto the widget's own chrome, and hand it back for the frame's URL. */
|
||||
function applyColorScheme(): 'light' | 'dark' {
|
||||
const colorScheme = resolveColorScheme();
|
||||
for (const element of [widgetButton, widgetWindow]) {
|
||||
element.dataset.colorScheme = colorScheme;
|
||||
}
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
throw new Error(
|
||||
@@ -102,12 +144,8 @@ function getIframe() {
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.allow = 'clipboard-write';
|
||||
if (frameOptions?.colorScheme) {
|
||||
widgetIframe.style.colorScheme = frameOptions.colorScheme;
|
||||
}
|
||||
widgetIframe.src = client.getFrameURL({
|
||||
...frameOptions,
|
||||
});
|
||||
// One read for both, so the docs can't come back in a different scheme than the panel.
|
||||
widgetIframe.src = client.getFrameURL({ ...frameOptions, colorScheme: applyColorScheme() });
|
||||
widgetWindow.appendChild(widgetIframe);
|
||||
|
||||
_frame = client.createFrame(widgetIframe);
|
||||
@@ -115,25 +153,53 @@ function getIframe() {
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
});
|
||||
// A new frame starts from the site's own defaults, so replay whatever the host configured.
|
||||
if (frameConfigured) {
|
||||
_frame.configure(frameConfiguration);
|
||||
}
|
||||
}
|
||||
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.'
|
||||
);
|
||||
}
|
||||
case 'init': {
|
||||
// `~gitbook/embed/script.js` already calls `init`, so an integrator following the docs
|
||||
// ends up calling it a second time. Take the new options instead of throwing: throwing
|
||||
// here dropped every call queued behind it (RND-12558).
|
||||
_client = createGitBook(args[1]);
|
||||
frameOptions = args[2];
|
||||
frameOptions = {
|
||||
// Replace rather than merge: a call that leaves out `visitor` — a logout, another
|
||||
// site — must not keep the token from the last one.
|
||||
...args[2],
|
||||
// Except the scheme, where the first one wins: `script.js` passes the site's own
|
||||
// theme when it pins one, and that is not the integrator's to override.
|
||||
colorScheme: frameOptions?.colorScheme ?? args[2]?.colorScheme,
|
||||
};
|
||||
const colorScheme = applyColorScheme();
|
||||
|
||||
// Rebuild the frame only if the new options change its URL — reloading it on the
|
||||
// loader's `init` plus the integrator's would throw away a chat for nothing.
|
||||
const frameURL = _client.getFrameURL({ ...frameOptions, colorScheme });
|
||||
if (widgetIframe && widgetIframe.src !== frameURL) {
|
||||
const wasOpen = !widgetWindow.classList.contains('hidden');
|
||||
widgetIframe.remove();
|
||||
widgetIframe = undefined;
|
||||
_frame = undefined;
|
||||
if (wasOpen) {
|
||||
getIframe();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'unload':
|
||||
_client = undefined;
|
||||
_frame = undefined;
|
||||
widgetIframe?.remove();
|
||||
widgetIframe = undefined;
|
||||
frameOptions = undefined;
|
||||
frameConfigured = false;
|
||||
applyColorScheme();
|
||||
widgetWindow.classList.add('hidden');
|
||||
break;
|
||||
case 'show':
|
||||
@@ -192,6 +258,7 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
}
|
||||
}
|
||||
|
||||
frameConfigured = true;
|
||||
getIframe().frame.configure({
|
||||
...frameConfiguration,
|
||||
});
|
||||
@@ -214,4 +281,11 @@ 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));
|
||||
// Replay each queued call on its own, so one that throws doesn't drop the rest.
|
||||
precalls.forEach((call) => {
|
||||
try {
|
||||
GitBook(...call);
|
||||
} catch (error) {
|
||||
console.error('[gitbook:embed]', error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,14 +29,30 @@
|
||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gitbook-widget-text-color: #FFFFFF;
|
||||
--gitbook-widget-border-color: #202020;
|
||||
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
|
||||
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
|
||||
--gitbook-widget-background-solid: #f0f0f0;
|
||||
}
|
||||
/* The widget owns the panel's surface, so it renders in the same scheme as the docs inside the
|
||||
* iframe. `data-color-scheme` carries that one resolved scheme — see `resolveColorScheme()` — and
|
||||
* is always set, so the declaration below only shows before the widget initializes (RND-12558).
|
||||
* The colours are declared here rather than on `:root` because `light-dark()` resolves against the
|
||||
* color-scheme of the element that declares them. The bundler lowers it to a variable toggle, so
|
||||
* browsers without `light-dark()` still get both schemes. */
|
||||
#gitbook-widget-button,
|
||||
#gitbook-widget-window {
|
||||
color-scheme: light;
|
||||
|
||||
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
|
||||
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
|
||||
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
|
||||
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
|
||||
--gitbook-widget-background-solid: light-dark(#FFFFFF, #0f0f0f);
|
||||
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #141414);
|
||||
}
|
||||
#gitbook-widget-button[data-color-scheme="light"],
|
||||
#gitbook-widget-window[data-color-scheme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
#gitbook-widget-button[data-color-scheme="dark"],
|
||||
#gitbook-widget-window[data-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import emojisRaws from 'emoji-assets/emoji.json';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import emojisRaws from 'emoji-assets/emoji.json';
|
||||
|
||||
interface EmojiData {
|
||||
code_points: {
|
||||
|
||||
@@ -36,7 +36,11 @@
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -58,10 +58,10 @@ describe('autocomplete', () => {
|
||||
}),
|
||||
};
|
||||
const context = new SymbolsTable(symbols);
|
||||
const SCENARIOS: Array<{
|
||||
const SCENARIOS: {
|
||||
expressionWithCursor: string;
|
||||
expectedSuggestions: AutocompleteSuggestions;
|
||||
}> = [
|
||||
}[] = [
|
||||
{
|
||||
expressionWithCursor: 'visit<cur>',
|
||||
expectedSuggestions: [
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
} 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';
|
||||
@@ -312,7 +311,7 @@ export class AutoComplete {
|
||||
node: BinaryExpression,
|
||||
cursorOffset: number,
|
||||
context: SymbolsTable
|
||||
): Array<AutocompleteLiteralValueSuggestion> {
|
||||
): AutocompleteLiteralValueSuggestion[] {
|
||||
const { left, right } = node;
|
||||
|
||||
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
|
||||
@@ -431,7 +430,7 @@ export class AutoComplete {
|
||||
node: AnyNode,
|
||||
cursorOffset: number,
|
||||
context: SymbolsTable
|
||||
): Array<AutocompleteOperatorSuggestion> {
|
||||
): AutocompleteOperatorSuggestion[] {
|
||||
if (node.type === 'Literal') {
|
||||
const parent = findParentNode(node, ast);
|
||||
|
||||
@@ -496,7 +495,7 @@ export class AutoComplete {
|
||||
*/
|
||||
private getOperatorSuggestionsForSymbol(
|
||||
symbol: ExtractSymbolDef<SymbolType>
|
||||
): Array<AutocompleteOperatorSuggestion> {
|
||||
): AutocompleteOperatorSuggestion[] {
|
||||
switch (symbol.type) {
|
||||
case SymbolType.Number:
|
||||
case SymbolType.Boolean:
|
||||
@@ -526,7 +525,7 @@ export class AutoComplete {
|
||||
case SymbolType.Function:
|
||||
return this.getOperatorSuggestionsForSymbol(symbol.returns);
|
||||
case SymbolType.Union: {
|
||||
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
|
||||
return symbol.members.reduce<AutocompleteOperatorSuggestion[]>((prev, cur) => {
|
||||
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
|
||||
return prev;
|
||||
}, []);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
import { filterOutNullable } from './utils';
|
||||
|
||||
type InputValuesType =
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ExpressionParserResult {
|
||||
/**
|
||||
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
|
||||
*/
|
||||
invalidNodes: Array<ExpressionStatement>;
|
||||
invalidNodes: ExpressionStatement[];
|
||||
}
|
||||
|
||||
export interface ExpressionAutocompleteResults {
|
||||
@@ -158,11 +158,11 @@ export interface AutocompleteSymbolSuggestion {
|
||||
symbol: SymbolInfo;
|
||||
}
|
||||
|
||||
export type AutocompleteSuggestions = Array<
|
||||
export type AutocompleteSuggestions = (
|
||||
| AutocompleteSymbolSuggestion
|
||||
| AutocompleteLiteralValueSuggestion
|
||||
| AutocompleteOperatorSuggestion
|
||||
>;
|
||||
)[];
|
||||
|
||||
type LoggerFn = (message: string, ...args: any[]) => void;
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { APIv2 } from 'google-font-metadata';
|
||||
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';
|
||||
|
||||
@@ -27,7 +27,12 @@
|
||||
"unit": "bun test",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import rawFonts from './data/fonts.json' with { type: 'json' };
|
||||
import type { FontDefinitions } from './types';
|
||||
|
||||
import rawFonts from './data/fonts.json' with { type: 'json' };
|
||||
|
||||
export const fonts: FontDefinitions = rawFonts;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||
|
||||
import { getDefaultFont } from './getDefaultFont';
|
||||
|
||||
describe('getDefaultFont', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomizationDefaultFont } from '@gitbook/api';
|
||||
|
||||
import { fonts } from './fonts';
|
||||
import type { FontWeight } from './types';
|
||||
|
||||
@@ -86,7 +87,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
|
||||
// ---------- 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; ) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,5 +1,168 @@
|
||||
# gitbook
|
||||
|
||||
## 0.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3d37684: Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
|
||||
- 523b7cd: Support external links in published site navigation.
|
||||
- 89c4a0f: Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 048c4e4: Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
|
||||
- 996d7ec: Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
|
||||
- fa9c9b3: Force software rendering (SwiftShader) in Playwright Chromium to eliminate image downscaling drift between GPU-equipped local runs and headless CI runs in Argos screenshots.
|
||||
- d9fc460: Stop showing previously asked questions as suggestions in the GitBook Assistant
|
||||
- 1ee1853: Add an assistant tool to rate its own previous response when the user reacts to it.
|
||||
- cef1870: Add an assistant tool to submit feedback about the current page on behalf of the user.
|
||||
- 49d35aa: Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.
|
||||
- 8ad7465: Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
|
||||
- b3db1c5: Keep the breadcrumbs from covering the page actions' hit area.
|
||||
- 9b822ab: Include published page descriptions in the page's Markdown output.
|
||||
- 64e143a: Bump Next.js to 16.3.3.
|
||||
- c08d05a: Fix select filters not working on table and cards blocks.
|
||||
- a566153: Chunk oversized visitor auth cookies to fix an infinite redirect loop when the visitor token exceeds the browser cookie size limit.
|
||||
- 1b7ac0e: Fix inline Ask AI buttons opening a configured custom assistant.
|
||||
- ae9367d: Assistant: the "Explored briefly" activity heading no longer appears when there's nothing to show. It now renders only when the answer is preceded by a real preamble or one or more tool calls, so a simple answer with an empty reasoning step no longer surfaces an empty collapsible.
|
||||
- 688515e: Fix ContentKit buttons to size to their content instead of stretching to the full container width.
|
||||
- e041e49: Stop the built-in cookie banner from flashing on sites using a consent integration such as Osano or OneTrust.
|
||||
- 5f88f4e: Fix breadcrumbs for cross-space page links in grouped sites.
|
||||
- 1ef7160: Show the theme toggle in the footer whenever the outline column that hosts the other toggle isn't pinned open, so it stays reachable on laptop-sized screens in wide layouts and while the AI chat is open.
|
||||
- 3939125: Disable click-to-zoom for inline line sized images.
|
||||
- 177ef85: Fix host action buttons in the Docs Embed not reaching the assistant.
|
||||
- 4e9071d: Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL.
|
||||
- 94a496c: Fix the Docs Embed widget rendering with a dark surface when the visitor's OS is in dark mode, even on a light page or a site published with a light theme. The embed script now accepts `?theme=light|dark` to force a scheme.
|
||||
- 9002f65: Fix suggested question clicks in the embed search not opening the assistant.
|
||||
- 13059b8: Fix switching from the Search tab to the Docs or Assistant tab in the embed doing nothing.
|
||||
- b5f3c14: Add configurable default visibility to Prompt block
|
||||
- a69a307: A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
|
||||
- f9ad9b5: Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
|
||||
|
||||
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
|
||||
|
||||
- 03bbacf: Add missing link reference to OpenAPI models
|
||||
- 530f98a: Skip internal paths (`~gitbook/*`, `.well-known/oauth-protected-resource`, `llms.txt`, `robots.txt`, `sitemap.xml`, `rss.xml`) when building URL lookup alternatives, to avoid resolving URLs that can never match content.
|
||||
- cc441c9: Keep published search results within the selected site-space scope during local and remote result fusion.
|
||||
- 4452fd7: Fix page link titles showing the site section name instead of the target space title.
|
||||
- d0a63ba: Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
|
||||
- 7bab574: Self-host default Google fonts and inline only the @font-face rules of the fonts a site uses, instead of shipping render-blocking stylesheets covering all 23 families on every page.
|
||||
- 6928a9b: Hide card fields that render no content, along with their title
|
||||
- c41cf9c: Add cover image background mode and masks
|
||||
- 56c2558: Upgrade react-hotkeys-hook to v5 and use its native `useKey` option so keyboard shortcuts match the produced key on non-QWERTY layouts.
|
||||
- 39156ee: Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
|
||||
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
|
||||
- c1b5be0: Keep embed demos and frames on the deployment that served them.
|
||||
- b13fd91: Fix a hydration mismatch on every page load caused by the AI chat time-based greeting being computed in the server timezone.
|
||||
- 2ccd43e: Lazy-load the Mermaid code block so pages that have code blocks but no diagram no longer ship its rendering dependencies.
|
||||
- 65f99ea: Lazy load the Scalar API client modal and stop preloading the Scalar runtime. The modal is now code-split into its own chunk, fetched in parallel with the runtime only when a reader clicks "Test it", and a spinner is shown on the button until the client opens.
|
||||
|
||||
Breaking: the package no longer ships the modal in its main entry — consumers must serve the emitted `ScalarApiModal` chunk and use a bundler that supports dynamic `import()`, and the Scalar runtime is no longer preloaded on page load. The internal `preloadScalarRuntime` helper is removed.
|
||||
|
||||
- 810244e: Stop preloading the zoom-modal variant of every zoomable image at render time; it downloaded each image twice during the initial page load. The modal image still loads on hover or click.
|
||||
- 75bdff3: Serve an indexable `X-Robots-Tag` on markdown pages requested by AI agents
|
||||
- 2ff77e1: Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
|
||||
- d7c867a: Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
|
||||
- ccb9d7b: Submit `sendFeedback` MCP tool findings through the dedicated `submitSiteAgentFeedback` API endpoint. The `pageUrl` is now required and an optional `goal` can be provided.
|
||||
- 01c9b05: Mermaid diagrams no longer hijack page scrolling: zooming with the wheel now requires holding Ctrl/Cmd inline, while the fullscreen view keeps free wheel zoom.
|
||||
- b3db1c5: Migrate the headless UI primitives from Radix and react-aria to Base UI.
|
||||
- a664407: Persist content selection (tabs and other `select` blocks) in localStorage only, dropping the `?select=` query parameter from the URL. A tab click still writes the tab's hash, so a copied URL lands on that tab and reactivates it on load.
|
||||
- 14562e9: Serve `X-Robots-Tag: noindex` on internal search/assistant URLs (`?q=` / `?ask=`) and stop disallowing them in robots.txt, so Google can crawl the directive and drop them from the index instead of reporting "Indexed, though blocked by robots.txt".
|
||||
- 90b5468: Redesign the site OAuth consent screen for published sites MCP and translate its strings
|
||||
- 40a879a: Drop a column instead of shrinking them all when a section group dropdown runs out of room
|
||||
- a9c5d1b: Let a section group dropdown scroll when it is taller than the screen
|
||||
- 827e4f9: Fix the variant switcher linking to the wrong URL for non-default variants of the default section
|
||||
- 35be383: Skip the rendering work for off-screen OpenAPI blocks, so pages with many operations stay smooth to scroll.
|
||||
- db176ba: Add an "On this page" table of contents on OpenAPI models pages. Each model in a grouped/multi-model "Models" section is now listed as its own section, matching operations and webhooks.
|
||||
- 332089e: Improve cookie handling in the OpenAPI "Test it" request proxy.
|
||||
- a80d412: Serve the OpenAPI "Test it" request proxy from GitBook's own domain.
|
||||
- 1424c56: Keep the OpenAPI renderer out of the initial bundle of pages that have no OpenAPI block, by building its context on the client behind a dynamic boundary.
|
||||
- e4b214e: Fix OpenAPI webhook payload and schema example panels being clipped instead of scrollable.
|
||||
- 7009bb9: Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages.
|
||||
- 40150f0: Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
|
||||
- e460ec3: Preserve external page destinations returned by published search.
|
||||
- 148a43a: Use one canonical backend-ranked result set for published searches across site sections.
|
||||
- 5da854f: Preserve canonical backend ranking and present page or section context that matches each published search destination.
|
||||
- 78c589f: Align the Previous page navigation button to the left edge and the Next button to the right edge, including when only one of them is present.
|
||||
- b9453a9: Preserve the full site preview path and query parameters when redirecting users to log in.
|
||||
- 8e87856: Fix pages resolving to "not found" when a root URL lookup resolves to a custom homepage, by no longer using the homepage pathname as a prefix for the requested page path.
|
||||
- 1faa57b: Keep current-space search results inside revision previews.
|
||||
- 7543eb8: Prewarm published search caches when readers open the search interface.
|
||||
- 185dd83: Inject site tracking scripts (analytics integrations) after `load` + idle instead of preloading them and executing them during the critical loading window.
|
||||
- 77058e3: Render horizontal and vertical merged table cells on published pages.
|
||||
- 634a24f: Fix text disappearing in Firefox and iOS Safari on pages with a background cover
|
||||
- 64ce9e1: Render a site-space custom home page at its placement root while preserving the full space and normal page URLs.
|
||||
- 81dba64: Resolve stable page, space and file refs in images, definitions and HTML blocks of published markdown pages, instead of leaking internal `/pages/{id}`, `/spaces/{id}` and `/files/{id}` URLs.
|
||||
- da7fb13: Fail closed in `/~gitbook/revalidate` when `GITBOOK_SECRET` is not configured, returning `403 Revalidation is disabled` instead of skipping the signature check, consistent with `force-revalidate`.
|
||||
- a9a52fe: Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
|
||||
- 195c9e6: Fix the docs embed not applying `?theme=light`/`?theme=dark`. The theme was dropped when the embed redirected to its default tab, and the embed tabs couldn't read it because they render statically (their request headers are empty). The middleware now threads a forced embed theme through the embed route context (scoped to the embed, not the main site), so the embed tabs honor it while staying statically rendered, and the redirect forwards it to the default tab. The forced theme is also persisted to the embed's own theme storage so it is remembered across tab navigation instead of only applying while the query string is present.
|
||||
- 597fe34: Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once.
|
||||
- e14609c: Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint.
|
||||
- 98b2df4: Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback.
|
||||
- 8e9a49d: Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded).
|
||||
- cf4efc7: Fix the search field losing focus if it was focused just before the page finished hydrating.
|
||||
- e73b182: Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
|
||||
- 6d02b8a: Fix code block syntax highlighting so comment delimiters (e.g. `//`, `/*`) use the same color as the rest of the comment. Previously the delimiter fell through to the generic punctuation scope, making it a different color from the comment body (most visible in dark mode).
|
||||
- bcd1c41: Scroll to the top when selecting a search result for the page already being viewed.
|
||||
- 1f250d1: Add end-to-end coverage for root and nested external links in site section navigation.
|
||||
- 8a0e0df: Fix section links in search results opening the page without scrolling to the section.
|
||||
- 484cc11: Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
|
||||
- c1b5be0: Allow published search to be scoped to any visible site section.
|
||||
- d33e570: Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
|
||||
- 0a82628: Keep keyboard focus within search while the results are open.
|
||||
- 5889642: Keep the last search query visible after closing search, and restore it when reopening, without breaking navigation when clicking a search result.
|
||||
- 3db14ad: Link page-level search matches to the top of the page while preserving section anchors for section matches.
|
||||
- 0dd2f4f: Show the containing section name when hovering a direct link to a space.
|
||||
- 8c10d92: Fix the section group menu dismissing while the mouse is still inside it, when the collapsed sidebar rail overlaps the menu on no-sidebar pages.
|
||||
- ded2f56: Show loose sections in a section group dropdown as secondary links unless the group starts with one
|
||||
- 0ff21b7: Stop the section tabs in the header from showing a scroll button and faded edge when all tabs already fit.
|
||||
- cd506e3: Keep Shiki out of the initial page bundle by splitting the highlighter from the plain-text token helpers, so client code no longer pulls the engine and language bundles in through a shared import.
|
||||
- ad3399b: Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask <assistant> about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow.
|
||||
- 931cbe7: Use cached Git metadata (via @gitbook/api 0.201.0's `cachedMetadata` param) when rendering Edit on Git actions.
|
||||
- e0bd04f: Render headings with the site's heading font when one is configured, falling back to the main font otherwise.
|
||||
- bf29570: Fix the tabs "more" dropdown showing when no tab is overflowing, and stop a tab click re-rendering every tab group on the page.
|
||||
- 703e654: Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted.
|
||||
- 57f3077: Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations.
|
||||
- 8676ad1: Hide unfocusable unlabelled button from screen readers
|
||||
- 6cf4278: Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space.
|
||||
- 7594a33: Show image thumbnails, including SVG previews, for file attachments on published sites.
|
||||
- 5c74eef: Make the table search "no results" empty state more prominent with vertical spacing so it no longer blends into the content below.
|
||||
- 038008c: Fix heading anchor links being unreachable on touch devices by adding a tap-to-reveal state. The anchor icon now appears after the heading text without wrapping onto an orphan line while retaining its existing desktop placement. Use `pointerup` for the dismiss listener to fix unreliable dismissal on iOS Safari, and enlarge the anchor's touch tap target to a square 24px area (meeting the WCAG 2.5.8 minimum) so the icon stays centered instead of overflowing shorter headings' line height.
|
||||
- a9a1a60: Fix search results from a previous scope staying stuck on top of the new results when switching the search filter.
|
||||
- 46b3a18: Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
|
||||
- 8a6baab: Load the admin toolbar and its CSS lazily so published pages no longer ship a render-blocking stylesheet for admin-only UI.
|
||||
- a9c5d1b: Remove the gap between the columns of a section group dropdown holding a large group
|
||||
- a9041a3: Remove the fallback query parameter after successful page navigation without adding a browser history entry.
|
||||
- 931cbe7: Restore Edit on Git page actions for Git-synced pages.
|
||||
- ea19801: Support localized custom AI Assistant greeting subtitles.
|
||||
- 1dccf8f: Fix the first item of a table-of-contents page group appearing cut off (faded under the group header) after client-side navigation.
|
||||
- 48fba7c: Highlight a table-of-contents link entry as active when it points to the page — or the section of a page — you are currently viewing.
|
||||
- 048c4e4: Fix chevrons and open-state styling incorrectly reacting to a tooltip opening on the same trigger, by switching from the shared Base UI `data-popup-open` attribute to `aria-expanded`.
|
||||
- f3408ed: a11y screen reader fixes
|
||||
- 3bf55ce: Support query params in the `@webframe.navigate` action.
|
||||
- cb92754: Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
|
||||
- 6083a88: Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
|
||||
- 2c4d40a: Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
|
||||
- 8a70022: Only track embed view events once the frame is actually shown to the reader
|
||||
- cf94386: Fix an issue where certain keywords could cause an exception when rendering emojis
|
||||
- 673f4b6: Add the `select` action to InlineButton. Clicking the button activates its slug, so any block containing that slug switches to it.
|
||||
- Updated dependencies [94a496c]
|
||||
- Updated dependencies [a69a307]
|
||||
- Updated dependencies [03bbacf]
|
||||
- Updated dependencies [bf674a4]
|
||||
- Updated dependencies [65f99ea]
|
||||
- Updated dependencies [1424c56]
|
||||
- Updated dependencies [3ef1802]
|
||||
- Updated dependencies [3bf55ce]
|
||||
- Updated dependencies [cb92754]
|
||||
- Updated dependencies [6083a88]
|
||||
- @gitbook/embed@0.5.2
|
||||
- @gitbook/colors@0.4.4
|
||||
- @gitbook/openapi-parser@3.0.13
|
||||
- @gitbook/react-openapi@2.0.0
|
||||
- @gitbook/browser-types@0.1.6
|
||||
- @gitbook/react-contentkit@0.7.17
|
||||
|
||||
## 0.27.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -215,7 +215,8 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.n8n.io',
|
||||
contentBaseURL: 'https://docs.n8n.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// The site registers its own cookie banner, so the built-in one never shows.
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherryai.com.cn',
|
||||
@@ -282,11 +283,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://vimeo.com',
|
||||
tests: [{ name: 'Home', url: '/legal' }],
|
||||
},
|
||||
{
|
||||
name: 'help.platipomiru.com',
|
||||
contentBaseURL: 'https://help.platipomiru.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'help.aikido.dev',
|
||||
contentBaseURL: 'https://help.aikido.dev',
|
||||
@@ -312,11 +308,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.triumpharcade.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.nats.io',
|
||||
contentBaseURL: 'https://docs.nats.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.glpi-project.org',
|
||||
contentBaseURL: 'https://help.glpi-project.org',
|
||||
@@ -365,7 +356,9 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.verifone.com',
|
||||
contentBaseURL: 'https://docs.verifone.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// Verifone's custom Cookiebot integration races with and suppresses GitBook's built-in
|
||||
// banner, so waitForCookiesDialog is not a stable invariant here.
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// Deactivate it because of a custom Ask AI that causes flakiness.
|
||||
// {
|
||||
@@ -390,7 +383,7 @@ const testCases: TestsCase[] = [
|
||||
{ name: 'Home', url: '/docs', run: waitForCookiesDialog },
|
||||
{
|
||||
name: 'OpenAPI',
|
||||
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask',
|
||||
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask/ask-a-question-in-a-site',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
@@ -403,7 +396,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'faq.wanttopay.net/wanttopay-app',
|
||||
contentBaseURL: 'https://faq.wanttopay.net',
|
||||
tests: [{ name: 'Home', url: '/wanttopay-app', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/wanttopay-app' }],
|
||||
},
|
||||
{
|
||||
name: 'guide.prismlive.com',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
CustomizationAIMode,
|
||||
CustomizationBackground,
|
||||
@@ -7,14 +10,11 @@ import {
|
||||
CustomizationDepth,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationPageActionType,
|
||||
CustomizationSidebarListStyle,
|
||||
SiteSocialAccountPlatform,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookStandalone } from '@gitbook/embed';
|
||||
import { expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
waitForAdminToolbar,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForHydration,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
|
||||
// single fixed search, a concise answer, and a fixed number of follow-ups. The
|
||||
@@ -51,6 +53,27 @@ const AI_PROMPT = [
|
||||
'4. Always end by proposing exactly 3 follow-up suggestions.',
|
||||
].join('\n');
|
||||
|
||||
// `InsightsProvider` debounces its flushes by 1.5s.
|
||||
const INSIGHTS_FLUSH_TIMEOUT = 3000;
|
||||
|
||||
/**
|
||||
* Collect the insights events of a given type sent by the page and its frames.
|
||||
*/
|
||||
function trackInsightsEvents(page: Page, type: string) {
|
||||
const collected: { type: string }[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.postDataJSON() as { events?: { type: string }[] } | null;
|
||||
collected.push(...(body?.events ?? []).filter((event) => event.type === type));
|
||||
});
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
if (greeting) {
|
||||
@@ -135,6 +158,54 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - Keyboard focus stays within search',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await searchInput.fill('gitbook');
|
||||
|
||||
const searchPopup = page.getByTestId('search-popover');
|
||||
await expect(searchPopup).toBeVisible({ timeout: 10_000 });
|
||||
const finalPopupControl = searchPopup
|
||||
.locator(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
.filter({ visible: true })
|
||||
.last();
|
||||
await expect(finalPopupControl).toBeVisible();
|
||||
await finalPopupControl.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(searchInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(finalPopupControl).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
// `fill()` bypasses key events, so it can't catch a swallowed key. RND-12484.
|
||||
name: 'Search - AI Mode: None - Typing multi-word queries',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await searchInput.pressSequentially('getting started');
|
||||
await expect(searchInput).toHaveValue('getting started');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: None - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
@@ -175,6 +246,43 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
// RND-12844: the popover's focus manager re-focused the closing popup and
|
||||
// scrolled the page back to the top right after landing on the section.
|
||||
name: 'Search - Section result scrolls to the section',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
// Type like a visitor: `fill()` doesn't trigger the remote search.
|
||||
await searchInput.pressSequentially('tasks');
|
||||
|
||||
const sectionResult = page.locator(
|
||||
'[data-testid="search-page-result"][href$="/blocks/lists#tasks"]'
|
||||
);
|
||||
// Section results come from the remote index, which can be slow to answer.
|
||||
await expect(sectionResult).toBeVisible({ timeout: 30_000 });
|
||||
await sectionResult.click();
|
||||
await page.waitForURL(/\/blocks\/lists#tasks$/);
|
||||
|
||||
// The regression scrolled back to the top shortly after landing, so let
|
||||
// that happen before asserting.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The heading is parked under the header, within its scroll margin.
|
||||
const top = await page
|
||||
.locator('#tasks')
|
||||
.evaluate((heading) => heading.getBoundingClientRect().top);
|
||||
expect(top).toBeGreaterThanOrEqual(0);
|
||||
expect(top).toBeLessThanOrEqual(150);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
@@ -381,6 +489,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Customized variant titles are displayed',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -408,6 +517,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Switch variant with alternate link in metadata',
|
||||
url: 'rfcs',
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -441,11 +551,30 @@ const testCases: TestsCase[] = [
|
||||
name: 'GitBook Site (Navigation when switching variant)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Strip fallback after loading a page without adding history',
|
||||
url: 'api-multi-versions/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const previousURL = page.url();
|
||||
const targetURL = new URL(previousURL);
|
||||
targetURL.searchParams.set('fallback', 'true');
|
||||
targetURL.searchParams.set('ref', 'variant');
|
||||
targetURL.hash = 'pets';
|
||||
await page.goto(targetURL.toString());
|
||||
targetURL.searchParams.delete('fallback');
|
||||
await expect(page).toHaveURL(targetURL.toString());
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(previousURL);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Keep navigation path/route when switching variant (Public)',
|
||||
url: 'api-multi-versions/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -462,8 +591,11 @@ const testCases: TestsCase[] = [
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.pathname.includes(
|
||||
'api-multi-versions/2.0/reference/api-reference/pets'
|
||||
) && !url.searchParams.has('fallback')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -472,6 +604,7 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -513,6 +646,7 @@ const testCases: TestsCase[] = [
|
||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||
},
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -548,6 +682,7 @@ const testCases: TestsCase[] = [
|
||||
url: 'ecosystem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -578,6 +713,7 @@ const testCases: TestsCase[] = [
|
||||
url: 'nl/ecosysteem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
@@ -618,18 +754,162 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const trigger = page.getByRole('button', { name: 'Test Section Group 1' });
|
||||
// Radix NavigationMenu opens the dropdown on a `pointermove`. A single
|
||||
// synthetic hover can land before hydration and be lost, so re-hover
|
||||
// until the dropdown content actually appears.
|
||||
await expect(async () => {
|
||||
await trigger.hover();
|
||||
await expect(page.getByText('Section B')).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
// The menu only opens on `mouseenter`, which cannot fire again once the pointer
|
||||
// is inside: a hover landing before hydration is lost for good.
|
||||
await waitForHydration(page);
|
||||
const trigger = page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 1' });
|
||||
await trigger.hover();
|
||||
await expect(page.getByText('Section B')).toBeVisible();
|
||||
await page.getByText('Section B').click();
|
||||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootSections = page.locator('[data-gb-sections]');
|
||||
const rootItems = rootSections.locator(':scope > li');
|
||||
|
||||
await expect(rootItems).toHaveCount(4);
|
||||
await expect(rootItems.nth(0)).toContainText('Home');
|
||||
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
|
||||
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
|
||||
await expect(rootItems.last()).toContainText('Gitbook Docs');
|
||||
await expect(
|
||||
rootSections.getByRole('link', { name: 'Gitbook Docs' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootLink = page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' });
|
||||
|
||||
await expect(rootLink).toBeVisible();
|
||||
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
|
||||
await expect(rootLink).not.toHaveAttribute('target');
|
||||
await expect(rootLink).not.toHaveAttribute('rel');
|
||||
await expect(rootLink).toHaveAttribute('data-active', 'false');
|
||||
await expect(rootLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
|
||||
const nestedItems = nestedLink
|
||||
.locator('xpath=ancestor::ul[1]')
|
||||
.locator(':scope > li');
|
||||
await expect(nestedItems).toHaveCount(3);
|
||||
await expect(nestedItems.nth(0)).toContainText('Section C');
|
||||
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
|
||||
await expect(nestedItems.last()).toContainText('Gitbook Site');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
|
||||
await expect(nestedLink).not.toHaveAttribute('target');
|
||||
await expect(nestedLink).not.toHaveAttribute('rel');
|
||||
await expect(nestedLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'External links use the configured window open behavior',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
|
||||
const windowOpenCalls: {
|
||||
url: string;
|
||||
target: string;
|
||||
features: string | undefined;
|
||||
}[] = [];
|
||||
await page.exposeFunction(
|
||||
'recordExternalWindowOpen',
|
||||
(url: string, target: string, features?: string) => {
|
||||
windowOpenCalls.push({ url, target, features });
|
||||
}
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const recordExternalWindowOpen = (
|
||||
window as unknown as {
|
||||
recordExternalWindowOpen: (
|
||||
url: string,
|
||||
target: string,
|
||||
features?: string
|
||||
) => void;
|
||||
}
|
||||
).recordExternalWindowOpen;
|
||||
window.open = ((url, target, features) => {
|
||||
void recordExternalWindowOpen(
|
||||
url?.toString() ?? '',
|
||||
target ?? '',
|
||||
features
|
||||
);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
|
||||
const initialURL = page.url();
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' })
|
||||
.click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(1);
|
||||
expect(windowOpenCalls[0]).toEqual({
|
||||
url: 'https://gitbook.com/docs',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
await page.getByRole('link', { name: 'Gitbook Site' }).click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(2);
|
||||
expect(windowOpenCalls[1]).toEqual({
|
||||
url: 'https://gitbook.com',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1241,8 +1521,8 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
// New site themes
|
||||
...allThemes.flatMap((theme) => [
|
||||
...allTintColors.flatMap((tint) => [
|
||||
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||||
...allTintColors.flatMap((tint) =>
|
||||
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||||
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
@@ -1262,8 +1542,8 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
}))
|
||||
),
|
||||
...allSearchStyles.flatMap((searchStyle) => ({
|
||||
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
@@ -1283,8 +1563,8 @@ const testCases: TestsCase[] = [
|
||||
})),
|
||||
]),
|
||||
// Deprecated header themes
|
||||
...allDeprecatedThemePresets.flatMap((preset) => [
|
||||
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||||
...allDeprecatedThemePresets.flatMap((preset) =>
|
||||
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||||
name: `With tint - Legacy header preset ${preset} - Sidebar ${sidebarStyle} - Theme mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
@@ -1310,8 +1590,8 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
}))
|
||||
),
|
||||
{
|
||||
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
@@ -1351,6 +1631,70 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Edit on Git page actions',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'With Edit on Git as the default action',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
items: [CustomizationPageActionType.Git],
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Edit', exact: true })
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
|
||||
);
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'With Edit on Git in the dropdown',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
items: [
|
||||
CustomizationPageActionType.Markdown,
|
||||
CustomizationPageActionType.Git,
|
||||
],
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page.getByRole('button', { name: 'More' }).click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Edit on GitHub' })
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
|
||||
);
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Without Edit on Git',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
items: [CustomizationPageActionType.Markdown],
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page.getByRole('button', { name: 'More' }).click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Edit on GitHub' })
|
||||
).toHaveCount(0);
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Page actions',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
@@ -1609,6 +1953,71 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Space (oversized token)',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||||
// Our Cloudflare stack still folds multiple Set-Cookie headers into one,
|
||||
// breaking chunked cookies (variant of opennextjs-cloudflare#501).
|
||||
skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare',
|
||||
tests: [
|
||||
{
|
||||
name: 'Oversized token is chunked into cookies and survives navigation',
|
||||
url: () => {
|
||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
// Inflate the token above the ~4KB browser cookie limit,
|
||||
// like an IdP issuing many group claims would.
|
||||
groups: Array.from(
|
||||
{ length: 60 },
|
||||
(_, index) => `group-${index}-${'x'.repeat(80)}`
|
||||
),
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
}
|
||||
);
|
||||
return `first?jwt_token=${token}`;
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'first' })
|
||||
).toBeVisible();
|
||||
|
||||
// The token must be persisted as a chunk-count marker plus chunk cookies.
|
||||
const cookies = await page.context().cookies();
|
||||
// Next.js percent-encodes cookie values, so the raw value is `chunks%3A2`.
|
||||
const marker = cookies.find(
|
||||
(cookie) =>
|
||||
cookie.name.startsWith(VISITOR_TOKEN_COOKIE) &&
|
||||
/^chunks(:|%3A)\d+$/.test(cookie.value)
|
||||
);
|
||||
expect(marker).toBeDefined();
|
||||
const chunks = cookies.filter((cookie) =>
|
||||
cookie.name.startsWith(`${marker?.name}-`)
|
||||
);
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Navigating without the token must authenticate from the chunked cookie.
|
||||
// `first` is the space's default page, so the post-sign-in redirect
|
||||
// canonicalizes to the space root: derive `second` from that base.
|
||||
const secondURL = new URL(page.url());
|
||||
const basePathname = secondURL.pathname
|
||||
.replace(/\/first\/?$/, '')
|
||||
.replace(/\/$/, '');
|
||||
secondURL.pathname = `${basePathname}/second`;
|
||||
secondURL.search = '';
|
||||
await page.goto(secondURL.toString());
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'second' })
|
||||
).toBeVisible();
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Collection',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
|
||||
@@ -2236,6 +2645,33 @@ const testCases: TestsCase[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Only tracks ask_view once the widget is opened',
|
||||
// `trigger=custom` loads the frame but leaves the window closed.
|
||||
url: '?trigger=custom',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const askViews = trackInsightsEvents(page, 'ask_view');
|
||||
const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat');
|
||||
|
||||
// The assistant renders inside the hidden frame, but nobody has seen it.
|
||||
await expect(chat).toBeAttached({ timeout: 20000 });
|
||||
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
|
||||
expect(askViews).toHaveLength(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Open' }).click();
|
||||
await expect(chat).toBeVisible();
|
||||
await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1);
|
||||
|
||||
// Hiding and showing the same frame again is not a second view.
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(chat).toBeHidden();
|
||||
await page.getByRole('button', { name: 'Open' }).click();
|
||||
await expect(chat).toBeVisible();
|
||||
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
|
||||
expect(askViews).toHaveLength(1);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2405,7 +2841,7 @@ const testCases: TestsCase[] = [
|
||||
actions.nth(1).click(),
|
||||
]);
|
||||
// Verify the new page would have opened with the expected URL
|
||||
expect(newPage.url()).toContain('gitbook.com');
|
||||
await expect(newPage).toHaveURL(/gitbook\.com/);
|
||||
// Close it immediately to avoid navigation
|
||||
await newPage.close();
|
||||
|
||||
@@ -2553,7 +2989,7 @@ const testCases: TestsCase[] = [
|
||||
openInNewTabButton.click(),
|
||||
]);
|
||||
// Verify the new page would have opened with the expected URL
|
||||
expect(newPage.url()).toContain('gitbook.gitbook.io');
|
||||
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
|
||||
// Close it immediately to avoid navigation
|
||||
await newPage.close();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
import { waitForIcons } from './util';
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
|
||||
// that the store pulls in — Playwright's loader doesn't resolve it.
|
||||
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
|
||||
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
|
||||
* group must show exactly the most-recently-activated of its options (its "first ranking"
|
||||
* selection), falling back to its default when none are active. These run in a real browser against
|
||||
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
|
||||
*/
|
||||
|
||||
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
|
||||
async function renderGroup(page: Page, slugs: string[]) {
|
||||
const css = generateSelectCSS(slugs);
|
||||
const scope = selectSetClassName(slugs);
|
||||
const panes = slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
|
||||
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
|
||||
*/
|
||||
async function applySelection(page: Page, active: string[]) {
|
||||
for (const [rank, value] of active.entries()) {
|
||||
await page.evaluate(
|
||||
({ attr, value }) => document.documentElement.setAttribute(attr, value),
|
||||
{ attr: selectRankAttribute(rank), value }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert exactly one pane is visible, and it is the expected slug. */
|
||||
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(page: Page, slugs: string[], active: string[]) {
|
||||
await renderGroup(page, slugs);
|
||||
await applySelection(page, active);
|
||||
}
|
||||
|
||||
test.describe('select CSS visibility', () => {
|
||||
const slugs = ['python', 'go', 'java'];
|
||||
|
||||
test('shows the default when nothing is selected', async ({ page }) => {
|
||||
await setup(page, slugs, []);
|
||||
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
|
||||
});
|
||||
|
||||
test('shows the selected option and hides the rest', async ({ page }) => {
|
||||
await setup(page, slugs, ['go']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('shows the most-recently-activated option of the group', async ({ page }) => {
|
||||
// Recency list is most-recent-first: `go` is more recent than `python`.
|
||||
await setup(page, slugs, ['go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
|
||||
await setup(page, slugs, ['python', 'go']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('ignores more-recent selections that are not in the group', async ({ page }) => {
|
||||
// `dark` is more recent but not one of this group's options, so `go` still wins.
|
||||
await setup(page, slugs, ['dark', 'go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('falls back to the default when no active slug is in the group', async ({ page }) => {
|
||||
await setup(page, slugs, ['dark', 'light']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
|
||||
// selectors without collapsing together.
|
||||
const symbols = ['c', 'c++', 'c#'];
|
||||
await setup(page, symbols, ['c++']);
|
||||
await expectOnlyVisible(page, symbols, 'c++');
|
||||
});
|
||||
|
||||
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Two panes share the slug `js`; activating it must reveal only the first, never both.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-first')).toBeVisible();
|
||||
await expect(page.getByTestId('js-second')).toBeHidden();
|
||||
await expect(page.getByTestId('ts')).toBeHidden();
|
||||
});
|
||||
|
||||
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
|
||||
// the pinned one must win over the first-match default.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-second')).toBeVisible();
|
||||
await expect(page.getByTestId('js-first')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupSpec {
|
||||
id: string;
|
||||
slugs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
|
||||
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
|
||||
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
|
||||
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
|
||||
* sees — a real click switching every group that offers that option — driven by real browser CSS.
|
||||
*/
|
||||
async function renderGroups(page: Page, groups: GroupSpec[]) {
|
||||
const styles = [
|
||||
...new Map(
|
||||
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
|
||||
).values(),
|
||||
]
|
||||
.map((css) => `<style>${css}</style>`)
|
||||
.join('');
|
||||
|
||||
const markup = groups
|
||||
.map((group) => {
|
||||
const scope = selectSetClassName(group.slugs);
|
||||
const buttons = group.slugs
|
||||
.map(
|
||||
(slug) =>
|
||||
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
|
||||
)
|
||||
.join('');
|
||||
const panes = group.slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
|
||||
async function expectGroupShows(
|
||||
page: Page,
|
||||
groupId: string,
|
||||
slugs: string[],
|
||||
expectedSlug: string
|
||||
) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('select syncing across groups (click-driven)', () => {
|
||||
test('clicking a tab syncs every group offering that option', async ({ page }) => {
|
||||
const slugs = ['python', 'go'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs },
|
||||
{ id: 'b', slugs },
|
||||
]);
|
||||
|
||||
// Both groups start on their default (first) pane.
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
|
||||
// Clicking a tab in group A switches group B too.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'go');
|
||||
await expectGroupShows(page, 'b', slugs, 'go');
|
||||
|
||||
// And the sync works from either group.
|
||||
await page.getByTestId('b-btn-python').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
});
|
||||
|
||||
test('only groups that share the clicked option follow along', async ({ page }) => {
|
||||
const shared = ['python', 'go'];
|
||||
const other = ['go', 'rust'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs: shared },
|
||||
{ id: 'b', slugs: other },
|
||||
]);
|
||||
|
||||
// `rust` exists only in group B, so clicking it leaves group A on its default.
|
||||
await page.getByTestId('b-btn-rust').click();
|
||||
await expectGroupShows(page, 'b', other, 'rust');
|
||||
await expectGroupShows(page, 'a', shared, 'python');
|
||||
|
||||
// `go` is shared, so clicking it in A moves both groups.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', shared, 'go');
|
||||
await expectGroupShows(page, 'b', other, 'go');
|
||||
});
|
||||
});
|
||||
|
||||
interface NestedSpec {
|
||||
outer: string[];
|
||||
inner: string[];
|
||||
/** Which of the outer options hosts the nested group. */
|
||||
host: string;
|
||||
/**
|
||||
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
|
||||
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
|
||||
*/
|
||||
innerStyleFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
|
||||
* produces: panes are direct children of the element carrying the set class, and a pane's body is
|
||||
* wrapped in a padding div before the nested group.
|
||||
*/
|
||||
async function renderNestedGroups(page: Page, spec: NestedSpec) {
|
||||
const { outer, inner, host, innerStyleFirst = false } = spec;
|
||||
const outerScope = selectSetClassName(outer);
|
||||
const innerScope = selectSetClassName(inner);
|
||||
|
||||
const innerPanes = inner
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
|
||||
|
||||
const outerPanes = outer
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
|
||||
if (innerStyleFirst) {
|
||||
styles.reverse();
|
||||
}
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
|
||||
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
|
||||
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
|
||||
*/
|
||||
test.describe('select CSS visibility in nested groups', () => {
|
||||
const outer = ['macos', 'windows'];
|
||||
const inner = ['npm', 'yarn'];
|
||||
|
||||
test('shows both defaults when nothing is selected', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'windows' });
|
||||
await applySelection(page, ['windows']);
|
||||
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves each group against its own options', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { type CDPSession, type Locator, type Page, expect, test } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
import { waitForCookiesDialog } from './util';
|
||||
|
||||
/** Already covered by `customers.spec.ts`, and among the largest pages we render. */
|
||||
const LARGE_PAGE_URL = 'https://docs.snyk.io/snyk-api/reference/apps';
|
||||
|
||||
/** Table rows the preview workflow pastes into a PR comment. Relative to the package, gitignored. */
|
||||
const REPORT_FILE = 'test-results/style-perf.md';
|
||||
|
||||
// Share of the page one popup may restyle: a ratio so it survives the page growing, a count rather
|
||||
// than a duration so it does not move with CI machine speed. `search` is above 1 because it still
|
||||
// restyles the whole document — a ratchet against today's state, to lower as families get fixed.
|
||||
const RESTYLE_BUDGET_RATIO = {
|
||||
'openapi-select': 0.25,
|
||||
search: 1.25,
|
||||
};
|
||||
|
||||
type TraceEvent = { name: string; args?: { elementCount?: number } };
|
||||
|
||||
/** Elements Blink restyled while `action` ran — DevTools reports this as "Elements affected". */
|
||||
async function countRestyledElements(
|
||||
page: Page,
|
||||
client: CDPSession,
|
||||
action: () => Promise<void>
|
||||
): Promise<number> {
|
||||
// Style is computed lazily, so settle queued recalcs or earlier work lands in the trace.
|
||||
await page.evaluate(() => void document.body.offsetHeight);
|
||||
|
||||
await client.send('Tracing.start', {
|
||||
categories: 'disabled-by-default-devtools.timeline',
|
||||
transferMode: 'ReturnAsStream',
|
||||
});
|
||||
|
||||
await action();
|
||||
await page.evaluate(() => void document.body.offsetHeight);
|
||||
|
||||
const handle = await new Promise<string>((resolve) => {
|
||||
client.once('Tracing.tracingComplete', (event) => resolve(event.stream as string));
|
||||
void client.send('Tracing.end');
|
||||
});
|
||||
|
||||
let raw = '';
|
||||
for (let eof = false; !eof;) {
|
||||
const chunk = await client.send('IO.read', { handle });
|
||||
raw += chunk.data;
|
||||
eof = chunk.eof;
|
||||
}
|
||||
await client.send('IO.close', { handle });
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const events: TraceEvent[] = Array.isArray(parsed) ? parsed : parsed.traceEvents;
|
||||
|
||||
return events
|
||||
.filter((event) => event.name === 'UpdateLayoutTree')
|
||||
.reduce((total, event) => total + (event.args?.elementCount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a *re*open: the first open also pays for mounting the popup, and waiting on anything
|
||||
* looser than the popup being hidden again undercounts the reopen by ~10x.
|
||||
*/
|
||||
async function measureReopen(
|
||||
page: Page,
|
||||
client: CDPSession,
|
||||
open: () => Promise<void>,
|
||||
popup: Locator
|
||||
): Promise<number> {
|
||||
await open();
|
||||
await expect(popup).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(popup).toBeHidden();
|
||||
|
||||
const restyled = await countRestyledElements(page, client, open);
|
||||
await expect(popup, 'the measurement is meaningless if the popup did not open').toBeVisible();
|
||||
|
||||
// Leave the page as we found it, so the next measurement is not measuring this popup's teardown.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(popup).toBeHidden();
|
||||
|
||||
return restyled;
|
||||
}
|
||||
|
||||
type Measurement = { name: keyof typeof RESTYLE_BUDGET_RATIO; restyled: number };
|
||||
|
||||
function expectWithinBudgets(measurements: Measurement[], total: number) {
|
||||
const count = (value: number) => value.toLocaleString('en-US');
|
||||
|
||||
// Reported before asserting, so a blown budget still reaches the PR comment.
|
||||
mkdirSync(dirname(REPORT_FILE), { recursive: true });
|
||||
writeFileSync(
|
||||
REPORT_FILE,
|
||||
measurements
|
||||
.map(({ name, restyled }) => {
|
||||
const ratio = restyled / total;
|
||||
const budget = RESTYLE_BUDGET_RATIO[name];
|
||||
const status = ratio < budget ? '✅' : '❌';
|
||||
return `| \`${name}\` | ${count(restyled)} | ${count(total)} | ${(ratio * 100).toFixed(1)}% | ${(budget * 100).toFixed(0)}% | ${status} |\n`;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
|
||||
// Soft, so one breach still reports the other interaction rather than masking it.
|
||||
for (const { name, restyled } of measurements) {
|
||||
expect
|
||||
.soft(restyled / total, `${name} restyled ${restyled} of ${total} elements`)
|
||||
.toBeLessThan(RESTYLE_BUDGET_RATIO[name]);
|
||||
}
|
||||
}
|
||||
|
||||
const countElements = (page: Page) =>
|
||||
page.evaluate(() => document.getElementsByTagName('*').length);
|
||||
|
||||
// Not `networkidle`: third-party subresources on this customer site can hang, so it never settles.
|
||||
async function waitForStableElementCount(page: Page): Promise<number> {
|
||||
let previous = await countElements(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await countElements(page);
|
||||
const stable = current === previous;
|
||||
previous = current;
|
||||
return stable;
|
||||
},
|
||||
{ message: 'the tree never stopped changing', timeout: 15_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
async function openLargePage(page: Page) {
|
||||
await page.goto(getContentTestURL(LARGE_PAGE_URL));
|
||||
await waitForCookiesDialog(page);
|
||||
|
||||
// Measure a settled page: before hydration the tree is smaller and no popup can open at all.
|
||||
await page.locator('html.hydrated').waitFor();
|
||||
await expect(page.getByLabel('OpenAPI Select').first()).toBeAttached();
|
||||
|
||||
const totalElements = await waitForStableElementCount(page);
|
||||
const client = await page.context().newCDPSession(page);
|
||||
|
||||
return { client, totalElements };
|
||||
}
|
||||
|
||||
// Guards the harness: if an idle window is busy, background work is leaking into the measurements
|
||||
// and they mean nothing. Closing a popup leaves a few elements of teardown, so this is not zero —
|
||||
// it only has to sit far below a real measurement (hundreds) and a whole-document restyle (~11k).
|
||||
const IDLE_RESTYLE_TOLERANCE = 100;
|
||||
|
||||
async function expectIdle(page: Page, client: CDPSession) {
|
||||
const restyled = await countRestyledElements(page, client, async () => {});
|
||||
expect(restyled, 'an idle page should barely restyle').toBeLessThan(IDLE_RESTYLE_TOLERANCE);
|
||||
}
|
||||
|
||||
// Both checks below probe a block's children, never the block itself: `content-visibility: auto`
|
||||
// skips an element's *contents*, so the element carrying it keeps reporting visible and every block
|
||||
// would look rendered. Same reason `toBeVisible()` is no use here — a skipped child still has a box.
|
||||
function countRenderedBlocks(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
[...document.querySelectorAll('.openapi-block')].filter((block) =>
|
||||
[...block.children].some((child) =>
|
||||
child.checkVisibility({ contentVisibilityAuto: true })
|
||||
)
|
||||
).length
|
||||
);
|
||||
}
|
||||
|
||||
test('off-screen OpenAPI blocks skip their rendering work', async ({ page }) => {
|
||||
await openLargePage(page);
|
||||
|
||||
const blocks = page.locator('.openapi-block');
|
||||
const total = await blocks.count();
|
||||
expect(total, 'the fixture needs enough blocks for some to sit off-screen').toBeGreaterThan(4);
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
const rendered = await countRenderedBlocks(page);
|
||||
expect(
|
||||
rendered,
|
||||
`${rendered} of ${total} blocks rendered from the top of the page`
|
||||
).toBeLessThan(total / 2);
|
||||
|
||||
// Un-skipping on approach is what keeps #anchors and find-in-page working.
|
||||
const last = blocks.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
last.evaluate((block) =>
|
||||
[...block.children].some((child) =>
|
||||
child.checkVisibility({ contentVisibilityAuto: true })
|
||||
)
|
||||
),
|
||||
{ message: 'the last block never rendered after being scrolled to' }
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('opening a popup restyles a bounded part of a large API reference', async ({ page }) => {
|
||||
const { client, totalElements } = await openLargePage(page);
|
||||
await expectIdle(page, client);
|
||||
|
||||
const trigger = page.getByLabel('OpenAPI Select').first();
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const select = await measureReopen(
|
||||
page,
|
||||
client,
|
||||
() => trigger.click(),
|
||||
page.locator('.openapi-select-popover')
|
||||
);
|
||||
|
||||
await expectIdle(page, client);
|
||||
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
const search = await measureReopen(
|
||||
page,
|
||||
client,
|
||||
() => searchInput.focus(),
|
||||
page.getByTestId('search-results')
|
||||
);
|
||||
|
||||
expectWithinBudgets(
|
||||
[
|
||||
{ name: 'openapi-select', restyled: select },
|
||||
{ name: 'search', restyled: search },
|
||||
],
|
||||
totalElements
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
// Import the specific module (not the package barrel) so this stays free of the `@/` path alias,
|
||||
// which Playwright's loader doesn't resolve — same reason as `select.spec.ts`.
|
||||
import { resolveOverflowingItems } from '../src/components/hooks/listOverflow';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the tab bar's overflow rule (`useListOverflow`), which decides which tabs move
|
||||
* into the "more" dropdown. Rects are measured in a real browser so the geometry is genuine — the
|
||||
* layout below mirrors the tab bar in `DynamicTabs`: a non-wrapping flex row of `shrink-0` items,
|
||||
* clipped by `overflow: hidden`, measured with the dropdown rendered ahead of the tabs.
|
||||
*
|
||||
* The regression these guard is a dropdown appearing when nothing actually overflowed: measuring
|
||||
* with the dropdown present consumes `MENU` pixels, so a bar whose tabs total just under the
|
||||
* container would hand its last tab to a menu it never needed.
|
||||
*/
|
||||
|
||||
/** Width of the ellipsis button, matching the `px-3.5` + `size-4` icon of the real one. */
|
||||
const MENU = 44;
|
||||
|
||||
interface Row {
|
||||
/** Container width in px. */
|
||||
container: number;
|
||||
/** Tab widths in px, in order. */
|
||||
tabs: number[];
|
||||
/** Whether the dropdown is reserving space ahead of the tabs, as during a measure pass. */
|
||||
withMenu?: boolean;
|
||||
/** Hide an ancestor, so the row measures with no box at all. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the row out in the browser and run the real rule over the rects it produces.
|
||||
* Returns the ids reported as overflowing, or `null` when the measurement carried no information.
|
||||
*/
|
||||
async function measure(page: Page, row: Row): Promise<string[] | null> {
|
||||
const { container, tabs, withMenu = true, hidden = false } = row;
|
||||
|
||||
const items = tabs
|
||||
.map((w, i) => `<div class="item" id="tab-${i}" style="width:${w}px">${i}</div>`)
|
||||
.join('');
|
||||
const menu = withMenu ? `<div class="item" style="width:${MENU}px">…</div>` : '';
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
.pane { ${hidden ? 'display: none;' : ''} }
|
||||
.bar {
|
||||
width: ${container}px;
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar::after { content: ""; flex: 1; }
|
||||
.item { flex-shrink: 0; max-width: 100%; }
|
||||
</style></head><body>
|
||||
<div class="pane"><div class="bar" id="bar">${menu}${items}</div></div>
|
||||
</body></html>`
|
||||
);
|
||||
|
||||
const measured = await page.evaluate(() => {
|
||||
const bar = document.getElementById('bar');
|
||||
if (!bar) {
|
||||
throw new Error('missing bar');
|
||||
}
|
||||
const rect = bar.getBoundingClientRect();
|
||||
return {
|
||||
container: { left: rect.left, right: rect.right, width: rect.width },
|
||||
items: [...bar.querySelectorAll<HTMLElement>('.item[id]')].map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { id: el.id, rect: { left: r.left, right: r.right } };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = resolveOverflowingItems(measured.container, measured.items);
|
||||
return result ? [...result].sort() : null;
|
||||
}
|
||||
|
||||
test.describe('tab overflow rule', () => {
|
||||
test('reports nothing when every tab fits with room to spare', async ({ page }) => {
|
||||
// 3 x 100 = 300 of 600, so even with the menu reserved there is slack.
|
||||
expect(await measure(page, { container: 600, tabs: [100, 100, 100] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('reports nothing when the tabs fit exactly', async ({ page }) => {
|
||||
expect(await measure(page, { container: 300, tabs: [100, 100, 100] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('reports nothing when only the reserved menu made the row overflow', async ({ page }) => {
|
||||
// The regression: tabs total 300 and the container is 320, so they fit — but measuring
|
||||
// reserves 44 for the menu, which used to push the last tab out and show a needless
|
||||
// dropdown. Every width in `container - MENU < 300 <= container` must stay empty.
|
||||
for (const container of [300, 305, 320, 330, 343]) {
|
||||
expect(
|
||||
await measure(page, { container, tabs: [100, 100, 100] }),
|
||||
`container ${container}px`
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('reports the tabs that genuinely do not fit alongside the menu', async ({ page }) => {
|
||||
// 300 of tabs into 290: the row really does overflow, so the menu is warranted and the
|
||||
// remaining tabs must fit beside it (100 + 100 + 44 = 244 <= 290).
|
||||
expect(await measure(page, { container: 290, tabs: [100, 100, 100] })).toEqual(['tab-2']);
|
||||
});
|
||||
|
||||
test('gives up as many tabs as the width demands', async ({ page }) => {
|
||||
expect(await measure(page, { container: 190, tabs: [100, 100, 100] })).toEqual([
|
||||
'tab-1',
|
||||
'tab-2',
|
||||
]);
|
||||
expect(await measure(page, { container: 150, tabs: [100, 100, 100] })).toEqual([
|
||||
'tab-1',
|
||||
'tab-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('moves every tab into the menu once not even the first fits beside it', async ({
|
||||
page,
|
||||
}) => {
|
||||
// 100 + 44 > 120, so no tab can share the row with the menu. Everything goes in, leaving a
|
||||
// bar that is only the menu — deliberately, since the menu is then the sole route to any
|
||||
// tab. Forcing the first tab to stay would push the menu past the clipped edge and strand
|
||||
// the rest.
|
||||
expect(await measure(page, { container: 120, tabs: [100, 100, 100] })).toEqual([
|
||||
'tab-0',
|
||||
'tab-1',
|
||||
'tab-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps a single tab that fills the bar rather than hiding it behind a menu', async ({
|
||||
page,
|
||||
}) => {
|
||||
// `max-width: 100%` truncates it to the container, so it fits — a lone tab should never be
|
||||
// the only thing in the dropdown.
|
||||
expect(await measure(page, { container: 200, tabs: [400] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('cuts a nested bar earlier, since its pane padding narrows it', async ({ page }) => {
|
||||
// A nested tab bar sits inside a `p-4` pane, so it has 32px less to work with. At 330 the
|
||||
// outer bar keeps all three tabs; the nested one at 330 - 32 cannot.
|
||||
expect(await measure(page, { container: 330, tabs: [100, 100, 100] })).toEqual([]);
|
||||
expect(await measure(page, { container: 330 - 32, tabs: [100, 100, 100] })).toEqual([
|
||||
'tab-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('progressively fills the menu as a long list is squeezed', async ({ page }) => {
|
||||
const tabs = Array.from({ length: 12 }, () => 100);
|
||||
let previous = -1;
|
||||
for (const container of [1300, 1200, 1000, 800, 600, 400, 200]) {
|
||||
const overflowing = await measure(page, { container, tabs });
|
||||
expect(overflowing, `container ${container}px`).not.toBeNull();
|
||||
const hidden = overflowing?.length ?? 0;
|
||||
// Never loses a tab, and never un-hides one as the space shrinks.
|
||||
expect(hidden, `container ${container}px`).toBeGreaterThanOrEqual(previous);
|
||||
expect(hidden, `container ${container}px`).toBeLessThanOrEqual(tabs.length);
|
||||
previous = hidden;
|
||||
}
|
||||
// Widest fits everything; at 200 only the first tab still fits beside the menu.
|
||||
expect(await measure(page, { container: 1300, tabs })).toEqual([]);
|
||||
expect((await measure(page, { container: 200, tabs }))?.length).toBe(11);
|
||||
});
|
||||
|
||||
test('reports nothing measurable while an ancestor is hidden', async ({ page }) => {
|
||||
// A bar behind an inactive tab has no box, so every rect is zero. That says nothing about
|
||||
// what fits, and must not be mistaken for "everything overflows".
|
||||
expect(
|
||||
await measure(page, { container: 200, tabs: [100, 100, 100], hidden: true })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('reports nothing measurable for an empty list', async ({ page }) => {
|
||||
expect(await measure(page, { container: 600, tabs: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,16 @@
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
type BrowserContext,
|
||||
type FrameLocator,
|
||||
type Page,
|
||||
type Response,
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import {
|
||||
CustomizationAIMode,
|
||||
CustomizationBackground,
|
||||
@@ -22,17 +34,6 @@ import {
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
} from '@gitbook/api';
|
||||
import {
|
||||
type BrowserContext,
|
||||
type FrameLocator,
|
||||
type Page,
|
||||
type Response,
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import { getContentTestURL, getTestURL } from '../tests/utils';
|
||||
|
||||
@@ -91,7 +92,7 @@ export interface Test {
|
||||
export type TestsCase = {
|
||||
name: string;
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
tests: Test[];
|
||||
contentBaseURL?: string;
|
||||
/**
|
||||
* Whether screenshots in this test case should capture the full scrollable page by default.
|
||||
@@ -111,10 +112,10 @@ export const allThemeModes: CustomizationDefaultThemeMode[] = [
|
||||
CustomizationDefaultThemeMode.Dark,
|
||||
];
|
||||
|
||||
export const allTintColors: Array<{
|
||||
export const allTintColors: {
|
||||
label: string;
|
||||
value: CustomizationThemedColor | undefined;
|
||||
}> = [
|
||||
}[] = [
|
||||
{
|
||||
label: 'Off',
|
||||
value: undefined,
|
||||
@@ -172,6 +173,10 @@ export async function waitForCookiesDialog(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForHydration(page: Page) {
|
||||
await page.locator('html.hydrated').waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the GitBook admin toolbar to be present.
|
||||
*
|
||||
@@ -460,7 +465,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
socialAccounts: [],
|
||||
};
|
||||
|
||||
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||
const encoded = rison.encode_object(
|
||||
deepMerge(DEFAULT_CUSTOMIZATION, partial, {
|
||||
arrayMerge: (_target, source) => source,
|
||||
})
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('customization', encoded);
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
// @ts-check
|
||||
|
||||
import { networkInterfaces } from 'node:os';
|
||||
|
||||
// Next blocks its dev client and HMR when a physical device opens the server over a LAN address.
|
||||
// Needed to hydrate the dev client on physical phones/tablets over the internal network
|
||||
const allowedDevOrigins =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [
|
||||
...new Set(
|
||||
Object.values(networkInterfaces())
|
||||
.flat()
|
||||
.filter(
|
||||
(networkInterface) =>
|
||||
networkInterface &&
|
||||
!networkInterface.internal &&
|
||||
networkInterface.family === 'IPv4'
|
||||
)
|
||||
.map((networkInterface) => networkInterface?.address)
|
||||
),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
|
||||
let deploymentId =
|
||||
process.env.GITBOOK_RUNTIME === 'cloudflare'
|
||||
@@ -21,6 +42,7 @@ if (VERCEL_TARGET_ENV === 'preview') {
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
allowedDevOrigins,
|
||||
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
@@ -37,15 +59,24 @@ const nextConfig = {
|
||||
optimisticClientCache: false,
|
||||
// Disable splitting the RSC in like 5 chunks
|
||||
prefetchInlining: true,
|
||||
|
||||
// Rewrites barrel imports into deep ones: without it, importing a single helper from
|
||||
// react-openapi drags its whole client renderer into every page's entry.
|
||||
optimizePackageImports: ['@gitbook/react-openapi'],
|
||||
},
|
||||
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
BUILD_VERSION: (
|
||||
process.env.GITBOOK_HEAD_SHA ||
|
||||
process.env.GITHUB_SHA ||
|
||||
Date.now().toString()
|
||||
).slice(0, 7),
|
||||
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
@@ -74,6 +105,9 @@ const nextConfig = {
|
||||
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
poweredByHeader: false,
|
||||
|
||||
// We maintain our own AGENTS.md/CLAUDE.md at the repo root.
|
||||
agentRules: false,
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
|
||||
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||
|
||||
//Only needed to run locally, in prod we'll use the one from do.js
|
||||
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||
|
||||
@@ -28,9 +28,8 @@ export default {
|
||||
async fetch(request, env, ctx) {
|
||||
return runWithCloudflareRequestContext(request, env, ctx, async () => {
|
||||
// We can't move the handler import to the top level, otherwise the runtime will not be properly initialized
|
||||
const { handler } = await import(
|
||||
'../../.open-next/server-functions/default/handler.mjs'
|
||||
);
|
||||
const { handler } =
|
||||
await import('../../.open-next/server-functions/default/handler.mjs');
|
||||
|
||||
// - `Request`s are handled by the Next server
|
||||
return handler(request, env, ctx);
|
||||
|
||||
@@ -3,177 +3,178 @@
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2026-04-02",
|
||||
"minify": true,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
"global_fetch_strictly_public",
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
},
|
||||
"env": {
|
||||
"dev": {
|
||||
"vars": {
|
||||
"STAGE": "dev",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
"GITBOOK_URL": "http://localhost:8771",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-server-dev"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-dev",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
"class_name": "R2WriteBuffer",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
}
|
||||
]
|
||||
"class_name": "DOShardedTagCache",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["R2WriteBuffer", "DOShardedTagCache"]
|
||||
}
|
||||
]
|
||||
"new_sqlite_classes": ["R2WriteBuffer", "DOShardedTagCache"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"preview": {
|
||||
"vars": {
|
||||
"STAGE": "preview",
|
||||
// Just as a test for the preview environment to check that everything works
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-server-preview"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-preview",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
}
|
||||
]
|
||||
}
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-staging",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-server-staging"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-staging",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
}
|
||||
]
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
],
|
||||
},
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-staging-tail"
|
||||
}
|
||||
]
|
||||
"service": "gitbook-x-staging-tail",
|
||||
},
|
||||
],
|
||||
},
|
||||
"production": {
|
||||
"vars": {
|
||||
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-production"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-production",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-server-production"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-production",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
}
|
||||
]
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
],
|
||||
},
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-prod-tail"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"service": "gitbook-x-prod-tail",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,96 +6,96 @@
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
"global_fetch_strictly_public",
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
"STAGE": "preview",
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-preview"
|
||||
}
|
||||
"service": "gitbook-open-v2-preview",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler"
|
||||
"class_name": "DOQueueHandler",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
"class_name": "DOShardedTagCache",
|
||||
},
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
}
|
||||
]
|
||||
"class_name": "R2WriteBuffer",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||
}
|
||||
]
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"STAGE": "staging",
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-staging",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-staging-tail"
|
||||
}
|
||||
"service": "gitbook-x-staging-tail",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-staging"
|
||||
}
|
||||
"service": "gitbook-open-v2-staging",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler"
|
||||
"class_name": "DOQueueHandler",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
"class_name": "DOShardedTagCache",
|
||||
},
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
}
|
||||
]
|
||||
"class_name": "R2WriteBuffer",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||
}
|
||||
]
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"production": {
|
||||
"vars": {
|
||||
@@ -104,47 +104,47 @@
|
||||
// We don't want to pollute the memory with broken cache entries
|
||||
// Most of the time, those are fake requests.
|
||||
"NEXT_CACHE_DO_QUEUE_MAX_RETRIES": "1",
|
||||
"STAGE": "production"
|
||||
"STAGE": "production",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-production"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-production",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-prod-tail"
|
||||
}
|
||||
"service": "gitbook-x-prod-tail",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-production"
|
||||
}
|
||||
"service": "gitbook-open-v2-production",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler"
|
||||
"class_name": "DOQueueHandler",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
"class_name": "DOShardedTagCache",
|
||||
},
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
}
|
||||
]
|
||||
"class_name": "R2WriteBuffer",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||
|
||||
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||
import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs';
|
||||
|
||||
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
|
||||
@@ -38,7 +38,7 @@ export default class extends WorkerEntrypoint {
|
||||
reqOrResp.headers.get('x-opennext-cache') ?? 'MISS',
|
||||
getResolvedRoute(reqOrResp, 'middleware')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(logMessage);
|
||||
return reqOrResp;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default class extends WorkerEntrypoint {
|
||||
`SERVER-${response.headers.get('x-nextjs-cache') ?? 'MISS'}`,
|
||||
getResolvedRoute(reqOrResp, 'unresolved')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(formatedLog);
|
||||
|
||||
return response;
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
"global_fetch_strictly_public",
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
},
|
||||
"assets": {
|
||||
"directory": "../../.open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
"binding": "ASSETS",
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
},
|
||||
"env": {
|
||||
"dev": {
|
||||
@@ -27,129 +27,129 @@
|
||||
// we should just bypass the cache to go to the server directly
|
||||
"SHOULD_BYPASS_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
"GITBOOK_URL": "http://localhost:8771",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-dev"
|
||||
"service": "gitbook-open-v2-dev",
|
||||
},
|
||||
{
|
||||
"binding": "DEFAULT_WORKER",
|
||||
"service": "gitbook-open-v2-server-dev"
|
||||
}
|
||||
]
|
||||
"service": "gitbook-open-v2-server-dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
"preview": {
|
||||
"vars": {
|
||||
"STAGE": "preview",
|
||||
"PREVIEW_HOSTNAME": "TO_REPLACE",
|
||||
"WORKER_VERSION_ID": "TO_REPLACE"
|
||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-preview"
|
||||
"service": "gitbook-open-v2-preview",
|
||||
},
|
||||
{
|
||||
"binding": "DEFAULT_WORKER",
|
||||
"service": "gitbook-open-v2-server-preview"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-preview",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-preview"
|
||||
}
|
||||
]
|
||||
}
|
||||
"script_name": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"STAGE": "staging",
|
||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "open-2c.gitbook-staging.com/*",
|
||||
"zone_name": "gitbook-staging.com"
|
||||
"zone_name": "gitbook-staging.com",
|
||||
},
|
||||
{
|
||||
"pattern": "static-2c.gitbook-staging.com/*",
|
||||
"zone_name": "gitbook-staging.com"
|
||||
}
|
||||
"zone_name": "gitbook-staging.com",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-staging",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-staging"
|
||||
"service": "gitbook-open-v2-staging",
|
||||
},
|
||||
{
|
||||
"binding": "DEFAULT_WORKER",
|
||||
"service": "gitbook-open-v2-server-staging"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-staging",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-staging-tail"
|
||||
}
|
||||
"service": "gitbook-x-staging-tail",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-staging"
|
||||
}
|
||||
]
|
||||
"script_name": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||
}
|
||||
]
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"production": {
|
||||
"vars": {
|
||||
@@ -161,64 +161,64 @@
|
||||
"DEBUG_CLOUDFLARE": "true",
|
||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||
"STAGE": "production",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "open-2c.gitbook.com/*",
|
||||
"zone_name": "gitbook.com"
|
||||
"zone_name": "gitbook.com",
|
||||
},
|
||||
{
|
||||
"pattern": "static-2c.gitbook.com/*",
|
||||
"zone_name": "gitbook.com"
|
||||
}
|
||||
"zone_name": "gitbook.com",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-production"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-production",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-production"
|
||||
"service": "gitbook-open-v2-production",
|
||||
},
|
||||
{
|
||||
"binding": "DEFAULT_WORKER",
|
||||
"service": "gitbook-open-v2-server-production"
|
||||
}
|
||||
"service": "gitbook-open-v2-server-production",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
{
|
||||
"service": "gitbook-x-prod-tail"
|
||||
}
|
||||
"service": "gitbook-x-prod-tail",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler",
|
||||
"script_name": "gitbook-open-v2-do-production"
|
||||
}
|
||||
]
|
||||
"script_name": "gitbook-open-v2-do-production",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||
import type {
|
||||
CacheEntryType,
|
||||
CacheValue,
|
||||
@@ -6,8 +6,7 @@ import type {
|
||||
WithLastModified,
|
||||
} from '@opennextjs/aws/types/overrides.js';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
|
||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||
export const DEFAULT_PREFIX = 'incremental-cache';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getLogger } from '@/lib/logger';
|
||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||
|
||||
import { getLogger } from '@/lib/logger';
|
||||
|
||||
export default {
|
||||
name: 'GitbookISRQueue',
|
||||
send: async (msg) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createLogger, getLogger } from '@/lib/logger';
|
||||
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||
|
||||
import { createLogger, getLogger } from '@/lib/logger';
|
||||
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.27.2",
|
||||
"version": "0.28.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
@@ -19,16 +20,9 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@opennextjs/aws": "4.1.3",
|
||||
"@opennextjs/cloudflare": "1.20.5",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
@@ -57,7 +51,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next": "^16.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -67,9 +61,8 @@
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-hotkeys-hook": "^5.3.3",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -110,10 +103,12 @@
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"bun-types": "catalog:",
|
||||
"deepmerge": "^4.3.1",
|
||||
"diff": "^8.0.2",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"stylelint": "^16.16.0",
|
||||
"stylelint-no-unsupported-browser-features": "^8.1.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
@@ -123,18 +118,24 @@
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.ts && bun ./scripts/download-fonts.ts",
|
||||
"generate:fonts": "bun ./scripts/generate-font-faces.ts",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar && rm -rf ./public/~gitbook/static/fonts",
|
||||
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "bun run generate:assets && next build --webpack",
|
||||
"build:local": "bun run generate:assets && GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
|
||||
"check:css-browser-compatibility:local": "bun run check:css-browser-compatibility --local origin/main",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts e2e/tabs-overflow.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-style-perf": "playwright test e2e/style-perf.spec.ts --project=chromium --reporter=list",
|
||||
"unit": "bun run generate:assets && bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,12 @@ export default defineConfig({
|
||||
'--disable-lcd-text',
|
||||
// Disable font hinting so glyph rasterization is platform-independent.
|
||||
'--font-render-hinting=none',
|
||||
// Force software rendering everywhere so image compositing/downscaling
|
||||
// always goes through the same filter, whether or not a GPU is present —
|
||||
// a machine with a real GPU renders images more sharply than headless CI
|
||||
// (no GPU, SwiftShader fallback), causing smooth-vs-pixelated diffs.
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
type CompatibilityDiagnostic,
|
||||
getCompatibilityDiagnostics,
|
||||
} from '../src/lib/cssBrowserCompatibility';
|
||||
|
||||
interface PullRequestEvent {
|
||||
pull_request: {
|
||||
base: { sha: string };
|
||||
head: { sha: string };
|
||||
number: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PullRequestFile {
|
||||
filename: string;
|
||||
previous_filename?: string;
|
||||
status: 'added' | 'copied' | 'modified' | 'removed' | 'renamed' | 'unchanged';
|
||||
}
|
||||
|
||||
interface ContentResponse {
|
||||
content: string;
|
||||
encoding: string;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
interface GitBlobResponse {
|
||||
content: string;
|
||||
encoding: string;
|
||||
}
|
||||
|
||||
class GitHubRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
class GitHubApi {
|
||||
constructor(
|
||||
private readonly repository: string,
|
||||
private readonly token: string
|
||||
) {}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GitHubRequestError(
|
||||
response.status,
|
||||
`${init?.method ?? 'GET'} ${path} failed: ${responseText}`
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as T;
|
||||
}
|
||||
|
||||
async listPullRequestFiles(pullRequestNumber: number): Promise<PullRequestFile[]> {
|
||||
const files: PullRequestFile[] = [];
|
||||
|
||||
for (let page = 1; ; page += 1) {
|
||||
const result = await this.request<PullRequestFile[]>(
|
||||
`/repos/${this.repository}/pulls/${pullRequestNumber}/files?per_page=100&page=${page}`
|
||||
);
|
||||
files.push(...result);
|
||||
if (result.length < 100) {
|
||||
return files;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getContent(path: string, ref: string): Promise<string> {
|
||||
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
|
||||
const content = await this.request<ContentResponse>(
|
||||
`/repos/${this.repository}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`
|
||||
);
|
||||
const response = content.content
|
||||
? content
|
||||
: await this.request<GitBlobResponse>(
|
||||
`/repos/${this.repository}/git/blobs/${content.sha}`
|
||||
);
|
||||
|
||||
if (response.encoding !== 'base64') {
|
||||
throw new Error(
|
||||
`Unsupported GitHub content encoding for ${path}: ${response.encoding}`
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.from(response.content.replaceAll('\n', ''), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
async getFileAtRef(path: string, ref: string): Promise<string> {
|
||||
return this.getContent(path, ref);
|
||||
}
|
||||
}
|
||||
|
||||
async function getBrowserslist(api: GitHubApi, headSha: string): Promise<string[]> {
|
||||
const packageJson = JSON.parse(
|
||||
await api.getFileAtRef('packages/gitbook/package.json', headSha)
|
||||
) as { browserslist?: string[] };
|
||||
|
||||
if (!packageJson.browserslist?.length) {
|
||||
throw new Error('packages/gitbook/package.json must define a Browserslist configuration.');
|
||||
}
|
||||
|
||||
return packageJson.browserslist;
|
||||
}
|
||||
|
||||
async function getBaseContent(
|
||||
api: GitHubApi,
|
||||
file: PullRequestFile,
|
||||
baseSha: string
|
||||
): Promise<string> {
|
||||
if (file.status === 'added') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.getFileAtRef(file.previous_filename ?? file.filename, baseSha);
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubRequestError && error.status === 404) {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function report(diagnostics: CompatibilityDiagnostic[]): boolean {
|
||||
if (diagnostics.length === 0) {
|
||||
console.log('CSS browser compatibility check passed.');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`${diagnostics.length} newly added CSS declaration(s) are not fully supported by the configured Browserslist targets:`
|
||||
);
|
||||
for (const diagnostic of diagnostics) {
|
||||
// Workflow command so the failure is annotated on the PR diff.
|
||||
console.error(
|
||||
`::error file=${diagnostic.file},line=${diagnostic.line},col=${diagnostic.column}::${diagnostic.property} is not supported by ${diagnostic.unsupportedBrowsers}`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Same check as CI, but against a local `git diff` instead of the GitHub API. */
|
||||
async function runLocal(baseRef: string): Promise<boolean> {
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
const root = git('rev-parse', '--show-toplevel').trim();
|
||||
const mergeBase = git('merge-base', baseRef, 'HEAD').trim();
|
||||
const browsers = (
|
||||
JSON.parse(await readFile(join(root, 'packages/gitbook/package.json'), 'utf8')) as {
|
||||
browserslist?: string[];
|
||||
}
|
||||
).browserslist;
|
||||
|
||||
if (!browsers?.length) {
|
||||
throw new Error('packages/gitbook/package.json must define a Browserslist configuration.');
|
||||
}
|
||||
|
||||
// Working tree, so uncommitted changes are checked too.
|
||||
const changes = git(
|
||||
'diff',
|
||||
'--name-status',
|
||||
'--find-renames',
|
||||
'--diff-filter=ACMR',
|
||||
mergeBase,
|
||||
'--',
|
||||
'*.css'
|
||||
)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, ...paths] = line.split('\t');
|
||||
const previousPath = paths.length > 1 ? paths[0] : undefined;
|
||||
const path = paths.at(-1) as string;
|
||||
return { added: status?.startsWith('A'), path, previousPath };
|
||||
});
|
||||
|
||||
const diagnostics: CompatibilityDiagnostic[] = [];
|
||||
for (const change of changes) {
|
||||
let base = '';
|
||||
if (!change.added) {
|
||||
try {
|
||||
base = git('show', `${mergeBase}:${change.previousPath ?? change.path}`);
|
||||
} catch {
|
||||
base = '';
|
||||
}
|
||||
}
|
||||
diagnostics.push(
|
||||
...(await getCompatibilityDiagnostics({
|
||||
base,
|
||||
browsers,
|
||||
file: change.path,
|
||||
head: await readFile(join(root, change.path), 'utf8'),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Checked ${changes.length} changed CSS file(s) against ${baseRef}.`);
|
||||
return report(diagnostics);
|
||||
}
|
||||
|
||||
async function run(): Promise<boolean> {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
|
||||
if (!token || !repository || !eventPath) {
|
||||
throw new Error('GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_EVENT_PATH are required.');
|
||||
}
|
||||
|
||||
const event = JSON.parse(await readFile(eventPath, 'utf8')) as PullRequestEvent;
|
||||
const pullRequest = event.pull_request;
|
||||
if (!pullRequest) {
|
||||
throw new Error('This script must run from a pull request event.');
|
||||
}
|
||||
|
||||
const api = new GitHubApi(repository, token);
|
||||
const browsers = await getBrowserslist(api, pullRequest.head.sha);
|
||||
const files = (await api.listPullRequestFiles(pullRequest.number)).filter(
|
||||
(file) => file.status !== 'removed' && file.filename.endsWith('.css')
|
||||
);
|
||||
const diagnostics: CompatibilityDiagnostic[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const [base, head] = await Promise.all([
|
||||
getBaseContent(api, file, pullRequest.base.sha),
|
||||
api.getFileAtRef(file.filename, pullRequest.head.sha),
|
||||
]);
|
||||
diagnostics.push(
|
||||
...(await getCompatibilityDiagnostics({
|
||||
base,
|
||||
browsers,
|
||||
file: file.filename,
|
||||
head,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return report(diagnostics);
|
||||
}
|
||||
|
||||
const localFlagIndex = process.argv.indexOf('--local');
|
||||
|
||||
try {
|
||||
let success: boolean;
|
||||
if (localFlagIndex === -1) {
|
||||
success = await run();
|
||||
} else {
|
||||
success = await runLocal(process.argv[localFlagIndex + 1] ?? 'origin/main');
|
||||
}
|
||||
process.exitCode = success ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -7,3 +7,5 @@ rm -rf ./.next
|
||||
rm -rf ./public/~gitbook/static/icons
|
||||
rm -rf ./public/~gitbook/static/math
|
||||
rm -rf ./public/~gitbook/static/embed
|
||||
rm -rf ./public/~gitbook/static/mermaid
|
||||
rm -rf ./public/~gitbook/static/scalar
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { FontSourcesData } from '../src/fonts/types';
|
||||
import { getFontDefinitionsHash } from './font-definitions-hash';
|
||||
|
||||
const CONCURRENCY = 8;
|
||||
const ATTEMPTS = 6;
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fontsDir = join(scriptDir, '../src/fonts');
|
||||
const outputDir = join(scriptDir, '../public/~gitbook/static/fonts');
|
||||
const sourcesPath = join(fontsDir, 'generated/sources.json');
|
||||
|
||||
const readSources = async () => JSON.parse(await readFile(sourcesPath, 'utf8')) as FontSourcesData;
|
||||
|
||||
let sourcesData = await readSources();
|
||||
if (sourcesData.definitionsHash !== (await getFontDefinitionsHash())) {
|
||||
console.warn(
|
||||
'definitions.ts changed since the font manifest was generated — regenerating. Commit the changes in src/fonts/generated.'
|
||||
);
|
||||
await import('./generate-font-faces');
|
||||
sourcesData = await readSources();
|
||||
}
|
||||
|
||||
const { google, local } = sourcesData;
|
||||
const sources = new Map<string, string>(Object.entries(local));
|
||||
for (const [googleId, { prefix, files }] of Object.entries(google)) {
|
||||
for (const file of files) {
|
||||
sources.set(`${googleId}/${file}`, `${prefix}/${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Faces move between releases; stale files would otherwise pile up in the deployed assets.
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
for (const entry of await readdir(outputDir, { recursive: true, withFileTypes: true })) {
|
||||
if (entry.isFile()) {
|
||||
const file = relative(outputDir, join(entry.parentPath, entry.name));
|
||||
if (!sources.has(file)) {
|
||||
await rm(join(outputDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pending = [...sources].filter(([file]) => !Bun.file(join(outputDir, file)).size);
|
||||
if (pending.length > 0) {
|
||||
console.log(`Downloading ${pending.length} font files…`);
|
||||
}
|
||||
|
||||
const queue = pending.values();
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
||||
|
||||
async function worker() {
|
||||
for (const [file, source] of queue) {
|
||||
const target = join(outputDir, file);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
|
||||
if (source.startsWith('./')) {
|
||||
await copyFile(join(fontsDir, source), target);
|
||||
continue;
|
||||
}
|
||||
|
||||
await writeFile(target, await fetchWithRetries(source));
|
||||
}
|
||||
}
|
||||
|
||||
// Google Fonts intermittently refuses connections when a build asks for hundreds of files at once,
|
||||
// and a single miss fails the whole build.
|
||||
async function fetchWithRetries(url: string): Promise<Buffer> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
} catch (error) {
|
||||
if (attempt >= ATTEMPTS) {
|
||||
throw new Error(`Unable to download ${url}: ${error}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export async function getFontDefinitionsHash(): Promise<string> {
|
||||
const path = join(dirname(fileURLToPath(import.meta.url)), '../src/fonts/definitions.ts');
|
||||
return createHash('sha256')
|
||||
.update(await readFile(path))
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Regenerates the committed font manifest from the Google Fonts CSS API. download-fonts.ts runs it
|
||||
// automatically when definitions.ts changed since the last generation; `bun run generate:fonts`
|
||||
// forces it (e.g. to pick up new Google Fonts releases).
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { ABC_FAVORIT, FONT_DEFINITIONS, type FontDefinition } from '../src/fonts/definitions';
|
||||
import type {
|
||||
FontFacesData,
|
||||
FontFallbackFaceData,
|
||||
FontSourcesData,
|
||||
FontVariantData,
|
||||
} from '../src/fonts/types';
|
||||
import { getFontDefinitionsHash } from './font-definitions-hash';
|
||||
|
||||
// Google Fonts picks the file format from the user agent — the same modern Chrome `next/font` sends,
|
||||
// so we keep getting compact woff2 (and vector rather than bitmap emoji).
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36';
|
||||
|
||||
// The precalculated metrics `next/font/google` uses, so the fallback faces stay identical.
|
||||
const { calculateSizeAdjustValues } = createRequire(import.meta.url)(
|
||||
'next/dist/server/font-utils'
|
||||
) as {
|
||||
calculateSizeAdjustValues: (family: string) => {
|
||||
ascent: string;
|
||||
descent: string;
|
||||
lineGap: string;
|
||||
fallbackFont: string;
|
||||
sizeAdjust: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ResolvedFace = {
|
||||
weight: string;
|
||||
style: string;
|
||||
file: string;
|
||||
source: string;
|
||||
unicodeRange: string;
|
||||
};
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const faces: FontFacesData = {};
|
||||
const sources: FontSourcesData = {
|
||||
definitionsHash: await getFontDefinitionsHash(),
|
||||
google: {},
|
||||
local: {},
|
||||
};
|
||||
|
||||
for (const [name, definition] of Object.entries(FONT_DEFINITIONS)) {
|
||||
const resolved = definition.googleId
|
||||
? await getGoogleFaces(definition)
|
||||
: await getABCFavoritFaces();
|
||||
|
||||
if (resolved.length === 0) {
|
||||
throw new Error(`No font faces resolved for ${name}`);
|
||||
}
|
||||
|
||||
recordSources(definition, resolved);
|
||||
|
||||
const subsets = [...new Set(resolved.map((face) => face.unicodeRange))];
|
||||
const variants = new Map<string, FontVariantData>();
|
||||
|
||||
for (const face of resolved) {
|
||||
const key = `${face.weight}|${face.style}`;
|
||||
let variant = variants.get(key);
|
||||
if (!variant) {
|
||||
variant = { weight: face.weight, style: face.style, files: [] };
|
||||
variants.set(key, variant);
|
||||
}
|
||||
variant.files[subsets.indexOf(face.unicodeRange)] = face.file;
|
||||
}
|
||||
|
||||
faces[name] = {
|
||||
family: definition.family,
|
||||
variable: definition.variable,
|
||||
fontFamilyValue: [
|
||||
`"${definition.family}"`,
|
||||
...(definition.adjustFallback ? [`"${definition.family} Fallback"`] : []),
|
||||
...definition.fallback,
|
||||
].join(','),
|
||||
subsets,
|
||||
variants: [...variants.values()],
|
||||
fallbackFace: definition.adjustFallback ? getFallbackFace(name, definition.family) : null,
|
||||
...(definition.googleId ? {} : { ascentOverride: ABC_FAVORIT.ascentOverride }),
|
||||
};
|
||||
}
|
||||
|
||||
const generatedDir = join(scriptDir, '../src/fonts/generated');
|
||||
await writeFile(join(generatedDir, 'faces.json'), `${JSON.stringify(faces, null, 4)}\n`);
|
||||
await writeFile(join(generatedDir, 'sources.json'), `${JSON.stringify(sources, null, 4)}\n`);
|
||||
|
||||
/** Google serves every file of a family from one versioned directory, so only the names differ. */
|
||||
function recordSources(definition: FontDefinition, resolved: ResolvedFace[]) {
|
||||
if (!definition.googleId) {
|
||||
for (const face of resolved) {
|
||||
sources.local[face.file] = face.source;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prefixes = new Set(
|
||||
resolved.map((face) => face.source.slice(0, face.source.lastIndexOf('/')))
|
||||
);
|
||||
if (prefixes.size !== 1) {
|
||||
throw new Error(`${definition.family} spans several Google Fonts directories`);
|
||||
}
|
||||
|
||||
sources.google[definition.googleId] = {
|
||||
prefix: [...prefixes][0] as string,
|
||||
files: [...new Set(resolved.map((face) => basename(face.file)))],
|
||||
};
|
||||
}
|
||||
|
||||
async function getGoogleFaces(definition: FontDefinition): Promise<ResolvedFace[]> {
|
||||
const { family, googleId, weights } = definition;
|
||||
const url = `https://fonts.googleapis.com/css2?family=${family.replaceAll(' ', '+')}:wght@${weights.join(';')}&display=swap`;
|
||||
|
||||
const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch ${family} from Google Fonts: ${response.status} (${url})`);
|
||||
}
|
||||
|
||||
const resolved = [...(await response.text()).matchAll(/@font-face\s*\{([^}]*)\}/g)].map(
|
||||
(match) => {
|
||||
const block = match[1] ?? '';
|
||||
const source = read(block, 'src')?.match(/url\((https:[^)]+\.woff2)\)/)?.[1];
|
||||
const weight = read(block, 'font-weight');
|
||||
const unicodeRange = read(block, 'unicode-range');
|
||||
|
||||
if (!source || !weight || !unicodeRange) {
|
||||
throw new Error(`Unexpected @font-face for ${family}: ${block}`);
|
||||
}
|
||||
|
||||
return {
|
||||
weight,
|
||||
style: read(block, 'font-style') ?? 'normal',
|
||||
// Google's filenames are content-addressed, so the asset can stay immutable.
|
||||
file: `${googleId}/${basename(new URL(source).pathname)}`,
|
||||
source,
|
||||
unicodeRange: unicodeRange.toLowerCase(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const missing = weights.filter((weight) => !resolved.some((face) => face.weight === weight));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Google Fonts returned no ${missing.join('/')} weight for ${family}`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function getABCFavoritFaces(): Promise<ResolvedFace[]> {
|
||||
return Promise.all(
|
||||
ABC_FAVORIT.sources.map(async (source) => {
|
||||
const path = join(scriptDir, '../src/fonts/ABCFavorit', source.file);
|
||||
const digest = createHash('sha256')
|
||||
.update(await readFile(path))
|
||||
.digest('hex');
|
||||
|
||||
return {
|
||||
weight: source.weight,
|
||||
style: source.style,
|
||||
file: `abcfavorit/${digest.slice(0, 16)}.woff2`,
|
||||
source: `./ABCFavorit/${source.file}`,
|
||||
unicodeRange: '',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getFallbackFace(name: string, family: string): FontFallbackFaceData {
|
||||
if (name === 'ABCFavorit') {
|
||||
return { family: `${family} Fallback`, local: 'Arial', ...ABC_FAVORIT.fallbackMetrics };
|
||||
}
|
||||
|
||||
const metrics = calculateSizeAdjustValues(family);
|
||||
return {
|
||||
family: `${family} Fallback`,
|
||||
local: metrics.fallbackFont,
|
||||
ascentOverride: `${metrics.ascent}%`,
|
||||
descentOverride: `${metrics.descent}%`,
|
||||
lineGapOverride: `${metrics.lineGap}%`,
|
||||
sizeAdjust: `${metrics.sizeAdjust}%`,
|
||||
};
|
||||
}
|
||||
|
||||
function read(block: string, property: string): string | undefined {
|
||||
return block.match(new RegExp(`${property}\\s*:\\s*([^;]+);`))?.[1]?.trim();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { build } from 'bun';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { MERMAID_RUNTIME_PATH } from '../src/components/DocumentView/CodeBlock/mermaid-runtime-path';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const outputDir = join(scriptDir, '../public/~gitbook/static/mermaid');
|
||||
const temporaryDir = join(outputDir, '.build');
|
||||
const require = createRequire(import.meta.url);
|
||||
const mermaidPackage = require('mermaid/package.json') as { version: string };
|
||||
const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string };
|
||||
|
||||
// The runtime URL is served as immutable, it has to change whenever the bundled versions change.
|
||||
if (
|
||||
MERMAID_RUNTIME_PATH !==
|
||||
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
|
||||
) {
|
||||
throw new Error(
|
||||
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
|
||||
);
|
||||
}
|
||||
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
await mkdir(temporaryDir, { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entrypoints: [join(scriptDir, 'mermaid-runtime.ts')],
|
||||
format: 'esm',
|
||||
minify: true,
|
||||
outdir: temporaryDir,
|
||||
target: 'browser',
|
||||
});
|
||||
|
||||
const [output] = result.outputs;
|
||||
if (!result.success || !output || result.outputs.length !== 1) {
|
||||
throw new Error(`Unable to build Mermaid runtime: ${result.logs.join('\n')}`);
|
||||
}
|
||||
|
||||
const outputPath = join(outputDir, MERMAID_RUNTIME_PATH.replace('mermaid/', ''));
|
||||
await rename(output.path, outputPath);
|
||||
await rm(temporaryDir, { force: true, recursive: true });
|
||||
@@ -0,0 +1,81 @@
|
||||
import { build } from 'bun';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { SCALAR_RUNTIME_PATH } from '../src/components/DocumentView/OpenAPI/scalar-runtime-path';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const outputDir = join(scriptDir, '../public/~gitbook/static/scalar');
|
||||
const temporaryDir = join(outputDir, '.build');
|
||||
const scalarPackage = createRequire(import.meta.url)('@scalar/api-client-react/package.json') as {
|
||||
version: string;
|
||||
};
|
||||
|
||||
if (SCALAR_RUNTIME_PATH !== `scalar/scalar-api-client@${scalarPackage.version}.mjs`) {
|
||||
throw new Error(
|
||||
'Update SCALAR_RUNTIME_PATH for the installed @scalar/api-client-react version'
|
||||
);
|
||||
}
|
||||
|
||||
const reactShim = `
|
||||
const react = () => globalThis.__gitbookScalarReact;
|
||||
export const createContext = (...args) => react().createContext(...args);
|
||||
export const useContext = (...args) => react().useContext(...args);
|
||||
export const useEffect = (...args) => react().useEffect(...args);
|
||||
export const useRef = (...args) => react().useRef(...args);
|
||||
export const useSyncExternalStore = (...args) => react().useSyncExternalStore(...args);
|
||||
`;
|
||||
|
||||
const jsxRuntimeShim = `
|
||||
const runtime = () => globalThis.__gitbookScalarJSXRuntime;
|
||||
export const jsx = (...args) => runtime().jsx(...args);
|
||||
export const jsxs = (...args) => runtime().jsxs(...args);
|
||||
`;
|
||||
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
await mkdir(temporaryDir, { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entrypoints: [join(scriptDir, 'scalar-runtime.ts')],
|
||||
format: 'esm',
|
||||
minify: true,
|
||||
outdir: temporaryDir,
|
||||
plugins: [
|
||||
{
|
||||
name: 'scalar-react-shims',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react$/ }, () => ({
|
||||
namespace: 'scalar-runtime',
|
||||
path: 'react',
|
||||
}));
|
||||
build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({
|
||||
namespace: 'scalar-runtime',
|
||||
path: 'react-jsx-runtime',
|
||||
}));
|
||||
build.onLoad({ filter: /^react$/, namespace: 'scalar-runtime' }, () => ({
|
||||
contents: reactShim,
|
||||
loader: 'js',
|
||||
}));
|
||||
build.onLoad(
|
||||
{ filter: /^react-jsx-runtime$/, namespace: 'scalar-runtime' },
|
||||
() => ({
|
||||
contents: jsxRuntimeShim,
|
||||
loader: 'js',
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
target: 'browser',
|
||||
});
|
||||
|
||||
const [output] = result.outputs;
|
||||
if (!result.success || !output || result.outputs.length !== 1) {
|
||||
throw new Error(`Unable to build Scalar runtime: ${result.logs.join('\n')}`);
|
||||
}
|
||||
|
||||
const outputPath = join(outputDir, SCALAR_RUNTIME_PATH.replace('scalar/', ''));
|
||||
await rename(output.path, outputPath);
|
||||
await rm(temporaryDir, { force: true, recursive: true });
|
||||
@@ -3,6 +3,9 @@
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Generate assets that the server loads by URL instead of bundling.
|
||||
bun run generate:assets
|
||||
|
||||
# Copy the assets
|
||||
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
||||
gitbook-math ./public/~gitbook/static/math
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import zenuml from '@mermaid-js/mermaid-zenuml';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
let registration: Promise<void> | null = null;
|
||||
|
||||
export async function loadMermaid() {
|
||||
if (!registration) {
|
||||
registration = mermaid.registerExternalDiagrams([zenuml]).catch((error) => {
|
||||
registration = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
await registration;
|
||||
return mermaid;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { file, sleep, spawn } from 'bun';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { createConnection } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
type HeapUsage = {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
embedderHeapUsedSize: number;
|
||||
backingStorageSize: number;
|
||||
};
|
||||
|
||||
type DevWorker = {
|
||||
command: string[];
|
||||
process: WorkerProcess;
|
||||
};
|
||||
|
||||
type WorkerProcess = {
|
||||
exitCode: number | null;
|
||||
exited: Promise<number>;
|
||||
stdin: { write(data: string): unknown };
|
||||
kill(): void;
|
||||
};
|
||||
|
||||
const appPath = `${import.meta.dir}/..`;
|
||||
const requestURL = process.env.PROFILE_URL ?? 'http://127.0.0.1:8771/url/gitbook.com/docs';
|
||||
const requestCount = Number.parseInt(process.env.PROFILE_REQUESTS ?? '20', 10);
|
||||
const forceGarbageCollection = process.env.PROFILE_FORCE_GC === 'true';
|
||||
const settleMs = Number.parseInt(process.env.PROFILE_SETTLE_MS ?? '5000', 10);
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(requestCount) ||
|
||||
requestCount < 1 ||
|
||||
!Number.isSafeInteger(settleMs) ||
|
||||
settleMs < 0
|
||||
) {
|
||||
throw new Error('PROFILE_REQUESTS and PROFILE_SETTLE_MS must be positive integers');
|
||||
}
|
||||
|
||||
const workers: DevWorker[] = [];
|
||||
|
||||
try {
|
||||
const server = await startWorker(['bun', 'run', 'dev:cf:server'], 8772);
|
||||
workers.push(server);
|
||||
const middleware = await startWorker(['bun', 'run', 'dev:cf:middleware'], 8771);
|
||||
workers.push(middleware);
|
||||
|
||||
const coldResponse = await requestUntilReady(requestURL);
|
||||
await maybeCollectGarbage();
|
||||
|
||||
const cold = await getMeasurements();
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: requestCount }, () => request(requestURL))
|
||||
);
|
||||
|
||||
await sleep(settleMs);
|
||||
await maybeCollectGarbage();
|
||||
|
||||
const afterLoad = await getMeasurements();
|
||||
const bundle = await getBundleMetrics();
|
||||
|
||||
// oxlint-disable-next-line no-console -- JSON on stdout is this script's public interface.
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
requestURL,
|
||||
requestCount,
|
||||
forceGarbageCollection,
|
||||
settleMs,
|
||||
coldResponse,
|
||||
responses: summarizeResponses(responses),
|
||||
heap: { cold, afterLoad },
|
||||
bundle,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
for (const worker of workers.reverse()) {
|
||||
await stopWorker(worker.process);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWorker(command: string[], port: number): Promise<DevWorker> {
|
||||
const process = spawn(command, {
|
||||
cwd: appPath,
|
||||
stdin: 'pipe',
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(port, process);
|
||||
return { command, process };
|
||||
} catch (error) {
|
||||
process.kill();
|
||||
await process.exited;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWorker(process: WorkerProcess) {
|
||||
process.stdin.write('x\n');
|
||||
await Promise.race([process.exited, sleep(5_000)]);
|
||||
|
||||
if (process.exitCode === null) {
|
||||
process.kill();
|
||||
await process.exited;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPort(port: number, process: WorkerProcess) {
|
||||
const timeout = Date.now() + 60_000;
|
||||
|
||||
while (Date.now() < timeout) {
|
||||
if (process.exitCode !== null) {
|
||||
throw new Error(`Worker exited before becoming ready: ${process.exitCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectToPort(port);
|
||||
return;
|
||||
} catch {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Worker did not become ready within 60 seconds on port ${port}`);
|
||||
}
|
||||
|
||||
async function connectToPort(port: number) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = createConnection({ host: '127.0.0.1', port });
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', (error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function request(url: string) {
|
||||
const startedAt = performance.now();
|
||||
const response = await fetch(url);
|
||||
const body = await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
bytes: body.byteLength,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestUntilReady(url: string) {
|
||||
let response: Awaited<ReturnType<typeof request>> | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
response = await request(url);
|
||||
if (response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(`Worker did not return a successful response: ${response?.status}`);
|
||||
}
|
||||
|
||||
function summarizeResponses(responses: Awaited<ReturnType<typeof request>>[]) {
|
||||
return {
|
||||
statuses: Object.fromEntries(
|
||||
Object.entries(Object.groupBy(responses, ({ status }) => status)).map(
|
||||
([status, groupedResponses]) => [status, groupedResponses?.length ?? 0]
|
||||
)
|
||||
),
|
||||
bytes: responses.reduce((total, { bytes }) => total + bytes, 0),
|
||||
maxDurationMs: Math.max(...responses.map(({ durationMs }) => durationMs)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMeasurements() {
|
||||
return {
|
||||
server: await sendDevtoolsCommand<HeapUsage>(
|
||||
'ws://127.0.0.1:9229/ws',
|
||||
'Runtime.getHeapUsage'
|
||||
),
|
||||
middleware: await sendDevtoolsCommand<HeapUsage>(
|
||||
'ws://127.0.0.1:9230/ws',
|
||||
'Runtime.getHeapUsage'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeCollectGarbage() {
|
||||
if (!forceGarbageCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
collectGarbage('ws://127.0.0.1:9229/ws'),
|
||||
collectGarbage('ws://127.0.0.1:9230/ws'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectGarbage(url: string) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const websocket = new WebSocket(url);
|
||||
const timeout = setTimeout(() => {
|
||||
websocket.terminate();
|
||||
reject(new Error('Timed out waiting for HeapProfiler.takeHeapSnapshot'));
|
||||
}, 60_000);
|
||||
|
||||
websocket.on('open', () => {
|
||||
websocket.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: 'HeapProfiler.takeHeapSnapshot',
|
||||
params: { reportProgress: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
websocket.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.id !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
websocket.terminate();
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
websocket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Unable to connect to DevTools: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sendDevtoolsCommand<Result>(url: string, method: string): Promise<Result> {
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
const websocket = new WebSocket(url);
|
||||
const timeout = setTimeout(() => {
|
||||
websocket.terminate();
|
||||
reject(new Error(`Timed out waiting for ${method}`));
|
||||
}, 10_000);
|
||||
|
||||
websocket.on('open', () => {
|
||||
websocket.send(JSON.stringify({ id: 1, method }));
|
||||
});
|
||||
websocket.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.id !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
websocket.terminate();
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message));
|
||||
return;
|
||||
}
|
||||
resolve(message.result as Result);
|
||||
});
|
||||
websocket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Unable to connect to DevTools: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getBundleMetrics() {
|
||||
const handlerPath = join(
|
||||
appPath,
|
||||
'.open-next/server-functions/default/packages/gitbook/handler.mjs'
|
||||
);
|
||||
const metafilePath = `${handlerPath}.meta.json`;
|
||||
const metafile = (await file(metafilePath).json()) as {
|
||||
outputs: Record<string, { inputs: Record<string, { bytesInOutput: number }> }>;
|
||||
};
|
||||
const [outputPath] = Object.keys(metafile.outputs);
|
||||
if (!outputPath) {
|
||||
throw new Error(`No output found in metafile: ${metafilePath}`);
|
||||
}
|
||||
const output = metafile.outputs[outputPath];
|
||||
if (!output) {
|
||||
throw new Error(`Missing output in metafile: ${outputPath}`);
|
||||
}
|
||||
const shikiBytes = Object.entries(output.inputs).reduce(
|
||||
(total, [path, input]) =>
|
||||
path.includes('@shikijs/langs') ? total + input.bytesInOutput : total,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
handlerBytes: (await stat(handlerPath)).size,
|
||||
shikiLanguageBytes: shikiBytes,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user