Compare commits

..

44 Commits

Author SHA1 Message Date
Peter White 52dc989a7c changeset 2026-09-28 09:41:20 +02:00
Peter White afbad208a7 Send previousUrl with site insights events 2026-09-28 09:41:20 +02:00
Peter White afbc57c3e0 Don't track 404 page views for internal ~gitbook routes (#4639) 2026-09-25 12:32:54 +02:00
Tomek e4638f1833 Fix webframes inside modals not receiving visitor claims (#4635) 2026-09-25 12:13:44 +02:00
Peter White d7f2addb03 Improve PR description context guidance (#4636) 2026-09-25 09:11:47 +02:00
Tomek 7f8ab7dcc7 Add support for ChatGPT Search (OAI-SearchBot) user agent (#4633) 2026-09-24 09:49:33 +02:00
Nolann B. 8dd9444596 Track Insights link clicks on the file block Download button (#4632) 2026-09-23 17:16:06 +02:00
Peter White bdc2497771 Return 404 instead of looping on missing non-ASCII page paths (#4631) 2026-09-23 14:20:50 +00:00
Peter White 581abe0bb7 Announce pagination at the top of each llms-full.txt part (#4630) 2026-09-23 09:05:44 +02:00
conico974 4503be2f19 Update Next.js dependency to 16.3.6 (#4629) 2026-09-22 21:00:10 +02:00
Peter White bdfc98b97e Add PR description skill and template (#4628) 2026-09-22 17:32:53 +02:00
Brett Jephson 0f0cfde6ba Filter table with client side selection (#4616)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:28:07 +01:00
Peter White 357af3f6d2 Fix RSS discovery for pages without updates (#4626) 2026-09-21 11:45:24 +02:00
Tomek b95add7b27 Apply release notes tag filters from URL via CSS before first paint (#4613)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-18 16:16:07 +02:00
conico974 99984ebbb3 Implement bypass for URL lookup alternatives (#4620) 2026-09-18 13:50:06 +02:00
Tomek a52daf8b15 align dropdown chevron buttons between group items and page items (#4622) 2026-09-18 11:17:41 +00:00
conico974 f80429e4e8 Update @opennextjs/aws dependency and refine e2e tests (#4587) 2026-09-18 12:18:47 +02:00
spastorelli 6b4c453497 Add server card for published sites MCPs (#4618) 2026-09-18 10:16:27 +02:00
Peter White c7d042a733 Add Slapr PR reactions in engineering (#4617) 2026-09-17 14:04:03 +02:00
Tomek e94a5085eb Fix search popover keyboard focus trap (#4615)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-17 11:21:17 +02:00
Tomek 3ae1524a88 Hide Intercom launcher when Assistant opens (#4614)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-17 11:20:20 +02:00
Nolann B. b967b248e7 Request insights and visitor endpoints relative to the served origin (#4603) 2026-09-16 20:54:20 +02:00
Nolann B. a9e6814744 Let the AI Assistant report documentation findings to the site's team (#4576) 2026-09-15 18:03:37 +02:00
Brett Jephson 5725da19e2 Honour an explicit select slug on a tab (#4601)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:37:03 +01:00
Peter White 05cff0a03b Hide anchor links on landing pages (#4609) 2026-09-15 09:20:38 +02:00
Nolann B. 1e8c34a426 Let ContentKit webframes dispatch modal and URL actions (#4611) 2026-09-14 15:19:05 +02:00
spastorelli cd881f9ac1 Fix set-cookie in RSC requests conflicting with logout requests (#4606) 2026-09-14 14:45:32 +02:00
Greg Bergé 39316fc205 Add agent actions to the prompt block (#4610)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:01:04 +02:00
Brett Jephson c08d05ade5 Fix select filters on table and cards blocks (#4608)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:33:02 +00:00
Peter White b9453a92f8 Fix preview auth redirects dropping content paths (#4602) 2026-09-11 08:42:28 +02:00
Peter White 2c4d40ad97 Expose the site MCP tools through WebMCP in published docs (#4604) 2026-09-11 08:30:36 +02:00
Tomek 688515efcf Support fit-content width sizing for ContentKit buttons (#4605)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-10 15:50:37 +02:00
Tomek 931cbe717e Restore Edit on Git page actions (#4597)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Viktor Renkema <49148610+viktorrenkema@users.noreply.github.com>
Co-authored-by: conico974 <nicodorseuil@yahoo.fr>
2026-09-10 12:37:37 +02:00
Tomek 3ef1802e7e Add close button for modals in published sites (#4600)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-09 12:23:17 +02:00
Greg Bergé 8a0e0df84e Fix search section links not scrolling to the target section (#4598)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:40:58 +02:00
Peter White a9041a309a Fix fallback URL cleanup after successful page navigation (#4599) 2026-09-09 11:22:09 +02:00
conico974 625c108196 Add isRevalidationRequest function to handle specific requests (#4596) 2026-09-08 14:18:32 +02:00
Viktor Renkema 8e87856501 Fix page not found in preview links for sites with a custom homepage (#4594) 2026-09-08 11:27:07 +00:00
Tomek 9b822abbb6 Include page descriptions in markdown output (#4595)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-08 12:38:54 +02:00
Peter White 81dba6455b Resolve stable refs in HTML blocks of published markdown pages (#4592) 2026-09-08 09:45:14 +02:00
Johan Preynat 1faa57b812 Keep revision search results in preview (#4582) 2026-09-07 19:46:57 +02:00
Tomek 40150f0a6c Serve markdown with text/plain content-type for ChatGPT (#4591)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-07 17:13:19 +02:00
Brett Jephson bf29570bd5 RND-12706: measuring tabs fix (#4588)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:40:43 +01:00
Peter White e8e979b6c0 Serve permanent site redirects (#4586) 2026-09-07 16:07:40 +02:00
235 changed files with 6758 additions and 4012 deletions
@@ -0,0 +1,48 @@
---
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
---
Add `leaveAgentFeedback` and `leaveUserFeedback` assistant tools, reporting documentation findings to the site's team through the same path as the MCP `sendFeedback` tool, and record an `agent_feedback` insights event on both surfaces.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Include published page descriptions in the page's Markdown output.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix select filters not working on table and cards blocks.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ContentKit buttons to size to their content instead of stretching to the full container width.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Give the prompt block a default "Copy prompt" action alongside "Open in Claude", "Open in Codex" and "Open in Cursor", and remember the visitor's last pick across every prompt block.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Only advertise RSS feeds on pages with Updates blocks.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve Markdown responses as plain text to OpenAI's ChatGPT Search crawler.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages).
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide the Intercom launcher while the Assistant panel is open, so it no longer overlaps the message box.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Announce pagination at the top of every part of a multi-part llms-full.txt, with absolute links to the previous and next parts.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Prefix all cache tags with `ppr:` when rendering under the PPR route, so PPR cache entries are partitioned from the static ones.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Require a GitBook-secret signature on the `x-gbo-*` PPR headers, so a client can't opt itself into the PPR route.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Exchange the PPR revalidation token for a content API token scoped to each PPR component, so the API receives claims it understands and the header and table of contents can be cached across pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve the full site preview path and query parameters when redirecting users to log in.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix pages resolving to "not found" when a root URL lookup resolves to a custom homepage, by no longer using the homepage pathname as a prefix for the requested page path.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep current-space search results inside revision previews.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pass page context to webframes opened from integration modals.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter.
@@ -0,0 +1,7 @@
---
"gitbook": patch
---
Fix analytics and adaptive content silently breaking on sites served from a different host than the one configured (apex vs www, domain alias, CDN). The insights and visitor-claims endpoints are now requested relative to the page's own origin instead of the configured host, which a prerendered page cannot know.
Keep the traffic of GitBook's own preview deployments out of the sites' analytics, now that those requests reach the events endpoint.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Resolve stable page, space and file refs in images, definitions and HTML blocks of published markdown pages, instead of leaking internal `/pages/{id}`, `/spaces/{id}` and `/files/{id}` URLs.
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Show a close button on ContentKit modals rendered on published sites.
@@ -0,0 +1,7 @@
---
"@gitbook/react-contentkit": patch
---
Let ContentKit webframes dispatch the built-in `@ui.*` actions. Actions posted from a webframe were forwarded to the integration as a plain re-render instead of being handled, so `@ui.modal.open`, `@ui.modal.close` and `@ui.url.open` did nothing — most visibly, an integration could not close a modal from inside its own webframe.
Keep `null` values and class instances such as `Date` intact when resolving dynamic bindings in an action, instead of throwing or flattening them to `{}`.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix section links in search results opening the page without scrolling to the section.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix keyboard focus getting trapped inside the header search popover, allowing Tab/Shift+Tab to exit it as expected.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use cached Git metadata (via @gitbook/api 0.201.0's `cachedMetadata` param) when rendering Edit on Git actions.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Don't track a page view for 404s on internal `~gitbook` routes, so they no longer appear as broken URLs.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Narrow a table or card grid to the reader's content selection: choosing an option in a tab or a select button now filters any select column that offers it. The table says what it has been narrowed to, and offers a Clear that drops the selection.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the tabs "more" dropdown showing when no tab is overflowing, and stop a tab click re-rendering every tab group on the page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Let a tab keep its content selection across a rename, by honouring an explicit slug set on the tab instead of always deriving one from its title.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the fallback query parameter after successful page navigation without adding a browser history entry.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Restore Edit on Git page actions for Git-synced pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track Insights link clicks on the Download button of file blocks.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/write-pr-description
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/write-pr-description
@@ -48,7 +48,6 @@ runs:
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_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
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
@@ -70,6 +69,8 @@ runs:
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_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
+6 -1
View File
@@ -50,7 +50,6 @@ runs:
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
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
@@ -76,8 +75,14 @@ runs:
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
- name: Inject staging build env vars
if: ${{ inputs.environment == 'staging' }}
shell: bash
run: |
echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
+17
View File
@@ -0,0 +1,17 @@
## 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
@@ -0,0 +1,22 @@
{
"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
}
}
+88
View File
@@ -0,0 +1,88 @@
# Slack PR reactions
[DataDog/slapr](https://github.com/DataDog/slapr) updates reactions on existing PR-link messages in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs in GitHub Actions. It does not post messages, approve or merge PRs, or change branch protection.
## Rollout and ownership
Owner: Peter White.
The rollout targets `GitbookIO/gitbook` (GBO), `GitbookIO/gitbook-x` (GBX), and `GitbookIO/integrations`. Each repository contains `.github/workflows/slapr.yml` and this runbook.
| Configuration | Value |
| --- | --- |
| Channel | `#engineering`, `C01NXGWJELS` |
| App manifest | `.github/slapr-app-manifest.json` |
| Slack app ID | `A0C2AGLAD0T` |
| Bot user ID | `U0C2AGP16R1`, repository variable `SLAPR_BOT_USER_ID` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| Action | `DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973` |
| Approval threshold | Stock default, one approval |
| Events | `pull_request_review: submitted`, `pull_request: closed` |
## Install and configure
1. In [Slack app management](https://api.slack.com/apps), create the **PR Status** app from `.github/slapr-app-manifest.json` in the GitBook workspace. Obtain workspace approval if required, then install it. The bot requests Slapr's documented `channels:history`, `reactions:read`, and `reactions:write` scopes. `#engineering` is public, so `groups:history` is unnecessary. No review map is configured, so `channels:read` is unnecessary.
2. Invite **PR Status** only to `#engineering`. Keep it dedicated to Slapr: stock Slapr removes obsolete reactions belonging to this bot on a matched message. It leaves other users' reactions alone.
3. Record the app ID and bot user ID above. The bot profile's member ID or Slack `auth.test` response's `user_id` is the required bot user ID, not the app ID or bot ID.
4. Store the bot OAuth token as `SLAPR_SLACK_BOT_TOKEN` in each of the three repositories' Actions secrets. Use the GitHub secret UI or `gh secret set SLAPR_SLACK_BOT_TOKEN --repo GitbookIO/<repository>` and its hidden prompt. Never paste the token into a command argument, chat, workflow, or committed file. Alternatively, an organization secret must use selected-repository visibility limited to these three repositories.
5. Set Actions variable `SLAPR_BOT_USER_ID` in each repository to that bot's user ID. The token and user ID must belong to the same installation. Use `gh variable set SLAPR_BOT_USER_ID --repo GitbookIO/<repository> --body '<user-id>'` or the GitHub UI.
6. Merge the workflows through normal review, then run the live checks below. Repository maintainers handle reviews and merges; Slapr has only read access to GitHub contents and pull requests.
GBX runs this workflow on `blacksmith-2vcpu-ubuntu-2404`. GBO and integrations use `ubuntu-latest`, matching their existing workflows.
The workflow hardcodes the engineering channel and does not check out PR code. It uses the stock event triggers and leaves the approval threshold unset. There are no listeners, schedules, review maps, custom discovery, or additional services.
## Reactions
Slapr's status selection stays unchanged. Missing custom emoji names are mapped through the action's supported inputs to existing equivalents. Workspace lookup on 2026-09-17 confirmed `pr-approved` and `merged`; the other mappings are standard Slack emojis.
| Stock status | Reaction |
| --- | --- |
| Review started | `:eyes:` |
| Partially approved | `:next_track_button:` |
| Approved | `:pr-approved:` |
| Changes requested | `:construction:` |
| Merged | `:merged:` |
| Closed without merging | `:no_entry_sign:` |
| Comment-only review | `:speech_balloon:` |
The partially-approved reaction is configured but cannot occur with the default threshold of one approval. It becomes relevant only if that threshold is raised.
On approval, expect review-started and approved reactions. On merge, stock Slapr removes review-started and adds merged; it can retain approved alongside merged. Approval and merging have distinct reactions. Other stock review-state behavior is accepted.
## Live acceptance test
For each repository after credentials are configured and the workflow is on the default branch:
1. Open a fresh same-repository test PR through normal review. Post its full `https://github.com/GitbookIO/<repository>/pull/<number>` link as a new top-level message in `#engineering` before the review event.
2. Have another contributor approve it. Open the **Slack PR reactions** Actions run and verify that the matched message has the bot's `pr-approved` reaction.
3. Have an authorized maintainer merge it. Verify the closed-event run succeeds and the same message has the bot's `merged` reaction. No new Slack status message should appear.
4. Record the PR URL, Slack message permalink, approval/merge workflow run URLs, date, and result below. A green workflow alone does not prove the message was found or reacted to.
| Repository | PR and Slack message | Approval run/result | Merge run/result |
| --- | --- | --- | --- |
| GitbookIO/gitbook | Pending | Not tested | Not tested |
| GitbookIO/gitbook-x | Pending | Not tested | Not tested |
| GitbookIO/integrations | Pending | Not tested | Not tested |
Only the first matching message in Slapr's recent-history response is updated. Older messages, thread replies, duplicate posts, fork PRs, and links posted after the event are outside this rollout's acceptance criteria. Slapr does not paginate history or reconcile missed events.
## Troubleshoot and disable
- No workflow run: confirm the workflow is merged, Actions permits the pinned action, and the event was a submitted review or PR closure. These are the only configured triggers.
- `No message found requesting review`: confirm the full PR link was posted as a recent top-level message in `#engineering` before the event. This is a successful no-op in stock Slapr.
- `not_in_channel` or `channel_not_found`: check the app installation, channel ID, and bot membership in `#engineering`.
- `missing_scope`: compare the installed bot scopes with the manifest and reinstall after changes.
- `invalid_auth` or `account_inactive`: replace the repository secrets with the current bot token. If the bot identity changed, update `SLAPR_BOT_USER_ID` too.
- `invalid_name`: check the emoji mappings still exist. If a custom emoji was removed, map that input to another existing emoji, keeping approval and merged distinct.
- Old reactions remain or removal fails: confirm `SLAPR_BOT_USER_ID` is the member ID for the token's bot.
- GitHub permission errors: keep `contents: read` and `pull-requests: read`; do not introduce a PAT, write permissions, or `pull_request_target` to cover forks. Fork and Dependabot secret restrictions are accepted limitations.
- Rate limits or transient API failures: inspect the Actions log and retry the failed run after the limit resets. There is no scheduled reconciliation.
Disable one repository with `gh workflow disable slapr.yml --repo GitbookIO/<repository>`, or disable **Slack PR reactions** in its Actions UI. Repeat for all three to stop the integration. Revoke the Slack app token to stop its access immediately, remove its repository secrets, and remove the bot from `#engineering` when retiring it. Existing reactions remain.
## Pin review
On 2026-09-17, the pinned action definition, Dockerfile, entry point, dependency declarations, GitHub reads, Slack history lookup and reaction writes were inspected. Without `review-map`, the action targets only the supplied channel. It reads PR/review state and adds/removes reactions; it does not post Slack messages or write to GitHub.
The action SHA pins Slapr's source. Its upstream Dockerfile still uses the mutable `python:3.14` image and installs dependencies, including `pyyaml>=5.0`, at build time. This rollout keeps stock Slapr unchanged. Review upstream source and dependency changes before updating the SHA in all three repositories.
+30
View File
@@ -0,0 +1,30 @@
name: Slack PR reactions
on:
pull_request_review:
types: [submitted]
pull_request:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
run_slapr:
runs-on: ubuntu-latest
steps:
- name: Update PR reactions in engineering
uses: DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
slack-api-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
slack-channel-id: C01NXGWJELS
bot-user-id: ${{ vars.SLAPR_BOT_USER_ID }}
emoji-review-started: eyes
emoji-partially-approved: next_track_button
emoji-approved: pr-approved
emoji-changes-requested: construction
emoji-merged: merged
emoji-closed: no_entry_sign
emoji-commented: speech_balloon
+3
View File
@@ -29,3 +29,6 @@ yarn-error.log*
# Bun pack artifacts
packages/*/*.tgz
# Playwright MCP artifacts
.playwright-mcp/
-29
View File
@@ -24,35 +24,6 @@ Examples:
- `http://localhost:3000/url/gitbook.com/docs`
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
### PPR routes
PPR requests are normally resolved upstream and arrive with a large set of `x-gbo-*` headers, so they
can't be reproduced by hitting the dev server directly. `bun run dev:ppr` (from `packages/gitbook`)
starts a dev-only proxy on port 3001 that resolves the URL, injects those headers and signs them.
The app rejects an unsigned set, so `GITBOOK_SECRET` must be set in `.env.local` (any value works
locally, as long as both processes read the same one).
The app also exchanges the PPR token for one scoped to each component, against
`GITBOOK_EXCHANGE_TOKEN_URL`. That endpoint only accepts a revalidation token, which the
published-URLs lookup never returns, so the proxy mints one with `PPR_DEV_API_TOKEN_SECRET` — a
local-only stand-in for the API token secret the cache worker holds in production. It must match the
secret the target `/token` endpoint verifies with, so local PPR needs a local gitbook-x sites stack.
The secret is read only by the proxy script: never add it to `src/lib/env`, `next.config.mjs` or a
deploy workflow.
```
GITBOOK_SECRET=<any value>
GITBOOK_EXCHANGE_TOKEN_URL=http://localhost:8788/token
PPR_DEV_API_TOKEN_SECRET=<local gitbook-x functionsConfig.api.tokenSecret>
```
```
http://localhost:3001/url/<published-gitbook-url>
```
Responses carry `x-gitbook-route-type: ppr` when the PPR route was used. Hot reload doesn't work
through the proxy (its websocket can't be forwarded), so keep using port 3000 while iterating.
## Architecture
```
+110 -45
View File
@@ -160,7 +160,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.3",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -275,6 +275,7 @@
},
"devDependencies": {
"@types/react": "catalog:",
"bun-types": "catalog:",
"react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
@@ -341,10 +342,10 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.3": "patches/next@16.3.3.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -354,7 +355,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +727,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="],
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -778,57 +779,57 @@
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="],
@@ -890,23 +891,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -942,7 +943,7 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-nMDA3APULL1hFREeWqPdxRk3djdgF1/Y38gnqlYBVs4iUib9sxFguP1uGSAyYbR7eVxGholjXoWBYx7jDBP/Ag=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
@@ -2648,7 +2649,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="],
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2968,7 +2969,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -3326,6 +3327,8 @@
"@argos-ci/core/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"@argos-ci/core/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
@@ -3416,11 +3419,11 @@
"@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@internationalized/date/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -3954,6 +3957,60 @@
"@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"@argos-ci/core/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
"@argos-ci/core/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
"@argos-ci/core/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
"@argos-ci/core/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
"@argos-ci/core/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
"@argos-ci/core/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
"@argos-ci/core/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
"@argos-ci/core/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
"@argos-ci/core/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
"@argos-ci/core/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
"@argos-ci/core/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@argos-ci/core/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@argos-ci/core/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@argos-ci/core/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
"@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
@@ -4000,9 +4057,9 @@
"@headlessui/react/@floating-ui/react/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -4376,6 +4433,10 @@
"yargs/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
@@ -4460,6 +4521,10 @@
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@headlessui/react/@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
+3 -2
View File
@@ -19,7 +19,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
},
"private": true,
"scripts": {
@@ -48,7 +49,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+135 -86
View File
@@ -1,4 +1,4 @@
import { type Page, expect, test } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
@@ -10,12 +10,12 @@ import {
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationPageActionType,
CustomizationSidebarListStyle,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { signPPRRequestHeaders } from '../src/lib/ppr';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
type Test,
@@ -159,7 +159,7 @@ const searchTestCases: Test[] = [
},
},
{
name: 'Search - Keyboard focus stays within search',
name: 'Search - Keyboard focus exits to the next page control',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
@@ -180,13 +180,16 @@ const searchTestCases: Test[] = [
)
.filter({ visible: true })
.last();
const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first();
await expect(finalPopupControl).toBeVisible();
await expect(nextPageControl).toBeVisible();
await finalPopupControl.focus();
await page.keyboard.press('Tab');
await expect(searchInput).toBeFocused();
await expect(searchPopup).toBeHidden();
await expect(nextPageControl).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(finalPopupControl).toBeFocused();
await expect(searchInput).toBeFocused();
},
},
{
@@ -246,6 +249,43 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
// RND-12844: the popover's focus manager re-focused the closing popup and
// scrolled the page back to the top right after landing on the section.
name: 'Search - Section result scrolls to the section',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
// Type like a visitor: `fill()` doesn't trigger the remote search.
await searchInput.pressSequentially('tasks');
const sectionResult = page.locator(
'[data-testid="search-page-result"][href$="/blocks/lists#tasks"]'
);
// Section results come from the remote index, which can be slow to answer.
await expect(sectionResult).toBeVisible({ timeout: 30_000 });
await sectionResult.click();
await page.waitForURL(/\/blocks\/lists#tasks$/);
// The regression scrolled back to the top shortly after landing, so let
// that happen before asserting.
await page.waitForTimeout(1000);
// The heading is parked under the header, within its scroll margin.
const top = await page
.locator('#tasks')
.evaluate((heading) => heading.getBoundingClientRect().top);
expect(top).toBeGreaterThanOrEqual(0);
expect(top).toBeLessThanOrEqual(150);
},
},
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
@@ -347,49 +387,6 @@ const searchTestCases: Test[] = [
},
];
const PPR_TEST_SITE_URL = 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/';
/**
* The `x-gbo-*` set GBO resolves upstream. `sign` mirrors what GBO does with `GITBOOK_SECRET`;
* without it the app must fall back to resolving the URL itself.
*/
async function getPPRHeaders(options: { sign: string | undefined }) {
const data = await getSiteAPIToken(PPR_TEST_SITE_URL);
if (!data.revision) {
throw new Error('PPR test site did not resolve to content with a revision');
}
const headers = new Headers({
'x-gbo-site': data.site,
'x-gbo-site-section': data.siteSection ?? '',
'x-gbo-site-space': data.siteSpace,
'x-gbo-space': data.space,
'x-gbo-site-base-path': data.siteBasePath,
'x-gbo-base-path': data.basePath,
'x-gbo-pathname': data.pathname || '/',
'x-gbo-organization': data.organization,
'x-gbo-share-key': data.shareKey ?? '',
'x-gbo-complete': String(data.complete),
'x-gbo-context-id': data.contextId ?? '',
'x-gbo-canonical-url': data.canonicalUrl,
'x-gbo-preview': data.preview === undefined ? '' : String(data.preview),
'x-gbo-revision': data.revision ?? '',
'x-gbo-change-request': data.changeRequest ?? '',
'x-gbo-api-token': data.apiToken,
'x-gbo-revalidation-id': 'ppr-e2e-revalidation',
'x-gbo-default-site-section': data.siteSection ?? '',
'x-gbo-default-site-space': data.siteSpace,
'x-gbo-default-space': data.space,
});
if (options.sign) {
await signPPRRequestHeaders(headers, options.sign);
}
return Object.fromEntries(headers.entries());
}
const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
@@ -400,37 +397,6 @@ const testCases: TestsCase[] = [
url: '',
run: waitForCookiesDialog,
},
{
name: 'PPR route renders the site shell',
url: '',
headers: () => getPPRHeaders({ sign: process.env.GITBOOK_SECRET }),
screenshot: false,
run: async (page, response) => {
// The deployment signs with its own `GITBOOK_SECRET`; without it here the
// headers can only be tested for rejection (see the test below).
test.skip(
!process.env.GITBOOK_SECRET,
'GITBOOK_SECRET is required to sign PPR headers'
);
expect(response?.headers()['x-gitbook-route-type']).toBe('ppr');
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
await expect(page.getByTestId('table-of-contents')).toBeVisible();
await expect(page.locator('main')).toBeVisible();
},
},
{
name: 'PPR route ignores an unsigned header set',
url: '',
headers: () => getPPRHeaders({ sign: undefined }),
screenshot: false,
run: async (page, response) => {
// Anyone can send these headers, so an unsigned set must never take the PPR
// path: it skips URL resolution and visitor-auth, and picks the cache key.
expect(response?.headers()['x-gitbook-route-type']).not.toBe('ppr');
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
await expect(page.locator('main')).toBeVisible();
},
},
{
name: 'No variants dropdown',
url: '',
@@ -588,6 +554,24 @@ const testCases: TestsCase[] = [
name: 'GitBook Site (Navigation when switching variant)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
tests: [
{
name: 'Strip fallback after loading a page without adding history',
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const previousURL = page.url();
const targetURL = new URL(previousURL);
targetURL.searchParams.set('fallback', 'true');
targetURL.searchParams.set('ref', 'variant');
targetURL.hash = 'pets';
await page.goto(targetURL.toString());
targetURL.searchParams.delete('fallback');
await expect(page).toHaveURL(targetURL.toString());
await page.goBack();
await expect(page).toHaveURL(previousURL);
},
},
{
name: 'Keep navigation path/route when switching variant (Public)',
url: 'api-multi-versions/reference/api-reference/pets',
@@ -610,8 +594,11 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL((url) =>
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
await page.waitForURL(
(url) =>
url.pathname.includes(
'api-multi-versions/2.0/reference/api-reference/pets'
) && !url.searchParams.has('fallback')
);
},
},
@@ -1647,6 +1634,70 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Edit on Git page actions',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
tests: [
{
name: 'With Edit on Git as the default action',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Git],
},
}),
run: async (page) => {
await waitForHydration(page);
await expect(
page.getByRole('link', { name: 'Edit', exact: true })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'With Edit on Git in the dropdown',
url: getCustomizationURL({
pageActions: {
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.Git,
],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'Without Edit on Git',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Markdown],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveCount(0);
},
screenshot: false,
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1699,7 +1750,6 @@ const testCases: TestsCase[] = [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
],
},
@@ -1908,9 +1958,6 @@ const testCases: TestsCase[] = [
{
name: 'Visitor Auth - Space (oversized token)',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
// Our Cloudflare stack still folds multiple Set-Cookie headers into one,
// breaking chunked cookies (variant of opennextjs-cloudflare#501).
skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare',
tests: [
{
name: 'Oversized token is chunked into cookies and survives navigation',
@@ -1961,7 +2008,9 @@ const testCases: TestsCase[] = [
.replace(/\/$/, '');
secondURL.pathname = `${basePathname}/second`;
secondURL.search = '';
await page.goto(secondURL.toString());
// Same reason as the harness navigation: third-party subresources on
// this site can hang and never fire `load`.
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('heading', { level: 1, name: 'second' })
).toBeVisible();
+178
View File
@@ -0,0 +1,178 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific module (not the package barrel) so this stays free of the `@/` path alias,
// which Playwright's loader doesn't resolve — same reason as `select.spec.ts`.
import { resolveOverflowingItems } from '../src/components/hooks/listOverflow';
/**
* Behaviour tests for the tab bar's overflow rule (`useListOverflow`), which decides which tabs move
* into the "more" dropdown. Rects are measured in a real browser so the geometry is genuine — the
* layout below mirrors the tab bar in `DynamicTabs`: a non-wrapping flex row of `shrink-0` items,
* clipped by `overflow: hidden`, measured with the dropdown rendered ahead of the tabs.
*
* The regression these guard is a dropdown appearing when nothing actually overflowed: measuring
* with the dropdown present consumes `MENU` pixels, so a bar whose tabs total just under the
* container would hand its last tab to a menu it never needed.
*/
/** Width of the ellipsis button, matching the `px-3.5` + `size-4` icon of the real one. */
const MENU = 44;
interface Row {
/** Container width in px. */
container: number;
/** Tab widths in px, in order. */
tabs: number[];
/** Whether the dropdown is reserving space ahead of the tabs, as during a measure pass. */
withMenu?: boolean;
/** Hide an ancestor, so the row measures with no box at all. */
hidden?: boolean;
}
/**
* Lay the row out in the browser and run the real rule over the rects it produces.
* Returns the ids reported as overflowing, or `null` when the measurement carried no information.
*/
async function measure(page: Page, row: Row): Promise<string[] | null> {
const { container, tabs, withMenu = true, hidden = false } = row;
const items = tabs
.map((w, i) => `<div class="item" id="tab-${i}" style="width:${w}px">${i}</div>`)
.join('');
const menu = withMenu ? `<div class="item" style="width:${MENU}px">…</div>` : '';
await page.setContent(
`<!doctype html><html><head><style>
* { box-sizing: border-box; }
body { margin: 0; }
.pane { ${hidden ? 'display: none;' : ''} }
.bar {
width: ${container}px;
display: inline-flex;
overflow: hidden;
}
.bar::after { content: ""; flex: 1; }
.item { flex-shrink: 0; max-width: 100%; }
</style></head><body>
<div class="pane"><div class="bar" id="bar">${menu}${items}</div></div>
</body></html>`
);
const measured = await page.evaluate(() => {
const bar = document.getElementById('bar');
if (!bar) {
throw new Error('missing bar');
}
const rect = bar.getBoundingClientRect();
return {
container: { left: rect.left, right: rect.right, width: rect.width },
items: [...bar.querySelectorAll<HTMLElement>('.item[id]')].map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, rect: { left: r.left, right: r.right } };
}),
};
});
const result = resolveOverflowingItems(measured.container, measured.items);
return result ? [...result].sort() : null;
}
test.describe('tab overflow rule', () => {
test('reports nothing when every tab fits with room to spare', async ({ page }) => {
// 3 x 100 = 300 of 600, so even with the menu reserved there is slack.
expect(await measure(page, { container: 600, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when the tabs fit exactly', async ({ page }) => {
expect(await measure(page, { container: 300, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when only the reserved menu made the row overflow', async ({ page }) => {
// The regression: tabs total 300 and the container is 320, so they fit — but measuring
// reserves 44 for the menu, which used to push the last tab out and show a needless
// dropdown. Every width in `container - MENU < 300 <= container` must stay empty.
for (const container of [300, 305, 320, 330, 343]) {
expect(
await measure(page, { container, tabs: [100, 100, 100] }),
`container ${container}px`
).toEqual([]);
}
});
test('reports the tabs that genuinely do not fit alongside the menu', async ({ page }) => {
// 300 of tabs into 290: the row really does overflow, so the menu is warranted and the
// remaining tabs must fit beside it (100 + 100 + 44 = 244 <= 290).
expect(await measure(page, { container: 290, tabs: [100, 100, 100] })).toEqual(['tab-2']);
});
test('gives up as many tabs as the width demands', async ({ page }) => {
expect(await measure(page, { container: 190, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
expect(await measure(page, { container: 150, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
});
test('moves every tab into the menu once not even the first fits beside it', async ({
page,
}) => {
// 100 + 44 > 120, so no tab can share the row with the menu. Everything goes in, leaving a
// bar that is only the menu — deliberately, since the menu is then the sole route to any
// tab. Forcing the first tab to stay would push the menu past the clipped edge and strand
// the rest.
expect(await measure(page, { container: 120, tabs: [100, 100, 100] })).toEqual([
'tab-0',
'tab-1',
'tab-2',
]);
});
test('keeps a single tab that fills the bar rather than hiding it behind a menu', async ({
page,
}) => {
// `max-width: 100%` truncates it to the container, so it fits — a lone tab should never be
// the only thing in the dropdown.
expect(await measure(page, { container: 200, tabs: [400] })).toEqual([]);
});
test('cuts a nested bar earlier, since its pane padding narrows it', async ({ page }) => {
// A nested tab bar sits inside a `p-4` pane, so it has 32px less to work with. At 330 the
// outer bar keeps all three tabs; the nested one at 330 - 32 cannot.
expect(await measure(page, { container: 330, tabs: [100, 100, 100] })).toEqual([]);
expect(await measure(page, { container: 330 - 32, tabs: [100, 100, 100] })).toEqual([
'tab-2',
]);
});
test('progressively fills the menu as a long list is squeezed', async ({ page }) => {
const tabs = Array.from({ length: 12 }, () => 100);
let previous = -1;
for (const container of [1300, 1200, 1000, 800, 600, 400, 200]) {
const overflowing = await measure(page, { container, tabs });
expect(overflowing, `container ${container}px`).not.toBeNull();
const hidden = overflowing?.length ?? 0;
// Never loses a tab, and never un-hides one as the space shrinks.
expect(hidden, `container ${container}px`).toBeGreaterThanOrEqual(previous);
expect(hidden, `container ${container}px`).toBeLessThanOrEqual(tabs.length);
previous = hidden;
}
// Widest fits everything; at 200 only the first tab still fits beside the menu.
expect(await measure(page, { container: 1300, tabs })).toEqual([]);
expect((await measure(page, { container: 200, tabs }))?.length).toBe(11);
});
test('reports nothing measurable while an ancestor is hidden', async ({ page }) => {
// A bar behind an inactive tab has no box, so every rect is zero. That says nothing about
// what fits, and must not be mistaken for "everything overflows".
expect(
await measure(page, { container: 200, tabs: [100, 100, 100], hidden: true })
).toBeNull();
});
test('reports nothing measurable for an empty list', async ({ page }) => {
expect(await measure(page, { container: 600, tabs: [] })).toBeNull();
});
});
+5 -11
View File
@@ -44,10 +44,6 @@ export interface Test {
*/
url: string | (() => string | Promise<string>);
cookies?: Parameters<BrowserContext['addCookies']>[0];
/** Headers to send with the main document request. */
headers?:
| Record<string, string>
| (() => Record<string, string> | Promise<Record<string, string>>);
/**
* Test to run
*/
@@ -283,11 +279,6 @@ export function runTestCases(testCases: TestsCase[]) {
} catch {}
});
const headers =
typeof testEntry.headers === 'function'
? await testEntry.headers()
: testEntry.headers;
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
@@ -296,7 +287,6 @@ export function runTestCases(testCases: TestsCase[]) {
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
...headers,
},
});
} else {
@@ -475,7 +465,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
socialAccounts: [],
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
const encoded = rison.encode_object(
deepMerge(DEFAULT_CUSTOMIZATION, partial, {
arrayMerge: (_target, source) => source,
})
);
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
+1 -1
View File
@@ -88,13 +88,13 @@ const nextConfig = {
GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN,
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_EXCHANGE_TOKEN_URL: process.env.GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE,
GITBOOK_DISABLE_INSIGHTS: process.env.GITBOOK_DISABLE_INSIGHTS,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
+2 -3
View File
@@ -51,7 +51,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.3",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -122,7 +122,6 @@
"generate:fonts": "bun ./scripts/generate-font-faces.ts",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar && rm -rf ./public/~gitbook/static/fonts",
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
"dev:ppr": "env-cmd --silent -f ../../.env.local bun scripts/ppr-dev-proxy.ts",
"build": "bun run generate:assets && next build --webpack",
"build:local": "bun run generate:assets && GITBOOK_URL=http://localhost:3000 next build --webpack",
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
@@ -133,7 +132,7 @@
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts e2e/tabs-overflow.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"e2e-style-perf": "playwright test e2e/style-perf.spec.ts --project=chromium --reporter=list",
"unit": "bun run generate:assets && bun test {src,packages} --preload ./tests/preload-bun.ts",
-351
View File
@@ -1,351 +0,0 @@
/**
* Dev-only proxy that stands in for the upstream layer resolving PPR requests.
*
* It resolves the incoming URL against the published-URLs API and forwards the request to the local
* app with the complete `x-gbo-*` header set, so PPR routes can be exercised locally:
*
* bun dev # app on :3000
* bun run dev:ppr # this proxy on :3001
* open http://localhost:3001/url/gitbook.com/docs
*
* Never deploy this. It trusts its input and caches API tokens in memory.
*/
import type { PublishedSiteContent, PublishedSiteContentLookup, Space } from '@gitbook/api';
import { PPRRequestHeaders, signPPRRequestHeaders } from '../src/lib/ppr';
const PORT = Number(process.env.PPR_PROXY_PORT || 3001);
const UPSTREAM = process.env.PPR_UPSTREAM || 'http://localhost:3000';
const API_URL = process.env.GITBOOK_API_URL || 'https://api.gitbook.com/cache';
const API_TOKEN = process.env.GITBOOK_API_TOKEN;
const REVALIDATION_ID = process.env.PPR_REVALIDATION_ID;
const LOOKUP_TTL = Number(process.env.PPR_LOOKUP_TTL || 60) * 1000;
const URL_PREFIX = '/url/';
const PASSTHROUGH_PREFIXES = ['/_next/', '/~gitbook/static/'];
function log(message: string) {
// biome-ignore lint/suspicious/noConsole: this is a CLI script
console.log(`[ppr-proxy] ${message}`);
}
// The app rejects an unsigned header set, so `bun dev` must run with the same secret.
function requireSecret(): string {
const secret = process.env.GITBOOK_SECRET;
if (!secret) {
log(
'GITBOOK_SECRET is not set: the app rejects unsigned PPR headers. Add it to .env.local.'
);
process.exit(1);
}
return secret;
}
// This proxy mints API tokens, so it must never be reachable outside a developer machine.
if (process.env.NODE_ENV === 'production') {
log('This proxy mints API tokens and must never run in production.');
process.exit(1);
}
const SECRET = requireSecret();
// The app always exchanges the PPR token, and the exchange endpoint only accepts a revalidation
// token, which the published-URLs lookup never returns: we have to mint one ourselves.
function requireAPITokenSecret(): string {
const secret = process.env.PPR_DEV_API_TOKEN_SECRET;
if (!secret) {
log(
'PPR_DEV_API_TOKEN_SECRET is not set: the token exchange rejects the lookup token. Add it to .env.local.'
);
process.exit(1);
}
return secret;
}
const API_TOKEN_SECRET = requireAPITokenSecret();
const cache = new Map<string, { value: unknown; expiresAt: number }>();
const inflight = new Map<string, Promise<unknown>>();
/**
* Cache and dedupe an API call. A single page load fans out into many requests for the same site,
* and the site root lookup is shared by every page of a site.
*/
function cached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return Promise.resolve(entry.value as T);
}
const pending = inflight.get(key);
if (pending) {
return pending as Promise<T>;
}
const promise = fetcher()
.then((value) => {
cache.set(key, { value, expiresAt: Date.now() + LOOKUP_TTL });
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
async function api<T>(path: string, init: RequestInit, token = API_TOKEN): Promise<T> {
const response = await fetch(`${API_URL}/v1${path}`, {
...init,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<T>;
}
function lookupPublishedURL(url: string): Promise<PublishedSiteContentLookup> {
return cached(`url:${url}`, () =>
api<PublishedSiteContentLookup>('/urls/published', {
method: 'POST',
body: JSON.stringify({ url }),
})
);
}
/**
* The lookup only carries a revision for preview URLs, so for a regular published URL we read the
* space's active revision using the short-lived token the lookup returned.
*/
function getActiveRevision(content: PublishedSiteContent): Promise<string> {
return cached(`revision:${content.space}`, async () => {
const space = await api<Space>(`/spaces/${content.space}`, {}, content.apiToken);
return space.revision;
});
}
/**
* The `x-gbo-default-*` headers describe the site's default variant, which is the cache key of the
* shared header. Resolving the site root is the only way to get it from the published-URLs API.
*/
async function getDefaults(content: PublishedSiteContent) {
const fallback = {
siteSection: content.siteSection,
siteSpace: content.siteSpace,
space: content.space,
};
try {
const rootURL = `https://${new URL(content.canonicalUrl).host}${content.siteBasePath}`;
const root = await lookupPublishedURL(rootURL);
if ('redirect' in root) {
return fallback;
}
return { siteSection: root.siteSection, siteSpace: root.siteSpace, space: root.space };
} catch {
return fallback;
}
}
/**
* Turn the lookup's content token into the revalidation token the cache worker would send: the same
* payload, with the claims split into the per-scope buckets the exchange endpoint narrows down.
* Reusing the payload keeps `spaces`, `iat` and `exp` valid.
*/
async function mintRevalidationToken(apiToken: string): Promise<string> {
const { claims: _claims, ...payload } = decodeJWTPayload(apiToken);
const result = signJWT(
{
...payload,
target: 'content',
// Empty buckets: local dev has no revalidation run to compute adaptive claims from.
siteClaims: {},
revisionClaims: {},
pageClaims: {},
},
API_TOKEN_SECRET
);
console.log('minted revalidation token', await result);
return result;
}
function decodeJWTPayload(token: string): Record<string, unknown> {
const payload = token.split('.')[1];
if (!payload) {
throw new Error('API token is not a JWT');
}
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
}
async function signJWT(payload: Record<string, unknown>, secret: string): Promise<string> {
const signingInput = `${base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${base64url(
JSON.stringify(payload)
)}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
return `${signingInput}.${base64url(Buffer.from(signature))}`;
}
function base64url(value: string | Buffer): string {
return (typeof value === 'string' ? Buffer.from(value, 'utf8') : value).toString('base64url');
}
async function setPPRHeaders(
headers: Headers,
content: PublishedSiteContent & { revision: string },
defaults: { siteSection: string | undefined; siteSpace: string; space: string },
secret: string
) {
headers.set(PPRRequestHeaders.Site, content.site);
headers.set(PPRRequestHeaders.SiteSection, content.siteSection ?? '');
headers.set(PPRRequestHeaders.SiteSpace, content.siteSpace);
headers.set(PPRRequestHeaders.Space, content.space);
headers.set(PPRRequestHeaders.SiteBasePath, content.siteBasePath);
headers.set(PPRRequestHeaders.BasePath, content.basePath);
// An empty pathname is rejected; the root page is `/`.
headers.set(PPRRequestHeaders.Pathname, content.pathname || '/');
headers.set(PPRRequestHeaders.Organization, content.organization);
headers.set(PPRRequestHeaders.ShareKey, content.shareKey ?? '');
headers.set(PPRRequestHeaders.Complete, String(content.complete));
headers.set(PPRRequestHeaders.ContextID, content.contextId ?? '');
headers.set(PPRRequestHeaders.CanonicalURL, content.canonicalUrl);
// Anything other than '', 'true' or 'false' rejects the whole PPR request.
headers.set(
PPRRequestHeaders.Preview,
content.preview === undefined ? '' : String(content.preview)
);
headers.set(PPRRequestHeaders.Revision, content.revision);
headers.set(PPRRequestHeaders.ChangeRequest, content.changeRequest ?? '');
headers.set(PPRRequestHeaders.APIToken, await mintRevalidationToken(content.apiToken));
headers.set(PPRRequestHeaders.RevalidationID, REVALIDATION_ID || content.revision);
// Unlike the other optional headers this one is checked with `has()`, so it must be sent even
// when the site has no sections.
headers.set(PPRRequestHeaders.DefaultSiteSection, defaults.siteSection ?? '');
headers.set(PPRRequestHeaders.DefaultSiteSpace, defaults.siteSpace);
headers.set(PPRRequestHeaders.DefaultSpace, defaults.space);
// Signed last: the signature covers every other PPR header.
await signPPRRequestHeaders(headers, secret);
}
/**
* `fetch` decodes the response body, so the upstream framing headers no longer describe what we are
* about to send. Forwarding `content-encoding: gzip` with plain bytes renders as a blank page.
*/
const DECODED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding'];
async function forward(request: Request, url: URL, headers: Headers): Promise<Response> {
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
const upstreamURL = new URL(url.pathname + url.search, UPSTREAM);
// `/url/` mode is only enabled for requests on the app's own host (`GITBOOK_URL`), so the proxy
// host must not leak through: the app would treat it as a custom domain and 404.
headers.set('host', upstreamURL.host);
let response: Response;
try {
response = await fetch(upstreamURL, {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
redirect: 'manual',
...(hasBody ? { duplex: 'half' } : {}),
} as RequestInit);
} catch (error) {
log(`${request.method} ${url.pathname} → upstream unreachable at ${UPSTREAM}`);
return new Response(`Upstream ${UPSTREAM} unreachable: ${error}`, { status: 502 });
}
const responseHeaders = new Headers(response.headers);
for (const name of DECODED_RESPONSE_HEADERS) {
responseHeaders.delete(name);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
async function handle(request: Request): Promise<Response> {
const url = new URL(request.url);
// `fetch` can't perform an upgrade, so hot reload only works when hitting the app directly.
if (request.headers.get('upgrade') === 'websocket') {
return new Response('Websocket upgrades are not proxied', { status: 501 });
}
const headers = new Headers(request.headers);
// Never let a client inject its own PPR headers.
for (const name of Object.values(PPRRequestHeaders)) {
headers.delete(name);
}
if (
!url.pathname.startsWith(URL_PREFIX) ||
PASSTHROUGH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))
) {
return forward(request, url, headers);
}
const publishedURL = `https://${url.pathname.slice(URL_PREFIX.length)}${url.search}`;
const skip = (reason: string) => {
log(`${request.method} ${url.pathname} → no PPR: ${reason}`);
return forward(request, url, headers);
};
let content: PublishedSiteContent;
try {
const result = await lookupPublishedURL(publishedURL);
if ('redirect' in result) {
return skip(`lookup redirected to ${result.redirect}`);
}
content = result;
} catch (error) {
return skip(`lookup failed: ${error instanceof Error ? error.message : String(error)}`);
}
let revision: string;
let defaults: Awaited<ReturnType<typeof getDefaults>>;
try {
[revision, defaults] = await Promise.all([
content.revision ?? getActiveRevision(content),
getDefaults(content),
]);
} catch (error) {
return skip(`no revision: ${error instanceof Error ? error.message : String(error)}`);
}
await setPPRHeaders(headers, { ...content, revision }, defaults, SECRET);
log(
`${request.method} ${url.pathname} → site=${content.site} space=${content.space} revision=${revision}`
);
return forward(request, url, headers);
}
Bun.serve({
port: PORT,
idleTimeout: 60,
fetch: handle,
});
log(`listening on http://localhost:${PORT} → ${UPSTREAM}`);
log(`try http://localhost:${PORT}/url/gitbook.com/docs`);
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteAiCatalog } from '@/lib/aiCatalog';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteAiCatalog(context, request);
}
export { handler as GET, handler as OPTIONS };
@@ -1,30 +1,12 @@
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
/**
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
*/
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
waitUntil(
trackServerInsightsEvents(args).catch((error) => {
console.error('Failed to track MCP insights event:', error);
})
);
}
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
import { buildMcpServerInfo } from '@/lib/mcp/serverCard';
import { createSiteMcpTools, registerSiteMcpTools, trackMcpEvent } from '@/lib/mcp/tools';
export async function handleMcpRequest(
rawRequest: NextRequest,
@@ -32,11 +14,8 @@ export async function handleMcpRequest(
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
if (!isMcpEnabled) {
if (!isSiteMcpEnabled(context)) {
return new Response('Not Found', { status: 404 });
}
@@ -61,418 +40,12 @@ export async function handleMcpRequest(
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
{
title: 'Search documentation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
// The search API returns sections ordered highest-score-first, so
// the first section with a body is the best-scoring preview.
const body = (pageResult.sections ?? []).find(
(section) => section.body
)?.body;
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
const siteUrl = context.siteSpace.urls.published;
server.tool(
'getPage',
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
{
url: z
.string()
.describe('The URL of the page to fetch')
.transform((value, ctx) => {
if (URL.canParse(value)) {
return value;
}
if (URL.canParse(`https://${value}`)) {
return `https://${value}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}),
},
{
title: 'Get page content',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const markdown = await getMarkdownForPageInSpace(
context,
match.siteSpace,
resolved.page
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
},
],
request,
});
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
// Only expose the answer tool when the site has AI enabled, since it relies on
// the same AI search backend that powers the site's "ask a question" experience.
if (isAIEnabled(context.customization.ai.mode)) {
server.tool(
'askQuestion',
`Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`,
{
question: z
.string()
.describe(
`The natural-language question to answer about ${site.title}.`
),
goal: z
.string()
.optional()
.describe(
'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.'
),
},
{
title: 'Ask a question',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async ({ question, goal }) => {
try {
const trimmedQuestion = question.trim();
if (!trimmedQuestion) {
return {
content: [
{
type: 'text',
text: 'Please provide a question to answer.',
},
],
isError: true,
};
}
const trimmedGoal = goal?.trim() || undefined;
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
goal: trimmedGoal,
});
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'ask_question',
query: trimmedQuestion,
...(trimmedGoal ? { goal: trimmedGoal } : {}),
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
if (!answer || !answer.answer || !('markdown' in answer.answer)) {
return {
content: [
{
type: 'text',
text: "We couldn't answer this question.",
},
],
};
}
const answerMarkdown = toPageMarkdown(
await fromPageMarkdown(context, {
markdown: answer.answer.markdown,
pagePath: '',
})
);
const sourcesMarkdown = await renderAskSourcesMarkdown(
context,
answer.sources ?? []
);
let text = answerMarkdown.trim();
if (sourcesMarkdown) {
text += `\n\n# Sources\n\n${sourcesMarkdown}`;
}
return { content: [{ type: 'text', text }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
}
server.tool(
'sendFeedback',
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
{
content: z
.string()
.min(1)
.max(2048)
.describe(
'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.'
),
pageUrl: z
.string()
.describe(
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started), so the finding is linked to the exact page.`
)
.transform((value, ctx) => {
const candidate = URL.canParse(value)
? new URL(value)
: URL.canParse(value, siteUrl)
? new URL(value, siteUrl)
: null;
if (
!candidate ||
(candidate.protocol !== 'https:' && candidate.protocol !== 'http:')
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}
return candidate.toString();
}),
goal: z
.string()
.optional()
.describe(
'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.'
),
},
{
title: 'Send feedback',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async ({ content, pageUrl, goal }) => {
try {
const match = findSiteSpaceByUrl(context.structure, pageUrl);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
const trimmedGoal = goal?.trim() || undefined;
//!! DISABLED FOR NOW: We'll add this back in when we have a way to track agent feedback.
// trackMcpEvent({
// organizationId: context.organizationId,
// siteId: site.id,
// events: [
// {
// type: 'agent_feedback',
// feedback: { content, category },
// location: {
// displayContext: SiteInsightsDisplayContext.Mcp,
// page: resolved.page.id,
// space: match.siteSpace.space.id,
// revision: match.siteSpace.space.revision,
// },
// },
// ],
// request,
// });
const apiClient = await dataFetcher.api();
await apiClient.orgs.submitSiteAgentFeedback(
context.organizationId,
site.id,
{
feedback: content,
url: pageUrl,
spaceId: match.siteSpace.space.id,
pageId: resolved.page.id,
...(trimmedGoal ? { goal: trimmedGoal } : {}),
}
);
return {
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
};
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
registerSiteMcpTools(server, createSiteMcpTools(context, { request }));
},
{
// The same identity the server card republishes, so the card and the handshake agree.
serverInfo: buildMcpServerInfo(context),
},
{},
{
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteMcpServerCard } from '@/lib/mcp/serverCard';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteMcpServerCard(context, request);
}
export { handler as GET, handler as OPTIONS };
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'bun:test';
import type { SearchPageResult, SearchSpaceResult, SiteSpace } from '@gitbook/api';
import type {
RevisionPage,
RevisionPageDocument,
SearchPageResult,
SearchSpaceResult,
SiteSpace,
} from '@gitbook/api';
import { orderSearchResultGroups } from './orderSearchResults';
import { createLinker } from '@/lib/links';
@@ -16,6 +22,13 @@ const linker = createLinker({
spaceBasePath: '/',
});
const revisionLinker = createLinker({
protocol: 'https:',
host: 'docs.runway.team',
siteBasePath: '/handbook/',
spaceBasePath: '/handbook/api/~/revisions/revision_preview/',
});
const spaceItem: SearchSpaceResult = {
type: 'space',
id: 'space_runway',
@@ -63,6 +76,33 @@ function transformPage(pageItem: SearchPageResult, asEmbeddable = false) {
pageItem,
spaceItem,
siteSpace,
})!;
}
function createRevisionPage(id: string, path: string): RevisionPageDocument {
return {
id,
type: 'document',
path,
pages: [],
} as RevisionPageDocument;
}
function createRevisionPages(pagePath: string): RevisionPage[] {
return [
createRevisionPage('page_home', 'home'),
createRevisionPage('page_api_reference', pagePath),
];
}
function transformRevisionPage(pageItem: SearchPageResult, revisionPages: RevisionPage[]) {
return transformSitePageResult({
asEmbeddable: false,
linker: revisionLinker,
pageItem,
spaceItem,
siteSpace,
revisionPages,
});
}
@@ -83,12 +123,92 @@ describe('transformSitePageResult', () => {
expect(transformPage(createPageResult({ path: destination }), true).href).toBe(destination);
});
it('resolves a relative page path through the published site URL', () => {
it('keeps published search results on their published destination', () => {
const result = transformPage(createPageResult({ path: 'guides/getting-started' }));
expect(result.href).toBe('/guides/getting-started');
});
it('keeps a current-space page result inside the revision being previewed', () => {
const result = transformRevisionPage(
createPageResult({ path: 'guides/getting-started' }),
createRevisionPages('guides/getting-started')
);
expect(result?.href).toBe(
'/handbook/api/~/revisions/revision_preview/guides/getting-started'
);
});
it('uses the current revision path when a page moved after the indexed revision', () => {
const result = transformRevisionPage(
createPageResult({ path: 'guides/getting-started' }),
createRevisionPages('start/quickstart')
);
expect(result?.href).toBe('/handbook/api/~/revisions/revision_preview/start/quickstart');
});
it('keeps a section result and its anchor inside the current revision', () => {
const result = transformRevisionPage(
createPageResult({
path: 'guides/getting-started',
sections: [
{
id: 'section_authentication',
title: 'Authentication',
body: 'Synthetic section excerpt',
path: 'guides/getting-started#authentication',
score: 10,
resultType: 'section',
urls: {
app: 'https://app.gitbook.com/o/example/s/example',
},
},
],
}),
createRevisionPages('guides/getting-started')
);
expect(result?.bestSection?.href).toBe(
'/handbook/api/~/revisions/revision_preview/guides/getting-started#authentication'
);
});
it('falls back to the revision page root when a section anchor cannot be safely retained', () => {
const result = transformRevisionPage(
createPageResult({
path: 'guides/getting-started',
sections: [
{
id: 'section_authentication',
title: 'Authentication',
body: 'Synthetic section excerpt',
path: 'another-page#authentication',
score: 10,
resultType: 'section',
urls: {
app: 'https://app.gitbook.com/o/example/s/example',
},
},
],
}),
createRevisionPages('start/quickstart')
);
expect(result?.bestSection?.href).toBe(
'/handbook/api/~/revisions/revision_preview/start/quickstart'
);
});
it('excludes a main-index result that does not exist in the current revision', () => {
const result = transformRevisionPage(createPageResult({ path: 'guides/getting-started' }), [
createRevisionPage('page_home', 'home'),
]);
expect(result).toBeNull();
});
it('keeps embeddable GitBook page links in the embeddable route', () => {
const result = transformPage(createPageResult({ path: 'guides/getting-started' }), true);
@@ -126,7 +246,33 @@ describe('transformSitePageResult', () => {
spaceItem,
});
expect(result.href).toBe('/guides/getting-started');
expect(result?.href).toBe('/guides/getting-started');
});
it('keeps cross-space results on their published destination during a revision preview', () => {
const otherSpaceItem = { ...spaceItem, id: 'space_sdk', title: 'SDK' };
const otherSiteSpace = {
...siteSpace,
id: 'site_space_sdk',
path: 'sdk',
space: {
...siteSpace.space,
id: otherSpaceItem.id,
title: otherSpaceItem.title,
},
urls: {
published: 'https://docs.runway.team/handbook/sdk/',
},
} as SiteSpace;
const result = transformSitePageResult({
asEmbeddable: false,
linker: revisionLinker,
pageItem: createPageResult({ path: 'guides/getting-started' }),
spaceItem: otherSpaceItem,
siteSpace: otherSiteSpace,
});
expect(result?.href).toBe('/handbook/sdk/guides/getting-started');
});
it('preserves ranks, scores, and result ordering', () => {
@@ -9,7 +9,7 @@ import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { transformSitePageResult } from '@/lib/search';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
const [searchResults, { structure }, revision] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
@@ -38,8 +38,24 @@ export async function POST(request: NextRequest) {
siteShareKey: siteURLData.shareKey,
})
),
siteURLData.revision
? throwIfDataError(
context.dataFetcher.getRevision({
spaceId: siteURLData.space,
revisionId: siteURLData.revision,
})
)
: Promise.resolve(undefined),
]);
const currentSiteSpace = revision
? findSiteSpaceBy(structure, (siteSpace) => siteSpace.id === siteURLData.siteSpace)
: null;
const revisionLinker =
revision && currentSiteSpace
? getLinkerForSiteSpace(context.linker, currentSiteSpace.siteSpace, revision.pages)
: context.linker;
const results = orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
@@ -55,25 +71,31 @@ export async function POST(request: NextRequest) {
return { type: 'context' as const, results: [result] };
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const isCurrentRevisionSpace = Boolean(revision && resultItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
return {
type: 'pages' as const,
results: resultItem.pages.map((pageItem) => ({
rank: pageItem.rank,
result: transformSitePageResult({
results: resultItem.pages.flatMap((pageItem) => {
const result = transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
})),
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
return result ? [{ rank: pageItem.rank, result }] : [];
}),
};
})
);
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,31 +0,0 @@
import type { Metadata, Viewport } from 'next';
import { type PPRRouteParams, getPPRPageRouteParams, getPagePathFromParams } from '@/app/utils';
import {
PPRPageBody,
cachedGenerateSitePageMetadata,
cachedGenerateSitePageViewport,
} from '@/components/SitePage/PPRSitePage';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<PPRRouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const pathname = getPagePathFromParams(params);
return <PPRPageBody params={await getPPRPageRouteParams(params)} pathname={pathname} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const params = await props.params;
return cachedGenerateSitePageViewport(await getPPRPageRouteParams(params));
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
return cachedGenerateSitePageMetadata(await getPPRPageRouteParams(params));
}
@@ -1,94 +0,0 @@
import type React from 'react';
import {
type PPRRouteLayoutParams,
getPPRHeaderRouteParams,
getPPRPageRouteParams,
getPPRSiteRouteParams,
getPPRStaticSiteContext,
getPPRStaticSiteScopeContext,
getPPRTableOfContentsRouteParams,
getPPRVisitorAuthClaims,
} from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import {
PPRAdminToolbar,
PPRAnnouncement,
PPRFooter,
PPRHeader,
PPRRevisionIconsProvider,
PPRTableOfContents,
} from '@/components/SitePage/PPRSitePage';
import { shouldTrackEvents } from '@/lib/tracking';
interface SitePPRLayoutProps {
params: Promise<PPRRouteLayoutParams>;
}
export default async function SitePPRLayout({
params,
children,
}: React.PropsWithChildren<SitePPRLayoutProps>) {
const routeParams = await params;
const [siteParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([
getPPRSiteRouteParams(routeParams),
getPPRHeaderRouteParams(routeParams),
getPPRTableOfContentsRouteParams(routeParams),
// Each component holds a token narrowed to one scope, so the client claims need their union.
getPPRVisitorAuthClaims(routeParams),
]);
// The layout is rendered on every request, so it only resolves site-level data — under the
// scope of the header, whose site fetch it then shares. Everything below the site level is
// delegated to the cached components in the slots.
const { context } = await getPPRStaticSiteScopeContext(siteParams, 'header');
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<PPRRevisionIconsProvider params={tableOfContentsParams}>
<SiteLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
slots={{
announcement: <PPRAnnouncement params={tableOfContentsParams} />,
header: <PPRHeader params={headerParams} />,
tableOfContents: <PPRTableOfContents params={tableOfContentsParams} />,
footer: <PPRFooter params={tableOfContentsParams} />,
adminToolbar: <PPRAdminToolbar params={tableOfContentsParams} />,
}}
// The header and table of contents are cached across pages, so the selection they
// were rendered with belongs to another page and has to be resolved on the client.
clientNavigationSelection
>
{children}
</SiteLayout>
</PPRRevisionIconsProvider>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SitePPRLayoutProps) {
const { context } = await getPPRStaticSiteContext(
await getPPRPageRouteParams(await params),
'header'
);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SitePPRLayoutProps) {
const { context } = await getPPRStaticSiteContext(
await getPPRPageRouteParams(await params),
'header'
);
return generateSiteLayoutMetadata(context);
}
-267
View File
@@ -1,267 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import * as realContext from '@/lib/context';
mock.module('server-only', () => ({}));
// Only the lookup is stubbed: mocking the whole module would leak into the other test files,
// as `mock.module` replaces it for the entire test process.
mock.module('@/lib/context', () => ({
...realContext,
getBaseContext: (input: unknown) => input,
fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
fetchSiteScopeContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
}));
// Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is
// stubbed at the network boundary rather than with `mock.module`, which would replace
// `@/lib/ppr-token` for the entire test process and break its own test file.
const realFetch = globalThis.fetch;
beforeAll(() => {
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
const { scope } = JSON.parse(String(init?.body));
// Deterministic per scope, so the assertions below can compare tokens across pages.
return Response.json({
token: jwt.sign(
{ exp: Math.floor(Date.now() / 1000) + 3600, claims: { scope } },
'secret'
),
});
}) as typeof fetch;
});
afterAll(() => {
globalThis.fetch = realFetch;
});
const {
getPPRHeaderRouteParams,
getPPRPageRouteParams,
getPPRRouteParams,
getPPRSiteRouteParams,
getPPRStaticSiteContext,
getPPRStaticSiteScopeContext,
getPPRTableOfContentsRouteParams,
getPPRVisitorAuthClaims,
getSiteURLDataFromParams,
} = await import('./utils');
type PPRRouteParams = import('./utils').PPRRouteParams;
const apiToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) + 3600,
siteClaims: { audience: 'external' },
revisionClaims: { account: { tier: 'pro' } },
pageClaims: { unsigned: { locale: 'fr' } },
},
'secret'
);
const routeParams: PPRRouteParams = {
mode: 'url',
siteURL: 'docs.example.com',
siteData: encodeURIComponent(
rison.encode({
apiToken,
site: 'site-id',
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
revision: 'resolved-revision-id',
siteBasePath: '/docs/',
basePath: '/docs/v/page-variant/',
imagesContextId: 'images-context-id',
})
),
revisionId: encodeURIComponent('ppr-revision-id'),
revalidationId: encodeURIComponent('revalidation-id'),
pprDefaults: encodeURIComponent(
rison.encode({
siteSection: 'default-site-section-id',
siteSpace: 'default-site-space-id',
space: 'default-space-id',
})
),
pagePath: 'guide',
};
describe('getPPRRouteParams', () => {
it('removes the PPR cache key while preserving resolved content and API token', () => {
const params = getPPRRouteParams(routeParams);
expect(params).toMatchObject({
mode: routeParams.mode,
siteURL: routeParams.siteURL,
pagePath: routeParams.pagePath,
});
expect(params).not.toHaveProperty('revisionId');
expect(params).not.toHaveProperty('revalidationId');
expect(params).not.toHaveProperty('pprDefaults');
expect(getSiteURLDataFromParams(params)).toMatchObject({
apiToken,
site: 'site-id',
space: 'space-id',
revision: 'ppr-revision-id',
imagesContextId: 'images-context-id',
});
});
});
describe('PPR cache region params', () => {
const changedRouteParams: PPRRouteParams = {
...routeParams,
siteData: encodeURIComponent(
rison.encode({
apiToken: jwt.sign({ siteClaims: {} }, 'other-secret'),
site: 'site-id',
siteSection: 'new-page-site-section-id',
siteSpace: 'new-page-site-space-id',
space: 'new-space-id',
revision: 'resolved-revision-id',
siteBasePath: '/docs/',
basePath: '/docs/v/other-variant/',
imagesContextId: 'images-context-id',
})
),
};
it('keeps header params stable, API token included', async () => {
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
const changedHeaderData = getSiteURLDataFromParams(
await getPPRHeaderRouteParams(changedRouteParams)
);
expect(headerData).toMatchObject({
siteSection: 'default-site-section-id',
siteSpace: 'default-site-space-id',
space: 'default-space-id',
// The default variant is served at the site root, so its links can't keep the base
// path of the variant the visitor is on.
basePath: '/docs/',
});
// The whole point of the exchange: the site-scoped token no longer varies per page, so the
// header can be cached once for the site instead of once per page.
expect(headerData).toEqual(changedHeaderData);
});
it('keeps the visited location in the site params, with the header token', async () => {
const siteData = getSiteURLDataFromParams(await getPPRSiteRouteParams(routeParams));
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
// The shell renders the variant the visitor is on, it just never reads below the site level.
expect(siteData).toMatchObject({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
basePath: '/docs/v/page-variant/',
revision: 'ppr-revision-id',
});
// Same scope as the header, so they share their site fetch.
expect(siteData.apiToken).toBe(headerData.apiToken);
});
it('narrows the header token to the site scope', async () => {
const { apiToken: headerToken } = getSiteURLDataFromParams(
await getPPRHeaderRouteParams(routeParams)
);
expect(headerToken).not.toBe(apiToken);
expect(jwt.decode(headerToken)).toMatchObject({ claims: { scope: 'site' } });
});
it('keeps the visited base path when the defaults point at the visited variant', async () => {
const headerData = getSiteURLDataFromParams(
await getPPRHeaderRouteParams({
...routeParams,
pprDefaults: encodeURIComponent(
rison.encode({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
})
),
})
);
expect(headerData).toMatchObject({
siteSpace: 'page-site-space-id',
basePath: '/docs/v/page-variant/',
});
});
it('keeps TOC params stable apart from its current location', async () => {
const tocData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(routeParams)
);
const changedTOCData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(changedRouteParams)
);
expect(tocData).toMatchObject({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
basePath: '/docs/v/page-variant/',
});
// The revision-scoped token drops the page claims, so it is shared by every page of the
// space; only the location data still varies.
expect(tocData.apiToken).toBe(changedTOCData.apiToken);
expect({
...tocData,
siteSection: undefined,
siteSpace: undefined,
space: undefined,
basePath: undefined,
}).toEqual({
...changedTOCData,
siteSection: undefined,
siteSpace: undefined,
space: undefined,
basePath: undefined,
});
});
it('narrows the TOC and page tokens to their own scopes', async () => {
const tocData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(routeParams)
);
const pageData = getSiteURLDataFromParams(await getPPRPageRouteParams(routeParams));
expect(jwt.decode(tocData.apiToken)).toMatchObject({ claims: { scope: 'revision' } });
expect(jwt.decode(pageData.apiToken)).toMatchObject({ claims: { scope: 'page' } });
expect(tocData.apiToken).not.toBe(pageData.apiToken);
});
});
describe('getPPRVisitorAuthClaims', () => {
it('resolves every scope at once through a full exchange', async () => {
// The scoped tokens each carry one bucket, so the client claims can only come from `full`.
expect(await getPPRVisitorAuthClaims(routeParams)).toEqual({ scope: 'full' });
});
});
describe('getPPRStaticSiteScopeContext', () => {
it('resolves the site scope from the supplied params', async () => {
const { context } = await getPPRStaticSiteScopeContext(
getPPRRouteParams(routeParams),
'header'
);
expect(context).toMatchObject({
apiToken,
revision: 'ppr-revision-id',
});
});
});
describe('getPPRStaticSiteContext', () => {
it('uses the supplied API token without resolving published content again', async () => {
const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body');
expect(context).toMatchObject({
apiToken,
revision: 'ppr-revision-id',
});
});
});
+11 -246
View File
@@ -4,20 +4,9 @@ import rison from 'rison';
import type { SiteAPIToken } from '@gitbook/api';
import {
type VisitorAuthClaims,
getVisitorAuthClaims,
getVisitorAuthClaimsFromToken,
} from '@/lib/adaptive';
import type { PPRCacheScope } from '@/lib/cache-tags';
import {
type SiteURLData,
fetchSiteContextByURLLookup,
fetchSiteScopeContextByURLLookup,
getBaseContext,
} from '@/lib/context';
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context';
import { getDynamicCustomizationSettings } from '@/lib/customization';
import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token';
export type RouteParamMode = 'url-host' | 'url';
@@ -35,50 +24,10 @@ export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
export type PPRRouteLayoutParams = RouteLayoutParams & {
revisionId: string;
revalidationId: string;
pprDefaults: string;
};
export type PPRRouteParams = PPRRouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export async function getStaticSiteContext(
params: RouteLayoutParams,
options?: { pprScope?: PPRCacheScope }
) {
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
return {
context: await fetchSiteContextByURLLookup(baseContext, siteURLData),
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
* Get the site-level part of the static context, without resolving the space and its revision.
*/
export async function getStaticSiteScopeContext(
params: RouteLayoutParams,
options?: { pprScope?: PPRCacheScope }
) {
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
return {
context: await fetchSiteScopeContextByURLLookup(baseContext, siteURLData),
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
* Decode the params of a static route and open a base context for them.
*/
function getStaticBaseContext(params: RouteLayoutParams, options?: { pprScope?: PPRCacheScope }) {
export async function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
@@ -89,15 +38,18 @@ function getStaticBaseContext(params: RouteLayoutParams, options?: { pprScope?:
forbidden();
}
return {
baseContext: getBaseContext({
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
...(options?.pprScope ? { pprScope: options.pprScope } : {}),
}),
siteURLData,
decoded,
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
@@ -180,190 +132,3 @@ export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData
notFound();
}
}
export function getPPRRouteParams(params: PPRRouteParams): RouteParams;
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams;
/**
* Project PPR route params for the current page, without PPR-only cache inputs.
*/
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams {
const { revisionId, revalidationId, pprDefaults: _, ...routeParams } = params;
const siteURLData = getSiteURLDataFromParams(params);
return {
...routeParams,
siteData: encodeURIComponent(
rison.encode({
...siteURLData,
revision: getPPRRouteParam(revisionId, 'revision ID'),
})
),
};
}
/**
* Project PPR params for the current page, with a token scoped to the page claims.
*/
export function getPPRPageRouteParams(params: PPRRouteParams): Promise<RouteParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.body);
}
/**
* Project PPR params for the site-level shell, with a token scoped to the site claims.
* Unlike the header params, they keep the location data of the visited page: the shell renders the
* current variant, it just never reads anything below the site level.
*/
export function getPPRSiteRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.header);
}
/**
* Project PPR params for the shared header by replacing page-varying location data.
*/
export async function getPPRHeaderRouteParams(
params: PPRRouteLayoutParams
): Promise<RouteLayoutParams> {
const routeParams = getPPRRouteParams(params);
const { revision, ...siteURLData } = getSiteURLDataFromParams(routeParams);
const defaults = getPPRDefaults(params);
return withExchangedPPRToken(
{
...routeParams,
siteData: encodeSiteData({
...siteURLData,
// For the header, we keep site section and space data from the PPR defaults, so that the header can be cached across all pages in a site.
siteSection: defaults.siteSection ?? undefined,
siteSpace: defaults.siteSpace,
space: defaults.space,
// The base path has to describe the same variant as the ids above, or the header
// prefixes one variant's page paths with another variant's base path. The site default
// variant is published at the site root; defaults pointing at the visited variant keep
// its own base path.
basePath:
defaults.siteSpace === siteURLData.siteSpace
? siteURLData.basePath
: siteURLData.siteBasePath,
}),
},
PPR_TOKEN_SCOPE.header
);
}
/** rison can't encode undefined values, so they are dropped like the middleware does. */
function encodeSiteData(siteURLData: Record<string, unknown>): string {
return encodeURIComponent(
rison.encode(
Object.fromEntries(
Object.entries(siteURLData).filter(([_, value]) => typeof value !== 'undefined')
)
)
);
}
/**
* Project PPR params for the table of contents, keeping its current location data.
* The table of contents depends only on the space you're in and the claims of that revision, not on the page,
* and the layout params carry no page path, so only the token has to be narrowed.
*/
export function getPPRTableOfContentsRouteParams(
params: PPRRouteLayoutParams
): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.toc);
}
/**
* Replace the revalidation token carried by the PPR params with a content API token narrowed to
* `scope`. Components sharing a scope then share a token, and with it a cache entry.
*/
async function withExchangedPPRToken<T extends RouteLayoutParams>(
params: T,
scope: PPRTokenScope
): Promise<T> {
const siteURLData = getSiteURLDataFromParams(params);
return {
...params,
siteData: encodeSiteData({
...siteURLData,
apiToken: await exchangePPRToken(siteURLData.apiToken, scope),
}),
};
}
/**
* Get the claims the client should resolve adaptive content with. Each component holds a token
* narrowed to a single scope, so the union has to come from a `full` exchange.
*/
export async function getPPRVisitorAuthClaims(
params: PPRRouteLayoutParams
): Promise<VisitorAuthClaims> {
const { apiToken } = getSiteURLDataFromParams(params);
const fullToken = await exchangePPRToken(apiToken, 'full');
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(fullToken));
}
/**
* Get the static context for a PPR component. The scope partitions the cache entries and scopes
* the tags they emit, so the component and its data are revalidated as one unit.
*/
export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScope: PPRCacheScope) {
return getStaticSiteContext(params, { pprScope });
}
/**
* Get the site-level context for a PPR component, without resolving the space and its revision.
*/
export async function getPPRStaticSiteScopeContext(
params: RouteLayoutParams,
pprScope: PPRCacheScope
) {
return getStaticSiteScopeContext(params, { pprScope });
}
function getPPRRouteParam(encodedParam: string, name: string): string {
try {
return decodeURIComponent(encodedParam);
} catch (error) {
console.error(`Returning 404 after failing to decode PPR ${name}: ${error}`);
notFound();
}
}
type PPRDefaults = {
siteSection: string | null;
siteSpace: string;
space: string;
};
function getPPRDefaults(params: PPRRouteLayoutParams): PPRDefaults {
let defaults: unknown;
try {
defaults = rison.decode(decodeURIComponent(params.pprDefaults));
} catch (error) {
console.error(`Returning 404 after failing to decode PPR defaults: ${error}`);
notFound();
}
if (
!defaults ||
typeof defaults !== 'object' ||
Array.isArray(defaults) ||
!('siteSection' in defaults) ||
!('siteSpace' in defaults) ||
!('space' in defaults) ||
(defaults.siteSection !== null && typeof defaults.siteSection !== 'string') ||
typeof defaults.siteSpace !== 'string' ||
!defaults.siteSpace ||
typeof defaults.space !== 'string' ||
!defaults.space
) {
console.error(`Returning 404 after decoding invalid PPR defaults: ${params.pprDefaults}`);
notFound();
}
return defaults as PPRDefaults;
}
+4 -2
View File
@@ -6,8 +6,9 @@ import {
GITBOOK_API_URL,
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
GITBOOK_DISABLE_TRACKING,
GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
@@ -29,7 +30,6 @@ export async function GET(_req: NextRequest) {
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -37,6 +37,8 @@ export async function GET(_req: NextRequest) {
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
// Secret envs
GITBOOK_SECRET: !!GITBOOK_SECRET,
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { setIntercomLauncherHidden } from './intercom';
const originalWindow = globalThis.window;
afterEach(() => {
if (originalWindow === undefined) {
delete (globalThis as { window?: Window }).window;
} else {
globalThis.window = originalWindow;
}
});
function installIntercom(intercom: Window['Intercom']): void {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { Intercom: intercom },
writable: true,
});
}
describe('setIntercomLauncherHidden', () => {
it('hides the Intercom launcher', () => {
const calls: unknown[][] = [];
installIntercom((...args) => calls.push(args));
setIntercomLauncherHidden(true);
expect(calls).toEqual([['update', { hide_default_launcher: true }]]);
});
it('shows the Intercom launcher', () => {
const calls: unknown[][] = [];
installIntercom((...args) => calls.push(args));
setIntercomLauncherHidden(false);
expect(calls).toEqual([['update', { hide_default_launcher: false }]]);
});
it('does nothing when Intercom is unavailable', () => {
expect(() => setIntercomLauncherHidden(true)).not.toThrow();
});
});
@@ -0,0 +1,16 @@
declare global {
interface Window {
Intercom?: (command: string, options?: Record<string, unknown>) => void;
}
}
/**
* Hide or show Intercom's default launcher when it is available on the host page.
*/
export function setIntercomLauncherHidden(hidden: boolean): void {
if (typeof window === 'undefined' || typeof window.Intercom !== 'function') {
return;
}
window.Intercom('update', { hide_default_launcher: hidden });
}
@@ -0,0 +1,33 @@
'use server';
import { AgentFeedbackSource } from '@gitbook/api';
import type { AgentFeedbackResult } from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
/**
* Record a finding about the site's content, submitted by the assistant while helping a reader.
*/
export async function submitAssistantFeedbackToTeam(input: {
feedback: string;
goal?: string;
/** Page the finding is about, as a URL on the site or a `/spaces/…/pages/…` ref. */
page: string;
asEmbeddable?: boolean;
}): Promise<AgentFeedbackResult> {
return traceErrorOnly('AI.submitAssistantFeedbackToTeam', async () => {
const baseContext = await getServerActionBaseContext({
isEmbeddable: input.asEmbeddable,
});
const context = await fetchServerActionSiteContext(baseContext);
return submitAgentFeedback(context, {
feedback: input.feedback,
goal: input.goal,
page: input.page,
source: AgentFeedbackSource.Assistant,
});
});
}
@@ -1,3 +1,4 @@
export * from './types';
export * from './chat';
export * from './feedback';
export * from './navigate';
@@ -1,7 +1,7 @@
'use server';
import { toInSiteHref } from '../navigation';
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
import { resolveContentRef, resolveStringContentRef, toContentRefPath } from '@/lib/references';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -21,15 +21,7 @@ export async function resolveAINavigationLink(
const baseContext = await getServerActionBaseContext();
const context = await fetchServerActionSiteContext(baseContext);
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
let path = url;
if (URL.canParse(url)) {
const parsed = new URL(url);
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
const contentRef = resolveStringContentRef(path);
const contentRef = resolveStringContentRef(toContentRefPath(url));
if (contentRef) {
const resolved = await resolveContentRef(contentRef, context);
if (!resolved) {
@@ -18,10 +18,12 @@ import { type UpdateSearchState, useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { setIntercomLauncherHidden } from './intercom';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import {
type ResponseToRate,
@@ -240,7 +242,6 @@ export function AIChatProvider(props: {
const setSearchStateInURL = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
const displayContext = renderMessageOptions?.asEmbeddable
? SiteInsightsDisplayContext.Embed
: SiteInsightsDisplayContext.Site;
@@ -256,6 +257,10 @@ export function AIChatProvider(props: {
const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null });
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
// Findings reported to the site's team are capped at one per conversation, so the assistant
// cannot flood the inbox over a long chat.
const reportedAgentFeedbackRef = React.useRef(false);
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or
// assistant feedback). Each tool has a stable identity, so it can be referenced directly from
// the streaming callback.
@@ -265,22 +270,41 @@ export function AIChatProvider(props: {
displayContext,
getResponseToRate,
});
const leaveAgentFeedbackTool = useLeaveAgentFeedbackTool({
asEmbeddable: renderMessageOptions?.asEmbeddable,
displayContext,
reportedRef: reportedAgentFeedbackRef,
});
const leaveUserFeedbackTool = useLeaveUserFeedbackTool({
asEmbeddable: renderMessageOptions?.asEmbeddable,
displayContext,
});
// The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down
// rating), while the page-feedback tool is gated on the site's "Was this helpful?" setting.
// rating), while the tools carrying the reader's own feedback are gated on the site's
// "Was this helpful?" setting.
const builtInTools = React.useMemo(() => {
const tools = [navigateToPageTool, submitAssistantFeedbackTool];
const tools = [navigateToPageTool, submitAssistantFeedbackTool, leaveAgentFeedbackTool];
if (withPageFeedback) {
tools.push(submitPageFeedbackTool);
tools.push(submitPageFeedbackTool, leaveUserFeedbackTool);
}
return tools;
}, [navigateToPageTool, submitAssistantFeedbackTool, submitPageFeedbackTool, withPageFeedback]);
}, [
navigateToPageTool,
submitAssistantFeedbackTool,
leaveAgentFeedbackTool,
leaveUserFeedbackTool,
submitPageFeedbackTool,
withPageFeedback,
]);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
setIntercomLauncherHidden(true);
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
@@ -297,6 +321,8 @@ export function AIChatProvider(props: {
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
setIntercomLauncherHidden(false);
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
@@ -711,6 +737,7 @@ export function AIChatProvider(props: {
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
responseToRateRef.current = { responseId: null, query: null };
reportedAgentFeedbackRef.current = false;
globalState.setState((state) => ({
opened: state.opened,
responding: false,
@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { AIToolDefinition, SiteInsightsDisplayContext } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { type PagePointer, useCurrentPage } from '../hooks';
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
import { submitAssistantFeedbackToTeam } from './server-actions';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
AGENT_FEEDBACK_MAX_LENGTH,
agentFeedbackDescriptions,
formatAgentFeedbackPageRef,
} from '@/lib/agentFeedback';
const feedbackInput = z.string().min(1).max(AGENT_FEEDBACK_MAX_LENGTH);
const pageInput = z.string().optional().describe(agentFeedbackDescriptions.pageReference);
const LeaveAgentFeedbackInputSchema = z.object({
feedback: feedbackInput.describe(agentFeedbackDescriptions.finding),
goal: z
.string()
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
.optional()
.describe(agentFeedbackDescriptions.goal),
page: pageInput,
});
const LeaveUserFeedbackInputSchema = z.object({
feedback: feedbackInput.describe(
"The user's feedback about the content, copied verbatim. Never rephrase, summarise, correct, translate or add to their words. Quote only what they said about the documentation, and leave out anything personal or confidential."
),
page: pageInput,
});
type LeaveFeedbackToolOptions = {
/** Whether the assistant runs in the embeddable, which resolves its own site context. */
asEmbeddable?: boolean;
/** Display context recorded with the feedback event (e.g. `site` vs. `embed`). */
displayContext: SiteInsightsDisplayContext;
};
/** The assistant's own finding, so it submits without confirmation, capped at one per chat. */
export function useLeaveAgentFeedbackTool(
options: LeaveFeedbackToolOptions & {
/** Whether a finding was already reported in this conversation, reset when it is cleared. */
reportedRef: React.RefObject<boolean>;
}
): GitBookIntegrationTool {
const { asEmbeddable, displayContext, reportedRef } = options;
const language = useLanguage();
const currentPage = useCurrentPage();
const trackEvent = useTrackEvent();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({
language,
currentPage,
asEmbeddable,
displayContext,
trackEvent,
reportedRef,
});
React.useEffect(() => {
ref.current = {
language,
currentPage,
asEmbeddable,
displayContext,
trackEvent,
reportedRef,
};
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'leaveAgentFeedback',
description:
'Leave feedback as you, the GitBook Assistant, on ways this question could have been answered more easily through content changes or reorganization — a missing explanation, an outdated or contradictory passage, or an answer you had to piece together from several pages. Use it when answering was hard, or when you could not answer at all, so the team can close the gap. Write it for a documentation maintainer who never saw this conversation, and send at most one per conversation. This carries your own observation: use `leaveUserFeedback` to relay what the user said, and `submitPageFeedback` to record their rating of a page. Do not use it to confirm that a page is accurate.',
inputSchema: zodToJsonSchema(
LeaveAgentFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { reportedRef, ...tracking } = ref.current;
const { feedback, goal, page } = LeaveAgentFeedbackInputSchema.parse(input);
if (reportedRef.current) {
throw new Error(
'A finding was already reported in this conversation. Do not report another one.'
);
}
await submitFeedback({ feedback, goal, page, ...tracking });
reportedRef.current = true;
return {
output: { submitted: true },
summary: {
icon: 'comment-check',
text: tString(tracking.language, 'ai_chat_tools_shared_feedback_with_team'),
},
};
},
}),
// Rebuild when the locale changes so the summary (read at memo time, not from the ref)
// stays translated.
[language]
);
}
/** Speaks for the reader, so it confirms first, showing the exact text about to be sent. */
export function useLeaveUserFeedbackTool(
options: LeaveFeedbackToolOptions
): GitBookIntegrationTool {
const { asEmbeddable, displayContext } = options;
const language = useLanguage();
const currentPage = useCurrentPage();
const trackEvent = useTrackEvent();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({ language, currentPage, asEmbeddable, displayContext, trackEvent });
React.useEffect(() => {
ref.current = { language, currentPage, asEmbeddable, displayContext, trackEvent };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'leaveUserFeedback',
description:
"Leave feedback on behalf of the user about the documentation, so the team can act on it. Use it when the user reports that something in the content is wrong, missing, outdated or confusing, or asks you to pass a message to the documentation team. Do not modify their feedback in any way: copy their own words. The user will be asked to confirm before it is sent. This carries the user's words: use `leaveAgentFeedback` for your own findings about the content, and `submitPageFeedback` to record their rating of a page.",
confirmation: (input) => {
const parsed = LeaveUserFeedbackInputSchema.safeParse(input);
const feedback = parsed.success ? parsed.data.feedback.trim() : undefined;
return {
icon: 'paper-plane',
label: tString(language, 'ai_chat_tools_submit_feedback'),
context: feedback ? `"${feedback}"` : undefined,
};
},
inputSchema: zodToJsonSchema(
LeaveUserFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const tracking = ref.current;
const { feedback, page } = LeaveUserFeedbackInputSchema.parse(input);
await submitFeedback({ feedback, page, ...tracking });
return {
output: { submitted: true },
summary: {
icon: 'comment-check',
text: tString(tracking.language, 'ai_chat_tools_submitted_feedback'),
},
};
},
}),
// Rebuild when the locale changes so the confirmation label (read at memo time, not from
// the ref) stays translated.
[language]
);
}
async function submitFeedback(input: {
feedback: string;
goal?: string;
page?: string;
currentPage: PagePointer | null;
asEmbeddable?: boolean;
displayContext: SiteInsightsDisplayContext;
trackEvent: ReturnType<typeof useTrackEvent>;
}): Promise<void> {
const page =
input.page?.trim() ||
(input.currentPage ? formatAgentFeedbackPageRef(input.currentPage) : null);
if (!page) {
throw new Error('No documentation page is currently open to leave feedback about.');
}
const result = await submitAssistantFeedbackToTeam({
feedback: input.feedback,
goal: input.goal,
page,
asEmbeddable: input.asEmbeddable,
});
if (!result.submitted) {
throw new Error(result.error);
}
// Attribute to the page the reader is on, since the insights location only lets us override
// the page — its space and revision come from the ambient context.
const pageContext: InsightsEventPageContext = {
pageId: input.currentPage?.pageId ?? null,
displayContext: input.displayContext,
};
input.trackEvent({ type: 'agent_feedback' }, pageContext, { immediate: true });
}
@@ -80,7 +80,7 @@ export function useSubmitPageFeedbackTool(options: {
() => ({
name: 'submitPageFeedback',
description:
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words.",
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words. This records their rating of the page — use `leaveUserFeedback` to pass a report of their own about the content on to the documentation team.",
confirmation: (input) => {
const parsed = SubmitPageFeedbackInputSchema.safeParse(input);
const comment = parsed.success ? parsed.data.comment?.trim() : undefined;
@@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map<
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
const settled = (async () => {
try {
result = await promise;
} catch {
result = null;
}
return result;
})();
return {
read() {
if (result === undefined) {
throw suspender;
throw settled;
}
return result;
},
load() {
return settled;
},
};
}
@@ -65,6 +69,12 @@ export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
export type AdaptiveVisitorAsyncContextValue = () => Promise<AdaptiveVisitorClaims | null>;
const AdaptiveVisitorAsyncContext = createContext<AdaptiveVisitorAsyncContextValue>(
async () => null
);
/**
* Provide context to adapt site based on visitor claims.
*/
@@ -83,11 +93,22 @@ export function AdaptiveVisitorContextProvider(
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
const loadAdaptiveVisitorClaims = React.useCallback(async () => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).load();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
<AdaptiveVisitorAsyncContext.Provider value={loadAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider
getPrefillInputContextData={getAdaptiveVisitorClaims}
>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorAsyncContext.Provider>
</AdaptiveVisitorContext.Provider>
);
}
@@ -98,3 +119,10 @@ export function AdaptiveVisitorContextProvider(
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
/**
* Hook that returns an async getter for adaptive visitor claims data, for use outside of render.
*/
export function useAdaptiveVisitorAsync(): AdaptiveVisitorAsyncContextValue {
return useContext(AdaptiveVisitorAsyncContext);
}
@@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { shouldShowHashLinks } from '../HashLinkButton';
import { Inlines } from '../Inlines';
import { Details } from './Details';
import { ToggleChevron } from '@/components/primitives';
@@ -31,6 +32,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<Details
id={id}
@@ -41,7 +44,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
className={tcls(
'cursor-pointer',
'px-4',
'pr-10',
// Reserve room for the anchor icon pinned to the right edge
showHashLink && 'pr-10',
'py-4',
'relative',
'list-none',
@@ -71,32 +75,34 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
context={context}
ancestorInlines={[]}
/>
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
>
<Icon
icon="hashtag"
{showHashLink ? (
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
/>
</a>
>
<Icon
icon="hashtag"
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
)}
/>
</a>
) : null}
</summary>
<Blocks
nodes={body.nodes}
@@ -2,9 +2,22 @@ import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import type { DocumentContext } from './DocumentView';
import { getBlockTextStyle } from './spacing';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
* Whether blocks should render a visible anchor link icon.
* Only the page body carries a page; search answers, AI chat and PDF export keep anchors.
*/
export function shouldShowHashLinks(context: DocumentContext): boolean {
const contentContext = context.contentContext;
if (!contentContext || !('page' in contentContext)) {
return true;
}
return contentContext.page.layout.anchors !== false;
}
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
@@ -1,7 +1,7 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { HashLinkButton, hashLinkButtonWrapperStyles, shouldShowHashLinks } from './HashLinkButton';
import { HeadingRevealWrapper } from './HeadingRevealWrapper';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -24,6 +24,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<HeadingRevealWrapper
as={Tag}
@@ -34,7 +36,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
'font-heading',
'pdf-heading',
'block',
'pr-6',
// Reserve room for the absolutely positioned anchor icon on coarse pointers
showHashLink && 'pr-6',
'pointer-fine:flex',
'pointer-fine:items-baseline',
'pointer-fine:pr-0',
@@ -63,20 +66,22 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
</span>
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
{showHashLink ? (
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
) : null}
</HeadingRevealWrapper>
);
}
@@ -6,7 +6,7 @@ import React from 'react';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import type { WebframePageContext } from './adaptive';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useAdaptiveVisitorAsync } from '@/components/Adaptive';
import { NavigationStatusContext } from '@/components/hooks';
import { type GitBookLinker, createLinker } from '@/lib/links';
@@ -40,7 +40,12 @@ export function ContentKitWithClientContext<RenderContext>(
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const loadAdaptiveVisitorClaims = useAdaptiveVisitorAsync();
const getVisitorContext = React.useCallback(async () => {
const visitorClaims = await loadAdaptiveVisitorClaims();
return { visitor: visitorClaims?.visitor ?? null };
}, [loadAdaptiveVisitorClaims]);
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
// of the app, instead of duplicating the join logic here.
@@ -55,15 +60,9 @@ export function ContentKitWithClientContext<RenderContext>(
},
[onNavigationClick, router]
);
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
const clientContext = React.useMemo<ContentKitClientContextData>(
() => ({
getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
getVisitorContext: canAccessVisitorClaims ? getVisitorContext : undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor, query }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
@@ -78,7 +77,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + search + hash);
},
}),
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
[canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -1,8 +1,8 @@
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import type { BlockProps } from '../Block';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import { contentKitServerContext } from './contentkit';
import './contentkit.css';
import {
@@ -74,16 +74,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
const page = getWebframePageContext(context.contentContext);
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
// exposes the current page, plus visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
renderContext: {
integrationName: block.data.integration,
@@ -105,20 +100,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
) : (
<ContentKit {...contentKitProps}>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
)}
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
</div>
);
}
@@ -1,47 +1,9 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
const webframe: ContentKitWebFrame = {
type: 'webframe',
source: { url: 'https://integrations.gitbook.com/frame' },
};
function elementOutput(element: unknown): ContentKitRenderOutput {
return {
type: 'element',
element,
state: {},
props: {},
} as ContentKitRenderOutput;
}
describe('integrationBlockContainsWebframe', () => {
it('returns false for a completed output', () => {
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
});
it('returns false when there is no webframe in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'text', text: 'hello' }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(false);
});
it('returns true when a webframe is nested in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'vstack', children: [webframe] }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(true);
});
});
describe('getWebframePageContext', () => {
it('returns null when the context has no page', () => {
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
@@ -1,14 +1,5 @@
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
ContentKitRootElement,
ContentKitStepper,
} from '@gitbook/api';
import type { GitBookAnyContext } from '@/lib/context';
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Current page exposed to a webframe through the client-only webframe state.
*/
@@ -19,18 +10,6 @@ export type WebframePageContext = {
title: string;
};
/**
* Whether an integration block's output contains a webframe that can consume client-only context
* (navigation, visitor claims and/or the current page).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
return false;
}
return doesContentKitElementContainWebframe(output.element);
}
/**
* Extract the current page to expose to a webframe, or `null` when it is unknown
* (e.g. a non-page context, or reusable content resolved from another source).
@@ -55,46 +34,3 @@ export function getWebframePageContext(
title,
};
}
/**
* Check whether a ContentKit element tree contains a webframe element.
*/
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
switch (element.type) {
case 'webframe':
return true;
case 'block':
case 'box':
case 'hstack':
case 'vstack':
case 'step':
case 'modal':
case 'configuration':
case 'stepper':
case 'card':
return doesContentKitElementArrayContainWebframe(element.children);
case 'codeblock':
return (
doesContentKitElementArrayContainWebframe(element.header) ||
doesContentKitElementArrayContainWebframe(element.footer)
);
default:
return false;
}
}
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
if (!Array.isArray(elements)) {
return doesContentKitElementContainWebframeValue(elements);
}
return elements.some(doesContentKitElementContainWebframeValue);
}
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
if (typeof value !== 'object' || value === null || !('type' in value)) {
return false;
}
return doesContentKitElementContainWebframe(value as ContentKitElement);
}
@@ -95,7 +95,7 @@
/** Buttons */
.contentkit-button {
@apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none place-self-start ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all grow-0 shrink-0 truncate w-full;
@apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all truncate;
}
.contentkit-button-style-primary {
@@ -130,15 +130,38 @@
.contentkit-modal-header {
@apply flex flex-col gap-2 px-4 py-2;
}
.contentkit-modal-header-with-close {
@apply flex-row items-start gap-4 px-4 pb-0 pt-4;
}
.contentkit-modal-header-content {
@apply min-w-0 flex-1;
}
.contentkit-modal-title {
@apply text-2xl font-medium text-tint-strong;
}
.contentkit-modal-header-with-close .contentkit-modal-subtitle {
@apply break-words text-sm text-tint;
}
.contentkit-modal-header-with-close .contentkit-modal-title {
@apply break-words;
}
.contentkit-modal-body {
@apply px-4 py-4;
}
.contentkit-modal-footer {
@apply flex justify-end gap-2 px-4 pb-4;
}
.contentkit-modal-close {
@apply flex size-8 shrink-0 items-center justify-center rounded-full text-tint transition-colors hover:bg-tint-hover hover:text-tint-strong focus-visible:outline-2 focus-visible:outline-primary;
}
.contentkit-modal-close-icon {
@apply size-4;
}
.contentkit-modal-header + .contentkit-modal-body {
@apply pt-0;
}
@@ -208,9 +231,6 @@
.contentkit-modal-confirm .contentkit-modal-footer {
@apply flex gap-2 justify-end;
}
.contentkit-button-confirm {
@apply w-auto flex-grow-0 flex-shrink-0;
}
.contentkit-button-style-danger {
@apply bg-danger text-danger hover:bg-danger-hover hover:text-danger-strong contrast-more:bg-tint-subtle;
}
@@ -0,0 +1,156 @@
'use client';
import React from 'react';
import { Icon } from '@gitbook/icons';
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuItem,
ToggleChevron,
} from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { AI_AGENTS, getAIAgent } from '@/lib/ai-agents';
import { tcls } from '@/lib/tailwind';
/** How long the copy button shows its confirmation. */
const COPIED_MESSAGE_DURATION = 1000;
/**
* Actions of a prompt block: copying the prompt, or handing it to a coding agent. The visitor's
* last pick becomes the main button, here and in every other prompt block they come across.
*/
export function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
const { prompt, openInAIProviders } = props;
const language = useLanguage();
const selectedAction = usePromptAction();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const copyPrompt = () => {
navigator.clipboard.writeText(prompt);
setCopied(true);
};
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
// actions are turned off — and with nothing to hand over, an agent link would open an agent on
// an empty prompt.
const action: PromptActionId = openInAIProviders && prompt ? selectedAction : 'copy';
const agent = action === 'copy' ? null : getAIAgent(action);
const mainButton = agent ? (
<Button
variant="secondary"
size="xsmall"
icon={agent.icon}
label={tString(language, 'open_in', agent.label)}
href={agent.getURL(prompt)}
// The OS picks the deep link up and the page stays put, where `_blank` would strand the
// visitor on a tab that never loads anything.
target="_self"
/>
) : (
<Button
variant="secondary"
size="xsmall"
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
disabled={!prompt}
onClick={copyPrompt}
/>
);
return (
// Lifted above the header's overlay button, which otherwise swallows the clicks.
<div className="relative z-20 flex shrink-0 items-center gap-2">
{openInAIProviders ? (
<ButtonGroup>
{mainButton}
<DropdownMenu
align="end"
className="!min-w-48 max-w-max"
button={
<Button
label={tString(language, 'more')}
size="xsmall"
variant="secondary"
truncate={false}
disabled={!prompt}
>
<span className="flex items-center gap-1.5">
<AgentIconStack />
<ToggleChevron className="size-text-sm" />
</span>
</Button>
}
>
<DropdownMenuItem
active={action === 'copy'}
leadingIcon="copy"
onClick={() => {
setPromptAction('copy');
copyPrompt();
}}
>
{tString(language, 'prompt_copy')}
</DropdownMenuItem>
{AI_AGENTS.map((item) => (
<DropdownMenuItem
key={item.id}
active={action === item.id}
leadingIcon={item.icon}
href={item.getURL(prompt)}
target="_self"
onClick={() => setPromptAction(item.id)}
>
{tString(language, 'open_in', item.label)}
</DropdownMenuItem>
))}
</DropdownMenu>
</ButtonGroup>
) : (
mainButton
)}
</div>
);
}
/**
* The agents on offer, overlapped into a stack, so the menu advertises what it holds without
* spelling out three names next to a button that already has one.
*/
function AgentIconStack() {
return (
<span className="flex items-center">
{AI_AGENTS.map((agent, index) => (
<span
key={agent.id}
className={tcls(
'flex size-4 shrink-0 items-center justify-center rounded-full border border-tint bg-tint-base',
// Each chip's border cuts into the one behind it, so the marks stay legible
// however tightly they are stacked.
index > 0 && '-ms-1.5'
)}
>
<Icon icon={agent.icon} className="size-2.5" />
</span>
))}
</span>
);
}
@@ -5,14 +5,11 @@ import React from 'react';
import type { DocumentBlockPrompt } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives';
import { getURLForLLM } from '@/components/utils';
import { PromptActions } from './PromptActions';
import { ToggleChevron } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
type PromptClientProps = DocumentBlockPrompt['data'] & {
contentIcon: IconName | null;
prompt: string;
@@ -88,106 +85,3 @@ export function PromptClient(props: PromptClientProps) {
</div>
);
}
function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
const { prompt, openInAIProviders } = props;
return (
<div className="relative z-20 flex shrink-0 items-center gap-2">
<CopyPromptButton prompt={prompt} />
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
</div>
);
}
// time in milliseconds to show the "Copied" message after copying a prompt
const COPIED_MESSAGE_DURATION = 1000;
function CopyPromptButton(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
return (
<Button
variant="primary"
size="xsmall"
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
disabled={!prompt}
onClick={() => {
navigator.clipboard.writeText(prompt);
setCopied(true);
}}
/>
);
}
function OpenPromptDropdown(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
return (
<DropdownMenu
align="end"
className="!min-w-48 max-w-max"
button={
<Button
label={tString(language, 'open_in_ai')}
trailing={<ToggleChevron className="size-text-sm" />}
size="xsmall"
variant="secondary"
className="max-sm:hidden"
disabled={!prompt}
/>
}
>
{OPEN_IN_AI_PROVIDERS.map((provider) => {
const definition = getPromptOpenActionDefinition(provider);
return (
<DropdownMenuItem
key={provider}
href={getURLForLLM(provider, prompt)}
target="_blank"
leadingIcon={definition.icon}
>
{tString(language, 'open_in', definition.label)}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
function getPromptOpenActionDefinition(action: AIProviders): { icon: IconName; label: string } {
switch (action) {
case 'cursor':
return {
icon: 'cursor',
label: 'Cursor',
};
case 'claude':
return {
icon: 'claude',
label: 'Claude',
};
case 'chatgpt':
return {
icon: 'chatgpt',
label: 'ChatGPT',
};
}
}
@@ -0,0 +1,95 @@
'use client';
import React from 'react';
import { type AIAgentId, isAIAgentId } from '@/lib/ai-agents';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
/**
* What a prompt block does when its main button is pressed: copy the prompt, or hand it to one of
* the coding agents.
*/
export type PromptActionId = 'copy' | AIAgentId;
/**
* Not namespaced per site: a visitor who works in Cursor works in Cursor everywhere, so the pick
* follows them across sites the same way the `select` store's slugs do.
*/
const STORAGE_KEY = '@gitbook/prompt-action';
const DEFAULT_ACTION: PromptActionId = 'copy';
let state: PromptActionId = DEFAULT_ACTION;
let loaded = false;
const listeners = new Set<() => void>();
function isPromptActionId(value: unknown): value is PromptActionId {
return value === 'copy' || isAIAgentId(value);
}
function read(): PromptActionId {
const stored = getLocalStorageItem<unknown>(STORAGE_KEY, DEFAULT_ACTION);
return isPromptActionId(stored) ? stored : DEFAULT_ACTION;
}
function emitChange() {
for (const listener of listeners) {
listener();
}
}
/**
* Adopt what's in storage, once per page load. Deferred to the first subscription rather than run at
* module scope so the first client render still matches the server's, and the stored pick only
* lands once React is listening.
*/
function load() {
if (loaded || typeof window === 'undefined') {
return;
}
loaded = true;
state = read();
// Another tab picking an action updates this one too.
window.addEventListener('storage', (event) => {
if (event.key !== null && event.key !== STORAGE_KEY) {
return;
}
state = read();
emitChange();
});
}
function subscribe(listener: () => void) {
load();
listeners.add(listener);
// Storage was only read just now, so nudge the subscriber to re-read the snapshot.
listener();
return () => {
listeners.delete(listener);
};
}
/**
* Remember the action the visitor picked, for every prompt block on the page and the next one.
*/
export function setPromptAction(action: PromptActionId) {
load();
if (state === action) {
return;
}
state = action;
setLocalStorageItem(STORAGE_KEY, action);
emitChange();
}
/**
* The action the visitor last picked, defaulting to copying the prompt.
*/
export function usePromptAction(): PromptActionId {
return React.useSyncExternalStore(
subscribe,
() => state,
() => DEFAULT_ACTION
);
}
@@ -15,7 +15,12 @@ import {
} from './search';
import { shouldShowTableSearch } from './shouldShowSearch';
import { StickyViewGrid } from './StickyViewGrid';
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
import {
TableSearchEmpty,
TableSearchInput,
TableSearchProvider,
TableSelectionFilter,
} from './TableSearch';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { tcls } from '@/lib/tailwind';
@@ -48,9 +53,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
searchOverride: block.data.search,
isPrint: context.mode === 'print',
});
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const selectColumns = getTableSelectColumns(block);
// Also needed when the search bar is hidden: a reader's content selection can narrow a select
// column from a tab or picker elsewhere on the page, and without records there is nothing to
// match against. A table with no select column can never be narrowed that way, so it skips.
const searchRecords =
showSearch || selectColumns.length > 0
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const cellMergeLayout = createTableCellMergeLayout(
block,
records.map(([recordId]) => recordId)
@@ -59,6 +69,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
return (
<TableSearchProvider
records={searchRecords}
selectColumns={selectColumns}
recordGroups={
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
}
@@ -66,10 +77,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
selectColumns={getTableSelectColumns(block)}
selectColumns={selectColumns}
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
{/* Sits under the filter control it relates to, and tight against the search bar,
so it reads as part of the filter controls rather than a caption on the table.
Standalone — cards, short grids — there is no control to sit under. */}
<TableSelectionFilter className={showSearch ? '-mt-1.5 justify-end' : undefined} />
<TableView
{...props}
isOffscreen={isOffscreen}
@@ -10,8 +10,16 @@ import {
type TableSearchRecordData,
getVisibleTableRecordIds,
} from './searchMatch';
import {
type SlugFilterEntry,
getAppliedSlugFilter,
reconcileSelectedOptions,
resolveSlugFilter,
slugFilterKey,
} from './slugFilter';
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { selectStore } from '@/lib/select';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
@@ -37,6 +45,13 @@ type TableSearchContextValue = {
visibleIds: ReadonlySet<string> | null;
/** True when there are records but the active filters match none of them. */
isEmpty: boolean;
/**
* Select columns the reader's content selection narrowed and the reader has left alone.
* A column they have since filtered themselves drops out, since the table no longer shows it.
*/
slugFilter: SlugFilterEntry[];
/** Drop the selection driving {@link slugFilter}, site-wide. The only thing that clears one. */
clearSlugFilter: () => void;
};
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
@@ -47,16 +62,32 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
export function TableSearchProvider(props: {
records?: TableSearchRecordData[];
recordGroups?: readonly (readonly string[])[];
/** Select columns of this table, so the reader's content selection can narrow them. */
selectColumns?: TableSelectColumn[];
children: React.ReactNode;
}) {
const { records = [], recordGroups = [] } = props;
const { records = [], recordGroups = [], selectColumns = [] } = props;
const [query, setQuery] = React.useState('');
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
() => new Set()
);
// The selection that drives this lives outside the table — a tab, a select button or a picker
// elsewhere on the page — so this synchronises with it rather than deriving from it.
const slugFilter = useSlugFilter(selectColumns);
// Columns the selection narrowed last time round. Kept so a new selection — or clearing it —
// undoes the previous one, rather than leaving a filter the reader can no longer account for.
const narrowedColumns = React.useRef<string[]>([]);
const toggleOption = React.useCallback((column: string, value: string) => {
// The reader is taking this column over, so the selection no longer owns it: a later clear
// must leave their choice of options alone. Their change stays local — the filter is this
// table's, for this visit, while the selection is site-wide and persists, so only the
// clear beside the notice touches it.
narrowedColumns.current = narrowedColumns.current.filter((narrowed) => narrowed !== column);
setSelectedOptions((previous) => {
const values = new Set(previous[column]);
if (values.has(value)) {
@@ -87,6 +118,33 @@ export function TableSearchProvider(props: {
});
}, []);
React.useEffect(() => {
const previouslyNarrowed = narrowedColumns.current;
narrowedColumns.current = slugFilter.map((entry) => entry.column);
setSelectedOptions((previous) =>
reconcileSelectedOptions(previous, previouslyNarrowed, slugFilter)
);
}, [slugFilter]);
// What the notice may speak for: the columns the selection narrowed and the reader has left
// alone. A column they have since filtered themselves still has an active slug, but the table
// is no longer showing it, so the notice must not claim it.
const appliedSlugFilter = React.useMemo(
() => getAppliedSlugFilter(slugFilter, selectedOptions),
[slugFilter, selectedOptions]
);
// Clearing goes through the store rather than local state: the selection is what persists, so
// only dropping it there stops the filter coming back on the next load. It is site-wide, so a
// tab elsewhere on the page reverts to its default too. This is the only thing that clears a
// selection — changing the filter never does.
const clearSlugFilter = React.useCallback(() => {
for (const entry of appliedSlugFilter) {
selectStore.deactivate(entry.slug);
}
}, [appliedSlugFilter]);
// Match every record once, here, rather than in each row — rows just look themselves up by id.
const visibleIds = React.useMemo(
() =>
@@ -112,8 +170,20 @@ export function TableSearchProvider(props: {
toggleCheckbox,
visibleIds,
isEmpty,
slugFilter: appliedSlugFilter,
clearSlugFilter,
}),
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
[
query,
selectedOptions,
toggleOption,
checkedColumns,
toggleCheckbox,
visibleIds,
isEmpty,
appliedSlugFilter,
clearSlugFilter,
]
);
return (
@@ -121,6 +191,40 @@ export function TableSearchProvider(props: {
);
}
/**
* The reader's selection, reduced to the columns of *this* table.
*
* Subscribes once and returns a string rather than an object: `useSyncExternalStore` compares
* snapshots by identity, so a fresh object each call would loop. It also means a selection that
* changes nothing for this table re-renders nothing — the reason `useSelect` stopped exposing the
* recency list in the first place.
*/
function useSlugFilter(selectColumns: TableSelectColumn[]): SlugFilterEntry[] {
const columnsKey = selectColumns
.map(
(column) =>
`${column.id}:${column.options.map((option) => `${option.value}=${option.label}`).join('|')}`
)
.join(';');
const getKey = React.useCallback(
() => slugFilterKey(resolveSlugFilter(selectColumns, selectStore.getState().slugs)),
// `selectColumns` is a fresh array each render; its contents are what matter.
// oxlint-disable-next-line react-hooks/exhaustive-deps
[columnsKey]
);
const filterKey = React.useSyncExternalStore(selectStore.subscribe, getKey, getKey);
// The key is only an identity: rebuild the filter itself when it moves, rather than parsing the
// key back apart, since an option value can be any string an import gave it.
return React.useMemo(
() => resolveSlugFilter(selectColumns, selectStore.getState().slugs),
// oxlint-disable-next-line react-hooks/exhaustive-deps
[filterKey]
);
}
function useTableSearch(): TableSearchContextValue {
const context = React.useContext(TableSearchContext);
if (!context) {
@@ -193,6 +297,49 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
);
}
/**
* Names the selection narrowing this table, and lets the reader drop it.
*
* Deliberately worded around the *selection* rather than the filter: the column dropdown beside it
* shows the same column as active, but clearing there only resets local state and the filter returns
* on the next load. This is the control that actually undoes it.
*
* Rendered independently of the search bar. `shouldShowTableSearch` leaves the filter controls off
* cards, off grids below the row threshold, and off any table whose author turned search off — and
* in every one of those a narrowed table would otherwise just read as missing rows.
*/
export function TableSelectionFilter(props: { className?: ClassValue }) {
const language = useLanguage();
const { slugFilter, clearSlugFilter } = useTableSearch();
if (slugFilter.length === 0) {
return null;
}
return (
<div
className={tcls('flex flex-wrap items-center gap-2 text-sm text-tint', props.className)}
>
<Icon icon="filter" className="size-3 shrink-0" />
<span>
{tString(
language,
'table_filtered_by_selection',
slugFilter.map((entry) => entry.label).join(', ')
)}
</span>
<Button
variant="blank"
size="xsmall"
icon="xmark"
iconOnly
label={tString(language, 'table_clear_selection')}
onClick={clearSlugFilter}
/>
</div>
);
}
/**
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
* of its options are selected.
@@ -226,11 +373,10 @@ function SelectFilterDropdown(props: { column: TableSelectColumn }) {
key={option.value}
active={selected}
leadingIcon={selected ? 'check' : undefined}
onSelect={(event) => {
// Keep the menu open so several options can be toggled at once.
event.preventDefault();
toggleOption(column.id, option.value);
}}
// `closeOnClick={false}` keeps the menu open so several options can be
// toggled at once.
closeOnClick={false}
onClick={() => toggleOption(column.id, option.value)}
>
{option.label || option.value}
</DropdownMenuItem>
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'bun:test';
import type { TableSelectColumn } from './search';
import {
getAppliedSlugFilter,
getOptionSlug,
reconcileSelectedOptions,
resolveSlugFilter,
slugFilterKey,
} from './slugFilter';
/**
* A select column as the editor writes one: each option carries an opaque generated `value` and the
* author's wording as its `label`. Tests must not conflate the two — matching on `value` looks
* right against fixtures that reuse the label and matches nothing against real content.
*/
function column(id: string, labels: string[]): TableSelectColumn {
return {
id,
label: id,
options: labels.map((label, index) => ({
value: `${id}-key-${index}`,
label,
color: 'blue',
})),
};
}
/** The opaque value of the option an author labelled `label`. */
function valueOf(column: TableSelectColumn, label: string): string {
const option = column.options.find((option) => option.label === label);
if (!option) {
throw new Error(`no option labelled ${label}`);
}
return option.value;
}
describe('resolveSlugFilter', () => {
const platform = column('platform', ['macOS', 'Windows', 'Linux']);
const status = column('status', ['Done', 'To do']);
it('matches the option label, and resolves to its opaque value', () => {
expect(resolveSlugFilter([platform], ['macos'])).toEqual([
{
column: 'platform',
value: valueOf(platform, 'macOS'),
label: 'macOS',
slug: 'macos',
},
]);
});
it('carries the label and slug needed to show and clear the filter', () => {
// The reader is told what narrowed the table, and the slug is what clearing deactivates.
const [entry] = resolveSlugFilter([platform], ['windows']);
expect(entry?.label).toBe('Windows');
expect(entry?.slug).toBe('windows');
});
it('never matches the opaque value itself', () => {
expect(resolveSlugFilter([platform], [valueOf(platform, 'macOS')])).toEqual([]);
});
it('slugifies the label the way every other select surface does', () => {
const languages = column('language', ['Node.js', 'C++', 'Windows 10']);
expect(resolveSlugFilter([languages], ['node.js'])[0]?.label).toBe('Node.js');
expect(resolveSlugFilter([languages], ['c++'])[0]?.label).toBe('C++');
expect(resolveSlugFilter([languages], ['windows-10'])[0]?.label).toBe('Windows 10');
});
it('falls back to the value when an option has no label', () => {
const legacy: TableSelectColumn = {
id: 'legacy',
label: 'legacy',
options: [{ value: 'macos', label: '', color: 'blue' }],
};
expect(resolveSlugFilter([legacy], ['macos'])).toEqual([
{ column: 'legacy', value: 'macos', label: 'macos', slug: 'macos' },
]);
});
it('leaves a column the selection says nothing about', () => {
const entries = resolveSlugFilter([platform, status], ['macos']);
expect(entries.map((entry) => entry.column)).toEqual(['platform']);
});
it('narrows several columns at once when the selection covers both', () => {
const entries = resolveSlugFilter([platform, status], ['macos', 'done']);
expect(entries.map((entry) => entry.value)).toEqual([
valueOf(platform, 'macOS'),
valueOf(status, 'Done'),
]);
});
it('takes the most recently activated when a column offers several active options', () => {
// Most-recent-first, so `windows` wins over `macos` — the rule tabs resolve with.
expect(resolveSlugFilter([platform], ['windows', 'macos'])[0]?.value).toBe(
valueOf(platform, 'Windows')
);
expect(resolveSlugFilter([platform], ['macos', 'windows'])[0]?.value).toBe(
valueOf(platform, 'macOS')
);
});
it('filters nothing when the selection matches no column', () => {
expect(resolveSlugFilter([platform, status], ['python'])).toEqual([]);
expect(resolveSlugFilter([platform], [])).toEqual([]);
expect(resolveSlugFilter([], ['macos'])).toEqual([]);
});
});
describe('getOptionSlug', () => {
// Shared by the matcher and by the write-back that moves the selection when a reader changes a
// governed column, so the two can never disagree about what an option answers to.
it('slugifies the label, and falls back to the value', () => {
expect(getOptionSlug({ value: 'key-0', label: 'macOS' })).toBe('macos');
expect(getOptionSlug({ value: 'key-1', label: 'Windows 10' })).toBe('windows-10');
expect(getOptionSlug({ value: 'macos', label: '' })).toBe('macos');
});
});
describe('reconcileSelectedOptions', () => {
const entry = (column: string, value: string) => ({
column,
value,
label: value,
slug: value,
});
it('drops the column when the selection is cleared', () => {
const previous = { platform: new Set(['macos-value']) };
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({});
});
it('leaves filters the reader set themselves', () => {
const previous = {
platform: new Set(['macos-value']),
status: new Set(['done-value']),
};
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({
status: new Set(['done-value']),
});
});
it('replaces the previous selection rather than adding to it', () => {
const previous = { platform: new Set(['macos-value']) };
expect(
reconcileSelectedOptions(previous, ['platform'], [entry('platform', 'windows-value')])
).toEqual({ platform: new Set(['windows-value']) });
});
it('gives the same answer however many times it is applied', () => {
// React may invoke a state updater more than once with the same input. An earlier version
// tracked the narrowed columns inside the updater, so the second pass saw them already
// cleared, took the early return and handed back the *unchanged* state — silently undoing
// a clear while leaving an apply working, which is exactly how it presented.
const previous = { platform: new Set(['macos-value']) };
const once = reconcileSelectedOptions(previous, ['platform'], []);
const twice = reconcileSelectedOptions(previous, ['platform'], []);
expect(twice).toEqual(once);
});
it('is a no-op when there is nothing to narrow and nothing to undo', () => {
const previous = { status: new Set(['done-value']) };
expect(reconcileSelectedOptions(previous, [], [])).toBe(previous);
});
});
describe('getAppliedSlugFilter', () => {
const macos = { column: 'platform', value: 'macos-value', label: 'macOS', slug: 'macos' };
it('speaks for a column the reader has left alone', () => {
const selected = { platform: new Set(['macos-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([macos]);
});
it('drops a column the reader has filtered to something else', () => {
// Changing the filter deliberately leaves the selection active, so the slug is still there;
// the notice just must not claim a match the table is no longer showing.
const selected = { platform: new Set(['windows-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
});
it('drops a column the reader has widened to several options', () => {
const selected = { platform: new Set(['macos-value', 'windows-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
});
it('drops a column the reader has cleared', () => {
expect(getAppliedSlugFilter([macos], {})).toEqual([]);
});
});
describe('slugFilterKey', () => {
const entry = (column: string, value: string) => ({
column,
value,
label: value,
slug: value,
});
it('is stable whatever order the columns resolve in', () => {
expect(slugFilterKey([entry('platform', 'macos'), entry('status', 'done')])).toBe(
slugFilterKey([entry('status', 'done'), entry('platform', 'macos')])
);
});
it('changes when the selection moves', () => {
expect(slugFilterKey([entry('platform', 'macos')])).not.toBe(
slugFilterKey([entry('platform', 'linux')])
);
});
it('is empty when nothing is filtered', () => {
expect(slugFilterKey([])).toBe('');
});
});
@@ -0,0 +1,127 @@
import type { TableSelectColumn } from './search';
import type { SelectedOptions } from './searchMatch';
import { slugifySelectValue } from '@/lib/select';
/** One select column narrowed by the reader's content selection. */
export interface SlugFilterEntry {
/** Id of the select column being narrowed. */
column: string;
/** The option's opaque value, which the record matcher compares against. */
value: string;
/** The option's author-typed label, shown to the reader. */
label: string;
/** The active slug that narrowed the column, so the reader can clear it again. */
slug: string;
}
/**
* The `select` slug an option answers to.
*
* A table option's `value` is an opaque generated key, so it is the author-typed `label` that names
* it — the same wording a tab title or select button would carry, put through the same slugifier so
* a "macOS" column option and a "macOS" tab resolve to the one slug. Options with no label fall back
* to the raw value, mirroring how a cell renders one.
*/
export function getOptionSlug(option: { value: string; label: string }): string {
return slugifySelectValue(option.label || option.value);
}
/**
* Work out which option of each select column the reader's current selection points at.
*
* A table's select column declares its own option set, so a slug only filters a column that
* actually offers it: a reader who picked `macos` narrows a Platform column to macOS and leaves a
* Status column alone. Where several of a column's options are active at once, the most recently
* activated wins — the same rule tabs resolve with, so a table and the tabs beside it agree.
*/
export function resolveSlugFilter(
columns: TableSelectColumn[],
slugs: string[]
): SlugFilterEntry[] {
const entries: SlugFilterEntry[] = [];
for (const column of columns) {
let best: SlugFilterEntry | undefined;
let bestRank = Number.POSITIVE_INFINITY;
for (const option of column.options) {
const slug = getOptionSlug(option);
if (!slug) {
continue;
}
const rank = slugs.indexOf(slug);
if (rank >= 0 && rank < bestRank) {
bestRank = rank;
best = {
column: column.id,
value: option.value,
label: option.label || option.value,
slug,
};
}
}
if (best) {
entries.push(best);
}
}
return entries;
}
/** Stable identity for a filter, so it is only rebuilt when the selection actually moves. */
export function slugFilterKey(entries: SlugFilterEntry[]): string {
return entries
.map((entry) => `${entry.column}=${entry.value}`)
.sort()
.join(',');
}
/**
* Fold the columns the selection narrows into the reader's own filters.
*
* Kept pure, and given the previously narrowed columns rather than reading them from a ref, so it
* can be applied more than once without changing the answer — React may invoke a state updater
* twice, and an earlier version tracked those columns inside the updater itself, which silently
* undid a clear on the second pass.
*
* Only columns the selection narrowed last time are dropped; anything the reader filtered by hand
* is left exactly as it was.
*/
export function reconcileSelectedOptions(
previous: SelectedOptions,
previouslyNarrowed: readonly string[],
slugFilter: readonly SlugFilterEntry[]
): SelectedOptions {
if (previouslyNarrowed.length === 0 && slugFilter.length === 0) {
return previous;
}
const next = { ...previous };
for (const column of previouslyNarrowed) {
delete next[column];
}
for (const entry of slugFilter) {
next[entry.column] = new Set([entry.value]);
}
return next;
}
/**
* Narrow the filter to the columns the table is *still* showing.
*
* A reader can change any of these columns by hand, and that deliberately leaves the selection
* alone — the filter is this table's, for this visit, while the selection is site-wide and
* persists. So the notice has to stop speaking for a column that no longer matches, rather than
* describing a view the reader has since changed.
*/
export function getAppliedSlugFilter(
slugFilter: readonly SlugFilterEntry[],
selectedOptions: SelectedOptions
): SlugFilterEntry[] {
return slugFilter.filter((entry) => {
const values = selectedOptions[entry.column];
return values?.size === 1 && values.has(entry.value);
});
}
@@ -5,7 +5,12 @@ import { validateIconName } from '@gitbook/icons/icons';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { DynamicTabs } from './DynamicTabs';
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
import {
generateSelectCSS,
resolveSelectSlug,
selectSetClassName,
slugifySelectValue,
} from '@/lib/select';
import { tcls } from '@/lib/tailwind';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
@@ -26,6 +31,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
return {
id: tab.meta?.id ?? tab.key,
title: tab.data.title ?? '',
slug: tab.data.slug,
icon,
body: (
<Blocks
@@ -94,19 +100,20 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) {
}
/**
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
* so they stay selectable.
* Resolve a `select` slug for each tab: its explicit slug when set, else one derived from its title.
* Untitled tabs fall back to their (stable) id so they stay selectable.
*
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
* with a positional suffix: that would desync the duplicate and make a stored selection retarget
* whenever tabs are renamed or reordered.
* whenever tabs are renamed or reordered. An explicit slug is the way out for a tab that needs to
* keep its identity across a rename.
*/
function withSelectSlugs<T extends { id: string; title: string }>(
function withSelectSlugs<T extends { id: string; title: string; slug?: string }>(
items: T[]
): (T & { slug: string })[] {
return items.map((item) => ({
...item,
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
slug: resolveSelectSlug(item) || slugifySelectValue(item.id) || item.id,
}));
}
@@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons';
import { TagIcon } from '../Tag';
import { Button } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_ATTR,
UPDATES_TAG_CHIP_SELECTED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR,
UPDATES_TAG_CLEAR_ATTR,
UPDATES_TAG_FILTER_ATTR,
normalizeUpdatesFilterTags,
updatesFilterStyleKey,
} from '@/lib/updates';
type UpdatesFilterContextValue = {
selectedTags: string[];
@@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
const sanitizeTags = React.useCallback(
(tags: string[]) => {
const next: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
if (!availableTags.has(tag) || seen.has(tag)) {
continue;
}
next.push(tag);
seen.add(tag);
}
return next;
},
(tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags),
[availableTags]
);
@@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
() => sanitizeTags(rawSelectedTags),
[sanitizeTags, rawSelectedTags]
);
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const selectedTagsRef = React.useRef(selectedTags);
const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]);
// Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale
// stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR).
React.useLayoutEffect(() => {
document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey);
return () => {
document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR);
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
};
}, [styleKey]);
const replaceTags = React.useCallback(
(nextTags: string[]) => {
@@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
}
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
React.useEffect(() => {
// Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI.
React.useLayoutEffect(() => {
applyTagFilterAttribute(urlSelectedTags);
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
return;
}
@@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
selectedTagsRef.current = nextTags;
setSelectedTags(nextTags);
applyTagFilterAttribute(nextTags);
replaceTags(nextTags);
},
[replaceTags, sanitizeTags]
@@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: {
clearLabel: string;
}) {
const { tags, tagsLabel, clearLabel } = props;
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
const isFiltering = selectedTags.length > 0;
const { toggleTag, clearTags } = useUpdatesFilter();
if (tags.length === 0) {
return null;
@@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: {
<Icon icon="tags" className="size-3" />
{tagsLabel}
</div>
{/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */}
<Button
variant="blank"
size="xsmall"
icon="xmark"
label={clearLabel}
onClick={isFiltering ? clearTags : undefined}
aria-hidden={!isFiltering}
tabIndex={isFiltering ? undefined : -1}
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
onClick={clearTags}
{...{ [UPDATES_TAG_CLEAR_ATTR]: '' }}
className="pointer-events-none invisible text-xs"
/>
</div>
<div className="flex flex-wrap gap-1.5 px-3">
{tags.map((tag) => {
const selected = selectedTagSet.has(tag.slug);
return (
<button
key={tag.slug}
type="button"
aria-pressed={selected}
onClick={() => toggleTag(tag.slug)}
className={tcls(
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
'circular-corners:rounded-2xl straight-corners:rounded-xs',
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
selected
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
isFiltering && !selected && 'opacity-8 hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</button>
);
})}
{tags.map((tag) => (
<TagChip key={tag.slug} tag={tag} onToggle={toggleTag} />
))}
</div>
</div>
);
}
const CHIP_CLASS =
'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary';
const CHIP_VARIANT_CLASS =
'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs';
function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) {
const { tag, onToggle } = props;
const { selectedTagSet } = useUpdatesFilter();
const onClick = () => onToggle(tag.slug);
return (
<button
type="button"
aria-pressed={selectedTagSet.has(tag.slug)}
onClick={onClick}
className={CHIP_CLASS}
>
<span
{...{ [UPDATES_TAG_CHIP_SELECTED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-tint-5 text-tint-strong opacity-8 hover:bg-tint-hover hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'flex bg-tint-5 text-tint-strong hover:bg-tint-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
</button>
);
}
function areTagsEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index]);
}
/** Mirrors the active filter onto `<html>`, matching what the pre-paint script does on first load. */
function applyTagFilterAttribute(tags: string[]) {
if (typeof document === 'undefined') {
return;
}
if (tags.length > 0) {
document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' '));
} else {
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
}
}
/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */
export function FilteredUpdate(props: {
tagSlugs: string[];
className?: string;
children: React.ReactNode;
}) {
const { tagSlugs, className, children } = props;
const { selectedTagSet } = useUpdatesFilter();
const isVisible =
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
return (
<div className={className} hidden={!isVisible}>
<div className={className} {...{ [UPDATES_TAG_ATTR]: tagSlugs.join(' ') }}>
{children}
</div>
);
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'bun:test';
import { serializeUpdatesFilterScriptArgs } from './UpdatesFilterScript';
describe('serializeUpdatesFilterScriptArgs', () => {
it('cannot terminate the surrounding script element', () => {
const serialized = serializeUpdatesFilterScriptArgs([
['</script><script>globalThis.injected = true</script>'],
]);
expect(serialized).not.toContain('</script>');
expect(serialized).toContain('\\u003c/script>');
});
});
@@ -0,0 +1,95 @@
'use client';
import { useServerInsertedHTML } from 'next/navigation';
import { useRef } from 'react';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
updatesFilterStyleKey,
} from '@/lib/updates';
/**
* Mirrors the `?tag=` search params onto `<html>` as `data-updates-tag-filter`, before first paint,
* so the generated CSS can filter with no flash. Also stamps the filter style key.
*
* NOTE: stringified and injected as an inline script — must be self-contained, touching only
* `document.documentElement` and `window.location`.
*/
export function applyUpdatesFilterScript(
searchParam: string,
attribute: string,
cap: number,
availableTags: string[],
keyAttribute: string,
styleKey: string
) {
const el = document.documentElement;
el.setAttribute(keyAttribute, styleKey);
try {
const validTags = new Set(availableTags);
const params = new URLSearchParams(window.location.search);
const seen = new Set<string>();
const slugs: string[] = [];
for (const value of params.getAll(searchParam)) {
const slug = value.trim();
if (!slug || seen.has(slug) || slugs.length >= cap || !validTags.has(slug)) {
continue;
}
seen.add(slug);
slugs.push(slug);
}
if (slugs.length > 0) {
el.setAttribute(attribute, slugs.join(' '));
} else {
el.removeAttribute(attribute);
}
} catch {
// Malformed URL — fall through, so the generated CSS's default (show everything) applies.
}
}
/**
* Inline script that applies the URL's `?tag=` filter to `<html>` before first paint. Rendered only
* on pages with filterable updates (see SitePage), not globally.
*/
export function UpdatesFilterScript(props: { tagSlugs: string[] }) {
const { tagSlugs } = props;
const inserted = useRef(false);
const scriptArgs = serializeUpdatesFilterScriptArgs([
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
tagSlugs,
UPDATES_FILTER_KEY_ATTR,
updatesFilterStyleKey(tagSlugs),
]);
// Only needed for the initial document — client navs are handled by UpdatesFilterProvider.
useServerInsertedHTML(() => {
if (inserted.current) {
return null;
}
inserted.current = true;
return (
<script
dangerouslySetInnerHTML={{
__html: `(${applyUpdatesFilterScript.toString()})(${scriptArgs})`,
}}
/>
);
});
return null;
}
/** Serialize arguments without allowing revision data to terminate the surrounding script tag. */
export function serializeUpdatesFilterScriptArgs(args: unknown[]): string {
return JSON.stringify(args).replaceAll('<', '\\u003c').slice(1, -1);
}
@@ -34,7 +34,7 @@ export function SpacesDropdown(
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
path: siteSp.path,
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
@@ -46,7 +46,6 @@ export function SpacesDropdown(
className={className}
dropdownClassName={dropdownClassName}
slimSpaces={slimSpaces}
siteSpaceId={siteSpace.id}
curPath={siteSpace.path}
/>
);
@@ -2,10 +2,9 @@
import type { IconName } from '@gitbook/icons';
import { useSelectedSiteSpaceId } from '../hooks';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems, type VariantSpace } from './SpacesDropdownMenuItem';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
@@ -18,16 +17,16 @@ export function SpacesDropdownClient(props: {
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: VariantSpace[];
/** Site space the server rendered as selected, used as a fallback. */
siteSpaceId: string;
slimSpaces: {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}[];
curPath: string;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, siteSpaceId, curPath } =
props;
const selectedId = useSelectedSiteSpaceId(siteSpaceId);
const selected = selectedId ? slimSpaces.find((space) => space.id === selectedId) : undefined;
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
return (
<DropdownMenu
@@ -41,15 +40,11 @@ export function SpacesDropdownClient(props: {
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{selected?.title ?? title}</span>
<span className="button-content">{title}</span>
</Button>
}
>
<SpacesDropdownMenuItems
slimSpaces={slimSpaces}
selectedId={selectedId}
curPath={selected?.path ?? curPath}
/>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
</DropdownMenu>
);
}
@@ -8,7 +8,7 @@ export interface VariantSpace {
id: string;
title: string;
url: string;
path: string;
isActive: boolean;
spaceId: string;
}
@@ -66,12 +66,8 @@ export function SpacesDropdownMenuItem(props: {
);
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
selectedId: string | null;
curPath: string;
}) {
const { slimSpaces, selectedId, curPath } = props;
export function SpacesDropdownMenuItems(props: { slimSpaces: VariantSpace[]; curPath: string }) {
const { slimSpaces, curPath } = props;
return (
<>
@@ -79,7 +75,7 @@ export function SpacesDropdownMenuItems(props: {
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.id === selectedId}
active={space.isActive}
currentSpacePath={curPath}
/>
))}
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
@@ -75,12 +76,15 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -154,6 +159,9 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -161,6 +169,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -214,6 +223,18 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
}
/**
* Post the events to the server.
*/
@@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -258,6 +280,11 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -1,5 +1,6 @@
'use client';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import type { SiteInsightsDisplayContext } from '@gitbook/api';
@@ -7,6 +8,7 @@ import type { SiteInsightsDisplayContext } from '@gitbook/api';
import { useCurrentPage } from '../hooks';
import { useIsVisible } from '../VisibilityContext';
import { useTrackEvent } from './InsightsProvider';
import { isGitBookInternalPath } from '@/lib/paths';
/**
* Track a page view event.
@@ -15,11 +17,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
const { displayContext } = props;
const page = useCurrentPage();
const trackEvent = useTrackEvent();
const pathname = usePathname();
// Internal routes that 404 (e.g. `~gitbook/pdf` under an unpublished variant) aren't broken URLs.
const isInternalNotFound = !page && isGitBookInternalPath(pathname ?? '');
// Always true outside of the embed, whose frame can be loaded while hidden.
const isVisible = useIsVisible();
React.useEffect(() => {
if (!isVisible) {
if (!isVisible || isInternalNotFound) {
return;
}
@@ -32,7 +37,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
displayContext,
}
);
}, [page, trackEvent, displayContext, isVisible]);
}, [page, trackEvent, displayContext, isVisible, isInternalNotFound]);
return null;
}
@@ -9,6 +9,7 @@ import { useScrollActiveId } from '@/components/hooks';
import { useBodyLoaded } from '@/components/primitives';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { UPDATES_TAG_SECTION_ATTR } from '@/lib/updates';
/**
* The threshold at which we consider a section as intersecting the viewport.
@@ -22,20 +23,18 @@ const ACTIVE_ITEM_OFFSET = 100;
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
const { selectedTagSet } = useUpdatesFilter();
const visibleSections = React.useMemo(() => {
if (selectedTagSet.size === 0) {
return sections;
}
return sections.filter((section) => {
if (section.tags === undefined) {
return true;
}
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
});
}, [sections, selectedTagSet]);
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
const ids = React.useMemo(
() =>
sections
.filter(
(section) =>
selectedTagSet.size === 0 ||
section.tags === undefined ||
section.tags.some((tagSlug) => selectedTagSet.has(tagSlug))
)
.map(({ id }) => id),
[sections, selectedTagSet]
);
const enabled = useBodyLoaded();
@@ -62,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
className="relative flex flex-col border-tint-subtle pb-5 sidebar-list-line:border-l"
ref={scrollContainerRef}
>
{visibleSections.map((section) => (
{sections.map((section) => (
<li
key={section.id}
className={tcls(
@@ -76,6 +75,9 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
ref={activeId === section.id ? activeItemRef : null}
{...(section.tags !== undefined
? { [UPDATES_TAG_SECTION_ATTR]: section.tags.join(' ') }
: {})}
>
<a
href={`#${section.id}`}

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