mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 11:52:10 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27441c0605 | |||
| dd1ca93837 | |||
| 08e61c89f0 | |||
| 587398ddd4 | |||
| 321b06d873 | |||
| 50eee8fec9 | |||
| 1f250d1fa8 | |||
| ec709c3d99 | |||
| 46b3a18b25 | |||
| 2ff77e15b5 | |||
| d7c867aa63 | |||
| bcd1c41aa1 | |||
| 77058e3f17 | |||
| 8ad7465801 | |||
| 1b7ac0e41b | |||
| 5f88f4e680 | |||
| 7009bb9311 | |||
| e460ec381e | |||
| 0a82628298 | |||
| 18ff11b68b | |||
| 3bf55cef10 | |||
| e041e49f11 | |||
| c1b5be05eb | |||
| 7543eb83ad | |||
| 523b7cd4b6 | |||
| af698a49f5 | |||
| db67585ee2 | |||
| a566153d98 | |||
| 530f98a9c4 | |||
| 39391259c8 | |||
| 35be3835f3 | |||
| 4452fd793c | |||
| 90566879d2 | |||
| 40a879ad5f | |||
| cc441c99c6 | |||
| 64e143a15b | |||
| 8c10d92f77 | |||
| 0a22eb5340 | |||
| d9fc4608e3 | |||
| ded2f560e3 | |||
| a9c5d1b546 | |||
| 0ff21b7ae2 | |||
| 94a496c561 | |||
| 148a43ac01 | |||
| 5da854f09c | |||
| b772f74c2b | |||
| e0bd04f641 | |||
| 8d7c3edda8 | |||
| 80951e9889 | |||
| 185dd8337e | |||
| da7fb13d83 | |||
| 49c993f181 | |||
| 90b54682e5 | |||
| 1dccf8f06c | |||
| 810244e8a9 | |||
| 0dd2f4fbcd | |||
| 8a700222e6 | |||
| b13fd91afc | |||
| 8a6baaba6a | |||
| 48fba7cb49 | |||
| 6928a9b00a | |||
| 7bab574c63 | |||
| 75bdff3ff3 | |||
| 56c25587db | |||
| cf4efc7213 | |||
| 1ef71609e7 | |||
| 195c9e6b84 | |||
| 09f39f8300 | |||
| 9002f6598a | |||
| 01c9b059c6 | |||
| 6d02b8ab72 | |||
| 177ef8582a | |||
| 78c589ffba | |||
| e4b214e6bd | |||
| 64ce9e180d | |||
| c64d3a50e8 | |||
| fd070ce9ca | |||
| 048c4e4c70 | |||
| fb01dc9ecb | |||
| d33e570bc7 | |||
| 87fd234d55 | |||
| 0f32eb17d1 | |||
| 3e29680792 | |||
| b3db1c58c8 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Bump Next.js to 16.3.3.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix inline Ask AI buttons opening a configured custom assistant.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Disable click-to-zoom for inline line sized images.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix host action buttons in the Docs Embed not reaching the assistant.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix suggested question clicks in the embed search not opening the assistant.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support external links in published site navigation.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Hide card fields that render no content, along with their title
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Keep embed demos and frames on the deployment that served them.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Serve an indexable `X-Robots-Tag` on markdown pages requested by AI agents
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drop a column instead of shrinking them all when a section group dropdown runs out of room
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prewarm published search caches when readers open the search interface.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Render horizontal and vertical merged table cells on published pages.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Allow published search to be scoped to any visible site section.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep keyboard focus within search while the results are open.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Show the containing section name when hovering a direct link to a space.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/react-contentkit": patch
|
||||
---
|
||||
|
||||
Support query params in the `@webframe.navigate` action.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only track embed view events once the frame is actually shown to the reader
|
||||
@@ -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:
|
||||
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+6
-15
@@ -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);
|
||||
}
|
||||
|
||||
+13
-5
@@ -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);
|
||||
}
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+7
-3
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -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}"` }],
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
+14
@@ -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 });
|
||||
}
|
||||
+51
@@ -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]);
|
||||
});
|
||||
});
|
||||
+34
@@ -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];
|
||||
}
|
||||
+153
@@ -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]);
|
||||
});
|
||||
});
|
||||
+22
-152
@@ -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];
|
||||
}
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+7
-2
@@ -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>
|
||||
|
||||
+7
-3
@@ -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 { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
@@ -14,11 +14,15 @@ export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(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), so reading it keeps this route 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user