mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 03:16:40 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ef4f6aed |
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix host action buttons in the Docs Embed not reaching the assistant.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix text disappearing in Firefox and iOS Safari on pages with a background cover
|
||||
@@ -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).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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)
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2v-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -19,16 +19,16 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: production
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
environment: production
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2c-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -50,4 +50,4 @@ jobs:
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2v-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -19,16 +19,16 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: staging
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
environment: staging
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2c-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -50,4 +50,4 @@ jobs:
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
"recommendations": ["biomejs.biome"]
|
||||
}
|
||||
|
||||
Vendored
+5
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+7
-12
@@ -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",
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { GitBookClient } from '../client';
|
||||
|
||||
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import emojisRaws from 'emoji-assets/emoji.json';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import emojisRaws from 'emoji-assets/emoji.json';
|
||||
|
||||
interface EmojiData {
|
||||
code_points: {
|
||||
|
||||
@@ -36,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: [
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
import { filterOutNullable } from './utils';
|
||||
|
||||
type InputValuesType =
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ExpressionParserResult {
|
||||
/**
|
||||
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
|
||||
*/
|
||||
invalidNodes: 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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
import { waitForIcons } from './util';
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user