Compare commits

..

62 Commits

Author SHA1 Message Date
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
Tomek 1f250d1fa8 Add site structure e2e tests for external links (#4584) 2026-09-07 09:31:50 +02:00
Brett Jephson 46b3a18b25 Fix nested select css (#4583)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:20:52 +01:00
Nolann B. 2ff77e15b5 Attribute a markdown request from the page actions menu (#4581) 2026-09-04 12:17:00 +02:00
Tomek d7c867aa63 Fix MCP/Markdown copy confirmation closing dropdown instantly (#4579)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:08:53 +02:00
Tomek bcd1c41aa1 Scroll to top when selecting current page in search (#4578)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-03 11:35:07 +02:00
Viktor Renkema 77058e3f17 Support merged cells (#4500)
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-09-03 10:26:43 +02:00
Tomek 8ad7465801 Simplify carousel overflow with edge masks & fix carousel scroll (#4577)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-02 14:32:53 +00:00
arnau 1b7ac0e41b Open inline Ask AI questions in the configured assistant (#4574) 2026-09-02 12:05:37 +02:00
Tomek 5f88f4e680 Improve cross-space page breadcrumbs (#4571)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-02 11:43:35 +02:00
Peter White 7009bb9311 Drop the body-anchored cover gate from paragraph contrast text (#4573) 2026-09-02 11:24:54 +02:00
Johan Preynat e460ec381e Preserve linked page destinations in published search (#4566) 2026-09-01 15:06:26 +02:00
Johan Preynat 0a82628298 Trap keyboard focus within published search (#4567) 2026-09-01 13:12:01 +02:00
conico974 18ff11b68b Bump @opennextjs/aws and @opennextjs/cloudflare versions (#4570) 2026-09-01 12:14:55 +02:00
Peter White 3bf55cef10 Allow adding query params to the @webframe.navigate action (#4569) 2026-09-01 10:14:07 +02:00
Peter White e041e49f11 Wait for integrations to register before showing the cookie banner (#4568) 2026-09-01 09:09:15 +02:00
Johan Preynat c1b5be05eb Allow search to target any section (#4564) 2026-08-31 18:32:02 +02:00
Johan Preynat 7543eb83ad Prewarm Turbopuffer when published search opens (#4563) 2026-08-31 13:32:47 +00:00
Tomek 523b7cd4b6 Support external links (#4561)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-31 13:11:13 +02:00
spastorelli af698a49f5 Extend MCP tests (#4562) 2026-08-31 11:59:46 +02:00
conico974 db67585ee2 Remove MCP authentication path from internal paths registry (#4560) 2026-08-28 14:57:21 +02:00
Peter White a566153d98 Chunk oversized visitor authentication cookies (#4554) 2026-08-28 12:28:53 +02:00
conico974 530f98a9c4 Skip internal paths in URL lookup (#4557) 2026-08-28 12:24:29 +02:00
Tomek 39391259c8 Disable zoom for inline images (#4555) 2026-08-28 11:32:19 +02:00
Nolann B. 35be3835f3 Skip rendering work for off-screen OpenAPI blocks (#4502) 2026-08-27 18:41:33 +02:00
Tomek 4452fd793c Fix link title showing site section name (4542) (#4553)
Co-authored-by: Kai Kewley <kaikewley@gmail.com>
2026-08-27 09:57:32 +02:00
conico974 90566879d2 Improve image signature handling and resizing functionality (#4511) 2026-08-26 15:55:57 +02:00
Zeno Kapitein 40a879ad5f Keep section group dropdown columns readable when space is tight (#4549)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:05:28 +02:00
Johan Preynat cc441c99c6 Fix published search scope fusion (#4552) 2026-08-26 14:22:33 +02:00
conico974 64e143a15b Bump Next.js to version 16.3.3 (#4547) 2026-08-26 12:32:56 +02:00
Peter White 8c10d92f77 Keep the section group menu open over the collapsed sidebar rail (#4548) 2026-08-26 12:16:13 +02:00
Zeno Kapitein 0a22eb5340 Stop expecting a cookies dialog in the faq.wanttopay.net e2e test (#4550)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:54:31 +02:00
281 changed files with 10878 additions and 1961 deletions
@@ -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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
+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
---
Bump Next.js to 16.3.3.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix select filters not working on table and cards blocks.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Chunk oversized visitor auth cookies to fix an infinite redirect loop when the visitor token exceeds the browser cookie size limit.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix inline Ask AI buttons opening a configured custom assistant.
@@ -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
---
Stop the built-in cookie banner from flashing on sites using a consent integration such as Osano or OneTrust.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix breadcrumbs for cross-space page links in grouped sites.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Disable click-to-zoom for inline line sized images.
+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": minor
---
Support external links in published site navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Skip internal paths (`~gitbook/*`, `.well-known/oauth-protected-resource`, `llms.txt`, `robots.txt`, `sitemap.xml`, `rss.xml`) when building URL lookup alternatives, to avoid resolving URLs that can never match content.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep published search results within the selected site-space scope during local and remote result fusion.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix page link titles showing the site section name instead of the target space title.
+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
---
Keep embed demos and frames on the deployment that served them.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Drop a column instead of shrinking them all when a section group dropdown runs out of room
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Skip the rendering work for off-screen OpenAPI blocks, so pages with many operations stay smooth to scroll.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve external page destinations returned by published search.
+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
---
Prewarm published search caches when readers open the search interface.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render horizontal and vertical merged table cells on published pages.
+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
---
Scroll to the top when selecting a search result for the page already being viewed.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add end-to-end coverage for root and nested external links in site section navigation.
@@ -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
---
Allow published search to be scoped to any visible site section.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep keyboard focus within search while the results are open.
+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
---
Fix the section group menu dismissing while the mouse is still inside it, when the collapsed sidebar rail overlaps the menu on no-sidebar pages.
+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.
+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
---
Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
+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.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/react-contentkit": patch
---
Support query params in the `@webframe.navigate` action.
+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.
@@ -69,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
@@ -75,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
+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/
+132 -114
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.0",
"@opennextjs/cloudflare": "1.20.2",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -160,7 +160,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.11",
"next": "^16.3.3",
"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:",
@@ -340,11 +341,12 @@
},
},
"patchedDependencies": {
"next@16.2.11": "patches/next@16.2.11.patch",
"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 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.196.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -650,7 +652,7 @@
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
@@ -726,7 +728,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.196.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-14f3LXiZljPuxFtYbsdH9mkXN4jucRBnF3QpSACuACDdvJHpTqGsCe52chg3nj72XBbMjD57BsXGklY+IYz9gw=="],
"@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"],
@@ -776,59 +778,59 @@
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@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-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@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-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@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-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@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-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@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-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@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-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@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-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@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-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@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-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@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-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@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-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@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-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@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-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@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-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@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-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@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-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-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@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 +892,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.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="],
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg=="],
"@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-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="],
"@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-musl": ["@next/swc-linux-x64-musl@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g=="],
"@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-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -942,9 +944,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.0", "", { "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.21 <16 || >=16.2.11" }, "bin": { "open-next": "dist/index.js" } }, "sha512-GuKkdUbnJhLvtwTiJlytNtWIcSYN3+Dzi+OusUlRgf+Ur7dGoXdzBopd+VFiSdTBduPOvr9lPFDg6BYNjEV3wQ=="],
"@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.2", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.0", "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.21 <16 || >=16.2.11", "rclone.js": "^0.6.6", "wrangler": "^4.86.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-iFBjABnaDk3be27F5EpxyMLMGPbVnnArFx5I3Y8Rf6BSx5nBV8h0UuJiMKrx3+whDU5ahIy4d8sfbvWvMiF1Kg=="],
"@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=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1314,7 +1316,7 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.7", "", {}, "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="],
@@ -1680,7 +1682,7 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="],
@@ -1736,7 +1738,7 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001668", "", {}, "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -2648,7 +2650,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "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-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="],
"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-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=="],
@@ -2796,7 +2798,7 @@
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -2968,7 +2970,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"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=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -3326,8 +3328,6 @@
"@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=="],
@@ -3420,8 +3420,14 @@
"@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-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=="],
"@internationalized/date/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@internationalized/number/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
@@ -3470,8 +3476,6 @@
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@opennextjs/aws/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -3482,6 +3486,18 @@
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@react-aria/focus/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@react-aria/interactions/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@react-aria/ssr/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@react-aria/utils/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@react-stately/flags/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@react-stately/utils/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@redis/client/generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
"@redis/client/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -3702,10 +3718,6 @@
"brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="],
"call-bound/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -3744,7 +3756,7 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"doiuse/caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"doiuse/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"doiuse/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -3830,14 +3842,14 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"multimatch/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"parse5/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -3850,7 +3862,7 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
@@ -3892,6 +3904,8 @@
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"side-channel-list/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"side-channel-map/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -3908,6 +3922,8 @@
"stylelint/postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"stylelint-no-unsupported-browser-features/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"swr/use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="],
@@ -3940,58 +3956,6 @@
"@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"@argos-ci/core/sharp/@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@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-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-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=="],
@@ -4078,6 +4042,8 @@
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"@playwright/test/playwright/playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
@@ -4288,6 +4254,8 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"doiuse/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"doiuse/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"doiuse/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
@@ -4308,9 +4276,55 @@
"micro/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"next/postcss/nanoid": ["nanoid@3.3.7", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="],
"miniflare/sharp/@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"next/postcss/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"miniflare/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"pino-abstract-transport/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@@ -4350,6 +4364,8 @@
"side-channel-weakmap/get-intrinsic/has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"stylelint-no-unsupported-browser-features/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"stylelint/postcss/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
"type-is/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
@@ -4472,6 +4488,8 @@
"@vercel/backends/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@vercel/backends/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@zenuml/core/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+4 -3
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.196.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,6 +68,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.11": "patches/next@16.2.11.patch"
"next@16.3.3": "patches/next@16.3.3.patch"
}
}
+367 -3
View File
@@ -10,6 +10,7 @@ import {
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationPageActionType,
CustomizationSidebarListStyle,
SiteSocialAccountPlatform,
} from '@gitbook/api';
@@ -157,6 +158,40 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeFocused();
},
},
{
name: 'Search - Keyboard focus exits to the next page control',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await searchInput.fill('gitbook');
const searchPopup = page.getByTestId('search-popover');
await expect(searchPopup).toBeVisible({ timeout: 10_000 });
const finalPopupControl = searchPopup
.locator(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
.filter({ visible: true })
.last();
const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first();
await expect(finalPopupControl).toBeVisible();
await expect(nextPageControl).toBeVisible();
await finalPopupControl.focus();
await page.keyboard.press('Tab');
await expect(searchPopup).toBeHidden();
await expect(nextPageControl).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(searchInput).toBeFocused();
},
},
{
// `fill()` bypasses key events, so it can't catch a swallowed key. RND-12484.
name: 'Search - AI Mode: None - Typing multi-word queries',
@@ -214,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({
@@ -482,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',
@@ -504,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')
);
},
},
@@ -676,6 +769,150 @@ const testCases: TestsCase[] = [
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
},
{
name: 'Root external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootSections = page.locator('[data-gb-sections]');
const rootItems = rootSections.locator(':scope > li');
await expect(rootItems).toHaveCount(4);
await expect(rootItems.nth(0)).toContainText('Home');
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
await expect(rootItems.last()).toContainText('Gitbook Docs');
await expect(
rootSections.getByRole('link', { name: 'Gitbook Docs' })
).toBeVisible();
},
},
{
name: 'Root external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootLink = page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' });
await expect(rootLink).toBeVisible();
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
await expect(rootLink).not.toHaveAttribute('target');
await expect(rootLink).not.toHaveAttribute('rel');
await expect(rootLink).toHaveAttribute('data-active', 'false');
await expect(rootLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'Nested external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
const nestedItems = nestedLink
.locator('xpath=ancestor::ul[1]')
.locator(':scope > li');
await expect(nestedItems).toHaveCount(3);
await expect(nestedItems.nth(0)).toContainText('Section C');
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
await expect(nestedItems.last()).toContainText('Gitbook Site');
},
},
{
name: 'Nested external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
await expect(nestedLink).not.toHaveAttribute('target');
await expect(nestedLink).not.toHaveAttribute('rel');
await expect(nestedLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'External links use the configured window open behavior',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const windowOpenCalls: {
url: string;
target: string;
features: string | undefined;
}[] = [];
await page.exposeFunction(
'recordExternalWindowOpen',
(url: string, target: string, features?: string) => {
windowOpenCalls.push({ url, target, features });
}
);
await page.evaluate(() => {
const recordExternalWindowOpen = (
window as unknown as {
recordExternalWindowOpen: (
url: string,
target: string,
features?: string
) => void;
}
).recordExternalWindowOpen;
window.open = ((url, target, features) => {
void recordExternalWindowOpen(
url?.toString() ?? '',
target ?? '',
features
);
return null;
}) as typeof window.open;
});
const initialURL = page.url();
await page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' })
.click();
await expect.poll(() => windowOpenCalls.length).toBe(1);
expect(windowOpenCalls[0]).toEqual({
url: 'https://gitbook.com/docs',
target: '_self',
features: undefined,
});
await expect(page).toHaveURL(initialURL);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
await page.getByRole('link', { name: 'Gitbook Site' }).click();
await expect.poll(() => windowOpenCalls.length).toBe(2);
expect(windowOpenCalls[1]).toEqual({
url: 'https://gitbook.com',
target: '_self',
features: undefined,
});
await expect(page).toHaveURL(initialURL);
},
},
],
},
{
@@ -1397,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/',
@@ -1449,7 +1750,6 @@ const testCases: TestsCase[] = [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
],
},
@@ -1655,6 +1955,70 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Space (oversized token)',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
tests: [
{
name: 'Oversized token is chunked into cookies and survives navigation',
url: () => {
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
// Inflate the token above the ~4KB browser cookie limit,
// like an IdP issuing many group claims would.
groups: Array.from(
{ length: 60 },
(_, index) => `group-${index}-${'x'.repeat(80)}`
),
},
privateKey,
{
expiresIn: '24h',
}
);
return `first?jwt_token=${token}`;
},
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' })
).toBeVisible();
// The token must be persisted as a chunk-count marker plus chunk cookies.
const cookies = await page.context().cookies();
// Next.js percent-encodes cookie values, so the raw value is `chunks%3A2`.
const marker = cookies.find(
(cookie) =>
cookie.name.startsWith(VISITOR_TOKEN_COOKIE) &&
/^chunks(:|%3A)\d+$/.test(cookie.value)
);
expect(marker).toBeDefined();
const chunks = cookies.filter((cookie) =>
cookie.name.startsWith(`${marker?.name}-`)
);
expect(chunks.length).toBeGreaterThanOrEqual(2);
// Navigating without the token must authenticate from the chunked cookie.
// `first` is the space's default page, so the post-sign-in redirect
// canonicalizes to the space root: derive `second` from that base.
const secondURL = new URL(page.url());
const basePathname = secondURL.pathname
.replace(/\/first\/?$/, '')
.replace(/\/$/, '');
secondURL.pathname = `${basePathname}/second`;
secondURL.search = '';
// Same reason as the harness navigation: third-party subresources on
// this site can hang and never fire `load`.
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('heading', { level: 1, name: 'second' })
).toBeVisible();
},
screenshot: false,
},
],
},
{
name: 'Visitor Auth - Collection',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
+96
View File
@@ -236,3 +236,99 @@ test.describe('select syncing across groups (click-driven)', () => {
await expectGroupShows(page, 'b', other, 'go');
});
});
interface NestedSpec {
outer: string[];
inner: string[];
/** Which of the outer options hosts the nested group. */
host: string;
/**
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
*/
innerStyleFirst?: boolean;
}
/**
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
* produces: panes are direct children of the element carrying the set class, and a pane's body is
* wrapped in a padding div before the nested group.
*/
async function renderNestedGroups(page: Page, spec: NestedSpec) {
const { outer, inner, host, innerStyleFirst = false } = spec;
const outerScope = selectSetClassName(outer);
const innerScope = selectSetClassName(inner);
const innerPanes = inner
.map(
(slug, index) =>
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
const outerPanes = outer
.map(
(slug, index) =>
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
)
.join('');
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
if (innerStyleFirst) {
styles.reverse();
}
await page.setContent(
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
);
}
/**
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
*/
test.describe('select CSS visibility in nested groups', () => {
const outer = ['macos', 'windows'];
const inner = ['npm', 'yarn'];
test('shows both defaults when nothing is selected', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'windows' });
await applySelection(page, ['windows']);
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves each group against its own options', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
});
+67 -2
View File
@@ -111,6 +111,28 @@ function expectWithinBudgets(measurements: Measurement[], total: number) {
}
}
const countElements = (page: Page) =>
page.evaluate(() => document.getElementsByTagName('*').length);
// Not `networkidle`: third-party subresources on this customer site can hang, so it never settles.
async function waitForStableElementCount(page: Page): Promise<number> {
let previous = await countElements(page);
await expect
.poll(
async () => {
const current = await countElements(page);
const stable = current === previous;
previous = current;
return stable;
},
{ message: 'the tree never stopped changing', timeout: 15_000 }
)
.toBe(true);
return previous;
}
async function openLargePage(page: Page) {
await page.goto(getContentTestURL(LARGE_PAGE_URL));
await waitForCookiesDialog(page);
@@ -118,10 +140,9 @@ async function openLargePage(page: Page) {
// Measure a settled page: before hydration the tree is smaller and no popup can open at all.
await page.locator('html.hydrated').waitFor();
await expect(page.getByLabel('OpenAPI Select').first()).toBeAttached();
await page.waitForLoadState('networkidle');
const totalElements = await waitForStableElementCount(page);
const client = await page.context().newCDPSession(page);
const totalElements = await page.evaluate(() => document.getElementsByTagName('*').length);
return { client, totalElements };
}
@@ -136,6 +157,50 @@ async function expectIdle(page: Page, client: CDPSession) {
expect(restyled, 'an idle page should barely restyle').toBeLessThan(IDLE_RESTYLE_TOLERANCE);
}
// Both checks below probe a block's children, never the block itself: `content-visibility: auto`
// skips an element's *contents*, so the element carrying it keeps reporting visible and every block
// would look rendered. Same reason `toBeVisible()` is no use here — a skipped child still has a box.
function countRenderedBlocks(page: Page): Promise<number> {
return page.evaluate(
() =>
[...document.querySelectorAll('.openapi-block')].filter((block) =>
[...block.children].some((child) =>
child.checkVisibility({ contentVisibilityAuto: true })
)
).length
);
}
test('off-screen OpenAPI blocks skip their rendering work', async ({ page }) => {
await openLargePage(page);
const blocks = page.locator('.openapi-block');
const total = await blocks.count();
expect(total, 'the fixture needs enough blocks for some to sit off-screen').toBeGreaterThan(4);
await page.evaluate(() => window.scrollTo(0, 0));
const rendered = await countRenderedBlocks(page);
expect(
rendered,
`${rendered} of ${total} blocks rendered from the top of the page`
).toBeLessThan(total / 2);
// Un-skipping on approach is what keeps #anchors and find-in-page working.
const last = blocks.last();
await last.scrollIntoViewIfNeeded();
await expect
.poll(
() =>
last.evaluate((block) =>
[...block.children].some((child) =>
child.checkVisibility({ contentVisibilityAuto: true })
)
),
{ message: 'the last block never rendered after being scrolled to' }
)
.toBe(true);
});
test('opening a popup restyles a bounded part of a large API reference', async ({ page }) => {
const { client, totalElements } = await openLargePage(page);
await expectIdle(page, client);
+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 -1
View File
@@ -465,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);
+4
View File
@@ -94,6 +94,7 @@ const nextConfig = {
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,
@@ -105,6 +106,9 @@ const nextConfig = {
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
// We maintain our own AGENTS.md/CLAUDE.md at the repo root.
agentRules: false,
images: {
remotePatterns: [
{
+4 -4
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.0",
"@opennextjs/cloudflare": "1.20.2",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -51,7 +51,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.11",
"next": "^16.3.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -132,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",
@@ -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 };
@@ -0,0 +1,14 @@
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
export async function POST() {
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const apiClient = await context.dataFetcher.api();
await apiClient.orgs.prewarmSiteSearch(siteURLData.organization, siteURLData.site);
return new Response(null, { status: 204 });
}
@@ -0,0 +1,299 @@
import { describe, expect, it } from 'bun:test';
import type {
RevisionPage,
RevisionPageDocument,
SearchPageResult,
SearchSpaceResult,
SiteSpace,
} from '@gitbook/api';
import { orderSearchResultGroups } from './orderSearchResults';
import { createLinker } from '@/lib/links';
import { transformSitePageResult } from '@/lib/search';
const runwaySiteURL = 'https://docs.runway.team/';
const runwayAPIURL = 'https://api-docs.runway.team';
const linker = createLinker({
protocol: 'https:',
host: 'docs.runway.team',
siteBasePath: '/',
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',
title: 'Runway',
score: 1,
pages: [],
};
const siteSpace = {
object: 'site-space',
id: 'site_space_runway',
path: '',
space: {
id: spaceItem.id,
title: spaceItem.title,
language: 'en',
},
title: spaceItem.title,
draft: false,
urls: {
published: runwaySiteURL,
},
} as SiteSpace;
function createPageResult(overrides: Partial<SearchPageResult> = {}): SearchPageResult {
return {
id: 'page_api_reference',
title: 'API reference documentation',
description: 'Synthetic search result',
path: runwayAPIURL,
score: 12,
rank: 2,
ancestors: [],
urls: {
app: 'https://app.gitbook.com/o/example/s/example',
},
...overrides,
};
}
function transformPage(pageItem: SearchPageResult, asEmbeddable = false) {
return transformSitePageResult({
asEmbeddable,
linker,
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,
});
}
describe('transformSitePageResult', () => {
it.each([false, true])(
'preserves the Runway external page destination when asEmbeddable is %s',
(asEmbeddable) => {
const result = transformPage(createPageResult(), asEmbeddable);
expect(result.href).toBe(runwayAPIURL);
expect(result.href.startsWith(runwaySiteURL)).toBe(false);
}
);
it('preserves the complete external URL in embeddable mode', () => {
const destination = `${runwayAPIURL}/reference?version=latest#authentication`;
expect(transformPage(createPageResult({ path: destination }), true).href).toBe(destination);
});
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);
expect(result.href).toBe('/~gitbook/embed/page/guides/getting-started');
});
it('resolves a section result to its existing page anchor', () => {
const result = transformPage(
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',
},
},
],
})
);
expect(result.bestSection?.href).toBe('/guides/getting-started#authentication');
});
it('keeps relative page behavior when the published space URL is unresolved', () => {
const result = transformSitePageResult({
asEmbeddable: false,
linker,
pageItem: createPageResult({ path: 'guides/getting-started' }),
spaceItem,
});
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', () => {
const external = transformPage(createPageResult());
const relative = transformPage(
createPageResult({
id: 'page_getting_started',
title: 'Getting started',
path: 'guides/getting-started',
score: 20,
rank: 1,
})
);
const results = orderSearchResultGroups([
{ type: 'pages', results: [{ rank: external.rank, result: external }] },
{ type: 'pages', results: [{ rank: relative.rank, result: relative }] },
]);
expect(results.map(({ id }) => id)).toEqual([relative.id, external.id]);
expect(results.map(({ rank }) => rank)).toEqual([1, 2]);
expect(results.map(({ score }) => score)).toEqual([20, 12]);
});
});
@@ -1,27 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { orderSearchResultGroups } from './orderSearchResults';
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { transformSitePageResult } from '@/lib/search';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
@@ -34,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,
@@ -50,9 +38,25 @@ 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 results = orderSearchResultGroups(
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') {
const result: OrderedComputedResult = {
@@ -67,143 +71,34 @@ 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 }] : [];
}),
};
})
);
return NextResponse.json(results);
}
function transformSitePageResult(args: {
asEmbeddable: boolean;
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): OrderedComputedResult {
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
});
}
if (siteSection) {
breadcrumbs.push({
icon: siteSection.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
});
}
if (
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection.siteSpaces?.filter(
(candidate) => candidate.space.language === space.space.language
).length > 1
).length ?? 0) > 1 &&
siteSpace
) {
breadcrumbs.push({
label: getLocalizedTitle(siteSpace, currentLanguage),
});
}
breadcrumbs.push(
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
}))
);
const pageHref = !spaceURL
? linker.toPathInSpace(pageItem.path)
: asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
description: pageItem.description,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: pageItem.score,
rank: pageItem.rank,
resultType: pageItem.resultType,
breadcrumbs,
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(section.path);
if (spaceURL) {
if (asEmbeddable) {
sectionHref = toEmbeddableLinkForPublishedContent(
linker,
spaceURL,
section.path
);
} else {
sectionHref = linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, section.path)
);
}
}
return {
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: sectionHref,
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
};
}) ?? [];
// The API returns at most one section per page, ordered for use as the section destination preview.
const bestSection = pageSections[0];
if (bestSection) {
page.bestSection = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return page;
}
@@ -117,7 +117,7 @@ export async function GET(
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script src="${context.linker.toPathInSite('~gitbook/embed/script.js')}"></script>
<script>
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
window.GitBook('configure', {
@@ -18,7 +18,7 @@ export async function GET(
) {
const { context } = await getEmbeddableStaticContext(await params);
const initOptions: CreateGitBookOptions = {
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
siteURL: context.linker.toPathInSite(''),
};
// The theme this site pins its embeds to, if it has one. The widget paints the panel around the
@@ -41,7 +41,12 @@ export async function GET(
const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token');
const initOptions = { ...${JSON.stringify(initOptions)}, ...(window.gitbookSettings || {}) };
const initOptions = {
...${JSON.stringify(initOptions)},
// Preview deployments must keep the iframe on the deployment that served this script.
siteURL: new URL(${JSON.stringify(initOptions.siteURL)}, window.location.origin).toString(),
...(window.gitbookSettings || {}),
};
const initFrameOptions = {};
const theme = searchParams.get('theme');
// The site's own theme first: it renders in that one whatever the embedder asks for.
+4
View File
@@ -6,6 +6,8 @@ import {
GITBOOK_API_URL,
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
GITBOOK_DISABLE_TRACKING,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -35,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;
@@ -53,8 +53,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden',
// Hidden where an overflow-clipped ancestor would cut it off (tables, record cards).
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
// tables, record cards).
'in-[[role=table]]:hidden',
'in-[table]:hidden',
'in-[[data-card]]:hidden',
className
)}
@@ -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>
);
}
@@ -1,5 +1,5 @@
'use client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useAI, useAIChatState } from '../AI';
import { Button, type ButtonProps, Input } from '../primitives';
import { useSetSearchState } from '../Search';
import { tString, useLanguage } from '@/intl/client';
@@ -18,17 +18,13 @@ export function InlineActionButton(
const { action, query, buttonProps } = props;
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const setSearchState = useSetSearchState();
const language = useLanguage();
const handleSubmit = (value: string) => {
if (action === 'ask') {
chatController.open();
if (value ?? query) {
chatController.postMessage({ message: value ?? query });
}
assistants[0]?.open(value || query);
} else if (action === 'search') {
setSearchState((prev) => ({
...prev,
@@ -54,7 +54,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
loading={context.mode === 'print' ? 'eager' : 'lazy'}
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline
zoom={!isInLink}
zoom={size !== 'line' && !isInLink}
/>
</span>
);
@@ -12,6 +12,9 @@ import { type GitBookLinker, createLinker } from '@/lib/links';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
// Query params a webframe may set when navigating: the search/ask widget params (see `useSearch`).
const ALLOWED_NAVIGATE_QUERY_PARAMS = ['q', 'ask', 'scope', 'section'];
/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */
export type WebframeLinkerData = Pick<
Parameters<typeof createLinker>[0],
@@ -62,11 +65,17 @@ export function ContentKitWithClientContext<RenderContext>(
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor }) => {
navigate: ({ path, anchor, query }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
// to any section or space within the site (and nowhere outside it).
const suffix = anchor ? `#${anchor}` : '';
navigateTo(linker.toPathInSite(path) + suffix);
const params = new URLSearchParams(
Object.entries(query ?? {}).filter(([key]) =>
ALLOWED_NAVIGATE_QUERY_PARAMS.includes(key)
)
).toString();
const search = params ? `?${params}` : '';
const hash = anchor ? `#${anchor}` : '';
navigateTo(linker.toPathInSite(path) + search + hash);
},
}),
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
@@ -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;
}
@@ -13,6 +13,33 @@
@apply gap-0;
}
/* A reference page can hold a hundred of these, each with its own sticky preview pane; skipping the
render work for the off-screen ones is what keeps such a page scrollable. The DOM is untouched,
so the server HTML, find-in-page and #anchors all still work. */
.openapi-block {
/* Below Safari 18 / Firefox 125 this is ignored and every block renders, as it does today. */
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: auto;
/* Only the block axis: the inline size comes from the flex parent. Plain length first, as a
fallback for engines without the `auto` keyword, where a skipped block would collapse to 0.
The length is a rough guess — `auto` swaps in the real size once a block has rendered. */
contain-intrinsic-height: 1200px;
contain-intrinsic-height: auto 1200px;
}
/* PDF export lays the whole space out off-screen, so nothing there may be skipped. */
body:has(.print-mode) .openapi-block {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: visible;
}
@media print {
.openapi-block {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: visible;
}
}
.openapi-schemas-title {
@apply tabular-nums text-[0.813rem] leading-4 font-mono shrink-0 font-medium text-tint-strong;
}
@@ -12,22 +12,13 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { context } = contextProps;
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
const inlineButtonStyle =
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
const paragraph = (
<p
// Cover-aware contrast text applies only to the page body, not to documents
// rendered in overlays (search answers, AI chat) on a background-cover page.
data-cover-aware-text={context.isPageBody ? '' : undefined}
className={tcls(
// Cover-aware contrast text applies only to the page body, not to documents
// rendered in overlays (search answers, AI chat) on a background-cover page.
context.isPageBody &&
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
inlineButtonStyle,
style,
getTextAlignment(block.data?.align)
)}
// Paragraph styles live in globals.css (`.paragraph`) to keep the class attribute short.
className={tcls('paragraph', style, getTextAlignment(block.data?.align))}
>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
@@ -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
);
}
@@ -0,0 +1,291 @@
import { getTableCellMerge } from './cellMerges';
import { getColumnWidth, getViewGridLayout, hasVisibleHeader } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import { StickyViewGrid } from './StickyViewGrid';
import type { TableGridViewProps } from './Table';
import { TableHoverTable } from './TableHoverTable';
import { TableSearchTableBody } from './TableSearch';
import { type VerticalAlignment, getColumnAlignment, getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
/** Semantic table renderer used when a cell spans multiple records. */
export function NativeViewGrid(props: TableGridViewProps) {
const { block, view, context, style } = props;
const { tableWidth } = getViewGridLayout({
block,
view,
mode: context.mode,
});
const tableContainerClassName = tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
const withHeader = hasVisibleHeader(block, view);
const withStickyHeader = withHeader && context.mode !== 'print' && view.stickyHeader === true;
const withStickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const header = withHeader ? (
<NativeViewGridHeader
{...props}
stickyHeader={withStickyHeader}
stickyFirstColumn={withStickyFirstColumn}
className={tcls(
withStickyHeader
? [
'mb-0 border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-x-0',
]
: undefined
)}
/>
) : undefined;
const body = (
<NativeViewGridBody
{...props}
withHeader={withHeader}
stickyFirstColumn={withStickyFirstColumn}
/>
);
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
stickyHeader={withStickyHeader}
tableClassName={tableContainerClassName}
withTableRole={false}
header={header}
>
{body}
</StickyViewGrid>
);
}
return (
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
<div className={tcls('flex flex-col', tableContainerClassName)}>
{header}
{body}
</div>
</div>
</div>
);
}
function NativeViewGridHeader(
props: TableGridViewProps & {
stickyHeader: boolean;
stickyFirstColumn: boolean;
className?: string;
}
) {
const { block, view, stickyHeader, stickyFirstColumn, className } = props;
const firstVisibleColumn = view.columns[0];
return (
<div
aria-hidden="true"
className={tcls(
'mb-1 rounded-lg border border-tint-subtle bg-tint',
stickyHeader || !stickyFirstColumn ? 'overflow-hidden' : undefined,
className
)}
>
<table className="w-full table-fixed border-separate border-spacing-0">
<NativeViewGridColumns {...props} />
<thead>
<tr>
{view.columns.map((column) => {
const definition = block.data.definition[column];
if (!definition) {
return null;
}
const isStickyFirstColumn =
stickyFirstColumn && column === firstVisibleColumn;
return (
<th
key={column}
scope="col"
className={tcls(
'relative px-3 py-2 font-medium text-sm text-tint-strong',
isStickyFirstColumn
? stickyHeader
? 'z-20 bg-tint'
: 'sticky left-0 z-20 bg-tint'
: undefined,
getColumnAlignment(definition)
)}
style={{
left:
stickyHeader && isStickyFirstColumn
? 'calc(-1 * var(--table-sticky-scroll-left, 0px))'
: undefined,
}}
title={definition.title}
>
{definition.title}
</th>
);
})}
</tr>
</thead>
</table>
</div>
);
}
function NativeViewGridBody(
props: TableGridViewProps & {
withHeader: boolean;
stickyFirstColumn: boolean;
}
) {
const { block, view, records, withHeader, stickyFirstColumn, cellMergeLayout } = props;
const firstVisibleColumn = view.columns[0];
const lastVisibleColumn = view.columns.at(-1);
const recordsById = new Map(records.map((record) => [record[0], record] as const));
const recordIndexes = new Map(records.map((record, index) => [record[0], index] as const));
return (
<TableHoverTable
className={tcls(
'w-full table-fixed border-separate border-spacing-0',
'[&>tbody>tr>td[data-table-hovered]]:bg-tint-hover',
'[&>tbody>tr+tr>td]:border-t',
'[&>tbody+tbody>tr:first-child>td]:border-t'
)}
>
<NativeViewGridColumns {...props} />
{withHeader ? (
<thead className="sr-only">
<tr>
{view.columns.map((column) => {
const definition = block.data.definition[column];
return definition ? (
<th key={column} scope="col">
{definition.title}
</th>
) : null;
})}
</tr>
</thead>
) : null}
{cellMergeLayout.recordGroups.map((recordGroup) => (
<TableSearchTableBody key={recordGroup[0]} recordIds={recordGroup}>
{recordGroup.map((recordId) => {
const record = recordsById.get(recordId);
const recordIndex = recordIndexes.get(recordId);
if (!record || recordIndex === undefined) {
return null;
}
return (
<tr key={recordId}>
{view.columns.map((column) => {
const cellMerge = getTableCellMerge(
cellMergeLayout,
recordId,
column
);
if (cellMerge && !cellMerge.isAnchor) {
return null;
}
const definition = block.data.definition[column];
if (!definition) {
return null;
}
const verticalAlignment =
getColumnVerticalAlignment(definition);
const renderedColumns = cellMerge?.merge.columns ?? [column];
const isLastColumn =
renderedColumns.at(-1) === lastVisibleColumn;
const isStickyFirstColumn =
stickyFirstColumn && column === firstVisibleColumn;
const rowSpan =
cellMerge && cellMerge.merge.rowSpan > 1
? cellMerge.merge.rowSpan
: undefined;
const colSpan =
cellMerge && cellMerge.merge.colSpan > 1
? cellMerge.merge.colSpan
: undefined;
return (
<td
key={column}
rowSpan={rowSpan}
colSpan={colSpan}
aria-rowspan={rowSpan}
aria-colspan={colSpan}
data-table-row-start={recordIndex}
data-table-row-end={
recordIndex + (cellMerge?.merge.rowSpan ?? 1) - 1
}
className={tcls(
'relative px-3 py-2 text-sm transition-colors',
!isLastColumn
? 'border-tint-subtle border-r'
: undefined,
getNativeCellVerticalAlignment(verticalAlignment),
isStickyFirstColumn
? 'sticky left-0 z-10 bg-tint-base'
: undefined
)}
>
<RecordColumnValue
{...props}
record={record}
column={column}
verticalAlignment={verticalAlignment}
/>
</td>
);
})}
</tr>
);
})}
</TableSearchTableBody>
))}
</TableHoverTable>
);
}
function NativeViewGridColumns(props: TableGridViewProps) {
const { block, view, context } = props;
const { columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
block,
view,
mode: context.mode,
});
return (
<colgroup>
{view.columns.map((column) => (
<col
key={column}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
}}
/>
))}
</colgroup>
);
}
function getNativeCellVerticalAlignment(verticalAlignment: VerticalAlignment) {
switch (verticalAlignment) {
case 'self-start':
return 'align-top';
case 'self-end':
return 'align-bottom';
case 'self-center':
return 'align-middle';
}
}
@@ -1,20 +1,19 @@
import type { DocumentTableViewGrid } from '@gitbook/api';
import { getMergedCellWidth, getTableCellMerge } from './cellMerges';
import { getColumnWidth } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import type { TableGridViewProps, TableRecordKV } from './Table';
import { TableSearchRecord } from './TableSearch';
import { getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
export function RecordRow(
props: TableViewProps<DocumentTableViewGrid> & {
props: TableGridViewProps & {
record: TableRecordKV;
autoSizedColumns: string[];
fixedColumns: string[];
}
) {
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
const { view, record, autoSizedColumns, fixedColumns, block, context, cellMergeLayout } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
@@ -31,12 +30,22 @@ export function RecordRow(
)}
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
});
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
if (cellMerge && !cellMerge.isAnchor) {
return null;
}
const renderedColumns = cellMerge?.merge.columns ?? [column];
const columnWidth = getMergedCellWidth(
renderedColumns.map((mergedColumn) =>
getColumnWidth({
column: mergedColumn,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
})
)
);
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -45,6 +54,7 @@ export function RecordRow(
<div
key={column}
role="cell"
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
@@ -9,6 +9,7 @@ interface StickyViewGridProps {
header?: ReactNode;
stickyHeader?: boolean;
tableClassName?: string;
withTableRole?: boolean;
children: ReactNode;
}
@@ -23,6 +24,7 @@ function DefaultHeaderScrollGrid({
className,
header,
tableClassName,
withTableRole = true,
children,
}: StickyViewGridProps) {
const resolvedTableClassName = tableClassName ?? 'w-fit';
@@ -30,7 +32,7 @@ function DefaultHeaderScrollGrid({
return (
<div className={className}>
<div
role="table"
role={withTableRole ? 'table' : undefined}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle"
>
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
@@ -48,6 +50,7 @@ function StickyHeaderOverlayScrollGrid({
className,
header,
tableClassName,
withTableRole = true,
children,
}: StickyViewGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
@@ -141,7 +144,7 @@ function StickyHeaderOverlayScrollGrid({
ref={rootRef}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
data-scrollable="false"
role="table"
role={withTableRole ? 'table' : undefined}
>
{header ? (
<div
@@ -1,10 +1,12 @@
import assertNever from 'assert-never';
import type { DocumentBlockTable } from '@gitbook/api';
import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { type TableCellMergeLayout, createTableCellMergeLayout } from './cellMerges';
import { getViewGridLayout, hasVisibleHeader } from './layout';
import { NativeViewGrid } from './NativeViewGrid';
import {
type TableRecordKV,
getTableCheckboxColumns,
@@ -26,6 +28,10 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
isOffscreen: boolean;
}
export interface TableGridViewProps extends TableViewProps<DocumentTableViewGrid> {
cellMergeLayout: TableCellMergeLayout;
}
export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
@@ -45,9 +51,18 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const cellMergeLayout = createTableCellMergeLayout(
block,
records.map(([recordId]) => recordId)
);
return (
<TableSearchProvider records={searchRecords}>
<TableSearchProvider
records={searchRecords}
recordGroups={
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
}
>
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
@@ -55,7 +70,12 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
<TableView {...props} isOffscreen={isOffscreen} records={records} />
<TableView
{...props}
isOffscreen={isOffscreen}
records={records}
cellMergeLayout={cellMergeLayout}
/>
<TableSearchEmpty />
</div>
</TableSearchProvider>
@@ -68,8 +88,13 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
function TableView({
isOffscreen,
records,
cellMergeLayout,
...props
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
}: BlockProps<DocumentBlockTable> & {
isOffscreen: boolean;
records: TableRecordKV[];
cellMergeLayout: TableCellMergeLayout;
}) {
const { block, context, style } = props;
switch (block.data.view.type) {
@@ -88,6 +113,7 @@ function TableView({
view: block.data.view,
isOffscreen,
records,
cellMergeLayout,
};
const { tableWidth } = getViewGridLayout({
block,
@@ -102,6 +128,10 @@ function TableView({
const withStickyFirstColumn =
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
if (cellMergeLayout.hasVerticalMerges) {
return <NativeViewGrid {...gridProps} />;
}
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid

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