diff --git a/.changeset/ask-ai-paragraph-margin-button.md b/.changeset/ask-ai-paragraph-margin-button.md deleted file mode 100644 index a273e6a38..000000000 --- a/.changeset/ask-ai-paragraph-margin-button.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable. diff --git a/.changeset/assistant-type-while-answering.md b/.changeset/assistant-type-while-answering.md new file mode 100644 index 000000000..760d05d1b --- /dev/null +++ b/.changeset/assistant-type-while-answering.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes. diff --git a/.changeset/cards-carousel-layout.md b/.changeset/cards-carousel-layout.md new file mode 100644 index 000000000..fe7f4bd7e --- /dev/null +++ b/.changeset/cards-carousel-layout.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid. diff --git a/.changeset/embed-navigate-to-page-normalize.md b/.changeset/embed-navigate-to-page-normalize.md new file mode 100644 index 000000000..a2d7e41bc --- /dev/null +++ b/.changeset/embed-navigate-to-page-normalize.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL. diff --git a/.changeset/exact-light-tint-background.md b/.changeset/exact-light-tint-background.md new file mode 100644 index 000000000..1154980f2 --- /dev/null +++ b/.changeset/exact-light-tint-background.md @@ -0,0 +1,6 @@ +--- +"@gitbook/colors": patch +"gitbook": patch +--- + +A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone. diff --git a/.changeset/few-parts-lead.md b/.changeset/few-parts-lead.md new file mode 100644 index 000000000..12eb6e774 --- /dev/null +++ b/.changeset/few-parts-lead.md @@ -0,0 +1,7 @@ +--- +"@gitbook/openapi-parser": patch +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Add missing link reference to OpenAPI models diff --git a/.changeset/hot-views-judge.md b/.changeset/hot-views-judge.md new file mode 100644 index 000000000..384096100 --- /dev/null +++ b/.changeset/hot-views-judge.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add cover image background mode and masks diff --git a/.changeset/inline-button-size.md b/.changeset/inline-button-size.md new file mode 100644 index 000000000..7edd4cbb0 --- /dev/null +++ b/.changeset/inline-button-size.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Button blocks now respect the `size` option, so you can render small, medium, or large buttons. diff --git a/.changeset/markdown-ask-goal-param.md b/.changeset/markdown-ask-goal-param.md deleted file mode 100644 index d10324fda..000000000 --- a/.changeset/markdown-ask-goal-param.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it. diff --git a/.changeset/mcp-ask-question-tool.md b/.changeset/mcp-ask-question-tool.md new file mode 100644 index 000000000..25eedc203 --- /dev/null +++ b/.changeset/mcp-ask-question-tool.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled. diff --git a/.changeset/mcp-available-badge.md b/.changeset/mcp-available-badge.md deleted file mode 100644 index 67a7b53bd..000000000 --- a/.changeset/mcp-available-badge.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@gitbook/openapi-parser": patch -"@gitbook/react-openapi": patch -"gitbook": patch ---- - -Add an "Available in MCP" badge on OpenAPI operations marked with `x-gitbook-mcp: true`. When `x-gitbook-mcp-url` is set (on the operation, path, or root — most specific wins), the badge becomes a button that copies the MCP server URL to the clipboard. diff --git a/.changeset/mcp-search-best-section.md b/.changeset/mcp-search-best-section.md deleted file mode 100644 index 189c1799a..000000000 --- a/.changeset/mcp-search-best-section.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Reduce the size of `searchDocumentation` MCP responses by returning only the best-matching section per page instead of concatenating every section body. diff --git a/.changeset/rnd-11356-remove-gbo-bestsection.md b/.changeset/rnd-11356-remove-gbo-bestsection.md new file mode 100644 index 000000000..d0adb74b5 --- /dev/null +++ b/.changeset/rnd-11356-remove-gbo-bestsection.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change. diff --git a/.changeset/rnd-11616-responses-selector-sync.md b/.changeset/rnd-11616-responses-selector-sync.md new file mode 100644 index 000000000..ab563228f --- /dev/null +++ b/.changeset/rnd-11616-responses-selector-sync.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once. diff --git a/.changeset/rnd-11620-changed-pages-toolbar.md b/.changeset/rnd-11620-changed-pages-toolbar.md deleted file mode 100644 index 8847645f3..000000000 --- a/.changeset/rnd-11620-changed-pages-toolbar.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Show changed pages in preview toolbars for change requests and revisions. diff --git a/.changeset/rnd-11643-fix-system-theme-flash.md b/.changeset/rnd-11643-fix-system-theme-flash.md new file mode 100644 index 000000000..621e8270d --- /dev/null +++ b/.changeset/rnd-11643-fix-system-theme-flash.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint. diff --git a/.changeset/rnd-11847-mcp-send-feedback.md b/.changeset/rnd-11847-mcp-send-feedback.md new file mode 100644 index 000000000..dc016291b --- /dev/null +++ b/.changeset/rnd-11847-mcp-send-feedback.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback. diff --git a/.changeset/rnd-11847-separate-prompt-block-actions.md b/.changeset/rnd-11847-separate-prompt-block-actions.md new file mode 100644 index 000000000..b93ee22c5 --- /dev/null +++ b/.changeset/rnd-11847-separate-prompt-block-actions.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded). diff --git a/.changeset/rnd-11876-embed-standalone-turbo-output.md b/.changeset/rnd-11876-embed-standalone-turbo-output.md new file mode 100644 index 000000000..14f556179 --- /dev/null +++ b/.changeset/rnd-11876-embed-standalone-turbo-output.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed. diff --git a/.changeset/scroll-container-overflow-state.md b/.changeset/scroll-container-overflow-state.md new file mode 100644 index 000000000..eda768fcd --- /dev/null +++ b/.changeset/scroll-container-overflow-state.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results). diff --git a/.changeset/section-dropdown-embedded-empty.md b/.changeset/section-dropdown-embedded-empty.md deleted file mode 100644 index dfd70d68f..000000000 --- a/.changeset/section-dropdown-embedded-empty.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped. diff --git a/.changeset/shorten-paragraph-ask-tooltip.md b/.changeset/shorten-paragraph-ask-tooltip.md new file mode 100644 index 000000000..19028516b --- /dev/null +++ b/.changeset/shorten-paragraph-ask-tooltip.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow. diff --git a/.changeset/spicy-pages-rescue.md b/.changeset/spicy-pages-rescue.md deleted file mode 100644 index 123ecbefb..000000000 --- a/.changeset/spicy-pages-rescue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gitbook": patch ---- - -Improve the 404 page: remove the table of contents sidebar and center the content, suggest related pages (ranked from the already-cached search index, so no extra request per 404), show a search or assistant input depending on the site's AI configuration, and move "Go to homepage" to the bottom as a last resort. diff --git a/.changeset/split-site-search-requests.md b/.changeset/split-site-search-requests.md new file mode 100644 index 000000000..53238f16f --- /dev/null +++ b/.changeset/split-site-search-requests.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted. diff --git a/.changeset/sticky-api-page-actions.md b/.changeset/sticky-api-page-actions.md new file mode 100644 index 000000000..b8a2efe1c --- /dev/null +++ b/.changeset/sticky-api-page-actions.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations. diff --git a/.changeset/strict-jokes-play.md b/.changeset/strict-jokes-play.md new file mode 100644 index 000000000..fa5130941 --- /dev/null +++ b/.changeset/strict-jokes-play.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Hide unfocusable unlabelled button from screen readers diff --git a/.changeset/stricter-back-to-space.md b/.changeset/stricter-back-to-space.md new file mode 100644 index 000000000..41a2cefe1 --- /dev/null +++ b/.changeset/stricter-back-to-space.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space. diff --git a/.changeset/wacky-eyes-post.md b/.changeset/wacky-eyes-post.md new file mode 100644 index 000000000..0ef6f6347 --- /dev/null +++ b/.changeset/wacky-eyes-post.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +a11y screen reader fixes diff --git a/.changeset/webframe-navigate.md b/.changeset/webframe-navigate.md new file mode 100644 index 000000000..52baed3b1 --- /dev/null +++ b/.changeset/webframe-navigate.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-contentkit": patch +"gitbook": patch +--- + +Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar. diff --git a/.changeset/webframe-page-context.md b/.changeset/webframe-page-context.md new file mode 100644 index 000000000..041df33bc --- /dev/null +++ b/.changeset/webframe-page-context.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-contentkit": patch +"gitbook": patch +--- + +Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims. diff --git a/.changeset/whole-hands-report.md b/.changeset/whole-hands-report.md new file mode 100644 index 000000000..11ed011ec --- /dev/null +++ b/.changeset/whole-hands-report.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix an issue where certain keywords could cause an exception when rendering emojis diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 8e5ee4823..ae38528b4 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -53,11 +53,13 @@ runs: GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL + GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY - GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL + GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2 + GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL @@ -66,6 +68,9 @@ runs: env: GITBOOK_RUNTIME: cloudflare GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} + GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} + # Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default. + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }} shell: bash - name: Upload the DO worker diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index bb7b81750..d6171290a 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -55,11 +55,13 @@ runs: GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL + GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY - GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL + GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2 + GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL @@ -72,6 +74,7 @@ runs: echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local + echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---" cat .vercel/.env.${{ inputs.environment }}.local - name: Build Project Artifacts @@ -82,6 +85,8 @@ runs: VERCEL_PROJECT_ID: ${{ inputs.vercelProject }} GITBOOK_RUNTIME: vercel GITBOOK_HEAD_SHA: ${{ inputs.headSha }} + # Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default. + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }} - name: Deploy Project Artifacts to Vercel id: deploy shell: bash diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index 726580de7..44269f1d1 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -168,7 +168,7 @@ jobs: name: Visual Testing Customers v2 needs: deploy-v2-vercel if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }} - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 @@ -192,7 +192,7 @@ jobs: name: Visual Testing Customers v2 (Cloudflare) needs: deploy-v2-cloudflare if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }} - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 41e810c5b..0dd765268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,3 +75,7 @@ Save as `.changeset/.md`, then commit it separately with message: `changes ## Formatting Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing. + +## Comments + +Comment to explain *why*, not *what* — the code already shows what it does. Keep comments short, ideally a single line. Avoid multi-line block comments that narrate mechanics a reader can follow from the code; they add noise and go stale. Reserve longer comments for genuinely non-obvious rationale: a subtle invariant, or a workaround and the reason it exists. diff --git a/README.md b/README.md index 767bd1cb1..36efc7079 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ GitBook +

+

GitBook

diff --git a/assets/gitbook_icon_dark.svg b/assets/gitbook_icon_dark.svg new file mode 100644 index 000000000..b70471805 --- /dev/null +++ b/assets/gitbook_icon_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/bun.lock b/bun.lock index 072d9c885..30b3d0904 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.18", + "turbo": "^2.10.3", "vercel": "50.37.3", }, }, @@ -74,14 +74,14 @@ }, "packages/expr": { "name": "@gitbook/expr", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "acorn": "^8.15.0", "acorn-loose": "^8.5.2", "acorn-walk": "^8.3.4", "assert-never": "catalog:", "escodegen": "^2.1.0", - "eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a", + "eval-estree-expression": "^3.0.1", }, "devDependencies": { "@tsconfig/node20": "catalog:", @@ -109,7 +109,7 @@ }, "packages/gitbook": { "name": "gitbook", - "version": "0.27.1", + "version": "0.27.2", "dependencies": { "@cloudflare/workers-types": "^4.20251011.0", "@gitbook/api": "catalog:", @@ -201,8 +201,8 @@ "zustand": "^5.0.3", }, "devDependencies": { - "@argos-ci/playwright": "^6.4.2", - "@playwright/test": "^1.58.2", + "@argos-ci/playwright": "^7.2.0", + "@playwright/test": "^1.61.1", "@scalar/api-client-react": "catalog:", "@tailwindcss/postcss": "^4.1.11", "@types/js-cookie": "^3.0.6", @@ -253,7 +253,7 @@ }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", - "version": "3.0.11", + "version": "3.0.12", "dependencies": { "@scalar/json-magic": "^0.9.4", "@scalar/openapi-parser": "^0.24.5", @@ -312,7 +312,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.5.14", + "version": "1.5.15", "dependencies": { "@gitbook/expr": "workspace:*", "@gitbook/openapi-parser": "workspace:*", @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.186.0", + "@gitbook/api": "0.189.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -392,15 +392,15 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@argos-ci/api-client": ["@argos-ci/api-client@0.16.0", "", { "dependencies": { "debug": "^4.4.3", "openapi-fetch": "^0.15.0" } }, "sha512-BG6g+AZABKN8W2syzfPWKhPxxrAB9Wjp2iNS11R4IskHDV6T41+qeQDpT88GOq6eUZ64Sjzoh/nXG+9EpNxtfw=="], + "@argos-ci/api-client": ["@argos-ci/api-client@0.24.0", "", { "dependencies": { "debug": "^4.4.3", "openapi-fetch": "^0.17.0", "p-retry": "^8.0.0" } }, "sha512-pWEkmqVyD2CbGGG9KsTQgufWIYRzMsZVWyLY4d3lI2pUbuTlv0W0pvvmJybmKkIk3Tcn1T5vMBjT2DYuUiZbqQ=="], - "@argos-ci/browser": ["@argos-ci/browser@5.1.2", "", {}, "sha512-eQqtM54Vh83++Dac5+7ml4YXKW/KnUHRQWjTZ+VGeXfOJdjnZa4JjfVL6fnFG6CKrXjCK5dd9gcZRmbVpbt8rA=="], + "@argos-ci/browser": ["@argos-ci/browser@6.3.0", "", {}, "sha512-quTww/Yhm2jv+Y6q6dD4xEpm9Bb/SrZNFJaXgncAnCvW8jNnCrmDE2+8zAuGUh41Wkq1JQNDYKlBCO/RpY6A8Q=="], - "@argos-ci/core": ["@argos-ci/core@5.1.1", "", { "dependencies": { "@argos-ci/api-client": "0.16.0", "@argos-ci/util": "3.2.0", "convict": "^6.2.4", "debug": "^4.4.3", "fast-glob": "^3.3.3", "mime-types": "^3.0.2", "sharp": "^0.34.5", "tmp": "^0.2.5" } }, "sha512-RI5pYb5wmWUoXzWefNCjKSqGA3BroUB2ac9awlYV3McJjSAW8nCGJUt2Eds90Shkp+i4S6KbMLnLG56slhTCsg=="], + "@argos-ci/core": ["@argos-ci/core@6.4.0", "", { "dependencies": { "@argos-ci/api-client": "0.24.0", "@argos-ci/util": "4.0.0", "convict": "^6.2.5", "debug": "^4.4.3", "fast-glob": "^3.3.3", "mime-types": "^3.0.2", "p-retry": "^8.0.0", "sharp": "^0.35.2", "tmp": "^0.2.7" } }, "sha512-YUySBUbjHpI8GZKe96n4VzgPPXl90hjw/GCHLQ3U6xbbIEZczCBCHeHB/MDjyXCHyk+9T7FUJSE0ddWnpDRI8Q=="], - "@argos-ci/playwright": ["@argos-ci/playwright@6.4.2", "", { "dependencies": { "@argos-ci/browser": "5.1.2", "@argos-ci/core": "5.1.1", "@argos-ci/util": "3.2.0", "chalk": "^5.6.2", "debug": "^4.4.3" } }, "sha512-0HE4rmgx2/znlMUi3z0Bp8OD7u7zNls9SlljL+qTVoWQRW22sDvpmRdZpjzb4pEjoe6onqPTDOqm5/f8QYHMwg=="], + "@argos-ci/playwright": ["@argos-ci/playwright@7.2.0", "", { "dependencies": { "@argos-ci/browser": "6.3.0", "@argos-ci/core": "6.4.0", "@argos-ci/util": "4.0.0", "chalk": "^5.6.2", "debug": "^4.4.3" } }, "sha512-YkZswuc+hZLC8sUUp6QPzZJSfZsxnbt+sfyZr5DWSHuz5g2XRxJ8oEsXJwFraDMYku5jwfAtqXvza1J3WUo6qA=="], - "@argos-ci/util": ["@argos-ci/util@3.2.0", "", {}, "sha512-/Bn0qCH8VsdPv5WB9TUEf3oTgsIqsTMUEjPVDopHLzKK+j7nQYGOF3MnN7VhBow82BXeStBfJCS3UiZ6cgxRlw=="], + "@argos-ci/util": ["@argos-ci/util@4.0.0", "", {}, "sha512-2yrJr81OVpa2TkvSjzGZlGv6SVqZcxF62AVk6M79aZ7nXkwWFg3tjA8awbFwn2mODI67gkP5FarAD29bzx3P3w=="], "@ast-grep/napi": ["@ast-grep/napi@0.40.5", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.5", "@ast-grep/napi-darwin-x64": "0.40.5", "@ast-grep/napi-linux-arm64-gnu": "0.40.5", "@ast-grep/napi-linux-arm64-musl": "0.40.5", "@ast-grep/napi-linux-x64-gnu": "0.40.5", "@ast-grep/napi-linux-x64-musl": "0.40.5", "@ast-grep/napi-win32-arm64-msvc": "0.40.5", "@ast-grep/napi-win32-ia32-msvc": "0.40.5", "@ast-grep/napi-win32-x64-msvc": "0.40.5" } }, "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A=="], @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.186.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-UAF+tVtstqyA8s9OYXoYhgHfOUQaaQBRjQX4WLEB1YxcYtJZ9YM/EM8Ouj38Tg4qW3tr76t1/OESZipCE6t8cw=="], + "@gitbook/api": ["@gitbook/api@0.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], @@ -812,6 +812,8 @@ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], @@ -850,6 +852,8 @@ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], @@ -1026,7 +1030,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], @@ -1590,17 +1594,17 @@ "@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-guuiO2kKc7yUQFU2jhWyM/BrYGD/brqb0+JvJIXTQ/QI1NlWfHZ1id50kkkOWqxmBiDc8DgW2orfY85pQIc7gw=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UglEDl/r1/h5Vw6oS6cEE29Jugz2sDLxHCSupNznKatj1fDMRXFqYKFcbvm8RTFhtYPI45NxkrPNo9BqNychBg=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="], + "@turbo/linux-64": ["@turbo/linux-64@2.10.3", "", { "os": "linux", "cpu": "x64" }, "sha512-KuQxaPWD7OBmwEZqO0sgijwcuXR5eMWbo7BEt2m1D2Q3RylJDj7WMcJ0Btv2VJA0QC+khzAaTAm1yWowJ0CWAw=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-DPkIKQ+6p0sho3Cx/e/A3F+AKgXQJA+z//cHsTcyyM1YWRGSYA0UTP8NUpb4iS2tVBSniGwmjRUr73hpq8kcDg=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="], + "@turbo/windows-64": ["@turbo/windows-64@2.10.3", "", { "os": "win32", "cpu": "x64" }, "sha512-8NvFAze9D4PsosZAnK3aGqHz2vLzREz9lNIxLgfE0jRchq2sZQE190cwFm7WX17yg3ftPvwCvWH3rhLbG1UHCQ=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AqjqV5cHbFa0YRaQ+Dyw6lSm6as4h/Uf8LcZ7VN8i+Odr23ShFD5SUvVAR1d3rZqibFVs9c0VdtXdfQfkdcs3A=="], "@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="], @@ -2024,7 +2028,7 @@ "convert-hrtime": ["convert-hrtime@3.0.0", "", {}, "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA=="], - "convict": ["convict@6.2.4", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "yargs-parser": "^20.2.7" } }, "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ=="], + "convict": ["convict@6.2.5", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "yargs-parser": "^20.2.7" } }, "sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -2270,7 +2274,7 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eval-estree-expression": ["eval-estree-expression@github:jonschlinkert/eval-estree-expression#fb0246a", {}, "jonschlinkert-eval-estree-expression-fb0246a"], + "eval-estree-expression": ["eval-estree-expression@3.0.1", "", {}, "sha512-zTLKGbiVdQYp4rQkSoXPibrFf5ZoPn6jzExegRLEQ13F+FSxu5iLgaRH6hlDs2kWSUa6vp8yD20cdJi0me6pEw=="], "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], @@ -2908,11 +2912,11 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - "openapi-fetch": ["openapi-fetch@0.15.2", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg=="], + "openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], + "openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="], "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], @@ -3322,7 +3326,7 @@ "tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3356,7 +3360,7 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="], + "turbo": ["turbo@2.10.3", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.3", "@turbo/darwin-arm64": "2.10.3", "@turbo/linux-64": "2.10.3", "@turbo/linux-arm64": "2.10.3", "@turbo/windows-64": "2.10.3", "@turbo/windows-arm64": "2.10.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-uZIqzfgtWbyqu1Tqwdd0giRnPGgL0ejbcdF8eZLJhtTTVSrOgArZGzYeXTD6XNcc7ANgdhqex11Y9bHBeyiQLQ=="], "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], @@ -3538,6 +3542,8 @@ "@argos-ci/core/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "@argos-ci/core/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="], @@ -3630,6 +3636,10 @@ "@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], @@ -3682,7 +3692,7 @@ "@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "@playwright/test/playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + "@playwright/test/playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -4288,6 +4298,58 @@ "@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@argos-ci/core/sharp/@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@argos-ci/core/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], + + "@argos-ci/core/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], + + "@argos-ci/core/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], + + "@argos-ci/core/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], + + "@argos-ci/core/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], + + "@argos-ci/core/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], + + "@argos-ci/core/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], + + "@argos-ci/core/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + + "@argos-ci/core/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + + "@argos-ci/core/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + + "@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "@argos-ci/core/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="], "@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="], @@ -4332,6 +4394,10 @@ "@headlessui/react/@floating-ui/react/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -4370,7 +4436,7 @@ "@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "@playwright/test/playwright/playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "@playwright/test/playwright/playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/package.json b/package.json index c46549d49..491e101b6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.18", + "turbo": "^2.10.3", "vercel": "50.37.3" }, "packageManager": "bun@1.3.7", @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.186.0", + "@gitbook/api": "0.189.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/colors/package.json b/packages/colors/package.json index d0f0b54cb..6bf6d0660 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "tsdown", "typecheck": "tsc --noEmit", + "unit": "bun test", "dev": "bun run build -- --watch ./src", "publish-to-npm": "../../scripts/publish-if-new.sh" }, diff --git a/packages/colors/src/transformations.test.ts b/packages/colors/src/transformations.test.ts new file mode 100644 index 000000000..949a39f8d --- /dev/null +++ b/packages/colors/src/transformations.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'bun:test'; + +import { colorScale } from './transformations'; + +describe('colorScale exact base', () => { + it('takes a very light tint as the exact background on step 1 (clean/bold)', () => { + const scale = colorScale('#F5F3EF', { baseStep: 1 }); + expect(scale[0]).toBe('#F5F3EF'); + }); + + it('anchors a very light tint to step 2 when the theme uses the subtle step (muted)', () => { + const scale = colorScale('#F5F3EF', { baseStep: 2 }); + expect(scale[1]).toBe('#F5F3EF'); + // Step 1 sits just above the exact base, toward white. + expect(scale[0]).not.toBe('#F5F3EF'); + expect(scale[0]).not.toBe('#ffffff'); + }); + + it('takes a darker-than-dark tint as the exact background, preserving hue and chroma', () => { + const scale = colorScale('#0B0F19', { darkMode: true, baseStep: 1 }); + expect(scale[0]).toBe('#0B0F19'); + }); + + it('does not trigger for a normal mid-lightness tint', () => { + const scale = colorScale('#787878', { baseStep: 2 }); + // The default white background is kept; the tint only colors the scale. + expect(scale[0]).toBe('#ffffff'); + expect(scale[1]).not.toBe('#787878'); + }); + + it('does not trigger for a saturated light color, keeping the normal accent ramp', () => { + // Light enough (L≈0.93) to pass the lightness bound, but too chromatic to read as a + // background — emitting it verbatim would leave a vivid step 1 above a near-gray scale. + const scale = colorScale('#FFEB3B', { baseStep: 1 }); + expect(scale[0]).toBe('#ffffff'); + expect(scale[0]).not.toBe('#FFEB3B'); + }); + + it('respects a custom light background instead of overriding it with the tint', () => { + // The color is darker than the requested background, so it is not the extreme end and the + // supplied base must be preserved rather than overwritten. + const scale = colorScale('#eeeeee', { baseStep: 1, background: '#f8f8f8' }); + expect(scale[0]).not.toBe('#eeeeee'); + }); + + it('anchors an exact base even when a neutral mix is supplied (tint === primary)', () => { + // getTintMixColor blends neutral into the tint when it equals the primary color; that must + // not darken a near-white tint out of the exact-base path. + const scale = colorScale('#F5F3EF', { + baseStep: 1, + mix: { color: '#787878', ratio: 0.4 }, + }); + expect(scale[0]).toBe('#F5F3EF'); + }); + + it('does not trigger for a light accent color below the near-white threshold', () => { + // #D8DEEC (light blue-gray, L≈0.90) is a UI accent, not a background, so it must not anchor. + const scale = colorScale('#D8DEEC', { baseStep: 1 }); + expect(scale[0]).toBe('#ffffff'); + expect(scale[0]).not.toBe('#D8DEEC'); + }); + + it('never anchors when no baseStep is given (accent scales and the bold theme)', () => { + // A scale that does not define the page background opts out of the exact base entirely. + const scale = colorScale('#F5F3EF', {}); + expect(scale[0]).toBe('#ffffff'); + expect(scale[0]).not.toBe('#F5F3EF'); + }); +}); diff --git a/packages/colors/src/transformations.ts b/packages/colors/src/transformations.ts index d7950792c..b4da6ad05 100644 --- a/packages/colors/src/transformations.ts +++ b/packages/colors/src/transformations.ts @@ -82,6 +82,22 @@ export const colorMixMapping = { dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1], }; +/** + * Light mode has no equivalent to the dark base bound (nothing is lighter than white), so a tint + * at or above this lightness is treated as an explicit, near-white background (e.g. a warm `#F5F3EF` + * at L≈0.96). Kept high so light UI accent colors (around L≈0.90) aren't mistaken for backgrounds. + */ +const EXACT_BASE_LIGHT_THRESHOLD = 0.95; + +/** + * Only a near-neutral tint reads as a background. A saturated color would keep the exact hue at the + * anchored step while the rest of the low scale stays ~gray, so those keep the normal accent ramp. + */ +const EXACT_BASE_NEUTRAL_CHROMA = 0.05; + +/** Lightness of the default white light background (≈0.99999, not exactly 1). */ +const LIGHT_BASE_L = rgbToOklch(hexToRgbArray(LIGHT_BASE)).L; + /** * Convert a hex color to an RGB color. */ @@ -159,6 +175,14 @@ export type ColorScaleOptions = { /** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */ foreground?: string; + /** + * The 1-indexed scale step this scale renders as the page background (1 = `tint-base` for + * `clean`, 2 = `tint-subtle` for `muted`). When set, an extreme near-neutral tint is taken as + * the exact background, anchored to this step so it matches exactly. Omit for scales that don't + * define the page background (accents, or the two-tone `bold` theme) — they never anchor. + */ + baseStep?: number; + mix?: { /** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */ color: string; @@ -179,6 +203,7 @@ export function colorScale( darkMode = false, background = darkMode ? DARK_BASE : LIGHT_BASE, foreground = darkMode ? LIGHT_BASE : DARK_BASE, + baseStep, mix, }: ColorScaleOptions = {} ) { @@ -188,31 +213,51 @@ export function colorScale( const backgroundColor = rgbToOklch(hexToRgbArray(background)); let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light; - if (mixColor && mix?.ratio && mix.ratio > 0) { - // If defined, we mix in a (tiny) bit of the mix color with the base color. + // A near-neutral tint at the extreme end of the scale is taken as the exact page background + // rather than tinting pure black/white with it — letting brands set an exact background such as + // a warm `#F5F3EF`. Only scales that define the page background opt in (via `baseStep`). In light + // mode the base is pure white by default, so a near-white tint also qualifies (nothing is lighter + // than white); a custom, lower background is respected instead. Decided on the raw color so a + // neutral mix (below) can't darken a tint out of the exact base. + const isExtremeBase = darkMode + ? baseColor.L < backgroundColor.L + : backgroundColor.L >= LIGHT_BASE_L + ? baseColor.L > EXACT_BASE_LIGHT_THRESHOLD + : baseColor.L > backgroundColor.L; + const isExactBase = + baseStep !== undefined && isExtremeBase && baseColor.C < EXACT_BASE_NEUTRAL_CHROMA; + const exactBaseIndex = (baseStep ?? 1) - 1; + + if (mixColor && mix?.ratio && mix.ratio > 0 && !isExactBase) { + // Mix a little of the mix color into the base — but not when the tint is the exact base, + // where it must stay true to the supplied color (and match `--header-background`). baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio); baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio); baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H; } - if ( - (darkMode && baseColor.L < backgroundColor.L) || - (!darkMode && baseColor.L > backgroundColor.L) - ) { - // If the supplied color is outside of our lightness bounds, use the supplied color's lightness. - // This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look. + if (isExactBase) { const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L; - backgroundColor.L = baseColor.L; // At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out. const amplifier = 1; mapping = mapping.map((step, index) => index < 9 ? step + step * amplifier * difference : step ); + + // Anchor the supplied color to the step the theme renders as the background, solving the + // background lightness so neighbouring steps stay continuous with it. + const baseMix = mapping[exactBaseIndex]!; + backgroundColor.L = (baseColor.L - foregroundColor.L * baseMix) / (1 - baseMix); } const result = []; for (let index = 0; index < mapping.length; index++) { + if (isExactBase && index === exactBaseIndex) { + result.push(hex); + continue; + } + const step = mapping[index]!; const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step); @@ -238,7 +283,10 @@ export function colorScale( case 11: return 0.1; default: - return index * 0.05; + // When the tint is the exact base, hold the steps from the base toward the + // accents at its chroma so the background stays tinted; steps lighter than the + // base (e.g. cards in `muted`) keep desaturating toward white. + return isExactBase && index >= exactBaseIndex ? 1 : index * 0.05; } })(); diff --git a/packages/embed/README.md b/packages/embed/README.md index 47166e799..f422bd043 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -187,7 +187,7 @@ function MyComponent() { - `GitBook('open')` - Open widget window - `GitBook('close')` - Close widget window - `GitBook('toggle')` - Toggle widget window -- `GitBook('navigateToPage', path: string)` - Navigate to page +- `GitBook('navigateToPage', path: string)` - Navigate to page (accepts the page path within the site, an absolute path, or the full published URL; resolves pages in any space/section) - `GitBook('navigateToAssistant')` - Navigate to assistant tab - `GitBook('postUserMessage', message: string)` - Post message to chat - `GitBook('clearChat')` - Clear chat history @@ -202,7 +202,7 @@ function MyComponent() { - `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient` **Frame Client:** -- `frame.navigateToPage(path: string)` → `void` +- `frame.navigateToPage(path: string)` → `void` (accepts the page path within the site, an absolute path, or the full published URL; resolves pages in any space/section) - `frame.navigateToAssistant()` → `void` - `frame.postUserMessage(message: string)` → `void` - `frame.clearChat()` → `void` diff --git a/packages/embed/src/client/createGitBookFrame.ts b/packages/embed/src/client/createGitBookFrame.ts index fe4fa4c90..579bb5725 100644 --- a/packages/embed/src/client/createGitBookFrame.ts +++ b/packages/embed/src/client/createGitBookFrame.ts @@ -7,7 +7,12 @@ import type { export type GitBookFrameClient = { /** - * Navigate to a page by its path. + * Navigate to a page in the docs tab. + * + * Accepts the page's path within the site (e.g. `getting-started/quickstart`), + * an absolute path (e.g. `/help-center/integrations`), or its full published URL. + * The target page may live in any space/section of the site — including one other + * than the embed is currently showing — and is resolved to the right space. */ navigateToPage: (path: string) => void; diff --git a/packages/embed/turbo.json b/packages/embed/turbo.json new file mode 100644 index 000000000..91a6fd634 --- /dev/null +++ b/packages/embed/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**", "standalone/**"] + } + } +} diff --git a/packages/expr/CHANGELOG.md b/packages/expr/CHANGELOG.md index ff056b763..62ba8e602 100644 --- a/packages/expr/CHANGELOG.md +++ b/packages/expr/CHANGELOG.md @@ -1,5 +1,11 @@ # @gitbook/expr +## 1.3.1 + +### Patch Changes + +- 3fedaaa: Depend on `eval-estree-expression` from the npm registry (`^3.0.1`) instead of a pinned GitHub commit. The published `3.0.1` release is built from the exact commit the package was pinned to, so the code is unchanged — this only removes the fragile git/tarball dependency so consumers install it from npm like any other package. + ## 1.3.0 ### Minor Changes diff --git a/packages/expr/package.json b/packages/expr/package.json index 5189c0b2c..7b4a0e8d1 100644 --- a/packages/expr/package.json +++ b/packages/expr/package.json @@ -1,7 +1,7 @@ { "name": "@gitbook/expr", "description": "Safely evaluate & parse user-defined GitBook expressions.", - "version": "1.3.0", + "version": "1.3.1", "type": "module", "exports": { ".": { @@ -16,7 +16,7 @@ "acorn-walk": "^8.3.4", "assert-never": "catalog:", "escodegen": "^2.1.0", - "eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a" + "eval-estree-expression": "^3.0.1" }, "devDependencies": { "@tsconfig/strictest": "catalog:", diff --git a/packages/gitbook/CHANGELOG.md b/packages/gitbook/CHANGELOG.md index 42dc1275d..359c414a6 100644 --- a/packages/gitbook/CHANGELOG.md +++ b/packages/gitbook/CHANGELOG.md @@ -1,5 +1,32 @@ # gitbook +## 0.27.2 + +### Patch Changes + +- bb30db1: Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable. +- 62ca6d3: Show a "Back to [space]" shortcut at the top of the table of contents when a reader follows a link into a different space, so they don't lose track of where they were browsing from. +- fdea8f1: Show where a page lives in the site at the start of the page breadcrumbs: its section (including any enclosing section groups) and variant. Hovering any breadcrumb item reveals a dropdown to switch to its siblings at the same level — other section groups, sections, variants, page groups or pages — computed on the server from already-available data (no extra request). Language variants are left to the dedicated language picker rather than shown as a crumb. Also restyles the breadcrumbs to normal casing (matching search results). +- 37c2dd8: Navigate customer visual tests with `domcontentloaded` to avoid `load`-event hangs on external sites. +- c923c65: Fix center- and end-aligned paragraphs shifting left on wide/no-TOC pages when the AI Assistant is enabled, caused by the per-paragraph ask-button wrapper not inheriting the block alignment. +- 37c2dd8: Fix flaky customer e2e screenshots by waiting for the built-in cookie banner before capturing. +- 37c2dd8: Add a `data-testid` to the admin toolbar so e2e tests can assert its presence while hiding it from visual screenshots (it animates open, causing flaky diffs). +- 36dbdb4: Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it. +- 7e55cd5: Add an "Available in MCP" badge on OpenAPI operations marked with `x-gitbook-mcp: true`. When `x-gitbook-mcp-url` is set (on the operation, path, or root — most specific wins), the badge becomes a button that copies the MCP server URL to the clipboard. +- 6146f8e: Reduce the size of `searchDocumentation` MCP responses by returning only the best-matching section per page instead of concatenating every section body. +- 37c2dd8: Disable the content max-width transition under reduced motion, matching the surrounding layout transitions. +- 37c2dd8: Reset cross-space navigation state between e2e navigations so the "Back to " shortcut can't leak in and cause flaky screenshots. +- bcea23e: Render `anyOf`/`oneOf` with a `null` member as a nullable schema instead of a `null` union branch. +- 77efd44: Show changed pages in preview toolbars for change requests and revisions. +- 578a3a8: Fix site section dropdowns not being bounded by their container during the open/resize animation, causing the contents to visually clip. +- 2dc76a2: Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped. +- 9f65133: Improve the 404 page: remove the table of contents sidebar and center the content, suggest related pages (ranked from the already-cached search index, so no extra request per 404), show a search or assistant input depending on the site's AI configuration, and move "Go to homepage" to the bottom as a last resort. +- Updated dependencies [3fedaaa] +- Updated dependencies [7e55cd5] + - @gitbook/expr@1.3.1 + - @gitbook/openapi-parser@3.0.12 + - @gitbook/react-openapi@1.5.15 + ## 0.27.1 ### Patch Changes diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index 207189d66..8ccdcda75 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -40,11 +40,6 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id', tests: [{ name: 'Home', url: '/' }], }, - { - name: 'jasons-tutorials.gitbook.io', - contentBaseURL: 'https://jasons-tutorials.gitbook.io', - tests: [{ name: 'Home', url: '/' }], - }, { name: 'faq.deltaemulator.com', contentBaseURL: 'https://faq.deltaemulator.com', @@ -70,16 +65,12 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://docs.gmgn.ai', tests: [{ name: 'Home', url: '/' }], }, + // Removed, an AI widget, makes it flaky. // { - // name: 'docs.spicychat.ai', - // contentBaseURL: 'https://docs.spicychat.ai', - // tests: [{ name: 'Home', url: '/' }], + // name: 'docs.portainer.io', + // contentBaseURL: 'https://docs.portainer.io', + // tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], // }, - { - name: 'docs.portainer.io', - contentBaseURL: 'https://docs.portainer.io', - tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], - }, { name: 'docs.chirptoken.io', contentBaseURL: 'https://docs.chirptoken.io', @@ -110,27 +101,11 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://docs.midas.app', tests: [{ name: 'Home', url: '/' }], }, - // Disabling for now, one of the image is making the tests timeout - // { - // name: 'docs.keeper.io', - // contentBaseURL: 'https://docs.keeper.io', - // tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }], - // }, { name: 'adiblar.gitbook.io', contentBaseURL: 'https://adiblar.gitbook.io', tests: [{ name: 'Home', url: '/' }], }, - // { - // name: 'docs.gradient.network', - // contentBaseURL: 'https://docs.gradient.network', - // tests: [{ name: 'Home', url: '/' }], - // }, - // { - // name: 'mygate-network.gitbook.io', - // contentBaseURL: 'https://mygate-network.gitbook.io', - // tests: [{ name: 'Home', url: '/' }], - // }, { name: 'treasurenft.gitbook.io', contentBaseURL: 'https://treasurenft.gitbook.io', @@ -159,23 +134,13 @@ const testCases: TestsCase[] = [ { name: 'docs.tickettool.xyz', contentBaseURL: 'https://docs.tickettool.xyz', - tests: [{ name: 'Home', url: '/' }], + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], }, { name: 'wiki.redmodding.org', contentBaseURL: 'https://wiki.redmodding.org', tests: [{ name: 'Home', url: '/' }], }, - // { - // name: 'docs.cherry-ai.com', - // contentBaseURL: 'https://docs.cherry-ai.com', - // tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], - // }, - { - name: 'docs.snyk.io', - contentBaseURL: 'https://docs.snyk.io', - tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], - }, { name: 'docs.realapp.link', contentBaseURL: 'https://docs.realapp.link', @@ -203,16 +168,6 @@ const testCases: TestsCase[] = [ }, ], }, - { - name: 'sosovalue-white-paper.gitbook.io', - contentBaseURL: 'https://sosovalue-white-paper.gitbook.io', - tests: [{ name: 'Home', url: '/' }], - }, - // { - // name: 'docs.revrobotics.com', - // contentBaseURL: 'https://docs.revrobotics.com', - // tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], - // }, { name: 'chartschool.stockcharts.com', contentBaseURL: 'https://chartschool.stockcharts.com', @@ -221,24 +176,13 @@ const testCases: TestsCase[] = [ { name: 'docs.soniclabs.com', contentBaseURL: 'https://docs.soniclabs.com', - tests: [{ name: 'Home', url: '/' }], + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], }, - // This one redirects to binance now - // { - // name: 'docs.meshchain.ai', - // contentBaseURL: 'https://docs.meshchain.ai', - // tests: [{ name: 'Home', url: '/' }], - // }, { name: 'docs.thousandeyes.com', contentBaseURL: 'https://docs.thousandeyes.com', tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], }, - { - name: 'docs.raydium.io', - contentBaseURL: 'https://docs.raydium.io', - tests: [{ name: 'Home', url: '/' }], - }, { name: 'docs.fluentbit.io', contentBaseURL: 'https://docs.fluentbit.io', @@ -256,6 +200,412 @@ const testCases: TestsCase[] = [ { name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' }, ], }, + + // Additional customer docs sites. + { + name: 'unsloth.ai/docs', + contentBaseURL: 'https://unsloth.ai', + tests: [{ name: 'Home', url: '/docs', run: waitForCookiesDialog }], + }, + { + name: 'mariadb.com/docs', + contentBaseURL: 'https://mariadb.com', + tests: [{ name: 'Home', url: '/docs', run: waitForCookiesDialog }], + }, + { + name: 'docs.n8n.io', + contentBaseURL: 'https://docs.n8n.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.cherryai.com.cn', + contentBaseURL: 'https://docs.cherryai.com.cn', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'library.zoom.com', + contentBaseURL: 'https://library.zoom.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'help.verkada.com', + contentBaseURL: 'https://help.verkada.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.overleaf.com', + contentBaseURL: 'https://docs.overleaf.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'wiki.tiltedphoques.com/tilted-online', + contentBaseURL: 'https://wiki.tiltedphoques.com', + tests: [{ name: 'Home', url: '/tilted-online' }], + }, + { + name: 'handbook.musescore.org', + contentBaseURL: 'https://handbook.musescore.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'kakaobusiness.gitbook.io/main', + contentBaseURL: 'https://kakaobusiness.gitbook.io', + tests: [{ name: 'Home', url: '/main', run: waitForCookiesDialog }], + }, + { + name: 'docs.maestro.dev', + contentBaseURL: 'https://docs.maestro.dev', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'developers.oxylabs.io', + contentBaseURL: 'https://developers.oxylabs.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.parallels.com/landing', + contentBaseURL: 'https://docs.parallels.com', + tests: [{ name: 'Home', url: '/landing', run: waitForCookiesDialog }], + }, + { + name: 'help.impact.com', + contentBaseURL: 'https://help.impact.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.9proxy.com', + contentBaseURL: 'https://docs.9proxy.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'vimeo.com/legal', + contentBaseURL: 'https://vimeo.com', + tests: [{ name: 'Home', url: '/legal' }], + }, + { + name: 'help.aikido.dev', + contentBaseURL: 'https://help.aikido.dev', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'doc.demarche.numerique.gouv.fr', + contentBaseURL: 'https://doc.demarche.numerique.gouv.fr', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.adapta.org', + contentBaseURL: 'https://docs.adapta.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'www.xabuxa.com', + contentBaseURL: 'https://www.xabuxa.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.triumpharcade.com', + 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', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.waydro.id', + contentBaseURL: 'https://docs.waydro.id', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'bellingcat.gitbook.io/toolkit', + contentBaseURL: 'https://bellingcat.gitbook.io', + tests: [{ name: 'Home', url: '/toolkit' }], + }, + { + name: 'wiki.project-fika.com', + contentBaseURL: 'https://wiki.project-fika.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.pinot.apache.org', + contentBaseURL: 'https://docs.pinot.apache.org', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.devolutions.net', + contentBaseURL: 'https://docs.devolutions.net', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'guides.gresb.com', + contentBaseURL: 'https://guides.gresb.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.prestashop-project.org/welcome', + contentBaseURL: 'https://docs.prestashop-project.org', + tests: [{ name: 'Home', url: '/welcome', run: waitForCookiesDialog }], + }, + { + name: 'help.researchgate.net', + contentBaseURL: 'https://help.researchgate.net', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.verifone.com', + contentBaseURL: 'https://docs.verifone.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + // Deactivate it because of a custom Ask AI that causes flakiness. + // { + // name: 'docs.roboflow.com', + // contentBaseURL: 'https://docs.roboflow.com', + // tests: [{ name: 'Home', url: '/' }], + // }, + { + name: 'www.netexec.wiki', + contentBaseURL: 'https://www.netexec.wiki', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'guide.strikepack.com', + contentBaseURL: 'https://guide.strikepack.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'gitbook.com/docs', + contentBaseURL: 'https://gitbook.com', + tests: [ + { name: 'Home', url: '/docs', run: waitForCookiesDialog }, + { + name: 'OpenAPI', + url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask', + run: waitForCookiesDialog, + }, + ], + }, + { + name: 'documentation.gravitee.io', + contentBaseURL: 'https://documentation.gravitee.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'faq.wanttopay.net/wanttopay-app', + contentBaseURL: 'https://faq.wanttopay.net', + tests: [{ name: 'Home', url: '/wanttopay-app', run: waitForCookiesDialog }], + }, + { + name: 'guide.prismlive.com', + contentBaseURL: 'https://guide.prismlive.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.ionos.com/cloud', + contentBaseURL: 'https://docs.ionos.com', + tests: [{ name: 'Home', url: '/cloud', run: waitForCookiesDialog }], + }, + { + name: 'support.evite.com', + contentBaseURL: 'https://support.evite.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'knowledge.illumina.com', + contentBaseURL: 'https://knowledge.illumina.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'wiki.retrobat.org', + contentBaseURL: 'https://wiki.retrobat.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'wiki.polymaker.com', + contentBaseURL: 'https://wiki.polymaker.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.ducks-services.com', + contentBaseURL: 'https://docs.ducks-services.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.hex-rays.com', + contentBaseURL: 'https://docs.hex-rays.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'whitepaper.interlinklabs.ai', + contentBaseURL: 'https://whitepaper.interlinklabs.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'help.openloyalty.io', + contentBaseURL: 'https://help.openloyalty.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'retrozia.gitbook.io/retrozia', + contentBaseURL: 'https://retrozia.gitbook.io', + tests: [{ name: 'Home', url: '/retrozia' }], + }, + { + name: 'helpcenter.channable.com', + contentBaseURL: 'https://helpcenter.channable.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'developerdocs.instructure.com', + contentBaseURL: 'https://developerdocs.instructure.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'legal.jagex.com', + contentBaseURL: 'https://legal.jagex.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'manual.edgetx.org', + contentBaseURL: 'https://manual.edgetx.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.cortex.io', + contentBaseURL: 'https://docs.cortex.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + // Flaky because of the GIF + // { + // name: 'docs.mufy.ai', + // contentBaseURL: 'https://docs.mufy.ai', + // tests: [{ name: 'Home', url: '/' }], + // }, + { + name: 'docs.ndi.video/all', + contentBaseURL: 'https://docs.ndi.video', + tests: [{ name: 'Home', url: '/all', run: waitForCookiesDialog }], + }, + { + name: 'docs.sevenpens.com/drawtab', + contentBaseURL: 'https://docs.sevenpens.com', + tests: [{ name: 'Home', url: '/drawtab' }], + }, + { + name: 'manuals.i-reporter.jp', + contentBaseURL: 'https://manuals.i-reporter.jp', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.holybro.com', + contentBaseURL: 'https://docs.holybro.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'help.tokenpocket.pro/en', + contentBaseURL: 'https://help.tokenpocket.pro', + tests: [{ name: 'Home', url: '/en' }], + }, + { + name: 'docs.bullmq.io', + contentBaseURL: 'https://docs.bullmq.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'tools.osintnewsletter.com', + contentBaseURL: 'https://tools.osintnewsletter.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'wiki.mmorealms.gg', + contentBaseURL: 'https://wiki.mmorealms.gg', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.vectra.ai', + contentBaseURL: 'https://docs.vectra.ai', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.cipp.app', + contentBaseURL: 'https://docs.cipp.app', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'sinfa-com-co.gitbook.io/manual-de-usuario', + contentBaseURL: 'https://sinfa-com-co.gitbook.io', + tests: [{ name: 'Home', url: '/manual-de-usuario' }], + }, + { + name: 'support.skylum.com', + contentBaseURL: 'https://support.skylum.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.jgscripts.com', + contentBaseURL: 'https://docs.jgscripts.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.patchmypc.com', + contentBaseURL: 'https://docs.patchmypc.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'guide.cryosparc.com', + contentBaseURL: 'https://guide.cryosparc.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'guides.stellaraio.com/stellar', + contentBaseURL: 'https://guides.stellaraio.com', + tests: [{ name: 'Home', url: '/stellar', run: waitForCookiesDialog }], + }, + { + name: 'docs.iyzico.com', + contentBaseURL: 'https://docs.iyzico.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'help.wotnot.io', + contentBaseURL: 'https://help.wotnot.io', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.sportmonks.com/v3', + contentBaseURL: 'https://docs.sportmonks.com', + tests: [{ name: 'Home', url: '/v3', run: waitForCookiesDialog }], + }, + { + name: 'docs.payments.thalescloud.io', + contentBaseURL: 'https://docs.payments.thalescloud.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'doc.anytype.io/anytype-docs', + contentBaseURL: 'https://doc.anytype.io', + tests: [{ name: 'Home', url: '/anytype-docs', run: waitForCookiesDialog }], + }, + { + name: 'help.blotato.com', + contentBaseURL: 'https://help.blotato.com', + tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + }, + { + name: 'docs.cartographer3d.com', + contentBaseURL: 'https://docs.cartographer3d.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.acestudio.ai', + contentBaseURL: 'https://docs.acestudio.ai', + tests: [{ name: 'Home', url: '/' }], + }, ]; runTestCases(testCases); diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 77a86956f..77ee57d9d 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -32,6 +32,7 @@ import { runTestCases, setTimeToMorning, waitForAIChatResponse, + waitForAdminToolbar, waitForCookiesDialog, waitForCoverImages, waitForNotFound, @@ -612,21 +613,19 @@ const testCases: TestsCase[] = [ name: 'Site with sections and section groups', url: '', }, - { - name: 'Section group dropdown', - url: '', - run: async (page) => { - await page.getByRole('button', { name: 'Test Section Group 1' }).hover(); - await expect(page.getByRole('link', { name: /Section B/ })).toBeVisible(); - }, - }, { name: 'Section group link', url: '', screenshot: false, run: async (page) => { - const sectionGroupDropdown = await page.getByText('Test Section Group 1'); - await sectionGroupDropdown.hover(); + 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 }); await page.getByText('Section B').click(); await page.waitForURL((url) => url.pathname.includes('/sections/sections-4')); }, @@ -656,7 +655,12 @@ const testCases: TestsCase[] = [ { name: 'Revision', url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + // Viewing a past revision shows the admin toolbar; assert it is + // present (it is hidden from the screenshot as it animates open). + await waitForAdminToolbar(page); + }, }, { name: 'Invalid revision', diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index 9b170e78c..402f4aede 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -172,6 +172,19 @@ export async function waitForCookiesDialog(page: Page) { }); } +/** + * Wait for the GitBook admin toolbar to be present. + * + * The toolbar only renders when signed in to GitBook. It is hidden from + * screenshots (see `argosCSS`) because it auto-expands with an animation, so + * use this to assert it is rendered without capturing its flaky visual state. + */ +export async function waitForAdminToolbar(page: Page) { + await expect(page.getByTestId('admin-toolbar')).toBeVisible({ + timeout: 10_000, + }); +} + export async function waitForNotFound(_page: Page, response: Response | null) { expect(response).not.toBeNull(); expect(response?.status()).toBe(404); @@ -248,6 +261,19 @@ export function runTestCases(testCases: TestsCase[]) { ); } + // Reset the cross-space navigation state on every document load so the + // "Back to " shortcut never leaks between navigations/tests. It is + // detected client-side from this sessionStorage, and a stale value (e.g. + // after a retry or a cross-space redirect) makes it appear or not + // non-deterministically, causing flaky screenshots. + await page.addInitScript(() => { + try { + sessionStorage.removeItem('gitbook-space-navigation:last'); + sessionStorage.removeItem('gitbook-space-navigation:back'); + sessionStorage.removeItem('gitbook-space-navigation:from-picker'); + } catch {} + }); + // Set the header to disable the Vercel toolbar // But only on the main document as it'd cause CORS issues on other resources await page.route('**/*', async (route, request) => { @@ -263,7 +289,11 @@ export function runTestCases(testCases: TestsCase[]) { } }); - const response = await page.goto(url); + // Wait only for `domcontentloaded` rather than the default `load`: these + // are real customer sites whose third-party subresources can hang and + // never fire `load`, aborting the navigation. Argos stabilization (run in + // `beforeScreenshot`) still waits for images/fonts before capturing. + const response = await page.goto(url, { waitUntil: 'domcontentloaded' }); if (testEntry.run) { await testEntry.run(page, response); } @@ -283,6 +313,12 @@ export function runTestCases(testCases: TestsCase[]) { /* Hide Intercom */ .intercom-lightweight-app { display: none !important; + } + /* Hide the GitBook admin toolbar: it auto-expands with an + animation, so its state at capture time is non-deterministic. + Its presence is asserted separately via waitForAdminToolbar. */ + [data-testid="admin-toolbar"] { + display: none !important; } `, threshold: screenshotOptions?.threshold ?? undefined, diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index 3db64e6a3..e093cfd77 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -46,10 +46,13 @@ const nextConfig = { GITBOOK_API_URL: process.env.GITBOOK_API_URL, GITBOOK_APP_URL: process.env.GITBOOK_APP_URL, GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL, + GITBOOK_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET, + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: process.env.GITBOOK_SITE_OAUTH_CONSENT_ENABLED, GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL, GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL, + GITBOOK_IMAGE_RESIZE_SALT: process.env.GITBOOK_IMAGE_RESIZE_SALT, GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL, GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN, GITBOOK_URL: process.env.GITBOOK_URL, @@ -61,6 +64,7 @@ const nextConfig = { GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL, GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME, GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION, + GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE, // Next.js envs NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index eb46bcf3e..8afd96e79 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -1,6 +1,6 @@ { "name": "gitbook", - "version": "0.27.1", + "version": "0.27.2", "private": true, "dependencies": { "@cloudflare/workers-types": "^4.20251011.0", @@ -93,8 +93,8 @@ "zustand": "^5.0.3" }, "devDependencies": { - "@argos-ci/playwright": "^6.4.2", - "@playwright/test": "^1.58.2", + "@argos-ci/playwright": "^7.2.0", + "@playwright/test": "^1.61.1", "@scalar/api-client-react": "catalog:", "@tailwindcss/postcss": "^4.1.11", "@types/js-cookie": "^3.0.6", diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx index 59736ceef..274f55334 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx @@ -1,7 +1,7 @@ import type { RouteLayoutParams } from '@/app/utils'; import { EmbeddableAssistantPage } from '@/components/Embeddable'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { getEmbeddableDynamicContext } from '@/lib/embeddable'; -import { CustomizationAIMode } from '@gitbook/api'; import { redirect } from 'next/navigation'; type PageProps = { @@ -15,7 +15,7 @@ export default async function Page(props: PageProps) { const { context } = await getEmbeddableDynamicContext(params); // If the assistant is not enabled, redirect to the docs - if (context.customization.ai.mode !== CustomizationAIMode.Assistant) { + if (!isAIChatEnabled(context.customization.ai.mode)) { redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`); } diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx index b847b5cf7..4abe02997 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx @@ -1,6 +1,6 @@ import type { RouteLayoutParams } from '@/app/utils'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { getEmbeddableDynamicContext } from '@/lib/embeddable'; -import { CustomizationAIMode } from '@gitbook/api'; import { redirect } from 'next/navigation'; type PageProps = { @@ -13,7 +13,7 @@ export default async function Page(props: PageProps) { const baseURL = context.linker.toPathInSite('~gitbook/embed/'); // If assistant is enabled, redirect to assistant, otherwise to docs - if (context.customization.ai.mode === CustomizationAIMode.Assistant) { + if (isAIChatEnabled(context.customization.ai.mode)) { redirect(`${baseURL}/assistant`); } else { redirect(`${baseURL}/page/`); diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts index 5b496a838..1e98525cd 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts @@ -1,11 +1,16 @@ -import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api'; +import { + CustomizationPageActionType, + SiteFindingType, + SiteInsightsDisplayContext, +} from '@gitbook/api'; import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask'; import { getExposableError, throwIfDataError } from '@/lib/data'; -import { getMarkdownForPageInSpace } from '@/lib/markdownPage'; +import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage'; import { resolvePagePath } from '@/lib/pages'; import { joinPathWithBaseURL } from '@/lib/paths'; -import { getBestScoredResult } from '@/lib/search'; import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites'; import { trackServerInsightsEvents } from '@/lib/tracking'; import { waitUntil } from '@/lib/waitUntil'; @@ -129,8 +134,10 @@ export async function handleMcpRequest( ) ); - const body = getBestScoredResult( - (pageResult.sections ?? []).filter((section) => section.body) + // The search API returns sections ordered highest-score-first, so + // the first section with a body is the best-scoring preview. + const body = (pageResult.sections ?? []).find( + (section) => section.body )?.body; return { @@ -236,6 +243,228 @@ export async function handleMcpRequest( } } ); + + // Only expose the answer tool when the site has AI enabled, since it relies on + // the same AI search backend that powers the site's "ask a question" experience. + if (isAIEnabled(context.customization.ai.mode)) { + server.tool( + 'askQuestion', + `Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`, + { + question: z + .string() + .describe( + `The natural-language question to answer about ${site.title}.` + ), + goal: z + .string() + .optional() + .describe( + 'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.' + ), + }, + { + title: 'Ask a question', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ question, goal }) => { + try { + const trimmedQuestion = question.trim(); + if (!trimmedQuestion) { + return { + content: [ + { + type: 'text', + text: 'Please provide a question to answer.', + }, + ], + isError: true, + }; + } + + const trimmedGoal = goal?.trim() || undefined; + + const answer = await streamSiteAskAnswer(context, trimmedQuestion, { + goal: trimmedGoal, + }); + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'ask_question', + query: trimmedQuestion, + ...(trimmedGoal ? { goal: trimmedGoal } : {}), + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + }, + }, + ], + request, + }); + + if (!answer || !answer.answer || !('markdown' in answer.answer)) { + return { + content: [ + { + type: 'text', + text: "We couldn't answer this question.", + }, + ], + }; + } + + const answerMarkdown = toPageMarkdown( + await fromPageMarkdown(context, { + markdown: answer.answer.markdown, + pagePath: '', + }) + ); + const sourcesMarkdown = await renderAskSourcesMarkdown( + context, + answer.sources ?? [] + ); + + let text = answerMarkdown.trim(); + if (sourcesMarkdown) { + text += `\n\n# Sources\n\n${sourcesMarkdown}`; + } + + return { content: [{ type: 'text', text }] }; + } catch (error) { + const exposable = getExposableError(error); + return { + content: [{ type: 'text', text: exposable.message }], + isError: true, + }; + } + } + ); + } + + server.tool( + 'sendFeedback', + `Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`, + { + category: z + .nativeEnum(SiteFindingType) + .describe( + 'The kind of issue. "content-outdated": the content was correct at some point but no longer matches the current product or reality. "incoherence": the content contradicts itself or another page. "content-gap": information the reader needs is missing entirely, whether or not it was ever documented. "other": only as a last resort when none of the above fits.' + ), + content: z + .string() + .min(1) + .max(2048) + .describe( + 'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.' + ), + pageUrl: z + .string() + .describe( + `The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started). Provide it whenever you can so the finding is linked to the exact page.` + ) + .transform((value, ctx) => { + const candidate = URL.canParse(value) + ? new URL(value) + : URL.canParse(value, siteUrl) + ? new URL(value, siteUrl) + : null; + + if ( + !candidate || + (candidate.protocol !== 'https:' && candidate.protocol !== 'http:') + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`, + }); + return z.NEVER; + } + + return candidate.toString(); + }) + .optional(), + }, + { + title: 'Send feedback', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ category, content, pageUrl }) => { + try { + let pageLocation: + | { page: string; space: string; revision: string } + | undefined; + + if (pageUrl) { + const match = findSiteSpaceByUrl(context.structure, pageUrl); + if (!match) { + return { + content: [ + { type: 'text', text: `Page not found: "${pageUrl}"` }, + ], + isError: true, + }; + } + + const revision = await throwIfDataError( + dataFetcher.getRevision({ + spaceId: match.siteSpace.space.id, + revisionId: match.siteSpace.space.revision, + }) + ); + + const resolved = resolvePagePath(revision.pages, match.pagePath ?? ''); + if (!resolved) { + return { + content: [ + { type: 'text', text: `Page not found: "${pageUrl}"` }, + ], + isError: true, + }; + } + + pageLocation = { + page: resolved.page.id, + space: match.siteSpace.space.id, + revision: match.siteSpace.space.revision, + }; + } + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'agent_feedback', + feedback: { content, category }, + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + ...pageLocation, + }, + }, + ], + request, + }); + + return { + content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }], + }; + } catch (error) { + const exposable = getExposableError(error); + return { + content: [{ type: 'text', text: exposable.message }], + isError: true, + }; + } + } + ); }, {}, { diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx new file mode 100644 index 000000000..7e6161251 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx @@ -0,0 +1,20 @@ +import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { CustomizationRootLayout } from '@/components/RootLayout/CustomizationRootLayout'; +import { getThemeFromMiddleware } from '@/lib/middleware'; + +/** + * Layout for the sites OAuth consent screen. + */ +export default async function Layout({ + params, + children, +}: React.PropsWithChildren<{ params: Promise }>) { + const { context } = await getDynamicSiteContext(await params); + const forcedTheme = await getThemeFromMiddleware(); + + return ( + + {children} + + ); +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx new file mode 100644 index 000000000..218f4afe7 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx @@ -0,0 +1,67 @@ +import { cookies, headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { + type RouteLayoutParams, + getDynamicSiteContext, + getSiteURLDataFromParams, +} from '@/app/utils'; +import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent'; +import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; +import { + SiteOAuthConsentError, + isSitesOAuthConsentEnabled, + startSiteOAuthConsent, +} from '@/lib/site-oauth'; +import { getVisitorToken } from '@/lib/visitors'; + +// The consent screen depends on the request (visitor, one-time interaction) and must never be cached. +export const dynamic = 'force-dynamic'; + +type PageParams = RouteLayoutParams & { siteId: string }; + +/** + * Render the sites OAuth consent screen for a post-login authorize resume. + */ +export default async function Page(props: { + params: Promise; + searchParams: Promise<{ gb_oauth_state?: string }>; +}) { + if (!isSitesOAuthConsentEnabled()) { + notFound(); + } + + const params = await props.params; + const searchParams = await props.searchParams; + const { siteId } = params; + + const { context } = await getDynamicSiteContext(params); + const siteBasePath = withTrailingSlash( + withLeadingSlash(getSiteURLDataFromParams(params).siteBasePath) + ); + const authorizeURL = new URL( + `${siteBasePath}~gitbook/oauth2/v1/${siteId}/authorize`, + context.linker.toAbsoluteURL('/') + ); + const visitorToken = getVisitorToken({ + cookies: (await cookies()).getAll(), + headers: await headers(), + url: authorizeURL, + }); + const jwtToken = visitorToken?.token; + const interactionId = searchParams.gb_oauth_state; + + if (!interactionId || !jwtToken) { + return ; + } + + try { + const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken }); + return ; + } catch (error) { + if (error instanceof SiteOAuthConsentError) { + return ; + } + throw error; + } +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts index e55e729d7..92f36e83b 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts @@ -8,7 +8,6 @@ import { throwIfDataError } from '@/lib/data'; import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { joinPathWithBaseURL } from '@/lib/paths'; -import { getBestScoredResult } from '@/lib/search'; import { getServerActionBaseContext } from '@/lib/server-actions'; import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites'; import type { @@ -187,8 +186,9 @@ function transformSitePageResult(args: { }; }) ?? []; - // Find the best-scoring section to use as a body preview on the page result. - const bestSection = getBestScoredResult(pageSections); + // 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, diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx index 9b8b40a76..b5e5cfa42 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/assistant/page.tsx @@ -1,7 +1,7 @@ import type { RouteParams } from '@/app/utils'; import { EmbeddableAssistantPage } from '@/components/Embeddable'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { getEmbeddableStaticContext } from '@/lib/embeddable'; -import { CustomizationAIMode } from '@gitbook/api'; import { redirect } from 'next/navigation'; export const dynamic = 'force-static'; @@ -15,7 +15,7 @@ export default async function Page(props: PageProps) { const { context } = await getEmbeddableStaticContext(params); // If the assistant is not enabled, redirect to the docs - if (context.customization.ai.mode !== CustomizationAIMode.Assistant) { + if (!isAIChatEnabled(context.customization.ai.mode)) { redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`); } diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx index 2f667d280..0198f23f2 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/embed/page.tsx @@ -1,6 +1,6 @@ import type { RouteLayoutParams } from '@/app/utils'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { getEmbeddableStaticContext } from '@/lib/embeddable'; -import { CustomizationAIMode } from '@gitbook/api'; import { redirect } from 'next/navigation'; export const dynamic = 'force-static'; @@ -15,7 +15,7 @@ export default async function Page(props: PageProps) { const baseURL = context.linker.toPathInSite('~gitbook/embed/'); // If assistant is enabled, redirect to assistant, otherwise to docs - if (context.customization.ai.mode === CustomizationAIMode.Assistant) { + if (isAIChatEnabled(context.customization.ai.mode)) { redirect(`${baseURL}/assistant`); } else { redirect(`${baseURL}/page/`); diff --git a/packages/gitbook/src/components/AI/server-actions/chat.ts b/packages/gitbook/src/components/AI/server-actions/chat.ts index c5f65b285..beca9500b 100644 --- a/packages/gitbook/src/components/AI/server-actions/chat.ts +++ b/packages/gitbook/src/components/AI/server-actions/chat.ts @@ -1,6 +1,7 @@ 'use server'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; -import { getServerActionBaseContext } from '@/lib/server-actions'; +import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions'; import { traceErrorOnly } from '@/lib/tracing'; import { type AIMessageContext, @@ -39,6 +40,11 @@ export async function* streamAIChatResponse({ isEmbeddable: options?.asEmbeddable, }); + const siteContext = await fetchServerActionSiteContext(context); + if (!isAIChatEnabled(siteContext.customization.ai.mode)) { + throw new Error('The AI Assistant is not enabled for this site.'); + } + const siteURLData = await getSiteURLDataFromMiddleware(); const api = await context.dataFetcher.api(); diff --git a/packages/gitbook/src/components/AI/useAI.tsx b/packages/gitbook/src/components/AI/useAI.tsx index 9c6c81e06..e8322c1a2 100644 --- a/packages/gitbook/src/components/AI/useAI.tsx +++ b/packages/gitbook/src/components/AI/useAI.tsx @@ -1,10 +1,11 @@ 'use client'; -import { CustomizationAIMode } from '@gitbook/api'; +import type { CustomizationAIMode } from '@gitbook/api'; import { Icon, type IconName } from '@gitbook/icons'; import * as React from 'react'; import type { ReactNode } from 'react'; +import { isAIChatEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled'; import { tString, useLanguage } from '@/intl/client'; import type { GitBookAssistant } from '@gitbook/browser-types'; import { useAIChatController, useAIChatState } from '.'; @@ -89,7 +90,7 @@ export function useAI(): AIContext { const assistants: Assistant[] = []; - if (config.aiMode === CustomizationAIMode.Assistant) { + if (isAIChatEnabled(config.aiMode)) { assistants.push({ id: 'gitbook-assistant', label: config.assistantName ?? getAIChatName(language, config.trademark), @@ -110,7 +111,7 @@ export function useAI(): AIContext { ui: true, mode: 'sidebar', }); - } else if (config.aiMode === CustomizationAIMode.Search) { + } else if (isAISearchEnabled(config.aiMode)) { assistants.push({ id: 'gitbook-ai-search', label: tString(language, 'ai_chat_context_badge'), diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 44389b432..3003ebc56 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -123,6 +123,14 @@ export type AIChatState = { * this value (seeding its editable content) and then clears it back to an empty string. */ draft: string; + + /** + * Follow-ups the visitor submitted while a previous turn was still streaming. They are held + * here and sent automatically, one at a time in submission order, as each answer finishes + * (see the flush in `streamResponse`), so submitting mid-stream isn't lost. Empty when + * nothing is queued. + */ + queuedMessages: string[]; }; export type AIChatEvent = @@ -158,6 +166,8 @@ export type AIChatController = { focus: () => void; /** Pre-fill the chat input with draft text, without sending it. */ setDraft: (draft: string) => void; + /** Remove a follow-up queued to send after the current answer finishes, by its queue index. */ + cancelQueuedMessage: (index: number) => void; /** Register an event listener */ on: ( event: T, @@ -182,6 +192,7 @@ const globalState = zustand.create(() => { initialQuery: null, references: [], draft: '', + queuedMessages: [], }; }); @@ -257,6 +268,9 @@ export function AIChatProvider(props: { notify(eventsRef.current.get('close'), {}); }, [setSearchState]); + // Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later. + const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null); + // Stream a message with the AI backend const streamResponse = React.useCallback( async (input: { @@ -512,6 +526,15 @@ export function AIChatProvider(props: { loading: false, error: false, })); + + // Turn settled: send the next queued follow-up (oldest first). Held back while a + // control is pending, since posting would throw; it flushes after that resolves. + const { queuedMessages, control: activeControl } = globalState.getState(); + const [next, ...rest] = queuedMessages; + if (next !== undefined && !activeControl) { + globalState.setState((state) => ({ ...state, queuedMessages: rest })); + postMessageRef.current?.({ message: next }); + } } } catch (error) { console.error('Error streaming AI response', error); @@ -545,8 +568,12 @@ export function AIChatProvider(props: { throw new Error("We can't post a message when a control is active"); } - // Ignore duplicates while a previous turn is still streaming + // Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`). if (responding) { + globalState.setState((state) => ({ + ...state, + queuedMessages: [...state.queuedMessages, input.message], + })); return; } @@ -607,6 +634,21 @@ export function AIChatProvider(props: { [setSearchState, siteSpaceId, trackEvent, streamResponse] ); + // Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback. + postMessageRef.current = onPostMessage; + + // Remove a follow-up queued while the assistant is still answering (the × on the affordance). + const onCancelQueuedMessage = React.useCallback((index: number) => { + globalState.setState((state) => + index < 0 || index >= state.queuedMessages.length + ? state + : { + ...state, + queuedMessages: state.queuedMessages.filter((_, i) => i !== index), + } + ); + }, []); + // Clear the conversation and reset ask parameter const onClear = React.useCallback(() => { globalState.setState((state) => ({ @@ -621,6 +663,7 @@ export function AIChatProvider(props: { error: false, initialQuery: null, references: [], + queuedMessages: [], })); // Reset ask parameter to empty string (keeps chat open but clears content) @@ -704,6 +747,7 @@ export function AIChatProvider(props: { clearReferences: onClearReferences, focus: onFocus, setDraft: onSetDraft, + cancelQueuedMessage: onCancelQueuedMessage, on: onEvent, }; }, [ @@ -716,6 +760,7 @@ export function AIChatProvider(props: { onClearReferences, onFocus, onSetDraft, + onCancelQueuedMessage, onEvent, ]); diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index dbc6cfa3c..3b50ba9d1 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -35,6 +35,7 @@ import { AIChatExpandButton } from './AIChatExpandButton'; import { AIChatIcon } from './AIChatIcon'; import { AIChatInput } from './AIChatInput'; import { AIChatMessages } from './AIChatMessages'; +import { AIChatQueuedMessage } from './AIChatQueuedMessage'; import { AIChatResizeHandle } from './AIChatResizeHandle'; import AIChatSuggestedQuestions from './AIChatSuggestedQuestions'; @@ -119,6 +120,7 @@ export function AIChat() { chat={chat} suggestions={config.suggestions} trademark={config.trademark} + assistantName={config.assistantName} /> @@ -207,14 +209,17 @@ export function AIChatBody(props: { welcomeMessage?: string; suggestions?: string[]; trademark?: boolean; + /** Custom assistant name override; falls back to the branded/unbranded default name. */ + assistantName?: string; greeting?: { title: string; subtitle: string; }; }) { - const { chatController, chat, suggestions, greeting, trademark } = props; + const { chatController, chat, suggestions, greeting, trademark, assistantName } = props; const language = useLanguage(); + const resolvedAssistantName = assistantName ?? getAIChatName(language, trademark ?? true); const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting const isEmpty = !chat.messages.length; @@ -280,13 +285,24 @@ export function AIChatBody(props: {

+ {!chat.error && + chat.queuedMessages.map((message, index) => ( + chatController.cancelQueuedMessage(index)} + /> + ))} {/* Display an error banner when something went wrong. */} {chat.error ? : null} {chat.control ? : null} { chatController.postMessage({ message: value }); }} diff --git a/packages/gitbook/src/components/AIChat/AIChatInput.tsx b/packages/gitbook/src/components/AIChat/AIChatInput.tsx index 5e52ff8f4..c2ca5f379 100644 --- a/packages/gitbook/src/components/AIChat/AIChatInput.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatInput.tsx @@ -11,7 +11,8 @@ import { AIChatReferenceChips } from './AIChatReferenceChips'; export function AIChatInput(props: { disabled?: boolean; /** - * When true, the input is disabled + * When true, the assistant is streaming an answer. The field stays editable and submitting a + * follow-up queues it (sent automatically once the answer finishes) rather than being blocked. */ responding: boolean; onSubmit: (value: string) => void; @@ -110,7 +111,7 @@ export function AIChatInput(props: { } : undefined } - disabled={disabled || responding || chat.control !== null} + disabled={disabled || chat.control !== null} aria-busy={responding} ref={inputRef} header={ diff --git a/packages/gitbook/src/components/AIChat/AIChatQueuedMessage.tsx b/packages/gitbook/src/components/AIChat/AIChatQueuedMessage.tsx new file mode 100644 index 000000000..f4bd3cadf --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AIChatQueuedMessage.tsx @@ -0,0 +1,44 @@ +import { t, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import { Button, Tooltip } from '../primitives'; + +/** + * Shows a follow-up the visitor submitted while the assistant was still answering. It mirrors a + * sent user message — a right-aligned, tinted bubble — so it reads as "theirs", with a clock icon + * marking it as pending and a tooltip (on the whole bubble) explaining it will be sent once the + * current answer finishes. The × beside the bubble drops it from the queue. + */ +export function AIChatQueuedMessage(props: { + message: string; + assistantName: string; + onRemove: () => void; +}) { + const { message, assistantName, onRemove } = props; + const language = useLanguage(); + + return ( +
+ +
+ + {message} +
+
+
+ ); +} diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx index 2f2b269a1..0df53f8cc 100644 --- a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx @@ -7,7 +7,6 @@ import { Button } from '@/components/primitives'; import { t, tString, useLanguage } from '@/intl/client'; import { type ClassValue, tcls } from '@/lib/tailwind'; -import { getAIChatName } from '../AIChat'; import { AIChatIcon } from '../AIChatIcon'; /** @@ -48,13 +47,16 @@ export function AskAIParagraphButton(props: { content: string; className?: Class // Per-block nudges: `in-[…]` matches an ancestor (tag or class) with no markup // changes elsewhere — add a self-contained rule per block type to clear its gutter. 'in-[.hint]:-top-0.5 in-[.hint]:pr-2', + 'in-[li]:-left-14', 'in-[blockquote]:pr-0', // Hover affordance only: hidden until the paragraph (or the button) is hovered. 'invisible opacity-0 transition-opacity duration-150', 'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100', // Never shown on touch / hover-less contexts. 'not-pointer-fine:hidden', + // Hidden where an overflow-clipped ancestor would cut it off (tables, record cards). 'in-[[role=table]]:hidden', + 'in-[[data-card]]:hidden', className )} > @@ -63,11 +65,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class size="xsmall" iconOnly icon={} - label={t( - language, - 'ai_chat_ask_about_this', - config.assistantName ?? getAIChatName(language, config.trademark) - )} + label={t(language, 'ask')} onClick={onClick} // Don't steal focus (and shift the scroll position) when clicked with the mouse. onMouseDown={(event) => event.preventDefault()} diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx index 94056b6ee..ee3d00909 100644 --- a/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx +++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx @@ -1,6 +1,5 @@ 'use client'; -import { CustomizationAIMode } from '@gitbook/api'; import fnv1a from '@sindresorhus/fnv1a'; import { AnimatePresence, motion } from 'motion/react'; import * as React from 'react'; @@ -10,6 +9,7 @@ import { useAIChatController, useAIConfig } from '@/components/AI'; import { useIsMobile } from '@/components/hooks/useIsMobile'; import { useIsMounted } from '@/components/hooks/useIsMounted'; import { Button } from '@/components/primitives'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { t, useLanguage } from '@/intl/client'; import { AIChatIcon } from '../AIChatIcon'; @@ -31,7 +31,7 @@ export function AskAITextSelection() { const isMobile = useIsMobile(); const isMounted = useIsMounted(); - const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile; + const enabled = isAIChatEnabled(config.aiMode) && !isMobile; const toolbarRef = React.useRef(null); const { selection, clear } = useStableTextSelection({ diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx index 8b62699a6..93f70d63a 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -105,7 +105,10 @@ export function Toolbar(props: ToolbarProps) { } return ( - + diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/AskAICodeButton.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/AskAICodeButton.tsx index 10a784439..850988337 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/AskAICodeButton.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/AskAICodeButton.tsx @@ -1,10 +1,9 @@ 'use client'; -import { CustomizationAIMode } from '@gitbook/api'; - import { useAIChatController, useAIConfig } from '@/components/AI'; import { AIChatIcon } from '@/components/AIChat'; import { Button } from '@/components/primitives'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { t, useLanguage } from '@/intl/client'; import { type ClassValue, tcls } from '@/lib/tailwind'; @@ -22,7 +21,7 @@ export function AskAICodeButton(props: { const config = useAIConfig(); const chatController = useAIChatController(); - if (config.aiMode !== CustomizationAIMode.Assistant) { + if (!isAIChatEnabled(config.aiMode)) { return null; } diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index 9d5f45eac..629afba5f 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -36,6 +36,14 @@ export interface DocumentContext { * @default false */ withLinkPreviews?: boolean; + + /** + * True when this document is the main page body — the only place a background page cover is + * shown behind the content. Cover-aware text styling (contrast over the cover) is scoped to it + * so auxiliary documents (search answers, AI chat, PDF export) don't inherit the cover colors. + * @default false + */ + isPageBody?: boolean; } export interface DocumentContextProps { diff --git a/packages/gitbook/src/components/DocumentView/Heading.tsx b/packages/gitbook/src/components/DocumentView/Heading.tsx index 8977997b9..b074ba617 100644 --- a/packages/gitbook/src/components/DocumentView/Heading.tsx +++ b/packages/gitbook/src/components/DocumentView/Heading.tsx @@ -59,6 +59,9 @@ export async function Heading(props: BlockProps) { 'justify-self-start', 'max-w-full', 'break-words', + // Cover-aware contrast text applies only to the page body, not to documents + // rendered in overlays (search answers, AI chat) on a background-cover page. + context.isPageBody && 'page-cover-background:text-contrast-cover', getTextAlignment(block.data.align), textStyle.lineHeight )} diff --git a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx index 065f6abe1..704d2486c 100644 --- a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx @@ -4,6 +4,14 @@ import { useAI, useAIChatController, useAIChatState } from '../AI'; import { useSetSearchState } from '../Search'; import { Button, type ButtonProps, Input } from '../primitives'; +// The Input primitive has no `xsmall`; otherwise it shares the button's size scale. +const INPUT_SIZE_MAP: Record, 'small' | 'medium' | 'large'> = { + xsmall: 'small', + small: 'small', + medium: 'medium', + large: 'large', +}; + export function InlineActionButton( props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick & { buttonProps: ButtonProps } ) { @@ -42,7 +50,7 @@ export function InlineActionButton( , + ButtonProps['size'] +> = { + small: 'xsmall', + medium: 'small', + large: 'medium', +}; + export function InlineButton(props: InlineProps) { const { inline, context } = props; @@ -16,7 +26,7 @@ export function InlineButton(props: InlineProps) { label: inline.data.label, variant: inline.data.kind, icon: inline.data.icon as IconName | undefined, - size: 'medium', + size: BUTTON_SIZE_MAP[inline.data.size ?? 'large'], }; const ButtonImplementation = () => { diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx deleted file mode 100644 index 3d174632d..000000000 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { useAdaptiveVisitor } from '@/components/Adaptive'; -import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; -import React from 'react'; - -type ContentKitProps = React.ComponentProps>; - -/** - * ContentKit wrapper for integration blocks that need client-only adaptive context. - */ -export function ContentKitWithAdaptiveVisitorContext( - props: ContentKitProps -) { - const getAdaptiveVisitorClaims = useAdaptiveVisitor(); - const visitorClaims = getAdaptiveVisitorClaims(); - - const clientContext = React.useMemo( - () => ({ - getVisitorContext: () => ({ - visitor: visitorClaims?.visitor ?? null, - }), - }), - [visitorClaims] - ); - - return ; -} diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx new file mode 100644 index 000000000..c044638d8 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useAdaptiveVisitor } from '@/components/Adaptive'; +import { NavigationStatusContext } from '@/components/hooks'; +import { type GitBookLinker, createLinker } from '@/lib/links'; +import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; +import { useRouter } from 'next/navigation'; +import React from 'react'; +import type { WebframePageContext } from './adaptive'; + +type ContentKitProps = React.ComponentProps>; + +/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */ +export type WebframeLinkerData = Pick< + Parameters[0], + 'host' | 'protocol' | 'siteBasePath' | 'spaceBasePath' +>; + +/** + * ContentKit wrapper for integration blocks that expose client-only capabilities to webframes: + * the current page, navigation to other pages, and adaptive visitor claims (only when the + * integration is allowed to access them). + */ +export function ContentKitWithClientContext( + props: ContentKitProps & { + /** Whether visitor claims may be exposed to the webframe (integration scope gated). */ + canAccessVisitorClaims: boolean; + /** Current page to inject into the webframe, or `null` when unknown. */ + page: WebframePageContext | null; + /** Data to rebuild the site linker, used to resolve webframe navigation requests. */ + linkerData: WebframeLinkerData; + } +) { + const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props; + + const router = useRouter(); + const { onNavigationClick } = React.useContext(NavigationStatusContext); + const getAdaptiveVisitorClaims = useAdaptiveVisitor(); + + // Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest + // of the app, instead of duplicating the join logic here. + const linker = React.useMemo(() => createLinker(linkerData), [linkerData]); + + // Navigate to an in-site href, driving the same navigation progress bar as a regular link so + // the reader gets feedback while the destination page loads. + const navigateTo = React.useCallback( + (href: string) => { + onNavigationClick(href); + router.push(href); + }, + [onNavigationClick, router] + ); + // Read during render (Suspense) only when the integration is allowed visitor claims, so that + // webframes that don't use visitor claims don't suspend on the visitor-claims fetch. + const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null; + + const clientContext = React.useMemo( + () => ({ + getVisitorContext: canAccessVisitorClaims + ? () => ({ visitor: visitorClaims?.visitor ?? null }) + : undefined, + getPageContext: page ? () => ({ page }) : undefined, + navigate: ({ path, anchor }) => { + // Resolve the requested path relative to the site root so a webframe can navigate + // to any section or space within the site (and nowhere outside it). + const suffix = anchor ? `#${anchor}` : ''; + navigateTo(linker.toPathInSite(path) + suffix); + }, + }), + [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo] + ); + + return ; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 8904145fd..1d599549c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -5,8 +5,12 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit'; import type { BlockProps } from '../Block'; import './contentkit.css'; -import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext'; -import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive'; +import type { GitBookLinker } from '@/lib/links'; +import { + ContentKitWithClientContext, + type WebframeLinkerData, +} from './ContentKitWithClientContext'; +import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; import { contentKitServerContext } from './contentkit'; import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; @@ -70,34 +74,74 @@ export async function IntegrationBlock(props: BlockProps - - - + {useClientContext ? ( + + + + ) : ( + + + + )}
); } + +/** + * Extract the serializable data needed to rebuild the site linker on the client, so webframe + * navigation resolves paths through the same (tested) linker as the rest of the app. + */ +function getWebframeLinkerData(linker: GitBookLinker): WebframeLinkerData { + const data: WebframeLinkerData = { + siteBasePath: linker.siteBasePath, + spaceBasePath: linker.spaceBasePath, + }; + + // `host`/`protocol` are only used to build absolute URLs, which webframe navigation never does. + // Carry them along when available so the rebuilt linker is complete (and avoids a dev warning). + try { + const url = new URL(linker.toAbsoluteURL('/')); + data.host = url.host; + data.protocol = url.protocol; + } catch { + // No usable host (e.g. tests): the linker still resolves in-site paths without it. + } + + return data; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts new file mode 100644 index 000000000..20108b89e --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test'; +import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api'; + +import type { GitBookAnyContext } from '@/lib/context'; +import { createLinker } from '@/lib/links'; +import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; + +const webframe: ContentKitWebFrame = { + type: 'webframe', + source: { url: 'https://integrations.gitbook.com/frame' }, +}; + +function elementOutput(element: unknown): ContentKitRenderOutput { + return { + type: 'element', + element, + state: {}, + props: {}, + } as ContentKitRenderOutput; +} + +describe('integrationBlockContainsWebframe', () => { + it('returns false for a completed output', () => { + expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false); + }); + + it('returns false when there is no webframe in the tree', () => { + const output = elementOutput({ + type: 'block', + children: [{ type: 'text', text: 'hello' }], + } as never); + expect(integrationBlockContainsWebframe(output)).toBe(false); + }); + + it('returns true when a webframe is nested in the tree', () => { + const output = elementOutput({ + type: 'block', + children: [{ type: 'vstack', children: [webframe] }], + } as never); + expect(integrationBlockContainsWebframe(output)).toBe(true); + }); +}); + +describe('getWebframePageContext', () => { + it('returns null when the context has no page', () => { + const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext; + expect(getWebframePageContext(context)).toBeNull(); + }); + + it('resolves the page path relative to the site root, including the section slug', () => { + const context = { + page: { + id: 'page-1', + path: 'guides/getting-started', + title: 'Getting started', + slug: 'getting-started', + }, + // Site served at /docs, with the page's space mounted under the `api` section. + linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }), + } as unknown as GitBookAnyContext; + + expect(getWebframePageContext(context)).toEqual({ + id: 'page-1', + path: 'api/guides/getting-started', + title: 'Getting started', + }); + }); + + it('leaves the path unprefixed when the space is served at the site root', () => { + const context = { + page: { + id: 'page-2', + path: 'guides/getting-started', + title: 'Getting started', + slug: 'getting-started', + }, + linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }), + } as unknown as GitBookAnyContext; + + expect(getWebframePageContext(context)).toEqual({ + id: 'page-2', + path: 'guides/getting-started', + title: 'Getting started', + }); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index 162f0f631..77c5732a7 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -1,3 +1,4 @@ +import type { GitBookAnyContext } from '@/lib/context'; import type { ContentKitDescendantElement, ContentKitRenderOutput, @@ -8,19 +9,50 @@ import type { type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; /** - * Decide whether an integration block should expose Adaptive visitor context to webframes. + * Current page exposed to a webframe through the client-only webframe state. */ -export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext( - output: ContentKitRenderOutput -) { +export type WebframePageContext = { + id: string; + /** Path of the page relative to the site root (includes the section and variant). */ + path: string; + title: string; +}; + +/** + * Whether an integration block's output contains a webframe that can consume client-only context + * (navigation, visitor claims and/or the current page). + */ +export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean { if (output.type === 'complete') { return false; } - return ( - output.canAccessVisitorClaims === true && - doesContentKitElementContainWebframe(output.element) - ); + return doesContentKitElementContainWebframe(output.element); +} + +/** + * Extract the current page to expose to a webframe, or `null` when it is unknown + * (e.g. a non-page context, or reusable content resolved from another source). + * + * The exposed `path` is resolved relative to the site root — so it carries the section and + * variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a + * path. A webframe can pass `page.path` straight back to the navigate action. + */ +export function getWebframePageContext( + contentContext: GitBookAnyContext +): WebframePageContext | null { + if (!('page' in contentContext) || !contentContext.page) { + return null; + } + + const { linker } = contentContext; + const { id, path, title } = contentContext.page; + + return { + id, + path: linker.toRelativePathInSite(linker.toPathInSpace(path)), + title, + }; } /** diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 682c25bea..b28aa5dea 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -52,6 +52,7 @@ export function getOpenAPIContext(args: { check: , lock: , mcp: , + hashtag: , }, renderCodeBlock: (codeProps) => ( .openapi-disclosure-trigger { @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1; } @@ -972,7 +999,7 @@ body:has(.openapi-select-popover) { } } -.openapi-disclosure-trigger[aria-expanded="true"] svg { +.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg { @apply rotate-45; } diff --git a/packages/gitbook/src/components/DocumentView/Paragraph.tsx b/packages/gitbook/src/components/DocumentView/Paragraph.tsx index d2f475acf..1212bbd36 100644 --- a/packages/gitbook/src/components/DocumentView/Paragraph.tsx +++ b/packages/gitbook/src/components/DocumentView/Paragraph.tsx @@ -18,7 +18,17 @@ export function Paragraph(props: BlockProps) { 'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center'; const paragraph = ( -

+

); @@ -33,8 +43,16 @@ export function Paragraph(props: BlockProps) { const text = aiAssistantEnabled ? getNodeText(block) : ''; if (aiAssistantEnabled && text.trim()) { + // The wrapper is now the flex child, so it must carry the block alignment (notably + // `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages. return ( -
+
{paragraph}
diff --git a/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx index b4c50f356..c1ecc4efb 100644 --- a/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx +++ b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx @@ -1,4 +1,3 @@ -import { tcls } from '@/lib/tailwind'; import { CustomizationPageActionType, type DocumentBlockPrompt, @@ -15,21 +14,12 @@ export function Prompt(props: BlockProps) { block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null; return ( -
- -
+ ); } diff --git a/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx index b768e99c7..cb2fbfe69 100644 --- a/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx +++ b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - Button, - ButtonGroup, - DropdownMenu, - DropdownMenuItem, - ToggleChevron, -} from '@/components/primitives'; +import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives'; import { getURLForLLM } from '@/components/utils'; import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; @@ -26,10 +20,21 @@ export function PromptClient(props: { const language = useLanguage(); const promptId = React.useId(); const [open, setOpen] = React.useState(false); - const [headerHasFocus, setHeaderHasFocus] = React.useState(false); return ( - <> -
+
+
) : null} - - ); -} - -function PromptDisclosureIcon(props: { - contentIcon: IconName | null; - headerHasFocus: boolean; - open: boolean; -}) { - const { contentIcon, headerHasFocus, open } = props; - return ( - - {contentIcon ? ( - <> - - - - - - - - ) : ( - - )} - +
); } @@ -109,10 +75,10 @@ function PromptActions(props: { prompt: string; openInAIProviders: boolean }) { const { prompt, openInAIProviders } = props; return ( - +
{openInAIProviders ? : null} - +
); } @@ -140,11 +106,9 @@ function CopyPromptButton(props: { prompt: string }) { return ( + +
+
+ + ); +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx b/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx new file mode 100644 index 000000000..3d1ee9bbd --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx @@ -0,0 +1,203 @@ +import { Icon } from '@gitbook/icons'; + +import { StyledLink } from '@/components/primitives/StyledLink'; +import type { SiteOAuthConsentStart } from '@/lib/site-oauth'; +import { tcls } from '@/lib/tailwind'; + +import { ConsentForm } from './ConsentForm'; + +/** + * Consent screen shown to a visitor when an MCP client requests authorization to a published site. + */ +export function ConsentScreen(props: { + siteId: string; + siteTitle: string; + consent: SiteOAuthConsentStart; +}) { + const { siteId, siteTitle, consent } = props; + const { client, redirectUri, consentSessionId } = consent; + const redirectParts = parseRedirectURI(redirectUri); + + return ( + +
+ {/* Client identity */} +
+ {client.logoUri ? ( + + ) : ( + + + + )} + +
+
+

{client.name}

+ +
+ {client.uri ? ( + + Website + + + ) : null} +
+
+ + {/* Request statement — toned down, with the client and site names emphasized. */} +

+ {client.name} wants to + access {siteTitle} MCP{' '} + on your behalf. +

+ + {/* Redirect URI, shown in full with the destination host emphasized. */} +
+ + After approving, an authorization code will be sent to: + +
+ + + {redirectParts ? ( + <> + {redirectParts.prefix} + + {redirectParts.host} + + {redirectParts.rest} + + ) : ( + {redirectUri} + )} + +
+
+ + {client.verified ? null : ( +
+ +
+ + GitBook has not verified this client + + + Only approve if you recognize this application and trust it with + access to {siteTitle}. + +
+
+ )} +
+ + {/* Footer: trust acknowledgement + decision */} +
+ +
+
+ ); +} + +/** + * Centered, branded card shell shared by the consent screen and its error state. + */ +function ConsentCard(props: { children: React.ReactNode }) { + return ( +
+
+ {props.children} +
+
+ ); +} + +/** + * Error state shown when the consent flow cannot be started (e.g. a refreshed or expired link). + */ +export function ConsentError(props: { title?: string; message?: string }) { + const { + title = 'This authorization link has expired', + message = 'Please start the sign-in again from the application.', + } = props; + + return ( + +
+ + + +

{title}

+

{message}

+
+
+ ); +} + +/** + * Split a redirect URI so the destination host (the trust-relevant part) can be emphasized while + * the scheme and path are shown muted. Returns null if the URI can't be parsed. + */ +function parseRedirectURI(uri: string): { prefix: string; host: string; rest: string } | null { + try { + const url = new URL(uri); + return { + prefix: `${url.protocol}//`, + host: url.host, + rest: `${url.pathname}${url.search}${url.hash}`, + }; + } catch { + return null; + } +} + +/** + * Inline verified/unverified indicator shown next to the client name. + */ +function ClientTrustBadge(props: { verified: boolean }) { + const { verified } = props; + + return ( + + + {verified ? 'Verified' : 'Unverified'} + + ); +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/actions.ts b/packages/gitbook/src/components/SiteOAuthConsent/actions.ts new file mode 100644 index 000000000..4ed6d96e7 --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/actions.ts @@ -0,0 +1,39 @@ +'use server'; + +import { type SiteOAuthConsentDecision, submitSiteOAuthConsentDecision } from '@/lib/site-oauth'; + +export type SubmitConsentInput = { + siteId: string; + consentSessionId: string; + decision: SiteOAuthConsentDecision; + trusted: boolean; +}; + +export type SubmitConsentResult = { redirectURL: string } | { error: string }; + +/** + * Server action to submit the consent decision to the sites OAuth server's `consent/decision` endpoint. + */ +export async function submitSiteOAuthConsent( + input: SubmitConsentInput +): Promise { + const { siteId, consentSessionId, decision, trusted } = input; + + if (!siteId || !consentSessionId || (decision !== 'approve' && decision !== 'deny')) { + return { error: 'Invalid request. Please start again from the application.' }; + } + + try { + const { redirectURL } = await submitSiteOAuthConsentDecision({ + siteId, + consentSessionId, + decision, + trusted, + }); + return { redirectURL }; + } catch (_error) { + return { + error: 'We could not complete the authorization. The request may have expired — please start again from the application.', + }; + } +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/index.ts b/packages/gitbook/src/components/SiteOAuthConsent/index.ts new file mode 100644 index 000000000..f7aaddeef --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/index.ts @@ -0,0 +1 @@ +export { ConsentScreen, ConsentError } from './ConsentScreen'; diff --git a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx index 9b9ed2e59..8808433ca 100644 --- a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx +++ b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx @@ -24,6 +24,7 @@ export function PageClientLayout({ useRegisterPageMetadata({ pageMetaLinks }); useStripFallbackQueryParam(); + useSetCoverHeight(); return null; } @@ -60,3 +61,78 @@ function useRegisterPageMetadata(metadata: { currentPageMetadataStore.setState({ metaLinks: pageMetaLinks }); }, [pageMetaLinks]); } + +/** + * Expose the visible bottom edge of the page cover as a viewport-relative CSS variable. + */ +function useSetCoverHeight() { + React.useEffect(() => { + const root = document.documentElement; + let animationFrame: number | null = null; + + const updateCoverHeight = () => { + const pageCover = document.querySelector('[data-gb-page-cover]'); + const isBackgroundCover = pageCover?.dataset.coverType === 'background'; + + if (!isBackgroundCover) { + root.style.setProperty('--cover-height', '0px'); + return; + } + + if (!pageCover) { + return; + } + + const bottom = pageCover.getBoundingClientRect().bottom; + const height = Math.max(Math.min(bottom, window.innerHeight), 0); + + root.style.setProperty('--cover-height', `${height}px`); + }; + + const scheduleUpdate = () => { + if (animationFrame !== null) { + return; + } + + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + updateCoverHeight(); + }); + }; + + scheduleUpdate(); + + window.addEventListener('scroll', scheduleUpdate, { passive: true }); + window.addEventListener('resize', scheduleUpdate, { passive: true }); + + const pageCover = document.querySelector('[data-gb-page-cover]'); + const resizeObserver = + pageCover && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + scheduleUpdate(); + }) + : null; + + if (pageCover && resizeObserver) { + resizeObserver.observe(pageCover); + } + + // Dismissing the announcement banner only toggles a class on (see + // dismissAnnouncement) — no scroll/resize event and no cover resize — yet it shifts the + // cover up. Watch class changes so the cover height is recomputed in that case too. + const classObserver = + typeof MutationObserver !== 'undefined' ? new MutationObserver(scheduleUpdate) : null; + classObserver?.observe(root, { attributes: true, attributeFilter: ['class'] }); + + return () => { + if (animationFrame !== null) { + cancelAnimationFrame(animationFrame); + } + + resizeObserver?.disconnect(); + classObserver?.disconnect(); + window.removeEventListener('scroll', scheduleUpdate); + window.removeEventListener('resize', scheduleUpdate); + }; + }, []); +} diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 073717f49..02b3083f6 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -25,7 +25,7 @@ import { } from '@/lib/icons/inline'; import { getResizedImageURL } from '@/lib/images'; import { resolveContentRef } from '@/lib/references'; -import { getLocalizedTitle } from '@/lib/sites'; +import { getSiteStructureTitle } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; import { getPageRSSURL } from '@/routes/rss'; import { PageContextProvider } from '../PageContext'; @@ -81,9 +81,18 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean }) <> {/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default. If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */} -
+
{withFullPageCover && page.cover ? ( - + ) : null}
section.object === 'site-section').length > 1 // Only if there are multiple sections - ) { - title.push(getLocalizedTitle(sections.current, currentLanguage)); - } - if ( - siteSpaces.length > 1 && // Only if there are multiple variants - siteSpace.default === false && // Only if the variant is not the default one - siteSpaces.filter((space) => space.space.language === siteSpace.space.language).length > 1 // Only if there are multiple variants *for the current language*. This filters out spaces that are "just" translations of each other, not versions. - ) { - title.push(getLocalizedTitle(siteSpace, siteSpace.space.language)); - } - return title.join(' '); -} - export async function generateSitePageMetadata(props: SitePageProps): Promise { const { context, pageTarget, pageMetaLinks } = await getPageDataWithFallback({ context: props.context, @@ -297,7 +281,7 @@ export async function getSitePageData(props: SitePageProps) { const withFullPageCover = !!( page.cover && page.layout.cover && - page.layout.coverSize === 'full' + (page.layout.coverSize === 'full' || page.layout.coverSize === 'background') ); const withPageFeedback = customization.feedback.enabled; diff --git a/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx b/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx index 6f0f01b16..7e90bd4a5 100644 --- a/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx +++ b/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react'; import { useAI } from '../AI'; import { PreservePageLayout } from '../PageBody/PreservePageLayout'; import { useSetSearchState } from '../Search'; +import { fetchSiteIndex } from '../Search/site-index'; import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink'; import { useSiteAdaptiveAuthLoginHref, @@ -229,34 +230,21 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) { ) : null; } -/** Minimal shape of an entry in the `~gitbook/site-index` response. */ -type IndexPage = { - id: string; - title: string; - pathname: string; - siteSpaceId: string; - icon?: string; - emoji?: string; -}; - /** * Return the pages whose path is closest to the one that 404'd. * * Rather than asking the server (which would mean an extra request per 404), this reuses the - * search index served at `~gitbook/site-index` — already preloaded and CDN-cached on every page — - * so it's a cache hit, not an origin request. The ranking is a lighter, client-side cousin of - * `getSimilarPages` (which the Markdown 404 runs server-side from the full page tree). + * search index served at `~gitbook/site-index` — preloaded on every page and shared with + * instant search via the module cache in `site-index.ts`, so at most one request is made. + * The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404 + * runs server-side from the full page tree). */ async function getRelatedPages( indexURL: string, requestedPath: string, siteSpaceId: string | null ): Promise { - const response = await fetch(indexURL); - if (!response.ok) { - return []; - } - const { pages } = (await response.json()) as { pages: IndexPage[] }; + const { pages } = await fetchSiteIndex(indexURL); return pages .filter((page) => !siteSpaceId || page.siteSpaceId === siteSpaceId) diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 0f6fa0c6e..8636c53c1 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -165,20 +165,26 @@ export function SiteSectionTabs(props: {
diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index eb0309f29..6325db25c 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -132,10 +132,10 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection, asEmbe * 4. Otherwise, return the default first language match. * 5. Otherwise, return the default one. */ -function findBestTargetURL( +export function findBestTargetURL( context: GitBookSiteContext, section: SiteSection, - asEmbeddable: boolean + asEmbeddable = false ) { const { siteSpace: currentSiteSpace } = context; diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 4d32607ba..9efffabf8 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -1,14 +1,11 @@ import type { GitBookSiteContext } from '@/lib/context'; -import { - CustomizationAIMode, - CustomizationHeaderPreset, - CustomizationSearchStyle, -} from '@gitbook/api'; +import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/api'; import type React from 'react'; import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; import { TableOfContents } from '@/components/TableOfContents'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { GITBOOK_APP_URL } from '@/lib/env'; import { tcls } from '@/lib/tailwind'; @@ -131,7 +128,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
- {customization.ai?.mode === CustomizationAIMode.Assistant ? ( + {isAIChatEnabled(customization.ai?.mode) ? ( <> @@ -147,7 +144,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { 'lg:flex-row', 'lg:justify-center', CONTAINER_STYLE, - 'transition-[max-width] duration-300', + 'transition-[max-width] duration-300 motion-reduce:transition-none', !withTopHeader || variants.generic.length > 1 ? 'has-sidebar' @@ -177,7 +174,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { // On bold themes also color the TOC header so the logo looks correct. 'site-header:theme-bold:bg-header-background', 'site-header:theme-bold:m-[-1.5rem_-1px_-0.5rem_-2rem]', - 'site-header:theme-bold:p-[1rem_0_1rem_2rem]' + 'site-header:theme-bold:p-[1rem_1rem_1rem_2rem]' )} > @@ -191,6 +188,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { } siteSpaces={variants.translations} className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" + variant="header" /> ) : null}
diff --git a/packages/gitbook/src/components/TableOfContents/BackToSpaceButton.tsx b/packages/gitbook/src/components/TableOfContents/BackToSpaceButton.tsx new file mode 100644 index 000000000..0b473aec9 --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/BackToSpaceButton.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Icon } from '@gitbook/icons'; + +import { t, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; + +import { useBackToSpace } from '../hooks'; +import { Link } from '../primitives'; + +/** + * Shortcut displayed at the top of the ToC to navigate back to the space the reader + * came from, when they followed a link into a different space. + * + * It renders nothing until a cross-space navigation is detected client-side (see + * {@link useBackToSpace}), so it has no effect on the vast majority of page views. + */ +export function BackToSpaceButton(props: { spaceId: string; spaceTitle: string }) { + const { spaceId, spaceTitle } = props; + const language = useLanguage(); + const backToSpace = useBackToSpace({ spaceId, spaceTitle }); + + if (!backToSpace) { + return null; + } + + return ( + // Top margin so the shortcut, as the first element in the sidebar, isn't flush + // against the (rounded) filled-sidebar edge. +
+ + + + {t(language, 'toc_back_to_space', backToSpace.spaceTitle)} + + +
+ ); +} diff --git a/packages/gitbook/src/components/TableOfContents/PagesList.tsx b/packages/gitbook/src/components/TableOfContents/PagesList.tsx index e00ee94b4..f164e2a90 100644 --- a/packages/gitbook/src/components/TableOfContents/PagesList.tsx +++ b/packages/gitbook/src/components/TableOfContents/PagesList.tsx @@ -5,6 +5,7 @@ import type { ClientTOCPage } from './encodeClientTableOfContents'; import { type ClassValue, tcls } from '@/lib/tailwind'; import assertNever from 'assert-never'; +import { markSpaceNavigationFromTOCLinkOnClick } from '../hooks'; import { PageDocumentItem } from './PageDocumentItem'; import { PageGroupItem } from './PageGroupItem'; import { PageLinkItem } from './PageLinkItem'; @@ -17,7 +18,13 @@ export function PagesList(props: { const { pages, style, isRoot = false } = props; return ( -
    +
      {pages.map((page, idx) => { switch (page.type) { case 'document': diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 25d2c5b95..dcc7edcdf 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -2,9 +2,11 @@ import type { GitBookSiteContext } from '@/lib/context'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import type React from 'react'; +import { getSiteStructureTitle } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; import { ScrollContainer } from '../primitives/ScrollContainer'; import { SideSheet } from '../primitives/SideSheet'; +import { BackToSpaceButton } from './BackToSpaceButton'; import { PagesList } from './PagesList'; import { TableOfContentsScript } from './TableOfContentsScript'; import { Trademark } from './Trademark'; @@ -119,6 +121,12 @@ export async function TableOfContents(props: { '[html.sidebar-filled.circular-corners_&]:layout-wide:rounded-4xl' )} > + {innerHeader}