Compare commits

..

12 Commits

Author SHA1 Message Date
Nolann Biron d25cc924e4 Gate AIChatProvider 2026-07-16 10:58:03 +02:00
Nolann Biron 68e50d68e5 revert link + re-add optimizePackage 2026-07-16 10:13:07 +02:00
Nolann Biron 46b43c4372 prefetch TOC on hover 2026-07-16 09:58:10 +02:00
Nolann Biron f8fd5e2bb4 revert some changes 2026-07-16 09:30:39 +02:00
Nolann Biron 9ab6cfb878 quick review 2026-07-16 00:16:17 +02:00
Nolann Biron c9a637e0d9 fix openapi styling 2026-07-15 23:03:23 +02:00
Nolann Biron c16ab0a2ce fixes 2026-07-15 22:27:53 +02:00
Nolann Biron fe20392d21 changeset 2026-07-15 21:15:42 +02:00
Nolann Biron 8a81d77151 Trim client bundle via optimizePackageImports and a modern browserslist
Enable Next.js optimizePackageImports for motion, @gitbook/icons and react-aria so barrel imports tree-shake to only the used entrypoints. Raise the browserslist target to Chrome 93 / Edge 93 / Firefox 92 / Safari 15.4 so modern syntax (Array.prototype.at, Object.hasOwn, etc.) is no longer down-compiled and polyfilled across the whole client bundle.
2026-07-15 21:15:23 +02:00
Nolann Biron 55ac341cc9 Lazy-load OpenAPI and ContentKit stylesheets
The OpenAPI/Scalar stylesheet (~148KB raw, unpurgeable) and the ContentKit stylesheet were statically imported by the block dispatcher, so they shipped in every page's render-blocking CSS regardless of whether the page used those blocks. They now load on demand only when an OpenAPI or integration block renders, mirroring the existing KaTeX CSS pattern.
2026-07-15 21:15:23 +02:00
Nolann Biron 2f5b0a7f8a Lazy-load the admin toolbar for anonymous visitors
The admin toolbar (and the Framer Motion it pulls in) shipped to every visitor and only decided client-side whether to render, so anonymous readers downloaded and executed it just to render nothing. It is now split behind a lightweight gate that dynamically imports the full toolbar only for editor/change-request/revision views and authenticated organization members.
2026-07-15 21:15:23 +02:00
Nolann Biron 99f751c060 Defer search index loading until search is opened
Every page previously preloaded and fetched the whole-site search index (~441KB) plus FlexSearch on load, even when search was never used. The index and FlexSearch now load on first search intent (open/query), the results UI is code-split, and the index payload dedupes repeated breadcrumbs into a shared table (version 2, backward-compatible with version 1).
2026-07-15 21:15:23 +02:00
872 changed files with 11467 additions and 33466 deletions
@@ -1,48 +0,0 @@
---
name: write-pr-description
description: Write a concise, product-focused PR description from the changes and available discussion. Use when asked for a PR summary or description.
---
# Write a PR description
Read the full branch diff and commit history against the PR's base branch, not just the latest commit. Use the PR discussion and linked issues for context when available. The PR's changes are the source of truth for what ships if an issue describes something different.
Write for contributors and reviewers who may not know the area. Lead with what changes, prefer user or product impact when supported, and include implementation details only when they help review. Keep the proposed changes short, using bullets only when useful.
Include relevant validation and screenshots when available. Do not claim checks passed unless they ran. Use only information appropriate for a public repository; do not copy private issue details or customer information into the description.
## Template and changelog
Read `.github/pull_request_template.md` immediately before writing. Preserve its required section names and order. Include `Proposed changes` and `Changelog`; omit `Demo` when there is nothing to show. Add optional `Context` before `Changelog` only when it meets the rule below.
Write a short changelog entry describing what shipped. Use `[Feature]` for user-facing additions or improvements, `[Fix]` for user-facing bug fixes, and `[Chore]` for internal maintenance. For features and fixes, say what users can do or what no longer goes wrong in plain words. Keep chores useful to teammates without inventing user impact. Remove unused examples and placeholders. Usually one entry is enough; use more only for distinct changes. The PR changelog does not replace any changeset required by `AGENTS.md`.
## Optional context
Write `Proposed changes` and `Changelog` first. Add `Context` only when it tells someone unfamiliar with the area something material that the changelog does not. `Problem` names a concrete failure or limitation supported by the PR or discussion. `Outcome` names what this PR delivers at its actual scope. Omit the section if either field would repeat the changelog, describe only implementation, or guess at an impact.
```markdown
## Context
**Problem:** <concrete failure or limitation>
**Outcome:** <result delivered by this PR>
```
Keep each field to 1–2 short, non-technical sentences. Prefer user or product impact when supported. For infrastructure changes, explain the failure or limitation they caused. Do not invent an actor, customer segment, ownership, or impact, or force the change into a user story. Do not turn a preparatory change into a claim that the whole workflow is fixed. Keep the roles distinct: `Context` explains why this matters, `Changelog` says what shipped, and `Proposed changes` can explain implementation.
Examples:
- A dependency bump with no demonstrated user effect needs no `Context`.
- If creating the first change request leaves the user on an empty screen, say that it now opens after creation. Leave the animation-listener details in `Proposed changes`.
- If a PR indexes links for later checks, describe the indexing result. Do not claim every link has already been checked.
## Writing pass
- Use plain words and direct verbs: "use" instead of "leverage", "help" instead of "facilitate". Prefer active voice when the actor is known.
- Name the concrete behavior instead of saying "improves the experience" or "makes it more robust". Support performance claims with measurements.
- Cut filler, promotional language, and generic conclusions. If a sentence could fit almost any PR, make it specific or delete it.
- Keep one idea per sentence and use consistent names. Remove redundant hedging without hiding real uncertainty.
- Avoid "not just X, but Y", forced groups of three, em dashes, decorative emojis, and unnecessary bold text.
Return the description in a Markdown code block unless asked to update the PR. When asked to update it, apply the description with `gh pr edit` using a body file.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL.
@@ -0,0 +1,6 @@
---
"@gitbook/colors": patch
"gitbook": patch
---
A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add cover image background mode and masks
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Follow site and space redirects when the MCP getPage tool resolves a page URL.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Let analytics tools like GA4 track outbound link clicks by using native link navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Reduce the JavaScript and CSS loaded on published site pages: the search index and its UI now load only when search is opened, and the admin toolbar and OpenAPI/ContentKit styles are no longer shipped to every visitor.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Resolve the published content with the full URL in a single lookup and always send the visitor country.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded).
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep lists, paragraphs and code blocks as HTML in Updates RSS feeds, instead of running all the text together
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask <assistant> about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide unfocusable unlabelled button from screen readers
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
a11y screen reader fixes
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-contentkit": patch
"gitbook": patch
---
Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix an issue where certain keywords could cause an exception when rendering emojis
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/write-pr-description
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/write-pr-description
-10
View File
@@ -1,10 +0,0 @@
# Stop editors from looking for .editorconfig files in parent directories.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
max_line_length = 100
+3
View File
@@ -0,0 +1,3 @@
# Changes to the API data cache functions can invalidate all existing data cache
# causing a massive amount of revalidation, impacting our API.
packages/gitbook/src/lib/data/api.ts @SamyPesse
+67 -46
View File
@@ -1,89 +1,110 @@
# Contributing to GitBook
# Welcome to GitBook's contributing guide!
> _For help, support and product questions, head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions)._
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
Thanks for helping make GitBook better. This repository holds the open source renderer behind every published GitBook site, and the team reads everything that comes in here.
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
## Ways to contribute
## Types of Contributions
- **Open an issue.** Report a bug, suggest a feature, or tell us when something feels off. This is the most useful thing you can do.
- **Join the conversation.** Share ideas and help others in [GitHub Community](https://github.com/orgs/GitbookIO/discussions).
- **Build with GitBook.** Publish your docs, try new features early, and tell us what works and what doesn't.
This repository contains code related to the rendering engine of GitBook's published content. Depending on what you'd like to contribute to, head to the section below to find the necessary steps.
## Issues instead of pull requests
### Add a feature
Pull requests are limited to the GitBook team. If you've found a bug or want something to change, please [open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) instead.
Because this portion of GitBook is open source and available for you to use - if you think you can provide extra value through a new feature - you're welcome to add it! If you plan to distribute the code, keep the source code public to comply with GNU GPLv3. To clone in a private repository, you must first acquire a [commercial license](https://www.gitbook.com/pricing).
This isn't because we don't want your help. Every change here ships to every published GitBook site, so we need to own each one from start to finish. A clear issue often helps us more than a pull request: it tells us what's wrong and why, and lets us fix it in a way that fits the rest of the codebase.
### Create a new issue
A good issue includes:
If you spot a problem within a repository, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue here!Please make sure any added issues are
- What you expected to happen, and what happened instead
- A link to a published GitBook site that shows the problem, if you have one
- Steps to reproduce it, plus screenshots or a recording for anything visual
- Your browser and OS, if it looks like it might matter
- Descriptive
- Thoughtful
- Organized
Before opening one, [search existing issues](https://github.com/GitbookIO/gitbook/issues). If someone has already reported it, add a 👍 or any extra details you have.
We recommend adding as many relevant links, minimal reproductions of the issue, and other materials that will help our team solve the issue fast.
Already fixed it in your fork? Link the branch or commit in your issue. We may open a pull request from it, and your commits keep your name.
### Solve an issue
If you open a pull request anyway, we'll close it and point you here. Nothing personal, and we'd still love the issue.
If you're interested in solving an issue in our repository, start by scanning through it's exisiting issues to find one that you're interested in working on. If you find an issue to work on, you are welcome to open a PR with a fix. See the following sections below for more information on contributing for specific sections.
## Reach out
### Documentation
Not sure if it's a bug? Have a half-formed idea? Open an issue or start a [discussion](https://github.com/orgs/GitbookIO/discussions) anyway. We'd much rather hear from you than not, and no report is too small.
The official documentation on GitBook open can be found directly in this Readme. Any updates or changes you would like to make, you can make directly to the README of this repository.
Spotted a wrong or missing translation? Open an issue with the string and the wording you'd suggest.
## Contributing
## Using AI
### Make changes locally
You're welcome to use AI to help you write an issue. Please read and check what it produces before you post: reports that are long, vague or unverified take time away from fixing real problems, and we may close them without a reply.
Any contribution you make can be made to the code located in this repository. In order to contribute, you'll need to start off of a local version of this repository.
## Running locally
#### 1. Fork the repository
You don't need a local setup to open an issue, but it can help you reproduce a problem or self-host the renderer.
##### Using GitHub Desktop:
### Prerequisites
- [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop.
- Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)!
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
##### Using the command line:
### Setup
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
1. Use the project's version of Node:
#### 2. Create a working branch and start with your changes
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
#### 3. Install dependencies and run the project locally
##### Prerequisites:
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
##### Setup steps:
1. Ensure you are using the project's version of Node:
```bash
nvm use
```
2. Install dependencies:
2. Install dependencies using Bun:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
Other commands:
Additional development commands:
- `bun format`: Format the code using Biome
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
- `bun lint`: Lint the code using Oxlint
- `bun lint:fix`: Automatically fix lint issues using Oxlint
- `bun format`: Format the code using Oxfmt
- `bun format:check`: Check formatting without changing files
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
#### 4. Preview your changes
### Preview a site
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
With the development server running, any published GitBook site can be rendered through your local version at `http://localhost:3000/url/<published-url>`. For example, visit `http://localhost:3000/url/gitbook.com/docs` to see our own docs.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/url/gitbook.com/docs` after running the development server.
The site must be [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to render locally.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
## License
### Commit your update
The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing).
[Commit your changes](https://github.com/git-guides/git-commit) once you are happy with them. See [Atom's contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages) to know how to use emoji for commit messages!
Once your changes are ready, don't forget to self-review your code to double check that your chagnes are ready to be added.
### Pull Request
When you're finished with the changes, [create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request), also known as a PR.
- Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one.
- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. Once you submit your PR, a GitBook team member will review your proposal. We may ask questions or request for additional information.
- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch.
- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations).
- If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues.
### Your PR is merged
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
@@ -1,83 +1,83 @@
name: Gradual Deploy to Cloudflare
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
inputs:
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
middlewareVersionId:
description: 'Middleware version ID to deploy'
required: true
serverVersionId:
description: 'Server version ID to deploy'
required: true
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
middlewareVersionId:
description: 'Middleware version ID to deploy'
required: true
serverVersionId:
description: 'Server version ID to deploy'
required: true
outputs:
deployment-url:
description: 'Deployment URL'
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
+52 -53
View File
@@ -1,34 +1,34 @@
name: 'Deploy cloudflare'
description: 'Deploy GitBook to Cloudflare'
inputs:
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
deploy:
description: 'Deploy as main version for all traffic instead of uploading versions'
required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
deploy:
description: 'Deploy as main version for all traffic instead of uploading versions'
required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
description: 'Deployment URL'
value: ${{ steps.upload_middleware.outputs.deployment-url }}
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
@@ -42,34 +42,32 @@ 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_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
@@ -136,10 +134,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 }}"
+59 -60
View File
@@ -1,31 +1,31 @@
name: 'Deploy vercel'
description: 'Deploy GitBook to Vercel'
inputs:
vercelOrg:
description: 'Vercel organization'
required: true
vercelProject:
description: 'Vercel project'
required: true
vercelToken:
description: 'Vercel token'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
vercelOrg:
description: 'Vercel organization'
required: true
vercelProject:
description: 'Vercel project'
required: true
vercelToken:
description: 'Vercel token'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
description: 'Deployment URL'
value: ${{ steps.deploy.outputs.deployment-url }}
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
@@ -40,52 +40,50 @@ 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_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 "GITBOOK_DISABLE_INSIGHTS=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
@@ -93,9 +91,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 }}"
-17
View File
@@ -1,17 +0,0 @@
## Proposed changes
<!-- Briefly describe what changed. Include relevant validation. -->
## Demo
<!-- Add screenshots or a recording when useful. Otherwise, remove this section. -->
<!-- Optional: add Context here only when it explains a concrete problem and result that the changelog does not. Remove this comment before submitting. -->
## Changelog
<!-- Keep the relevant entries and remove unused placeholders. Feature = user-facing addition or improvement; Fix = user-facing bug fix; Chore = internal maintenance. -->
- [Feature] ...
- [Fix] ...
- [Chore] ...
-22
View File
@@ -1,22 +0,0 @@
{
"display_information": {
"name": "PR Status",
"description": "Updates PR status reactions in engineering"
},
"features": {
"bot_user": {
"display_name": "PR Status",
"always_online": false
}
},
"oauth_config": {
"scopes": {
"bot": ["channels:history", "reactions:read", "reactions:write"]
}
},
"settings": {
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
-15
View File
@@ -1,15 +0,0 @@
# Slack PR status
[GitbookIO/slack-pr-status](https://github.com/GitbookIO/slack-pr-status) adds review-status reactions to PR links posted in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs from `.github/workflows/slack-pr-status.yml` in `GitbookIO/gitbook`, `GitbookIO/gitbook-x` and `GitbookIO/integrations`. Emojis and statuses are configured in that workflow; see the action's README for the options.
Owner: Peter White.
| Configuration | Value |
| --- | --- |
| Slack app | **PR Status** (`A0C2AGLAD0T`), created from `.github/slack-pr-status-app-manifest.json` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| GitHub token | The workflow's `GITHUB_TOKEN`, read-only |
Posts that link PRs from more than one repository are skipped, because each workflow's token can only read its own repository. PRs from forks get no reactions, because their workflows don't receive secrets.
To disable it in one repository, run `gh workflow disable slack-pr-status.yml --repo GitbookIO/<repository>`. To stop it everywhere, revoke the PR Status app's token.
-14
View File
@@ -20,20 +20,6 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format:check
lint:
runs-on: ubuntu-latest
name: Lint
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun lint
test:
runs-on: ubuntu-latest
name: Test
@@ -1,34 +0,0 @@
name: CSS browser compatibility
on:
pull_request_target:
types: [opened, reopened, synchronize]
permissions:
contents: read
pull-requests: read
concurrency:
group: css-browser-compatibility-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check newly added CSS declarations
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
# pull_request_target checks out the trusted base branch by default. Never use the PR ref here.
- name: Checkout trusted checker
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Check CSS browser compatibility
working-directory: packages/gitbook
run: bun run check:css-browser-compatibility
env:
GITHUB_TOKEN: ${{ github.token }}
+9 -57
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
@@ -21,8 +21,8 @@ jobs:
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
@@ -47,8 +47,8 @@ jobs:
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
steps:
@@ -72,10 +72,10 @@ jobs:
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
@@ -187,54 +187,6 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: always()
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: always() && github.event_name != 'push'
id: style-perf
working-directory: packages/gitbook
run: |
rows=$(cat test-results/style-perf.md 2>/dev/null || true)
if [ -z "$rows" ]; then exit 0; fi
{
echo 'body<<STYLE_PERF_EOF'
echo '### Style invalidation on a large API reference'
echo
echo 'Elements restyled by opening one popup on [the Snyk API reference](https://docs.snyk.io/snyk-api/reference/apps). A share near or above 100% means the insertion restyles the whole document.'
echo
echo '| interaction | restyled | page | share | budget | |'
echo '| --- | ---: | ---: | ---: | ---: | :-: |'
echo "$rows"
echo 'STYLE_PERF_EOF'
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.style-perf.outputs.body }}
edit-mode: replace
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
+9 -9
View File
@@ -7,7 +7,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -19,16 +19,16 @@ jobs:
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: production
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
environment: production
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (production)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -50,4 +50,4 @@ jobs:
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+9 -9
View File
@@ -7,7 +7,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -19,16 +19,16 @@ jobs:
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: staging
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
environment: staging
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (staging)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -50,4 +50,4 @@ jobs:
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
-33
View File
@@ -1,33 +0,0 @@
name: Slack PR status
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [closed, reopened]
permissions:
contents: read
pull-requests: read
jobs:
slack-pr-status:
# Secrets aren't available to workflows triggered from forks.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: GitbookIO/slack-pr-status@main
with:
slack-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
config: |
channels: [C01NXGWJELS]
approvalsRequired: 1
multiPr: aggregate
emojis:
commented: false
partiallyApproved: next_track_button
approved: pr-approved
changesRequested: construction
merged: merged
closed: no_entry_sign
partiallyReviewed: hourglass_flowing_sand
-3
View File
@@ -29,6 +29,3 @@ yarn-error.log*
# Bun pack artifacts
packages/*/*.tgz
# Playwright MCP artifacts
.playwright-mcp/
-70
View File
@@ -1,70 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"sortImports": {
"customGroups": [
{
"groupName": "workspace",
"elementNamePattern": ["@gitbook/**"]
}
],
"groups": [
["value-builtin", "value-external", "type-builtin", "type-external"],
{ "newlinesBetween": true },
"workspace",
{ "newlinesBetween": true },
[
"value-internal",
"type-internal",
"value-parent",
"type-parent",
"value-sibling",
"type-sibling",
"value-index",
"type-index"
],
"type-import",
"unknown"
]
},
"sortPackageJson": false,
"sortTailwindcss": {
"config": "./packages/gitbook/tailwind.config.ts",
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/public/**",
"**/.next/**",
"**/.open-next/**",
"**/.turbo/**",
"**/.vercel/**",
"**/.cache/**",
"**/.wrangler/**",
"**/*.log",
"**/*.MD",
"**/*.md",
"**/*.mdx",
"**/*.html",
"**/*.css",
"packages/embed/standalone/**",
"packages/openapi-parser/src/fixtures/**",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts"
]
}
-87
View File
@@ -1,87 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
},
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y", "vitest"],
"env": {
"builtin": true,
"browser": true,
"node": true,
"shared-node-browser": true,
"worker": true
},
"globals": {
"Bun": "readonly",
"GitBookIntegrationEvent": "readonly",
"React": "readonly"
},
"rules": {
"eslint/no-console": [
"warn",
{
"allow": ["assert", "error", "warn"]
}
],
"eslint/no-control-regex": "warn",
"eslint/no-prototype-builtins": "warn",
"eslint/no-cond-assign": "warn",
"eslint/no-undef": "error",
"eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"ignoreRestSiblings": true,
"varsIgnorePattern": "^_"
}
],
"react/button-has-type": "warn",
"react/exhaustive-deps": "warn",
"react/jsx-key": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-array-index-key": "warn",
"react/void-dom-elements-no-children": "warn",
"react/rules-of-hooks": "error",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/anchor-is-valid": "warn",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/iframe-has-title": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"jsx-a11y/label-has-associated-control": "warn",
"jsx-a11y/no-noninteractive-tabindex": "warn",
"jsx-a11y/role-has-required-aria-props": "warn",
"jsx-a11y/tabindex-no-positive": "warn",
"typescript/array-type": "error",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "warn",
"typescript/no-confusing-void-expression": "warn",
"typescript/only-throw-error": "error",
"vitest/no-focused-tests": "error",
"no-delete-var": "warn",
"no-debugger": "error",
"no-throw-literal": "error",
"use-isnan": "error",
"valid-typeof": "error"
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/public/**",
"**/.next/**",
"**/.open-next/**",
"**/.turbo/**",
"**/.vercel/**",
"**/.cache/**",
"**/.wrangler/**",
"packages/embed/standalone/**",
"packages/openapi-parser/src/fixtures/**",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"**/*.css"
]
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
"recommendations": ["biomejs.biome"]
}
+5 -3
View File
@@ -8,12 +8,14 @@
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "always"
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
"editor.defaultFormatter": "biomejs.biome"
}
}
+2 -3
View File
@@ -6,8 +6,7 @@
bun install # Install dependencies
bun dev # Start dev server (all packages)
bun run build # Build all packages
bun run lint # Lint with Oxlint
bun run format # Format with Oxfmt (run after every change)
bun run format # Format with Biome (run after every change)
bun run typecheck # Type-check all packages
bun run unit # Run unit tests
```
@@ -75,7 +74,7 @@ Save as `.changeset/<name>.md`, then commit it separately with message: `changes
## Formatting
Linting uses Oxlint and formatting uses Oxfmt. Always run `bun run format` before committing.
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
## Comments
+5 -5
View File
@@ -85,7 +85,7 @@ GitBook Open uses fontawesome. For self-hosting and local development, for licen
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn how to report bugs and suggest changes.
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
### Types of contributions
@@ -93,22 +93,22 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
#### Translations
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). Spotted a wrong or missing translation? Open an issue and let us know.
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
#### Bugs
Encounter a bug? [Open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) with as much detail as you can. Clear reports help us fix things faster for everyone.
Encounter a bug or find an issue you'd like to fix? Helping us fix issues related to GitBook greatly improves the experience for everyone. Head to the issues section of this repository to learn more about the types of bugs you can already help out with.
## Deployment
> [!WARNING]
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
>
> _Looking for a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to suggest it._
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
>
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to share your ideas with us instead of creating your own version.
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
This project allows you to self-host the rendering portion of your GitBook published content. Self-hosting has pros and cons.
+178
View File
@@ -0,0 +1,178 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [
"**/node_modules/**/*",
"**/dist/**/*",
"**/build/**/*",
"**/public/**/*",
"**/.next/**/*",
"**/.open-next/**/*",
"**/.turbo/**/*",
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noDelete": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noForEach": "off",
"noUselessFragments": "warn",
"noBannedTypes": "warn"
},
"correctness": {
"noUndeclaredVariables": "error",
"noUnusedVariables": "error",
"useArrayLiterals": "error",
"useHookAtTopLevel": "error",
"noUnusedImports": "error",
"noVoidElementsWithChildren": "warn",
"useJsxKeyInIterable": "warn",
"useExhaustiveDependencies": "warn",
"noUnknownFunction": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"noParameterAssign": "off",
"useThrowOnlyError": "error"
},
"suspicious": {
"noConsole": {
"level": "warn",
"options": {
"allow": ["assert", "error", "warn"]
}
},
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"noConfusingVoidType": "warn",
"noControlCharactersInRegex": "warn",
"noPrototypeBuiltins": "warn",
"noAssignInExpressions": "warn",
"noArrayIndexKey": "warn"
},
"a11y": {
"useSemanticElements": "warn",
"useKeyWithClickEvents": "warn",
"noSvgWithoutTitle": "warn",
"useButtonType": "warn",
"useIframeTitle": "warn",
"useAltText": "warn",
"noPositiveTabindex": "warn",
"useFocusableInteractive": "warn",
"useAriaPropsForRole": "warn",
"useValidAnchor": "warn",
"noLabelWithoutControl": "warn",
"noNoninteractiveTabindex": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "error",
"fix": "safe",
"options": {
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
}
}
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": [
"packages/gitbook/**/*",
"packages/react-openapi/**/*",
"packages/react-math/**/*",
"packages/react-contentkit/**/*",
"packages/icons/**/*"
],
"javascript": {
"globals": ["React"]
}
},
{
"include": ["packages/gitbook/**/*"],
"javascript": {
"globals": ["React", "GitBookIntegrationEvent"]
}
},
{
"include": ["*.css"],
"javascript": {
"globals": ["theme"]
}
},
{
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
"javascript": {
"globals": ["Bun"]
}
},
{
"include": [
"packages/cache-do/**/*",
"packages/gitbook/cf-env.d.ts",
"packages/gitbook/src/cloudflare-entrypoint.ts"
],
"javascript": {
"globals": [
"DurableObjectLocationHint",
"DurableObjectNamespace",
"DurableObjectStub",
"ContinentCode",
"Fetcher",
"ExportedHandler"
]
}
}
]
}
+799 -447
View File
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -5,10 +5,9 @@
"node": "^22.3.0"
},
"devDependencies": {
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.90.0",
"turbo": "^2.11.10",
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"turbo": "^2.10.3",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -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.206.0",
"@gitbook/api": "0.189.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,7 +62,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.4.3": "patches/next@16.4.3.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch"
"next@16.2.6": "patches/next@16.2.6.patch"
}
}
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/browser-types
## 0.1.6
### Patch Changes
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
## 0.1.5
### Patch Changes
+2 -6
View File
@@ -9,7 +9,7 @@
}
},
"sideEffects": false,
"version": "0.1.6",
"version": "0.1.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
@@ -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/"
+4 -18
View File
@@ -5,28 +5,14 @@ export type GitBookIntegrationEvent = 'load' | 'unload';
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
export type GitBookIntegrationToolConfirmation = {
icon?: IconName;
label: string;
/**
* Supporting context displayed to the user above the confirmation dialog,
* to help them understand what they are approving or rejecting.
* Limited to 512 characters.
*/
context?: string;
};
export type GitBookIntegrationTool = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
* Provide a static object, or a function that receives the input provided by
* the AI assistant and returns the confirmation — useful to display dynamic
* context based on the arguments the tool is about to be executed with.
*/
confirmation?:
| GitBookIntegrationToolConfirmation
| ((input: object) => GitBookIntegrationToolConfirmation);
confirmation?: {
icon?: IconName;
label: string;
};
/**
* Callback when the tool is executed.
+1 -5
View File
@@ -24,11 +24,7 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
+4 -5
View File
@@ -1,15 +1,14 @@
import assertNever from 'assert-never';
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
/**
* Get a stringified cache tag for a given object.
*/
export function getCacheTag(
spec: /**
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'user';
user: string;
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/colors
## 0.4.4
### Patch Changes
- a69a307: A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
## 0.4.3
### Patch Changes
+2 -6
View File
@@ -8,7 +8,7 @@
}
},
"sideEffects": false,
"version": "0.4.4",
"version": "0.4.3",
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
@@ -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/"
-11
View File
@@ -1,16 +1,5 @@
# @gitbook/embed
## 0.6.0
### Minor Changes
- f35be52: Add `defaultTab` and `defaultPage` options to the Docs Embed to choose where it opens. `defaultTab` opens the embed on the assistant, search or docs tab. `defaultPage` sets the page the docs tab opens on in place of the site's home page, and the embed opens on that page unless `defaultTab` says otherwise. Both work with the standalone script's `configure`, `frame.configure` and `<GitBookFrame>` props.
### Patch Changes
- 94a496c: Make the Docs Embed widget match the page it is embedded in rather than the visitor's OS: a widget on a light page stays light even when the visitor's system is in dark mode, and the widget's own chrome and the docs inside it always render in the same scheme. Sites published with a single theme impose it on the widget too, since they render in it regardless. The standalone script takes `?theme=light` on its URL, and calling `init` twice now updates the options instead of throwing.
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
## 0.5.1
### Patch Changes
+7 -54
View File
@@ -19,27 +19,15 @@ The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
```
The script initializes the widget itself, so there is nothing to call. To pin the embed to one color
scheme, put it on the script URL — it has to be known before the widget renders:
```html
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
```
To authenticate the visitor, call `init` with their token. Keep tokens out of the script URL: it is
publicly cacheable and ends up in server logs.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script>
window.GitBook('init',
// Initialize with Authenticated Access (optional)
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show');
</script>
```
@@ -54,7 +42,6 @@ GitBook('configure', {
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -108,7 +95,6 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -144,7 +130,6 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'search', 'docs']}
defaultPage="/getting-started"
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -252,33 +237,6 @@ Override which tabs are displayed. Defaults to your site's configuration.
tabs: ['assistant', 'search', 'docs']
```
### `defaultTab`
Available in: Standalone script, NPM package, React components
The tab the embed opens on. Without it, the embed opens on `defaultPage` if set, otherwise on the assistant (or the docs when the assistant isn't available). The tab must be one of the enabled `tabs`.
- **Type**: `'assistant' | 'search' | 'docs'`
```javascript
tabs: ['assistant', 'search', 'docs'],
defaultTab: 'search'
```
`defaultTab` and `defaultPage` apply when the embed first loads: the standalone widget keeps its place when it is closed and reopened. To move it later, use `navigateToPage` or `navigateToAssistant`.
### `defaultPage`
Available in: Standalone script, NPM package, React components
The page the docs tab opens on, in place of your site's home page. Accepts the same references as `navigateToPage`: the page's path within the site, an absolute path, or its full published URL. Unless `defaultTab` says otherwise, the embed opens on this page.
- **Type**: `string`
```javascript
defaultPage: '/getting-started/quickstart'
```
### `closeButton`
Available in: Standalone script, NPM package, React components
@@ -506,25 +464,20 @@ visitor: {
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme.
When omitted, the standalone widget follows the page it is embedded in — its `color-scheme`, falling back to the visitor's OS preference only when that page declares support for both. With the NPM package or the React components you own the iframe, so the embed follows the visitor's OS preference unless you pass this.
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: `?theme=light` on the script URL
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```html
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
```javascript
colorScheme: 'dark'
```
Sites published with a single theme always render in that theme, so `colorScheme` has no effect on
them — the widget follows the site instead, to keep its chrome and the docs inside it consistent.
### `button`
Available in: Standalone script only
+2 -6
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.6.0",
"version": "0.5.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -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 -3
View File
@@ -10,9 +10,7 @@ export type CreateGitBookOptions = {
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the standalone widget follows the page it is embedded in, and only falls back
* to the visitor's OS preference when that page supports both schemes. Building the iframe
* yourself, the embed follows the visitor's OS preference unless you pass this.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
*/
colorScheme?: 'light' | 'dark';
@@ -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'],
+4 -25
View File
@@ -1,32 +1,17 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
/**
* Confirmation action to be displayed to the user before executing a tool.
*/
export type GitBookToolConfirmation = {
icon?: IconName;
label: string;
/**
* Supporting context displayed to the user above the confirmation dialog,
* to help them understand what they are approving or rejecting.
* Limited to 512 characters.
*/
context?: string;
};
/**
* Custom tool definition to be passed to the AI assistant.
*/
export type GitBookToolDefinition = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
* Provide a static object, or a function that receives the input provided by
* the AI assistant and returns the confirmation — useful to display dynamic
* context based on the arguments the tool is about to be executed with.
*/
confirmation?: GitBookToolConfirmation | ((input: object) => GitBookToolConfirmation);
confirmation?: {
icon?: IconName;
label: string;
};
/**
* Callback when the tool is executed.
@@ -62,12 +47,6 @@ export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs' | 'search')[];
/** Tab to open the embed on. */
defaultTab?: 'assistant' | 'docs' | 'search';
/** Page to open the docs tab on, instead of the site's home page. */
defaultPage?: string;
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
+1 -7
View File
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
@@ -27,8 +26,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
defaultTab,
defaultPage,
trademark = true,
closeButton = false,
assistantName,
@@ -52,8 +49,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
defaultTab,
defaultPage,
actions,
greeting,
suggestions,
@@ -69,8 +64,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions,
tools,
tabs,
defaultTab,
defaultPage,
closeButton,
trademark,
assistantName,
@@ -85,6 +78,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
height="100%"
allow="clipboard-write"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import { type CreateGitBookOptions, createGitBook } from '../client';
import { GitBookContext } from './context';
-1
View File
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import type { GitBookClient } from '../client';
export const GitBookContext = React.createContext<GitBookClient | null>(null);
+14 -88
View File
@@ -55,7 +55,6 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfigured = false;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
@@ -86,47 +85,6 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
/**
* The one scheme everything follows: the widget's chrome, the frame's URL and the docs inside it.
* Either it was configured, or we match the page we are embedded in (RND-12558).
*/
function resolveColorScheme(): 'light' | 'dark' {
const configured = frameOptions?.colorScheme;
// Callers are plain JS, so anything else — a typo, a `system` — falls back to the page rather
// than reaching the CSS and the frame's URL, where the two would disagree.
return configured === 'light' || configured === 'dark' ? configured : hostColorScheme();
}
/**
* The scheme the embedding page renders in, which is not the visitor's OS preference: a page that
* never opted into dark stays light however the OS is set.
*
* Resolving a `light-dark()` is the only way to read it. A page's *used* color scheme isn't exposed
* anywhere — the CSSOM gives computed values, and a `<meta name="color-scheme">` (the common way to
* declare it) never even reaches those.
*/
function hostColorScheme(): 'light' | 'dark' {
const probe = document.createElement('div');
// The first `color` is the fallback where `light-dark()` is unsupported: without it the probe
// would inherit the page's own text colour and a white one would read as dark.
probe.style.cssText =
'display:none;color:rgb(0,0,0);color:light-dark(rgb(0,0,0), rgb(255,255,255))';
document.body.appendChild(probe);
const used = getComputedStyle(probe).color;
probe.remove();
return used === 'rgb(255, 255, 255)' ? 'dark' : 'light';
}
/** Mirror the resolved scheme onto the widget's own chrome, and hand it back for the frame's URL. */
function applyColorScheme(): 'light' | 'dark' {
const colorScheme = resolveColorScheme();
for (const element of [widgetButton, widgetWindow]) {
element.dataset.colorScheme = colorScheme;
}
return colorScheme;
}
function getClient() {
if (!_client) {
throw new Error(
@@ -144,8 +102,12 @@ function getIframe() {
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.allow = 'clipboard-write';
// One read for both, so the docs can't come back in a different scheme than the panel.
widgetIframe.src = client.getFrameURL({ ...frameOptions, colorScheme: applyColorScheme() });
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
@@ -153,53 +115,25 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
// A new frame starts from the site's own defaults, so replay whatever the host configured.
if (frameConfigured) {
_frame.configure(frameConfiguration);
}
}
return { iframe: widgetIframe, frame: _frame };
}
const GitBook = (...args: StandaloneCalls) => {
switch (args[0]) {
case 'init': {
// `~gitbook/embed/script.js` already calls `init`, so an integrator following the docs
// ends up calling it a second time. Take the new options instead of throwing: throwing
// here dropped every call queued behind it (RND-12558).
_client = createGitBook(args[1]);
frameOptions = {
// Replace rather than merge: a call that leaves out `visitor` — a logout, another
// site — must not keep the token from the last one.
...args[2],
// Except the scheme, where the first one wins: `script.js` passes the site's own
// theme when it pins one, and that is not the integrator's to override.
colorScheme: frameOptions?.colorScheme ?? args[2]?.colorScheme,
};
const colorScheme = applyColorScheme();
// Rebuild the frame only if the new options change its URL — reloading it on the
// loader's `init` plus the integrator's would throw away a chat for nothing.
const frameURL = _client.getFrameURL({ ...frameOptions, colorScheme });
if (widgetIframe && widgetIframe.src !== frameURL) {
const wasOpen = !widgetWindow.classList.contains('hidden');
widgetIframe.remove();
widgetIframe = undefined;
_frame = undefined;
if (wasOpen) {
getIframe();
}
case 'init':
if (_client) {
throw new Error(
'GitBook client already initialized. Call GitBook("unload") first.'
);
}
_client = createGitBook(args[1]);
frameOptions = args[2];
break;
}
case 'unload':
_client = undefined;
_frame = undefined;
widgetIframe?.remove();
widgetIframe = undefined;
frameOptions = undefined;
frameConfigured = false;
applyColorScheme();
widgetWindow.classList.add('hidden');
break;
case 'show':
@@ -258,7 +192,6 @@ const GitBook = (...args: StandaloneCalls) => {
}
}
frameConfigured = true;
getIframe().frame.configure({
...frameConfiguration,
});
@@ -281,11 +214,4 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
// Replay each queued call on its own, so one that throws doesn't drop the rest.
precalls.forEach((call) => {
try {
GitBook(...call);
} catch (error) {
console.error('[gitbook:embed]', error);
}
});
precalls.forEach((call) => GitBook(...call));
+8 -24
View File
@@ -29,30 +29,14 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* The widget owns the panel's surface, so it renders in the same scheme as the docs inside the
* iframe. `data-color-scheme` carries that one resolved scheme — see `resolveColorScheme()` — and
* is always set, so the declaration below only shows before the widget initializes (RND-12558).
* The colours are declared here rather than on `:root` because `light-dark()` resolves against the
* color-scheme of the element that declares them. The bundler lowers it to a variable toggle, so
* browsers without `light-dark()` still get both schemes. */
#gitbook-widget-button,
#gitbook-widget-window {
color-scheme: light;
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
--gitbook-widget-background-solid: light-dark(#FFFFFF, #0f0f0f);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #141414);
}
#gitbook-widget-button[data-color-scheme="light"],
#gitbook-widget-window[data-color-scheme="light"] {
color-scheme: light;
}
#gitbook-widget-button[data-color-scheme="dark"],
#gitbook-widget-window[data-color-scheme="dark"] {
color-scheme: dark;
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
* {
+1 -1
View File
@@ -1,6 +1,6 @@
import emojisRaws from 'emoji-assets/emoji.json';
import fs from 'node:fs';
import path from 'node:path';
import emojisRaws from 'emoji-assets/emoji.json';
interface EmojiData {
code_points: {
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/expr
## 1.4.0
### Minor Changes
- 02f4cbc: Restrict expressions to an allowlist of methods and syntax, and report unsupported expressions when parsing.
## 1.3.1
### Patch Changes
+3 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.4.0",
"version": "1.3.1",
"type": "module",
"exports": {
".": {
@@ -16,7 +16,7 @@
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
"eval-estree-expression": "^4.0.0"
"eval-estree-expression": "^3.0.1"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
@@ -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: [
+6 -435
View File
@@ -1,15 +1,7 @@
import { parseExpressionAt } from 'acorn';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { describe, expect, it } from 'bun:test';
import { ExpressionError } from '../errors';
import {
ALLOWED_METHODS,
MAX_CALLBACK_CALLS,
UnsafeExpressionError,
evaluateRestricted,
} from '../restricted';
import { ExpressionRuntime } from '../runtime';
import { SymbolArray, SymbolString } from '../symbols';
import type { Logger } from '../types';
const SILENT_LOGGER: Logger = {
@@ -18,17 +10,6 @@ const SILENT_LOGGER: Logger = {
error: () => {},
};
const getInputs = () => ({ user: { role: 'editor' }, tags: ['b', 'a'], count: 1 });
// Returns the thrown error instead of throwing, so that side effects can be asserted first.
const attempt = (fn: () => unknown) => {
try {
return fn();
} catch (error) {
return error;
}
};
describe('ExpressionRuntime', () => {
const runtime = new ExpressionRuntime(SILENT_LOGGER);
@@ -85,51 +66,6 @@ describe('ExpressionRuntime', () => {
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
{
scenario: 'array filter',
condition: 'reviews.filter(review => review.status === "approved").length >= 2',
inputs: {
reviews: [
{ status: 'approved' },
{ status: 'pending' },
{ status: 'approved' },
],
},
expectedResult: true,
},
{
scenario: 'array some',
condition: 'tags.some(tag => tag.startsWith("b") && tag.length === x)',
inputs: { tags: ['a', 'bb'], x: 2 },
expectedResult: true,
},
{
scenario: 'nested callbacks reading outer parameters',
condition: 'required.every(id => reviews.some(review => review.reviewer === id))',
inputs: {
required: ['u1', 'u2'],
reviews: [{ reviewer: 'u2' }, { reviewer: 'u1' }],
},
expectedResult: true,
},
{
scenario: 'callback parameter shadowing an input',
condition: 'tags.some(role => role === "b") && role === "admin"',
inputs: { tags: ['a', 'b'], role: 'admin' },
expectedResult: true,
},
{
scenario: 'array literal includes',
condition: '["admin", "editor"].includes(user.role)',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'array and string at',
condition: 'tags.at(-1).at(0)',
inputs: { tags: ['a', 'bc'] },
expectedResult: 'b',
},
{
scenario: 'string startsWith',
condition: 'user.role.startsWith("ad")',
@@ -166,79 +102,6 @@ describe('ExpressionRuntime', () => {
inputs: { user: { role: ' admin ' } },
expectedResult: true,
},
{
scenario: 'string length and index',
condition: 'user.role.length === 5 && user.role[0] === "a"',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'missing property',
condition: 'user.plan === undefined && missing === undefined',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'boolean claim',
condition: 'visitor.claims.flag === true',
inputs: { visitor: { claims: { flag: true } } },
expectedResult: true,
},
{
scenario: 'nested claims joined with ||',
condition: 'visitor.claims.group.a === true || visitor.claims.group.b === true',
inputs: { visitor: { claims: { group: { b: true } } } },
expectedResult: true,
},
{
scenario: 'negated guard',
condition: "!visitor.claims.unsigned || visitor.claims.unsigned.region !== 'eu'",
inputs: { visitor: { claims: { unsigned: { region: 'us' } } } },
expectedResult: true,
},
{
scenario: 'negated guard with a missing claim',
condition: "!visitor.claims.unsigned || visitor.claims.unsigned.region !== 'eu'",
inputs: { visitor: { claims: {} } },
expectedResult: true,
},
{
scenario: 'key with a colon',
condition: "visitor.claims['custom:tier'] !== 'gold'",
inputs: { visitor: { claims: { 'custom:tier': 'silver' } } },
expectedResult: true,
},
{
scenario: 'key with a colon and a missing claim',
condition: "visitor.claims['custom:tier'] !== 'gold'",
inputs: { visitor: { claims: {} } },
expectedResult: true,
},
{
scenario: 'URL-style key',
condition: "visitor.claims['https://example.com/flag'] === true",
inputs: { visitor: { claims: { 'https://example.com/flag': true } } },
expectedResult: true,
},
{
scenario: 'claim array includes',
condition: "visitor.claims.products.includes('a')",
inputs: { visitor: { claims: { products: ['a', 'b'] } } },
expectedResult: true,
},
{
scenario: 'array literal includes a claim',
condition: "['a', 'b'].includes(visitor.claims.org)",
inputs: { visitor: { claims: { org: 'a' } } },
expectedResult: true,
},
{
scenario: '&& guard with a dashed key',
condition:
"visitor.claims.unsigned && visitor.claims.unsigned['early-access'] === true",
inputs: { visitor: { claims: { unsigned: { 'early-access': true } } } },
expectedResult: true,
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
@@ -263,6 +126,11 @@ describe('ExpressionRuntime', () => {
condition: 'while (1) {}',
inputs: {},
},
{
scenario: 'unsafe expression',
condition: '[1, 2, 3].map(() => { while (1) {}})',
inputs: {},
},
];
it.each(INVALID_EXPRESSSIONS)(
@@ -280,303 +148,6 @@ describe('ExpressionRuntime', () => {
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
}
);
it.each([
{ scenario: 'string method', condition: 'visitor.claims.plan.startsWith("p")' },
{ scenario: 'array method', condition: 'visitor.claims.products.includes("a")' },
{ scenario: 'callback method', condition: 'visitor.claims.products.some(p => p)' },
{ scenario: 'missing parent', condition: 'visitor.claims.org.name.toLowerCase()' },
])(
'should give undefined when calling a method on a missing claim: $scenario',
({ condition }) => {
const inputs = { visitor: { claims: {} } };
expect(runtime.evaluate(condition, inputs)).toBeUndefined();
expect(runtime.safeEvaluate(condition, inputs)).toEqual({ value: undefined });
expect(runtime.evaluateBoolean(condition, inputs)).toBe(false);
}
);
});
describe('unsafe expressions', () => {
// Each expression tries to call this function, which only an escape from the restrictions can reach.
const PAYLOAD = '__expressionPayload';
const CALL_PAYLOAD = `globalThis.${PAYLOAD}()`;
const payload = mock(() => true);
beforeEach(() => {
payload.mockClear();
Reflect.set(globalThis, PAYLOAD, payload);
});
afterEach(() => {
Reflect.deleteProperty(globalThis, PAYLOAD);
});
const UNSAFE_EXPRESSIONS = [
{
scenario: 'array key bypassing the constructor check',
condition: `({})[["constructor"]][["constructor"]]("${CALL_PAYLOAD}")()`,
},
{
scenario: 'array key on an input value',
condition: `user.role[["constructor"]][["constructor"]]("${CALL_PAYLOAD}")()`,
},
{
scenario: 'array key on a method',
condition: `"".includes[["constructor"]]("${CALL_PAYLOAD}")()`,
},
{
scenario: 'global access in a callback',
condition: `[1].some(() => ${CALL_PAYLOAD})`,
},
{
scenario: 'computed constructor key in a callback',
condition: `[1].some(x => x["constr" + "uctor"]["constr" + "uctor"]("${CALL_PAYLOAD}")())`,
},
{
scenario: 'computed constructor key in a map callback',
condition: `[1].map(x => x["constr" + "uctor"]["constr" + "uctor"]("${CALL_PAYLOAD}")())`,
},
{
scenario: 'arrow function called through implicit toString',
condition: `({ toString: () => ${CALL_PAYLOAD} }) + ""`,
},
{ scenario: 'inherited property', condition: 'user.constructor' },
{
scenario: 'inherited property with an array key',
condition: 'user[["constructor"]]',
},
{
scenario: 'inherited property of a string',
condition: 'user.role.toString',
runtimeOnly: true,
},
{ scenario: 'inherited property of a number', condition: 'count.constructor' },
{ scenario: 'prototype access', condition: 'user.__proto__' },
{ scenario: 'inherited variable', condition: 'toString', runtimeOnly: true },
{ scenario: 'arrow function outside a callback', condition: '[() => 1].length' },
{
scenario: 'block-bodied arrow function',
condition: '[1, 2, 3].some(() => { while (1) {} })',
},
{ scenario: 'function expression', condition: '[1].some(function () { return 1 })' },
{ scenario: 'async arrow callback', condition: 'tags.some(async t => t)' },
{
scenario: 'async function callback',
condition: 'tags.some(async function (t) { return t })',
},
{ scenario: 'immediately invoked async arrow', condition: '(async () => 1)()' },
{ scenario: 'object literal', condition: '({ role: "admin" }).role' },
{ scenario: 'constructor call', condition: 'new Date()' },
{ scenario: 'eval', condition: `eval("${CALL_PAYLOAD}")` },
{ scenario: 'eval as a method', condition: `globalThis.eval("${CALL_PAYLOAD}")` },
{ scenario: 'new Function', condition: `new Function("${CALL_PAYLOAD}")()` },
{ scenario: 'Function without new', condition: `Function("${CALL_PAYLOAD}")()` },
{
scenario: 'new on a function expression',
condition: `new function () { ${CALL_PAYLOAD} }()`,
},
{ scenario: 'method outside the standard library', condition: '"a".padStart(1e9)' },
{ scenario: 'string repeat', condition: 'user.role.repeat(1e9)' },
{ scenario: 'string repeat in a callback', condition: 'tags.map(t => t.repeat(1e9))' },
{ scenario: 'mutating array method', condition: 'tags.reverse()' },
{
scenario: 'callback for a method not taking one',
condition: 'tags.includes(t => t)',
},
{
scenario: 'inherited variable in a callback',
condition: 'tags.some(t => toString)',
runtimeOnly: true,
},
{
scenario: 'callback parameter named __proto__',
condition: 'tags.some(__proto__ => 1)',
},
{
scenario: 'callback parameter named constructor',
condition: 'tags.some(constructor => 1)',
},
{
scenario: 'expression deeper than the library limit',
condition: `${'!'.repeat(60)}true`,
runtimeOnly: true,
},
{
scenario: 'array literal longer than the library limit',
condition: `[${'1,'.repeat(10_001)}].length`,
runtimeOnly: true,
},
{
scenario: 'inherited property of a callback parameter',
condition: 'tags.some(t => t.constructor)',
},
{ scenario: 'non-arrow callback', condition: 'tags.map(Function)' },
{ scenario: 'callback with an extra argument', condition: 'tags.some(t => t, "a")' },
{ scenario: 'missing callback', condition: 'tags.some()' },
{
scenario: 'too many nested callback calls',
condition:
'[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(a => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(b => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(c => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(d => d))))',
runtimeOnly: true,
},
{ scenario: 'method on a plain object', condition: 'user.hasOwnProperty("role")' },
{ scenario: 'method on a function', condition: '[1].some.call([1], n => n)' },
{
scenario: 'non-primitive argument',
condition: '[user].includes(user)',
runtimeOnly: true,
},
{ scenario: 'regex operator', condition: 'user.role =~ /^ed/' },
{ scenario: 'regex argument', condition: 'user.role.includes(/(a+)+b/)' },
{ scenario: 'BigInt literal', condition: '2n ** 64n' },
{
scenario: 'BigInt literal in a callback',
condition: '[1, 2, 3, 4].some((n) => 9n ** 9n ** 9n)',
},
{ scenario: 'BigInt literal in an array', condition: '[9n][0] ** 9n' },
{ scenario: 'BigInt literal in a template', condition: '`${9n ** 9n ** 9n}`' },
{ scenario: 'delete', condition: 'delete user.role' },
{ scenario: 'assignment', condition: 'user.role = "admin"' },
{ scenario: 'update', condition: 'count++' },
];
it.each(UNSAFE_EXPRESSIONS)(
'should throw with evaluate and return the error with safeEvaluate: $scenario',
({ condition }) => {
const evaluated = attempt(() => runtime.evaluate(condition, getInputs()));
const safeEvaluated = runtime.safeEvaluate(condition, getInputs());
expect(payload).not.toHaveBeenCalled();
expect(evaluated).toBeInstanceOf(ExpressionError);
expect(safeEvaluated.value).toBeUndefined();
expect(safeEvaluated.error).toBeInstanceOf(ExpressionError);
}
);
it.each(UNSAFE_EXPRESSIONS)(
'should evaluate to false with evaluateBoolean/evaluateBooleanAll: $scenario',
({ condition }) => {
const evaluated = runtime.evaluateBoolean(condition, getInputs());
const evaluatedAll = runtime.evaluateBooleanAll(['true', condition], getInputs());
expect(payload).not.toHaveBeenCalled();
expect(evaluated).toBe(false);
expect(evaluatedAll).toBe(false);
}
);
it.each(UNSAFE_EXPRESSIONS)(
'should throw without executing code with evaluateTemplate: $scenario',
({ condition }) => {
const evaluated = attempt(() =>
runtime.evaluateTemplate(`Hello {{ ${condition} }}`, getInputs())
);
expect(payload).not.toHaveBeenCalled();
expect(evaluated).toBeInstanceOf(ExpressionError);
}
);
it.each(UNSAFE_EXPRESSIONS)('should not mutate the inputs: $scenario', ({ condition }) => {
const inputs = getInputs();
attempt(() => runtime.evaluate(condition, inputs));
expect(inputs).toEqual(getInputs());
});
it.each(UNSAFE_EXPRESSIONS)(
'should report the error when parsing, for editors: $scenario',
({ condition, runtimeOnly }) => {
const parsed = attempt(() => runtime.parse(condition));
const { errors } = runtime.parseTemplate(`Hello {{ ${condition} }}`);
if (runtimeOnly) {
// Depends on the values, so only enforced when evaluating.
expect(parsed).not.toBeInstanceOf(Error);
expect(errors).toEqual([]);
} else {
expect(parsed).toBeInstanceOf(ExpressionError);
expect((parsed as ExpressionError).location).toBeDefined();
expect(errors).toHaveLength(1);
}
}
);
it.each(UNSAFE_EXPRESSIONS)(
'should not execute code when evaluating without validating first: $scenario',
({ condition }) => {
const expression = parseExpressionAt(condition, 0, { ecmaVersion: 'latest' });
const evaluated = attempt(() => evaluateRestricted(expression, getInputs()));
expect(payload).not.toHaveBeenCalled();
expect(
evaluated instanceof UnsafeExpressionError || evaluated instanceof RangeError
).toBe(true);
}
);
it('should point the parse error at the offending token', () => {
const condition = 'user.role.padStart(10)';
const error = attempt(() => runtime.parse(condition)) as ExpressionError;
expect(error.message).toBe('Method "padStart" is not supported');
expect(error.location).toEqual({ line: 1, column: 10 });
expect(condition.slice(error.token?.start, error.token?.end)).toBe('padStart');
});
it('should keep the location of the error when evaluating', () => {
const condition = 'user.role.padStart(10)';
const error = attempt(() => runtime.evaluate(condition, getInputs()));
expect(error).toBeInstanceOf(UnsafeExpressionError);
expect((error as ExpressionError).location).toEqual({ line: 1, column: 10 });
});
it('should explain why async callbacks are rejected', () => {
const error = attempt(() => runtime.parse('tags.some(async t => t)'));
expect((error as ExpressionError).message).toBe('Async functions are not supported');
});
it('should not validate when parsing loosely for autocomplete', () => {
expect(() => runtime.parse('user.constructor', { loose: true })).not.toThrow();
});
it.each(['constructor', '__proto__', 'prototype', '__defineGetter__', '__lookupGetter__'])(
'should not read an own "%s" key of the inputs',
(key) => {
// JSON.parse creates own properties, even for `__proto__`.
const inputs = JSON.parse(`{ "data": { "${key}": "value" }, "${key}": "value" }`);
expect(() => runtime.evaluate(`data["${key}"]`, inputs)).toThrow(ExpressionError);
expect(() => runtime.evaluate(key, inputs)).toThrow(ExpressionError);
expect(runtime.evaluateBoolean(`data["${key}"] === "value"`, inputs)).toBe(false);
}
);
it('should limit the number of callback calls per evaluation', () => {
const condition = 'items.map(item => item).length > 0';
const atLimit = { items: Array.from({ length: MAX_CALLBACK_CALLS }, (_, i) => i) };
const overLimit = { items: [...atLimit.items, MAX_CALLBACK_CALLS] };
expect(runtime.evaluate(condition, atLimit)).toBe(true);
// The budget is per evaluation, not shared across evaluations.
expect(runtime.evaluate(condition, atLimit)).toBe(true);
expect(() => runtime.evaluate(condition, overLimit)).toThrow(ExpressionError);
expect(runtime.evaluateBoolean(condition, overLimit)).toBe(false);
});
it('should only allow the standard library offered in the editor and reviewed additions', () => {
const stringMethods = SymbolString().methods.map((method) => method.name);
const arrayMethods = SymbolArray({ items: SymbolString() }).methods.map(
(method) => method.name
);
// Extend only after reviewing that a method is safe (see `ALLOWED_METHODS`).
expect(ALLOWED_METHODS.string).toEqual(new Set(stringMethods));
expect(ALLOWED_METHODS.array).toEqual(new Set([...arrayMethods, 'filter', 'map']));
});
});
describe('parse', () => {
+5 -4
View File
@@ -11,6 +11,7 @@ import type {
} from 'acorn';
import { isDummy } from 'acorn-loose';
import * as walk from 'acorn-walk';
import assertNever from 'assert-never';
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
@@ -311,7 +312,7 @@ export class AutoComplete {
node: BinaryExpression,
cursorOffset: number,
context: SymbolsTable
): AutocompleteLiteralValueSuggestion[] {
): Array<AutocompleteLiteralValueSuggestion> {
const { left, right } = node;
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
@@ -430,7 +431,7 @@ export class AutoComplete {
node: AnyNode,
cursorOffset: number,
context: SymbolsTable
): AutocompleteOperatorSuggestion[] {
): Array<AutocompleteOperatorSuggestion> {
if (node.type === 'Literal') {
const parent = findParentNode(node, ast);
@@ -495,7 +496,7 @@ export class AutoComplete {
*/
private getOperatorSuggestionsForSymbol(
symbol: ExtractSymbolDef<SymbolType>
): AutocompleteOperatorSuggestion[] {
): Array<AutocompleteOperatorSuggestion> {
switch (symbol.type) {
case SymbolType.Number:
case SymbolType.Boolean:
@@ -525,7 +526,7 @@ export class AutoComplete {
case SymbolType.Function:
return this.getOperatorSuggestionsForSymbol(symbol.returns);
case SymbolType.Union: {
return symbol.members.reduce<AutocompleteOperatorSuggestion[]>((prev, cur) => {
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
return prev;
}, []);
-1
View File
@@ -1,5 +1,4 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from './utils';
type InputValuesType =
-236
View File
@@ -1,236 +0,0 @@
import type {
ArrowFunctionExpression,
CallExpression,
Expression,
Identifier,
Literal,
MemberExpression,
Node,
UnaryExpression,
} from 'acorn';
import evalESTreeExpr from 'eval-estree-expression';
import {
ALLOWED_METHODS,
CALLBACK_METHODS,
MAX_CALLBACK_CALLS,
UNSAFE_KEYS,
UnsafeExpressionError,
getCallbackParams,
} from './rules';
/**
* Evaluate a parsed expression with the restrictions, enforcing the rules that depend on values.
*/
export function evaluateRestricted(expression: Expression, inputs: object): unknown {
return evalESTreeExpr.evaluate.sync<Expression>(expression, inputs, {
// The library's own function support compiles code with `Function()`: calls and
// arrow functions are only handled by our visitors.
functions: false,
withMembers: true,
visitors: RESTRICTED_VISITORS,
});
}
/**
* Callback calls so far, per evaluator (the library creates one per evaluation).
*/
const callbackCalls = new WeakMap<object, number>();
type Visit = (node: Node, context: object, parent?: Node) => unknown;
type Evaluator = { visit: Visit };
/**
* Visitors overriding eval-estree-expression's defaults so that expressions can only read own
* properties of plain data and call the methods of the standard library.
*/
const RESTRICTED_VISITORS = {
Identifier(this: Evaluator, node: Identifier, context: object): unknown {
return readVariable(context, node.name);
},
MemberExpression(this: Evaluator, node: MemberExpression, context: object): unknown {
const object = this.visit(node.object, context, node);
const key = node.computed
? this.visit(node.property, context, node)
: (node.property as Identifier).name;
// Reject rather than coerce, so that keys like `["constructor"]` can't slip through.
if (typeof key !== 'string' && typeof key !== 'number') {
throw new UnsafeExpressionError('Property keys must be strings or numbers');
}
return readOwnProperty(object, String(key));
},
CallExpression(this: Evaluator, node: CallExpression, context: object): unknown {
const callee = node.callee;
if (callee.type !== 'MemberExpression' || callee.computed || callee.optional) {
throw new UnsafeExpressionError('Only method calls are supported');
}
const method = (callee.property as Identifier).name;
const receiver = this.visit(callee.object, context, callee);
// Like reading a property of a missing claim, calling a method on one gives `undefined`.
if (
(receiver === null || receiver === undefined) &&
(ALLOWED_METHODS.string.has(method) || ALLOWED_METHODS.array.has(method))
) {
return undefined;
}
const fn = getAllowedMethod(receiver, method);
if (!fn) {
throw new UnsafeExpressionError(`Method "${method}" is not supported`);
}
if (CALLBACK_METHODS.has(method)) {
const [callback] = node.arguments;
if (node.arguments.length !== 1 || callback?.type !== 'ArrowFunctionExpression') {
throw new UnsafeExpressionError(`"${method}" only accepts an arrow function`);
}
return Reflect.apply(fn, receiver, [createCallback(this, callback, context)]);
}
const args = node.arguments.map((arg) => {
if (arg.type === 'SpreadElement') {
throw new UnsafeExpressionError('Spread arguments are not supported');
}
const value = this.visit(arg, context, node);
if (!isPrimitive(value)) {
throw new UnsafeExpressionError('Only primitive values can be arguments');
}
return value;
});
return Reflect.apply(fn, receiver, args);
},
ArrowFunctionExpression(): never {
throw new UnsafeExpressionError('Arrow functions are only supported as callbacks');
},
FunctionExpression(): never {
throw new UnsafeExpressionError('Functions are not supported');
},
ObjectExpression(): never {
throw new UnsafeExpressionError('Object literals are not supported');
},
Literal(node: Literal): unknown {
// Regexes are only useful to the library's `=~` operator, and BigInt exponentiation can hang.
if (node.regex || typeof node.value === 'bigint') {
throw new UnsafeExpressionError('Regex and BigInt literals are not supported');
}
return node.value;
},
UnaryExpression(this: Evaluator, node: UnaryExpression, context: object): unknown {
// The library implements `delete` by mutating the inputs.
if (node.operator === 'delete') {
throw new UnsafeExpressionError('"delete" is not supported');
}
return Object.getPrototypeOf(this).UnaryExpression.call(this, node, context);
},
AssignmentExpression(): never {
throw new UnsafeExpressionError('Assignments are not supported');
},
UpdateExpression(): never {
throw new UnsafeExpressionError('Update expressions are not supported');
},
NewExpression(): never {
throw new UnsafeExpressionError('"new" is not supported');
},
TaggedTemplateExpression(): never {
throw new UnsafeExpressionError('Tagged templates are not supported');
},
ThisExpression(): never {
throw new UnsafeExpressionError('"this" is not supported');
},
};
/**
* Create a callback evaluating the arrow function's body with the evaluator, instead of compiling it.
*/
function createCallback(evaluator: Evaluator, node: ArrowFunctionExpression, context: object) {
const params = getCallbackParams(node);
return (...args: unknown[]) => {
const calls = (callbackCalls.get(evaluator) ?? 0) + 1;
if (calls > MAX_CALLBACK_CALLS) {
throw new UnsafeExpressionError('Too many callback calls');
}
callbackCalls.set(evaluator, calls);
const variables = new Map(params.map((name, index) => [name, args[index]]));
return evaluator.visit(node.body, new CallbackScope(variables, context), node);
};
}
/**
* Variables of a callback call, falling back to the enclosing scope.
* A Map avoids copying the inputs on each call.
*/
class CallbackScope {
constructor(
readonly variables: Map<string, unknown>,
readonly parent: object
) {}
}
function readVariable(scope: object, name: string): unknown {
if (UNSAFE_KEYS.has(name)) {
throw new UnsafeExpressionError(`"${name}" is not accessible`);
}
if (scope instanceof CallbackScope) {
return scope.variables.has(name)
? scope.variables.get(name)
: readVariable(scope.parent, name);
}
return readOwnProperty(scope, name);
}
/**
* Read an own property, treating any attempt to reach an inherited one (e.g. `constructor`) as unsafe.
*/
function readOwnProperty(object: unknown, key: string): unknown {
if (UNSAFE_KEYS.has(key)) {
throw new UnsafeExpressionError(`Property "${key}" is not accessible`);
}
if (object === null || object === undefined) {
return undefined;
}
if (typeof object === 'function') {
throw new UnsafeExpressionError('Functions are not accessible');
}
// Wrapping primitives exposes string indexes and `length` as own properties.
const target = Object(object);
if (Object.hasOwn(target, key)) {
return target[key];
}
if (key in target) {
throw new UnsafeExpressionError(`Property "${key}" is not accessible`);
}
return undefined;
}
function getAllowedMethod(receiver: unknown, method: string): Function | null {
if (typeof receiver === 'string' && ALLOWED_METHODS.string.has(method)) {
return Reflect.get(String.prototype, method);
}
if (Array.isArray(receiver) && ALLOWED_METHODS.array.has(method)) {
return Reflect.get(Array.prototype, method);
}
return null;
}
function isPrimitive(value: unknown): boolean {
return value === null || (typeof value !== 'object' && typeof value !== 'function');
}
-3
View File
@@ -1,3 +0,0 @@
export { evaluateRestricted } from './evaluate';
export { ALLOWED_METHODS, MAX_CALLBACK_CALLS, UnsafeExpressionError } from './rules';
export { validateExpression } from './validate';
-78
View File
@@ -1,78 +0,0 @@
import type { AnyNode, ArrowFunctionExpression } from 'acorn';
import { ExpressionError } from '../errors';
/**
* Methods that expressions are allowed to call, per receiver type.
* Must include the standard library offered in the editor (see `symbols.ts`). Only add methods
* after review: they must not mutate their receiver, and their output must stay bounded by their input.
*/
export const ALLOWED_METHODS = {
string: new Set([
'at',
'endsWith',
'includes',
'startsWith',
'toLowerCase',
'toUpperCase',
'trim',
]),
array: new Set(['at', 'every', 'filter', 'includes', 'map', 'some']),
};
/**
* Array methods taking a callback, the only place where arrow functions are allowed.
*/
export const CALLBACK_METHODS = new Set(['every', 'filter', 'map', 'some']);
/**
* Maximum number of callback calls in a single evaluation, as nested callbacks multiply and
* visitors control the size of unsigned claims. Enough for a pass over an identity provider's
* group claim (capped around 200 entries), or a small nested pair of arrays.
*/
export const MAX_CALLBACK_CALLS = 250;
/**
* Names never readable as variables or properties, even as own properties of the inputs.
* Replaces (and extends) the library's own denylist, as our visitors override the ones applying it.
*/
export const UNSAFE_KEYS = new Set([
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
'__proto__',
'constructor',
'prototype',
]);
/**
* Thrown when an expression uses something the restrictions don't allow.
*/
export class UnsafeExpressionError extends ExpressionError {}
/**
* Create an error pointing at the node, so that editors can highlight it.
*/
export function unsupported(node: AnyNode, message: string): UnsafeExpressionError {
return new UnsafeExpressionError(message, node.loc?.start);
}
/**
* Parameter names of an arrow function, if it has the shape allowed for callbacks.
*/
export function getCallbackParams(node: ArrowFunctionExpression): string[] {
if (node.async) {
throw unsupported(node, 'Async functions are not supported');
}
if (!node.expression) {
throw unsupported(node, 'Only single-expression arrow functions are supported');
}
return node.params.map((param) => {
if (param.type !== 'Identifier' || UNSAFE_KEYS.has(param.name)) {
throw unsupported(param, 'Only simple arrow function parameters are supported');
}
return param.name;
});
}
-137
View File
@@ -1,137 +0,0 @@
import type { AnyNode, CallExpression, Identifier } from 'acorn';
import {
ALLOWED_METHODS,
CALLBACK_METHODS,
UNSAFE_KEYS,
getCallbackParams,
unsupported,
} from './rules';
/**
* Check an expression against the restrictions without evaluating it, so that editors can report
* violations. Rules depending on values (receiver types, inherited properties, limits) are only
* enforced by `evaluateRestricted`.
*/
export function validateExpression(node: AnyNode): void {
switch (node.type) {
case 'Literal':
if (node.regex || typeof node.value === 'bigint') {
throw unsupported(
node,
'Regular expressions and BigInt literals are not supported'
);
}
return;
case 'Identifier':
return assertSafeKey(node.name, node);
case 'TemplateLiteral':
return node.expressions.forEach(validateExpression);
case 'ArrayExpression':
for (const element of node.elements) {
if (element) {
validateExpression(
element.type === 'SpreadElement' ? element.argument : element
);
}
}
return;
case 'UnaryExpression':
if (node.operator === 'delete') {
throw unsupported(node, '"delete" is not supported');
}
return validateExpression(node.argument);
case 'BinaryExpression':
case 'LogicalExpression':
validateExpression(node.left);
return validateExpression(node.right);
case 'ConditionalExpression':
validateExpression(node.test);
validateExpression(node.consequent);
return validateExpression(node.alternate);
case 'SequenceExpression':
return node.expressions.forEach(validateExpression);
case 'MemberExpression':
validateExpression(node.object);
if (!node.computed) {
return assertSafeKey((node.property as Identifier).name, node.property);
}
if (node.property.type === 'Literal') {
return assertSafeKey(String(node.property.value), node.property);
}
if (node.property.type === 'ArrayExpression') {
throw unsupported(node.property, 'Property keys must be strings or numbers');
}
return validateExpression(node.property);
case 'CallExpression':
return validateCall(node);
case 'ArrowFunctionExpression':
throw unsupported(node, 'Arrow functions are only supported as callbacks');
case 'ObjectExpression':
throw unsupported(node, 'Object literals are not supported');
case 'AssignmentExpression':
// `a =~ /regex/` parses as an assignment of `~/regex/`.
throw unsupported(
node,
node.right.type === 'UnaryExpression' && node.right.operator === '~'
? 'The "=~" regex operator is not supported'
: 'Assignments are not supported'
);
default:
throw unsupported(
node,
UNSUPPORTED_SYNTAX[node.type] ?? `${node.type} is not supported`
);
}
}
/**
* Errors shown to authors for rejected syntax.
*/
const UNSUPPORTED_SYNTAX: Partial<Record<AnyNode['type'], string>> = {
AwaitExpression: '"await" is not supported',
ChainExpression: 'Optional chaining ("?.") is not supported',
ClassExpression: 'Classes are not supported',
FunctionExpression: 'Functions are not supported',
ImportExpression: '"import" is not supported',
NewExpression: '"new" is not supported',
TaggedTemplateExpression: 'Tagged templates are not supported',
ThisExpression: '"this" is not supported',
UpdateExpression: '"++" and "--" are not supported',
YieldExpression: '"yield" is not supported',
};
function validateCall(node: CallExpression): void {
const callee = node.callee;
if (callee.type !== 'MemberExpression' || callee.computed || node.optional) {
throw unsupported(node, 'Only method calls are supported');
}
validateExpression(callee.object);
const method = (callee.property as Identifier).name;
if (!ALLOWED_METHODS.string.has(method) && !ALLOWED_METHODS.array.has(method)) {
throw unsupported(callee.property, `Method "${method}" is not supported`);
}
if (CALLBACK_METHODS.has(method)) {
const [callback] = node.arguments;
if (node.arguments.length !== 1 || callback?.type !== 'ArrowFunctionExpression') {
throw unsupported(node, `"${method}" only accepts an arrow function`);
}
getCallbackParams(callback);
return validateExpression(callback.body);
}
for (const arg of node.arguments) {
if (arg.type === 'SpreadElement') {
throw unsupported(arg, 'Spread arguments are not supported');
}
validateExpression(arg);
}
}
function assertSafeKey(key: string, node: AnyNode): void {
if (UNSAFE_KEYS.has(key)) {
throw unsupported(node, `"${key}" is not accessible`);
}
}
+6 -32
View File
@@ -11,11 +11,10 @@ import {
import { parse as parseLoose } from 'acorn-loose';
import escodegen from 'escodegen';
import evalESTreeExpr from 'eval-estree-expression';
const { variables } = evalESTreeExpr;
const { evaluate, variables } = evalESTreeExpr;
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
import { UnsafeExpressionError, evaluateRestricted, validateExpression } from './restricted';
import type { SymbolsTable } from './symbols';
import type { TemplatePart } from './template';
import { parseTemplate as parseTemplateParts } from './template';
@@ -40,7 +39,6 @@ export class ExpressionRuntime {
/**
* Evaluates an expression based on the given inputs/context.
* Throws an `ExpressionError` for invalid expressions, including those the restrictions don't allow.
*/
public evaluate(expr: string, inputs: object): unknown {
try {
@@ -50,12 +48,12 @@ export class ExpressionRuntime {
throw new ExpressionError('Invalid nodes found when parsing');
}
return evaluateRestricted(parsed.result, inputs);
return evaluate.sync<Expression>(parsed.result, inputs, {
functions: true,
withMembers: true,
generate: escodegen.generate,
});
} catch (error) {
// Keep the location, so that editors can point at the offending token.
if (error instanceof UnsafeExpressionError) {
throw error;
}
throw error instanceof Error
? new ExpressionError(error.message)
: new ExpressionError('Unexpected error');
@@ -215,11 +213,6 @@ export class ExpressionRuntime {
// Return information on the other nodes as invalid nodes
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
// Loose parsing serves autocomplete on partial expressions, which are expected to be incomplete.
if (!options.loose) {
validateRestrictedExpression(expr, statement.expression);
}
return {
result: statement.expression,
invalidNodes,
@@ -272,25 +265,6 @@ function createExpressionErrorFromSyntaxError(
return new ExpressionError(errorMessage, loc, token);
}
/**
* Validate an expression against the restrictions, pointing the error at the offending token for editors.
*/
function validateRestrictedExpression(code: string, expression: Expression): void {
try {
validateExpression(expression);
} catch (error) {
if (error instanceof UnsafeExpressionError && error.location) {
throw new UnsafeExpressionError(
error.message,
error.location,
getTokenAtLoc(code, error.location)
);
}
throw error;
}
}
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
const tokens = tokenizer(code, {
ecmaVersion: 'latest',
+3 -3
View File
@@ -20,7 +20,7 @@ export interface ExpressionParserResult {
/**
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
*/
invalidNodes: ExpressionStatement[];
invalidNodes: Array<ExpressionStatement>;
}
export interface ExpressionAutocompleteResults {
@@ -158,11 +158,11 @@ export interface AutocompleteSymbolSuggestion {
symbol: SymbolInfo;
}
export type AutocompleteSuggestions = (
export type AutocompleteSuggestions = Array<
| AutocompleteSymbolSuggestion
| AutocompleteLiteralValueSuggestion
| AutocompleteOperatorSuggestion
)[];
>;
type LoggerFn = (message: string, ...args: any[]) => void;
-4
View File
@@ -27,10 +27,6 @@ declare module 'eval-estree-expression' {
* Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined
*/
withMembers?: boolean;
/**
* Custom visitors by node type, taking precedence over the built-in ones. Called with the evaluator as `this`.
*/
visitors?: Record<string, Function>;
}
/**
+2 -1
View File
@@ -1,7 +1,8 @@
import { APIv2 } from 'google-font-metadata';
import fs from 'node:fs/promises';
import path from 'node:path';
import { APIv2 } from 'google-font-metadata';
import { CustomizationDefaultFont } from '@gitbook/api';
import type { FontDefinitions } from '../src/types';
+1 -6
View File
@@ -27,12 +27,7 @@
"unit": "bun test",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": [
"dist",
"bin",
"README.md",
"CHANGELOG.md"
],
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
"engines": {
"node": ">=20.0.0"
},
+2 -1
View File
@@ -1,4 +1,5 @@
import rawFonts from './data/fonts.json' with { type: 'json' };
import type { FontDefinitions } from './types';
import rawFonts from './data/fonts.json' with { type: 'json' };
export const fonts: FontDefinitions = rawFonts;
@@ -1,7 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont } from '@gitbook/api';
import { getDefaultFont } from './getDefaultFont';
describe('getDefaultFont', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
import { fonts } from './fonts';
import type { FontWeight } from './types';
@@ -87,7 +86,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
// ---------- tally code-point hits ----------
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
for (let i = 0; i < text.length;) {
for (let i = 0; i < text.length; ) {
const cp = text.codePointAt(i)!;
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
-240
View File
@@ -1,245 +1,5 @@
# gitbook
## 0.28.0
### Minor Changes
- 3d37684: Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
- 523b7cd: Support external links in published site navigation.
- 89c4a0f: Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled.
### Patch Changes
- a17ed29: Fix admin toolbar magnification, tooltip placement, title truncation and logo colour.
- 048c4e4: Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
- 996d7ec: Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
- fa9c9b3: Force software rendering (SwiftShader) in Playwright Chromium to eliminate image downscaling drift between GPU-equipped local runs and headless CI runs in Argos screenshots.
- 3ed448f: Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
- 2137c2d: Track which Ask AI entry point opened the chat in ask view analytics.
- a9e6814: Add `leaveAgentFeedback` and `leaveUserFeedback` assistant tools, reporting documentation findings to the site's team through the same path as the MCP `sendFeedback` tool, and record an `agent_feedback` insights event on both surfaces.
- d9fc460: Stop showing previously asked questions as suggestions in the GitBook Assistant
- 1ee1853: Add an assistant tool to rate its own previous response when the user reacts to it.
- cef1870: Add an assistant tool to submit feedback about the current page on behalf of the user.
- 49d35aa: Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.
- 8ad7465: Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
- 6199242: Recolor text over a background page cover in the first frame after hydration.
- b3db1c5: Keep the breadcrumbs from covering the page actions' hit area.
- 1bf3bd4: Update API
- 9b822ab: Include published page descriptions in the page's Markdown output.
- 64e143a: Bump Next.js to 16.3.3.
- 9d62cb4: Fix table of contents scrolling back to the current page when expanding another group.
- c08d05a: Fix select filters not working on table and cards blocks.
- a566153: Chunk oversized visitor auth cookies to fix an infinite redirect loop when the visitor token exceeds the browser cookie size limit.
- 1b7ac0e: Fix inline Ask AI buttons opening a configured custom assistant.
- ae9367d: Assistant: the "Explored briefly" activity heading no longer appears when there's nothing to show. It now renders only when the answer is preceded by a real preamble or one or more tool calls, so a simple answer with an empty reasoning step no longer surfaces an empty collapsible.
- b0cb035: Mark blocks, pages and titles so comments can target them on change request previews.
- 688515e: Fix ContentKit buttons to size to their content instead of stretching to the full container width.
- e041e49: Stop the built-in cookie banner from flashing on sites using a consent integration such as Osano or OneTrust.
- 5f88f4e: Fix breadcrumbs for cross-space page links in grouped sites.
- dff0c79: Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
- 1ef7160: Show the theme toggle in the footer whenever the outline column that hosts the other toggle isn't pinned open, so it stays reachable on laptop-sized screens in wide layouts and while the AI chat is open.
- 3939125: Disable click-to-zoom for inline line sized images.
- 39316fc: Give the prompt block a default "Copy prompt" action alongside "Open in Claude", "Open in Codex" and "Open in Cursor", and remember the visitor's last pick across every prompt block.
- c366796: Keep the docs embed open when switching section, variant or language.
- f35be52: Add `defaultTab` and `defaultPage` options to the Docs Embed to choose where it opens. `defaultTab` opens the embed on the assistant, search or docs tab. `defaultPage` sets the page the docs tab opens on in place of the site's home page, and the embed opens on that page unless `defaultTab` says otherwise. Both work with the standalone script's `configure`, `frame.configure` and `<GitBookFrame>` props.
- 177ef85: Fix host action buttons in the Docs Embed not reaching the assistant.
- 4e9071d: Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL.
- 94a496c: Fix the Docs Embed widget rendering with a dark surface when the visitor's OS is in dark mode, even on a light page or a site published with a light theme. The embed script now accepts `?theme=light|dark` to force a scheme.
- 9002f65: Fix suggested question clicks in the embed search not opening the assistant.
- 13059b8: Fix switching from the Search tab to the Docs or Assistant tab in the embed doing nothing.
- b5f3c14: Add configurable default visibility to Prompt block
- a69a307: A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
- 02f4cbc: Restrict expressions to an allowlist of methods and syntax, and report unsupported expressions when parsing.
- f9ad9b5: Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
- 03bbacf: Add missing link reference to OpenAPI models
- 530f98a: Skip internal paths (`~gitbook/*`, `.well-known/oauth-protected-resource`, `llms.txt`, `robots.txt`, `sitemap.xml`, `rss.xml`) when building URL lookup alternatives, to avoid resolving URLs that can never match content.
- cc441c9: Keep published search results within the selected site-space scope during local and remote result fusion.
- 0911abc: Fix page cover image jumping on load
- fce8a6f: Fix the double slash in the example page URLs of the MCP server's getPage and sendFeedback tools.
- 357af3f: Only advertise RSS feeds on pages with Updates blocks.
- 4452fd7: Fix page link titles showing the site section name instead of the target space title.
- b1102fc: Use the DocumentTextColor type defined in API schema.
- 9f22456: Allow sites and the embedded Assistant to load inside Ionic and Capacitor iOS apps by permitting the `ionic:` and `capacitor:` schemes in `frame-ancestors`.
- 7f8ab7d: Serve Markdown responses as plain text to OpenAI's ChatGPT Search crawler.
- d0a63ba: Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
- 7bab574: Self-host default Google fonts and inline only the @font-face rules of the fonts a site uses, instead of shipping render-blocking stylesheets covering all 23 families on every page.
- 05cff0a: Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages).
- 6928a9b: Hide card fields that render no content, along with their title
- 3ae1524: Hide the Intercom launcher while the Assistant panel is open, so it no longer overlaps the message box.
- 8bc381d: Fix low-contrast button labels inside success, warning and danger hints.
- c41cf9c: Add cover image background mode and masks
- 56c2558: Upgrade react-hotkeys-hook to v5 and use its native `useKey` option so keyboard shortcuts match the produced key on non-QWERTY layouts.
- d685660: Agents can now send a goal with any question they ask on the Markdown version of a page.
- 9f5290b: Fix inline Ask AI inputs and buttons doing nothing before search is opened.
- 39156ee: Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
- 552538a: Track server-side Insights events (markdown, llms.txt, RSS, ask) with the site's public URL instead of the proxy URL.
- 380af10: Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
- bf674a4: Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
- c1b5be0: Keep embed demos and frames on the deployment that served them.
- b13fd91: Fix a hydration mismatch on every page load caused by the AI chat time-based greeting being computed in the server timezone.
- 3370103: Fix blurry YouTube thumbnails in tabs by lazy-loading all embeds until they are visible.
- 9632516: Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
- 2ccd43e: Lazy-load the Mermaid code block so pages that have code blocks but no diagram no longer ship its rendering dependencies.
- 65f99ea: Lazy load the Scalar API client modal and stop preloading the Scalar runtime. The modal is now code-split into its own chunk, fetched in parallel with the runtime only when a reader clicks "Test it", and a spinner is shown on the button until the client opens.
Breaking: the package no longer ships the modal in its main entry — consumers must serve the emitted `ScalarApiModal` chunk and use a bundler that supports dynamic `import()`, and the Scalar runtime is no longer preloaded on page load. The internal `preloadScalarRuntime` helper is removed.
- 810244e: Stop preloading the zoom-modal variant of every zoomable image at render time; it downloaded each image twice during the initial page load. The modal image still loads on hover or click.
- 581abe0: Announce pagination at the top of every part of a multi-part llms-full.txt, with absolute links to the previous and next parts.
- 75bdff3: Serve an indexable `X-Robots-Tag` on markdown pages requested by AI agents
- 2ff77e1: Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
- d7c867a: Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
- ccb9d7b: Submit `sendFeedback` MCP tool findings through the dedicated `submitSiteAgentFeedback` API endpoint. The `pageUrl` is now required and an optional `goal` can be provided.
- 9f94067: Update Mermaid to 11.16.1 to match the editor, adding support for doughnut charts.
- 65acba0: Fix Mermaid block diagrams falling back to a code block on published sites.
- 65acba0: Fix long Mermaid labels being clipped instead of wrapping at some browser zoom levels.
- d8dc59b: Improve dark theme contrast for Mermaid edge labels.
- 01c9b05: Mermaid diagrams no longer hijack page scrolling: zooming with the wheel now requires holding Ctrl/Cmd inline, while the fullscreen view keeps free wheel zoom.
- b3db1c5: Migrate the headless UI primitives from Radix and react-aria to Base UI.
- a664407: Persist content selection (tabs and other `select` blocks) in localStorage only, dropping the `?select=` query parameter from the URL. A tab click still writes the tab's hash, so a copied URL lands on that tab and reactivates it on load.
- c7c1b79: Improve the prompt for agents to ask questions.
- 14562e9: Serve `X-Robots-Tag: noindex` on internal search/assistant URLs (`?q=` / `?ask=`) and stop disallowing them in robots.txt, so Google can crawl the directive and drop them from the index instead of reporting "Indexed, though blocked by robots.txt".
- bdc2497: Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect.
- 90b5468: Redesign the site OAuth consent screen for published sites MCP and translate its strings
- 40a879a: Drop a column instead of shrinking them all when a section group dropdown runs out of room
- a9c5d1b: Let a section group dropdown scroll when it is taller than the screen
- 827e4f9: Fix the variant switcher linking to the wrong URL for non-default variants of the default section
- 35be383: Skip the rendering work for off-screen OpenAPI blocks, so pages with many operations stay smooth to scroll.
- db176ba: Add an "On this page" table of contents on OpenAPI models pages. Each model in a grouped/multi-model "Models" section is now listed as its own section, matching operations and webhooks.
- 5c4a808: Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports.
- 332089e: Improve cookie handling in the OpenAPI "Test it" request proxy.
- a80d412: Serve the OpenAPI "Test it" request proxy from GitBook's own domain.
- 1424c56: Keep the OpenAPI renderer out of the initial bundle of pages that have no OpenAPI block, by building its context on the client behind a dynamic boundary.
- e4b214e: Fix OpenAPI webhook payload and schema example panels being clipped instead of scrollable.
- ffebd17: Use the page's tag title, when set, for the HTML `<title>` of published pages.
- 7009bb9: Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages.
- 40150f0: Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
- e460ec3: Preserve external page destinations returned by published search.
- 148a43a: Use one canonical backend-ranked result set for published searches across site sections.
- 5da854f: Preserve canonical backend ranking and present page or section context that matches each published search destination.
- 78c589f: Align the Previous page navigation button to the left edge and the Next button to the right edge, including when only one of them is present.
- b9453a9: Preserve the full site preview path and query parameters when redirecting users to log in.
- ad6262b: Fix previewed customization and theme overrides being lost when navigating between pages inside the site preview. The preview cookie was scoped to the site's canonical base path instead of the preview route it is actually served under, so the browser dropped it on the next in-preview navigation.
- 8e87856: Fix pages resolving to "not found" when a root URL lookup resolves to a custom homepage, by no longer using the homepage pathname as a prefix for the requested page path.
- 1faa57b: Keep current-space search results inside revision previews.
- 7543eb8: Prewarm published search caches when readers open the search interface.
- 990e702: Clarify the not-found page on sites with login-protected content: prompt anonymous visitors to log in, and tell logged-in visitors they may not have access.
- 185dd83: Inject site tracking scripts (analytics integrations) after `load` + idle instead of preloading them and executing them during the critical loading window.
- 77058e3: Render horizontal and vertical merged table cells on published pages.
- e4638f1: Pass page context to webframes opened from integration modals.
- 0540ebc: Stop page group breadcrumbs from linking to a URL that redirects to the group's first page.
- 634a24f: Fix text disappearing in Firefox and iOS Safari on pages with a background cover
- 64ce9e1: Render a site-space custom home page at its placement root while preserving the full space and normal page URLs.
- b95add7: Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter.
- 8e13169: Scroll the table of contents to the active page after client-side navigation.
- d839d9d: Site redirects whose source path has accented or non-Latin characters now redirect visitors instead of returning 404.
- f1d5123: Show site UI in French or Spanish for spaces set to Canadian French, Mexican Spanish or Latin American Spanish.
- b967b24: Fix analytics and adaptive content silently breaking on sites served from a different host than the one configured (apex vs www, domain alias, CDN). The insights and visitor-claims endpoints are now requested relative to the page's own origin instead of the configured host, which a prerendered page cannot know.
Keep the traffic of GitBook's own preview deployments out of the sites' analytics, now that those requests reach the events endpoint.
- 81dba64: Resolve stable page, space and file refs in images, definitions and HTML blocks of published markdown pages, instead of leaking internal `/pages/{id}`, `/spaces/{id}` and `/files/{id}` URLs.
- ac84365: Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade.
- 70bfdda: Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.6 upgrade (root `package.json`'s `patchedDependencies` still pinned it to the old `16.3.3` patch file, so Bun stopped applying it).
- da7fb13: Fail closed in `/~gitbook/revalidate` when `GITBOOK_SECRET` is not configured, returning `403 Revalidation is disabled` instead of skipping the signature check, consistent with `force-revalidate`.
- a9a52fe: Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
- 195c9e6: Fix the docs embed not applying `?theme=light`/`?theme=dark`. The theme was dropped when the embed redirected to its default tab, and the embed tabs couldn't read it because they render statically (their request headers are empty). The middleware now threads a forced embed theme through the embed route context (scoped to the embed, not the main site), so the embed tabs honor it while staying statically rendered, and the redirect forwards it to the default tab. The forced theme is also persisted to the embed's own theme storage so it is remembered across tab navigation instead of only applying while the query string is present.
- 597fe34: Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once.
- e14609c: Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint.
- 98b2df4: Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback.
- 8e9a49d: Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded).
- cf4efc7: Fix the search field losing focus if it was focused just before the page finished hydrating.
- e73b182: Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
- 6d02b8a: Fix code block syntax highlighting so comment delimiters (e.g. `//`, `/*`) use the same color as the rest of the comment. Previously the delimiter fell through to the generic punctuation scope, making it a different color from the comment body (most visible in dark mode).
- bcd1c41: Scroll to the top when selecting a search result for the page already being viewed.
- 1f250d1: Add end-to-end coverage for root and nested external links in site section navigation.
- 8a0e0df: Fix section links in search results opening the page without scrolling to the section.
- 484cc11: Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
- c1b5be0: Allow published search to be scoped to any visible site section.
- 9f447aa: Let search buttons open search limited to the current space.
- d33e570: Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
- 0a82628: Keep keyboard focus within search while the results are open.
- 5889642: Keep the last search query visible after closing search, and restore it when reopening, without breaking navigation when clicking a search result.
- 3db14ad: Link page-level search matches to the top of the page while preserving section anchors for section matches.
- e94a508: Fix keyboard focus getting trapped inside the header search popover, allowing Tab/Shift+Tab to exit it as expected.
- d053984: Fix the section group dropdown keeping the wrong width when moving quickly between groups.
- 0dd2f4f: Show the containing section name when hovering a direct link to a space.
- 8c10d92: Fix the section group menu dismissing while the mouse is still inside it, when the collapsed sidebar rail overlaps the menu on no-sidebar pages.
- ded2f56: Show loose sections in a section group dropdown as secondary links unless the group starts with one
- 0ff21b7: Stop the section tabs in the header from showing a scroll button and faded edge when all tabs already fit.
- 848a39d: Send the visitor country to resolvePublishedContentByUrl on dev, preview, staging and selected sites, using the forwarded `x-gitbook-country` header on revalidation requests.
- 03aa0f7: Render a single Ask AI paragraph button that moves to the hovered paragraph, instead of one hidden button per paragraph.
- cd506e3: Keep Shiki out of the initial page bundle by splitting the highlighter from the plain-text token helpers, so client code no longer pulls the engine and language bundles in through a shared import.
- ad3399b: Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask <assistant> about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow.
- 0e0085e: Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
- 931cbe7: Use cached Git metadata (via @gitbook/api 0.201.0's `cachedMetadata` param) when rendering Edit on Git actions.
- df2841c: Fix the site auth login link sometimes redirecting back to an internal URL after login.
- e0bd04f: Render headings with the site's heading font when one is configured, falling back to the main font otherwise.
- a625112: Skip rendering the Ask AI paragraph button inside table cells, where it is hidden, to keep pages with large tables within memory limits.
- afbc57c: Don't track a page view for 404s on internal `~gitbook` routes, so they no longer appear as broken URLs.
- 0f0cfde: Narrow a table or card grid to the reader's content selection: choosing an option in a tab or a select button now filters any select column that offers it. The table says what it has been narrowed to, and offers a Clear that drops the selection.
- bf29570: Fix the tabs "more" dropdown showing when no tab is overflowing, and stop a tab click re-rendering every tab group on the page.
- 703e654: Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted.
- 0b515b0: Fix 404 on Markdown pages (View as Markdown) in site previews and when a theme is forced with `?theme=`.
- 57f3077: Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations.
- 8676ad1: Hide unfocusable unlabelled button from screen readers
- 6cf4278: Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space.
- 7594a33: Show image thumbnails, including SVG previews, for file attachments on published sites.
- 5725da1: Let a tab keep its content selection across a rename, by honouring an explicit slug set on the tab instead of always deriving one from its title.
- 5c74eef: Make the table search "no results" empty state more prominent with vertical spacing so it no longer blends into the content below.
- 5f38ae6: Adjust tab panel and tab button padding so tabbed content has more breathing room.
- 038008c: Fix heading anchor links being unreachable on touch devices by adding a tap-to-reveal state. The anchor icon now appears after the heading text without wrapping onto an orphan line while retaining its existing desktop placement. Use `pointerup` for the dismiss listener to fix unreliable dismissal on iOS Safari, and enlarge the anchor's touch tap target to a square 24px area (meeting the WCAG 2.5.8 minimum) so the icon stays centered instead of overflowing shorter headings' line height.
- a9a1a60: Fix search results from a previous scope staying stuck on top of the new results when switching the search filter.
- 46b3a18: Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
- 8a6baab: Load the admin toolbar and its CSS lazily so published pages no longer ship a render-blocking stylesheet for admin-only UI.
- a9c5d1b: Remove the gap between the columns of a section group dropdown holding a large group
- a9041a3: Remove the fallback query parameter after successful page navigation without adding a browser history entry.
- 931cbe7: Restore Edit on Git page actions for Git-synced pages.
- ea19801: Support localized custom AI Assistant greeting subtitles.
- 1dccf8f: Fix the first item of a table-of-contents page group appearing cut off (faded under the group header) after client-side navigation.
- 48fba7c: Highlight a table-of-contents link entry as active when it points to the page — or the section of a page — you are currently viewing.
- 96626fe: Fix the admin toolbar insights button to link to the site's analytics page.
- 048c4e4: Fix chevrons and open-state styling incorrectly reacting to a tooltip opening on the same trigger, by switching from the shared Base UI `data-popup-open` attribute to `aria-expanded`.
- fc06bc6: Track `ask_trigger_click` insights event when an Ask entry point is clicked.
- 8dd9444: Track Insights link clicks on the Download button of file blocks.
- d04f8bc: Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
- a73800e: Upgrade object-identity to preserve value types when caching object arguments.
- 679ea07: Read the production hostnames that receive the visitor country from the `GITBOOK_VISITOR_COUNTRY_HOSTNAMES` build-time env variable.
- f3408ed: a11y screen reader fixes
- 3bf55ce: Support query params in the `@webframe.navigate` action.
- cb92754: Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
- 6083a88: Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
- f7dc7fa: Reserve the webframe's size in the server-rendered layout so anchor links to content below integration blocks no longer land too high.
- 2c4d40a: Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
- 8a70022: Only track embed view events once the frame is actually shown to the reader
- cf94386: Fix an issue where certain keywords could cause an exception when rendering emojis
- 673f4b6: Add the `select` action to InlineButton. Clicking the button activates its slug, so any block containing that slug switches to it.
- Updated dependencies [f35be52]
- Updated dependencies [94a496c]
- Updated dependencies [a69a307]
- Updated dependencies [02f4cbc]
- Updated dependencies [03bbacf]
- Updated dependencies [bf674a4]
- Updated dependencies [65f99ea]
- Updated dependencies [5c4a808]
- Updated dependencies [1424c56]
- Updated dependencies [3ef1802]
- Updated dependencies [1e8c34a]
- Updated dependencies [3bf55ce]
- Updated dependencies [cb92754]
- Updated dependencies [6083a88]
- Updated dependencies [f7dc7fa]
- @gitbook/embed@0.6.0
- @gitbook/colors@0.4.4
- @gitbook/expr@1.4.0
- @gitbook/react-openapi@2.0.0
- @gitbook/openapi-parser@3.0.13
- @gitbook/browser-types@0.1.6
- @gitbook/react-contentkit@0.7.17
## 0.27.2
### Patch Changes
+19 -7
View File
@@ -215,8 +215,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.n8n.io',
contentBaseURL: 'https://docs.n8n.io',
// The site registers its own cookie banner, so the built-in one never shows.
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.cherryai.com.cn',
@@ -283,6 +282,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://vimeo.com',
tests: [{ name: 'Home', url: '/legal' }],
},
{
name: 'help.platipomiru.com',
contentBaseURL: 'https://help.platipomiru.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'help.aikido.dev',
contentBaseURL: 'https://help.aikido.dev',
@@ -308,6 +312,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.triumpharcade.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.nats.io',
contentBaseURL: 'https://docs.nats.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'help.glpi-project.org',
contentBaseURL: 'https://help.glpi-project.org',
@@ -356,9 +365,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.
// {
@@ -383,7 +390,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,
},
],
@@ -396,7 +403,7 @@ const testCases: TestsCase[] = [
{
name: 'faq.wanttopay.net/wanttopay-app',
contentBaseURL: 'https://faq.wanttopay.net',
tests: [{ name: 'Home', url: '/wanttopay-app' }],
tests: [{ name: 'Home', url: '/wanttopay-app', run: waitForCookiesDialog }],
},
{
name: 'guide.prismlive.com',
@@ -473,6 +480,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://manual.edgetx.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cortex.io',
contentBaseURL: 'https://docs.cortex.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
// Flaky because of the GIF
// {
// name: 'docs.mufy.ai',
+25 -452
View File
@@ -1,6 +1,3 @@
import { type Page, expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
CustomizationAIMode,
CustomizationBackground,
@@ -10,11 +7,14 @@ import {
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationPageActionType,
CustomizationSidebarListStyle,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
@@ -35,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
@@ -53,27 +51,6 @@ const AI_PROMPT = [
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
// `InsightsProvider` debounces its flushes by 1.5s.
const INSIGHTS_FLUSH_TIMEOUT = 3000;
/**
* Collect the insights events of a given type sent by the page and its frames.
*/
function trackInsightsEvents(page: Page, type: string) {
const collected: { type: string }[] = [];
page.on('request', (request) => {
if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) {
return;
}
const body = request.postDataJSON() as { events?: { type: string }[] } | null;
collected.push(...(body?.events ?? []).filter((event) => event.type === type));
});
return collected;
}
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
if (greeting) {
@@ -158,57 +135,6 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeFocused();
},
},
{
name: 'Search - Keyboard focus exits to the next page control',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await searchInput.fill('gitbook');
const searchPopup = page.getByTestId('search-popover');
await expect(searchPopup).toBeVisible({ timeout: 10_000 });
const finalPopupControl = searchPopup
.locator(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
.filter({ visible: true })
.last();
const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first();
await expect(finalPopupControl).toBeVisible();
await expect(nextPageControl).toBeVisible();
await finalPopupControl.focus();
await page.keyboard.press('Tab');
await expect(searchPopup).toBeHidden();
await expect(nextPageControl).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(searchInput).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({
@@ -249,43 +175,6 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
// RND-12844: the popover's focus manager re-focused the closing popup and
// scrolled the page back to the top right after landing on the section.
name: 'Search - Section result scrolls to the section',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
// Type like a visitor: `fill()` doesn't trigger the remote search.
await searchInput.pressSequentially('tasks');
const sectionResult = page.locator(
'[data-testid="search-page-result"][href$="/blocks/lists#tasks"]'
);
// Section results come from the remote index, which can be slow to answer.
await expect(sectionResult).toBeVisible({ timeout: 30_000 });
await sectionResult.click();
await page.waitForURL(/\/blocks\/lists#tasks$/);
// The regression scrolled back to the top shortly after landing, so let
// that happen before asserting.
await page.waitForTimeout(1000);
// The heading is parked under the header, within its scroll margin.
const top = await page
.locator('#tasks')
.evaluate((heading) => heading.getBoundingClientRect().top);
expect(top).toBeGreaterThanOrEqual(0);
expect(top).toBeLessThanOrEqual(150);
},
},
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
@@ -492,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');
@@ -520,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');
@@ -554,30 +441,11 @@ const testCases: TestsCase[] = [
name: 'GitBook Site (Navigation when switching variant)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
tests: [
{
name: 'Strip fallback after loading a page without adding history',
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const previousURL = page.url();
const targetURL = new URL(previousURL);
targetURL.searchParams.set('fallback', 'true');
targetURL.searchParams.set('ref', 'variant');
targetURL.hash = 'pets';
await page.goto(targetURL.toString());
targetURL.searchParams.delete('fallback');
await expect(page).toHaveURL(targetURL.toString());
await page.goBack();
await expect(page).toHaveURL(previousURL);
},
},
{
name: 'Keep navigation path/route when switching variant (Public)',
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -594,11 +462,8 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
(url) =>
url.pathname.includes(
'api-multi-versions/2.0/reference/api-reference/pets'
) && !url.searchParams.has('fallback')
await page.waitForURL((url) =>
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
);
},
},
@@ -607,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');
@@ -649,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');
@@ -685,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');
@@ -716,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');
@@ -757,128 +618,18 @@ 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'));
},
},
{
name: 'Root external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootSections = page.locator('[data-gb-sections]');
const rootItems = rootSections.locator(':scope > li');
await expect(rootItems).toHaveCount(4);
await expect(rootItems.nth(0)).toContainText('Home');
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
await expect(rootItems.last()).toContainText('Gitbook Docs');
await expect(
rootSections.getByRole('link', { name: 'Gitbook Docs' })
).toBeVisible();
},
},
{
name: 'Root external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootLink = page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' });
await expect(rootLink).toBeVisible();
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
await expect(rootLink).not.toHaveAttribute('target');
await expect(rootLink).not.toHaveAttribute('rel');
await expect(rootLink).toHaveAttribute('data-active', 'false');
await expect(rootLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'Nested external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
const nestedItems = nestedLink
.locator('xpath=ancestor::ul[1]')
.locator(':scope > li');
await expect(nestedItems).toHaveCount(3);
await expect(nestedItems.nth(0)).toContainText('Section C');
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
await expect(nestedItems.last()).toContainText('Gitbook Site');
},
},
{
name: 'Nested external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
await expect(nestedLink).not.toHaveAttribute('target');
await expect(nestedLink).not.toHaveAttribute('rel');
await expect(nestedLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'External links navigate natively in the current tab',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page.route('https://gitbook.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '' })
);
const popups: string[] = [];
page.on('popup', (popup) => popups.push(popup.url()));
await page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' })
.click();
await expect(page).toHaveURL('https://gitbook.com/docs');
await page.goBack();
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
await page.getByRole('link', { name: 'Gitbook Site' }).click();
await expect(page).toHaveURL('https://gitbook.com/');
expect(popups).toEqual([]);
},
},
],
},
{
@@ -1091,30 +842,6 @@ const testCases: TestsCase[] = [
).toBeVisible();
},
},
{
name: 'Markdown page with theme cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
searchParams.set('theme', 'dark');
return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
// The app opens previews with `?theme=`, which the middleware persists in a cookie.
const markdownURL = new URL(page.url());
markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`;
markdownURL.search = '';
const response = await page.request.get(markdownURL.toString());
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/markdown');
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
@@ -1514,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: {
@@ -1535,8 +1262,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
}))
),
})),
]),
...allSearchStyles.flatMap((searchStyle) => ({
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
url: getCustomizationURL({
@@ -1556,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: {
@@ -1583,8 +1310,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
}))
),
})),
]),
{
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
url: getCustomizationURL({
@@ -1624,70 +1351,6 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Edit on Git page actions',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
tests: [
{
name: 'With Edit on Git as the default action',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Git],
},
}),
run: async (page) => {
await waitForHydration(page);
await expect(
page.getByRole('link', { name: 'Edit', exact: true })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'With Edit on Git in the dropdown',
url: getCustomizationURL({
pageActions: {
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.Git,
],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'Without Edit on Git',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Markdown],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveCount(0);
},
screenshot: false,
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1740,6 +1403,7 @@ const testCases: TestsCase[] = [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
],
},
@@ -1945,70 +1609,6 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Space (oversized token)',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
tests: [
{
name: 'Oversized token is chunked into cookies and survives navigation',
url: () => {
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
// Inflate the token above the ~4KB browser cookie limit,
// like an IdP issuing many group claims would.
groups: Array.from(
{ length: 60 },
(_, index) => `group-${index}-${'x'.repeat(80)}`
),
},
privateKey,
{
expiresIn: '24h',
}
);
return `first?jwt_token=${token}`;
},
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' })
).toBeVisible();
// The token must be persisted as a chunk-count marker plus chunk cookies.
const cookies = await page.context().cookies();
// Next.js percent-encodes cookie values, so the raw value is `chunks%3A2`.
const marker = cookies.find(
(cookie) =>
cookie.name.startsWith(VISITOR_TOKEN_COOKIE) &&
/^chunks(:|%3A)\d+$/.test(cookie.value)
);
expect(marker).toBeDefined();
const chunks = cookies.filter((cookie) =>
cookie.name.startsWith(`${marker?.name}-`)
);
expect(chunks.length).toBeGreaterThanOrEqual(2);
// Navigating without the token must authenticate from the chunked cookie.
// `first` is the space's default page, so the post-sign-in redirect
// canonicalizes to the space root: derive `second` from that base.
const secondURL = new URL(page.url());
const basePathname = secondURL.pathname
.replace(/\/first\/?$/, '')
.replace(/\/$/, '');
secondURL.pathname = `${basePathname}/second`;
secondURL.search = '';
// Same reason as the harness navigation: third-party subresources on
// this site can hang and never fire `load`.
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('heading', { level: 1, name: 'second' })
).toBeVisible();
},
screenshot: false,
},
],
},
{
name: 'Visitor Auth - Collection',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
@@ -2636,33 +2236,6 @@ const testCases: TestsCase[] = [
);
},
},
{
name: 'Only tracks ask_view once the widget is opened',
// `trigger=custom` loads the frame but leaves the window closed.
url: '?trigger=custom',
screenshot: false,
run: async (page) => {
const askViews = trackInsightsEvents(page, 'ask_view');
const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat');
// The assistant renders inside the hidden frame, but nobody has seen it.
await expect(chat).toBeAttached({ timeout: 20000 });
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(0);
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1);
// Hiding and showing the same frame again is not a second view.
await page.getByRole('button', { name: 'Close' }).click();
await expect(chat).toBeHidden();
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(1);
},
},
],
},
{
@@ -2832,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();
@@ -2980,7 +2553,7 @@ const testCases: TestsCase[] = [
openInNewTabButton.click(),
]);
// Verify the new page would have opened with the expected URL
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
expect(newPage.url()).toContain('gitbook.gitbook.io');
// Close it immediately to avoid navigation
await newPage.close();
},
-1
View File
@@ -1,6 +1,5 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { expect, test } from '@playwright/test';
import { getContentTestURL } from '../tests/utils';
import { waitForIcons } from './util';
-334
View File
@@ -1,334 +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');
});
});
interface NestedSpec {
outer: string[];
inner: string[];
/** Which of the outer options hosts the nested group. */
host: string;
/**
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
*/
innerStyleFirst?: boolean;
}
/**
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
* produces: panes are direct children of the element carrying the set class, and a pane's body is
* wrapped in a padding div before the nested group.
*/
async function renderNestedGroups(page: Page, spec: NestedSpec) {
const { outer, inner, host, innerStyleFirst = false } = spec;
const outerScope = selectSetClassName(outer);
const innerScope = selectSetClassName(inner);
const innerPanes = inner
.map(
(slug, index) =>
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
const outerPanes = outer
.map(
(slug, index) =>
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
)
.join('');
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
if (innerStyleFirst) {
styles.reverse();
}
await page.setContent(
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
);
}
/**
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
*/
test.describe('select CSS visibility in nested groups', () => {
const outer = ['macos', 'windows'];
const inner = ['npm', 'yarn'];
test('shows both defaults when nothing is selected', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'windows' });
await applySelection(page, ['windows']);
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves each group against its own options', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
});
-234
View File
@@ -1,234 +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]);
}
}
const countElements = (page: Page) =>
page.evaluate(() => document.getElementsByTagName('*').length);
// Not `networkidle`: third-party subresources on this customer site can hang, so it never settles.
async function waitForStableElementCount(page: Page): Promise<number> {
let previous = await countElements(page);
await expect
.poll(
async () => {
const current = await countElements(page);
const stable = current === previous;
previous = current;
return stable;
},
{ message: 'the tree never stopped changing', timeout: 15_000 }
)
.toBe(true);
return previous;
}
async function openLargePage(page: Page) {
await page.goto(getContentTestURL(LARGE_PAGE_URL));
await waitForCookiesDialog(page);
// Measure a settled page: before hydration the tree is smaller and no popup can open at all.
await page.locator('html.hydrated').waitFor();
await expect(page.getByLabel('OpenAPI Select').first()).toBeAttached();
const totalElements = await waitForStableElementCount(page);
const client = await page.context().newCDPSession(page);
return { client, totalElements };
}
// Guards the harness: if an idle window is busy, background work is leaking into the measurements
// and they mean nothing. Closing a popup leaves a few elements of teardown, so this is not zero —
// it only has to sit far below a real measurement (hundreds) and a whole-document restyle (~11k).
const IDLE_RESTYLE_TOLERANCE = 100;
async function expectIdle(page: Page, client: CDPSession) {
const restyled = await countRestyledElements(page, client, async () => {});
expect(restyled, 'an idle page should barely restyle').toBeLessThan(IDLE_RESTYLE_TOLERANCE);
}
// Both checks below probe a block's children, never the block itself: `content-visibility: auto`
// skips an element's *contents*, so the element carrying it keeps reporting visible and every block
// would look rendered. Same reason `toBeVisible()` is no use here — a skipped child still has a box.
function countRenderedBlocks(page: Page): Promise<number> {
return page.evaluate(
() =>
[...document.querySelectorAll('.openapi-block')].filter((block) =>
[...block.children].some((child) =>
child.checkVisibility({ contentVisibilityAuto: true })
)
).length
);
}
test('off-screen OpenAPI blocks skip their rendering work', async ({ page }) => {
await openLargePage(page);
const blocks = page.locator('.openapi-block');
const total = await blocks.count();
expect(total, 'the fixture needs enough blocks for some to sit off-screen').toBeGreaterThan(4);
await page.evaluate(() => window.scrollTo(0, 0));
const rendered = await countRenderedBlocks(page);
expect(
rendered,
`${rendered} of ${total} blocks rendered from the top of the page`
).toBeLessThan(total / 2);
// Un-skipping on approach is what keeps #anchors and find-in-page working.
const last = blocks.last();
await last.scrollIntoViewIfNeeded();
await expect
.poll(
() =>
last.evaluate((block) =>
[...block.children].some((child) =>
child.checkVisibility({ contentVisibilityAuto: true })
)
),
{ message: 'the last block never rendered after being scrolled to' }
)
.toBe(true);
});
test('opening a popup restyles a bounded part of a large API reference', async ({ page }) => {
const { client, totalElements } = await openLargePage(page);
await expectIdle(page, client);
const trigger = page.getByLabel('OpenAPI Select').first();
await trigger.scrollIntoViewIfNeeded();
const select = await measureReopen(
page,
client,
() => trigger.click(),
page.locator('.openapi-select-popover')
);
await expectIdle(page, client);
const searchInput = page.getByTestId('search-input');
const search = await measureReopen(
page,
client,
() => searchInput.focus(),
page.getByTestId('search-results')
);
expectWithinBudgets(
[
{ name: 'openapi-select', restyled: select },
{ name: 'search', restyled: search },
],
totalElements
);
});
-178
View File
@@ -1,178 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific module (not the package barrel) so this stays free of the `@/` path alias,
// which Playwright's loader doesn't resolve — same reason as `select.spec.ts`.
import { resolveOverflowingItems } from '../src/components/hooks/listOverflow';
/**
* Behaviour tests for the tab bar's overflow rule (`useListOverflow`), which decides which tabs move
* into the "more" dropdown. Rects are measured in a real browser so the geometry is genuine — the
* layout below mirrors the tab bar in `DynamicTabs`: a non-wrapping flex row of `shrink-0` items,
* clipped by `overflow: hidden`, measured with the dropdown rendered ahead of the tabs.
*
* The regression these guard is a dropdown appearing when nothing actually overflowed: measuring
* with the dropdown present consumes `MENU` pixels, so a bar whose tabs total just under the
* container would hand its last tab to a menu it never needed.
*/
/** Width of the ellipsis button, matching the `px-3.5` + `size-4` icon of the real one. */
const MENU = 44;
interface Row {
/** Container width in px. */
container: number;
/** Tab widths in px, in order. */
tabs: number[];
/** Whether the dropdown is reserving space ahead of the tabs, as during a measure pass. */
withMenu?: boolean;
/** Hide an ancestor, so the row measures with no box at all. */
hidden?: boolean;
}
/**
* Lay the row out in the browser and run the real rule over the rects it produces.
* Returns the ids reported as overflowing, or `null` when the measurement carried no information.
*/
async function measure(page: Page, row: Row): Promise<string[] | null> {
const { container, tabs, withMenu = true, hidden = false } = row;
const items = tabs
.map((w, i) => `<div class="item" id="tab-${i}" style="width:${w}px">${i}</div>`)
.join('');
const menu = withMenu ? `<div class="item" style="width:${MENU}px">…</div>` : '';
await page.setContent(
`<!doctype html><html><head><style>
* { box-sizing: border-box; }
body { margin: 0; }
.pane { ${hidden ? 'display: none;' : ''} }
.bar {
width: ${container}px;
display: inline-flex;
overflow: hidden;
}
.bar::after { content: ""; flex: 1; }
.item { flex-shrink: 0; max-width: 100%; }
</style></head><body>
<div class="pane"><div class="bar" id="bar">${menu}${items}</div></div>
</body></html>`
);
const measured = await page.evaluate(() => {
const bar = document.getElementById('bar');
if (!bar) {
throw new Error('missing bar');
}
const rect = bar.getBoundingClientRect();
return {
container: { left: rect.left, right: rect.right, width: rect.width },
items: [...bar.querySelectorAll<HTMLElement>('.item[id]')].map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, rect: { left: r.left, right: r.right } };
}),
};
});
const result = resolveOverflowingItems(measured.container, measured.items);
return result ? [...result].sort() : null;
}
test.describe('tab overflow rule', () => {
test('reports nothing when every tab fits with room to spare', async ({ page }) => {
// 3 x 100 = 300 of 600, so even with the menu reserved there is slack.
expect(await measure(page, { container: 600, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when the tabs fit exactly', async ({ page }) => {
expect(await measure(page, { container: 300, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when only the reserved menu made the row overflow', async ({ page }) => {
// The regression: tabs total 300 and the container is 320, so they fit — but measuring
// reserves 44 for the menu, which used to push the last tab out and show a needless
// dropdown. Every width in `container - MENU < 300 <= container` must stay empty.
for (const container of [300, 305, 320, 330, 343]) {
expect(
await measure(page, { container, tabs: [100, 100, 100] }),
`container ${container}px`
).toEqual([]);
}
});
test('reports the tabs that genuinely do not fit alongside the menu', async ({ page }) => {
// 300 of tabs into 290: the row really does overflow, so the menu is warranted and the
// remaining tabs must fit beside it (100 + 100 + 44 = 244 <= 290).
expect(await measure(page, { container: 290, tabs: [100, 100, 100] })).toEqual(['tab-2']);
});
test('gives up as many tabs as the width demands', async ({ page }) => {
expect(await measure(page, { container: 190, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
expect(await measure(page, { container: 150, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
});
test('moves every tab into the menu once not even the first fits beside it', async ({
page,
}) => {
// 100 + 44 > 120, so no tab can share the row with the menu. Everything goes in, leaving a
// bar that is only the menu — deliberately, since the menu is then the sole route to any
// tab. Forcing the first tab to stay would push the menu past the clipped edge and strand
// the rest.
expect(await measure(page, { container: 120, tabs: [100, 100, 100] })).toEqual([
'tab-0',
'tab-1',
'tab-2',
]);
});
test('keeps a single tab that fills the bar rather than hiding it behind a menu', async ({
page,
}) => {
// `max-width: 100%` truncates it to the container, so it fits — a lone tab should never be
// the only thing in the dropdown.
expect(await measure(page, { container: 200, tabs: [400] })).toEqual([]);
});
test('cuts a nested bar earlier, since its pane padding narrows it', async ({ page }) => {
// A nested tab bar sits inside a `p-4` pane, so it has 32px less to work with. At 330 the
// outer bar keeps all three tabs; the nested one at 330 - 32 cannot.
expect(await measure(page, { container: 330, tabs: [100, 100, 100] })).toEqual([]);
expect(await measure(page, { container: 330 - 32, tabs: [100, 100, 100] })).toEqual([
'tab-2',
]);
});
test('progressively fills the menu as a long list is squeezed', async ({ page }) => {
const tabs = Array.from({ length: 12 }, () => 100);
let previous = -1;
for (const container of [1300, 1200, 1000, 800, 600, 400, 200]) {
const overflowing = await measure(page, { container, tabs });
expect(overflowing, `container ${container}px`).not.toBeNull();
const hidden = overflowing?.length ?? 0;
// Never loses a tab, and never un-hides one as the space shrinks.
expect(hidden, `container ${container}px`).toBeGreaterThanOrEqual(previous);
expect(hidden, `container ${container}px`).toBeLessThanOrEqual(tabs.length);
previous = hidden;
}
// Widest fits everything; at 200 only the first tab still fits beside the menu.
expect(await measure(page, { container: 1300, tabs })).toEqual([]);
expect((await measure(page, { container: 200, tabs }))?.length).toBe(11);
});
test('reports nothing measurable while an ancestor is hidden', async ({ page }) => {
// A bar behind an inactive tab has no box, so every rect is zero. That says nothing about
// what fits, and must not be mistaken for "everything overflows".
expect(
await measure(page, { container: 200, tabs: [100, 100, 100], hidden: true })
).toBeNull();
});
test('reports nothing measurable for an empty list', async ({ page }) => {
expect(await measure(page, { container: 600, tabs: [] })).toBeNull();
});
});

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