Compare commits

..

84 Commits

Author SHA1 Message Date
Peter White 27441c0605 Drop ref memoization 2026-09-07 15:34:54 +02:00
Peter White dd1ca93837 Bump @gitbook/api to 0.200.0 2026-09-07 15:27:23 +02:00
Peter White 08e61c89f0 Read the permanent flag from the typed site redirect 2026-09-07 14:11:44 +02:00
Peter White 587398ddd4 Merge branch 'main' into peter/rnd-12709-support-permanent-308-site-redirects 2026-09-07 12:25:33 +02:00
Peter White 321b06d873 fix: keep permanent redirects temporary in site previews and for draft redirects 2026-09-07 12:14:36 +02:00
Peter White 50eee8fec9 fix: read isLoggedInVisitor from the site context 2026-09-07 10:26:52 +02:00
Tomek 1f250d1fa8 Add site structure e2e tests for external links (#4584) 2026-09-07 09:31:50 +02:00
Peter White ec709c3d99 feat: serve permanent site redirects 2026-09-07 09:25:03 +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
Zeno Kapitein d9fc4608e3 Stop suggesting previously asked questions in the GitBook Assistant (#4546)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:00:30 +02:00
Zeno Kapitein ded2f560e3 Show loose sections in a section group dropdown as secondary links unless the group starts with one (#4545)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:14:58 +02:00
Zeno Kapitein a9c5d1b546 Fix the gap between columns in large section group dropdowns (#4544)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:45:41 +02:00
Zeno Kapitein 0ff21b7ae2 Hide the section tabs scroll button when all tabs already fit (#4540)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:35:38 +02:00
Zeno Kapitein 94a496c561 Match the Docs Embed widget to the page it is embedded in (#4533)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:34:54 +02:00
Johan Preynat 148a43ac01 Preserve backend ranking across site sections (#4541) 2026-08-25 14:55:17 +02:00
Johan Preynat 5da854f09c Preserve backend ranking in published search (#4537) 2026-08-25 12:54:24 +02:00
spastorelli b772f74c2b Evaluate <if> blocks when serving markdown pages (#4538) 2026-08-25 12:18:53 +02:00
Zeno Kapitein e0bd04f641 Render headings with the site's heading font (#4531)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:08:03 +02:00
conico974 8d7c3edda8 Disable metadata fetching in revision-related API calls (#4459) 2026-08-24 13:55:50 +02:00
conico974 80951e9889 Remove CODEOWNERS file for API data cache functions (#4536) 2026-08-24 13:43:37 +02:00
Nolann B. 185dd8337e Defer tracking scripts until after load and idle (#4529) 2026-08-24 12:18:30 +02:00
Peter White da7fb13d83 Fail closed in /~gitbook/revalidate when GITBOOK_SECRET is unset (#4535) 2026-08-24 12:11:40 +02:00
conico974 49c993f181 Add API token expiration check (#4532) 2026-08-21 11:29:38 +02:00
Zeno Kapitein 90b54682e5 Redesign the site OAuth consent screen for published sites MCP (RND-12071) (#4530)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:45:46 +02:00
Zeno Kapitein 1dccf8f06c Fix table-of-contents page group's first item cut off after navigation (RND-11314) (#4391)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: zenoachtig <8304582+zenoachtig@users.noreply.github.com>
2026-08-20 15:28:02 +02:00
Nolann B. 810244e8a9 Stop preloading zoom-modal images at render time (#4528) 2026-08-20 15:02:00 +02:00
Tomek 0dd2f4fbcd Show containing section name when hovering direct space links (RND-12437) (#4525) 2026-08-20 14:06:10 +02:00
Nolann B. 8a700222e6 Only track embed view events once the frame is shown (RND-12362) (#4517) 2026-08-20 14:04:14 +02:00
Nolann B. b13fd91afc Compute the AI chat time greeting after mount to avoid a hydration mismatch (#4527) 2026-08-20 13:52:52 +02:00
Nolann B. 8a6baaba6a Load the admin toolbar and its CSS lazily (RND-12524) (#4526) 2026-08-20 13:47:49 +02:00
Zeno Kapitein 48fba7cb49 Fix: highlight a TOC link entry when on the page or section it points to (RND-11155) (#4476)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:48:45 +02:00
Zeno Kapitein 6928a9b00a Hide card fields that render no content (#4523)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:48:37 +02:00
Nolann B. 7bab574c63 Self-host default fonts and inline only the ones a site uses (RND-12524) (#4522) 2026-08-20 12:36:55 +02:00
conico974 75bdff3ff3 Add support for AI agent detection and robots directives in markdown (#4524) 2026-08-20 12:18:17 +02:00
Peter White 56c25587db Make keyboard shortcuts work on non-QWERTY layouts (#4516) 2026-08-19 15:03:34 +00:00
Zeno Kapitein cf4efc7213 Fix search input losing focus before hydration (RND-11849) (#4438)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 12:30:11 +02:00
Zeno Kapitein 1ef71609e7 Show the light/dark toggle on laptop-width screens (#4515)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:29:58 +02:00
Zeno Kapitein 195c9e6b84 Apply and remember the ?theme= light/dark override in the docs embed (#4380)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 11:07:23 +02:00
Zeno Kapitein 09f39f8300 Vertically center breadcrumb icons (#4513) 2026-08-19 08:52:10 +00:00
Peter White 9002f6598a Fix embed search suggested questions not opening the assistant (#4514) 2026-08-18 17:19:25 +02:00
Zeno Kapitein 01c9b059c6 Require Ctrl/Cmd to zoom Mermaid diagrams with the wheel (RND-12338) (#4465)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 16:37:49 +02:00
Zeno Kapitein 6d02b8ab72 Fix TS code block comment punctuation color in dark mode (RND-12204) (#4460)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 16:36:07 +02:00
Peter White 177ef8582a Fix Docs Embed host actions not reaching the assistant (#4512) 2026-08-18 16:33:03 +02:00
Zeno Kapitein 78c589ffba Align Previous/Next page navigation to opposite edges (RND-11235) (#4494)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 13:36:08 +02:00
Nolann B. e4b214e6bd Fix large OpenAPI payload section not being scrollable (RND-12291) (#4510) 2026-08-18 13:17:33 +02:00
Tomek 64ce9e180d Render site-space custom home page at root (#4475)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 12:20:52 +02:00
conico974 c64d3a50e8 Refactor CSS compatibility checks (#4504) 2026-08-18 10:30:43 +02:00
spastorelli fd070ce9ca Redirect to the upstream auth logout URL on ~gitbook/auth/logout (#4489) 2026-08-18 10:27:48 +02:00
Tomek 048c4e4c70 Fix tooltip data-popup-open state conflict and AI messages collapsible (#4509)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 10:22:47 +02:00
conico974 fb01dc9ecb Remove unused Cloudflare image resizing function (#4507) 2026-08-17 17:26:32 +02:00
Nolann B. d33e570bc7 Fix the spacebar being swallowed in the search bar (RND-12484) (#4508) 2026-08-17 13:21:21 +00:00
Tomek 87fd234d55 Fix dropdown icon rotation by reading dropdown open state (#4503)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-17 14:55:09 +02:00
Claire Chabas 0f32eb17d1 Fix toggle chevron alignment in ToC (#4505) 2026-08-17 14:41:44 +02:00
Tomek 3e29680792 Stabilize visual CI tests (#4506)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-17 13:14:47 +02:00
Nolann B. b3db1c58c8 Migrate headless UI primitives from Radix and react-aria to Base UI (#4499) 2026-08-15 21:05:32 +02:00
334 changed files with 12774 additions and 3849 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop showing previously asked questions as suggestions in the GitBook Assistant
@@ -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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the breadcrumbs from covering the page actions' hit area.
+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
---
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.
+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
---
Show the theme toggle in the footer whenever the outline column that hosts the other toggle isn't pinned open, so it stays reachable on laptop-sized screens in wide layouts and while the AI chat is open.
+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
---
Fix host action buttons in the Docs Embed not reaching the assistant.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the Docs Embed widget rendering with a dark surface when the visitor's OS is in dark mode, even on a light page or a site published with a light theme. The embed script now accepts `?theme=light|dark` to force a scheme.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix suggested question clicks in the embed search not opening the assistant.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": patch
---
Make the Docs Embed widget match the page it is embedded in rather than the visitor's OS: a widget on a light page stays light even when the visitor's system is in dark mode, and the widget's own chrome and the docs inside it always render in the same scheme. Sites published with a single theme impose it on the widget too, since they render in it regardless. The standalone script takes `?theme=light` on its URL, and calling `init` twice now updates the options instead of throwing.
+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
---
Self-host default Google fonts and inline only the @font-face rules of the fonts a site uses, instead of shipping render-blocking stylesheets covering all 23 families on every page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide card fields that render no content, along with their title
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Upgrade react-hotkeys-hook to v5 and use its native `useKey` option so keyboard shortcuts match the produced key on non-QWERTY layouts.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Keep embed demos and frames on the deployment that served them.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix a hydration mismatch on every page load caused by the AI chat time-based greeting being computed in the server timezone.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop preloading the zoom-modal variant of every zoomable image at render time; it downloaded each image twice during the initial page load. The modal image still loads on hover or click.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve an indexable `X-Robots-Tag` on markdown pages requested by AI agents
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Attribute the markdown and ask events tracked from the middleware to their space, and record the goal passed alongside a question.
@@ -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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Mermaid diagrams no longer hijack page scrolling: zooming with the wheel now requires holding Ctrl/Cmd inline, while the fullscreen view keeps free wheel zoom.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Migrate the headless UI primitives from Radix and react-aria to Base UI.
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Redesign the site OAuth consent screen for published sites MCP and translate its strings
+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
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Let a section group dropdown scroll when it is taller than the screen
@@ -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
---
Fix OpenAPI webhook payload and schema example panels being clipped instead of scrollable.
@@ -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
---
Preserve external page destinations returned by published search.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use one canonical backend-ranked result set for published searches across site sections.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve canonical backend ranking and present page or section context that matches each published search destination.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Align the Previous page navigation button to the left edge and the Next button to the right edge, including when only one of them is present.
+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
---
Inject site tracking scripts (analytics integrations) after `load` + idle instead of preloading them and executing them during the critical loading window.
+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
---
Render a site-space custom home page at its placement root while preserving the full space and normal page URLs.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fail closed in `/~gitbook/revalidate` when `GITBOOK_SECRET` is not configured, returning `403 Revalidation is disabled` instead of skipping the signature check, consistent with `force-revalidate`.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the docs embed not applying `?theme=light`/`?theme=dark`. The theme was dropped when the embed redirected to its default tab, and the embed tabs couldn't read it because they render statically (their request headers are empty). The middleware now threads a forced embed theme through the embed route context (scoped to the embed, not the main site), so the embed tabs honor it while staying statically rendered, and the redirect forwards it to the default tab. The forced theme is also persisted to the embed's own theme storage so it is remembered across tab navigation instead of only applying while the query string is present.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the search field losing focus if it was focused just before the page finished hydrating.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix code block syntax highlighting so comment delimiters (e.g. `//`, `/*`) use the same color as the rest of the comment. Previously the delimiter fell through to the generic punctuation scope, making it a different color from the comment body (most visible in dark mode).
@@ -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.
+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
---
Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
+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
---
Show the containing section name when hovering a direct link to a space.
+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.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show loose sections in a section group dropdown as secondary links unless the group starts with one
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop the section tabs in the header from showing a scroll button and faded edge when all tabs already fit.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render headings with the site's heading font when one is configured, falling back to the main font otherwise.
+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
---
Load the admin toolbar and its CSS lazily so published pages no longer ship a render-blocking stylesheet for admin-only UI.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the gap between the columns of a section group dropdown holding a large group
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the first item of a table-of-contents page group appearing cut off (faded under the group header) after client-side navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Highlight a table-of-contents link entry as active when it points to the page — or the section of a page — you are currently viewing.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix chevrons and open-state styling incorrectly reacting to a tooltip opening on the same trigger, by switching from the shared Base UI `data-popup-open` attribute to `aria-expanded`.
+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
---
Only track embed view events once the frame is actually shown to the reader
-3
View File
@@ -1,3 +0,0 @@
# Changes to the API data cache functions can invalidate all existing data cache
# causing a massive amount of revalidation, impacting our API.
packages/gitbook/src/lib/data/api.ts @SamyPesse
@@ -6,7 +6,6 @@ on:
permissions:
contents: read
issues: write
pull-requests: read
concurrency:
+131 -698
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -19,8 +19,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
"axios": "1.8.4"
},
"private": true,
"scripts": {
@@ -48,7 +47,8 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.195.0",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.200.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,6 +67,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"
}
}
+24 -7
View File
@@ -19,15 +19,27 @@ The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
```
The script initializes the widget itself, so there is nothing to call. To pin the embed to one color
scheme, put it on the script URL — it has to be known before the widget renders:
```html
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
```
To authenticate the visitor, call `init` with their token. Keep tokens out of the script URL: it is
publicly cacheable and ends up in server logs.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script>
// Initialize with Authenticated Access (optional)
window.GitBook('init',
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show');
</script>
```
@@ -464,20 +476,25 @@ visitor: {
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
Override the embed's color scheme.
When omitted, the standalone widget follows the page it is embedded in — its `color-scheme`, falling back to the visitor's OS preference only when that page declares support for both. With the NPM package or the React components you own the iframe, so the embed follows the visitor's OS preference unless you pass this.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**Standalone script**: `?theme=light` on the script URL
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```html
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
```
Sites published with a single theme always render in that theme, so `colorScheme` has no effect on
them — the widget follows the site instead, to keep its chrome and the docs inside it consistent.
### `button`
Available in: Standalone script only
+3 -1
View File
@@ -10,7 +10,9 @@ export type CreateGitBookOptions = {
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
* When omitted, the standalone widget follows the page it is embedded in, and only falls back
* to the visitor's OS preference when that page supports both schemes. Building the iframe
* yourself, the embed follows the visitor's OS preference unless you pass this.
*/
colorScheme?: 'light' | 'dark';
@@ -79,7 +79,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
height="100%"
allow="clipboard-write"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+88 -14
View File
@@ -55,6 +55,7 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfigured = false;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
@@ -85,6 +86,47 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
/**
* The one scheme everything follows: the widget's chrome, the frame's URL and the docs inside it.
* Either it was configured, or we match the page we are embedded in (RND-12558).
*/
function resolveColorScheme(): 'light' | 'dark' {
const configured = frameOptions?.colorScheme;
// Callers are plain JS, so anything else — a typo, a `system` — falls back to the page rather
// than reaching the CSS and the frame's URL, where the two would disagree.
return configured === 'light' || configured === 'dark' ? configured : hostColorScheme();
}
/**
* The scheme the embedding page renders in, which is not the visitor's OS preference: a page that
* never opted into dark stays light however the OS is set.
*
* Resolving a `light-dark()` is the only way to read it. A page's *used* color scheme isn't exposed
* anywhere — the CSSOM gives computed values, and a `<meta name="color-scheme">` (the common way to
* declare it) never even reaches those.
*/
function hostColorScheme(): 'light' | 'dark' {
const probe = document.createElement('div');
// The first `color` is the fallback where `light-dark()` is unsupported: without it the probe
// would inherit the page's own text colour and a white one would read as dark.
probe.style.cssText =
'display:none;color:rgb(0,0,0);color:light-dark(rgb(0,0,0), rgb(255,255,255))';
document.body.appendChild(probe);
const used = getComputedStyle(probe).color;
probe.remove();
return used === 'rgb(255, 255, 255)' ? 'dark' : 'light';
}
/** Mirror the resolved scheme onto the widget's own chrome, and hand it back for the frame's URL. */
function applyColorScheme(): 'light' | 'dark' {
const colorScheme = resolveColorScheme();
for (const element of [widgetButton, widgetWindow]) {
element.dataset.colorScheme = colorScheme;
}
return colorScheme;
}
function getClient() {
if (!_client) {
throw new Error(
@@ -102,12 +144,8 @@ function getIframe() {
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.allow = 'clipboard-write';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
// One read for both, so the docs can't come back in a different scheme than the panel.
widgetIframe.src = client.getFrameURL({ ...frameOptions, colorScheme: applyColorScheme() });
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
@@ -115,25 +153,53 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
// A new frame starts from the site's own defaults, so replay whatever the host configured.
if (frameConfigured) {
_frame.configure(frameConfiguration);
}
}
return { iframe: widgetIframe, frame: _frame };
}
const GitBook = (...args: StandaloneCalls) => {
switch (args[0]) {
case 'init':
if (_client) {
throw new Error(
'GitBook client already initialized. Call GitBook("unload") first.'
);
}
case 'init': {
// `~gitbook/embed/script.js` already calls `init`, so an integrator following the docs
// ends up calling it a second time. Take the new options instead of throwing: throwing
// here dropped every call queued behind it (RND-12558).
_client = createGitBook(args[1]);
frameOptions = args[2];
frameOptions = {
// Replace rather than merge: a call that leaves out `visitor` — a logout, another
// site — must not keep the token from the last one.
...args[2],
// Except the scheme, where the first one wins: `script.js` passes the site's own
// theme when it pins one, and that is not the integrator's to override.
colorScheme: frameOptions?.colorScheme ?? args[2]?.colorScheme,
};
const colorScheme = applyColorScheme();
// Rebuild the frame only if the new options change its URL — reloading it on the
// loader's `init` plus the integrator's would throw away a chat for nothing.
const frameURL = _client.getFrameURL({ ...frameOptions, colorScheme });
if (widgetIframe && widgetIframe.src !== frameURL) {
const wasOpen = !widgetWindow.classList.contains('hidden');
widgetIframe.remove();
widgetIframe = undefined;
_frame = undefined;
if (wasOpen) {
getIframe();
}
}
break;
}
case 'unload':
_client = undefined;
_frame = undefined;
widgetIframe?.remove();
widgetIframe = undefined;
frameOptions = undefined;
frameConfigured = false;
applyColorScheme();
widgetWindow.classList.add('hidden');
break;
case 'show':
@@ -192,6 +258,7 @@ const GitBook = (...args: StandaloneCalls) => {
}
}
frameConfigured = true;
getIframe().frame.configure({
...frameConfiguration,
});
@@ -214,4 +281,11 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
// Replay each queued call on its own, so one that throws doesn't drop the rest.
precalls.forEach((call) => {
try {
GitBook(...call);
} catch (error) {
console.error('[gitbook:embed]', error);
}
});
+24 -8
View File
@@ -29,14 +29,30 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
/* The widget owns the panel's surface, so it renders in the same scheme as the docs inside the
* iframe. `data-color-scheme` carries that one resolved scheme — see `resolveColorScheme()` — and
* is always set, so the declaration below only shows before the widget initializes (RND-12558).
* The colours are declared here rather than on `:root` because `light-dark()` resolves against the
* color-scheme of the element that declares them. The bundler lowers it to a variable toggle, so
* browsers without `light-dark()` still get both schemes. */
#gitbook-widget-button,
#gitbook-widget-window {
color-scheme: light;
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
--gitbook-widget-background-solid: light-dark(#FFFFFF, #0f0f0f);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #141414);
}
#gitbook-widget-button[data-color-scheme="light"],
#gitbook-widget-window[data-color-scheme="light"] {
color-scheme: light;
}
#gitbook-widget-button[data-color-scheme="dark"],
#gitbook-widget-window[data-color-scheme="dark"] {
color-scheme: dark;
}
* {
+6 -8
View File
@@ -215,7 +215,8 @@ const testCases: TestsCase[] = [
{
name: 'docs.n8n.io',
contentBaseURL: 'https://docs.n8n.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// The site registers its own cookie banner, so the built-in one never shows.
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherryai.com.cn',
@@ -307,11 +308,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.triumpharcade.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.nats.io',
contentBaseURL: 'https://docs.nats.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'help.glpi-project.org',
contentBaseURL: 'https://help.glpi-project.org',
@@ -360,7 +356,9 @@ const testCases: TestsCase[] = [
{
name: 'docs.verifone.com',
contentBaseURL: 'https://docs.verifone.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// Verifone's custom Cookiebot integration races with and suppresses GitBook's built-in
// banner, so waitForCookiesDialog is not a stable invariant here.
tests: [{ name: 'Home', url: '/' }],
},
// Deactivate it because of a custom Ask AI that causes flakiness.
// {
@@ -398,7 +396,7 @@ const testCases: TestsCase[] = [
{
name: 'faq.wanttopay.net/wanttopay-app',
contentBaseURL: 'https://faq.wanttopay.net',
tests: [{ name: 'Home', url: '/wanttopay-app', run: waitForCookiesDialog }],
tests: [{ name: 'Home', url: '/wanttopay-app' }],
},
{
name: 'guide.prismlive.com',
+324 -11
View File
@@ -1,4 +1,4 @@
import { expect } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
@@ -34,6 +34,7 @@ import {
waitForAdminToolbar,
waitForCookiesDialog,
waitForCoverImages,
waitForHydration,
waitForNotFound,
} from './util';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
@@ -51,6 +52,27 @@ const AI_PROMPT = [
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
// `InsightsProvider` debounces its flushes by 1.5s.
const INSIGHTS_FLUSH_TIMEOUT = 3000;
/**
* Collect the insights events of a given type sent by the page and its frames.
*/
function trackInsightsEvents(page: Page, type: string) {
const collected: { type: string }[] = [];
page.on('request', (request) => {
if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) {
return;
}
const body = request.postDataJSON() as { events?: { type: string }[] } | null;
collected.push(...(body?.events ?? []).filter((event) => event.type === type));
});
return collected;
}
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
if (greeting) {
@@ -135,6 +157,54 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeFocused();
},
},
{
name: 'Search - Keyboard focus stays within search',
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();
await expect(finalPopupControl).toBeVisible();
await finalPopupControl.focus();
await page.keyboard.press('Tab');
await expect(searchInput).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(finalPopupControl).toBeFocused();
},
},
{
// `fill()` bypasses key events, so it can't catch a swallowed key. RND-12484.
name: 'Search - AI Mode: None - Typing multi-word queries',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await searchInput.pressSequentially('getting started');
await expect(searchInput).toHaveValue('getting started');
},
},
{
name: 'Search - AI Mode: None - URL query (Initial)',
url: `${getCustomizationURL({
@@ -381,6 +451,7 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -408,6 +479,7 @@ const testCases: TestsCase[] = [
name: 'Switch variant with alternate link in metadata',
url: 'rfcs',
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -446,6 +518,7 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -472,6 +545,7 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -513,6 +587,7 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
},
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -548,6 +623,7 @@ const testCases: TestsCase[] = [
url: 'ecosystem/connection-provider',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -578,6 +654,7 @@ const testCases: TestsCase[] = [
url: 'nl/ecosysteem/connection-provider',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -618,18 +695,162 @@ const testCases: TestsCase[] = [
url: '',
screenshot: false,
run: async (page) => {
const trigger = page.getByRole('button', { name: 'Test Section Group 1' });
// Radix NavigationMenu opens the dropdown on a `pointermove`. A single
// synthetic hover can land before hydration and be lost, so re-hover
// until the dropdown content actually appears.
await expect(async () => {
await trigger.hover();
await expect(page.getByText('Section B')).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
// The menu only opens on `mouseenter`, which cannot fire again once the pointer
// is inside: a hover landing before hydration is lost for good.
await waitForHydration(page);
const trigger = page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 1' });
await trigger.hover();
await expect(page.getByText('Section B')).toBeVisible();
await page.getByText('Section B').click();
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
},
{
name: 'Root external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootSections = page.locator('[data-gb-sections]');
const rootItems = rootSections.locator(':scope > li');
await expect(rootItems).toHaveCount(4);
await expect(rootItems.nth(0)).toContainText('Home');
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
await expect(rootItems.last()).toContainText('Gitbook Docs');
await expect(
rootSections.getByRole('link', { name: 'Gitbook Docs' })
).toBeVisible();
},
},
{
name: 'Root external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootLink = page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' });
await expect(rootLink).toBeVisible();
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
await expect(rootLink).not.toHaveAttribute('target');
await expect(rootLink).not.toHaveAttribute('rel');
await expect(rootLink).toHaveAttribute('data-active', 'false');
await expect(rootLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'Nested external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
const nestedItems = nestedLink
.locator('xpath=ancestor::ul[1]')
.locator(':scope > li');
await expect(nestedItems).toHaveCount(3);
await expect(nestedItems.nth(0)).toContainText('Section C');
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
await expect(nestedItems.last()).toContainText('Gitbook Site');
},
},
{
name: 'Nested external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
await expect(nestedLink).not.toHaveAttribute('target');
await expect(nestedLink).not.toHaveAttribute('rel');
await expect(nestedLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'External links 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);
},
},
],
},
{
@@ -1609,6 +1830,71 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Space (oversized token)',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
// Our Cloudflare stack still folds multiple Set-Cookie headers into one,
// breaking chunked cookies (variant of opennextjs-cloudflare#501).
skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare',
tests: [
{
name: 'Oversized token is chunked into cookies and survives navigation',
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 = '';
await page.goto(secondURL.toString());
await expect(
page.getByRole('heading', { level: 1, name: 'second' })
).toBeVisible();
},
screenshot: false,
},
],
},
{
name: 'Visitor Auth - Collection',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
@@ -2236,6 +2522,33 @@ const testCases: TestsCase[] = [
);
},
},
{
name: 'Only tracks ask_view once the widget is opened',
// `trigger=custom` loads the frame but leaves the window closed.
url: '?trigger=custom',
screenshot: false,
run: async (page) => {
const askViews = trackInsightsEvents(page, 'ask_view');
const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat');
// The assistant renders inside the hidden frame, but nobody has seen it.
await expect(chat).toBeAttached({ timeout: 20000 });
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(0);
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1);
// Hiding and showing the same frame again is not a second view.
await page.getByRole('button', { name: 'Close' }).click();
await expect(chat).toBeHidden();
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(1);
},
},
],
},
{
@@ -2405,7 +2718,7 @@ const testCases: TestsCase[] = [
actions.nth(1).click(),
]);
// Verify the new page would have opened with the expected URL
expect(newPage.url()).toContain('gitbook.com');
await expect(newPage).toHaveURL(/gitbook\.com/);
// Close it immediately to avoid navigation
await newPage.close();
@@ -2553,7 +2866,7 @@ const testCases: TestsCase[] = [
openInNewTabButton.click(),
]);
// Verify the new page would have opened with the expected URL
expect(newPage.url()).toContain('gitbook.gitbook.io');
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
// Close it immediately to avoid navigation
await newPage.close();
},
+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);
+4
View File
@@ -173,6 +173,10 @@ export async function waitForCookiesDialog(page: Page) {
});
}
export async function waitForHydration(page: Page) {
await page.locator('html.hydrated').waitFor();
}
/**
* Wait for the GitBook admin toolbar to be present.
*
+3
View File
@@ -105,6 +105,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: [
{
+9 -14
View File
@@ -3,6 +3,7 @@
"version": "0.27.2",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
@@ -19,16 +20,9 @@
"@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",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
@@ -57,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",
@@ -67,9 +61,8 @@
"p-retry": "^8.0.0",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-aria": "^3.44.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"react-hotkeys-hook": "^5.3.3",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
@@ -125,12 +118,14 @@
},
"scripts": {
"generate": "./scripts/generate.sh",
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.ts",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar",
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.ts && bun ./scripts/download-fonts.ts",
"generate:fonts": "bun ./scripts/generate-font-faces.ts",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar && rm -rf ./public/~gitbook/static/fonts",
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
"build": "bun run generate:assets && next build --webpack",
"build:local": "bun run generate:assets && GITBOOK_URL=http://localhost:3000 next build --webpack",
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
"check:css-browser-compatibility:local": "bun run check:css-browser-compatibility --local origin/main",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
@@ -1,11 +1,10 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
type CompatibilityDiagnostic,
type IssueCommentClient,
formatCompatibilityComment,
getCompatibilityDiagnostics,
upsertCompatibilityComment,
} from '../src/lib/cssBrowserCompatibility';
interface PullRequestEvent {
@@ -33,12 +32,6 @@ interface GitBlobResponse {
encoding: string;
}
interface IssueComment {
body: string;
id: number;
user: { login: string } | null;
}
class GitHubRequestError extends Error {
constructor(
readonly status: number,
@@ -48,7 +41,7 @@ class GitHubRequestError extends Error {
}
}
class GitHubApi implements IssueCommentClient {
class GitHubApi {
constructor(
private readonly repository: string,
private readonly token: string
@@ -114,34 +107,6 @@ class GitHubApi implements IssueCommentClient {
async getFileAtRef(path: string, ref: string): Promise<string> {
return this.getContent(path, ref);
}
async listIssueComments(issueNumber: number): Promise<IssueComment[]> {
const comments: IssueComment[] = [];
for (let page = 1; ; page += 1) {
const result = await this.request<IssueComment[]>(
`/repos/${this.repository}/issues/${issueNumber}/comments?per_page=100&page=${page}`
);
comments.push(...result);
if (result.length < 100) {
return comments;
}
}
}
async createIssueComment(issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${this.repository}/issues/${issueNumber}/comments`, {
body: JSON.stringify({ body }),
method: 'POST',
});
}
async updateIssueComment(commentId: number, body: string): Promise<void> {
await this.request(`/repos/${this.repository}/issues/comments/${commentId}`, {
body: JSON.stringify({ body }),
method: 'PATCH',
});
}
}
async function getBrowserslist(api: GitHubApi, headSha: string): Promise<string[]> {
@@ -175,6 +140,83 @@ async function getBaseContent(
}
}
function report(diagnostics: CompatibilityDiagnostic[]): boolean {
if (diagnostics.length === 0) {
console.log('CSS browser compatibility check passed.');
return true;
}
console.error(
`${diagnostics.length} newly added CSS declaration(s) are not fully supported by the configured Browserslist targets:`
);
for (const diagnostic of diagnostics) {
// Workflow command so the failure is annotated on the PR diff.
console.error(
`::error file=${diagnostic.file},line=${diagnostic.line},col=${diagnostic.column}::${diagnostic.property} is not supported by ${diagnostic.unsupportedBrowsers}`
);
}
return false;
}
/** Same check as CI, but against a local `git diff` instead of the GitHub API. */
async function runLocal(baseRef: string): Promise<boolean> {
const git = (...args: string[]) =>
execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const root = git('rev-parse', '--show-toplevel').trim();
const mergeBase = git('merge-base', baseRef, 'HEAD').trim();
const browsers = (
JSON.parse(await readFile(join(root, 'packages/gitbook/package.json'), 'utf8')) as {
browserslist?: string[];
}
).browserslist;
if (!browsers?.length) {
throw new Error('packages/gitbook/package.json must define a Browserslist configuration.');
}
// Working tree, so uncommitted changes are checked too.
const changes = git(
'diff',
'--name-status',
'--find-renames',
'--diff-filter=ACMR',
mergeBase,
'--',
'*.css'
)
.split('\n')
.filter(Boolean)
.map((line) => {
const [status, ...paths] = line.split('\t');
const previousPath = paths.length > 1 ? paths[0] : undefined;
const path = paths.at(-1) as string;
return { added: status?.startsWith('A'), path, previousPath };
});
const diagnostics: CompatibilityDiagnostic[] = [];
for (const change of changes) {
let base = '';
if (!change.added) {
try {
base = git('show', `${mergeBase}:${change.previousPath ?? change.path}`);
} catch {
base = '';
}
}
diagnostics.push(
...(await getCompatibilityDiagnostics({
base,
browsers,
file: change.path,
head: await readFile(join(root, change.path), 'utf8'),
}))
);
}
console.log(`Checked ${changes.length} changed CSS file(s) against ${baseRef}.`);
return report(diagnostics);
}
async function run(): Promise<boolean> {
const token = process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY;
@@ -212,34 +254,19 @@ async function run(): Promise<boolean> {
);
}
const comment = formatCompatibilityComment({
diagnostics,
headSha: pullRequest.head.sha,
repository,
});
await upsertCompatibilityComment({
body: comment,
createIfMissing: diagnostics.length > 0,
client: api,
issueNumber: pullRequest.number,
});
if (diagnostics.length === 0) {
console.log('CSS browser compatibility check passed.');
return true;
}
console.error('Unsupported CSS declarations found:');
for (const diagnostic of diagnostics) {
console.error(
`${diagnostic.file}:${diagnostic.line} ${diagnostic.property} — ${diagnostic.unsupportedBrowsers}`
);
}
return false;
return report(diagnostics);
}
const localFlagIndex = process.argv.indexOf('--local');
try {
process.exitCode = (await run()) ? 0 : 1;
let success: boolean;
if (localFlagIndex === -1) {
success = await run();
} else {
success = await runLocal(process.argv[localFlagIndex + 1] ?? 'origin/main');
}
process.exitCode = success ? 0 : 1;
} catch (error) {
console.error(error);
process.exitCode = 1;
@@ -0,0 +1,85 @@
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { FontSourcesData } from '../src/fonts/types';
import { getFontDefinitionsHash } from './font-definitions-hash';
const CONCURRENCY = 8;
const ATTEMPTS = 6;
const scriptDir = dirname(fileURLToPath(import.meta.url));
const fontsDir = join(scriptDir, '../src/fonts');
const outputDir = join(scriptDir, '../public/~gitbook/static/fonts');
const sourcesPath = join(fontsDir, 'generated/sources.json');
const readSources = async () => JSON.parse(await readFile(sourcesPath, 'utf8')) as FontSourcesData;
let sourcesData = await readSources();
if (sourcesData.definitionsHash !== (await getFontDefinitionsHash())) {
console.warn(
'definitions.ts changed since the font manifest was generated — regenerating. Commit the changes in src/fonts/generated.'
);
await import('./generate-font-faces');
sourcesData = await readSources();
}
const { google, local } = sourcesData;
const sources = new Map<string, string>(Object.entries(local));
for (const [googleId, { prefix, files }] of Object.entries(google)) {
for (const file of files) {
sources.set(`${googleId}/${file}`, `${prefix}/${file}`);
}
}
// Faces move between releases; stale files would otherwise pile up in the deployed assets.
await mkdir(outputDir, { recursive: true });
for (const entry of await readdir(outputDir, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) {
const file = relative(outputDir, join(entry.parentPath, entry.name));
if (!sources.has(file)) {
await rm(join(outputDir, file));
}
}
}
const pending = [...sources].filter(([file]) => !Bun.file(join(outputDir, file)).size);
if (pending.length > 0) {
console.log(`Downloading ${pending.length} font files…`);
}
const queue = pending.values();
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
async function worker() {
for (const [file, source] of queue) {
const target = join(outputDir, file);
await mkdir(dirname(target), { recursive: true });
if (source.startsWith('./')) {
await copyFile(join(fontsDir, source), target);
continue;
}
await writeFile(target, await fetchWithRetries(source));
}
}
// Google Fonts intermittently refuses connections when a build asks for hundreds of files at once,
// and a single miss fails the whole build.
async function fetchWithRetries(url: string): Promise<Buffer> {
for (let attempt = 1; ; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (attempt >= ATTEMPTS) {
throw new Error(`Unable to download ${url}: ${error}`);
}
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
}
}
@@ -0,0 +1,11 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export async function getFontDefinitionsHash(): Promise<string> {
const path = join(dirname(fileURLToPath(import.meta.url)), '../src/fonts/definitions.ts');
return createHash('sha256')
.update(await readFile(path))
.digest('hex');
}
@@ -0,0 +1,194 @@
// Regenerates the committed font manifest from the Google Fonts CSS API. download-fonts.ts runs it
// automatically when definitions.ts changed since the last generation; `bun run generate:fonts`
// forces it (e.g. to pick up new Google Fonts releases).
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ABC_FAVORIT, FONT_DEFINITIONS, type FontDefinition } from '../src/fonts/definitions';
import type {
FontFacesData,
FontFallbackFaceData,
FontSourcesData,
FontVariantData,
} from '../src/fonts/types';
import { getFontDefinitionsHash } from './font-definitions-hash';
// Google Fonts picks the file format from the user agent — the same modern Chrome `next/font` sends,
// so we keep getting compact woff2 (and vector rather than bitmap emoji).
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36';
// The precalculated metrics `next/font/google` uses, so the fallback faces stay identical.
const { calculateSizeAdjustValues } = createRequire(import.meta.url)(
'next/dist/server/font-utils'
) as {
calculateSizeAdjustValues: (family: string) => {
ascent: string;
descent: string;
lineGap: string;
fallbackFont: string;
sizeAdjust: string;
};
};
type ResolvedFace = {
weight: string;
style: string;
file: string;
source: string;
unicodeRange: string;
};
const scriptDir = dirname(fileURLToPath(import.meta.url));
const faces: FontFacesData = {};
const sources: FontSourcesData = {
definitionsHash: await getFontDefinitionsHash(),
google: {},
local: {},
};
for (const [name, definition] of Object.entries(FONT_DEFINITIONS)) {
const resolved = definition.googleId
? await getGoogleFaces(definition)
: await getABCFavoritFaces();
if (resolved.length === 0) {
throw new Error(`No font faces resolved for ${name}`);
}
recordSources(definition, resolved);
const subsets = [...new Set(resolved.map((face) => face.unicodeRange))];
const variants = new Map<string, FontVariantData>();
for (const face of resolved) {
const key = `${face.weight}|${face.style}`;
let variant = variants.get(key);
if (!variant) {
variant = { weight: face.weight, style: face.style, files: [] };
variants.set(key, variant);
}
variant.files[subsets.indexOf(face.unicodeRange)] = face.file;
}
faces[name] = {
family: definition.family,
variable: definition.variable,
fontFamilyValue: [
`"${definition.family}"`,
...(definition.adjustFallback ? [`"${definition.family} Fallback"`] : []),
...definition.fallback,
].join(','),
subsets,
variants: [...variants.values()],
fallbackFace: definition.adjustFallback ? getFallbackFace(name, definition.family) : null,
...(definition.googleId ? {} : { ascentOverride: ABC_FAVORIT.ascentOverride }),
};
}
const generatedDir = join(scriptDir, '../src/fonts/generated');
await writeFile(join(generatedDir, 'faces.json'), `${JSON.stringify(faces, null, 4)}\n`);
await writeFile(join(generatedDir, 'sources.json'), `${JSON.stringify(sources, null, 4)}\n`);
/** Google serves every file of a family from one versioned directory, so only the names differ. */
function recordSources(definition: FontDefinition, resolved: ResolvedFace[]) {
if (!definition.googleId) {
for (const face of resolved) {
sources.local[face.file] = face.source;
}
return;
}
const prefixes = new Set(
resolved.map((face) => face.source.slice(0, face.source.lastIndexOf('/')))
);
if (prefixes.size !== 1) {
throw new Error(`${definition.family} spans several Google Fonts directories`);
}
sources.google[definition.googleId] = {
prefix: [...prefixes][0] as string,
files: [...new Set(resolved.map((face) => basename(face.file)))],
};
}
async function getGoogleFaces(definition: FontDefinition): Promise<ResolvedFace[]> {
const { family, googleId, weights } = definition;
const url = `https://fonts.googleapis.com/css2?family=${family.replaceAll(' ', '+')}:wght@${weights.join(';')}&display=swap`;
const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
if (!response.ok) {
throw new Error(`Unable to fetch ${family} from Google Fonts: ${response.status} (${url})`);
}
const resolved = [...(await response.text()).matchAll(/@font-face\s*\{([^}]*)\}/g)].map(
(match) => {
const block = match[1] ?? '';
const source = read(block, 'src')?.match(/url\((https:[^)]+\.woff2)\)/)?.[1];
const weight = read(block, 'font-weight');
const unicodeRange = read(block, 'unicode-range');
if (!source || !weight || !unicodeRange) {
throw new Error(`Unexpected @font-face for ${family}: ${block}`);
}
return {
weight,
style: read(block, 'font-style') ?? 'normal',
// Google's filenames are content-addressed, so the asset can stay immutable.
file: `${googleId}/${basename(new URL(source).pathname)}`,
source,
unicodeRange: unicodeRange.toLowerCase(),
};
}
);
const missing = weights.filter((weight) => !resolved.some((face) => face.weight === weight));
if (missing.length > 0) {
throw new Error(`Google Fonts returned no ${missing.join('/')} weight for ${family}`);
}
return resolved;
}
async function getABCFavoritFaces(): Promise<ResolvedFace[]> {
return Promise.all(
ABC_FAVORIT.sources.map(async (source) => {
const path = join(scriptDir, '../src/fonts/ABCFavorit', source.file);
const digest = createHash('sha256')
.update(await readFile(path))
.digest('hex');
return {
weight: source.weight,
style: source.style,
file: `abcfavorit/${digest.slice(0, 16)}.woff2`,
source: `./ABCFavorit/${source.file}`,
unicodeRange: '',
};
})
);
}
function getFallbackFace(name: string, family: string): FontFallbackFaceData {
if (name === 'ABCFavorit') {
return { family: `${family} Fallback`, local: 'Arial', ...ABC_FAVORIT.fallbackMetrics };
}
const metrics = calculateSizeAdjustValues(family);
return {
family: `${family} Fallback`,
local: metrics.fallbackFont,
ascentOverride: `${metrics.ascent}%`,
descentOverride: `${metrics.descent}%`,
lineGapOverride: `${metrics.lineGap}%`,
sizeAdjust: `${metrics.sizeAdjust}%`,
};
}
function read(block: string, property: string): string | undefined {
return block.match(new RegExp(`${property}\\s*:\\s*([^;]+);`))?.[1]?.trim();
}
@@ -2,6 +2,7 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { resolveUpstreamAuthURL } from '@/lib/site-auth-urls';
/**
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
@@ -12,20 +13,10 @@ export async function GET(
) {
const { context } = await getDynamicSiteContext(await params);
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
const loginURL = resolveUpstreamAuthURL({
siteAuthURL: context.site.urls.login,
location: request.nextUrl.searchParams.get('location'),
});
if (!context.site.urls.login) {
return NextResponse.redirect(noLoginFallbackURL);
}
try {
const loginURL = new URL(context.site.urls.login);
const location = request.nextUrl.searchParams.get('location');
if (location) {
loginURL.searchParams.set('location', location);
}
return NextResponse.redirect(loginURL);
} catch (_error) {
return NextResponse.redirect(noLoginFallbackURL);
}
return NextResponse.redirect(loginURL ?? noLoginFallbackURL);
}
@@ -8,10 +8,12 @@ import {
getSiteURLDataFromParams,
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { resolveUpstreamAuthURL } from '@/lib/site-auth-urls';
import { getVisitorAuthCookieName } from '@/lib/visitors';
/**
* Clear the site-scoped auth session cookies and redirect to the site root.
* Clear the site-scoped auth session cookies and redirect to the upstream auth provider
* logout URL of the site, or to the site root when not configured.
*/
export async function GET(
request: NextRequest,
@@ -30,8 +32,14 @@ export async function GET(
)
);
// TODO: Redirect to the site root for now. Once the API supports it,
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
// when defined in visitor auth settings.
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
const noLogoutFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
const logoutURL = resolveUpstreamAuthURL({
siteAuthURL: context.site.urls.logout,
// Default the location to the site root, so the upstream logout can send the visitor back
// to the site. On a site behind visitor auth, coming back re-enters the login flow and
// surfaces the upstream login page, as the visitor no longer has a session on either side.
location: request.nextUrl.searchParams.get('location') ?? '/',
});
return NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
}
@@ -1,13 +1,12 @@
import { headers } from 'next/headers';
import type { RouteLayoutParams } from '@/app/utils';
import { type RouteLayoutParams, getSiteURLDataFromParams } from '@/app/utils';
import {
EmbeddableRootLayout,
generateEmbeddableMetadata,
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
interface SiteStaticLayoutProps {
@@ -18,9 +17,12 @@ export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const resolvedParams = await params;
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(resolvedParams);
const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
// The forced theme (`?theme=`) comes through the route context (set by the middleware), not a
// request header, so the embed can honor it while staying statically rendered. RND-11571
const forcedTheme = getSiteURLDataFromParams(resolvedParams).embedTheme ?? null;
return (
<EmbeddableRootLayout
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
import type { RouteLayoutParams } from '@/app/utils';
import { type RouteLayoutParams, getSiteURLDataFromParams } from '@/app/utils';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
@@ -12,11 +12,15 @@ export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// Forward the forced theme to the default tab so `?theme=` isn't lost on the redirect. It comes
// from the route context (set by the middleware), not the query, so the embed stays static. RND-11571
const embedTheme = getSiteURLDataFromParams(params).embedTheme;
const query = embedTheme ? `?theme=${embedTheme}` : '';
// If assistant is enabled, redirect to assistant, otherwise to docs
if (isAIChatEnabled(context.customization.ai.mode)) {
redirect(`${baseURL}/assistant`);
redirect(`${baseURL}/assistant${query}`);
} else {
redirect(`${baseURL}/page/`);
redirect(`${baseURL}/page/${query}`);
}
}
@@ -9,9 +9,8 @@ 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 { resolvePagePath } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
@@ -199,7 +198,11 @@ export async function handleMcpRequest(
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
@@ -410,7 +413,11 @@ export async function handleMcpRequest(
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
@@ -49,15 +49,15 @@ export default async function Page(props: {
const interactionId = searchParams.gb_oauth_state;
if (!interactionId || !jwtToken) {
return <ConsentError />;
return <ConsentError context={context} />;
}
try {
const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken });
return <ConsentScreen siteId={siteId} siteTitle={context.site.title} consent={consent} />;
return <ConsentScreen siteId={siteId} context={context} consent={consent} />;
} catch (error) {
if (error instanceof SiteOAuthConsentError) {
return <ConsentError />;
return <ConsentError context={context} />;
}
throw error;
}
@@ -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,51 @@
import { describe, expect, it } from 'bun:test';
import { orderSearchResultGroups } from './orderSearchResults';
describe('orderSearchResultGroups', () => {
it('reconstructs canonical page ranks across space groups before context records', () => {
const results = orderSearchResultGroups([
{
type: 'pages',
results: [
{
rank: 4,
result: { title: 'Snyk CLI documentation', score: 42, rank: 4 },
},
{
rank: 2,
result: { title: 'Types of automations', score: 12, rank: 2 },
},
],
},
{
type: 'context',
results: [{ title: 'Snyk integration context', score: 100, rank: undefined }],
},
{
type: 'pages',
results: [
{
rank: 1,
result: { title: 'Snyk CLI', score: 1, rank: 1 },
},
{
rank: 3,
result: { title: 'Method URL', score: 24, rank: 3 },
},
],
},
]);
expect(results.map(({ title }) => title)).toEqual([
'Snyk CLI',
'Types of automations',
'Method URL',
'Snyk CLI documentation',
'Snyk integration context',
]);
expect(results[0]?.score).toBe(1);
expect(results[3]?.score).toBe(42);
expect(results.map(({ rank }) => rank)).toEqual([1, 2, 3, 4, undefined]);
});
});
@@ -0,0 +1,34 @@
type RankedPageResult<TResult> = {
rank: number;
result: TResult;
};
export type SearchResultGroup<TResult> =
| { type: 'pages'; results: RankedPageResult<TResult>[] }
| { type: 'context'; results: TResult[] };
/** Reconstruct the backend's global page order after it grouped results by space. */
export function orderSearchResultGroups<TResult>(groups: SearchResultGroup<TResult>[]): TResult[] {
const pages: (RankedPageResult<TResult> & { inputOrder: number })[] = [];
const context: TResult[] = [];
for (const group of groups) {
if (group.type === 'pages') {
const inputOffset = pages.length;
pages.push(
...group.results.map((entry, index) => ({
...entry,
inputOrder: inputOffset + index,
}))
);
} else {
context.push(...group.results);
}
}
pages.sort((left, right) => {
return left.rank - right.rank || left.inputOrder - right.inputOrder;
});
return [...pages.map(({ result }) => result), ...context];
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'bun:test';
import type { 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 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,
});
}
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('resolves a relative page path through the published site URL', () => {
const result = transformPage(createPageResult({ path: 'guides/getting-started' }));
expect(result.href).toBe('/guides/getting-started');
});
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('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,26 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server';
import { orderSearchResultGroups } from './orderSearchResults';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
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 } from '@/lib/sites';
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
@@ -51,8 +40,8 @@ export async function POST(request: NextRequest) {
),
]);
const results = searchResults
.flatMap((resultItem) => {
const results = orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
@@ -63,7 +52,7 @@ export async function POST(request: NextRequest) {
score: resultItem.score,
};
return [{ score: resultItem.score, items: [result] }];
return { type: 'context' as const, results: [result] };
}
const found = findSiteSpaceBy(
@@ -71,142 +60,23 @@ export async function POST(request: NextRequest) {
(siteSpace) => siteSpace.space.id === resultItem.id
);
return resultItem.pages.map((pageItem) => ({
score: pageItem.score,
items: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
}));
return {
type: 'pages' as const,
results: resultItem.pages.map((pageItem) => ({
rank: pageItem.rank,
result: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
})),
};
})
.sort((a, b) => b.score - a.score)
.flatMap((group) => group.items);
);
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));
// The deployed API already returns this field, but older generated clients and responses do not.
const resultType =
'resultType' in pageItem &&
(pageItem.resultType === 'page' || pageItem.resultType === 'section')
? pageItem.resultType
: undefined;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: pageItem.score,
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 search API returns each page's sections ordered highest-score-first and caps them at one
// per page, so the first section is the best-scoring one to use as a body 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', {
@@ -127,7 +127,15 @@ export async function GET(
'Show me tips and tricks',
],
tabs: ['assistant', 'search', 'docs'],
closeButton: useCustomTrigger
closeButton: useCustomTrigger,
// Host-page action: posts a message into the chat, like an embedding site would.
actions: [
{
icon: 'rocket',
label: 'Ask about pricing',
onClick: () => window.GitBook('postUserMessage', 'How much does GitBook cost?'),
},
],
});
if (useCustomTrigger) {
@@ -1,4 +1,4 @@
import type { RouteLayoutParams } from '@/app/utils';
import { type RouteLayoutParams, getSiteURLDataFromParams } from '@/app/utils';
import {
EmbeddableRootLayout,
generateEmbeddableMetadata,
@@ -15,14 +15,19 @@ export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const resolvedParams = await params;
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(resolvedParams);
const withTracking = shouldTrackEvents();
// The forced theme (`?theme=`) is threaded through the route context by the middleware so the
// embed stays statically rendered — read it from the params rather than a request header. RND-11571
const forcedTheme = getSiteURLDataFromParams(resolvedParams).embedTheme ?? null;
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
>
{children}
</EmbeddableRootLayout>

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