Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 32ef4f6aed Fix search results linking to section anchors on title matches
Search results that match a page title now link to the top of the page
instead of a mid-page section anchor. Section-level matches keep their
heading anchor.

Adds a pure `isPageTitleMatch` helper (mirroring the substring title
notion in reciprocalRankFusion) and uses it in SearchPageResultItem to
choose the link target. Recovers the approach from the previously-closed
draft PR #4434 for RND-11916.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1rctbY7mEtGP1ZwVowxoE
2026-07-29 00:14:48 +00:00
660 changed files with 5455 additions and 9959 deletions
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep the breadcrumbs from covering the page actions' hit area.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix host action buttons in the Docs Embed not reaching the assistant.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix suggested question clicks in the embed search not opening the assistant.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix switching from the Search tab to the Docs or Assistant tab in the embed doing nothing.
-7
View File
@@ -1,7 +0,0 @@
---
"gitbook": patch
---
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.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Lazy-load the Mermaid code block so pages that have code blocks but no diagram no longer ship its rendering dependencies.
-8
View File
@@ -1,8 +0,0 @@
---
"@gitbook/react-openapi": major
"gitbook": patch
---
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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Migrate the headless UI primitives from Radix and react-aria to Base UI.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the variant switcher linking to the wrong URL for non-default variants of the default section
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve cookie handling in the OpenAPI "Test it" request proxy.
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/react-openapi": minor
---
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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix OpenAPI webhook payload and schema example panels being clipped instead of scrollable.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix text disappearing in Firefox and iOS Safari on pages with a background cover
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Render a site-space custom home page at its placement root while preserving the full space and normal page URLs.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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).
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Link page-level search matches to the top of the page while preserving section anchors for section matches.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Search results that match a page title now link to the top of the page instead of a mid-page section anchor; section-level matches keep their heading anchor.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show image thumbnails, including SVG previews, for file attachments on published sites.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix search results from a previous scope staying stuck on top of the new results when switching the search filter.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support localized custom AI Assistant greeting subtitles.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
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`.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add the `select` action to InlineButton. Clicking the button activates its slug, so any block containing that slug switches to it.
-10
View File
@@ -1,10 +0,0 @@
# 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 -4
View File
@@ -76,10 +76,7 @@ bun dev
```
Additional development commands:
- `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 format`: Format the code using Biome
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
@@ -1,25 +1,25 @@
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:
@@ -30,17 +30,17 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
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.
# 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
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%
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
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
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,13 +71,13 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
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 }}"
+57 -69
View File
@@ -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,57 +42,44 @@ 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_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
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
VERCEL_TARGET_ENV: ${{ inputs.environment }}
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
- id: extract_deployment_id
name: Extract Next deployment ID
shell: bash
run: |
deployment_id=$(bun -e '
const buildOutput = await Bun.file("packages/gitbook/.open-next/server-functions/default/packages/gitbook/.next/required-server-files.json").json();
const deploymentId = buildOutput.config?.deploymentId;
if (!deploymentId) throw new Error("Next build did not emit a deployment ID");
console.log(deploymentId);
')
echo "deployment_id=$deployment_id" >> $GITHUB_OUTPUT
- name: Upload the DO worker
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
- id: upload_server
name: Upload server to Cloudflare
@@ -101,7 +88,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -124,7 +111,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -148,10 +135,11 @@ 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 }}"
+60 -59
View File
@@ -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,51 +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_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
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
@@ -92,9 +92,10 @@ 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 }}"
-14
View File
@@ -20,20 +20,6 @@ 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
@@ -1,34 +0,0 @@
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 }}
+9 -57
View File
@@ -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,54 +187,6 @@ 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)
+9 -9
View File
@@ -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 }}"
+9 -9
View File
@@ -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 }}"
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
-70
View File
@@ -1,70 +0,0 @@
{
"$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"
]
}
-87
View File
@@ -1,87 +0,0 @@
{
"$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"
]
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
"recommendations": ["biomejs.biome"]
}
+5 -3
View File
@@ -8,12 +8,14 @@
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "always"
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
"editor.defaultFormatter": "biomejs.biome"
}
}
+2 -3
View File
@@ -6,8 +6,7 @@
bun install # Install dependencies
bun dev # Start dev server (all packages)
bun run build # Build all packages
bun run lint # Lint with Oxlint
bun run format # Format with Oxfmt (run after every change)
bun run format # Format with Biome (run after every change)
bun run typecheck # Type-check all packages
bun run unit # Run unit tests
```
@@ -75,7 +74,7 @@ Save as `.changeset/<name>.md`, then commit it separately with message: `changes
## Formatting
Linting uses Oxlint and formatting uses Oxfmt. Always run `bun run format` before committing.
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
## Comments
+178
View File
@@ -0,0 +1,178 @@
{
"$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"
]
}
}
]
}
+629 -275
View File
File diff suppressed because it is too large Load Diff
+7 -12
View File
@@ -5,9 +5,8 @@
"node": "^22.3.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"oxfmt": "^0.62.0",
"oxlint": "^1.77.0",
"turbo": "^2.10.3",
"vercel": "50.37.3"
},
@@ -19,7 +18,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
},
"private": true,
"scripts": {
@@ -27,10 +27,8 @@
"build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
"lint": "oxlint --quiet",
"lint:fix": "oxlint --fix --quiet",
"format": "oxfmt",
"format:check": "oxfmt --check",
"format": "biome check --write ./",
"format:check": "biome check --diagnostic-level=error ./",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
@@ -41,14 +39,11 @@
"clean": "turbo run clean"
},
"workspaces": {
"packages": [
"packages/*"
],
"packages": ["packages/*"],
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.195.0",
"@gitbook/api": "0.191.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+1 -5
View File
@@ -25,11 +25,7 @@
"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
View File
@@ -24,11 +24,7 @@
"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/"
+4 -5
View File
@@ -1,15 +1,14 @@
import assertNever from 'assert-never';
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
/**
* 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
View File
@@ -21,11 +21,7 @@
"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
View File
@@ -39,11 +39,7 @@
"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,4 @@
import { createChannel } from 'bidc';
import type {
FrameToParentMessage,
GitBookEmbeddableConfiguration,
@@ -77,7 +76,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
channel.send(message);
};
const events = new Map<string, ((...args: any[]) => void)[]>();
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'search', 'docs'],
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import { type CreateGitBookOptions, createGitBook } from '../client';
import { GitBookContext } from './context';
-1
View File
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import type { GitBookClient } from '../client';
export const GitBookContext = React.createContext<GitBookClient | null>(null);
+1 -1
View File
@@ -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: {
+1 -5
View File
@@ -36,11 +36,7 @@
"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: {
const SCENARIOS: Array<{
expressionWithCursor: string;
expectedSuggestions: AutocompleteSuggestions;
}[] = [
}> = [
{
expressionWithCursor: 'visit<cur>',
expectedSuggestions: [
+5 -4
View File
@@ -11,6 +11,7 @@ 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';
@@ -311,7 +312,7 @@ export class AutoComplete {
node: BinaryExpression,
cursorOffset: number,
context: SymbolsTable
): AutocompleteLiteralValueSuggestion[] {
): Array<AutocompleteLiteralValueSuggestion> {
const { left, right } = node;
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
@@ -430,7 +431,7 @@ export class AutoComplete {
node: AnyNode,
cursorOffset: number,
context: SymbolsTable
): AutocompleteOperatorSuggestion[] {
): Array<AutocompleteOperatorSuggestion> {
if (node.type === 'Literal') {
const parent = findParentNode(node, ast);
@@ -495,7 +496,7 @@ export class AutoComplete {
*/
private getOperatorSuggestionsForSymbol(
symbol: ExtractSymbolDef<SymbolType>
): AutocompleteOperatorSuggestion[] {
): Array<AutocompleteOperatorSuggestion> {
switch (symbol.type) {
case SymbolType.Number:
case SymbolType.Boolean:
@@ -525,7 +526,7 @@ export class AutoComplete {
case SymbolType.Function:
return this.getOperatorSuggestionsForSymbol(symbol.returns);
case SymbolType.Union: {
return symbol.members.reduce<AutocompleteOperatorSuggestion[]>((prev, cur) => {
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
return prev;
}, []);
-1
View File
@@ -1,5 +1,4 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from './utils';
type InputValuesType =
+3 -3
View File
@@ -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: ExpressionStatement[];
invalidNodes: Array<ExpressionStatement>;
}
export interface ExpressionAutocompleteResults {
@@ -158,11 +158,11 @@ export interface AutocompleteSymbolSuggestion {
symbol: SymbolInfo;
}
export type AutocompleteSuggestions = (
export type AutocompleteSuggestions = Array<
| AutocompleteSymbolSuggestion
| AutocompleteLiteralValueSuggestion
| AutocompleteOperatorSuggestion
)[];
>;
type LoggerFn = (message: string, ...args: any[]) => void;
+2 -1
View File
@@ -1,7 +1,8 @@
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';
+1 -6
View File
@@ -27,12 +27,7 @@
"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"
},
+2 -1
View File
@@ -1,4 +1,5 @@
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,7 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont } from '@gitbook/api';
import { getDefaultFont } from './getDefaultFont';
describe('getDefaultFont', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
import { fonts } from './fonts';
import type { FontWeight } from './types';
@@ -87,7 +86,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)
+2 -4
View File
@@ -360,9 +360,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.verifone.com',
contentBaseURL: 'https://docs.verifone.com',
// 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: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
// Deactivate it because of a custom Ask AI that causes flakiness.
// {
@@ -387,7 +385,7 @@ const testCases: TestsCase[] = [
{ name: 'Home', url: '/docs', run: waitForCookiesDialog },
{
name: 'OpenAPI',
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask/ask-a-question-in-a-site',
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask',
run: waitForCookiesDialog,
},
],
+22 -47
View File
@@ -1,6 +1,3 @@
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
CustomizationAIMode,
CustomizationBackground,
@@ -14,6 +11,10 @@ import {
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 {
@@ -34,10 +35,8 @@ 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
@@ -136,23 +135,6 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).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({
@@ -399,7 +381,6 @@ 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');
@@ -427,7 +408,6 @@ 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');
@@ -466,7 +446,6 @@ const testCases: TestsCase[] = [
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');
@@ -493,7 +472,6 @@ 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');
@@ -535,7 +513,6 @@ 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');
@@ -571,7 +548,6 @@ 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');
@@ -602,7 +578,6 @@ 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');
@@ -643,14 +618,14 @@ const testCases: TestsCase[] = [
url: '',
screenshot: false,
run: async (page) => {
// 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();
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 });
await page.getByText('Section B').click();
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
@@ -1266,8 +1241,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: {
@@ -1287,8 +1262,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
}))
),
})),
]),
...allSearchStyles.flatMap((searchStyle) => ({
name: `Theme ${theme} Search ${searchStyle} Mode ${themeMode}`,
url: getCustomizationURL({
@@ -1308,8 +1283,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: {
@@ -1335,8 +1310,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
}))
),
})),
]),
{
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
url: getCustomizationURL({
@@ -2430,7 +2405,7 @@ const testCases: TestsCase[] = [
actions.nth(1).click(),
]);
// Verify the new page would have opened with the expected URL
await expect(newPage).toHaveURL(/gitbook\.com/);
expect(newPage.url()).toContain('gitbook.com');
// Close it immediately to avoid navigation
await newPage.close();
@@ -2578,7 +2553,7 @@ const testCases: TestsCase[] = [
openInNewTabButton.click(),
]);
// Verify the new page would have opened with the expected URL
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
expect(newPage.url()).toContain('gitbook.gitbook.io');
// Close it immediately to avoid navigation
await newPage.close();
},
-1
View File
@@ -1,6 +1,5 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { expect, test } from '@playwright/test';
import { getContentTestURL } from '../tests/utils';
import { waitForIcons } from './util';
-238
View File
@@ -1,238 +0,0 @@
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');
});
});
-169
View File
@@ -1,169 +0,0 @@
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]);
}
}
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();
await page.waitForLoadState('networkidle');
const client = await page.context().newCDPSession(page);
const totalElements = await page.evaluate(() => document.getElementsByTagName('*').length);
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);
}
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
);
});
+14 -19
View File
@@ -1,16 +1,4 @@
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,
@@ -34,6 +22,17 @@ 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';
@@ -92,7 +91,7 @@ export interface Test {
export type TestsCase = {
name: string;
skip?: boolean;
tests: Test[];
tests: Array<Test>;
contentBaseURL?: string;
/**
* Whether screenshots in this test case should capture the full scrollable page by default.
@@ -112,10 +111,10 @@ export const allThemeModes: CustomizationDefaultThemeMode[] = [
CustomizationDefaultThemeMode.Dark,
];
export const allTintColors: {
export const allTintColors: Array<{
label: string;
value: CustomizationThemedColor | undefined;
}[] = [
}> = [
{
label: 'Off',
value: undefined,
@@ -173,10 +172,6 @@ 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.
*
+5 -5
View File
@@ -21,7 +21,11 @@ const allowedDevOrigins =
]
: undefined;
let deploymentId = process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
// 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'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
const { VERCEL_TARGET_ENV } = process.env;
@@ -55,10 +59,6 @@ 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: {
+4 -4
View File
@@ -8,8 +8,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
middleware: {
@@ -20,8 +20,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
dangerous: {
@@ -1,7 +1,7 @@
import { DurableObject } from 'cloudflare:workers';
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { DurableObject } from 'cloudflare:workers';
//Only needed to run locally, in prod we'll use the one from do.js
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
@@ -28,8 +28,9 @@ 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);
@@ -7,190 +7,174 @@
"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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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"
}
]
}
}
}
@@ -0,0 +1,38 @@
// This worker only purposes it to host the different DO that we will need in the other workers.
import { DurableObject } from 'cloudflare:workers';
// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second
// so we need to buffer writes to the R2 bucket to avoid hitting this limit.
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default {
async fetch() {
// This worker does not handle any requests, it only provides Durable Objects
return new Response('This worker is not meant to handle requests directly', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
});
},
};
@@ -1,144 +0,0 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
const runWithCloudflareRequestContext = mock(
async <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => operation()
);
const get = mock();
const getTagsFromValue = mock();
const hasBeenRevalidated = mock();
mock.module('cloudflare:workers', () => ({
DurableObject: class {},
WorkerEntrypoint: class {},
}));
mock.module('../../.open-next/cloudflare/init.js', () => ({ runWithCloudflareRequestContext }));
mock.module('../incrementalCache/incrementalCache', () => ({
GitbookIncrementalCache: class {
get = get;
},
}));
mock.module('@opennextjs/aws/utils/cache.js', () => ({ getTagsFromValue }));
mock.module('../tagCache/middleware', () => ({
default: { hasBeenRevalidated },
}));
const { default: IncrementalCacheWorker } = await import('./do');
const CACHE_CONTROL = 'public, s-maxage=3600, stale-while-revalidate=86400';
const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate';
const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
json: {},
revalidate: 60,
};
describe('IncrementalCacheWorker fetch', () => {
const selfFetch = mock();
beforeEach(() => {
selfFetch.mockReset();
get.mockReset();
getTagsFromValue.mockReset();
hasBeenRevalidated.mockReset();
getTagsFromValue.mockReturnValue(['space:1']);
hasBeenRevalidated.mockResolvedValue(false);
});
const fetch = (request: Request) =>
IncrementalCacheWorker.prototype.fetch.call(
{
env: { WORKER_SELF_REFERENCE: { fetch: selfFetch } },
ctx: {},
},
request
);
it('forwards cache reads to the internal endpoint and restores cache metadata', async () => {
selfFetch.mockResolvedValue(
Response.json(
{ value: cacheValue, lastModified: 123 },
{
headers: {
'x-gitbook-cache-control': CACHE_CONTROL,
'x-gitbook-cache-tag': 'incremental-cache:entry,space:1',
},
}
)
);
const response = await fetch(
new Request('https://incremental-cache.internal/?key=entry&cacheType=cache')
);
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
const forwardedRequest = selfFetch.mock.calls[0]?.[0] as Request;
const forwardedURL = new URL(forwardedRequest.url);
expect(forwardedURL.pathname).toBe('/internal');
expect(forwardedURL.searchParams.get('key')).toBe('entry');
expect(forwardedURL.searchParams.get('cacheType')).toBe('cache');
});
it('reads and annotates a cache hit only on the internal endpoint', async () => {
get.mockResolvedValue({ value: cacheValue, lastModified: Date.now() });
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=cache')
);
expect(get).toHaveBeenCalledWith('entry', 'cache');
expect(selfFetch).not.toHaveBeenCalled();
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
expect(response.headers.get('x-gitbook-cache-tag')).toBe('incremental-cache:entry,space:1');
});
it('keeps cache misses out of the worker cache', async () => {
get.mockResolvedValue(null);
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=missing')
);
expect(response.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(await response.json()).toBeNull();
});
it('keeps revalidated and stale entries out of the worker cache', async () => {
get.mockResolvedValueOnce({ value: cacheValue, lastModified: Date.now() });
hasBeenRevalidated.mockResolvedValueOnce(true);
const revalidatedResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=revalidated')
);
get.mockResolvedValueOnce({
value: { ...cacheValue, revalidate: 0 },
lastModified: Date.now() - 1_000,
});
const staleResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=stale')
);
expect(revalidatedResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(revalidatedResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
expect(staleResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(staleResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
});
it('rejects invalid internal requests and does not forward non-GET requests', async () => {
const invalidResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=invalid')
);
const methodResponse = await fetch(
new Request('https://incremental-cache.internal/?key=entry', { method: 'POST' })
);
expect(invalidResponse.status).toBe(400);
expect(methodResponse.status).toBe(405);
expect(selfFetch).not.toHaveBeenCalled();
});
});
@@ -1,199 +0,0 @@
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
import type {
CacheEntryType,
CacheValue,
NextModeTagCacheWriteInput,
} from '@opennextjs/aws/types/overrides.js';
import { getTagsFromValue } from '@opennextjs/aws/utils/cache.js';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
// @ts-ignore Generated by the Cloudflare build.
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache';
import tagCache from '../tagCache/middleware';
type CacheWorkerEnv = {
NEXT_INC_CACHE_R2_BUCKET: {
put(key: string, value: string): Promise<unknown>;
};
WORKER_SELF_REFERENCE: {
fetch(request: Request): Promise<Response>;
};
};
//@ts-ignore - Just to avoid tag cache crashing
globalThis.openNextConfig = {
dangerous: {
enableCacheInterception: true,
},
};
const cacheEntryTypes = new Set<CacheEntryType>(['cache', 'fetch', 'composable']);
const isCacheEntryType = (value: string | null): value is CacheEntryType =>
value !== null && cacheEntryTypes.has(value as CacheEntryType);
const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate';
const INTERNAL_PATH = '/internal';
const CACHE_CONTROL_HEADER = 'x-gitbook-cache-control';
const CACHE_TAG_HEADER = 'x-gitbook-cache-tag';
const getCacheHeaders = (cacheControl: string, cacheTag?: string): HeadersInit => ({
'Cache-Control': cacheControl,
[CACHE_CONTROL_HEADER]: cacheControl,
...(cacheTag
? {
'Cache-Tag': cacheTag,
[CACHE_TAG_HEADER]: cacheTag,
}
: {}),
});
const nullCacheResponse = (hasBeenRevalidated = false): Response =>
Response.json(null, {
headers: {
...getCacheHeaders(NO_STORE_CACHE_CONTROL),
...(hasBeenRevalidated ? { 'x-gitbook-cache-revalidated': 'true' } : {}),
},
});
const restoreCacheHeaders = (response: Response): Response => {
const headers = new Headers(response.headers);
const cacheControl = headers.get(CACHE_CONTROL_HEADER);
const cacheTag = headers.get(CACHE_TAG_HEADER);
if (cacheControl) {
headers.set('Cache-Control', cacheControl);
}
if (cacheTag) {
headers.set('Cache-Tag', cacheTag);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
const isTimeStale = (value: CacheValue<CacheEntryType>, lastModified?: number): boolean => {
const revalidate = value.revalidate;
if (typeof revalidate !== 'number') {
return false;
}
return Date.now() >= (lastModified ?? Date.now()) + revalidate * 1000;
};
const getTagName = (tag: NextModeTagCacheWriteInput): string =>
typeof tag === 'string' ? tag : tag.tag;
// `use cache` can write the same key concurrently, but R2 accepts one write per key per second.
export class R2WriteBuffer extends DurableObject<CacheWorkerEnv> {
private writePromise: Promise<unknown> | undefined;
async write(cacheKey: string, value: string): Promise<void> {
if (this.writePromise) {
return;
}
const writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.writePromise = writePromise;
this.ctx.waitUntil(
writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
// @ts-ignore Generated by the Cloudflare build.
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
// @ts-ignore Generated by the Cloudflare build.
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default class IncrementalCacheWorker extends WorkerEntrypoint<CacheWorkerEnv> {
async fetch(request: Request): Promise<Response> {
if (request.method !== 'GET') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== INTERNAL_PATH) {
url.pathname = INTERNAL_PATH;
const response = await this.env.WORKER_SELF_REFERENCE.fetch(new Request(url, request));
return restoreCacheHeaders(response);
}
const key = url.searchParams.get('key');
const cacheType = url.searchParams.get('cacheType');
if (!key || (cacheType !== null && !isCacheEntryType(cacheType))) {
return new Response('Invalid cache request', { status: 400 });
}
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
const value = await new GitbookIncrementalCache().get(key, cacheType ?? undefined);
if (!value?.value) {
return nullCacheResponse();
}
const tags = getTagsFromValue(value?.value as CacheValue<'cache'> | undefined);
if (await tagCache.hasBeenRevalidated(tags, value.lastModified)) {
return nullCacheResponse(true);
}
if (isTimeStale(value.value, value.lastModified)) {
return nullCacheResponse(true);
}
return Response.json(value, {
headers: getCacheHeaders(
// 1 hour cache, with a 1 day stale-while-revalidate.
'public, s-maxage=3600, stale-while-revalidate=86400',
[`incremental-cache:${key}`, ...tags].join(',')
),
});
});
}
async set<CacheType extends CacheEntryType>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
await this.#runRpcOperation(async () => {
await new GitbookIncrementalCache().set(key, value, cacheType);
});
}
async delete(key: string): Promise<void> {
await this.#runRpcOperation(async () => {
await new GitbookIncrementalCache().delete(key);
});
}
async writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void> {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
return;
}
await this.#runRpcOperation(async () => {
await tagCache.writeTags(tagsToWrite);
await this.ctx.cache?.purge({ tags: tagsToWrite.map(getTagName) });
});
}
async #runRpcOperation(operation: () => Promise<void>): Promise<void> {
await runWithCloudflareRequestContext(
new Request('https://incremental-cache.internal'),
this.env,
this.ctx,
async () => {
await operation();
return new Response(null, { status: 204 });
}
);
}
}
@@ -1,158 +1,101 @@
{
"main": "do.ts",
"main": "do.js",
"name": "gitbook-open-v2-do",
"compatibility_date": "2026-06-14",
"compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false,
},
"cache": {
"enabled": true,
},
"placement": {
"region": "gcp:us-central1",
"enabled": false
},
"env": {
"dev": {
"vars": {
"STAGE": "dev",
"OPEN_NEXT_BUILD_ID": "local",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview",
},
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-do-dev",
},
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
},
],
},
"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-do-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"],
},
],
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 1,
},
"logs": {
"enabled": true,
"head_sampling_rate": 1,
},
},
"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-do-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": {
@@ -161,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-do-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 { 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')
);
// oxlint-disable-next-line no-console
// biome-ignore lint/suspicious/noConsole: <explanation>
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')
);
// oxlint-disable-next-line no-console
// biome-ignore lint/suspicious/noConsole: <explanation>
console.log(formatedLog);
return response;
@@ -6,169 +6,150 @@
"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": {
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true",
// When deployed locally, we don't have access to the tag cache here,
// we should just bypass the cache to go to the server directly
"SHOULD_BYPASS_CACHE": "true",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771",
"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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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",
},
],
},
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 1,
},
"logs": {
"enabled": true,
"head_sampling_rate": 1,
},
},
"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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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": {
@@ -180,68 +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",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-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,84 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const getCloudflareContext = mock();
mock.module('@opennextjs/cloudflare', () => ({ getCloudflareContext }));
const { GitbookIncrementalCache } = await import('./cacheWorkerClient');
const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
json: {},
};
describe('GitbookIncrementalCache cache worker client', () => {
const fetch = mock();
const set = mock();
const remove = mock();
const originalConsoleError = console.error;
beforeEach(() => {
fetch.mockReset();
set.mockReset();
remove.mockReset();
getCloudflareContext.mockReturnValue({
env: {
NEXT_INC_CACHE_WORKER: { fetch, set, delete: remove },
},
});
console.error = mock();
});
afterEach(() => {
console.error = originalConsoleError;
});
it('gets cache entries through the service binding', async () => {
fetch.mockResolvedValue(
Response.json({
value: cacheValue,
lastModified: 123,
})
);
const result = await new GitbookIncrementalCache().get('key with / characters', 'cache');
expect(result).toEqual({ value: cacheValue, lastModified: 123 });
const request = fetch.mock.calls[0]?.[0] as Request;
const url = new URL(request.url);
expect(url.searchParams.get('key')).toBe('key with / characters');
expect(url.searchParams.get('cacheType')).toBe('cache');
});
it('returns null for cache misses and failed reads', async () => {
fetch.mockResolvedValue(Response.json(null));
expect(await new GitbookIncrementalCache().get('missing')).toBeNull();
fetch.mockResolvedValueOnce(new Response(null, { status: 503 }));
expect(await new GitbookIncrementalCache().get('unavailable-response')).toBeNull();
fetch.mockRejectedValueOnce(new Error('service unavailable'));
expect(await new GitbookIncrementalCache().get('unavailable')).toBeNull();
});
it('uses RPC for writes and deletes', async () => {
set.mockResolvedValue(undefined);
remove.mockResolvedValue(undefined);
const cache = new GitbookIncrementalCache();
await cache.set('entry', cacheValue, 'cache');
await cache.delete('entry');
expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache');
expect(remove).toHaveBeenCalledWith('entry');
});
it('contains mutation failures', async () => {
set.mockRejectedValue(new Error('service unavailable'));
remove.mockRejectedValue(new Error('service unavailable'));
const cache = new GitbookIncrementalCache();
await expect(cache.set('entry', cacheValue, 'cache')).resolves.toBeUndefined();
await expect(cache.delete('entry')).resolves.toBeUndefined();
});
});
@@ -1,78 +0,0 @@
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER';
type CacheWorker = {
fetch(request: Request): Promise<Response>;
set<CacheType extends CacheEntryType>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void>;
delete(key: string): Promise<void>;
};
export class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
try {
const url = new URL('https://incremental-cache.internal');
url.searchParams.set('key', key);
if (cacheType) {
url.searchParams.set('cacheType', cacheType);
}
const response = await this.getWorker().fetch(new Request(url));
if (!response.ok) {
console.error('Failed to get from cache worker', response.status);
return null;
}
return (await response.json()) as WithLastModified<CacheValue<CacheType>> | null;
} catch (error) {
console.error('Failed to get from cache worker', error);
return null;
}
}
async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
try {
await this.getWorker().set(key, value, cacheType);
} catch (error) {
console.error('Failed to set to cache worker', error);
}
}
async delete(key: string): Promise<void> {
try {
await this.getWorker().delete(key);
} catch (error) {
console.error('Failed to delete from cache worker', error);
}
}
private getWorker(): CacheWorker {
const env = getCloudflareContext().env as Record<string, unknown>;
const worker = env[BINDING_NAME] as CacheWorker | undefined;
if (!worker) {
throw new Error(`Missing ${BINDING_NAME} service binding`);
}
return worker;
}
}
export default new GitbookIncrementalCache();
@@ -1,51 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createHash } from 'node:crypto';
import { DEFAULT_PREFIX, GitbookIncrementalCache } from './incrementalCache';
const environmentKeys = ['OPEN_NEXT_BUILD_ID', 'DEPLOYMENT_ID', 'NEXT_BUILD_ID'] as const;
const originalEnvironment = Object.fromEntries(
environmentKeys.map((key) => [key, process.env[key]])
);
const hash = (key: string) => createHash('sha256').update(key).digest('hex');
afterEach(() => {
for (const key of environmentKeys) {
const value = originalEnvironment[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe('GitbookIncrementalCache cache keys', () => {
it('uses the deployment-aware OpenNext build ID', () => {
process.env.OPEN_NEXT_BUILD_ID = 'deployment-id';
process.env.NEXT_BUILD_ID = 'legacy-build-id';
expect(new GitbookIncrementalCache().getR2Key('entry')).toBe(
`${DEFAULT_PREFIX}/deployment-id/${hash('entry')}.cache`
);
});
it('uses the Cloudflare deployment ID when the worker build ID is unavailable', () => {
delete process.env.OPEN_NEXT_BUILD_ID;
process.env.DEPLOYMENT_ID = 'runtime-deployment-id';
expect(new GitbookIncrementalCache().getR2Key('entry')).toBe(
`${DEFAULT_PREFIX}/runtime-deployment-id/${hash('entry')}.cache`
);
});
it('normalizes composable cache keys before applying the deployment namespace', () => {
process.env.OPEN_NEXT_BUILD_ID = 'deployment-id';
const key = JSON.stringify(['next-build-id', 'cache-key']);
expect(new GitbookIncrementalCache().getR2Key(key, 'composable')).toBe(
`${DEFAULT_PREFIX}/dataCache/${hash(JSON.stringify(['cache-key']))}.composable`
);
});
});
@@ -1,4 +1,4 @@
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
import { createHash } from 'node:crypto';
import type {
CacheEntryType,
CacheValue,
@@ -6,7 +6,8 @@ import type {
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { createHash } from 'node:crypto';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
export const DEFAULT_PREFIX = 'incremental-cache';
@@ -128,8 +129,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
}
const hash = createHash('sha256').update(key).digest('hex');
const buildId = process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID;
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? buildId : 'dataCache'}/${hash}.${cacheType}`.replace(
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
/\/+/g,
'/'
);
@@ -1,14 +1,12 @@
// import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './cacheWorkerClient';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // We can do it because we use our own logic to invalidate the cache
// bypassTagCacheOnCacheHit: true,
// //TODO: bump it again once I figured out the race condition
// defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
export default new GitbookIncrementalCache();
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
//TODO: bump it again once I figured out the race condition
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,17 +1,15 @@
// import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './cacheWorkerClient';
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
// // and cause race conditions. This will be fixed in a future release of OpenNext
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
// bypassTagCacheOnCacheHit: false,
// //TODO: remove, reducing cache ttl of regional cache to help debugging
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
export default new GitbookIncrementalCache();
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
// and cause race conditions. This will be fixed in a future release of OpenNext
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
//TODO: remove, reducing cache ttl of regional cache to help debugging
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getLogger } from '@/lib/logger';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
export default {
name: 'GitbookISRQueue',
@@ -1,44 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const getCloudflareContext = mock();
mock.module('@opennextjs/cloudflare', () => ({ getCloudflareContext }));
const { default: tagCache } = await import('./cacheWorkerClient');
describe('GitbookTagCache cache worker client', () => {
const writeTags = mock();
beforeEach(() => {
writeTags.mockReset();
getCloudflareContext.mockReturnValue({
env: {
NEXT_INC_CACHE_WORKER: { writeTags },
},
});
});
afterEach(() => {
getCloudflareContext.mockReset();
});
it('writes hard tags through the cache worker service binding', async () => {
writeTags.mockResolvedValue(undefined);
await tagCache.writeTags([
'content',
{ tag: 'with-duration', stale: 100, expire: 200 },
'_N_T_/soft-tag',
]);
expect(writeTags).toHaveBeenCalledWith([
'content',
{ tag: 'with-duration', stale: 100, expire: 200 },
]);
});
it('does not call the worker when all tags are soft tags', async () => {
await tagCache.writeTags(['_N_T_/soft-tag']);
expect(writeTags).not.toHaveBeenCalled();
});
});
@@ -1,39 +0,0 @@
import type { NextModeTagCache, NextModeTagCacheWriteInput } from '@opennextjs/aws/types/overrides';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
const BINDING_NAME = 'NEXT_INC_CACHE_WORKER';
type CacheWorker = {
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
};
const getWorker = (): CacheWorker => {
const env = getCloudflareContext().env as Record<string, unknown>;
const worker = env[BINDING_NAME] as CacheWorker | undefined;
if (!worker) {
throw new Error(`Missing ${BINDING_NAME} service binding`);
}
return worker;
};
export default {
name: 'GitbookTagCache',
mode: 'nextMode',
// Do nothing.
getLastRevalidated: async () => {
return 0;
},
// Return false, everything handled at the incremental cache level in the do worker.
hasBeenRevalidated: async () => {
return false;
},
writeTags: async (tags: NextModeTagCacheWriteInput[]) => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
return;
}
await getWorker().writeTags(tagsToWrite);
},
} satisfies NextModeTagCache;
@@ -1,12 +1,8 @@
import type {
NextModeTagCache,
NextModeTagCacheWriteInput,
} from '@opennextjs/aws/types/overrides.js';
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,
@@ -51,7 +47,7 @@ export default {
return false; // In case of error, return false
}
},
writeTags: async (tags: NextModeTagCacheWriteInput[]) => {
writeTags: async (tags: string[]) => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
const logger = getLogger().subLogger('gitbookTagCache');
+17 -18
View File
@@ -3,8 +3,7 @@
"version": "0.27.2",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^5.20260716.1",
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
@@ -23,6 +22,13 @@
"@opennextjs/aws": "4.1.0",
"@opennextjs/cloudflare": "1.20.2",
"@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",
@@ -61,6 +67,7 @@
"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",
"rehype-raw": "^7.0.0",
@@ -103,39 +110,31 @@
"@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:",
"vercel": "50.37.3",
"wrangler": "^4.122.0",
"wrangler": "^4.79.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.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",
"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",
"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",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"build:cloudflare": "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",
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
"dev:cf:cache": "wrangler dev --env dev --config ./openNext/customWorkers/doWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"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",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit"
},
@@ -1,273 +0,0 @@
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;
}
-2
View File
@@ -7,5 +7,3 @@ 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
@@ -1,44 +0,0 @@
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 });

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