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
908 changed files with 11926 additions and 34919 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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Stop caching failed searches, so a rate-limited or failing search is retried on the next query instead of being served from cache for 15 minutes.
@@ -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
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Declare the Tailwind layer order in each component stylesheet, so a route whose first stylesheet is one of them still puts `base` before `components` and preflight does not strip their padding and borders.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add cover image background mode and masks
@@ -1,6 +0,0 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Define a page's repeated icons once in an SVG sprite that each icon references, instead of repeating the full SVG at every heading anchor, expandable and embed placeholder, and pack the class lists every expandable repeats into shared CSS classes.
+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
---
Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-contentkit": patch
---
Load a webframe only once it is near the viewport. A page can hold hundreds of them inside collapsed expandables, and loading every one on mount held up the first paint.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and carry a table cell's width once as a custom property instead of spelling it out as both width and min-width, so a large page carries far less markup.
-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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add a search input to filter models by name on OpenAPI models pages.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Let analytics tools like GA4 track outbound link clicks by using native link navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
+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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Resolve dark-mode image URLs in published Markdown.
@@ -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
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/openapi-parser": patch
---
Bump @scalar/openapi-parser to 0.29 so valid OpenAPI 3.2 documents no longer report false validation errors.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Only apply a site preview customization override to the section or variant it was made for.
@@ -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
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Render soft line breaks in paragraphs that contain buttons.
+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.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix visitors being logged out right after signing in on sites with a logout link in the header.
+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
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
+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"
]
}
}
]
}
+815 -517
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.208.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';

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