diff --git a/.agents/skills/write-pr-description/SKILL.md b/.agents/skills/write-pr-description/SKILL.md new file mode 100644 index 000000000..3b3abfe8e --- /dev/null +++ b/.agents/skills/write-pr-description/SKILL.md @@ -0,0 +1,48 @@ +--- +name: write-pr-description +description: Write a concise, product-focused PR description from the changes and available discussion. Use when asked for a PR summary or description. +--- + +# Write a PR description + +Read the full branch diff and commit history against the PR's base branch, not just the latest commit. Use the PR discussion and linked issues for context when available. The PR's changes are the source of truth for what ships if an issue describes something different. + +Write for contributors and reviewers who may not know the area. Lead with what changes, prefer user or product impact when supported, and include implementation details only when they help review. Keep the proposed changes short, using bullets only when useful. + +Include relevant validation and screenshots when available. Do not claim checks passed unless they ran. Use only information appropriate for a public repository; do not copy private issue details or customer information into the description. + +## Template and changelog + +Read `.github/pull_request_template.md` immediately before writing. Preserve its required section names and order. Include `Proposed changes` and `Changelog`; omit `Demo` when there is nothing to show. Add optional `Context` before `Changelog` only when it meets the rule below. + +Write a short changelog entry describing what shipped. Use `[Feature]` for user-facing additions or improvements, `[Fix]` for user-facing bug fixes, and `[Chore]` for internal maintenance. For features and fixes, say what users can do or what no longer goes wrong in plain words. Keep chores useful to teammates without inventing user impact. Remove unused examples and placeholders. Usually one entry is enough; use more only for distinct changes. The PR changelog does not replace any changeset required by `AGENTS.md`. + +## Optional context + +Write `Proposed changes` and `Changelog` first. Add `Context` only when it tells someone unfamiliar with the area something material that the changelog does not. `Problem` names a concrete failure or limitation supported by the PR or discussion. `Outcome` names what this PR delivers at its actual scope. Omit the section if either field would repeat the changelog, describe only implementation, or guess at an impact. + +```markdown +## Context + +**Problem:** + +**Outcome:** +``` + +Keep each field to 1–2 short, non-technical sentences. Prefer user or product impact when supported. For infrastructure changes, explain the failure or limitation they caused. Do not invent an actor, customer segment, ownership, or impact, or force the change into a user story. Do not turn a preparatory change into a claim that the whole workflow is fixed. Keep the roles distinct: `Context` explains why this matters, `Changelog` says what shipped, and `Proposed changes` can explain implementation. + +Examples: + +- A dependency bump with no demonstrated user effect needs no `Context`. +- If creating the first change request leaves the user on an empty screen, say that it now opens after creation. Leave the animation-listener details in `Proposed changes`. +- If a PR indexes links for later checks, describe the indexing result. Do not claim every link has already been checked. + +## Writing pass + +- Use plain words and direct verbs: "use" instead of "leverage", "help" instead of "facilitate". Prefer active voice when the actor is known. +- Name the concrete behavior instead of saying "improves the experience" or "makes it more robust". Support performance claims with measurements. +- Cut filler, promotional language, and generic conclusions. If a sentence could fit almost any PR, make it specific or delete it. +- Keep one idea per sentence and use consistent names. Remove redundant hedging without hiding real uncertainty. +- Avoid "not just X, but Y", forced groups of three, em dashes, decorative emojis, and unnecessary bold text. + +Return the description in a Markdown code block unless asked to update the PR. When asked to update it, apply the description with `gh pr edit` using a body file. diff --git a/.changeset/ask-param-no-search-flash.md b/.changeset/ask-param-no-search-flash.md new file mode 100644 index 000000000..88733a4fa --- /dev/null +++ b/.changeset/ask-param-no-search-flash.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Stop the `?ask` query briefly appearing in the search field before an integration assistant opens. diff --git a/.changeset/ask-view-trigger.md b/.changeset/ask-view-trigger.md new file mode 100644 index 000000000..f4f9cd804 --- /dev/null +++ b/.changeset/ask-view-trigger.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Track which Ask AI entry point opened the chat in ask view analytics. diff --git a/.changeset/assistant-leave-feedback-tools.md b/.changeset/assistant-leave-feedback-tools.md new file mode 100644 index 000000000..52be4392d --- /dev/null +++ b/.changeset/assistant-leave-feedback-tools.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add `leaveAgentFeedback` and `leaveUserFeedback` assistant tools, reporting documentation findings to the site's team through the same path as the MCP `sendFeedback` tool, and record an `agent_feedback` insights event on both surfaces. diff --git a/.changeset/brave-covers-mark.md b/.changeset/brave-covers-mark.md new file mode 100644 index 000000000..69cc3ed29 --- /dev/null +++ b/.changeset/brave-covers-mark.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Recolor text over a background page cover in the first frame after hydration. diff --git a/.changeset/bright-clubs-hunt.md b/.changeset/bright-clubs-hunt.md new file mode 100644 index 000000000..d7bca73a5 --- /dev/null +++ b/.changeset/bright-clubs-hunt.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Update API diff --git a/.changeset/bright-pages-describe.md b/.changeset/bright-pages-describe.md new file mode 100644 index 000000000..59685630d --- /dev/null +++ b/.changeset/bright-pages-describe.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Include published page descriptions in the page's Markdown output. diff --git a/.changeset/calm-toc-expand.md b/.changeset/calm-toc-expand.md new file mode 100644 index 000000000..5ea1aa053 --- /dev/null +++ b/.changeset/calm-toc-expand.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix table of contents scrolling back to the current page when expanding another group. diff --git a/.changeset/chubby-adults-poke.md b/.changeset/chubby-adults-poke.md new file mode 100644 index 000000000..f51701275 --- /dev/null +++ b/.changeset/chubby-adults-poke.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix select filters not working on table and cards blocks. diff --git a/.changeset/contentkit-buttons-natural-width.md b/.changeset/contentkit-buttons-natural-width.md new file mode 100644 index 000000000..197f9fc06 --- /dev/null +++ b/.changeset/contentkit-buttons-natural-width.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ContentKit buttons to size to their content instead of stretching to the full container width. diff --git a/.changeset/cross-space-repository-links.md b/.changeset/cross-space-repository-links.md new file mode 100644 index 000000000..7a3e6f56a --- /dev/null +++ b/.changeset/cross-space-repository-links.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available. diff --git a/.changeset/eighty-donkeys-tease.md b/.changeset/eighty-donkeys-tease.md new file mode 100644 index 000000000..c1936d346 --- /dev/null +++ b/.changeset/eighty-donkeys-tease.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Give the prompt block a default "Copy prompt" action alongside "Open in Claude", "Open in Codex" and "Open in Cursor", and remember the visitor's last pick across every prompt block. diff --git a/.changeset/embed-breadcrumb-section-links.md b/.changeset/embed-breadcrumb-section-links.md new file mode 100644 index 000000000..bb86dcd15 --- /dev/null +++ b/.changeset/embed-breadcrumb-section-links.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Keep the docs embed open when switching section, variant or language. diff --git a/.changeset/embed-default-tab-page.md b/.changeset/embed-default-tab-page.md new file mode 100644 index 000000000..fc3cbf8bd --- /dev/null +++ b/.changeset/embed-default-tab-page.md @@ -0,0 +1,6 @@ +--- +"@gitbook/embed": minor +"gitbook": patch +--- + +Add `defaultTab` and `defaultPage` options to the Docs Embed to choose where it opens. `defaultTab` opens the embed on the assistant, search or docs tab. `defaultPage` sets the page the docs tab opens on in place of the site's home page, and the embed opens on that page unless `defaultTab` says otherwise. Both work with the standalone script's `configure`, `frame.configure` and `` props. diff --git a/.changeset/fix-cover-image-jump.md b/.changeset/fix-cover-image-jump.md new file mode 100644 index 000000000..d5d255e56 --- /dev/null +++ b/.changeset/fix-cover-image-jump.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix page cover image jumping on load diff --git a/.changeset/fix-rss-discovery.md b/.changeset/fix-rss-discovery.md new file mode 100644 index 000000000..23751a09c --- /dev/null +++ b/.changeset/fix-rss-discovery.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Only advertise RSS feeds on pages with Updates blocks. diff --git a/.changeset/fluffy-houses-pay.md b/.changeset/fluffy-houses-pay.md new file mode 100644 index 000000000..50817d6a2 --- /dev/null +++ b/.changeset/fluffy-houses-pay.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Use the DocumentTextColor type defined in API schema. diff --git a/.changeset/friendly-bots-search.md b/.changeset/friendly-bots-search.md new file mode 100644 index 000000000..6429969a0 --- /dev/null +++ b/.changeset/friendly-bots-search.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Serve Markdown responses as plain text to OpenAI's ChatGPT Search crawler. diff --git a/.changeset/hide-anchor-links-landing.md b/.changeset/hide-anchor-links-landing.md new file mode 100644 index 000000000..4628ca658 --- /dev/null +++ b/.changeset/hide-anchor-links-landing.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages). diff --git a/.changeset/hide-intercom-launcher-with-assistant.md b/.changeset/hide-intercom-launcher-with-assistant.md new file mode 100644 index 000000000..460d990dd --- /dev/null +++ b/.changeset/hide-intercom-launcher-with-assistant.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Hide the Intercom launcher while the Assistant panel is open, so it no longer overlaps the message box. diff --git a/.changeset/hint-button-contrast.md b/.changeset/hint-button-contrast.md new file mode 100644 index 000000000..fd6bfea64 --- /dev/null +++ b/.changeset/hint-button-contrast.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix low-contrast button labels inside success, warning and danger hints. diff --git a/.changeset/hungry-taxes-admire.md b/.changeset/hungry-taxes-admire.md new file mode 100644 index 000000000..f26a87d49 --- /dev/null +++ b/.changeset/hungry-taxes-admire.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Agents can now send a goal with any question they ask on the Markdown version of a page. diff --git a/.changeset/inline-ask-ai-search-state.md b/.changeset/inline-ask-ai-search-state.md new file mode 100644 index 000000000..1fe9f111f --- /dev/null +++ b/.changeset/inline-ask-ai-search-state.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix inline Ask AI inputs and buttons doing nothing before search is opened. diff --git a/.changeset/insights-canonical-url.md b/.changeset/insights-canonical-url.md new file mode 100644 index 000000000..c55a29a68 --- /dev/null +++ b/.changeset/insights-canonical-url.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Track server-side Insights events (markdown, llms.txt, RSS, ask) with the site's public URL instead of the proxy URL. diff --git a/.changeset/insights-previous-url.md b/.changeset/insights-previous-url.md new file mode 100644 index 000000000..d3ab5fd91 --- /dev/null +++ b/.changeset/insights-previous-url.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Send the previous page's URL with site insights events so broken links can be traced to the page linking to them. diff --git a/.changeset/lazy-load-embed-iframes.md b/.changeset/lazy-load-embed-iframes.md new file mode 100644 index 000000000..02eaa5690 --- /dev/null +++ b/.changeset/lazy-load-embed-iframes.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them. diff --git a/.changeset/llms-full-pagination-header.md b/.changeset/llms-full-pagination-header.md new file mode 100644 index 000000000..f32bb78e2 --- /dev/null +++ b/.changeset/llms-full-pagination-header.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Announce pagination at the top of every part of a multi-part llms-full.txt, with absolute links to the previous and next parts. diff --git a/.changeset/mermaid-11-16-1.md b/.changeset/mermaid-11-16-1.md new file mode 100644 index 000000000..ccc53c19f --- /dev/null +++ b/.changeset/mermaid-11-16-1.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Update Mermaid to 11.16.1 to match the editor, adding support for doughnut charts. diff --git a/.changeset/mermaid-block-diagrams.md b/.changeset/mermaid-block-diagrams.md new file mode 100644 index 000000000..4a086982a --- /dev/null +++ b/.changeset/mermaid-block-diagrams.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix Mermaid block diagrams falling back to a code block on published sites. diff --git a/.changeset/mermaid-clipped-labels.md b/.changeset/mermaid-clipped-labels.md new file mode 100644 index 000000000..4d917cf70 --- /dev/null +++ b/.changeset/mermaid-clipped-labels.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix long Mermaid labels being clipped instead of wrapping at some browser zoom levels. diff --git a/.changeset/mermaid-edge-label-contrast.md b/.changeset/mermaid-edge-label-contrast.md new file mode 100644 index 000000000..ea3f6b0c2 --- /dev/null +++ b/.changeset/mermaid-edge-label-contrast.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Improve dark theme contrast for Mermaid edge labels. diff --git a/.changeset/nice-cooks-melt.md b/.changeset/nice-cooks-melt.md new file mode 100644 index 000000000..87454a67b --- /dev/null +++ b/.changeset/nice-cooks-melt.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Improve the prompt for agents to ask questions. diff --git a/.changeset/non-ascii-missing-page-404.md b/.changeset/non-ascii-missing-page-404.md new file mode 100644 index 000000000..394e896da --- /dev/null +++ b/.changeset/non-ascii-missing-page-404.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect. diff --git a/.changeset/openapi-nullable-recursion.md b/.changeset/openapi-nullable-recursion.md new file mode 100644 index 000000000..367166228 --- /dev/null +++ b/.changeset/openapi-nullable-recursion.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports. diff --git a/.changeset/page-tag-title.md b/.changeset/page-tag-title.md new file mode 100644 index 000000000..72cc8e4c7 --- /dev/null +++ b/.changeset/page-tag-title.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Use the page's tag title, when set, for the HTML `` of published pages. diff --git a/.changeset/preview-auth-content-path.md b/.changeset/preview-auth-content-path.md new file mode 100644 index 000000000..3d8dd6f2a --- /dev/null +++ b/.changeset/preview-auth-content-path.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Preserve the full site preview path and query parameters when redirecting users to log in. diff --git a/.changeset/preview-cookie-path-scope.md b/.changeset/preview-cookie-path-scope.md new file mode 100644 index 000000000..bbc5d9a25 --- /dev/null +++ b/.changeset/preview-cookie-path-scope.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix previewed customization and theme overrides being lost when navigating between pages inside the site preview. The preview cookie was scoped to the site's canonical base path instead of the preview route it is actually served under, so the browser dropped it on the next in-preview navigation. diff --git a/.changeset/preview-page-not-found.md b/.changeset/preview-page-not-found.md new file mode 100644 index 000000000..418a0510e --- /dev/null +++ b/.changeset/preview-page-not-found.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix pages resolving to "not found" when a root URL lookup resolves to a custom homepage, by no longer using the homepage pathname as a prefix for the requested page path. diff --git a/.changeset/protected-page-login-copy.md b/.changeset/protected-page-login-copy.md new file mode 100644 index 000000000..1c78d4810 --- /dev/null +++ b/.changeset/protected-page-login-copy.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Clarify the not-found page on sites with login-protected content: prompt anonymous visitors to log in, and tell logged-in visitors they may not have access. diff --git a/.changeset/quiet-frames-travel.md b/.changeset/quiet-frames-travel.md new file mode 100644 index 000000000..c2a0e8680 --- /dev/null +++ b/.changeset/quiet-frames-travel.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Pass page context to webframes opened from integration modals. diff --git a/.changeset/quiet-groups-link.md b/.changeset/quiet-groups-link.md new file mode 100644 index 000000000..4f1fb7398 --- /dev/null +++ b/.changeset/quiet-groups-link.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Stop page group breadcrumbs from linking to a URL that redirects to the group's first page. diff --git a/.changeset/quiet-tags-filter.md b/.changeset/quiet-tags-filter.md new file mode 100644 index 000000000..a1c44b7ce --- /dev/null +++ b/.changeset/quiet-tags-filter.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter. diff --git a/.changeset/quiet-toc-scroll.md b/.changeset/quiet-toc-scroll.md new file mode 100644 index 000000000..b35cd70de --- /dev/null +++ b/.changeset/quiet-toc-scroll.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Scroll the table of contents to the active page after client-side navigation. diff --git a/.changeset/redirect-non-ascii-source.md b/.changeset/redirect-non-ascii-source.md new file mode 100644 index 000000000..0645b5bf1 --- /dev/null +++ b/.changeset/redirect-non-ascii-source.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Site redirects whose source path has accented or non-Latin characters now redirect visitors instead of returning 404. diff --git a/.changeset/regional-translation-locales.md b/.changeset/regional-translation-locales.md new file mode 100644 index 000000000..d4bc06174 --- /dev/null +++ b/.changeset/regional-translation-locales.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Show site UI in French or Spanish for spaces set to Canadian French, Mexican Spanish or Latin American Spanish. diff --git a/.changeset/relative-insights-visitor-urls.md b/.changeset/relative-insights-visitor-urls.md new file mode 100644 index 000000000..aec780a60 --- /dev/null +++ b/.changeset/relative-insights-visitor-urls.md @@ -0,0 +1,7 @@ +--- +"gitbook": patch +--- + +Fix analytics and adaptive content silently breaking on sites served from a different host than the one configured (apex vs www, domain alias, CDN). The insights and visitor-claims endpoints are now requested relative to the page's own origin instead of the configured host, which a prerendered page cannot know. + +Keep the traffic of GitBook's own preview deployments out of the sites' analytics, now that those requests reach the events endpoint. diff --git a/.changeset/restore-next-oom-patch-16-3-8.md b/.changeset/restore-next-oom-patch-16-3-8.md new file mode 100644 index 000000000..fc25eb64d --- /dev/null +++ b/.changeset/restore-next-oom-patch-16-3-8.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade. diff --git a/.changeset/restore-next-oom-patch.md b/.changeset/restore-next-oom-patch.md new file mode 100644 index 000000000..9c77d4b7e --- /dev/null +++ b/.changeset/restore-next-oom-patch.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.6 upgrade (root `package.json`'s `patchedDependencies` still pinned it to the old `16.3.3` patch file, so Bun stopped applying it). diff --git a/.changeset/rnd-12671-modal-close-button.md b/.changeset/rnd-12671-modal-close-button.md new file mode 100644 index 000000000..771b704db --- /dev/null +++ b/.changeset/rnd-12671-modal-close-button.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Show a close button on ContentKit modals rendered on published sites. diff --git a/.changeset/rnd-12672-webframe-dispatch-actions.md b/.changeset/rnd-12672-webframe-dispatch-actions.md new file mode 100644 index 000000000..8789232f2 --- /dev/null +++ b/.changeset/rnd-12672-webframe-dispatch-actions.md @@ -0,0 +1,7 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Let ContentKit webframes dispatch the built-in `@ui.*` actions. Actions posted from a webframe were forwarded to the integration as a plain re-render instead of being handled, so `@ui.modal.open`, `@ui.modal.close` and `@ui.url.open` did nothing — most visibly, an integration could not close a modal from inside its own webframe. + +Keep `null` values and class instances such as `Date` intact when resolving dynamic bindings in an action, instead of throwing or flattening them to `{}`. diff --git a/.changeset/rnd-12844-search-section-links-scroll.md b/.changeset/rnd-12844-search-section-links-scroll.md new file mode 100644 index 000000000..58d5c64a5 --- /dev/null +++ b/.changeset/rnd-12844-search-section-links-scroll.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix section links in search results opening the page without scrolling to the section. diff --git a/.changeset/search-button-current-space.md b/.changeset/search-button-current-space.md new file mode 100644 index 000000000..d627612c7 --- /dev/null +++ b/.changeset/search-button-current-space.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Let search buttons open search limited to the current space. diff --git a/.changeset/search-tab-focus-trap.md b/.changeset/search-tab-focus-trap.md new file mode 100644 index 000000000..b8ac452ac --- /dev/null +++ b/.changeset/search-tab-focus-trap.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix keyboard focus getting trapped inside the header search popover, allowing Tab/Shift+Tab to exit it as expected. diff --git a/.changeset/send-visitor-country.md b/.changeset/send-visitor-country.md new file mode 100644 index 000000000..7b2669f47 --- /dev/null +++ b/.changeset/send-visitor-country.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Send the visitor country to resolvePublishedContentByUrl on dev, preview, staging and selected sites, using the forwarded `x-gitbook-country` header on revalidation requests. diff --git a/.changeset/shared-ask-ai-paragraph-button.md b/.changeset/shared-ask-ai-paragraph-button.md new file mode 100644 index 000000000..392b36afc --- /dev/null +++ b/.changeset/shared-ask-ai-paragraph-button.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Render a single Ask AI paragraph button that moves to the hovered paragraph, instead of one hidden button per paragraph. diff --git a/.changeset/sidebar-group-first-item-clipped.md b/.changeset/sidebar-group-first-item-clipped.md new file mode 100644 index 000000000..a6f18e78d --- /dev/null +++ b/.changeset/sidebar-group-first-item-clipped.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the first item of a sidebar page group sometimes appearing cut off after navigating. diff --git a/.changeset/silly-wolves-cache.md b/.changeset/silly-wolves-cache.md new file mode 100644 index 000000000..7f5c4893a --- /dev/null +++ b/.changeset/silly-wolves-cache.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Use cached Git metadata (via @gitbook/api 0.201.0's `cachedMetadata` param) when rendering Edit on Git actions. diff --git a/.changeset/site-auth-login-location.md b/.changeset/site-auth-login-location.md new file mode 100644 index 000000000..f453c791f --- /dev/null +++ b/.changeset/site-auth-login-location.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the site auth login link sometimes redirecting back to an internal URL after login. diff --git a/.changeset/skip-ask-ai-in-tables.md b/.changeset/skip-ask-ai-in-tables.md new file mode 100644 index 000000000..7db75cc14 --- /dev/null +++ b/.changeset/skip-ask-ai-in-tables.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Skip rendering the Ask AI paragraph button inside table cells, where it is hidden, to keep pages with large tables within memory limits. diff --git a/.changeset/skip-internal-404-page-views.md b/.changeset/skip-internal-404-page-views.md new file mode 100644 index 000000000..8b69767d0 --- /dev/null +++ b/.changeset/skip-internal-404-page-views.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Don't track a page view for 404s on internal `~gitbook` routes, so they no longer appear as broken URLs. diff --git a/.changeset/slimy-parks-say.md b/.changeset/slimy-parks-say.md new file mode 100644 index 000000000..4dc09b933 --- /dev/null +++ b/.changeset/slimy-parks-say.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Narrow a table or card grid to the reader's content selection: choosing an option in a tab or a select button now filters any select column that offers it. The table says what it has been narrowed to, and offers a Clear that drops the selection. diff --git a/.changeset/static-markdown-theme-override.md b/.changeset/static-markdown-theme-override.md new file mode 100644 index 000000000..f3879764d --- /dev/null +++ b/.changeset/static-markdown-theme-override.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix 404 on Markdown pages (View as Markdown) in site previews and when a theme is forced with `?theme=`. diff --git a/.changeset/sweet-ends-occur.md b/.changeset/sweet-ends-occur.md new file mode 100644 index 000000000..4ccb6fcb0 --- /dev/null +++ b/.changeset/sweet-ends-occur.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Let a tab keep its content selection across a rename, by honouring an explicit slug set on the tab instead of always deriving one from its title. diff --git a/.changeset/tidy-variant-fallback.md b/.changeset/tidy-variant-fallback.md new file mode 100644 index 000000000..525e0d7b5 --- /dev/null +++ b/.changeset/tidy-variant-fallback.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Remove the fallback query parameter after successful page navigation without adding a browser history entry. diff --git a/.changeset/tidy-wolves-edit-git.md b/.changeset/tidy-wolves-edit-git.md new file mode 100644 index 000000000..77dc1cda8 --- /dev/null +++ b/.changeset/tidy-wolves-edit-git.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Restore Edit on Git page actions for Git-synced pages. diff --git a/.changeset/toolbar-analytics-link.md b/.changeset/toolbar-analytics-link.md new file mode 100644 index 000000000..3319b53d9 --- /dev/null +++ b/.changeset/toolbar-analytics-link.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the admin toolbar insights button to link to the site's analytics page. diff --git a/.changeset/track-file-download-click.md b/.changeset/track-file-download-click.md new file mode 100644 index 000000000..1d8d7a263 --- /dev/null +++ b/.changeset/track-file-download-click.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Track Insights link clicks on the Download button of file blocks. diff --git a/.changeset/trademark-powered-by-url.md b/.changeset/trademark-powered-by-url.md new file mode 100644 index 000000000..00d994694 --- /dev/null +++ b/.changeset/trademark-powered-by-url.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Point the "Powered by GitBook" trademark link to gitbook.com/powered-by. diff --git a/.changeset/typed-cache-identities.md b/.changeset/typed-cache-identities.md new file mode 100644 index 000000000..d03a77658 --- /dev/null +++ b/.changeset/typed-cache-identities.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Upgrade object-identity to preserve value types when caching object arguments. diff --git a/.changeset/visitor-country-hostnames-env.md b/.changeset/visitor-country-hostnames-env.md new file mode 100644 index 000000000..0d2a1a371 --- /dev/null +++ b/.changeset/visitor-country-hostnames-env.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Read the production hostnames that receive the visitor country from the `GITBOOK_VISITOR_COUNTRY_HOSTNAMES` build-time env variable. diff --git a/.changeset/webframe-reserve-size-before-mount.md b/.changeset/webframe-reserve-size-before-mount.md new file mode 100644 index 000000000..c32db756c --- /dev/null +++ b/.changeset/webframe-reserve-size-before-mount.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-contentkit": patch +"gitbook": patch +--- + +Reserve the webframe's size in the server-rendered layout so anchor links to content below integration blocks no longer land too high. diff --git a/.changeset/webmcp-published-docs.md b/.changeset/webmcp-published-docs.md new file mode 100644 index 000000000..57454328c --- /dev/null +++ b/.changeset/webmcp-published-docs.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled. diff --git a/.claude/skills/write-pr-description b/.claude/skills/write-pr-description new file mode 120000 index 000000000..a6899b7b4 --- /dev/null +++ b/.claude/skills/write-pr-description @@ -0,0 +1 @@ +../../.agents/skills/write-pr-description \ No newline at end of file diff --git a/.codex/skills/write-pr-description b/.codex/skills/write-pr-description new file mode 120000 index 000000000..a6899b7b4 --- /dev/null +++ b/.codex/skills/write-pr-description @@ -0,0 +1 @@ +../../.agents/skills/write-pr-description \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 182cb6f2e..90cbd8147 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,113 +1,89 @@ -# Welcome to GitBook's contributing guide! +# Contributing to GitBook -> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_ +> _For help, support and product questions, head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions)._ -Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute. +Thanks for helping make GitBook better. This repository holds the open source renderer behind every published GitBook site, and the team reads everything that comes in here. -## Types of Contributions +## Ways to contribute -This repository contains code related to the rendering engine of GitBook's published content. Depending on what you'd like to contribute to, head to the section below to find the necessary steps. +- **Open an issue.** Report a bug, suggest a feature, or tell us when something feels off. This is the most useful thing you can do. +- **Join the conversation.** Share ideas and help others in [GitHub Community](https://github.com/orgs/GitbookIO/discussions). +- **Build with GitBook.** Publish your docs, try new features early, and tell us what works and what doesn't. -### Add a feature +## Issues instead of pull requests -Because this portion of GitBook is open source and available for you to use - if you think you can provide extra value through a new feature - you're welcome to add it! If you plan to distribute the code, keep the source code public to comply with GNU GPLv3. To clone in a private repository, you must first acquire a [commercial license](https://www.gitbook.com/pricing). +Pull requests are limited to the GitBook team. If you've found a bug or want something to change, please [open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) instead. -### Create a new issue +This isn't because we don't want your help. Every change here ships to every published GitBook site, so we need to own each one from start to finish. A clear issue often helps us more than a pull request: it tells us what's wrong and why, and lets us fix it in a way that fits the rest of the codebase. -If you spot a problem within a repository, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue here!Please make sure any added issues are +A good issue includes: -- Descriptive -- Thoughtful -- Organized +- What you expected to happen, and what happened instead +- A link to a published GitBook site that shows the problem, if you have one +- Steps to reproduce it, plus screenshots or a recording for anything visual +- Your browser and OS, if it looks like it might matter -We recommend adding as many relevant links, minimal reproductions of the issue, and other materials that will help our team solve the issue fast. +Before opening one, [search existing issues](https://github.com/GitbookIO/gitbook/issues). If someone has already reported it, add a 👍 or any extra details you have. -### Solve an issue +Already fixed it in your fork? Link the branch or commit in your issue. We may open a pull request from it, and your commits keep your name. -If you're interested in solving an issue in our repository, start by scanning through it's exisiting issues to find one that you're interested in working on. If you find an issue to work on, you are welcome to open a PR with a fix. See the following sections below for more information on contributing for specific sections. +If you open a pull request anyway, we'll close it and point you here. Nothing personal, and we'd still love the issue. -### Documentation +## Reach out -The official documentation on GitBook open can be found directly in this Readme. Any updates or changes you would like to make, you can make directly to the README of this repository. +Not sure if it's a bug? Have a half-formed idea? Open an issue or start a [discussion](https://github.com/orgs/GitbookIO/discussions) anyway. We'd much rather hear from you than not, and no report is too small. -## Contributing +Spotted a wrong or missing translation? Open an issue with the string and the wording you'd suggest. -### Make changes locally +## Using AI -Any contribution you make can be made to the code located in this repository. In order to contribute, you'll need to start off of a local version of this repository. +You're welcome to use AI to help you write an issue. Please read and check what it produces before you post: reports that are long, vague or unverified take time away from fixing real problems, and we may close them without a reply. -#### 1. Fork the repository +## Running locally -##### Using GitHub Desktop: +You don't need a local setup to open an issue, but it can help you reproduce a problem or self-host the renderer. -- [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop. -- Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)! +### Prerequisites -##### Using the command line: +- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`) +- [Bun](https://bun.sh/) (see "packageManager" in `package.json`) -- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. +### Setup -#### 2. Create a working branch and start with your changes +1. Use the project's version of Node: -After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. - -#### 3. Install dependencies and run the project locally - -##### Prerequisites: - -- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`) -- [Bun](https://bun.sh/) (see "packageManager" in `package.json`) - -##### Setup steps: - -1. Ensure you are using the project's version of Node: ```bash nvm use ``` -2. Install dependencies using Bun: +2. Install dependencies: + ```bash bun install ``` 3. Start the development server: + ```bash bun dev ``` -Additional development commands: -- `bun lint`: Lint the code using Oxlint -- `bun lint:fix`: Automatically fix lint issues using Oxlint -- `bun format`: Format the code using Oxfmt -- `bun format:check`: Check formatting without changing files -- `bun typecheck`: Run TypeScript type checking -- `bun unit`: Run unit tests -- `bun e2e`: Run end-to-end tests +Other commands: -#### 4. Preview your changes +- `bun lint`: Lint the code using Oxlint +- `bun lint:fix`: Automatically fix lint issues using Oxlint +- `bun format`: Format the code using Oxfmt +- `bun format:check`: Check formatting without changing files +- `bun typecheck`: Run TypeScript type checking +- `bun unit`: Run unit tests +- `bun e2e`: Run end-to-end tests -When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`. +### Preview a site -For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/url/gitbook.com/docs` after running the development server. +With the development server running, any published GitBook site can be rendered through your local version at `http://localhost:3000/url/<published-url>`. For example, visit `http://localhost:3000/url/gitbook.com/docs` to see our own docs. -You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version. +The site must be [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to render locally. -### Commit your update +## License -[Commit your changes](https://github.com/git-guides/git-commit) once you are happy with them. See [Atom's contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages) to know how to use emoji for commit messages! - -Once your changes are ready, don't forget to self-review your code to double check that your chagnes are ready to be added. - -### Pull Request - -When you're finished with the changes, [create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request), also known as a PR. - -- Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. -- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. Once you submit your PR, a GitBook team member will review your proposal. We may ask questions or request for additional information. -- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. -- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). -- If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues. - -### Your PR is merged - -Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository. +The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing). diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 77bc5689d..346e295ac 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -69,6 +69,8 @@ runs: GITBOOK_RUNTIME: cloudflare GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} + GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }} + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }} shell: bash - name: Upload the DO worker diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 4aa9fbbfe..aeb934fd9 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -75,8 +75,19 @@ runs: echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local + echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---" cat .vercel/.env.${{ inputs.environment }}.local + - name: Inject visitor country hostnames + if: ${{ env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES != '' }} + shell: bash + run: | + echo "GITBOOK_VISITOR_COUNTRY_HOSTNAMES=$GITBOOK_VISITOR_COUNTRY_HOSTNAMES" >> .vercel/.env.${{ inputs.environment }}.local + - name: Inject staging build env vars + if: ${{ inputs.environment == 'staging' }} + shell: bash + run: | + echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local - name: Build Project Artifacts run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }} shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..5a4dcf95a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## Proposed changes + +<!-- Briefly describe what changed. Include relevant validation. --> + +## Demo + +<!-- Add screenshots or a recording when useful. Otherwise, remove this section. --> + +<!-- Optional: add Context here only when it explains a concrete problem and result that the changelog does not. Remove this comment before submitting. --> + +## Changelog + +<!-- Keep the relevant entries and remove unused placeholders. Feature = user-facing addition or improvement; Fix = user-facing bug fix; Chore = internal maintenance. --> + +- [Feature] ... +- [Fix] ... +- [Chore] ... diff --git a/.github/slack-pr-status-app-manifest.json b/.github/slack-pr-status-app-manifest.json new file mode 100644 index 000000000..920e74b69 --- /dev/null +++ b/.github/slack-pr-status-app-manifest.json @@ -0,0 +1,22 @@ +{ + "display_information": { + "name": "PR Status", + "description": "Updates PR status reactions in engineering" + }, + "features": { + "bot_user": { + "display_name": "PR Status", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": ["channels:history", "reactions:read", "reactions:write"] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/.github/slack-pr-status.md b/.github/slack-pr-status.md new file mode 100644 index 000000000..b73e6eeaf --- /dev/null +++ b/.github/slack-pr-status.md @@ -0,0 +1,15 @@ +# Slack PR status + +[GitbookIO/slack-pr-status](https://github.com/GitbookIO/slack-pr-status) adds review-status reactions to PR links posted in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs from `.github/workflows/slack-pr-status.yml` in `GitbookIO/gitbook`, `GitbookIO/gitbook-x` and `GitbookIO/integrations`. Emojis and statuses are configured in that workflow; see the action's README for the options. + +Owner: Peter White. + +| Configuration | Value | +| --- | --- | +| Slack app | **PR Status** (`A0C2AGLAD0T`), created from `.github/slack-pr-status-app-manifest.json` | +| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` | +| GitHub token | The workflow's `GITHUB_TOKEN`, read-only | + +Posts that link PRs from more than one repository are skipped, because each workflow's token can only read its own repository. PRs from forks get no reactions, because their workflows don't receive secrets. + +To disable it in one repository, run `gh workflow disable slack-pr-status.yml --repo GitbookIO/<repository>`. To stop it everywhere, revoke the PR Status app's token. diff --git a/.github/workflows/deploy-production.yaml b/.github/workflows/deploy-production.yaml index dcf465a76..471ada6ba 100644 --- a/.github/workflows/deploy-production.yaml +++ b/.github/workflows/deploy-production.yaml @@ -10,6 +10,8 @@ jobs: environment: name: 2v-production url: ${{ steps.deploy.outputs.deployment-url }} + env: + GITBOOK_VISITOR_COUNTRY_HOSTNAMES: ${{ vars.GITBOOK_VISITOR_COUNTRY_HOSTNAMES }} outputs: deployment-url: ${{ steps.deploy.outputs.deployment-url }} steps: @@ -31,6 +33,8 @@ jobs: environment: name: 2c-production url: ${{ steps.deploy.outputs.deployment-url }} + env: + GITBOOK_VISITOR_COUNTRY_HOSTNAMES: ${{ vars.GITBOOK_VISITOR_COUNTRY_HOSTNAMES }} outputs: deployment-url: ${{ steps.deploy.outputs.deployment-url }} steps: diff --git a/.github/workflows/slack-pr-status.yml b/.github/workflows/slack-pr-status.yml new file mode 100644 index 000000000..3fa8712de --- /dev/null +++ b/.github/workflows/slack-pr-status.yml @@ -0,0 +1,33 @@ +name: Slack PR status + +on: + pull_request_review: + types: [submitted, dismissed] + pull_request: + types: [closed, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + slack-pr-status: + # Secrets aren't available to workflows triggered from forks. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: GitbookIO/slack-pr-status@main + with: + slack-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }} + config: | + channels: [C01NXGWJELS] + approvalsRequired: 1 + multiPr: aggregate + emojis: + commented: false + partiallyApproved: next_track_button + approved: pr-approved + changesRequested: construction + merged: merged + closed: no_entry_sign + partiallyReviewed: hourglass_flowing_sand diff --git a/.gitignore b/.gitignore index 56824a05d..6eb696b03 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ yarn-error.log* # Bun pack artifacts packages/*/*.tgz + +# Playwright MCP artifacts +.playwright-mcp/ diff --git a/README.md b/README.md index 36efc7079..ca6eee02b 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ GitBook Open uses fontawesome. For self-hosting and local development, for licen ## Contributing -GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request. +GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn how to report bugs and suggest changes. ### Types of contributions @@ -93,22 +93,22 @@ We encourage you to contribute to GitBook to help us build the best tool for doc #### Translations -The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI. +The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). Spotted a wrong or missing translation? Open an issue and let us know. #### Bugs -Encounter a bug or find an issue you'd like to fix? Helping us fix issues related to GitBook greatly improves the experience for everyone. Head to the issues section of this repository to learn more about the types of bugs you can already help out with. +Encounter a bug? [Open an issue](https://github.com/GitbookIO/gitbook/issues/new/choose) with as much detail as you can. Clear reports help us fix things faster for everyone. ## Deployment > [!WARNING] > While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need. > -> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._ +> _Looking for a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to suggest it._ > > Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project. > -> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version. +> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to share your ideas with us instead of creating your own version. This project allows you to self-host the rendering portion of your GitBook published content. Self-hosting has pros and cons. diff --git a/bun.lock b/bun.lock index b48f12e68..1fa67ddd8 100644 --- a/bun.lock +++ b/bun.lock @@ -129,8 +129,8 @@ "@gitbook/react-openapi": "workspace:*", "@mermaid-js/mermaid-zenuml": "^0.2.2", "@modelcontextprotocol/sdk": "1.17.5", - "@opennextjs/aws": "4.1.3", - "@opennextjs/cloudflare": "1.20.5", + "@opennextjs/aws": "4.1.7", + "@opennextjs/cloudflare": "1.20.8", "@panzoom/panzoom": "^4.6.1", "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", @@ -155,16 +155,16 @@ "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "memoizee": "^0.4.17", - "mermaid": "^11.14.0", + "mermaid": "11.16.1", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "motion": "^12.23.24", "negotiator": "^1.0.0", - "next": "^16.3.3", + "next": "^16.3.8", "next-themes": "^0.4.6", "nuqs": "^2.2.3", "object-hash": "^3.0.0", - "object-identity": "^0.1.2", + "object-identity": "^0.2.1", "openapi-types": "^12.1.3", "p-map": "^7.0.3", "p-retry": "^8.0.0", @@ -275,6 +275,7 @@ }, "devDependencies": { "@types/react": "catalog:", + "bun-types": "catalog:", "react": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", @@ -341,7 +342,8 @@ }, "patchedDependencies": { "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", - "next@16.3.3": "patches/next@16.3.3.patch", + "next@16.3.8": "patches/next@16.3.8.patch", + "mermaid@11.16.1": "patches/mermaid@11.16.1.patch", }, "overrides": { "@codemirror/state": "6.4.1", @@ -354,7 +356,7 @@ }, "catalog": { "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.200.0", + "@gitbook/api": "0.206.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -570,15 +572,7 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="], - - "@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="], - - "@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="], - - "@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], @@ -726,7 +720,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.200.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-JgPosRwabDqw8FzTpSZdEWCUdwUxO2vIHJH9A8CXYXBmubjjj/Ft01qnzbL2T5q1CSLSQKe9PvW9t5V+R/8iJA=="], + "@gitbook/api": ["@gitbook/api@0.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], @@ -778,57 +772,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], @@ -884,29 +878,29 @@ "@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="], - "@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -942,9 +936,9 @@ "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], - "@opennextjs/aws": ["@opennextjs/aws@4.1.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-nMDA3APULL1hFREeWqPdxRk3djdgF1/Y38gnqlYBVs4iUib9sxFguP1uGSAyYbR7eVxGholjXoWBYx7jDBP/Ag=="], + "@opennextjs/aws": ["@opennextjs/aws@4.1.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ELezSARrTfxp/76cz41eQjki1AV0CuBUKOznU3w/tOk7i87x867RkkYho+gDkog9gpnGzefHdJu463o9VaabNQ=="], - "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="], + "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.7", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-7rrsBqd234GBBnHITobS0gagT4/fSfHJwm+ogYTyUb7ubg3PGC06biJgL9l4DZabFeu/ucqvml80sBr5torBgg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -1750,10 +1744,6 @@ "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="], - "chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - "chokidar": ["chokidar@4.0.0", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -1846,7 +1836,7 @@ "cva": ["cva@1.0.0-beta.2", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "typescript": ">= 4.5.5 < 6" }, "optionalPeers": ["typescript"] }, "sha512-dqcOFe247I5pKxfuzqfq3seLL5iMYsTgo40Uw7+pKZAntPgFtR7Tmy59P5IVIq/XgB0NQWoIvYDt9TwHkuK8Cg=="], - "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], + "cytoscape": ["cytoscape@3.34.4", "", {}, "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -1922,7 +1912,7 @@ "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], - "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1972,7 +1962,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -2030,6 +2020,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], @@ -2400,7 +2392,7 @@ "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], - "katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "keyv": ["keyv@5.3.2", "", { "dependencies": { "@keyv/serialize": "^1.0.3" } }, "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ=="], @@ -2412,8 +2404,6 @@ "known-css-properties": ["known-css-properties@0.35.0", "", {}, "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A=="], - "langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="], - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], @@ -2538,7 +2528,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], + "mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -2648,7 +2638,7 @@ "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], - "next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -2676,7 +2666,7 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="], + "object-identity": ["object-identity@0.2.1", "", {}, "sha512-itX2N4m0IvaLhFZMi9YkW4nFML6FshKCCpnn6iKJZVIkCNM/ZRaG4t1bzD2ncaW4r6cHJa59XY+7oZAH7tg8tA=="], "object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="], @@ -2932,8 +2922,6 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.16.11", "", { "dependencies": { "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/types": "^7.28.4", "ast-kit": "^2.1.2", "birpc": "^2.6.1", "debug": "^4.4.3", "dts-resolver": "^2.1.2", "get-tsconfig": "^4.10.1", "magic-string": "^0.30.19" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.9", "typescript": "^5.0.0", "vue-tsc": "~3.1.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-9IQDaPvPqTx3RjG2eQCK5GYZITo203BxKunGI80AGYicu1ySFTUyugicAaTZWRzFWh9DSnzkgNeMNbDWBbSs0w=="], - "rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="], - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "router": ["router@2.0.0", "", { "dependencies": { "array-flatten": "3.0.0", "is-promise": "4.0.0", "methods": "~1.1.2", "parseurl": "~1.3.3", "path-to-regexp": "^8.0.0", "setprototypeof": "1.2.0", "utils-merge": "1.0.1" } }, "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ=="], @@ -2968,7 +2956,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -3220,18 +3208,6 @@ "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "vue": ["vue@3.5.21", "", { "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", "@vue/runtime-dom": "3.5.21", "@vue/server-renderer": "3.5.21", "@vue/shared": "3.5.21" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA=="], "vue-component-type-helpers": ["vue-component-type-helpers@3.1.1", "", {}, "sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw=="], @@ -3326,6 +3302,8 @@ "@argos-ci/core/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "@argos-ci/core/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="], @@ -3416,11 +3394,11 @@ "@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], "@internationalized/date/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -3692,6 +3670,8 @@ "@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@zenuml/core/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], "@zenuml/core/tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="], @@ -3954,6 +3934,60 @@ "@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@argos-ci/core/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], + + "@argos-ci/core/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], + + "@argos-ci/core/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], + + "@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], + + "@argos-ci/core/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], + + "@argos-ci/core/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], + + "@argos-ci/core/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], + + "@argos-ci/core/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], + + "@argos-ci/core/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], + + "@argos-ci/core/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], + + "@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + + "@argos-ci/core/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + + "@argos-ci/core/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + + "@argos-ci/core/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + + "@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "@argos-ci/core/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="], "@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="], @@ -4000,9 +4034,9 @@ "@headlessui/react/@floating-ui/react/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -4376,6 +4410,10 @@ "yargs/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + + "@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -4460,6 +4498,10 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@headlessui/react/@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], diff --git a/package.json b/package.json index c46f42fe8..01ecec168 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.200.0", + "@gitbook/api": "0.206.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -67,6 +67,7 @@ "patchedDependencies": { "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch", - "next@16.3.3": "patches/next@16.3.3.patch" + "next@16.3.8": "patches/next@16.3.8.patch", + "mermaid@11.16.1": "patches/mermaid@11.16.1.patch" } } diff --git a/packages/embed/README.md b/packages/embed/README.md index 229129e3a..88a3f268e 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -54,6 +54,7 @@ GitBook('configure', { icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book' }, tabs: ['assistant', 'search', 'docs'], + defaultPage: '/getting-started', actions: [ { icon: 'circle-question', @@ -107,6 +108,7 @@ frame.clearChat(); // Configure the embed (see Configuration section for all options) frame.configure({ tabs: ['assistant', 'search', 'docs'], + defaultPage: '/getting-started', actions: [ { icon: 'circle-question', @@ -142,6 +144,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react'; unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions }} tabs={['assistant', 'search', 'docs']} + defaultPage="/getting-started" greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }} assistantName="Support Assistant" suggestions={['What is GitBook?', 'How do I get started?']} @@ -249,6 +252,33 @@ Override which tabs are displayed. Defaults to your site's configuration. tabs: ['assistant', 'search', 'docs'] ``` +### `defaultTab` + +Available in: Standalone script, NPM package, React components + +The tab the embed opens on. Without it, the embed opens on `defaultPage` if set, otherwise on the assistant (or the docs when the assistant isn't available). The tab must be one of the enabled `tabs`. + +- **Type**: `'assistant' | 'search' | 'docs'` + +```javascript +tabs: ['assistant', 'search', 'docs'], +defaultTab: 'search' +``` + +`defaultTab` and `defaultPage` apply when the embed first loads: the standalone widget keeps its place when it is closed and reopened. To move it later, use `navigateToPage` or `navigateToAssistant`. + +### `defaultPage` + +Available in: Standalone script, NPM package, React components + +The page the docs tab opens on, in place of your site's home page. Accepts the same references as `navigateToPage`: the page's path within the site, an absolute path, or its full published URL. Unless `defaultTab` says otherwise, the embed opens on this page. + +- **Type**: `string` + +```javascript +defaultPage: '/getting-started/quickstart' +``` + ### `closeButton` Available in: Standalone script, NPM package, React components diff --git a/packages/embed/src/client/protocol.ts b/packages/embed/src/client/protocol.ts index 5deb02524..6b1c5848a 100644 --- a/packages/embed/src/client/protocol.ts +++ b/packages/embed/src/client/protocol.ts @@ -62,6 +62,12 @@ export type GitBookEmbeddableConfiguration = { /** Tabs to display in the embed (if enabled on the site). */ tabs: ('assistant' | 'docs' | 'search')[]; + /** Tab to open the embed on. */ + defaultTab?: 'assistant' | 'docs' | 'search'; + + /** Page to open the docs tab on, instead of the site's home page. */ + defaultPage?: string; + /** Additional buttons to be displayed in the header of the GitBook embed. */ actions: GitBookEmbeddableActionDefinition[]; diff --git a/packages/embed/src/react/GitBookFrame.tsx b/packages/embed/src/react/GitBookFrame.tsx index d2968cb91..e28c7b1b8 100644 --- a/packages/embed/src/react/GitBookFrame.tsx +++ b/packages/embed/src/react/GitBookFrame.tsx @@ -27,6 +27,8 @@ export function GitBookFrame(props: GitBookFrameProps) { suggestions = [], tools = [], tabs = ['assistant', 'search', 'docs'], + defaultTab, + defaultPage, trademark = true, closeButton = false, assistantName, @@ -50,6 +52,8 @@ export function GitBookFrame(props: GitBookFrameProps) { useEffect(() => { gitbookFrame?.configure({ tabs, + defaultTab, + defaultPage, actions, greeting, suggestions, @@ -65,6 +69,8 @@ export function GitBookFrame(props: GitBookFrameProps) { suggestions, tools, tabs, + defaultTab, + defaultPage, closeButton, trademark, assistantName, diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index c45c7411b..f5846d320 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -10,6 +10,7 @@ import { CustomizationDepth, CustomizationHeaderPreset, CustomizationIconsStyle, + CustomizationPageActionType, CustomizationSidebarListStyle, SiteSocialAccountPlatform, } from '@gitbook/api'; @@ -158,7 +159,7 @@ const searchTestCases: Test[] = [ }, }, { - name: 'Search - Keyboard focus stays within search', + name: 'Search - Keyboard focus exits to the next page control', url: getCustomizationURL({ ai: { mode: CustomizationAIMode.Search, @@ -179,13 +180,16 @@ const searchTestCases: Test[] = [ ) .filter({ visible: true }) .last(); + const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first(); await expect(finalPopupControl).toBeVisible(); + await expect(nextPageControl).toBeVisible(); await finalPopupControl.focus(); await page.keyboard.press('Tab'); - await expect(searchInput).toBeFocused(); + await expect(searchPopup).toBeHidden(); + await expect(nextPageControl).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(finalPopupControl).toBeFocused(); + await expect(searchInput).toBeFocused(); }, }, { @@ -245,6 +249,43 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('search-results')).toBeVisible(); }, }, + { + // RND-12844: the popover's focus manager re-focused the closing popup and + // scrolled the page back to the top right after landing on the section. + name: 'Search - Section result scrolls to the section', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.None, + }, + }), + screenshot: false, + run: async (page) => { + await waitForCookiesDialog(page); + const searchInput = page.getByTestId('search-input'); + await searchInput.focus(); + // Type like a visitor: `fill()` doesn't trigger the remote search. + await searchInput.pressSequentially('tasks'); + + const sectionResult = page.locator( + '[data-testid="search-page-result"][href$="/blocks/lists#tasks"]' + ); + // Section results come from the remote index, which can be slow to answer. + await expect(sectionResult).toBeVisible({ timeout: 30_000 }); + await sectionResult.click(); + await page.waitForURL(/\/blocks\/lists#tasks$/); + + // The regression scrolled back to the top shortly after landing, so let + // that happen before asserting. + await page.waitForTimeout(1000); + + // The heading is parked under the header, within its scroll margin. + const top = await page + .locator('#tasks') + .evaluate((heading) => heading.getBoundingClientRect().top); + expect(top).toBeGreaterThanOrEqual(0); + expect(top).toBeLessThanOrEqual(150); + }, + }, { name: 'Ask - AI Mode: Assistant - Complete flow', url: getCustomizationURL({ @@ -513,6 +554,24 @@ const testCases: TestsCase[] = [ name: 'GitBook Site (Navigation when switching variant)', contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/', tests: [ + { + name: 'Strip fallback after loading a page without adding history', + url: 'api-multi-versions/reference/api-reference/pets', + screenshot: false, + run: async (page) => { + await waitForHydration(page); + const previousURL = page.url(); + const targetURL = new URL(previousURL); + targetURL.searchParams.set('fallback', 'true'); + targetURL.searchParams.set('ref', 'variant'); + targetURL.hash = 'pets'; + await page.goto(targetURL.toString()); + targetURL.searchParams.delete('fallback'); + await expect(page).toHaveURL(targetURL.toString()); + await page.goBack(); + await expect(page).toHaveURL(previousURL); + }, + }, { name: 'Keep navigation path/route when switching variant (Public)', url: 'api-multi-versions/reference/api-reference/pets', @@ -535,8 +594,11 @@ const testCases: TestsCase[] = [ .click(); // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant - await page.waitForURL((url) => - url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets') + await page.waitForURL( + (url) => + url.pathname.includes( + 'api-multi-versions/2.0/reference/api-reference/pets' + ) && !url.searchParams.has('fallback') ); }, }, @@ -1063,6 +1125,30 @@ const testCases: TestsCase[] = [ ).toBeVisible(); }, }, + { + name: 'Markdown page with theme cookie', + url: async () => { + const data = await getSiteAPIToken( + 'https://gitbook.gitbook.io/test-gitbook-open/' + ); + + const searchParams = new URLSearchParams(); + searchParams.set('token', data.apiToken); + searchParams.set('theme', 'dark'); + + return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`; + }, + screenshot: false, + run: async (page) => { + // The app opens previews with `?theme=`, which the middleware persists in a cookie. + const markdownURL = new URL(page.url()); + markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`; + markdownURL.search = ''; + const response = await page.request.get(markdownURL.toString()); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/markdown'); + }, + }, { name: 'Redirect to app for authentication when missing token', url: async () => { @@ -1572,6 +1658,70 @@ const testCases: TestsCase[] = [ }, ], }, + { + name: 'Edit on Git page actions', + contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/', + tests: [ + { + name: 'With Edit on Git as the default action', + url: getCustomizationURL({ + pageActions: { + items: [CustomizationPageActionType.Git], + }, + }), + run: async (page) => { + await waitForHydration(page); + await expect( + page.getByRole('link', { name: 'Edit', exact: true }) + ).toHaveAttribute( + 'href', + 'https://github.com/taranvohra/yjs-docs/tree/main/README.md' + ); + }, + screenshot: false, + }, + { + name: 'With Edit on Git in the dropdown', + url: getCustomizationURL({ + pageActions: { + items: [ + CustomizationPageActionType.Markdown, + CustomizationPageActionType.Git, + ], + }, + }), + run: async (page) => { + await waitForHydration(page); + await page.getByRole('button', { name: 'More' }).click(); + await expect(page.getByRole('menu')).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Edit on GitHub' }) + ).toHaveAttribute( + 'href', + 'https://github.com/taranvohra/yjs-docs/tree/main/README.md' + ); + }, + screenshot: false, + }, + { + name: 'Without Edit on Git', + url: getCustomizationURL({ + pageActions: { + items: [CustomizationPageActionType.Markdown], + }, + }), + run: async (page) => { + await waitForHydration(page); + await page.getByRole('button', { name: 'More' }).click(); + await expect(page.getByRole('menu')).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Edit on GitHub' }) + ).toHaveCount(0); + }, + screenshot: false, + }, + ], + }, { name: 'Page actions', contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', @@ -1624,7 +1774,6 @@ const testCases: TestsCase[] = [ { name: 'Without previewed ads', url: 'text-page?ads_preview=1', - run: waitForCookiesDialog, }, ], }, @@ -1833,9 +1982,6 @@ const testCases: TestsCase[] = [ { name: 'Visitor Auth - Space (oversized token)', contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/', - // Our Cloudflare stack still folds multiple Set-Cookie headers into one, - // breaking chunked cookies (variant of opennextjs-cloudflare#501). - skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare', tests: [ { name: 'Oversized token is chunked into cookies and survives navigation', @@ -1886,7 +2032,9 @@ const testCases: TestsCase[] = [ .replace(/\/$/, ''); secondURL.pathname = `${basePathname}/second`; secondURL.search = ''; - await page.goto(secondURL.toString()); + // Same reason as the harness navigation: third-party subresources on + // this site can hang and never fire `load`. + await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' }); await expect( page.getByRole('heading', { level: 1, name: 'second' }) ).toBeVisible(); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index 2040eb618..e3a5c91ff 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -465,7 +465,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin socialAccounts: [], }; - const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial)); + const encoded = rison.encode_object( + deepMerge(DEFAULT_CUSTOMIZATION, partial, { + arrayMerge: (_target, source) => source, + }) + ); const searchParams = new URLSearchParams(); searchParams.set('customization', encoded); diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index e046e2c6e..6523801da 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -94,6 +94,9 @@ const nextConfig = { GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME, GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION, GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE, + GITBOOK_DISABLE_INSIGHTS: process.env.GITBOOK_DISABLE_INSIGHTS, + GITBOOK_VISITOR_COUNTRY_HOSTNAMES: process.env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES, + VERCEL_TARGET_ENV: process.env.VERCEL_TARGET_ENV, // 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 f1b1081d8..db479a82b 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -20,8 +20,8 @@ "@gitbook/react-openapi": "workspace:*", "@mermaid-js/mermaid-zenuml": "^0.2.2", "@modelcontextprotocol/sdk": "1.17.5", - "@opennextjs/aws": "4.1.3", - "@opennextjs/cloudflare": "1.20.5", + "@opennextjs/aws": "4.1.7", + "@opennextjs/cloudflare": "1.20.8", "@panzoom/panzoom": "^4.6.1", "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", @@ -46,16 +46,16 @@ "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "memoizee": "^0.4.17", - "mermaid": "^11.14.0", + "mermaid": "11.16.1", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "motion": "^12.23.24", "negotiator": "^1.0.0", - "next": "^16.3.3", + "next": "^16.3.8", "next-themes": "^0.4.6", "nuqs": "^2.2.3", "object-hash": "^3.0.0", - "object-identity": "^0.1.2", + "object-identity": "^0.2.1", "openapi-types": "^12.1.3", "p-map": "^7.0.3", "p-retry": "^8.0.0", diff --git a/packages/gitbook/scripts/generate-mermaid-runtime.ts b/packages/gitbook/scripts/generate-mermaid-runtime.ts index f7dd914cb..4ae1788be 100644 --- a/packages/gitbook/scripts/generate-mermaid-runtime.ts +++ b/packages/gitbook/scripts/generate-mermaid-runtime.ts @@ -1,5 +1,6 @@ import { build } from 'bun'; -import { mkdir, rename, rm } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, rm } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -12,14 +13,20 @@ const temporaryDir = join(outputDir, '.build'); const require = createRequire(import.meta.url); const mermaidPackage = require('mermaid/package.json') as { version: string }; const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string }; +const mermaidPatch = await readFile( + join(scriptDir, `../../../patches/mermaid@${mermaidPackage.version}.patch`) +).catch(() => null); +const mermaidPatchSuffix = mermaidPatch + ? `-patch.${createHash('sha256').update(mermaidPatch).digest('hex').slice(0, 8)}` + : ''; -// The runtime URL is served as immutable, it has to change whenever the bundled versions change. +// The runtime URL is served as immutable, it has to change whenever the bundled versions or patch change. if ( MERMAID_RUNTIME_PATH !== - `mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs` + `mermaid/mermaid@${mermaidPackage.version}${mermaidPatchSuffix}-zenuml@${zenumlPackage.version}.mjs` ) { throw new Error( - 'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions' + 'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions and the mermaid patch' ); } diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts new file mode 100644 index 000000000..f1c8e3244 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from 'next/server'; + +import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { serveSiteAiCatalog } from '@/lib/aiCatalog'; + +async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) { + const { context } = await getDynamicSiteContext(await params); + return serveSiteAiCatalog(context, request); +} + +export { handler as GET, handler as OPTIONS }; 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 e8f48e04c..97c234b0c 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,30 +1,12 @@ import { createMcpHandler } from 'mcp-handler'; import type { NextRequest } from 'next/server'; -import { z } from 'zod'; -import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api'; +import { SiteInsightsDisplayContext } from '@gitbook/api'; import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; -import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; -import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask'; -import { getExposableError, throwIfDataError } from '@/lib/data'; -import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage'; -import { joinPathWithBaseURL } from '@/lib/paths'; -import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites'; -import { trackServerInsightsEvents } from '@/lib/tracking'; -import { waitUntil } from '@/lib/waitUntil'; - -/** - * Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the - * insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error. - */ -function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) { - waitUntil( - trackServerInsightsEvents(args).catch((error) => { - console.error('Failed to track MCP insights event:', error); - }) - ); -} +import { isSiteMcpEnabled } from '@/lib/mcp/endpoints'; +import { buildMcpServerInfo } from '@/lib/mcp/serverCard'; +import { createSiteMcpTools, registerSiteMcpTools, trackMcpEvent } from '@/lib/mcp/tools'; export async function handleMcpRequest( rawRequest: NextRequest, @@ -32,11 +14,8 @@ export async function handleMcpRequest( endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth' ) { const { context } = await getDynamicSiteContext(params); - const { dataFetcher, linker, site } = context; - const { pageActions } = context.customization; - const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp); - if (!isMcpEnabled) { + if (!isSiteMcpEnabled(context)) { return new Response('Not Found', { status: 404 }); } @@ -61,418 +40,12 @@ export async function handleMcpRequest( const mcpHandler = createMcpHandler( (server) => { - server.tool( - 'searchDocumentation', - `Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`, - { - query: z.string(), - }, - { - title: 'Search documentation', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - async ({ query }) => { - const results = await throwIfDataError( - dataFetcher.searchSiteContent({ - organizationId: context.organizationId, - siteId: site.id, - query, - scope: { mode: 'all' }, - }) - ); - - trackMcpEvent({ - organizationId: context.organizationId, - siteId: site.id, - events: [ - { - type: 'search_type_query', - query, - location: { - displayContext: SiteInsightsDisplayContext.Mcp, - }, - }, - ], - request, - }); - - return { - content: results.flatMap((result) => { - if (result.type === 'record') { - return { - type: 'text', - text: [ - `Title: ${result.title}`, - `Link: ${result.url}`, - result.description ? `Content: ${result.description}` : '', - ] - .filter(Boolean) - .join('\n'), - }; - } - - const found = findSiteSpaceBy( - context.structure, - (siteSpace) => siteSpace.space.id === result.id - ); - const spaceURL = found?.siteSpace.urls.published; - if (!spaceURL) { - return []; - } - - return result.pages.map((pageResult) => { - const pageURL = linker.toAbsoluteURL( - linker.toLinkForContent( - joinPathWithBaseURL(spaceURL, pageResult.path) - ) - ); - - // The search API returns sections ordered highest-score-first, so - // the first section with a body is the best-scoring preview. - const body = (pageResult.sections ?? []).find( - (section) => section.body - )?.body; - - return { - type: 'text', - text: [ - `Title: ${pageResult.title}`, - `Link: ${pageURL}`, - body ? `Content: ${body}` : '', - ] - .filter(Boolean) - .join('\n'), - }; - }); - }), - }; - } - ); - - const siteUrl = context.siteSpace.urls.published; - server.tool( - 'getPage', - `Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`, - { - url: z - .string() - .describe('The URL of the page to fetch') - .transform((value, ctx) => { - if (URL.canParse(value)) { - return value; - } - if (URL.canParse(`https://${value}`)) { - return `https://${value}`; - } - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`, - }); - return z.NEVER; - }), - }, - { - title: 'Get page content', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - async ({ url }) => { - try { - const match = findSiteSpaceByUrl(context.structure, url); - if (!match) { - return { - content: [{ type: 'text', text: `Page not found: "${url}"` }], - isError: true, - }; - } - - const revision = await throwIfDataError( - dataFetcher.getRevision({ - spaceId: match.siteSpace.space.id, - revisionId: match.siteSpace.space.revision, - }) - ); - - const resolved = resolveSiteSpacePagePath( - match.siteSpace, - revision.pages, - match.pagePath - ); - if (!resolved) { - return { - content: [{ type: 'text', text: `Page not found: "${url}"` }], - isError: true, - }; - } - - const markdown = await getMarkdownForPageInSpace( - context, - match.siteSpace, - resolved.page - ); - - trackMcpEvent({ - organizationId: context.organizationId, - siteId: site.id, - events: [ - { - type: 'page_view', - location: { - displayContext: SiteInsightsDisplayContext.Mcp, - page: resolved.page.id, - space: match.siteSpace.space.id, - revision: match.siteSpace.space.revision, - }, - }, - ], - request, - }); - - return { content: [{ type: 'text', text: markdown }] }; - } catch (error) { - const exposable = getExposableError(error); - return { - content: [{ type: 'text', text: exposable.message }], - isError: true, - }; - } - } - ); - - // Only expose the answer tool when the site has AI enabled, since it relies on - // the same AI search backend that powers the site's "ask a question" experience. - if (isAIEnabled(context.customization.ai.mode)) { - server.tool( - 'askQuestion', - `Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`, - { - question: z - .string() - .describe( - `The natural-language question to answer about ${site.title}.` - ), - goal: z - .string() - .optional() - .describe( - 'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.' - ), - }, - { - title: 'Ask a question', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, - async ({ question, goal }) => { - try { - const trimmedQuestion = question.trim(); - if (!trimmedQuestion) { - return { - content: [ - { - type: 'text', - text: 'Please provide a question to answer.', - }, - ], - isError: true, - }; - } - - const trimmedGoal = goal?.trim() || undefined; - - const answer = await streamSiteAskAnswer(context, trimmedQuestion, { - goal: trimmedGoal, - }); - - trackMcpEvent({ - organizationId: context.organizationId, - siteId: site.id, - events: [ - { - type: 'ask_question', - query: trimmedQuestion, - ...(trimmedGoal ? { goal: trimmedGoal } : {}), - location: { - displayContext: SiteInsightsDisplayContext.Mcp, - }, - }, - ], - request, - }); - - if (!answer || !answer.answer || !('markdown' in answer.answer)) { - return { - content: [ - { - type: 'text', - text: "We couldn't answer this question.", - }, - ], - }; - } - - const answerMarkdown = toPageMarkdown( - await fromPageMarkdown(context, { - markdown: answer.answer.markdown, - pagePath: '', - }) - ); - const sourcesMarkdown = await renderAskSourcesMarkdown( - context, - answer.sources ?? [] - ); - - let text = answerMarkdown.trim(); - if (sourcesMarkdown) { - text += `\n\n# Sources\n\n${sourcesMarkdown}`; - } - - return { content: [{ type: 'text', text }] }; - } catch (error) { - const exposable = getExposableError(error); - return { - content: [{ type: 'text', text: exposable.message }], - isError: true, - }; - } - } - ); - } - - server.tool( - 'sendFeedback', - `Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`, - { - content: z - .string() - .min(1) - .max(2048) - .describe( - 'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.' - ), - pageUrl: z - .string() - .describe( - `The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started), so the finding is linked to the exact page.` - ) - .transform((value, ctx) => { - const candidate = URL.canParse(value) - ? new URL(value) - : URL.canParse(value, siteUrl) - ? new URL(value, siteUrl) - : null; - - if ( - !candidate || - (candidate.protocol !== 'https:' && candidate.protocol !== 'http:') - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`, - }); - return z.NEVER; - } - - return candidate.toString(); - }), - goal: z - .string() - .optional() - .describe( - 'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.' - ), - }, - { - title: 'Send feedback', - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, - async ({ content, pageUrl, goal }) => { - try { - const match = findSiteSpaceByUrl(context.structure, pageUrl); - if (!match) { - return { - content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }], - isError: true, - }; - } - - const revision = await throwIfDataError( - dataFetcher.getRevision({ - spaceId: match.siteSpace.space.id, - revisionId: match.siteSpace.space.revision, - }) - ); - - const resolved = resolveSiteSpacePagePath( - match.siteSpace, - revision.pages, - match.pagePath - ); - if (!resolved) { - return { - content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }], - isError: true, - }; - } - - const trimmedGoal = goal?.trim() || undefined; - - //!! DISABLED FOR NOW: We'll add this back in when we have a way to track agent feedback. - // trackMcpEvent({ - // organizationId: context.organizationId, - // siteId: site.id, - // events: [ - // { - // type: 'agent_feedback', - // feedback: { content, category }, - // location: { - // displayContext: SiteInsightsDisplayContext.Mcp, - // page: resolved.page.id, - // space: match.siteSpace.space.id, - // revision: match.siteSpace.space.revision, - // }, - // }, - // ], - // request, - // }); - - const apiClient = await dataFetcher.api(); - await apiClient.orgs.submitSiteAgentFeedback( - context.organizationId, - site.id, - { - feedback: content, - url: pageUrl, - spaceId: match.siteSpace.space.id, - pageId: resolved.page.id, - ...(trimmedGoal ? { goal: trimmedGoal } : {}), - } - ); - - return { - content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }], - }; - } catch (error) { - const exposable = getExposableError(error); - return { - content: [{ type: 'text', text: exposable.message }], - isError: true, - }; - } - } - ); + registerSiteMcpTools(server, createSiteMcpTools(context, { request })); + }, + { + // The same identity the server card republishes, so the card and the handshake agree. + serverInfo: buildMcpServerInfo(context), }, - {}, { streamableHttpEndpoint: context.linker.toPathInSite(endpoint), maxDuration: 60, diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts new file mode 100644 index 000000000..a213cfd75 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from 'next/server'; + +import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { serveSiteMcpServerCard } from '@/lib/mcp/serverCard'; + +async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) { + const { context } = await getDynamicSiteContext(await params); + return serveSiteMcpServerCard(context, request); +} + +export { handler as GET, handler as OPTIONS }; diff --git a/packages/gitbook/src/app/~gitbook/env/route.ts b/packages/gitbook/src/app/~gitbook/env/route.ts index 15a08167c..4d4382f4e 100644 --- a/packages/gitbook/src/app/~gitbook/env/route.ts +++ b/packages/gitbook/src/app/~gitbook/env/route.ts @@ -6,6 +6,8 @@ import { GITBOOK_API_URL, GITBOOK_APP_URL, GITBOOK_ASSETS_URL, + GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, GITBOOK_DISABLE_TRACKING, GITBOOK_FONTS_URL, GITBOOK_ICONS_URL, @@ -35,6 +37,8 @@ export async function GET(_req: NextRequest) { GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_DISABLE_TRACKING, + GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, // Secret envs GITBOOK_SECRET: !!GITBOOK_SECRET, diff --git a/packages/gitbook/src/components/AI/intercom.test.ts b/packages/gitbook/src/components/AI/intercom.test.ts new file mode 100644 index 000000000..bf5ef0a27 --- /dev/null +++ b/packages/gitbook/src/components/AI/intercom.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { setIntercomLauncherHidden } from './intercom'; + +const originalWindow = globalThis.window; + +afterEach(() => { + if (originalWindow === undefined) { + delete (globalThis as { window?: Window }).window; + } else { + globalThis.window = originalWindow; + } +}); + +function installIntercom(intercom: Window['Intercom']): void { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Intercom: intercom }, + writable: true, + }); +} + +describe('setIntercomLauncherHidden', () => { + it('hides the Intercom launcher', () => { + const calls: unknown[][] = []; + installIntercom((...args) => calls.push(args)); + + setIntercomLauncherHidden(true); + + expect(calls).toEqual([['update', { hide_default_launcher: true }]]); + }); + + it('shows the Intercom launcher', () => { + const calls: unknown[][] = []; + installIntercom((...args) => calls.push(args)); + + setIntercomLauncherHidden(false); + + expect(calls).toEqual([['update', { hide_default_launcher: false }]]); + }); + + it('does nothing when Intercom is unavailable', () => { + expect(() => setIntercomLauncherHidden(true)).not.toThrow(); + }); +}); diff --git a/packages/gitbook/src/components/AI/intercom.ts b/packages/gitbook/src/components/AI/intercom.ts new file mode 100644 index 000000000..3594c283b --- /dev/null +++ b/packages/gitbook/src/components/AI/intercom.ts @@ -0,0 +1,16 @@ +declare global { + interface Window { + Intercom?: (command: string, options?: Record<string, unknown>) => void; + } +} + +/** + * Hide or show Intercom's default launcher when it is available on the host page. + */ +export function setIntercomLauncherHidden(hidden: boolean): void { + if (typeof window === 'undefined' || typeof window.Intercom !== 'function') { + return; + } + + window.Intercom('update', { hide_default_launcher: hidden }); +} diff --git a/packages/gitbook/src/components/AI/server-actions/feedback.ts b/packages/gitbook/src/components/AI/server-actions/feedback.ts new file mode 100644 index 000000000..4db84d3c7 --- /dev/null +++ b/packages/gitbook/src/components/AI/server-actions/feedback.ts @@ -0,0 +1,33 @@ +'use server'; + +import { AgentFeedbackSource } from '@gitbook/api'; + +import type { AgentFeedbackResult } from '@/lib/agentFeedback'; +import { submitAgentFeedback } from '@/lib/agentFeedback/server'; +import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions'; +import { traceErrorOnly } from '@/lib/tracing'; + +/** + * Record a finding about the site's content, submitted by the assistant while helping a reader. + */ +export async function submitAssistantFeedbackToTeam(input: { + feedback: string; + goal?: string; + /** Page the finding is about, as a URL on the site or a `/spaces/…/pages/…` ref. */ + page: string; + asEmbeddable?: boolean; +}): Promise<AgentFeedbackResult> { + return traceErrorOnly('AI.submitAssistantFeedbackToTeam', async () => { + const baseContext = await getServerActionBaseContext({ + isEmbeddable: input.asEmbeddable, + }); + const context = await fetchServerActionSiteContext(baseContext); + + return submitAgentFeedback(context, { + feedback: input.feedback, + goal: input.goal, + page: input.page, + source: AgentFeedbackSource.Assistant, + }); + }); +} diff --git a/packages/gitbook/src/components/AI/server-actions/index.ts b/packages/gitbook/src/components/AI/server-actions/index.ts index 676e8fcc0..115b2044a 100644 --- a/packages/gitbook/src/components/AI/server-actions/index.ts +++ b/packages/gitbook/src/components/AI/server-actions/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './chat'; +export * from './feedback'; export * from './navigate'; diff --git a/packages/gitbook/src/components/AI/server-actions/navigate.ts b/packages/gitbook/src/components/AI/server-actions/navigate.ts index 1b831f683..ac40e3193 100644 --- a/packages/gitbook/src/components/AI/server-actions/navigate.ts +++ b/packages/gitbook/src/components/AI/server-actions/navigate.ts @@ -1,7 +1,7 @@ 'use server'; import { toInSiteHref } from '../navigation'; -import { resolveContentRef, resolveStringContentRef } from '@/lib/references'; +import { resolveContentRef, resolveStringContentRef, toContentRefPath } from '@/lib/references'; import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions'; import { traceErrorOnly } from '@/lib/tracing'; @@ -21,15 +21,7 @@ export async function resolveAINavigationLink( const baseContext = await getServerActionBaseContext(); const context = await fetchServerActionSiteContext(baseContext); - // The content-ref scheme operates on the path portion of the URL. Strip any origin so an - // absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too. - let path = url; - if (URL.canParse(url)) { - const parsed = new URL(url); - path = `${parsed.pathname}${parsed.search}${parsed.hash}`; - } - - const contentRef = resolveStringContentRef(path); + const contentRef = resolveStringContentRef(toContentRefPath(url)); if (contentRef) { const resolved = await resolveContentRef(contentRef, context); if (!resolved) { diff --git a/packages/gitbook/src/components/AI/useAI.tsx b/packages/gitbook/src/components/AI/useAI.tsx index a3146c8ae..3ae188fa9 100644 --- a/packages/gitbook/src/components/AI/useAI.tsx +++ b/packages/gitbook/src/components/AI/useAI.tsx @@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api'; import type { GitBookAssistant } from '@gitbook/browser-types'; import { Icon, type IconName } from '@gitbook/icons'; -import { useAIChatController, useAIChatState } from '.'; +import { type AskAITrigger, useAIChatController, useAIChatState } from '.'; import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat'; import { useIntegrationAssistants } from '../Integrations'; import { useSetSearchState } from '../Search/useSearch'; @@ -26,7 +26,8 @@ export type AIConfig = { }; }; -export type Assistant = Omit<GitBookAssistant, 'icon'> & { +export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & { + open: (query?: string, trigger?: AskAITrigger) => void; /** * Unique identifier for the assistant. Generated automatically using Crypto.randomUUID(). * @example '123e4567-e89b-12d3-a456-426614174000' @@ -102,8 +103,8 @@ export function useAI(): AIContext { className="size-text-lg" /> ), - open: (query?: string) => { - chatController.open(); + open: (query?: string, trigger?: AskAITrigger) => { + chatController.open(trigger); if (query) { chatController.postMessage({ message: query }); } @@ -119,9 +120,13 @@ export function useAI(): AIContext { icon: <AISearchIcon />, open: (query?: string) => { if (query) { - setSearchState((prev) => - prev ? { ...prev, query: null, ask: query, open: true } : null - ); + setSearchState((prev) => ({ + ...prev, + query: null, + ask: query, + scope: prev?.scope ?? 'default', + open: true, + })); } }, pageAction: false, diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 36cfb8224..73c34e0e0 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -11,6 +11,7 @@ import { type AIStreamResponseToolCallPending, type AIToolCallResult, SiteInsightsDisplayContext, + type SiteInsightsEventAskView, } from '@gitbook/api'; import { getInsightsSession, useTrackEvent } from '../Insights'; @@ -18,10 +19,12 @@ import { type UpdateSearchState, useSetSearchState } from '../Search'; import { addRecentSearchQuery } from '../Search/recent-queries'; import type { AnyAIControl } from './controls'; import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl'; +import { setIntercomLauncherHidden } from './intercom'; import { type AIChatReference, serializeReferences } from './references'; import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions'; import { getTools } from './tools'; import { useAIMessageContextRef } from './useAIMessageContext'; +import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools'; import { useNavigateToPageTool } from './useNavigateToPageTool'; import { type ResponseToRate, @@ -58,6 +61,8 @@ export type AIChatStatus = | 'error' | 'confirm'; +export type AskAITrigger = NonNullable<SiteInsightsEventAskView['trigger']>; + export type AIChatState = { /** * If true, the chat is open. @@ -158,7 +163,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void; export type AIChatController = { /** Open the dialog */ - open: () => void; + open: (trigger?: AskAITrigger) => void; /** Close the dialog */ close: () => void; /** Post a message to the session */ @@ -240,7 +245,6 @@ export function AIChatProvider(props: { const setSearchStateInURL = useSetSearchState(); const { siteSpaceId } = useCurrentContent(); const language = useLanguage(); - const displayContext = renderMessageOptions?.asEmbeddable ? SiteInsightsDisplayContext.Embed : SiteInsightsDisplayContext.Site; @@ -256,6 +260,10 @@ export function AIChatProvider(props: { const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null }); const getResponseToRate = React.useCallback(() => responseToRateRef.current, []); + // Findings reported to the site's team are capped at one per conversation, so the assistant + // cannot flood the inbox over a long chat. + const reportedAgentFeedbackRef = React.useRef(false); + // Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or // assistant feedback). Each tool has a stable identity, so it can be referenced directly from // the streaming callback. @@ -265,38 +273,65 @@ export function AIChatProvider(props: { displayContext, getResponseToRate, }); + const leaveAgentFeedbackTool = useLeaveAgentFeedbackTool({ + asEmbeddable: renderMessageOptions?.asEmbeddable, + displayContext, + reportedRef: reportedAgentFeedbackRef, + }); + const leaveUserFeedbackTool = useLeaveUserFeedbackTool({ + asEmbeddable: renderMessageOptions?.asEmbeddable, + displayContext, + }); // The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down - // rating), while the page-feedback tool is gated on the site's "Was this helpful?" setting. + // rating), while the tools carrying the reader's own feedback are gated on the site's + // "Was this helpful?" setting. const builtInTools = React.useMemo(() => { - const tools = [navigateToPageTool, submitAssistantFeedbackTool]; + const tools = [navigateToPageTool, submitAssistantFeedbackTool, leaveAgentFeedbackTool]; if (withPageFeedback) { - tools.push(submitPageFeedbackTool); + tools.push(submitPageFeedbackTool, leaveUserFeedbackTool); } return tools; - }, [navigateToPageTool, submitAssistantFeedbackTool, submitPageFeedbackTool, withPageFeedback]); + }, [ + navigateToPageTool, + submitAssistantFeedbackTool, + leaveAgentFeedbackTool, + leaveUserFeedbackTool, + submitPageFeedbackTool, + withPageFeedback, + ]); // Event listeners storage const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map()); // Open AI chat and sync with search state - const onOpen = React.useCallback(() => { - const { initialQuery } = globalState.getState(); - globalState.setState((state) => ({ ...state, opened: true })); + const onOpen = React.useCallback( + (trigger?: AskAITrigger) => { + setIntercomLauncherHidden(true); + if (!renderMessageOptions?.asEmbeddable) { + trackEvent({ type: 'ask_view', trigger }); + } - // Update search state to show ask mode with first message or current ask value - setSearchState((prev) => ({ - ask: prev?.ask ?? initialQuery ?? '', - query: prev?.query ?? null, - scope: prev?.scope ?? 'default', - open: false, // Close search popover when opening chat - })); + const { initialQuery } = globalState.getState(); + globalState.setState((state) => ({ ...state, opened: true })); - notify(eventsRef.current.get('open'), {}); - }, [setSearchState]); + // Update search state to show ask mode with first message or current ask value + setSearchState((prev) => ({ + ask: prev?.ask ?? initialQuery ?? '', + query: prev?.query ?? null, + scope: prev?.scope ?? 'default', + open: false, // Close search popover when opening chat + })); + + notify(eventsRef.current.get('open'), {}); + }, + [setSearchState, trackEvent, renderMessageOptions?.asEmbeddable] + ); // Close AI chat and clear ask parameter const onClose = React.useCallback(() => { + setIntercomLauncherHidden(false); + globalState.setState((state) => ({ ...state, opened: false })); // Clear ask parameter but keep other search state @@ -711,6 +746,7 @@ export function AIChatProvider(props: { // Clear the conversation and reset ask parameter const onClear = React.useCallback(() => { responseToRateRef.current = { responseId: null, query: null }; + reportedAgentFeedbackRef.current = false; globalState.setState((state) => ({ opened: state.opened, responding: false, diff --git a/packages/gitbook/src/components/AI/useLeaveFeedbackTools.ts b/packages/gitbook/src/components/AI/useLeaveFeedbackTools.ts new file mode 100644 index 000000000..4b2aaacc9 --- /dev/null +++ b/packages/gitbook/src/components/AI/useLeaveFeedbackTools.ts @@ -0,0 +1,205 @@ +'use client'; + +import * as React from 'react'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import type { AIToolDefinition, SiteInsightsDisplayContext } from '@gitbook/api'; +import type { GitBookIntegrationTool } from '@gitbook/browser-types'; + +import { type PagePointer, useCurrentPage } from '../hooks'; +import { type InsightsEventPageContext, useTrackEvent } from '../Insights'; +import { submitAssistantFeedbackToTeam } from './server-actions'; +import { useLanguage } from '@/intl/client'; +import { tString } from '@/intl/translate'; +import { + AGENT_FEEDBACK_GOAL_MAX_LENGTH, + AGENT_FEEDBACK_MAX_LENGTH, + agentFeedbackDescriptions, + formatAgentFeedbackPageRef, +} from '@/lib/agentFeedback'; + +const feedbackInput = z.string().min(1).max(AGENT_FEEDBACK_MAX_LENGTH); +const pageInput = z.string().optional().describe(agentFeedbackDescriptions.pageReference); + +const LeaveAgentFeedbackInputSchema = z.object({ + feedback: feedbackInput.describe(agentFeedbackDescriptions.finding), + goal: z + .string() + .max(AGENT_FEEDBACK_GOAL_MAX_LENGTH) + .optional() + .describe(agentFeedbackDescriptions.goal), + page: pageInput, +}); + +const LeaveUserFeedbackInputSchema = z.object({ + feedback: feedbackInput.describe( + "The user's feedback about the content, copied verbatim. Never rephrase, summarise, correct, translate or add to their words. Quote only what they said about the documentation, and leave out anything personal or confidential." + ), + page: pageInput, +}); + +type LeaveFeedbackToolOptions = { + /** Whether the assistant runs in the embeddable, which resolves its own site context. */ + asEmbeddable?: boolean; + /** Display context recorded with the feedback event (e.g. `site` vs. `embed`). */ + displayContext: SiteInsightsDisplayContext; +}; + +/** The assistant's own finding, so it submits without confirmation, capped at one per chat. */ +export function useLeaveAgentFeedbackTool( + options: LeaveFeedbackToolOptions & { + /** Whether a finding was already reported in this conversation, reset when it is cleared. */ + reportedRef: React.RefObject<boolean>; + } +): GitBookIntegrationTool { + const { asEmbeddable, displayContext, reportedRef } = options; + const language = useLanguage(); + const currentPage = useCurrentPage(); + const trackEvent = useTrackEvent(); + + // The tool object is memoized once, so read the latest values from a ref at call time. + const ref = React.useRef({ + language, + currentPage, + asEmbeddable, + displayContext, + trackEvent, + reportedRef, + }); + React.useEffect(() => { + ref.current = { + language, + currentPage, + asEmbeddable, + displayContext, + trackEvent, + reportedRef, + }; + }); + + return React.useMemo<GitBookIntegrationTool>( + () => ({ + name: 'leaveAgentFeedback', + description: + 'Leave feedback as you, the GitBook Assistant, on ways this question could have been answered more easily through content changes or reorganization — a missing explanation, an outdated or contradictory passage, or an answer you had to piece together from several pages. Use it when answering was hard, or when you could not answer at all, so the team can close the gap. Write it for a documentation maintainer who never saw this conversation, and send at most one per conversation. This carries your own observation: use `leaveUserFeedback` to relay what the user said, and `submitPageFeedback` to record their rating of a page. Do not use it to confirm that a page is accurate.', + inputSchema: zodToJsonSchema( + LeaveAgentFeedbackInputSchema as any + ) as AIToolDefinition['inputSchema'], + execute: async (input) => { + const { reportedRef, ...tracking } = ref.current; + const { feedback, goal, page } = LeaveAgentFeedbackInputSchema.parse(input); + + if (reportedRef.current) { + throw new Error( + 'A finding was already reported in this conversation. Do not report another one.' + ); + } + + await submitFeedback({ feedback, goal, page, ...tracking }); + reportedRef.current = true; + + return { + output: { submitted: true }, + summary: { + icon: 'comment-check', + text: tString(tracking.language, 'ai_chat_tools_shared_feedback_with_team'), + }, + }; + }, + }), + // Rebuild when the locale changes so the summary (read at memo time, not from the ref) + // stays translated. + [language] + ); +} + +/** Speaks for the reader, so it confirms first, showing the exact text about to be sent. */ +export function useLeaveUserFeedbackTool( + options: LeaveFeedbackToolOptions +): GitBookIntegrationTool { + const { asEmbeddable, displayContext } = options; + const language = useLanguage(); + const currentPage = useCurrentPage(); + const trackEvent = useTrackEvent(); + + // The tool object is memoized once, so read the latest values from a ref at call time. + const ref = React.useRef({ language, currentPage, asEmbeddable, displayContext, trackEvent }); + React.useEffect(() => { + ref.current = { language, currentPage, asEmbeddable, displayContext, trackEvent }; + }); + + return React.useMemo<GitBookIntegrationTool>( + () => ({ + name: 'leaveUserFeedback', + description: + "Leave feedback on behalf of the user about the documentation, so the team can act on it. Use it when the user reports that something in the content is wrong, missing, outdated or confusing, or asks you to pass a message to the documentation team. Do not modify their feedback in any way: copy their own words. The user will be asked to confirm before it is sent. This carries the user's words: use `leaveAgentFeedback` for your own findings about the content, and `submitPageFeedback` to record their rating of a page.", + confirmation: (input) => { + const parsed = LeaveUserFeedbackInputSchema.safeParse(input); + const feedback = parsed.success ? parsed.data.feedback.trim() : undefined; + return { + icon: 'paper-plane', + label: tString(language, 'ai_chat_tools_submit_feedback'), + context: feedback ? `"${feedback}"` : undefined, + }; + }, + inputSchema: zodToJsonSchema( + LeaveUserFeedbackInputSchema as any + ) as AIToolDefinition['inputSchema'], + execute: async (input) => { + const tracking = ref.current; + const { feedback, page } = LeaveUserFeedbackInputSchema.parse(input); + + await submitFeedback({ feedback, page, ...tracking }); + + return { + output: { submitted: true }, + summary: { + icon: 'comment-check', + text: tString(tracking.language, 'ai_chat_tools_submitted_feedback'), + }, + }; + }, + }), + // Rebuild when the locale changes so the confirmation label (read at memo time, not from + // the ref) stays translated. + [language] + ); +} + +async function submitFeedback(input: { + feedback: string; + goal?: string; + page?: string; + currentPage: PagePointer | null; + asEmbeddable?: boolean; + displayContext: SiteInsightsDisplayContext; + trackEvent: ReturnType<typeof useTrackEvent>; +}): Promise<void> { + const page = + input.page?.trim() || + (input.currentPage ? formatAgentFeedbackPageRef(input.currentPage) : null); + + if (!page) { + throw new Error('No documentation page is currently open to leave feedback about.'); + } + + const result = await submitAssistantFeedbackToTeam({ + feedback: input.feedback, + goal: input.goal, + page, + asEmbeddable: input.asEmbeddable, + }); + + if (!result.submitted) { + throw new Error(result.error); + } + + // Attribute to the page the reader is on, since the insights location only lets us override + // the page — its space and revision come from the ambient context. + const pageContext: InsightsEventPageContext = { + pageId: input.currentPage?.pageId ?? null, + displayContext: input.displayContext, + }; + input.trackEvent({ type: 'agent_feedback' }, pageContext, { immediate: true }); +} diff --git a/packages/gitbook/src/components/AI/useSubmitPageFeedbackTool.ts b/packages/gitbook/src/components/AI/useSubmitPageFeedbackTool.ts index 285a65f94..d36243021 100644 --- a/packages/gitbook/src/components/AI/useSubmitPageFeedbackTool.ts +++ b/packages/gitbook/src/components/AI/useSubmitPageFeedbackTool.ts @@ -80,7 +80,7 @@ export function useSubmitPageFeedbackTool(options: { () => ({ name: 'submitPageFeedback', description: - "Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words.", + "Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words. This records their rating of the page — use `leaveUserFeedback` to pass a report of their own about the content on to the documentation team.", confirmation: (input) => { const parsed = SubmitPageFeedbackInputSchema.safeParse(input); const comment = parsed.success ? parsed.data.comment?.trim() : undefined; diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index 1c0e8163a..a3fe78819 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -24,7 +24,6 @@ import { EmbeddableFrameTitle, } from '../Embeddable/EmbeddableFrame'; import { useIsMounted, useNow } from '../hooks'; -import { useTrackEvent } from '../Insights'; import { Button } from '../primitives'; import { ScrollContainer } from '../primitives/ScrollContainer'; import { SideSheet } from '../primitives/SideSheet'; @@ -68,16 +67,6 @@ export function AIChat() { } ); - // Track the view of the AI chat - const trackEvent = useTrackEvent(); - React.useEffect(() => { - if (chat.opened) { - trackEvent({ - type: 'ask_view', - }); - } - }, [chat.opened, trackEvent]); - return ( <SideSheet side="right" diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index 22e3cd89f..10e842b0f 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -39,7 +39,7 @@ export function AIChatButton(props: { </div> } aria-label={tString(language, 'ai_chat_ask', assistant.label)} - onClick={() => assistant.open()} + onClick={() => assistant.open(undefined, 'site')} > {showLabel ? t(language, 'ask') : null} </Button> diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx index 7f4034c2c..9872325ff 100644 --- a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx @@ -1,29 +1,58 @@ 'use client'; import fnv1a from '@sindresorhus/fnv1a'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; import { AIChatIcon } from '../AIChatIcon'; import { useAIChatController, useAIConfig } from '@/components/AI'; import { Button } from '@/components/primitives'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { t, tString, useLanguage } from '@/intl/client'; -import { type ClassValue, tcls } from '@/lib/tailwind'; +import { tcls } from '@/lib/tailwind'; /** - * A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on - * devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens - * the AI chat — a more discoverable variant of the text-selection "Ask" affordance. + * A small icon button revealed in the left margin of a paragraph when it is hovered (on devices + * with a fine pointer). Clicking it stages the paragraph's text as a reference and opens the AI + * chat — a more discoverable variant of the text-selection "Ask" affordance. * - * The button is absolutely positioned so it never affects the document flow, and its visibility is - * driven purely by the `group/ask-ai` hover state of its paragraph wrapper. + * A single instance is portaled into the last hovered paragraph wrapper, rather than rendering one + * per paragraph, so pages with many paragraphs don't pay for markup that is hidden until hovered. + * Its visibility is still driven purely by the `group/ask-ai` hover state of that wrapper. */ -export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) { - const { content, className } = props; +export function AskAIParagraphButton() { const config = useAIConfig(); const language = useLanguage(); const chatController = useAIChatController(); + const [target, setTarget] = React.useState<HTMLElement | null>(null); + + const enabled = isAIChatEnabled(config.aiMode); + + React.useEffect(() => { + if (!enabled) { + return; + } + + const onPointerOver = (event: PointerEvent) => { + if (!(event.target instanceof Element)) { + return; + } + const wrapper = event.target.closest<HTMLElement>('[data-ask-ai-paragraph]'); + if (wrapper) { + setTarget(wrapper); + } + }; + + document.addEventListener('pointerover', onPointerOver); + return () => document.removeEventListener('pointerover', onPointerOver); + }, [enabled]); + + if (!enabled || !target) { + return null; + } const onClick = () => { - const text = content.trim(); + const text = getParagraphText(target).trim(); if (!text) { return; } @@ -33,12 +62,12 @@ export function AskAIParagraphButton(props: { content: string; className?: Class id: `text-${fnv1a(text, { size: 32 })}`, content: text, }); - chatController.open(); + chatController.open('hover'); chatController.setDraft(tString(language, 'ai_chat_paragraph_draft')); chatController.focus(); }; - return ( + return createPortal( <div className={tcls( // Sit in the left margin, flush against the paragraph so there is no hover gap. @@ -52,13 +81,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class '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 (ARIA and native - // tables, record cards). - 'in-[[role=table]]:hidden', - 'in-[table]:hidden', - 'in-[[data-card]]:hidden', - className + 'not-pointer-fine:hidden' )} > <Button @@ -75,6 +98,37 @@ export function AskAIParagraphButton(props: { content: string; className?: Class 'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning' )} /> - </div> + </div>, + target ); } + +/** + * Read the paragraph's text, skipping rendered math like `getNodeText` does. + */ +function getParagraphText(wrapper: HTMLElement): string { + const paragraph = wrapper.querySelector(':scope > p'); + if (!paragraph) { + return ''; + } + + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + node instanceof Element && + node.matches('.katex, mjx-container, [aria-hidden="true"]') + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT, + } + ); + + let text = ''; + while (walker.nextNode()) { + if (walker.currentNode.nodeType === Node.TEXT_NODE) { + text += walker.currentNode.textContent ?? ''; + } + } + return text; +} diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx index ba6db9854..ce0965fe9 100644 --- a/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx +++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx @@ -84,7 +84,7 @@ export function AskAITextSelection() { id: `text-${fnv1a(content, { size: 32 })}`, content, }); - chatController.open(); + chatController.open('selection'); chatController.focus(); clear(); }; diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx index 91b163246..340977cad 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx @@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map< function createResourceReader<T>(promise: Promise<T>) { let result: T | null | undefined; - const suspender = (async () => { + const settled = (async () => { try { result = await promise; } catch { result = null; } + return result; })(); return { read() { if (result === undefined) { - throw suspender; + throw settled; } return result; }, + load() { + return settled; + }, }; } @@ -65,6 +69,12 @@ export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null; const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null); +export type AdaptiveVisitorAsyncContextValue = () => Promise<AdaptiveVisitorClaims | null>; + +const AdaptiveVisitorAsyncContext = createContext<AdaptiveVisitorAsyncContextValue>( + async () => null +); + /** * Provide context to adapt site based on visitor claims. */ @@ -83,11 +93,22 @@ export function AdaptiveVisitorContextProvider( return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read(); }, [visitorClaimsURL, contextId]); + const loadAdaptiveVisitorClaims = React.useCallback(async () => { + if (!contextId) { + return null; + } + return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).load(); + }, [visitorClaimsURL, contextId]); + return ( <AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}> - <OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}> - {children} - </OpenAPIPrefillContextProvider> + <AdaptiveVisitorAsyncContext.Provider value={loadAdaptiveVisitorClaims}> + <OpenAPIPrefillContextProvider + getPrefillInputContextData={getAdaptiveVisitorClaims} + > + {children} + </OpenAPIPrefillContextProvider> + </AdaptiveVisitorAsyncContext.Provider> </AdaptiveVisitorContext.Provider> ); } @@ -98,3 +119,10 @@ export function AdaptiveVisitorContextProvider( export function useAdaptiveVisitor(): AdaptiveVisitorContextValue { return useContext(AdaptiveVisitorContext); } + +/** + * Hook that returns an async getter for adaptive visitor claims data, for use outside of render. + */ +export function useAdaptiveVisitorAsync(): AdaptiveVisitorAsyncContextValue { + return useContext(AdaptiveVisitorAsyncContext); +} diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index cee8a1ad4..19cfebc0b 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -323,7 +323,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) { <ToolbarButton title="Open insights" href={getToolbarHref({ - href: `${site.urls.app}/insights`, + href: `${site.urls.app}/analytics`, siteId: site.id, buttonId: 'insights', })} diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx index 902370d52..3d8f604a8 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx @@ -247,11 +247,15 @@ async function renderMermaidDiagram(args: { const { source, id, darkMode, mermaidRuntimeURL } = args; const { mermaid } = await loadMermaid(mermaidRuntimeURL); + // Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA. + const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined; + mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', darkMode, theme: darkMode ? 'dark' : undefined, + themeVariables, }); const renderContainer = createMermaidRenderContainer(); diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/mermaid-runtime-path.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/mermaid-runtime-path.ts index 8a70fa08d..5025eba75 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/mermaid-runtime-path.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/mermaid-runtime-path.ts @@ -1 +1 @@ -export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs'; +export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.16.1-patch.78ecb855-zenuml@0.2.2.mjs'; diff --git a/packages/gitbook/src/components/DocumentView/Embed.tsx b/packages/gitbook/src/components/DocumentView/Embed.tsx index c36fe18c4..6f6d0ef75 100644 --- a/packages/gitbook/src/components/DocumentView/Embed.tsx +++ b/packages/gitbook/src/components/DocumentView/Embed.tsx @@ -37,7 +37,10 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { <> <div dangerouslySetInnerHTML={{ - __html: embed.html, + __html: + context.mode !== 'print' && shouldLazyLoad(block.data.url) + ? lazyLoadIframes(embed.html) + : embed.html, }} data-visual-test="blackout" /> @@ -73,6 +76,23 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { ); } +/** + * Pages with many Loom embeds exhaust the browser's request budget + * (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank. + */ +function lazyLoadIframes(html: string): string { + return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"'); +} + +function shouldLazyLoad(url: string): boolean { + try { + const { hostname } = new URL(url); + return hostname === 'loom.com' || hostname.endsWith('.loom.com'); + } catch { + return false; + } +} + /** * Create an integration block with an unfurl action from the GitBook Embed response. */ diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx index 2c8e4d05c..2c3832930 100644 --- a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx +++ b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx @@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; +import { shouldShowHashLinks } from '../HashLinkButton'; import { Inlines } from '../Inlines'; import { Details } from './Details'; import { ToggleChevron } from '@/components/primitives'; @@ -31,6 +32,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) { ? await getSpaceLanguage(context.contentContext) : defaultLanguage; + const showHashLink = shouldShowHashLinks(context); + return ( <Details id={id} @@ -41,7 +44,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) { className={tcls( 'cursor-pointer', 'px-4', - 'pr-10', + // Reserve room for the anchor icon pinned to the right edge + showHashLink && 'pr-10', 'py-4', 'relative', 'list-none', @@ -71,32 +75,34 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) { context={context} ancestorInlines={[]} /> - <a - href={`#${id}`} - aria-label={tString(language, 'direct_link_to_heading')} - className={tcls( - 'absolute', - 'top-2', - 'bottom-2', - 'right-4', - 'flex', - 'items-center', - 'dark:shadow-none', - 'dark:ring-0' - )} - > - <Icon - icon="hashtag" + {showHashLink ? ( + <a + href={`#${id}`} + aria-label={tString(language, 'direct_link_to_heading')} className={tcls( - 'inline-block', - 'size-3', - 'transition-colors', - 'text-transparent', - 'group-hover/expandable:text-tint-subtle', - 'contrast-more:group-hover/expandable:text-tint-strong' + 'absolute', + 'top-2', + 'bottom-2', + 'right-4', + 'flex', + 'items-center', + 'dark:shadow-none', + 'dark:ring-0' )} - /> - </a> + > + <Icon + icon="hashtag" + className={tcls( + 'inline-block', + 'size-3', + 'transition-colors', + 'text-transparent', + 'group-hover/expandable:text-tint-subtle', + 'contrast-more:group-hover/expandable:text-tint-strong' + )} + /> + </a> + ) : null} </summary> <Blocks nodes={body.nodes} diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx index 2cb43cbdf..093570111 100644 --- a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx +++ b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx @@ -2,9 +2,22 @@ import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import { Link } from '../primitives'; +import type { DocumentContext } from './DocumentView'; import { getBlockTextStyle } from './spacing'; import { type ClassValue, tcls } from '@/lib/tailwind'; +/** + * Whether blocks should render a visible anchor link icon. + * Only the page body carries a page; search answers, AI chat and PDF export keep anchors. + */ +export function shouldShowHashLinks(context: DocumentContext): boolean { + const contentContext = context.contentContext; + if (!contentContext || !('page' in contentContext)) { + return true; + } + return contentContext.page.layout.anchors !== false; +} + /** * A hash icon which adds the block or active block item's ID in the URL hash. * The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`. diff --git a/packages/gitbook/src/components/DocumentView/Heading.tsx b/packages/gitbook/src/components/DocumentView/Heading.tsx index dc1a8271d..82644a978 100644 --- a/packages/gitbook/src/components/DocumentView/Heading.tsx +++ b/packages/gitbook/src/components/DocumentView/Heading.tsx @@ -1,7 +1,7 @@ import type { DocumentBlockHeading } from '@gitbook/api'; import type { BlockProps } from './Block'; -import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton'; +import { HashLinkButton, hashLinkButtonWrapperStyles, shouldShowHashLinks } from './HashLinkButton'; import { HeadingRevealWrapper } from './HeadingRevealWrapper'; import { Inlines } from './Inlines'; import { getBlockTextStyle } from './spacing'; @@ -24,6 +24,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) { ? await getSpaceLanguage(context.contentContext) : defaultLanguage; + const showHashLink = shouldShowHashLinks(context); + return ( <HeadingRevealWrapper as={Tag} @@ -34,7 +36,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) { 'font-heading', 'pdf-heading', 'block', - 'pr-6', + // Reserve room for the absolutely positioned anchor icon on coarse pointers + showHashLink && 'pr-6', 'pointer-fine:flex', 'pointer-fine:items-baseline', 'pointer-fine:pr-0', @@ -63,20 +66,22 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) { <Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} /> </span> - <HashLinkButton - id={id} - block={block} - className={tcls( - 'absolute', - block.type === 'heading-1' - ? '[transform:translateY(0.125em)]' - : '[transform:translateY(0.17em)]', - 'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]', - 'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2' - )} - iconClassName={tcls('size-4')} - label={tString(language, 'direct_link_to_heading')} - /> + {showHashLink ? ( + <HashLinkButton + id={id} + block={block} + className={tcls( + 'absolute', + block.type === 'heading-1' + ? '[transform:translateY(0.125em)]' + : '[transform:translateY(0.17em)]', + 'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]', + 'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2' + )} + iconClassName={tcls('size-4')} + label={tString(language, 'direct_link_to_heading')} + /> + ) : null} </HeadingRevealWrapper> ); } diff --git a/packages/gitbook/src/components/DocumentView/Hint.tsx b/packages/gitbook/src/components/DocumentView/Hint.tsx index 2ca379684..4e043272a 100644 --- a/packages/gitbook/src/components/DocumentView/Hint.tsx +++ b/packages/gitbook/src/components/DocumentView/Hint.tsx @@ -135,10 +135,10 @@ const HINT_STYLES: { header: 'bg-warning-active', body: [ 'text-neutral-strong', - 'links-default:[&_a]:text-warning', - 'links-default:[&_a:hover]:text-warning-strong', - 'links-default:[&_a]:decoration-warning/6', - 'links-accent:[&_a]:decoration-warning', + 'links-default:[&_a:not(.button)]:text-warning', + 'links-default:[&_a:not(.button):hover]:text-warning-strong', + 'links-default:[&_a:not(.button)]:decoration-warning/6', + 'links-accent:[&_a:not(.button)]:decoration-warning', 'decoration-warning/6', '[&_.can-override-bg]:bg-warning-active', '[&_.can-override-text]:text-warning-strong', @@ -152,10 +152,10 @@ const HINT_STYLES: { header: 'bg-danger-active', body: [ 'text-neutral-strong', - 'links-default:[&_a]:text-danger', - 'links-default:[&_a:hover]:text-danger-strong', - 'links-default:[&_a]:decoration-danger/6', - 'links-accent:[&_a]:decoration-danger', + 'links-default:[&_a:not(.button)]:text-danger', + 'links-default:[&_a:not(.button):hover]:text-danger-strong', + 'links-default:[&_a:not(.button)]:decoration-danger/6', + 'links-accent:[&_a:not(.button)]:decoration-danger', 'decoration-danger/6', '[&_.can-override-bg]:bg-danger-active', '[&_.can-override-text]:text-danger-strong', @@ -169,10 +169,10 @@ const HINT_STYLES: { header: 'bg-success-active', body: [ 'text-neutral-strong', - 'links-default:[&_a]:text-success', - 'links-default:[&_a:hover]:text-success-strong', - 'links-default:[&_a]:decoration-success/6', - 'links-accent:[&_a]:decoration-success', + 'links-default:[&_a:not(.button)]:text-success', + 'links-default:[&_a:not(.button):hover]:text-success-strong', + 'links-default:[&_a:not(.button)]:decoration-success/6', + 'links-accent:[&_a:not(.button)]:decoration-success', 'decoration-success/6', '[&_.can-override-bg]:bg-success-active', '[&_.can-override-text]:text-success-strong', diff --git a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx index d794bef67..bf0a6ac04 100644 --- a/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineActionButton.tsx @@ -13,9 +13,14 @@ const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium }; export function InlineActionButton( - props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps } + props: { + action: 'ask' | 'search'; + query?: string; + /** Where a search runs: the whole site, or only the space the visitor is reading. */ + scope?: 'site' | 'space'; + } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps } ) { - const { action, query, buttonProps } = props; + const { action, query, scope, buttonProps } = props; const { assistants } = useAI(); const chatState = useAIChatState(); @@ -29,7 +34,7 @@ export function InlineActionButton( setSearchState((prev) => ({ ...prev, ask: null, - scope: 'default', + scope: scope === 'space' ? 'current' : 'default', query: value ?? query, open: true, })); diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index 40ba19c41..7c1297b82 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -47,6 +47,11 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) { <InlineActionButton action={inline.data.action.action} query={inline.data.action.query ?? ''} + scope={ + inline.data.action.action === 'search' + ? inline.data.action.scope + : undefined + } buttonProps={buttonProps} /> ); diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx index c9b8588ce..88bed7195 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) { const anchorElement = ( <InlineLinkAnchor href={resolved.href} - contentRef={inline.data.ref} - isExternal={inline.data.ref.kind === 'url'} + contentRef={resolved.resolvedRef ?? inline.data.ref} + isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'} > {inlinesElement} </InlineLinkAnchor> @@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: { let breadcrumbs = resolved.ancestors ?? []; const isMailto = resolved.href.startsWith('mailto:'); - const isExternal = inline.data.ref.kind === 'url'; + const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url'; const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; if (isMailto) { diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx index 04a103be5..660851c7c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; import type { WebframePageContext } from './adaptive'; -import { useAdaptiveVisitor } from '@/components/Adaptive'; +import { useAdaptiveVisitorAsync } from '@/components/Adaptive'; import { NavigationStatusContext } from '@/components/hooks'; import { type GitBookLinker, createLinker } from '@/lib/links'; @@ -40,7 +40,12 @@ export function ContentKitWithClientContext<RenderContext>( const router = useRouter(); const { onNavigationClick } = React.useContext(NavigationStatusContext); - const getAdaptiveVisitorClaims = useAdaptiveVisitor(); + const loadAdaptiveVisitorClaims = useAdaptiveVisitorAsync(); + + const getVisitorContext = React.useCallback(async () => { + const visitorClaims = await loadAdaptiveVisitorClaims(); + return { visitor: visitorClaims?.visitor ?? null }; + }, [loadAdaptiveVisitorClaims]); // Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest // of the app, instead of duplicating the join logic here. @@ -55,15 +60,9 @@ export function ContentKitWithClientContext<RenderContext>( }, [onNavigationClick, router] ); - // Read during render (Suspense) only when the integration is allowed visitor claims, so that - // webframes that don't use visitor claims don't suspend on the visitor-claims fetch. - const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null; - const clientContext = React.useMemo<ContentKitClientContextData>( () => ({ - getVisitorContext: canAccessVisitorClaims - ? () => ({ visitor: visitorClaims?.visitor ?? null }) - : undefined, + getVisitorContext: canAccessVisitorClaims ? getVisitorContext : undefined, getPageContext: page ? () => ({ page }) : undefined, navigate: ({ path, anchor, query }) => { // Resolve the requested path relative to the site root so a webframe can navigate @@ -78,7 +77,7 @@ export function ContentKitWithClientContext<RenderContext>( navigateTo(linker.toPathInSite(path) + search + hash); }, }), - [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo] + [canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo] ); return <ContentKit {...contentKitProps} clientContext={clientContext} />; diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 02c7f72c9..9d8ca9266 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -1,8 +1,8 @@ import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api'; -import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit'; +import { ContentKitOutput } from '@gitbook/react-contentkit'; import type { BlockProps } from '../Block'; -import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; +import { getWebframePageContext } from './adaptive'; import { contentKitServerContext } from './contentkit'; import './contentkit.css'; import { @@ -74,16 +74,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio return null; } - const containsWebframe = integrationBlockContainsWebframe(initialOutput); const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true; // The current page (path/id/title) is non-sensitive, so it is always exposed to webframes. const page = getWebframePageContext(context.contentContext); - // Any webframe uses the client-context wrapper: it enables navigation to other pages and - // exposes the current page, plus visitor claims when the integration is allowed them. - const useClientContext = containsWebframe; - const contentKitProps = { renderContext: { integrationName: block.data.integration, @@ -105,20 +100,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio return ( <div className={tcls(style)}> - {useClientContext ? ( - <ContentKitWithClientContext - {...contentKitProps} - canAccessVisitorClaims={canAccessVisitorClaims} - page={page} - linkerData={getWebframeLinkerData(context.contentContext.linker)} - > - <ContentKitOutput output={initialOutput} context={contentKitServerContext} /> - </ContentKitWithClientContext> - ) : ( - <ContentKit {...contentKitProps}> - <ContentKitOutput output={initialOutput} context={contentKitServerContext} /> - </ContentKit> - )} + <ContentKitWithClientContext + {...contentKitProps} + canAccessVisitorClaims={canAccessVisitorClaims} + page={page} + linkerData={getWebframeLinkerData(context.contentContext.linker)} + > + <ContentKitOutput output={initialOutput} context={contentKitServerContext} /> + </ContentKitWithClientContext> </div> ); } diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts index 46486b01e..57552b778 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -1,47 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api'; - -import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; +import { getWebframePageContext } from './adaptive'; import type { GitBookAnyContext } from '@/lib/context'; import { createLinker } from '@/lib/links'; -const webframe: ContentKitWebFrame = { - type: 'webframe', - source: { url: 'https://integrations.gitbook.com/frame' }, -}; - -function elementOutput(element: unknown): ContentKitRenderOutput { - return { - type: 'element', - element, - state: {}, - props: {}, - } as ContentKitRenderOutput; -} - -describe('integrationBlockContainsWebframe', () => { - it('returns false for a completed output', () => { - expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false); - }); - - it('returns false when there is no webframe in the tree', () => { - const output = elementOutput({ - type: 'block', - children: [{ type: 'text', text: 'hello' }], - } as never); - expect(integrationBlockContainsWebframe(output)).toBe(false); - }); - - it('returns true when a webframe is nested in the tree', () => { - const output = elementOutput({ - type: 'block', - children: [{ type: 'vstack', children: [webframe] }], - } as never); - expect(integrationBlockContainsWebframe(output)).toBe(true); - }); -}); - describe('getWebframePageContext', () => { it('returns null when the context has no page', () => { const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext; diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index d24846988..9b07e747a 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -1,14 +1,5 @@ -import type { - ContentKitDescendantElement, - ContentKitRenderOutput, - ContentKitRootElement, - ContentKitStepper, -} from '@gitbook/api'; - import type { GitBookAnyContext } from '@/lib/context'; -type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; - /** * Current page exposed to a webframe through the client-only webframe state. */ @@ -19,18 +10,6 @@ export type WebframePageContext = { title: string; }; -/** - * Whether an integration block's output contains a webframe that can consume client-only context - * (navigation, visitor claims and/or the current page). - */ -export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean { - if (output.type === 'complete') { - return false; - } - - return doesContentKitElementContainWebframe(output.element); -} - /** * Extract the current page to expose to a webframe, or `null` when it is unknown * (e.g. a non-page context, or reusable content resolved from another source). @@ -55,46 +34,3 @@ export function getWebframePageContext( title, }; } - -/** - * Check whether a ContentKit element tree contains a webframe element. - */ -function doesContentKitElementContainWebframe(element: ContentKitElement): boolean { - switch (element.type) { - case 'webframe': - return true; - case 'block': - case 'box': - case 'hstack': - case 'vstack': - case 'step': - case 'modal': - case 'configuration': - case 'stepper': - case 'card': - return doesContentKitElementArrayContainWebframe(element.children); - case 'codeblock': - return ( - doesContentKitElementArrayContainWebframe(element.header) || - doesContentKitElementArrayContainWebframe(element.footer) - ); - default: - return false; - } -} - -function doesContentKitElementArrayContainWebframe(elements: unknown): boolean { - if (!Array.isArray(elements)) { - return doesContentKitElementContainWebframeValue(elements); - } - - return elements.some(doesContentKitElementContainWebframeValue); -} - -function doesContentKitElementContainWebframeValue(value: unknown): boolean { - if (typeof value !== 'object' || value === null || !('type' in value)) { - return false; - } - - return doesContentKitElementContainWebframe(value as ContentKitElement); -} diff --git a/packages/gitbook/src/components/DocumentView/Integration/contentkit.css b/packages/gitbook/src/components/DocumentView/Integration/contentkit.css index 818ea4da4..dd8ff8e50 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/contentkit.css +++ b/packages/gitbook/src/components/DocumentView/Integration/contentkit.css @@ -52,6 +52,14 @@ @apply relative; } +.contentkit-webframe-container { + @apply relative; +} + +.contentkit-webframe-loading { + @apply absolute inset-0 m-auto size-5 animate-spin; +} + /** Card */ .contentkit-card { @apply flex flex-col border rounded border-tint-subtle; @@ -95,7 +103,7 @@ /** Buttons */ .contentkit-button { - @apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none place-self-start ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all grow-0 shrink-0 truncate w-full; + @apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all truncate; } .contentkit-button-style-primary { @@ -130,15 +138,38 @@ .contentkit-modal-header { @apply flex flex-col gap-2 px-4 py-2; } +.contentkit-modal-header-with-close { + @apply flex-row items-start gap-4 px-4 pb-0 pt-4; +} +.contentkit-modal-header-content { + @apply min-w-0 flex-1; +} .contentkit-modal-title { @apply text-2xl font-medium text-tint-strong; } +.contentkit-modal-header-with-close .contentkit-modal-subtitle { + @apply break-words text-sm text-tint; +} +.contentkit-modal-header-with-close .contentkit-modal-title { + @apply break-words; +} .contentkit-modal-body { @apply px-4 py-4; } +.contentkit-modal-footer { + @apply flex justify-end gap-2 px-4 pb-4; +} + +.contentkit-modal-close { + @apply flex size-8 shrink-0 items-center justify-center rounded-full text-tint transition-colors hover:bg-tint-hover hover:text-tint-strong focus-visible:outline-2 focus-visible:outline-primary; +} +.contentkit-modal-close-icon { + @apply size-4; +} + .contentkit-modal-header + .contentkit-modal-body { @apply pt-0; } @@ -208,9 +239,6 @@ .contentkit-modal-confirm .contentkit-modal-footer { @apply flex gap-2 justify-end; } -.contentkit-button-confirm { - @apply w-auto flex-grow-0 flex-shrink-0; -} .contentkit-button-style-danger { @apply bg-danger text-danger hover:bg-danger-hover hover:text-danger-strong contrast-more:bg-tint-subtle; } diff --git a/packages/gitbook/src/components/DocumentView/Paragraph.tsx b/packages/gitbook/src/components/DocumentView/Paragraph.tsx index 6359f0026..31aca82f3 100644 --- a/packages/gitbook/src/components/DocumentView/Paragraph.tsx +++ b/packages/gitbook/src/components/DocumentView/Paragraph.tsx @@ -4,12 +4,11 @@ import { CustomizationAIMode } from '@gitbook/api'; import type { BlockProps } from './Block'; import { Inlines } from './Inlines'; import { getTextAlignment } from './utils'; -import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton'; import { getNodeText } from '@/lib/document'; import { tcls } from '@/lib/tailwind'; export function Paragraph(props: BlockProps<DocumentBlockParagraph>) { - const { block, style, ...contextProps } = props; + const { block, style, ancestorBlocks, ...contextProps } = props; const { context } = contextProps; const paragraph = ( @@ -28,6 +27,8 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) { const contentContext = context.contentContext; const aiAssistantEnabled = context.mode !== 'print' && + // Table cells would clip the margin button, and big tables hold thousands of paragraphs. + !ancestorBlocks.some((ancestor) => ancestor.type === 'table') && contentContext != null && 'customization' in contentContext && contentContext.customization.ai.mode === CustomizationAIMode.Assistant; @@ -38,6 +39,8 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) { // `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages. return ( <div + // The shared `AskAIParagraphButton` portals itself into the hovered wrapper. + data-ask-ai-paragraph="" className={tcls( 'group/ask-ai relative', style, @@ -45,7 +48,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) { )} > {paragraph} - <AskAIParagraphButton content={text} /> </div> ); } diff --git a/packages/gitbook/src/components/DocumentView/Prompt/PromptActions.tsx b/packages/gitbook/src/components/DocumentView/Prompt/PromptActions.tsx new file mode 100644 index 000000000..f37e01b49 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Prompt/PromptActions.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React from 'react'; + +import { Icon } from '@gitbook/icons'; + +import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction'; +import { + Button, + ButtonGroup, + DropdownMenu, + DropdownMenuItem, + ToggleChevron, +} from '@/components/primitives'; +import { tString, useLanguage } from '@/intl/client'; +import { AI_AGENTS, getAIAgent } from '@/lib/ai-agents'; +import { tcls } from '@/lib/tailwind'; + +/** How long the copy button shows its confirmation. */ +const COPIED_MESSAGE_DURATION = 1000; + +/** + * Actions of a prompt block: copying the prompt, or handing it to a coding agent. The visitor's + * last pick becomes the main button, here and in every other prompt block they come across. + */ +export function PromptActions(props: { prompt: string; openInAIProviders: boolean }) { + const { prompt, openInAIProviders } = props; + const language = useLanguage(); + const selectedAction = usePromptAction(); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!copied) { + return; + } + + const timeout = setTimeout(() => { + setCopied(false); + }, COPIED_MESSAGE_DURATION); + + return () => { + clearTimeout(timeout); + }; + }, [copied]); + + const copyPrompt = () => { + navigator.clipboard.writeText(prompt); + setCopied(true); + }; + + // The pick follows the visitor from site to site, so fall back to copying wherever the agent + // actions are turned off — and with nothing to hand over, an agent link would open an agent on + // an empty prompt. + const action: PromptActionId = openInAIProviders && prompt ? selectedAction : 'copy'; + const agent = action === 'copy' ? null : getAIAgent(action); + + const mainButton = agent ? ( + <Button + variant="secondary" + size="xsmall" + icon={agent.icon} + label={tString(language, 'open_in', agent.label)} + href={agent.getURL(prompt)} + // The OS picks the deep link up and the page stays put, where `_blank` would strand the + // visitor on a tab that never loads anything. + target="_self" + /> + ) : ( + <Button + variant="secondary" + size="xsmall" + icon={copied ? 'check' : 'copy'} + label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')} + disabled={!prompt} + onClick={copyPrompt} + /> + ); + + return ( + // Lifted above the header's overlay button, which otherwise swallows the clicks. + <div className="relative z-20 flex shrink-0 items-center gap-2"> + {openInAIProviders ? ( + <ButtonGroup> + {mainButton} + <DropdownMenu + align="end" + className="!min-w-48 max-w-max" + button={ + <Button + label={tString(language, 'more')} + size="xsmall" + variant="secondary" + truncate={false} + disabled={!prompt} + > + <span className="flex items-center gap-1.5"> + <AgentIconStack /> + <ToggleChevron className="size-text-sm" /> + </span> + </Button> + } + > + <DropdownMenuItem + active={action === 'copy'} + leadingIcon="copy" + onClick={() => { + setPromptAction('copy'); + copyPrompt(); + }} + > + {tString(language, 'prompt_copy')} + </DropdownMenuItem> + {AI_AGENTS.map((item) => ( + <DropdownMenuItem + key={item.id} + active={action === item.id} + leadingIcon={item.icon} + href={item.getURL(prompt)} + target="_self" + onClick={() => setPromptAction(item.id)} + > + {tString(language, 'open_in', item.label)} + </DropdownMenuItem> + ))} + </DropdownMenu> + </ButtonGroup> + ) : ( + mainButton + )} + </div> + ); +} + +/** + * The agents on offer, overlapped into a stack, so the menu advertises what it holds without + * spelling out three names next to a button that already has one. + */ +function AgentIconStack() { + return ( + <span className="flex items-center"> + {AI_AGENTS.map((agent, index) => ( + <span + key={agent.id} + className={tcls( + 'flex size-4 shrink-0 items-center justify-center rounded-full border border-tint bg-tint-base', + // Each chip's border cuts into the one behind it, so the marks stay legible + // however tightly they are stacked. + index > 0 && '-ms-1.5' + )} + > + <Icon icon={agent.icon} className="size-2.5" /> + </span> + ))} + </span> + ); +} diff --git a/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx index fa130041a..6bca4f152 100644 --- a/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx +++ b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx @@ -5,14 +5,11 @@ import React from 'react'; import type { DocumentBlockPrompt } from '@gitbook/api'; import { Icon, type IconName } from '@gitbook/icons'; -import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives'; -import { getURLForLLM } from '@/components/utils'; +import { PromptActions } from './PromptActions'; +import { ToggleChevron } from '@/components/primitives'; import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const; -type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number]; - type PromptClientProps = DocumentBlockPrompt['data'] & { contentIcon: IconName | null; prompt: string; @@ -88,106 +85,3 @@ export function PromptClient(props: PromptClientProps) { </div> ); } - -function PromptActions(props: { prompt: string; openInAIProviders: boolean }) { - const { prompt, openInAIProviders } = props; - - return ( - <div className="relative z-20 flex shrink-0 items-center gap-2"> - <CopyPromptButton prompt={prompt} /> - {openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null} - </div> - ); -} - -// time in milliseconds to show the "Copied" message after copying a prompt -const COPIED_MESSAGE_DURATION = 1000; - -function CopyPromptButton(props: { prompt: string }) { - const { prompt } = props; - const language = useLanguage(); - const [copied, setCopied] = React.useState(false); - - React.useEffect(() => { - if (!copied) { - return; - } - - const timeout = setTimeout(() => { - setCopied(false); - }, COPIED_MESSAGE_DURATION); - - return () => { - clearTimeout(timeout); - }; - }, [copied]); - - return ( - <Button - variant="primary" - size="xsmall" - label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')} - disabled={!prompt} - onClick={() => { - navigator.clipboard.writeText(prompt); - setCopied(true); - }} - /> - ); -} - -function OpenPromptDropdown(props: { prompt: string }) { - const { prompt } = props; - const language = useLanguage(); - - return ( - <DropdownMenu - align="end" - className="!min-w-48 max-w-max" - button={ - <Button - label={tString(language, 'open_in_ai')} - trailing={<ToggleChevron className="size-text-sm" />} - size="xsmall" - variant="secondary" - className="max-sm:hidden" - disabled={!prompt} - /> - } - > - {OPEN_IN_AI_PROVIDERS.map((provider) => { - const definition = getPromptOpenActionDefinition(provider); - return ( - <DropdownMenuItem - key={provider} - href={getURLForLLM(provider, prompt)} - target="_blank" - leadingIcon={definition.icon} - > - {tString(language, 'open_in', definition.label)} - </DropdownMenuItem> - ); - })} - </DropdownMenu> - ); -} - -function getPromptOpenActionDefinition(action: AIProviders): { icon: IconName; label: string } { - switch (action) { - case 'cursor': - return { - icon: 'cursor', - label: 'Cursor', - }; - case 'claude': - return { - icon: 'claude', - label: 'Claude', - }; - case 'chatgpt': - return { - icon: 'chatgpt', - label: 'ChatGPT', - }; - } -} diff --git a/packages/gitbook/src/components/DocumentView/Prompt/promptAction.ts b/packages/gitbook/src/components/DocumentView/Prompt/promptAction.ts new file mode 100644 index 000000000..ad737d8c1 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Prompt/promptAction.ts @@ -0,0 +1,95 @@ +'use client'; + +import React from 'react'; + +import { type AIAgentId, isAIAgentId } from '@/lib/ai-agents'; +import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; + +/** + * What a prompt block does when its main button is pressed: copy the prompt, or hand it to one of + * the coding agents. + */ +export type PromptActionId = 'copy' | AIAgentId; + +/** + * Not namespaced per site: a visitor who works in Cursor works in Cursor everywhere, so the pick + * follows them across sites the same way the `select` store's slugs do. + */ +const STORAGE_KEY = '@gitbook/prompt-action'; + +const DEFAULT_ACTION: PromptActionId = 'copy'; + +let state: PromptActionId = DEFAULT_ACTION; +let loaded = false; +const listeners = new Set<() => void>(); + +function isPromptActionId(value: unknown): value is PromptActionId { + return value === 'copy' || isAIAgentId(value); +} + +function read(): PromptActionId { + const stored = getLocalStorageItem<unknown>(STORAGE_KEY, DEFAULT_ACTION); + return isPromptActionId(stored) ? stored : DEFAULT_ACTION; +} + +function emitChange() { + for (const listener of listeners) { + listener(); + } +} + +/** + * Adopt what's in storage, once per page load. Deferred to the first subscription rather than run at + * module scope so the first client render still matches the server's, and the stored pick only + * lands once React is listening. + */ +function load() { + if (loaded || typeof window === 'undefined') { + return; + } + loaded = true; + state = read(); + + // Another tab picking an action updates this one too. + window.addEventListener('storage', (event) => { + if (event.key !== null && event.key !== STORAGE_KEY) { + return; + } + state = read(); + emitChange(); + }); +} + +function subscribe(listener: () => void) { + load(); + listeners.add(listener); + // Storage was only read just now, so nudge the subscriber to re-read the snapshot. + listener(); + return () => { + listeners.delete(listener); + }; +} + +/** + * Remember the action the visitor picked, for every prompt block on the page and the next one. + */ +export function setPromptAction(action: PromptActionId) { + load(); + if (state === action) { + return; + } + state = action; + setLocalStorageItem(STORAGE_KEY, action); + emitChange(); +} + +/** + * The action the visitor last picked, defaulting to copying the prompt. + */ +export function usePromptAction(): PromptActionId { + return React.useSyncExternalStore( + subscribe, + () => state, + () => DEFAULT_ACTION + ); +} diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index 7c16b951a..b6641f046 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -50,6 +50,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>( tag: Tag = 'div', ariaLabelledBy, block, + ancestorBlocks, document, record, column, @@ -151,7 +152,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>( <Blocks tag={Tag} document={document} - ancestorBlocks={[]} + ancestorBlocks={[...ancestorBlocks, block]} nodes={fragment.nodes} style={[ 'blocks', diff --git a/packages/gitbook/src/components/DocumentView/Table/Table.tsx b/packages/gitbook/src/components/DocumentView/Table/Table.tsx index 9ecf7dde3..ed6d391bc 100644 --- a/packages/gitbook/src/components/DocumentView/Table/Table.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/Table.tsx @@ -15,7 +15,12 @@ import { } from './search'; import { shouldShowTableSearch } from './shouldShowSearch'; import { StickyViewGrid } from './StickyViewGrid'; -import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch'; +import { + TableSearchEmpty, + TableSearchInput, + TableSearchProvider, + TableSelectionFilter, +} from './TableSearch'; import { ViewCards } from './ViewCards'; import { ViewGrid, ViewGridHeader } from './ViewGrid'; import { tcls } from '@/lib/tailwind'; @@ -48,9 +53,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) { searchOverride: block.data.search, isPrint: context.mode === 'print', }); - const searchRecords = showSearch - ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) })) - : []; + const selectColumns = getTableSelectColumns(block); + // Also needed when the search bar is hidden: a reader's content selection can narrow a select + // column from a tab or picker elsewhere on the page, and without records there is nothing to + // match against. A table with no select column can never be narrowed that way, so it skips. + const searchRecords = + showSearch || selectColumns.length > 0 + ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) })) + : []; const cellMergeLayout = createTableCellMergeLayout( block, records.map(([recordId]) => recordId) @@ -59,6 +69,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) { return ( <TableSearchProvider records={searchRecords} + selectColumns={selectColumns} recordGroups={ block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined } @@ -66,10 +77,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) { <div className={tcls(style, 'flex flex-col gap-3')}> {showSearch ? ( <TableSearchInput - selectColumns={getTableSelectColumns(block)} + selectColumns={selectColumns} checkboxColumns={getTableCheckboxColumns(block)} /> ) : null} + {/* Sits under the filter control it relates to, and tight against the search bar, + so it reads as part of the filter controls rather than a caption on the table. + Standalone — cards, short grids — there is no control to sit under. */} + <TableSelectionFilter className={showSearch ? '-mt-1.5 justify-end' : undefined} /> <TableView {...props} isOffscreen={isOffscreen} diff --git a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx index b8f6c5901..ba17f5e91 100644 --- a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx @@ -10,8 +10,16 @@ import { type TableSearchRecordData, getVisibleTableRecordIds, } from './searchMatch'; +import { + type SlugFilterEntry, + getAppliedSlugFilter, + reconcileSelectedOptions, + resolveSlugFilter, + slugFilterKey, +} from './slugFilter'; import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives'; import { tString, useLanguage } from '@/intl/client'; +import { selectStore } from '@/lib/select'; import { type ClassValue, tcls } from '@/lib/tailwind'; /** @@ -37,6 +45,13 @@ type TableSearchContextValue = { visibleIds: ReadonlySet<string> | null; /** True when there are records but the active filters match none of them. */ isEmpty: boolean; + /** + * Select columns the reader's content selection narrowed and the reader has left alone. + * A column they have since filtered themselves drops out, since the table no longer shows it. + */ + slugFilter: SlugFilterEntry[]; + /** Drop the selection driving {@link slugFilter}, site-wide. The only thing that clears one. */ + clearSlugFilter: () => void; }; const TableSearchContext = React.createContext<TableSearchContextValue | null>(null); @@ -47,16 +62,32 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n export function TableSearchProvider(props: { records?: TableSearchRecordData[]; recordGroups?: readonly (readonly string[])[]; + /** Select columns of this table, so the reader's content selection can narrow them. */ + selectColumns?: TableSelectColumn[]; children: React.ReactNode; }) { - const { records = [], recordGroups = [] } = props; + const { records = [], recordGroups = [], selectColumns = [] } = props; const [query, setQuery] = React.useState(''); const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({})); const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>( () => new Set() ); + // The selection that drives this lives outside the table — a tab, a select button or a picker + // elsewhere on the page — so this synchronises with it rather than deriving from it. + const slugFilter = useSlugFilter(selectColumns); + + // Columns the selection narrowed last time round. Kept so a new selection — or clearing it — + // undoes the previous one, rather than leaving a filter the reader can no longer account for. + const narrowedColumns = React.useRef<string[]>([]); + const toggleOption = React.useCallback((column: string, value: string) => { + // The reader is taking this column over, so the selection no longer owns it: a later clear + // must leave their choice of options alone. Their change stays local — the filter is this + // table's, for this visit, while the selection is site-wide and persists, so only the + // clear beside the notice touches it. + narrowedColumns.current = narrowedColumns.current.filter((narrowed) => narrowed !== column); + setSelectedOptions((previous) => { const values = new Set(previous[column]); if (values.has(value)) { @@ -87,6 +118,33 @@ export function TableSearchProvider(props: { }); }, []); + React.useEffect(() => { + const previouslyNarrowed = narrowedColumns.current; + narrowedColumns.current = slugFilter.map((entry) => entry.column); + + setSelectedOptions((previous) => + reconcileSelectedOptions(previous, previouslyNarrowed, slugFilter) + ); + }, [slugFilter]); + + // What the notice may speak for: the columns the selection narrowed and the reader has left + // alone. A column they have since filtered themselves still has an active slug, but the table + // is no longer showing it, so the notice must not claim it. + const appliedSlugFilter = React.useMemo( + () => getAppliedSlugFilter(slugFilter, selectedOptions), + [slugFilter, selectedOptions] + ); + + // Clearing goes through the store rather than local state: the selection is what persists, so + // only dropping it there stops the filter coming back on the next load. It is site-wide, so a + // tab elsewhere on the page reverts to its default too. This is the only thing that clears a + // selection — changing the filter never does. + const clearSlugFilter = React.useCallback(() => { + for (const entry of appliedSlugFilter) { + selectStore.deactivate(entry.slug); + } + }, [appliedSlugFilter]); + // Match every record once, here, rather than in each row — rows just look themselves up by id. const visibleIds = React.useMemo( () => @@ -112,8 +170,20 @@ export function TableSearchProvider(props: { toggleCheckbox, visibleIds, isEmpty, + slugFilter: appliedSlugFilter, + clearSlugFilter, }), - [query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty] + [ + query, + selectedOptions, + toggleOption, + checkedColumns, + toggleCheckbox, + visibleIds, + isEmpty, + appliedSlugFilter, + clearSlugFilter, + ] ); return ( @@ -121,6 +191,40 @@ export function TableSearchProvider(props: { ); } +/** + * The reader's selection, reduced to the columns of *this* table. + * + * Subscribes once and returns a string rather than an object: `useSyncExternalStore` compares + * snapshots by identity, so a fresh object each call would loop. It also means a selection that + * changes nothing for this table re-renders nothing — the reason `useSelect` stopped exposing the + * recency list in the first place. + */ +function useSlugFilter(selectColumns: TableSelectColumn[]): SlugFilterEntry[] { + const columnsKey = selectColumns + .map( + (column) => + `${column.id}:${column.options.map((option) => `${option.value}=${option.label}`).join('|')}` + ) + .join(';'); + + const getKey = React.useCallback( + () => slugFilterKey(resolveSlugFilter(selectColumns, selectStore.getState().slugs)), + // `selectColumns` is a fresh array each render; its contents are what matter. + // oxlint-disable-next-line react-hooks/exhaustive-deps + [columnsKey] + ); + + const filterKey = React.useSyncExternalStore(selectStore.subscribe, getKey, getKey); + + // The key is only an identity: rebuild the filter itself when it moves, rather than parsing the + // key back apart, since an option value can be any string an import gave it. + return React.useMemo( + () => resolveSlugFilter(selectColumns, selectStore.getState().slugs), + // oxlint-disable-next-line react-hooks/exhaustive-deps + [filterKey] + ); +} + function useTableSearch(): TableSearchContextValue { const context = React.useContext(TableSearchContext); if (!context) { @@ -193,6 +297,49 @@ export function TableSearchEmpty(props: { className?: ClassValue }) { ); } +/** + * Names the selection narrowing this table, and lets the reader drop it. + * + * Deliberately worded around the *selection* rather than the filter: the column dropdown beside it + * shows the same column as active, but clearing there only resets local state and the filter returns + * on the next load. This is the control that actually undoes it. + * + * Rendered independently of the search bar. `shouldShowTableSearch` leaves the filter controls off + * cards, off grids below the row threshold, and off any table whose author turned search off — and + * in every one of those a narrowed table would otherwise just read as missing rows. + */ +export function TableSelectionFilter(props: { className?: ClassValue }) { + const language = useLanguage(); + const { slugFilter, clearSlugFilter } = useTableSearch(); + + if (slugFilter.length === 0) { + return null; + } + + return ( + <div + className={tcls('flex flex-wrap items-center gap-2 text-sm text-tint', props.className)} + > + <Icon icon="filter" className="size-3 shrink-0" /> + <span> + {tString( + language, + 'table_filtered_by_selection', + slugFilter.map((entry) => entry.label).join(', ') + )} + </span> + <Button + variant="blank" + size="xsmall" + icon="xmark" + iconOnly + label={tString(language, 'table_clear_selection')} + onClick={clearSlugFilter} + /> + </div> + ); +} + /** * A blank multi-select dropdown button for a single select column. Becomes `active` while any * of its options are selected. @@ -226,11 +373,10 @@ function SelectFilterDropdown(props: { column: TableSelectColumn }) { key={option.value} active={selected} leadingIcon={selected ? 'check' : undefined} - onSelect={(event) => { - // Keep the menu open so several options can be toggled at once. - event.preventDefault(); - toggleOption(column.id, option.value); - }} + // `closeOnClick={false}` keeps the menu open so several options can be + // toggled at once. + closeOnClick={false} + onClick={() => toggleOption(column.id, option.value)} > {option.label || option.value} </DropdownMenuItem> diff --git a/packages/gitbook/src/components/DocumentView/Table/slugFilter.test.ts b/packages/gitbook/src/components/DocumentView/Table/slugFilter.test.ts new file mode 100644 index 000000000..57a4ac688 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/slugFilter.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test'; + +import type { TableSelectColumn } from './search'; +import { + getAppliedSlugFilter, + getOptionSlug, + reconcileSelectedOptions, + resolveSlugFilter, + slugFilterKey, +} from './slugFilter'; + +/** + * A select column as the editor writes one: each option carries an opaque generated `value` and the + * author's wording as its `label`. Tests must not conflate the two — matching on `value` looks + * right against fixtures that reuse the label and matches nothing against real content. + */ +function column(id: string, labels: string[]): TableSelectColumn { + return { + id, + label: id, + options: labels.map((label, index) => ({ + value: `${id}-key-${index}`, + label, + color: 'blue', + })), + }; +} + +/** The opaque value of the option an author labelled `label`. */ +function valueOf(column: TableSelectColumn, label: string): string { + const option = column.options.find((option) => option.label === label); + if (!option) { + throw new Error(`no option labelled ${label}`); + } + return option.value; +} + +describe('resolveSlugFilter', () => { + const platform = column('platform', ['macOS', 'Windows', 'Linux']); + const status = column('status', ['Done', 'To do']); + + it('matches the option label, and resolves to its opaque value', () => { + expect(resolveSlugFilter([platform], ['macos'])).toEqual([ + { + column: 'platform', + value: valueOf(platform, 'macOS'), + label: 'macOS', + slug: 'macos', + }, + ]); + }); + + it('carries the label and slug needed to show and clear the filter', () => { + // The reader is told what narrowed the table, and the slug is what clearing deactivates. + const [entry] = resolveSlugFilter([platform], ['windows']); + expect(entry?.label).toBe('Windows'); + expect(entry?.slug).toBe('windows'); + }); + + it('never matches the opaque value itself', () => { + expect(resolveSlugFilter([platform], [valueOf(platform, 'macOS')])).toEqual([]); + }); + + it('slugifies the label the way every other select surface does', () => { + const languages = column('language', ['Node.js', 'C++', 'Windows 10']); + expect(resolveSlugFilter([languages], ['node.js'])[0]?.label).toBe('Node.js'); + expect(resolveSlugFilter([languages], ['c++'])[0]?.label).toBe('C++'); + expect(resolveSlugFilter([languages], ['windows-10'])[0]?.label).toBe('Windows 10'); + }); + + it('falls back to the value when an option has no label', () => { + const legacy: TableSelectColumn = { + id: 'legacy', + label: 'legacy', + options: [{ value: 'macos', label: '', color: 'blue' }], + }; + expect(resolveSlugFilter([legacy], ['macos'])).toEqual([ + { column: 'legacy', value: 'macos', label: 'macos', slug: 'macos' }, + ]); + }); + + it('leaves a column the selection says nothing about', () => { + const entries = resolveSlugFilter([platform, status], ['macos']); + expect(entries.map((entry) => entry.column)).toEqual(['platform']); + }); + + it('narrows several columns at once when the selection covers both', () => { + const entries = resolveSlugFilter([platform, status], ['macos', 'done']); + expect(entries.map((entry) => entry.value)).toEqual([ + valueOf(platform, 'macOS'), + valueOf(status, 'Done'), + ]); + }); + + it('takes the most recently activated when a column offers several active options', () => { + // Most-recent-first, so `windows` wins over `macos` — the rule tabs resolve with. + expect(resolveSlugFilter([platform], ['windows', 'macos'])[0]?.value).toBe( + valueOf(platform, 'Windows') + ); + expect(resolveSlugFilter([platform], ['macos', 'windows'])[0]?.value).toBe( + valueOf(platform, 'macOS') + ); + }); + + it('filters nothing when the selection matches no column', () => { + expect(resolveSlugFilter([platform, status], ['python'])).toEqual([]); + expect(resolveSlugFilter([platform], [])).toEqual([]); + expect(resolveSlugFilter([], ['macos'])).toEqual([]); + }); +}); + +describe('getOptionSlug', () => { + // Shared by the matcher and by the write-back that moves the selection when a reader changes a + // governed column, so the two can never disagree about what an option answers to. + it('slugifies the label, and falls back to the value', () => { + expect(getOptionSlug({ value: 'key-0', label: 'macOS' })).toBe('macos'); + expect(getOptionSlug({ value: 'key-1', label: 'Windows 10' })).toBe('windows-10'); + expect(getOptionSlug({ value: 'macos', label: '' })).toBe('macos'); + }); +}); + +describe('reconcileSelectedOptions', () => { + const entry = (column: string, value: string) => ({ + column, + value, + label: value, + slug: value, + }); + + it('drops the column when the selection is cleared', () => { + const previous = { platform: new Set(['macos-value']) }; + expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({}); + }); + + it('leaves filters the reader set themselves', () => { + const previous = { + platform: new Set(['macos-value']), + status: new Set(['done-value']), + }; + expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({ + status: new Set(['done-value']), + }); + }); + + it('replaces the previous selection rather than adding to it', () => { + const previous = { platform: new Set(['macos-value']) }; + expect( + reconcileSelectedOptions(previous, ['platform'], [entry('platform', 'windows-value')]) + ).toEqual({ platform: new Set(['windows-value']) }); + }); + + it('gives the same answer however many times it is applied', () => { + // React may invoke a state updater more than once with the same input. An earlier version + // tracked the narrowed columns inside the updater, so the second pass saw them already + // cleared, took the early return and handed back the *unchanged* state — silently undoing + // a clear while leaving an apply working, which is exactly how it presented. + const previous = { platform: new Set(['macos-value']) }; + const once = reconcileSelectedOptions(previous, ['platform'], []); + const twice = reconcileSelectedOptions(previous, ['platform'], []); + expect(twice).toEqual(once); + }); + + it('is a no-op when there is nothing to narrow and nothing to undo', () => { + const previous = { status: new Set(['done-value']) }; + expect(reconcileSelectedOptions(previous, [], [])).toBe(previous); + }); +}); + +describe('getAppliedSlugFilter', () => { + const macos = { column: 'platform', value: 'macos-value', label: 'macOS', slug: 'macos' }; + + it('speaks for a column the reader has left alone', () => { + const selected = { platform: new Set(['macos-value']) }; + expect(getAppliedSlugFilter([macos], selected)).toEqual([macos]); + }); + + it('drops a column the reader has filtered to something else', () => { + // Changing the filter deliberately leaves the selection active, so the slug is still there; + // the notice just must not claim a match the table is no longer showing. + const selected = { platform: new Set(['windows-value']) }; + expect(getAppliedSlugFilter([macos], selected)).toEqual([]); + }); + + it('drops a column the reader has widened to several options', () => { + const selected = { platform: new Set(['macos-value', 'windows-value']) }; + expect(getAppliedSlugFilter([macos], selected)).toEqual([]); + }); + + it('drops a column the reader has cleared', () => { + expect(getAppliedSlugFilter([macos], {})).toEqual([]); + }); +}); + +describe('slugFilterKey', () => { + const entry = (column: string, value: string) => ({ + column, + value, + label: value, + slug: value, + }); + + it('is stable whatever order the columns resolve in', () => { + expect(slugFilterKey([entry('platform', 'macos'), entry('status', 'done')])).toBe( + slugFilterKey([entry('status', 'done'), entry('platform', 'macos')]) + ); + }); + + it('changes when the selection moves', () => { + expect(slugFilterKey([entry('platform', 'macos')])).not.toBe( + slugFilterKey([entry('platform', 'linux')]) + ); + }); + + it('is empty when nothing is filtered', () => { + expect(slugFilterKey([])).toBe(''); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/slugFilter.ts b/packages/gitbook/src/components/DocumentView/Table/slugFilter.ts new file mode 100644 index 000000000..ffda4dd7e --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/slugFilter.ts @@ -0,0 +1,127 @@ +import type { TableSelectColumn } from './search'; +import type { SelectedOptions } from './searchMatch'; +import { slugifySelectValue } from '@/lib/select'; + +/** One select column narrowed by the reader's content selection. */ +export interface SlugFilterEntry { + /** Id of the select column being narrowed. */ + column: string; + /** The option's opaque value, which the record matcher compares against. */ + value: string; + /** The option's author-typed label, shown to the reader. */ + label: string; + /** The active slug that narrowed the column, so the reader can clear it again. */ + slug: string; +} + +/** + * The `select` slug an option answers to. + * + * A table option's `value` is an opaque generated key, so it is the author-typed `label` that names + * it — the same wording a tab title or select button would carry, put through the same slugifier so + * a "macOS" column option and a "macOS" tab resolve to the one slug. Options with no label fall back + * to the raw value, mirroring how a cell renders one. + */ +export function getOptionSlug(option: { value: string; label: string }): string { + return slugifySelectValue(option.label || option.value); +} + +/** + * Work out which option of each select column the reader's current selection points at. + * + * A table's select column declares its own option set, so a slug only filters a column that + * actually offers it: a reader who picked `macos` narrows a Platform column to macOS and leaves a + * Status column alone. Where several of a column's options are active at once, the most recently + * activated wins — the same rule tabs resolve with, so a table and the tabs beside it agree. + */ +export function resolveSlugFilter( + columns: TableSelectColumn[], + slugs: string[] +): SlugFilterEntry[] { + const entries: SlugFilterEntry[] = []; + + for (const column of columns) { + let best: SlugFilterEntry | undefined; + let bestRank = Number.POSITIVE_INFINITY; + + for (const option of column.options) { + const slug = getOptionSlug(option); + if (!slug) { + continue; + } + + const rank = slugs.indexOf(slug); + if (rank >= 0 && rank < bestRank) { + bestRank = rank; + best = { + column: column.id, + value: option.value, + label: option.label || option.value, + slug, + }; + } + } + + if (best) { + entries.push(best); + } + } + + return entries; +} + +/** Stable identity for a filter, so it is only rebuilt when the selection actually moves. */ +export function slugFilterKey(entries: SlugFilterEntry[]): string { + return entries + .map((entry) => `${entry.column}=${entry.value}`) + .sort() + .join(','); +} + +/** + * Fold the columns the selection narrows into the reader's own filters. + * + * Kept pure, and given the previously narrowed columns rather than reading them from a ref, so it + * can be applied more than once without changing the answer — React may invoke a state updater + * twice, and an earlier version tracked those columns inside the updater itself, which silently + * undid a clear on the second pass. + * + * Only columns the selection narrowed last time are dropped; anything the reader filtered by hand + * is left exactly as it was. + */ +export function reconcileSelectedOptions( + previous: SelectedOptions, + previouslyNarrowed: readonly string[], + slugFilter: readonly SlugFilterEntry[] +): SelectedOptions { + if (previouslyNarrowed.length === 0 && slugFilter.length === 0) { + return previous; + } + + const next = { ...previous }; + for (const column of previouslyNarrowed) { + delete next[column]; + } + for (const entry of slugFilter) { + next[entry.column] = new Set([entry.value]); + } + return next; +} + +/** + * Narrow the filter to the columns the table is *still* showing. + * + * A reader can change any of these columns by hand, and that deliberately leaves the selection + * alone — the filter is this table's, for this visit, while the selection is site-wide and + * persists. So the notice has to stop speaking for a column that no longer matches, rather than + * describing a view the reader has since changed. + */ +export function getAppliedSlugFilter( + slugFilter: readonly SlugFilterEntry[], + selectedOptions: SelectedOptions +): SlugFilterEntry[] { + return slugFilter.filter((entry) => { + const values = selectedOptions[entry.column]; + return values?.size === 1 && values.has(entry.value); + }); +} diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index bb15d3043..76daf3a30 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -5,7 +5,12 @@ import { validateIconName } from '@gitbook/icons/icons'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; import { DynamicTabs } from './DynamicTabs'; -import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select'; +import { + generateSelectCSS, + resolveSelectSlug, + selectSetClassName, + slugifySelectValue, +} from '@/lib/select'; import { tcls } from '@/lib/tailwind'; export function Tabs(props: BlockProps<DocumentBlockTabs>) { @@ -26,6 +31,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) { return { id: tab.meta?.id ?? tab.key, title: tab.data.title ?? '', + slug: tab.data.slug, icon, body: ( <Blocks @@ -94,19 +100,20 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) { } /** - * Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id - * so they stay selectable. + * Resolve a `select` slug for each tab: its explicit slug when set, else one derived from its title. + * Untitled tabs fall back to their (stable) id so they stay selectable. * * Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and * on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates * with a positional suffix: that would desync the duplicate and make a stored selection retarget - * whenever tabs are renamed or reordered. + * whenever tabs are renamed or reordered. An explicit slug is the way out for a tab that needs to + * keep its identity across a rename. */ -function withSelectSlugs<T extends { id: string; title: string }>( +function withSelectSlugs<T extends { id: string; title: string; slug?: string }>( items: T[] ): (T & { slug: string })[] { return items.map((item) => ({ ...item, - slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id, + slug: resolveSelectSlug(item) || slugifySelectValue(item.id) || item.id, })); } diff --git a/packages/gitbook/src/components/DocumentView/Text.tsx b/packages/gitbook/src/components/DocumentView/Text.tsx index c5142993a..f70a69782 100644 --- a/packages/gitbook/src/components/DocumentView/Text.tsx +++ b/packages/gitbook/src/components/DocumentView/Text.tsx @@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) { return ( <span className={tcls([ - textColorToStyle[mark.data.text], - backgroundColorToStyle[mark.data.background], + textColorToStyle[mark.data.text ?? 'default'], + backgroundColorToStyle[mark.data.background ?? 'default'], ])} > {children} diff --git a/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx b/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx index 9218b9a4b..cf26933a3 100644 --- a/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx +++ b/packages/gitbook/src/components/DocumentView/UpdatesFilter.tsx @@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons'; import { TagIcon } from '../Tag'; import { Button } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; - -const UPDATES_FILTER_SEARCH_PARAM = 'tag'; +import { + UPDATES_FILTER_KEY_ATTR, + UPDATES_FILTER_SEARCH_PARAM, + UPDATES_TAG_ATTR, + UPDATES_TAG_CHIP_SELECTED_ATTR, + UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR, + UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR, + UPDATES_TAG_CLEAR_ATTR, + UPDATES_TAG_FILTER_ATTR, + normalizeUpdatesFilterTags, + updatesFilterStyleKey, +} from '@/lib/updates'; type UpdatesFilterContextValue = { selectedTags: string[]; @@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]); const sanitizeTags = React.useCallback( - (tags: string[]) => { - const next: string[] = []; - const seen = new Set<string>(); - - for (const tag of tags) { - if (!availableTags.has(tag) || seen.has(tag)) { - continue; - } - - next.push(tag); - seen.add(tag); - } - - return next; - }, + (tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags), [availableTags] ); @@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea () => sanitizeTags(rawSelectedTags), [sanitizeTags, rawSelectedTags] ); - const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags); + const [selectedTags, setSelectedTags] = React.useState<string[]>([]); const selectedTagsRef = React.useRef(selectedTags); + const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]); + + // Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale + // stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR). + React.useLayoutEffect(() => { + document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey); + + return () => { + document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR); + document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR); + }; + }, [styleKey]); const replaceTags = React.useCallback( (nextTags: string[]) => { @@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea } }, [rawSelectedTags, replaceTags, urlSelectedTags]); - React.useEffect(() => { + // Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI. + React.useLayoutEffect(() => { + applyTagFilterAttribute(urlSelectedTags); + if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) { return; } @@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea selectedTagsRef.current = nextTags; setSelectedTags(nextTags); + applyTagFilterAttribute(nextTags); replaceTags(nextTags); }, [replaceTags, sanitizeTags] @@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: { clearLabel: string; }) { const { tags, tagsLabel, clearLabel } = props; - const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter(); - const isFiltering = selectedTags.length > 0; + const { toggleTag, clearTags } = useUpdatesFilter(); if (tags.length === 0) { return null; @@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: { <Icon icon="tags" className="size-3" /> {tagsLabel} </div> + {/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */} <Button variant="blank" size="xsmall" icon="xmark" label={clearLabel} - onClick={isFiltering ? clearTags : undefined} - aria-hidden={!isFiltering} - tabIndex={isFiltering ? undefined : -1} - className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')} + onClick={clearTags} + {...{ [UPDATES_TAG_CLEAR_ATTR]: '' }} + className="pointer-events-none invisible text-xs" /> </div> <div className="flex flex-wrap gap-1.5 px-3"> - {tags.map((tag) => { - const selected = selectedTagSet.has(tag.slug); - - return ( - <button - key={tag.slug} - type="button" - aria-pressed={selected} - onClick={() => toggleTag(tag.slug)} - className={tcls( - 'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors', - 'circular-corners:rounded-2xl straight-corners:rounded-xs', - 'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary', - selected - ? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover' - : 'bg-tint-5 text-tint-strong hover:bg-tint-hover', - isFiltering && !selected && 'opacity-8 hover:opacity-11' - )} - > - <TagIcon tag={tag} /> - <span className="truncate">{tag.label}</span> - </button> - ); - })} + {tags.map((tag) => ( + <TagChip key={tag.slug} tag={tag} onToggle={toggleTag} /> + ))} </div> </div> ); } +const CHIP_CLASS = + 'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary'; + +const CHIP_VARIANT_CLASS = + 'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs'; + +function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) { + const { tag, onToggle } = props; + const { selectedTagSet } = useUpdatesFilter(); + const onClick = () => onToggle(tag.slug); + + return ( + <button + type="button" + aria-pressed={selectedTagSet.has(tag.slug)} + onClick={onClick} + className={CHIP_CLASS} + > + <span + {...{ [UPDATES_TAG_CHIP_SELECTED_ATTR]: tag.slug }} + className={tcls( + CHIP_VARIANT_CLASS, + 'hidden bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover' + )} + > + <TagIcon tag={tag} /> + <span className="truncate">{tag.label}</span> + </span> + <span + {...{ [UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR]: tag.slug }} + className={tcls( + CHIP_VARIANT_CLASS, + 'hidden bg-tint-5 text-tint-strong opacity-8 hover:bg-tint-hover hover:opacity-11' + )} + > + <TagIcon tag={tag} /> + <span className="truncate">{tag.label}</span> + </span> + <span + {...{ [UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR]: tag.slug }} + className={tcls( + CHIP_VARIANT_CLASS, + 'flex bg-tint-5 text-tint-strong hover:bg-tint-hover' + )} + > + <TagIcon tag={tag} /> + <span className="truncate">{tag.label}</span> + </span> + </button> + ); +} + function areTagsEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((tag, index) => tag === right[index]); } +/** Mirrors the active filter onto `<html>`, matching what the pre-paint script does on first load. */ +function applyTagFilterAttribute(tags: string[]) { + if (typeof document === 'undefined') { + return; + } + + if (tags.length > 0) { + document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' ')); + } else { + document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR); + } +} + +/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */ export function FilteredUpdate(props: { tagSlugs: string[]; className?: string; children: React.ReactNode; }) { const { tagSlugs, className, children } = props; - const { selectedTagSet } = useUpdatesFilter(); - - const isVisible = - selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug)); return ( - <div className={className} hidden={!isVisible}> + <div className={className} {...{ [UPDATES_TAG_ATTR]: tagSlugs.join(' ') }}> {children} </div> ); diff --git a/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.test.ts b/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.test.ts new file mode 100644 index 000000000..f897af70d --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test'; + +import { serializeUpdatesFilterScriptArgs } from './UpdatesFilterScript'; + +describe('serializeUpdatesFilterScriptArgs', () => { + it('cannot terminate the surrounding script element', () => { + const serialized = serializeUpdatesFilterScriptArgs([ + ['</script><script>globalThis.injected = true</script>'], + ]); + + expect(serialized).not.toContain('</script>'); + expect(serialized).toContain('\\u003c/script>'); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.tsx b/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.tsx new file mode 100644 index 000000000..42bcfbd29 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/UpdatesFilterScript.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useServerInsertedHTML } from 'next/navigation'; +import { useRef } from 'react'; + +import { + UPDATES_FILTER_KEY_ATTR, + UPDATES_FILTER_SEARCH_PARAM, + UPDATES_TAG_FILTER_ATTR, + UPDATES_TAG_FILTER_CAP, + updatesFilterStyleKey, +} from '@/lib/updates'; + +/** + * Mirrors the `?tag=` search params onto `<html>` as `data-updates-tag-filter`, before first paint, + * so the generated CSS can filter with no flash. Also stamps the filter style key. + * + * NOTE: stringified and injected as an inline script — must be self-contained, touching only + * `document.documentElement` and `window.location`. + */ +export function applyUpdatesFilterScript( + searchParam: string, + attribute: string, + cap: number, + availableTags: string[], + keyAttribute: string, + styleKey: string +) { + const el = document.documentElement; + el.setAttribute(keyAttribute, styleKey); + + try { + const validTags = new Set(availableTags); + const params = new URLSearchParams(window.location.search); + const seen = new Set<string>(); + const slugs: string[] = []; + + for (const value of params.getAll(searchParam)) { + const slug = value.trim(); + if (!slug || seen.has(slug) || slugs.length >= cap || !validTags.has(slug)) { + continue; + } + seen.add(slug); + slugs.push(slug); + } + + if (slugs.length > 0) { + el.setAttribute(attribute, slugs.join(' ')); + } else { + el.removeAttribute(attribute); + } + } catch { + // Malformed URL — fall through, so the generated CSS's default (show everything) applies. + } +} + +/** + * Inline script that applies the URL's `?tag=` filter to `<html>` before first paint. Rendered only + * on pages with filterable updates (see SitePage), not globally. + */ +export function UpdatesFilterScript(props: { tagSlugs: string[] }) { + const { tagSlugs } = props; + const inserted = useRef(false); + const scriptArgs = serializeUpdatesFilterScriptArgs([ + UPDATES_FILTER_SEARCH_PARAM, + UPDATES_TAG_FILTER_ATTR, + UPDATES_TAG_FILTER_CAP, + tagSlugs, + UPDATES_FILTER_KEY_ATTR, + updatesFilterStyleKey(tagSlugs), + ]); + + // Only needed for the initial document — client navs are handled by UpdatesFilterProvider. + useServerInsertedHTML(() => { + if (inserted.current) { + return null; + } + inserted.current = true; + + return ( + <script + dangerouslySetInnerHTML={{ + __html: `(${applyUpdatesFilterScript.toString()})(${scriptArgs})`, + }} + /> + ); + }); + + return null; +} + +/** Serialize arguments without allowing revision data to terminate the surrounding script tag. */ +export function serializeUpdatesFilterScriptArgs(args: unknown[]): string { + return JSON.stringify(args).replaceAll('<', '\\u003c').slice(1, -1); +} diff --git a/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.test.ts b/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.test.ts index 5165d4e81..609cdf075 100644 --- a/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.test.ts +++ b/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'bun:test'; -import type { DocumentBlockParagraph, JSONDocument } from '@gitbook/api'; +import type { DocumentBlockParagraph, DocumentBlockTable, JSONDocument } from '@gitbook/api'; import { isBlockOffscreen } from './isBlockOffscreen'; @@ -42,3 +42,31 @@ it('should return true if block is offscreen', () => { ) ).toBe(true); }); + +it('should treat content in table cells as offscreen', () => { + const cell: DocumentBlockParagraph = { + object: 'block', + type: 'paragraph', + nodes: [], + }; + + const table: DocumentBlockTable = { + object: 'block', + type: 'table', + isVoid: true, + data: { + view: { type: 'cards', cardSize: 'medium', columns: ['column'] }, + records: {}, + definition: {}, + }, + fragments: [{ object: 'fragment', fragment: 'cell', nodes: [cell] }], + }; + + const document: JSONDocument = { + object: 'document', + nodes: [table], + data: {}, + }; + + expect(isBlockOffscreen({ document, block: cell, ancestorBlocks: [table] })).toBe(true); +}); diff --git a/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.ts b/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.ts index c26738223..e3b5ec495 100644 --- a/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.ts +++ b/packages/gitbook/src/components/DocumentView/utils/isBlockOffscreen.ts @@ -18,6 +18,11 @@ export function isBlockOffscreen( }, screenHeight = 500 ): boolean { + // Cell content lives in fragments, not table nodes, so its row position can't be estimated. + if (ancestorBlocks.some((ancestor) => ancestor.type === 'table')) { + return true; + } + let current = block; const allAncestors = [document, ...ancestorBlocks]; diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx index c05f45dd1..781ee9bd5 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx @@ -61,9 +61,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) { } trackEvent( - { - type: 'ask_view', - }, + { type: 'ask_view', trigger: 'embed' }, { pageId: null, displayContext: api.SiteInsightsDisplayContext.Embed, diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx index ca3390932..d63e44128 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx @@ -97,6 +97,7 @@ export async function EmbeddableDocsPage( ) ?? context.siteSpace } siteSpaces={variants.translations} + asEmbeddable className="my-1.5 ml-2 self-start" /> ) : null} @@ -125,6 +126,7 @@ export async function EmbeddableDocsPage( ) ?? context.siteSpace } siteSpaces={variants.translations} + asEmbeddable className="max-md:[&_.button-content]:block" /> ) : null @@ -137,6 +139,7 @@ export async function EmbeddableDocsPage( context={context} siteSpace={context.siteSpace} siteSpaces={variants.generic} + asEmbeddable className="w-full px-3" /> ) : null} diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx index 6df5958e4..a1c97287c 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx @@ -9,6 +9,7 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb import { integrationsAssistantTools } from '../Integrations'; import { Button, LinkContext, type LinkContextType } from '../primitives'; import { getChannel } from './channel'; +import { DEFAULT_EMBEDDABLE_TABS, getEmbeddableLandingTab } from './getEmbeddableLandingTab'; import { resolveEmbedPageLink } from './server-actions'; import { useAI, useAIChatController } from '@/components/AI'; import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; @@ -23,6 +24,17 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() = trademark: true, })); +const docsHome = createStore<{ reference?: string; href?: string }>(() => ({})); + +// Module-level so it survives the layout remounting on a cross-space navigation. +let landed = false; + +function resolvePageHref(pagePath: string, baseURL: string): Promise<string> { + return resolveEmbedPageLink(pagePath) + .then((resolved) => ('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`)) + .catch(() => `${baseURL}/page/${pagePath}`); +} + // oxlint-disable-next-line typescript/no-explicit-any function log(...data: any[]) { // oxlint-disable-next-line no-console @@ -66,6 +78,15 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) { const { baseURL, router, chatController } = refs.current; const message = payload as ParentToFrameMessage; + const navigate = (href: Promise<string>) => { + const token = ++navToken.current; + href.then((href) => { + if (navToken.current === token) { + refs.current.router.push(href); + } + }); + }; + log('[gitbook] received message', message); switch (message.type) { @@ -83,29 +104,43 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) { break; } case 'configure': { - embeddableConfiguration.setState(message.settings); + const { settings } = message; + embeddableConfiguration.setState(settings); integrationsAssistantTools.setState({ - tools: message.settings.tools, + tools: settings.tools, }); + + const defaultPage = + typeof settings.defaultPage === 'string' && settings.defaultPage + ? settings.defaultPage + : undefined; + let docsHomeHref: Promise<string> | undefined; + if (defaultPage !== docsHome.getState().reference) { + docsHome.setState({ reference: defaultPage, href: undefined }); + if (defaultPage) { + docsHomeHref = resolvePageHref(defaultPage, baseURL); + docsHomeHref.then((href) => { + if (docsHome.getState().reference === defaultPage) { + docsHome.setState({ href }); + } + }); + } + } + + if (!landed) { + landed = true; + const tab = getEmbeddableLandingTab(settings); + if (tab === 'docs') { + navigate(docsHomeHref ?? Promise.resolve(`${baseURL}/page/`)); + } else if (tab) { + navToken.current++; + router.push(`${baseURL}/${tab}`); + } + } break; } case 'navigateToPage': { - // Resolve the target server-side: on a multi-space site the page may - // live in another space/section, whose base must go before - // `~gitbook/embed/page`. Fall back to a same-space push on failure. - const { pagePath } = message; - const token = ++navToken.current; - // Ignore the result if a later navigation has since superseded this one. - const push = (href: string) => { - if (navToken.current === token) { - router.push(href); - } - }; - resolveEmbedPageLink(pagePath) - .then((resolved) => - push('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`) - ) - .catch(() => push(`${baseURL}/page/${pagePath}`)); + navigate(resolvePageHref(message.pagePath, baseURL)); break; } case 'navigateToAssistant': { @@ -132,7 +167,7 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>( export function useEmbeddableTabs() { const configuredTabs = useEmbeddableConfiguration((state) => state.tabs); - return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs']; + return configuredTabs.length > 0 ? configuredTabs : DEFAULT_EMBEDDABLE_TABS; } export function useEmbeddableLinkContext() { @@ -200,6 +235,7 @@ export function EmbeddableIframeTabs(props: { const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props; const actions = useEmbeddableConfiguration((state) => state.actions); const tabs = useEmbeddableTabs(); + const docsHomeHref = useStore(docsHome, (state) => state.href); const { assistants, config } = useAI(); const language = useLanguage(); @@ -228,7 +264,7 @@ export function EmbeddableIframeTabs(props: { key: 'docs', label: siteTitle, icon: 'book-open', - href: `${baseURL}/page/`, + href: docsHomeHref ?? `${baseURL}/page/`, } : null, ].filter((tab) => tab !== null); diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx index 4ac7a73a9..1e56d4c6d 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx @@ -1,6 +1,8 @@ import type { CustomizationDefaultThemeMode } from '@gitbook/api'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; +import { AskAIParagraphButton } from '../AIChat'; +import { LoadIntegrations } from '../Integrations'; import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutServerContext } from '../SpaceLayout'; import { Trademark } from '../TableOfContents/Trademark'; @@ -76,6 +78,7 @@ export async function EmbeddableRootLayout({ }} > <NavigationLoader /> + <AskAIParagraphButton /> <VisibilityProvider className="fixed inset-0 flex flex-col"> {children} {context.customization.trademark.enabled ? ( @@ -93,6 +96,8 @@ export async function EmbeddableRootLayout({ /> </SpaceLayoutServerContext> </EmbeddableAIContextProvider> + {/* Embeds load no integrations; this marks them loaded so search stops waiting. */} + <LoadIntegrations /> </SiteLayoutClientContexts> </CustomizationRootLayout> ); diff --git a/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts new file mode 100644 index 000000000..6010d87c5 --- /dev/null +++ b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; + +import { getEmbeddableLandingTab } from './getEmbeddableLandingTab'; + +describe('getEmbeddableLandingTab', () => { + it('keeps the embed default when nothing is configured', () => { + expect(getEmbeddableLandingTab({})).toBeNull(); + expect(getEmbeddableLandingTab({ tabs: ['docs', 'search'] })).toBeNull(); + }); + + it('opens on the configured tab', () => { + expect(getEmbeddableLandingTab({ defaultTab: 'search' })).toBe('search'); + expect( + getEmbeddableLandingTab({ tabs: ['assistant', 'search', 'docs'], defaultTab: 'search' }) + ).toBe('search'); + }); + + it('opens on the docs when only a default page is set', () => { + expect(getEmbeddableLandingTab({ defaultPage: 'getting-started' })).toBe('docs'); + }); + + it('prefers the configured tab over the default page', () => { + expect( + getEmbeddableLandingTab({ defaultTab: 'assistant', defaultPage: 'getting-started' }) + ).toBe('assistant'); + }); + + it('ignores a tab that is not enabled', () => { + expect(getEmbeddableLandingTab({ tabs: ['assistant'], defaultTab: 'search' })).toBeNull(); + expect( + getEmbeddableLandingTab({ tabs: ['assistant', 'search'], defaultPage: 'quickstart' }) + ).toBeNull(); + }); + + it('ignores an unknown tab', () => { + // @ts-expect-error - the parent window is plain JS and can send anything. + expect(getEmbeddableLandingTab({ defaultTab: 'settings' })).toBeNull(); + }); +}); diff --git a/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts new file mode 100644 index 000000000..fa686c28d --- /dev/null +++ b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts @@ -0,0 +1,17 @@ +import type { GitBookEmbeddableConfiguration } from '@gitbook/embed'; + +export type EmbeddableTab = GitBookEmbeddableConfiguration['tabs'][number]; + +export const DEFAULT_EMBEDDABLE_TABS: EmbeddableTab[] = ['assistant', 'search', 'docs']; + +export function getEmbeddableLandingTab( + settings: Partial<Pick<GitBookEmbeddableConfiguration, 'tabs' | 'defaultTab' | 'defaultPage'>> +): EmbeddableTab | null { + const tab = settings.defaultTab ?? (settings.defaultPage ? 'docs' : undefined); + if (!tab || !DEFAULT_EMBEDDABLE_TABS.includes(tab)) { + return null; + } + + const tabs = settings.tabs?.length ? settings.tabs : DEFAULT_EMBEDDABLE_TABS; + return tabs.includes(tab) ? tab : null; +} diff --git a/packages/gitbook/src/components/Header/SpacesDropdown.tsx b/packages/gitbook/src/components/Header/SpacesDropdown.tsx index befded446..cf353c86d 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdown.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdown.tsx @@ -2,9 +2,10 @@ import type { SiteSpace } from '@gitbook/api'; import type { IconName } from '@gitbook/icons'; import type { ButtonProps } from '../primitives'; +import { getTargetURLForSiteSpace } from '../SiteSections/encodeClientSiteSections'; import { SpacesDropdownClient } from './SpacesDropdownClient'; import type { GitBookSiteContext } from '@/lib/context'; -import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites'; +import { getLocalizedTitle } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; // Memoized regex for checking if a string starts with an emoji @@ -20,9 +21,18 @@ export function SpacesDropdown( siteSpace: SiteSpace; siteSpaces: SiteSpace[]; className?: string; + /** Keep the links inside the embed rather than sending readers to the full site. */ + asEmbeddable?: boolean; } & ButtonProps ) { - const { context, siteSpace, siteSpaces, className, ...buttonProps } = props; + const { + context, + siteSpace, + siteSpaces, + className, + asEmbeddable = false, + ...buttonProps + } = props; const currentLanguage = context.locale; const dropdownClassName = tcls( @@ -33,7 +43,7 @@ export function SpacesDropdown( const slimSpaces = siteSpaces.map((siteSp) => ({ id: siteSp.id, title: getLocalizedTitle(siteSp, currentLanguage), - url: getSiteSpaceURL(context, siteSp), + url: getTargetURLForSiteSpace(context, siteSp, asEmbeddable), isActive: siteSp.id === siteSpace.id, spaceId: siteSp.space.id, })); @@ -57,9 +67,10 @@ export function TranslationsDropdown( siteSpace: SiteSpace; siteSpaces: SiteSpace[]; className?: string; + asEmbeddable?: boolean; } & ButtonProps ) { - const { context, siteSpace, siteSpaces, className, ...buttonProps } = props; + const { context, siteSpace, siteSpaces, className, asEmbeddable, ...buttonProps } = props; const title = getLocalizedTitle(siteSpace, context.locale); const hasEmojiPrefix = startsWithEmoji(title); @@ -70,6 +81,7 @@ export function TranslationsDropdown( context={context} siteSpace={siteSpace} siteSpaces={siteSpaces} + asEmbeddable={asEmbeddable} variant="blank" className={tcls( 'bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden', diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index 40789941d..658c5c83f 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useDebounceCallback, useEventCallback } from 'usehooks-ts'; import type * as api from '@gitbook/api'; +import { SiteInsightsDisplayContext } from '@gitbook/api'; import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi'; import { type CurrentContentContext, useCurrentContent } from '../hooks'; @@ -75,12 +76,15 @@ export function InsightsProvider(props: InsightsProviderProps) { [pathname: string]: | { url: string; + previousUrl: string | null; events: TrackEventInput<InsightsEventName>[]; context: CurrentContentContext; pageContext?: InsightsEventPageContext; } | undefined; }>({}); + // Href of the last page an event was tracked on; `undefined` until the first event. + const lastUrlRef = React.useRef<string | null | undefined>(undefined); /** * Synchronously flush all the pending events. @@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) { allEvents.push( ...transformEvents({ url: eventsForPathname.url, + previousUrl: eventsForPathname.previousUrl, events: eventsForPathname.events, context: currentContent, pageContext: eventsForPathname.pageContext, @@ -154,6 +159,9 @@ export function InsightsProvider(props: InsightsProviderProps) { ) => { const pathname = window.location.pathname; const previous = eventsRef.current[pathname]; + const lastUrl = + lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current; + lastUrlRef.current = window.location.href; eventsRef.current[pathname] = { // An explicitly-provided context wins so page-scoped events (e.g. feedback) can // attribute to their page even when the pathname's ambient context has none — such @@ -161,6 +169,7 @@ export function InsightsProvider(props: InsightsProviderProps) { // context keep the stored one. pageContext: ctx ?? previous?.pageContext, url: previous?.url ?? window.location.href, + previousUrl: previous ? previous.previousUrl : lastUrl, events: [ ...(previous?.events ?? []), { @@ -214,6 +223,18 @@ export function useTrackEvent(): TrackEventCallback { return React.useContext(InsightsContext); } +/** + * The referrer when it's another page of this origin, so a full page load (e.g. an absolute link + * to a missing page) still records the page it came from. + */ +function getSameOriginReferrer(): string | null { + if (document.referrer === window.location.href || !URL.canParse(document.referrer)) { + return null; + } + const referrer = new URL(document.referrer); + return referrer.origin === window.location.origin ? referrer.href : null; +} + /** * Post the events to the server. */ @@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] }) */ function transformEvents(input: { url: string; + previousUrl: string | null; events: TrackEventInput<InsightsEventName>[]; context: CurrentContentContext; pageContext: InsightsEventPageContext; @@ -258,6 +280,11 @@ function transformEvents(input: { const location: api.SiteInsightsEventLocation = { url: input.url, + // The embed's navigation is between its own tabs, not pages of the site. + previousUrl: + input.pageContext.displayContext === SiteInsightsDisplayContext.Embed + ? null + : input.previousUrl, siteSection: input.context.siteSectionId ?? null, siteSpace: input.context.siteSpaceId ?? null, space: input.context.spaceId, diff --git a/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx b/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx index 1b7f59da9..30ba7c3e5 100644 --- a/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx +++ b/packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx @@ -1,5 +1,6 @@ 'use client'; +import { usePathname } from 'next/navigation'; import * as React from 'react'; import type { SiteInsightsDisplayContext } from '@gitbook/api'; @@ -7,6 +8,7 @@ import type { SiteInsightsDisplayContext } from '@gitbook/api'; import { useCurrentPage } from '../hooks'; import { useIsVisible } from '../VisibilityContext'; import { useTrackEvent } from './InsightsProvider'; +import { isGitBookInternalPath } from '@/lib/paths'; /** * Track a page view event. @@ -15,11 +17,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC const { displayContext } = props; const page = useCurrentPage(); const trackEvent = useTrackEvent(); + const pathname = usePathname(); + // Internal routes that 404 (e.g. `~gitbook/pdf` under an unpublished variant) aren't broken URLs. + const isInternalNotFound = !page && isGitBookInternalPath(pathname ?? ''); // Always true outside of the embed, whose frame can be loaded while hidden. const isVisible = useIsVisible(); React.useEffect(() => { - if (!isVisible) { + if (!isVisible || isInternalNotFound) { return; } @@ -32,7 +37,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC displayContext, } ); - }, [page, trackEvent, displayContext, isVisible]); + }, [page, trackEvent, displayContext, isVisible, isInternalNotFound]); return null; } diff --git a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx index b3627f610..3ea19f9cd 100644 --- a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx +++ b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx @@ -147,6 +147,10 @@ export function useCustomCookieBanner(): CustomCookieBannerStore { export function LoadIntegrations() { React.useEffect(() => { return whenIntegrationsReady(() => { + // Integrations must only receive `load` once, even if this is mounted twice. + if (integrationsStore.getState().loaded) { + return; + } dispatchGitBookIntegrationEvent('load'); integrationsStore.setState({ loaded: true }); }); diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index df50737f0..691b7abba 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: { href: page.href, }); } - assistant.open(); + assistant.open(undefined, 'page'); }} /> ); diff --git a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx index 3010bba9f..159b5651a 100644 --- a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx +++ b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx @@ -9,6 +9,7 @@ import { useScrollActiveId } from '@/components/hooks'; import { useBodyLoaded } from '@/components/primitives'; import type { DocumentSection } from '@/lib/document-sections'; import { tcls } from '@/lib/tailwind'; +import { UPDATES_TAG_SECTION_ATTR } from '@/lib/updates'; /** * The threshold at which we consider a section as intersecting the viewport. @@ -22,20 +23,18 @@ const ACTIVE_ITEM_OFFSET = 100; export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) { const { selectedTagSet } = useUpdatesFilter(); - const visibleSections = React.useMemo(() => { - if (selectedTagSet.size === 0) { - return sections; - } - - return sections.filter((section) => { - if (section.tags === undefined) { - return true; - } - - return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug)); - }); - }, [sections, selectedTagSet]); - const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]); + const ids = React.useMemo( + () => + sections + .filter( + (section) => + selectedTagSet.size === 0 || + section.tags === undefined || + section.tags.some((tagSlug) => selectedTagSet.has(tagSlug)) + ) + .map(({ id }) => id), + [sections, selectedTagSet] + ); const enabled = useBodyLoaded(); @@ -62,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] } className="relative flex flex-col border-tint-subtle pb-5 sidebar-list-line:border-l" ref={scrollContainerRef} > - {visibleSections.map((section) => ( + {sections.map((section) => ( <li key={section.id} className={tcls( @@ -76,6 +75,9 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] } section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0'] )} ref={activeId === section.id ? activeItemRef : null} + {...(section.tags !== undefined + ? { [UPDATES_TAG_SECTION_ATTR]: section.tags.join(' ') } + : {})} > <a href={`#${section.id}`} diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 1dd4d1a31..1d8e83d2b 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -1,4 +1,8 @@ -import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api'; +import { + type JSONDocument, + type RevisionPageDocument, + SiteInsightsDisplayContext, +} from '@gitbook/api'; import { DocumentView, DocumentViewSkeleton } from '../DocumentView'; import { CurrentPageProvider } from '../hooks/useCurrentPage'; @@ -25,6 +29,7 @@ import { import { getLLMsTxtURL, getPageMarkdownURL } from '@/lib/llms-directive'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; +import { getPageRSSURL } from '@/routes/rss'; const LINK_PREVIEW_MAX_COUNT = 500; @@ -86,6 +91,14 @@ export async function PageBody(props: { return ( <CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}> + {contentHasUpdates ? ( + <link + rel="alternate" + type="application/rss+xml" + href={getPageRSSURL(context, page)} + title="RSS Feed" + /> + ) : null} <main className={tcls( 'relative min-w-0 flex-1', @@ -118,6 +131,7 @@ export async function PageBody(props: { ancestors={ancestors} withRSSFeed={contentHasUpdates} hasAPIBlocks={hasAPIBlocks} + asEmbeddable={insightsDisplayContext === SiteInsightsDisplayContext.Embed} /> {document && !isNodeEmpty(document) ? ( <OptionalSuspense diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 247356e20..bb679254c 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -29,18 +29,21 @@ interface PageCoverImageProps { export function PageCoverImage(props: PageCoverImageProps) { const { imgs, y, height, mask } = props; - const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); + const { objectPositionY, isLoading } = useCoverPosition(imgs, y, { + height, + aspectRatio: PAGE_COVER_SIZE, + }); if (isLoading) { return ( - <div className="h-full w-full overflow-hidden" ref={containerRef}> + <div className="h-full w-full overflow-hidden"> <div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" /> </div> ); } return ( - <div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}> + <div className="h-full w-full overflow-hidden @container" style={{ height }}> <img src={imgs.light.src} srcSet={imgs.light.srcSet} @@ -52,7 +55,7 @@ export function PageCoverImage(props: PageCoverImageProps) { aspectRatio: height ? undefined : `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, - objectPosition: `50% ${objectPositionY}%`, + objectPosition: `50% ${objectPositionY}`, height, // if no height is passed, no height will be set. maskComposite: 'intersect', maskImage: @@ -73,7 +76,7 @@ export function PageCoverImage(props: PageCoverImageProps) { aspectRatio: height ? undefined : `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, - objectPosition: `50% ${objectPositionY}%`, + objectPosition: `50% ${objectPositionY}`, height, // if no height is passed, no height will be set. maskComposite: 'intersect', maskImage: diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 4b7d822e6..d63ff3535 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -7,7 +7,6 @@ import { type RevisionPageDocument, type SiteSection, type SiteSectionGroup, - SiteVisibility, } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; @@ -20,13 +19,17 @@ import { import { PageAsideToggleButton } from '../PageAside/PageAsideButton'; import { PageIcon } from '../PageIcon'; import { getPDFURLSearchParams } from '../PDF'; -import { findBestTargetURL } from '../SiteSections/encodeClientSiteSections'; +import { + findBestTargetURL, + getTargetURLForSiteSpace, +} from '../SiteSections/encodeClientSiteSections'; import { categorizeVariants } from '../SpaceLayout/categorizeVariants'; import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown'; import { PageTags } from './PageTags'; import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context'; +import { hasAdaptiveMcpEndpoint } from '@/lib/mcp/endpoints'; import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages'; -import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites'; +import { getLocalizedTitle } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; import { getPageRSSURL } from '@/routes/rss'; @@ -41,8 +44,10 @@ export async function PageHeader(props: { * exactly as-is. */ hasAPIBlocks: boolean; + /** Keep the context crumbs' links inside the embed rather than sending readers to the full site. */ + asEmbeddable: boolean; }) { - const { context, page, ancestors, withRSSFeed, hasAPIBlocks } = props; + const { context, page, ancestors, withRSSFeed, hasAPIBlocks, asEmbeddable } = props; const { revision, linker } = context; const hasAncestors = ancestors.length > 0; @@ -82,7 +87,10 @@ export async function PageHeader(props: { // Section groups have no URL of their own; only sections are navigable. Resolve the // section to the site space matching the current variant so switching sections keeps // the reader on their current variant (same logic as the section tabs). - href: node.object === 'site-section' ? findBestTargetURL(context, node) : undefined, + href: + node.object === 'site-section' + ? findBestTargetURL(context, node, asEmbeddable) + : undefined, label: getLocalizedTitle(node, context.locale), icon: node.icon, siblings: siblings @@ -92,7 +100,7 @@ export async function PageHeader(props: { // Don't offer hidden sections/groups as switch targets. .filter((sibling) => !visibleSectionIds || visibleSectionIds.has(sibling.id)) .map((sibling) => { - const href = getSectionNodeURL(context, sibling); + const href = getSectionNodeURL(context, sibling, asEmbeddable); // Keep siblings whose URL is "" (the site's first page); only drop the ones // with no resolvable URL at all. return href !== undefined @@ -112,7 +120,7 @@ export async function PageHeader(props: { if (currentSiteSpace) { contextCrumbs.push({ key: `variant-${currentSiteSpace.id}`, - href: getSiteSpaceURL(context, currentSiteSpace), + href: getTargetURLForSiteSpace(context, currentSiteSpace, asEmbeddable), label: getLocalizedTitle(currentSiteSpace, context.locale), siblings: [], // Reuse the header's variant switcher rather than plain per-variant links, so each entry @@ -122,7 +130,7 @@ export async function PageHeader(props: { slimSpaces: variantSpaces.map((siteSpace) => ({ id: siteSpace.id, title: getLocalizedTitle(siteSpace, context.locale), - url: getSiteSpaceURL(context, siteSpace), + url: getTargetURLForSiteSpace(context, siteSpace, asEmbeddable), isActive: siteSpace.id === currentSiteSpace.id, spaceId: siteSpace.space.id, })), @@ -214,9 +222,14 @@ export async function PageHeader(props: { ))} {ancestors.map((breadcrumb, index) => { const parentPages = ancestors[index - 1]?.pages ?? revision.pages; + // A group's own URL redirects to its first document, so link straight to it. + const target = + breadcrumb.type === 'group' + ? (resolveFirstDocument([breadcrumb], [])?.page ?? breadcrumb) + : breadcrumb; const href = linker.toPathForPage({ pages: revision.pages, - page: breadcrumb, + page: target, }); return ( <li key={breadcrumb.id} className="inline"> @@ -448,9 +461,13 @@ function collectSectionNodeIds(list: SiteStructureNode[]): Set<string> { * The URL resolves to the site space matching the current variant (same logic as the section tabs), * so switching sections keeps the reader on their current variant. */ -function getSectionNodeURL(context: GitBookSiteContext, node: SectionNode): string | undefined { +function getSectionNodeURL( + context: GitBookSiteContext, + node: SectionNode, + asEmbeddable: boolean +): string | undefined { const section = findFirstSection(node); - return section ? findBestTargetURL(context, section) : undefined; + return section ? findBestTargetURL(context, section, asEmbeddable) : undefined; } /** @@ -471,15 +488,16 @@ function getPageSiblings( if (page.hidden && page.id !== activeId) { continue; } - // A group whose subtree has no document resolves to no page — the sidebar renders it as a - // non-clickable label, so don't offer it as a navigable sibling (its path would 404). - if (page.type === 'group' && page.id !== activeId && !resolveFirstDocument([page], [])) { + // A group's own URL redirects to its first document, so link straight to it. A group whose + // subtree has no document resolves to no page, so don't offer it (its path would 404). + const target = page.type === 'group' ? resolveFirstDocument([page], [])?.page : page; + if (!target) { continue; } result.push({ id: page.id, title: page.linkTitle || page.title, - href: context.linker.toPathForPage({ pages: context.revision.pages, page }), + href: context.linker.toPathForPage({ pages: context.revision.pages, page: target }), emoji: page.emoji, icon: page.icon, isActive: page.id === activeId, @@ -559,12 +577,7 @@ function isPageActionEnabled( * Return the MCP URL to be used in the page actions dropdown. */ function getPageActionsMCPURL(context: GitBookSiteContext) { - const useAuthenticatedEndpoint = Boolean( - context.site.visibility !== SiteVisibility.VisitorAuth && - context.site.adaptiveContent?.enabled && - context.site.urls.login && - context.isLoggedInVisitor - ); + const useAuthenticatedEndpoint = hasAdaptiveMcpEndpoint(context) && context.isLoggedInVisitor; const endpoint = useAuthenticatedEndpoint ? '~gitbook/mcp/auth' : '~gitbook/mcp'; return context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)); diff --git a/packages/gitbook/src/components/PageBody/useCoverPosition.ts b/packages/gitbook/src/components/PageBody/useCoverPosition.ts index 8814c2a92..5f4563815 100644 --- a/packages/gitbook/src/components/PageBody/useCoverPosition.ts +++ b/packages/gitbook/src/components/PageBody/useCoverPosition.ts @@ -1,6 +1,5 @@ 'use client'; -import { useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useResizeObserver } from 'usehooks-ts'; +import { useLayoutEffect, useState } from 'react'; interface ImageSize { width: number; @@ -22,19 +21,18 @@ interface Images { } /** - * Hook to calculate the object position Y percentage for a cover image - * based on the y offset, image dimensions, and container dimensions. + * Hook to compute the CSS object position Y for a cover image, from the y offset and the image + * dimensions. The container must set `container-type: inline-size` and be as wide as the image, + * since the position is computed against its width. */ -export function useCoverPosition(imgs: Images, y: number) { - const containerRef = useRef<HTMLDivElement>(null); +export function useCoverPosition( + imgs: Images, + y: number, + container: { height: number | undefined; aspectRatio: ImageSize } +) { const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null); const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size); - const container = useResizeObserver({ - // @ts-expect-error wrong types - ref: containerRef, - }); - // Load original image dimensions if not provided in `imgs` useLayoutEffect(() => { // Check if we have dimensions from dark (if provided) or else the default light. @@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) { // Check dark first, then light, then loaded dimensions const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions; - // Calculate ratio and dimensions similar to useCoverPosition hook - const ratio = - imageDimensions && container.height && container.width - ? Math.max( - container.width / imageDimensions.width, - container.height / imageDimensions.height - ) - : 1; - const safeRatio = ratio || 1; - - const scaledHeight = - imageDimensions && container.height ? imageDimensions.height * safeRatio : null; - const maxOffset = - scaledHeight && container.height - ? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio) - : 0; - - // Parse the position between the allowed min/max - const objectPositionY = useMemo(() => { - if (!container.height || !imageDimensions) { - return 50; - } - - const scaled = imageDimensions.height * safeRatio; - if (scaled <= container.height || maxOffset === 0) { - return 50; - } - - const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y)); - const relative = (maxOffset - clampedOffset) / (2 * maxOffset); - return relative * 100; - }, [container.height, imageDimensions, maxOffset, safeRatio, y]); - return { - containerRef, - objectPositionY, + objectPositionY: imageDimensions + ? getCoverObjectPositionY(imageDimensions, y, container) + : '50%', isLoading: !imageDimensions || isLoading, }; } + +/** + * Offset the image `y` natural pixels from centered, clamped so it keeps covering the container. + * Expressed in CSS against the container width (`cqw`), so it renders the same on the server as + * after hydration, without measuring the container. + */ +function getCoverObjectPositionY( + image: ImageSize, + y: number, + container: { height: number | undefined; aspectRatio: ImageSize } +): string { + const containerHeight = container.height + ? `${container.height}px` + : `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`; + // Rendered height of the image under `object-fit: cover`. + const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`; + const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`; + const offset = `${scaledHeight} * ${y / image.height}`; + + return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`; +} diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index d6931cea1..992049cb3 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -16,18 +16,10 @@ import { SearchInput } from './SearchInput'; import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer'; import { SearchScopeControl } from './SearchScopeControl'; import { useSearchController } from './useSearchController'; +import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -const SEARCH_POPUP_FOCUSABLE_SELECTOR = [ - 'a[href]:not([aria-disabled="true"])', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), { ssr: false, }); @@ -144,54 +136,7 @@ export function SearchContainer({ ? Boolean(state?.open || state?.query || wasSearchOpened) : Boolean(state?.query || withAI); - React.useEffect(() => { - if ( - usesSideSheet || - !isSearchOpen || - !shouldShowSearchFrame || - !searchInputRef.current || - !searchPopup - ) { - return; - } - - const searchInput = searchInputRef.current.querySelector<HTMLElement>( - '[data-testid="search-input"]' - ); - if (!searchInput) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Tab') { - return; - } - - const popupControls = Array.from( - searchPopup.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR) - ).filter((element) => element.getClientRects().length > 0); - const focusableElements = [searchInput, ...popupControls]; - const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement); - const nextIndex = - currentIndex === -1 - ? event.shiftKey - ? focusableElements.length - 1 - : 0 - : event.shiftKey - ? currentIndex - 1 - : currentIndex + 1; - const wrappedIndex = (nextIndex + focusableElements.length) % focusableElements.length; - - event.preventDefault(); - focusableElements[wrappedIndex]?.focus(); - }; - - document.addEventListener('keydown', handleKeyDown, true); - - return () => { - document.removeEventListener('keydown', handleKeyDown, true); - }; - }, [isSearchOpen, searchPopup, shouldShowSearchFrame, usesSideSheet]); + useSearchPopupFocusTrap({ close, searchInputRef, searchPopup, usesSideSheet }); const scopeControlNode = searchProps.withVariants || searchProps.withSections ? ( diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index b7520a4ec..68ef5a08d 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -5,6 +5,7 @@ import { motion } from 'framer-motion'; import React from 'react'; import { Button, SkeletonParagraph, SkeletonSmall } from '../primitives'; +import { isExternalLink } from '../utils/link'; import { addRecentSearchQuery } from './recent-queries'; import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; @@ -218,6 +219,16 @@ export const SearchResults = React.forwardRef(function SearchResults( addRecentSearchQuery(siteSpaceId, query, 'search'); } + // The popover's focus manager re-focuses the popup when the focused + // result is torn down during close (base-ui `restoreFocus`), and that + // focus() scrolls the popup — anchored at the top of the page — into + // view, undoing the scroll to the section the result linked to. + // A click that opens elsewhere leaves the search open, and keyboard + // users should keep their place in it. + if (navigatesCurrentWindow(event)) { + event.currentTarget.blur(); + } + onResultSelect?.(); }; const resultItemProps = { @@ -385,3 +396,19 @@ const SearchResultsSkeleton = (props: { items: number }) => { </> ); }; + +/** + * Whether clicking a result navigates the current window, and so closes the search — as opposed + * to opening a new tab or window (modifier keys, `target="_blank"`, or an external destination, + * which an embed opens in a new tab). + */ +function navigatesCurrentWindow(event: React.MouseEvent<HTMLAnchorElement>) { + const link = event.currentTarget; + return ( + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + link.target !== '_blank' && + !isExternalLink(link.href, window.location.origin) + ); +} diff --git a/packages/gitbook/src/components/Search/useSearchController.tsx b/packages/gitbook/src/components/Search/useSearchController.tsx index d520ec6c5..02896b4c9 100644 --- a/packages/gitbook/src/components/Search/useSearchController.tsx +++ b/packages/gitbook/src/components/Search/useSearchController.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useAI } from '../AI'; import { useTrackEvent } from '../Insights'; +import { useIntegrationsLoaded } from '../Integrations'; import { useBodyLoaded } from '../primitives'; import { clearLastSearchQuery, @@ -140,6 +141,7 @@ export function useSearchController( const trackEvent = useTrackEvent(); const resultsRef = React.useRef<SearchResultsRef>(null); const isLoaded = useBodyLoaded(); + const integrationsLoaded = useIntegrationsLoaded(); const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null); React.useEffect(() => { @@ -169,6 +171,8 @@ export function useSearchController( const withAI = assistants.length > 0; const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0; + // Integration assistants register on window load, so wait for it before treating `ask` as a search query. + const askIsSearchQuery = withSearchAI || (!withAI && integrationsLoaded); // Handle initial ask state on page load, once assistants are ready. // `ask=` should still bootstrap the assistant on the docs site, so we must @@ -203,7 +207,7 @@ export function useSearchController( const query = prev?.query ?? getLastSearchQuery(siteSpace.id) ?? - (withSearchAI || !withAI ? prev?.ask : null) ?? + (askIsSearchQuery ? prev?.ask : null) ?? ''; return { @@ -217,7 +221,7 @@ export function useSearchController( trackEvent({ type: 'search_open', }); - }, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]); + }, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]); const setQuery = React.useCallback( (value: string) => { @@ -261,7 +265,7 @@ export function useSearchController( }); const searchValue = - state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? ''; + state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? ''; const searchResultsId = `search-results-${React.useId()}`; // Only clears the remembered last query and stops any in-flight fetch — it must diff --git a/packages/gitbook/src/components/Search/useSearchPopupFocusTrap.ts b/packages/gitbook/src/components/Search/useSearchPopupFocusTrap.ts new file mode 100644 index 000000000..4932796b2 --- /dev/null +++ b/packages/gitbook/src/components/Search/useSearchPopupFocusTrap.ts @@ -0,0 +1,115 @@ +import React from 'react'; + +const SEARCH_POPUP_FOCUSABLE_SELECTOR = [ + 'a[href]:not([aria-disabled="true"])', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +/** + * Traps Tab/Shift+Tab within the search input and its popup while it's open, and hands focus + * off to the adjacent page element once the trap's boundary is reached. + */ +export function useSearchPopupFocusTrap({ + close, + searchInputRef, + searchPopup, + usesSideSheet, +}: { + close: () => void; + searchInputRef: React.RefObject<HTMLDivElement | null>; + searchPopup: HTMLDivElement | null; + usesSideSheet: boolean; +}) { + React.useEffect(() => { + if (usesSideSheet || !searchInputRef.current || !searchPopup) { + return; + } + + const searchInput = searchInputRef.current.querySelector<HTMLElement>( + '[data-testid="search-input"]' + ); + if (!searchInput) { + return; + } + + // Finds the next (or previous) focusable element in real document order, + // skipping the popup's own content as well as Base UI's internal focus-guard + // sentinels — both live in a portal, so their DOM position doesn't match their + // visual position after the search input, and would otherwise re-trap focus. + const findAdjacentPageElement = (direction: 'next' | 'previous') => { + const step = direction === 'next' ? 1 : -1; + const pageFocusable = Array.from( + document.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR) + ).filter( + (element) => + element.getClientRects().length > 0 && + !element.closest('[data-base-ui-portal], [data-base-ui-focus-guard]') + ); + return pageFocusable[pageFocusable.indexOf(searchInput) + step]; + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + + const activeElement = document.activeElement as HTMLElement | null; + // Whether or not the popover ever visibly opened, Base UI still keeps a pair + // of hidden, tabbable focus-guard nodes mounted in its portal — reachable by + // Tab regardless of open state, and otherwise a dead end. + const isOnOwnFocusGuard = Boolean( + activeElement?.hasAttribute('data-base-ui-focus-guard') && + searchPopup.closest('[data-base-ui-portal]')?.contains(activeElement) + ); + + const popupControls = Array.from( + searchPopup.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR) + ).filter((element) => element.getClientRects().length > 0); + const focusableElements = [searchInput, ...popupControls]; + const currentIndex = focusableElements.indexOf(activeElement as HTMLElement); + + if (currentIndex === -1 && !isOnOwnFocusGuard) { + // Focus isn't within the search widget — leave native Tab handling alone. + return; + } + + if (event.shiftKey && currentIndex === 0) { + // Search input is a regular DOM node, so the browser's native Shift+Tab + // already moves focus to whatever precedes it once we close the popover. + close(); + return; + } + + if (event.shiftKey && (isOnOwnFocusGuard || currentIndex === -1)) { + event.preventDefault(); + close(); + findAdjacentPageElement('previous')?.focus(); + return; + } + + if ( + !event.shiftKey && + (isOnOwnFocusGuard || currentIndex === focusableElements.length - 1) + ) { + event.preventDefault(); + close(); + findAdjacentPageElement('next')?.focus(); + return; + } + + const nextIndex = event.shiftKey ? currentIndex - 1 : currentIndex + 1; + event.preventDefault(); + focusableElements[nextIndex]?.focus(); + }; + + document.addEventListener('keydown', handleKeyDown, true); + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [close, searchPopup, usesSideSheet]); +} diff --git a/packages/gitbook/src/components/SiteAuth/SiteAuthLoginLink.tsx b/packages/gitbook/src/components/SiteAuth/SiteAuthLoginLink.tsx index 52cfd6c10..1be3301ec 100644 --- a/packages/gitbook/src/components/SiteAuth/SiteAuthLoginLink.tsx +++ b/packages/gitbook/src/components/SiteAuth/SiteAuthLoginLink.tsx @@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation'; import { useMemo } from 'react'; import type React from 'react'; +import { useIsMounted } from '../hooks/useIsMounted'; import { Button, type ButtonProps } from '../primitives/Button'; import { DropdownMenuItem } from '../primitives/DropdownMenu'; import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link'; @@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) { const rawPathname = usePathname(); const searchParams = useSearchParams(); const currentSearch = searchParams?.toString(); - const pathname = rawPathname ?? '/'; + // On the server, usePathname() returns the internal rewritten route (/sites/…, which includes + // the site API token), so the location is only added once mounted. + // https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites + const isMounted = useIsMounted(); + const pathname = isMounted ? (rawPathname ?? '/') : null; return useMemo(() => { + if (pathname === null) { + return href; + } + const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'; const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL); const siteBasePath = removeTrailingSlash( diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 939543b6e..156373fa0 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -3,7 +3,7 @@ import Script from 'next/script'; import React from 'react'; import * as ReactDOM from 'react-dom'; -import { CustomizationDefaultThemeMode } from '@gitbook/api'; +import { CustomizationDefaultThemeMode, CustomizationPageActionType } from '@gitbook/api'; import { AIContextProvider } from '../AI'; import { RocketLoaderDetector } from './RocketLoaderDetector'; @@ -12,6 +12,7 @@ import { AdminToolbar } from '@/components/AdminToolbar'; import { CookiesToast } from '@/components/Cookies'; import { LoadIntegrations } from '@/components/Integrations'; import { SpaceLayout } from '@/components/SpaceLayout'; +import { WebMCP } from '@/components/WebMCP/WebMCP'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { buildVersion } from '@/lib/build'; import type { GitBookSiteContext } from '@/lib/context'; @@ -117,6 +118,9 @@ export async function SiteLayout(props: { </AIContextProvider> <LoadIntegrations /> + {customization.pageActions.items.includes(CustomizationPageActionType.Mcp) ? ( + <WebMCP mcpURL={context.linker.toPathInSite('~gitbook/mcp')} /> + ) : null} {scripts.map(({ script }) => isDeferrableScript(script) ? ( <Script key={script} src={script} strategy="lazyOnload" /> diff --git a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx index 4a7f2683d..0a61a96d4 100644 --- a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx +++ b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { usePathname, useSearchParams } from 'next/navigation'; import React from 'react'; import type { PageMetaLinks } from './SitePage'; @@ -33,7 +33,6 @@ export function PageClientLayout({ pageMetaLinks }: { pageMetaLinks: PageMetaLin * so we need to remove the fallback parameter. */ function useStripFallbackQueryParam() { - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -41,9 +40,14 @@ function useStripFallbackQueryParam() { if (searchParams?.has('fallback')) { const params = new URLSearchParams(searchParams.toString()); params.delete('fallback'); - router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`); + const query = params.toString(); + window.history.replaceState( + null, + '', + `${pathname}${query ? `?${query}` : ''}${window.location.hash}` + ); } - }, [router, pathname, searchParams]); + }, [pathname, searchParams]); } /** @@ -78,7 +82,7 @@ type CoverOverlap = * `data-over-cover="split"` with the crossing point in `--cover-edge`. */ function useMarkTextOverCover() { - React.useEffect(() => { + React.useLayoutEffect(() => { const root = document.documentElement; const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]'); @@ -146,7 +150,7 @@ function useMarkTextOverCover() { }); }; - scheduleUpdate(); + update(); window.addEventListener('scroll', scheduleUpdate, { passive: true }); window.addEventListener('resize', scheduleUpdate, { passive: true }); diff --git a/packages/gitbook/src/components/SitePage/SitePage.test.ts b/packages/gitbook/src/components/SitePage/SitePage.test.ts new file mode 100644 index 000000000..9ceec6580 --- /dev/null +++ b/packages/gitbook/src/components/SitePage/SitePage.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; + +import type { RevisionPageDocument } from '@gitbook/api'; + +import { getPageFullTitle } from './title'; +import type { GitBookSiteContext } from '@/lib/context'; + +function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext { + return { + site: { title: siteTitle }, + visibleSections: sectionTitle + ? { + current: { title: sectionTitle, default: false }, + list: [{ object: 'site-section' }, { object: 'site-section' }], + } + : undefined, + visibleSiteSpaces: [], + } as unknown as GitBookSiteContext; +} + +function makePage(title: string, tagTitle?: string): RevisionPageDocument { + return { title, tagTitle } as unknown as RevisionPageDocument; +} + +describe('getPageFullTitle', () => { + it('uses tagTitle as the first segment', () => { + expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe( + 'SEO title | GitBook' + ); + }); + + it('falls back to the page title when tagTitle is absent', () => { + expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe( + 'Page title | GitBook' + ); + }); + + it('deduplicates the section title against tagTitle', () => { + expect( + getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section')) + ).toBe('Section | GitBook'); + }); + + it('deduplicates the site title against tagTitle', () => { + expect( + getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title')) + ).toBe('SEO title'); + }); +}); diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 18cca768a..e4fabd70e 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -4,16 +4,22 @@ import { notFound, redirect } from 'next/navigation'; import { CustomizationDefaultThemeMode, CustomizationHeaderPreset, - type RevisionPageDocument, SiteInsightsDisplayContext, type TranslationLanguage, } from '@gitbook/api'; import { IconsProvider } from '@gitbook/icons'; import { PageContextProvider } from '../PageContext'; -import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch'; +import { + type PagePathParams, + fetchPageData, + getLowercasePathnameRedirect, + getPathnameParam, +} from './fetch'; import { PageClientLayout } from './PageClientLayout'; +import { getPageFullTitle } from './title'; import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter'; +import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; import type { GitBookSiteContext } from '@/lib/context'; @@ -27,14 +33,15 @@ import { getResizedImageURL } from '@/lib/images'; import { getPagePath } from '@/lib/pages'; import { resolveContentRef } from '@/lib/references'; import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; -import { - getSiteSpacePagePaths, - getSiteStructureTitle, - resolveSiteSpaceCustomHomePage, -} from '@/lib/sites'; +import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; -import { getDocumentFilterableTags } from '@/lib/updates'; -import { getPageRSSURL } from '@/routes/rss'; +import { + generateUpdatesFilterCSS, + getDocumentFilterableTags, + updatesFilterStyleHref, +} from '@/lib/updates'; + +export { getPageFullTitle } from './title'; export type SitePageProps = { context: GitBookSiteContext; @@ -81,6 +88,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean }) } = await getSitePageData(props); const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader }; const filterableTags = document ? getDocumentFilterableTags(document, context.revision) : []; + const filterableTagSlugs = filterableTags.map((tag) => tag.slug); const content = ( <> {/* 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. @@ -134,8 +142,10 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean }) return ( <IconsProvider iconSources={iconSources}> <PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}> - {filterableTags.length > 0 ? ( - <UpdatesFilterProvider tagSlugs={filterableTags.map((tag) => tag.slug)}> + {filterableTagSlugs.length > 0 ? ( + <UpdatesFilterProvider tagSlugs={filterableTagSlugs}> + <UpdatesFilterScript tagSlugs={filterableTagSlugs} /> + <UpdatesFilterStyle tagSlugs={filterableTagSlugs} /> {content} </UpdatesFilterProvider> ) : ( @@ -146,6 +156,22 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean }) ); } +/** + * Stylesheet that resolves which `updates` entries the active `?tag=` filter shows, purely in CSS + * (see generateUpdatesFilterCSS). Byte-identical for every visitor, so it has no cache impact. + */ +function UpdatesFilterStyle({ tagSlugs }: { tagSlugs: string[] }) { + const css = generateUpdatesFilterCSS(tagSlugs); + if (!css) { + return null; + } + return ( + <style href={updatesFilterStyleHref(tagSlugs)} precedence="high"> + {css} + </style> + ); +} + export async function generateSitePageViewport(context: GitBookSiteContext): Promise<Viewport> { const { customization } = context; @@ -220,9 +246,6 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me languages: alternates?.languages, types: { 'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`, - // We always reference the RSS feed even if the page doesn't have updates blocks, - // It might result in 404, but we can't know here if the page has updates blocks. - 'application/rss+xml': [{ url: getPageRSSURL(context, page), title: 'RSS Feed' }], // Currently it will output with an empty "type" like <link rel="alternate" href="..." type /> // Team at Vercel is aware of this and will ensure it will be omitted when the value is empty in future versions of Next.js // https://gitbook.slack.com/archives/C04K6MV5W1K/p1763034072958419?thread_ts=1762937203.511629&cid=C04K6MV5W1K @@ -257,15 +280,15 @@ export async function getSitePageData(props: SitePageProps) { const rawPathname = getPathnameParam(props.pageParams); if (!pageTarget) { - const pathname = rawPathname.toLowerCase(); - if (pathname !== rawPathname) { + const pathname = getLowercasePathnameRedirect(rawPathname); + if (pathname !== null) { // If the pathname was not normalized, redirect to the normalized version // before trying to resolve the page again - redirect(context.linker.toPathInSpace(pathname)); + redirect(context.linker.toPathForPagePath({ path: pathname })); } else { // If the page is not found and we are in fallback mode, return a redirect to the basepath if (context.isFallback) { - redirect(context.linker.toPathInSpace('/')); + redirect(context.linker.toPathForPagePath({ path: '/' })); } notFound(); } @@ -396,20 +419,3 @@ async function resolvePageMetaLinks( alternates: [], }; } - -/** - * Get the <title> for a page. - */ -export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) { - const { site } = context; - const siteStructureTitle = getSiteStructureTitle(context); - - return [ - page.title, - // Prevent duplicate titles by comparing against the page title. - page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it. - page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page. - ] - .filter(Boolean) - .join(' | '); -} diff --git a/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx b/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx index 94e394fc6..a3facac05 100644 --- a/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx +++ b/packages/gitbook/src/components/SitePage/SitePageNotFound.tsx @@ -16,6 +16,7 @@ import { useSetSearchState } from '../Search'; import { fetchSiteIndex } from '../Search/site-index'; import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink'; import { + useIsLoggedInVisitor, useSiteAdaptiveAuthLoginHref, useSiteIndexURL, useSpaceBasePath, @@ -44,6 +45,9 @@ export function SitePageNotFound() { const setSearchState = useSetSearchState(); const { assistants } = useAI(); + const isAuthenticated = useIsLoggedInVisitor(); + const copy = getNotFoundCopy({ hasAdaptiveLogin: !!adaptiveAuthLoginHref, isAuthenticated }); + // Show the assistant input when a non-search assistant is available (i.e. not just ask-AI). const assistant = assistants.find((candidate) => candidate.mode !== 'search') ?? null; const inputLabel = tString(language, assistant ? 'search_or_ask' : 'search'); @@ -124,21 +128,20 @@ export function SitePageNotFound() { 'text-tint-strong' )} > - {t( - language, - adaptiveAuthLoginHref - ? 'notfound_adaptive_title' - : 'notfound_title' - )} + {t(language, copy.title)} </h1> - <p className={tcls('text-base', 'text-tint')}> - {t( - language, - adaptiveAuthLoginHref ? 'notfound_adaptive' : 'notfound' - )} + <p className={tcls('text-base', 'text-tint', 'text-center')}> + {t(language, copy.message)} </p> </div> - {adaptiveAuthLoginHref ? ( + {adaptiveAuthLoginHref && isAuthenticated ? ( + // Re-running login refreshes the visitor's claims, e.g. after a role change. + <SiteAuthLoginButton + href={adaptiveAuthLoginHref} + variant="secondary" + label={t(language, 'notfound_adaptive_login_again')} + /> + ) : adaptiveAuthLoginHref ? ( <div className="flex flex-col items-center gap-4"> <SiteAuthLoginButton href={adaptiveAuthLoginHref} @@ -190,6 +193,19 @@ export function SitePageNotFound() { ); } +function getNotFoundCopy(args: { hasAdaptiveLogin: boolean; isAuthenticated: boolean }) { + if (!args.hasAdaptiveLogin) { + return { title: 'notfound_title', message: 'notfound' } as const; + } + if (args.isAuthenticated) { + return { + title: 'notfound_adaptive_no_access_title', + message: 'notfound_adaptive_no_access', + } as const; + } + return { title: 'notfound_adaptive_title', message: 'notfound_adaptive' } as const; +} + /** * List of related pages, with skeleton rows while they load. Renders nothing once we know * there are no suggestions. diff --git a/packages/gitbook/src/components/SitePage/fetch.test.ts b/packages/gitbook/src/components/SitePage/fetch.test.ts new file mode 100644 index 000000000..8375f7174 --- /dev/null +++ b/packages/gitbook/src/components/SitePage/fetch.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, mock } from 'bun:test'; + +import { CustomizationPageActionType, type RevisionPageDocument } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; + +mock.module('server-only', () => ({})); + +const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } = + await import('./fetch'); +const { normalizeURL } = await import('@/lib/data/urls'); + +const page = { + id: 'page-1', + title: 'Introduction', + kind: 'sheet', + type: 'document', + path: 'readme', + slug: 'readme', + pages: [], +} as RevisionPageDocument; + +const git = { + oid: 'abc123', + path: 'README.md', +}; + +function createContext(options: { gitEnabled: boolean; gitSync?: boolean }) { + const getRevisionPageByPath = mock(async () => ({ + data: { + ...page, + git, + }, + })); + + const context = { + revision: { pages: [page] }, + revisionId: 'revision-1', + customization: { + pageActions: { + items: options.gitEnabled ? [CustomizationPageActionType.Git] : [], + }, + }, + space: { + id: 'space-1', + gitSync: + options.gitSync === false + ? undefined + : { + url: 'https://github.com/gitbook/example/tree/main', + }, + }, + dataFetcher: { getRevisionPageByPath }, + } as unknown as GitBookSiteContext; + + return { context, getRevisionPageByPath }; +} + +describe('fetchPageData', () => { + it('fetches the Git metadata for an enabled Edit on Git action', async () => { + const { context, getRevisionPageByPath } = createContext({ gitEnabled: true }); + + const result = await fetchPageData(context, { pageId: page.id }); + + expect(getRevisionPageByPath).toHaveBeenCalledWith({ + spaceId: 'space-1', + revisionId: 'revision-1', + path: 'readme', + metadata: true, + cachedMetadata: true, + }); + expect(result.pageTarget?.page.git).toEqual(git); + expect(result.context.page?.git).toEqual(git); + }); + + it('does not fetch Git metadata when the action is disabled', async () => { + const { context, getRevisionPageByPath } = createContext({ gitEnabled: false }); + + const result = await fetchPageData(context, { pageId: page.id }); + + expect(getRevisionPageByPath).not.toHaveBeenCalled(); + expect(result.pageTarget?.page.git).toBeUndefined(); + }); + + it('does not fetch Git metadata without Git Sync', async () => { + const { context, getRevisionPageByPath } = createContext({ + gitEnabled: true, + gitSync: false, + }); + + const result = await fetchPageData(context, { pageId: page.id }); + + expect(getRevisionPageByPath).not.toHaveBeenCalled(); + expect(result.pageTarget?.page.git).toBeUndefined(); + }); +}); + +describe('resolveMissingPagePath', () => { + function createRedirectContext(options: { + siteRedirect?: { source?: string; target: string; permanent?: boolean }; + spaceRedirectPageId?: string; + }) { + const getSiteRedirectBySource = mock(async ({ source }: { source: string }) => + options.siteRedirect && source === (options.siteRedirect.source ?? '/old') + ? { + data: { + target: options.siteRedirect.target, + redirect: { permanent: options.siteRedirect.permanent ?? false }, + }, + } + : { error: { code: 404, message: 'Not found' } } + ); + const getRevisionPageByPath = mock(async () => + options.spaceRedirectPageId + ? { data: { id: options.spaceRedirectPageId } } + : { error: { code: 404, message: 'Not found' } } + ); + + return { + organizationId: 'org-1', + site: { id: 'site-1' }, + space: { id: 'space-1', revision: 'revision-1' }, + revisionId: 'revision-1', + revision: { pages: [page] }, + linker: { + toPathInSpace: (path: string) => path, + toRelativePathInSite: (path: string) => path, + toLinkForContent: (url: string) => new URL(url).pathname, + }, + dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath }, + } as unknown as GitBookSiteContext; + } + + it('resolves a site redirect', async () => { + const context = createRedirectContext({ + siteRedirect: { target: 'https://docs.example.com/new', permanent: true }, + }); + + expect(await resolveMissingPagePath(context, 'old')).toEqual({ + type: 'redirect', + destination: '/new', + permanent: true, + }); + }); + + it('resolves a site redirect whose source has non-ASCII characters', async () => { + const context = createRedirectContext({ + siteRedirect: { + source: '/de/kategorien-hinzuf%C3%BCgen', + target: 'https://docs.example.com/new', + }, + }); + const pathname = normalizeURL( + new URL('https://docs.example.com/de/kategorien-hinzufügen') + ).pathname.slice(1); + + expect(await resolveMissingPagePath(context, pathname)).toEqual({ + type: 'redirect', + destination: '/new', + permanent: false, + }); + }); + + it('resolves a space redirect to a page', async () => { + const context = createRedirectContext({ spaceRedirectPageId: page.id }); + + expect(await resolveMissingPagePath(context, 'old')).toEqual({ + type: 'page', + page: { page, ancestors: [] }, + }); + }); + + it('returns undefined when nothing matches', async () => { + const context = createRedirectContext({}); + + expect(await resolveMissingPagePath(context, 'old')).toBeUndefined(); + }); +}); + +describe('getLowercasePathnameRedirect', () => { + it('redirects ASCII paths with uppercase letters', () => { + expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar'); + }); + + it('does not redirect lowercase paths', () => { + expect(getLowercasePathnameRedirect('foo/cafe')).toBeNull(); + }); + + it('does not redirect when only percent-encoded hex digits are uppercase', () => { + expect(getLowercasePathnameRedirect('foo/caf%C3%A9')).toBeNull(); + expect(getLowercasePathnameRedirect('foo/caf%c3%a9')).toBeNull(); + }); + + it('lowercases encoded non-ASCII letters', () => { + // É -> é + expect(getLowercasePathnameRedirect('foo/%C3%89')).toBe('foo/%C3%A9'); + }); + + it('does not redirect paths that fail to decode', () => { + expect(getLowercasePathnameRedirect('Foo/%E0%A4%A')).toBeNull(); + }); + + it('redirects to a pathname the middleware leaves unchanged', () => { + const normalize = (pathname: string) => + normalizeURL(new URL(`https://example.com/${pathname}`)).pathname.slice(1); + + const paths = [ + 'Foo/Bar', + 'Foo/caf%C3%A9', + 'foo/%C3%89', + 'Video/porte%C3%91o-x.html', + '%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82', // Привет + '%CE%95%CE%BB%CE%BB%CE%AC%CE%B4%CE%B1', // Ελλάδα + '%C3%96sterreich/Stra%C3%9FE', // Österreich/StraßE + '%C4%B0stanbul', // İstanbul, lowercases to two code points + 'Foo:Bar', + 'Foo@Bar+Baz', + 'Brack[et]', + 'Q%3FX', + 'Hash%23Y', + 'Sp%20Ace', + ]; + + for (const path of paths) { + const target = getLowercasePathnameRedirect(path); + expect(target).not.toBeNull(); + expect(normalize(target!)).toBe(target!); + expect(getLowercasePathnameRedirect(target!)).toBeNull(); + } + }); +}); diff --git a/packages/gitbook/src/components/SitePage/fetch.ts b/packages/gitbook/src/components/SitePage/fetch.ts index 9da07f868..c00d94039 100644 --- a/packages/gitbook/src/components/SitePage/fetch.ts +++ b/packages/gitbook/src/components/SitePage/fetch.ts @@ -1,13 +1,15 @@ import { permanentRedirect, redirect } from 'next/navigation'; import { + CustomizationPageActionType, + type RevisionPageDocument, SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH, SITE_REDIRECT_SOURCE_PATH_PATTERN, } from '@gitbook/api'; import type { GitBookSiteContext } from '@/lib/context'; import { getDataOrNull } from '@/lib/data'; -import { resolvePageId } from '@/lib/pages'; +import { type ResolvedPagePath, resolvePageId } from '@/lib/pages'; import { withLeadingSlash } from '@/lib/paths'; import { resolveSiteSpacePagePath } from '@/lib/sites'; @@ -26,7 +28,34 @@ export type PageParams = PagePathParams | PageIdParams; * Optimized to fetch in parallel as much as possible. */ export async function fetchPageData(context: GitBookSiteContext, params: PageParams) { - const pageTarget = await resolvePage(context, params); + let pageTarget = await resolvePage(context, params); + + // Revision trees omit metadata for cache efficiency, so load it only when this action needs the Git path. + if ( + pageTarget && + !pageTarget.page.git && + context.space.gitSync?.url && + context.customization.pageActions.items.includes(CustomizationPageActionType.Git) + ) { + const response = await context.dataFetcher.getRevisionPageByPath({ + spaceId: context.space.id, + revisionId: context.revisionId, + path: pageTarget.page.path, + metadata: true, + cachedMetadata: true, + }); + const pageWithMetadata = response.data; + + if (pageWithMetadata?.type === 'document' && pageWithMetadata.git) { + pageTarget = { + ...pageTarget, + page: { + ...pageTarget.page, + git: pageWithMetadata.git, + }, + }; + } + } return { context: { @@ -42,7 +71,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar * If the path can't be found, we try to resolve it from the API to handle redirects. */ async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) { - const { organizationId, site, space, revision, shareKey, linker, revisionId } = context; + const { revision } = context; if ('pageId' in params) { return resolvePageId(revision.pages, params.pageId); @@ -57,77 +86,108 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams | return page; } + const fallback = await resolveMissingPagePath(context, rawPathname); + if (fallback?.type === 'redirect') { + return fallback.permanent + ? permanentRedirect(fallback.destination) + : redirect(fallback.destination); + } + + return fallback?.page; +} + +export type MissingPagePathResolution = + | { + type: 'redirect'; + /** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */ + destination: string; + permanent: boolean; + } + | { + type: 'page'; + page: ResolvedPagePath<RevisionPageDocument>; + }; + +/** + * Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects. + */ +export async function resolveMissingPagePath( + context: GitBookSiteContext, + rawPathname: string +): Promise<MissingPagePathResolution | undefined> { + const { organizationId, site, space, revision, shareKey, linker, revisionId } = context; + // We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway. // API has a limit of less than 512 characters for the source path, so we use the same limit here. - if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) { - const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN); - const redirectPathname = withLeadingSlash(rawPathname); - // If a page can't be found, we try with the API, in case we have a redirect at site level. - const redirectSources = new Set( - [ - // Test the pathname relative to the root - // For example hello/world -> section/variant/hello/world - linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)), - // Test the pathname relative to the content/space - // For example hello/world -> /hello/world - redirectPathname, - ] - .map(toSiteRedirectSourceCandidate) - .filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source)) - ); + if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) { + return undefined; + } - for (const source of redirectSources) { - // We try to resolve the site redirect - const resolvedSiteRedirect = - source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH && - (await getDataOrNull( - context.dataFetcher.getSiteRedirectBySource({ - organizationId, - siteId: site.id, - source, - siteShareKey: shareKey, - }) - )); - if (resolvedSiteRedirect) { - const destination = linker.toLinkForContent(resolvedSiteRedirect.target); - const isPublicLiveContext = - !shareKey && - !context.changeRequest && - !context.preview && - context.revisionId === context.space.revision && - !context.isLoggedInVisitor; - if ( + const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN); + const redirectPathname = withLeadingSlash(rawPathname); + // If a page can't be found, we try with the API, in case we have a redirect at site level. + const redirectSources = new Set( + [ + // Test the pathname relative to the root + // For example hello/world -> section/variant/hello/world + linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)), + // Test the pathname relative to the content/space + // For example hello/world -> /hello/world + redirectPathname, + ] + .map(toSiteRedirectSourceCandidate) + .filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source)) + ); + + for (const source of redirectSources) { + // We try to resolve the site redirect + const resolvedSiteRedirect = + source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH && + (await getDataOrNull( + context.dataFetcher.getSiteRedirectBySource({ + organizationId, + siteId: site.id, + source, + siteShareKey: shareKey, + }) + )); + if (resolvedSiteRedirect) { + const isPublicLiveContext = + !shareKey && + !context.changeRequest && + !context.preview && + context.revisionId === context.space.revision && + !context.isLoggedInVisitor; + return { + type: 'redirect', + destination: linker.toLinkForContent(resolvedSiteRedirect.target), + permanent: Boolean( resolvedSiteRedirect.redirect?.permanent && !resolvedSiteRedirect.redirect.draft && isPublicLiveContext - ) { - return permanentRedirect(destination); - } - return redirect(destination); - } - } - - // If page still can't be found, we try with the API, in case we have a redirect at space level. - // We use the raw pathname to handle special/malformed redirects setup by users in the GitSync. - // The page rendering will take care of redirecting to a normalized pathname. - const resolved = await getDataOrNull( - context.dataFetcher.getRevisionPageByPath({ - spaceId: space.id, - revisionId: revisionId, - path: rawPathname, - }) - ); - if (resolved) { - return resolvePageId(revision.pages, resolved.id); + ), + }; } } - return undefined; + // If page still can't be found, we try with the API, in case we have a redirect at space level. + // We use the raw pathname to handle special/malformed redirects setup by users in the GitSync. + // The page rendering will take care of redirecting to a normalized pathname. + const resolved = await getDataOrNull( + context.dataFetcher.getRevisionPageByPath({ + spaceId: space.id, + revisionId: revisionId, + path: rawPathname, + }) + ); + const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined; + return page ? { type: 'page', page } : undefined; } /** * Transform a pathname into a candidate source for site redirect matching. - * We also encode each segment to handle special characters in redirects. + * The pathname is already percent-encoded, so each segment is decoded before being encoded + * again: redirect sources store special characters percent-encoded exactly once. */ function toSiteRedirectSourceCandidate(pathname: string): string { const normalized = withLeadingSlash(pathname); @@ -135,11 +195,19 @@ function toSiteRedirectSourceCandidate(pathname: string): string { normalized .slice(1) .split('/') - .map((segment) => encodeURIComponent(segment)) + .map((segment) => encodeURIComponent(safeDecodeURIComponent(segment))) .join('/') ); } +function safeDecodeURIComponent(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + /** * Get the page path from the params. */ @@ -156,3 +224,41 @@ export function getPathnameParam(params: PagePathParams): string { return pathname.map((part) => decodeURIComponent(part)).join('/'); } + +/** + * Get the lowercased pathname to redirect a missing page to, or `null` if there is none. + * The pathname is percent-encoded, so lowercase its decoded form and re-encode it canonically: + * any other encoding would make the middleware redirect again, or loop. + */ +export function getLowercasePathnameRedirect(rawPathname: string): string | null { + let changed = false; + const segments: string[] = []; + + for (const segment of rawPathname.split('/')) { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return null; + } + + const lowercased = decoded.toLowerCase(); + changed ||= lowercased !== decoded; + segments.push(lowercased); + } + + if (!changed) { + return null; + } + + return encodeURLPathname(segments.join('/')).slice(1); +} + +/** + * Percent-encode a decoded pathname the way the URL parser does, the canonical form `normalizeURL` produces. + */ +function encodeURLPathname(pathname: string): string { + const url = new URL('https://gitbook.invalid'); + url.pathname = pathname; + return url.pathname; +} diff --git a/packages/gitbook/src/components/SitePage/title.ts b/packages/gitbook/src/components/SitePage/title.ts new file mode 100644 index 000000000..3a4626646 --- /dev/null +++ b/packages/gitbook/src/components/SitePage/title.ts @@ -0,0 +1,23 @@ +import type { RevisionPageDocument } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; +import { getSiteStructureTitle } from '@/lib/sites'; + +/** + * Get the <title> for a page. + */ +export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) { + const { site } = context; + const siteStructureTitle = getSiteStructureTitle(context); + const tagTitle = page.tagTitle || page.title; + + return [ + tagTitle, + // The first page of a section is often the same as the section title, so we don't need to show it. + tagTitle !== siteStructureTitle ? siteStructureTitle : null, + // The site title can also be the same as the page title on the site's landing page. + tagTitle !== site.title ? site.title : null, + ] + .filter(Boolean) + .join(' | '); +} diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index 8ee71f269..04150c635 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -201,7 +201,7 @@ function getTargetURLForSection( return getSectionURL(context, section); } -function getTargetURLForSiteSpace( +export function getTargetURLForSiteSpace( context: GitBookSiteContext, siteSpace: SiteSpace, asEmbeddable: boolean diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index df6b60f32..bcfb72aec 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -5,7 +5,7 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { AIChatProvider } from '../AI'; import type { RenderAIMessageOptions } from '../AI'; -import { AIChat, AskAITextSelection } from '../AIChat'; +import { AIChat, AskAIParagraphButton, AskAITextSelection } from '../AIChat'; import { Announcement } from '../Announcement'; import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown'; import { CurrentContentProvider } from '../hooks'; @@ -58,20 +58,21 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) { ? context.linker.toPathInSite('~gitbook/auth/login') : null; - const eventUrl = new URL( - context.linker.toAbsoluteURL(context.linker.toPathInSite('/~gitbook/__evt')) - ); - eventUrl.searchParams.set('o', context.organizationId); - eventUrl.searchParams.set('s', context.site.id); + // Kept relative: a prerendered page has no request to read the host from, so an absolute URL + // pins the configured host and turns these fetches cross-origin when it differs (apex vs www). + const eventParams = new URLSearchParams({ + o: context.organizationId, + s: context.site.id, + }); + const eventUrl = `${context.linker.toPathInSite('/~gitbook/__evt')}?${eventParams}`; - const getVisitorClaimsUrl = context.linker.toAbsoluteURL( - context.linker.toPathInSite('/~gitbook/visitor') - ); + const getVisitorClaimsUrl = context.linker.toPathInSite('/~gitbook/visitor'); return ( <SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')} siteAdaptiveAuthLoginHref={siteAdaptiveAuthLoginHref} + isLoggedInVisitor={context.isLoggedInVisitor} siteIndexURL={context.linker.toPathInSite('~gitbook/site-index')} > <AdaptiveVisitorContextProvider @@ -92,7 +93,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) { appURL={GITBOOK_APP_URL} visitorCookieTrackingEnabled={customization.insights?.trackingCookie} > - <InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}> + <InsightsProvider enabled={withTracking} eventUrl={eventUrl}> <AIChatProvider renderMessageOptions={aiChatRenderMessageOptions} withPageFeedback={customization.feedback.enabled} @@ -137,6 +138,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { <> <AIChat /> <AskAITextSelection /> + <AskAIParagraphButton /> </> ) : null} diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayoutContext.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayoutContext.tsx index c26f93d02..25c140ef9 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayoutContext.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayoutContext.tsx @@ -5,6 +5,7 @@ import React from 'react'; const SpaceLayoutContext = React.createContext({ basePath: '', siteAdaptiveAuthLoginHref: null as string | null, + isLoggedInVisitor: false, siteIndexURL: '', }); @@ -15,14 +16,21 @@ export function SpaceLayoutContextProvider( props: React.PropsWithChildren<{ basePath: string; siteAdaptiveAuthLoginHref?: string | null; + isLoggedInVisitor?: boolean; siteIndexURL: string; }> ) { - const { basePath, siteAdaptiveAuthLoginHref = null, siteIndexURL, children } = props; + const { + basePath, + siteAdaptiveAuthLoginHref = null, + isLoggedInVisitor = false, + siteIndexURL, + children, + } = props; const value = React.useMemo( - () => ({ basePath, siteAdaptiveAuthLoginHref, siteIndexURL }), - [basePath, siteAdaptiveAuthLoginHref, siteIndexURL] + () => ({ basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL }), + [basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL] ); return <SpaceLayoutContext.Provider value={value}>{children}</SpaceLayoutContext.Provider>; @@ -50,6 +58,17 @@ export function useSiteAdaptiveAuthLoginHref() { return context.siteAdaptiveAuthLoginHref; } +/** + * Return whether the visitor has a visitor auth token, with or without claims. + */ +export function useIsLoggedInVisitor() { + const context = React.useContext(SpaceLayoutContext); + if (!context) { + throw new Error('SpaceLayoutContext not found'); + } + return context.isLoggedInVisitor; +} + /** * Return the URL of the site search index (`~gitbook/site-index`). */ diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index 4acbbcd4e..ca42c11f0 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -29,7 +29,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole <div ref={sentinelRef} className="h-0" aria-hidden="true" /> <div className={tcls( - '-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity', + // No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls. + '-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent', isSticking ? '' : 'after:opacity-0', 'mt-1 pt-2.5 pb-0', 'bg-tint-base', @@ -71,7 +72,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole {hasDescendants ? ( <span className={tcls( - 'toc-group-chevron ml-auto flex shrink-0 transition-opacity duration-150', + 'toc-group-chevron ml-auto mr-1 flex shrink-0 transition-opacity duration-150', isOpen ? 'pointer-events-none opacity-0 delay-75' : 'opacity-6 delay-0' diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 2d7736242..a83339e21 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -134,6 +134,7 @@ export async function TableOfContents(props: { orientation="vertical" contentClassName="flex flex-col p-2 gutter-stable" active="[data-active=true]" + followActive leading={{ fade: true, button: { diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index 93dd99b5b..4946b320e 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -165,7 +165,7 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () = iconOnly variant="blank" aria-hidden="true" // The button has no label or focus so hiding it from screen readers. - className="-my-0.5 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base" + className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base" tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action. /> ); diff --git a/packages/gitbook/src/components/TableOfContents/Trademark.tsx b/packages/gitbook/src/components/TableOfContents/Trademark.tsx index 4abbf03a3..f29842d20 100644 --- a/packages/gitbook/src/components/TableOfContents/Trademark.tsx +++ b/packages/gitbook/src/components/TableOfContents/Trademark.tsx @@ -19,7 +19,7 @@ export async function Trademark( const { space } = context; const language = await getSpaceLanguage(context); - const url = new URL('https://www.gitbook.com'); + const url = new URL('https://www.gitbook.com/powered-by'); url.searchParams.set('utm_source', 'content'); url.searchParams.set('utm_medium', 'trademark'); url.searchParams.set('utm_campaign', space.id); diff --git a/packages/gitbook/src/components/WebMCP/WebMCP.tsx b/packages/gitbook/src/components/WebMCP/WebMCP.tsx new file mode 100644 index 000000000..e9cae97ab --- /dev/null +++ b/packages/gitbook/src/components/WebMCP/WebMCP.tsx @@ -0,0 +1,81 @@ +'use client'; + +import * as React from 'react'; + +// Subset of the WebMCP `ModelContext` interface (https://webmachinelearning.github.io/webmcp/). +type ModelContext = { + registerTool: ( + tool: { + name: string; + description: string; + inputSchema?: object; + execute: (input: object, options?: { signal?: AbortSignal }) => Promise<unknown>; + }, + options?: { signal?: AbortSignal } + ) => Promise<void>; +}; + +/** + * Expose the site's MCP tools (`~gitbook/mcp`) to browser agents through WebMCP, so anything + * added to the server is automatically available to them. Renders nothing. + */ +export function WebMCP(props: { mcpURL: string }) { + const { mcpURL } = props; + + React.useEffect(() => { + const modelContext = (document as { modelContext?: ModelContext }).modelContext; + if (!modelContext) { + return; + } + + // Aborting unregisters the tools and discards a load still in flight. + const controller = new AbortController(); + const { signal } = controller; + + (async () => { + // The MCP SDK is imported lazily: only agentic browsers pay for it. + const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([ + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + ]); + // Tagged so WebMCP calls are distinguishable in insights (the request URL is tracked). + const url = new URL(mcpURL, window.location.href); + url.searchParams.set('client', 'webmcp'); + const client = new Client({ name: 'gitbook-webmcp', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(url)); + const { tools } = await client.listTools(); + if (signal.aborted) { + return; + } + + for (const tool of tools) { + // Answer synthesis takes 20-30s and browser agents abort tool calls around 30s. + if (tool.name === 'askQuestion') { + continue; + } + await modelContext.registerTool( + { + name: tool.name, + description: tool.description ?? tool.name, + inputSchema: tool.inputSchema, + // The MCP result (`content` blocks, plus `isError` on failure) is passed through. + execute: (input, options) => + client.callTool( + { name: tool.name, arguments: input as Record<string, unknown> }, + undefined, + { signal: options?.signal } + ), + }, + { signal } + ); + } + })().catch((error) => { + // oxlint-disable-next-line no-console + console.warn('WebMCP: could not expose the site MCP tools', error); + }); + + return () => controller.abort(); + }, [mcpURL]); + + return null; +} diff --git a/packages/gitbook/src/components/hooks/useScrollActiveId.ts b/packages/gitbook/src/components/hooks/useScrollActiveId.ts index 30c0a03f5..e94a17873 100644 --- a/packages/gitbook/src/components/hooks/useScrollActiveId.ts +++ b/packages/gitbook/src/components/hooks/useScrollActiveId.ts @@ -15,11 +15,12 @@ export function useScrollActiveId( enabled: boolean; } = { enabled: true } ): string | undefined { - const [activeId, setActiveId] = React.useState<string | undefined>(ids[0]); + const [activeId, setActiveId] = React.useState<string | undefined>(undefined); const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map()); React.useEffect(() => { - const defaultActiveId = ids[0]; + const defaultActiveId = + ids.find((id) => document.getElementById(id)?.offsetParent !== null) ?? ids[0]; sectionsIntersectingMap.current.clear(); setActiveId((activeId) => activeId !== undefined && ids.includes(activeId) ? activeId : defaultActiveId diff --git a/packages/gitbook/src/components/primitives/DownloadButton.tsx b/packages/gitbook/src/components/primitives/DownloadButton.tsx index 205d0bcf6..e949659d5 100644 --- a/packages/gitbook/src/components/primitives/DownloadButton.tsx +++ b/packages/gitbook/src/components/primitives/DownloadButton.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useTrackEvent } from '../Insights'; import { Button, type ButtonProps } from './Button'; /** @@ -7,13 +8,17 @@ import { Button, type ButtonProps } from './Button'; export function DownloadButton( props: Omit<ButtonProps, 'onClick' | 'href'> & { downloadUrl: string; filename: string } ) { - const { downloadUrl, filename, ...buttonProps } = props; + const { downloadUrl, filename, insights, ...buttonProps } = props; + const trackEvent = useTrackEvent(); return ( <Button {...buttonProps} onClick={(e) => { e.preventDefault(); + if (insights) { + trackEvent(insights); + } void forceDownload(downloadUrl, filename); }} /> diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.tsx b/packages/gitbook/src/components/primitives/ScrollContainer.tsx index 4b9475ca9..408ba78be 100644 --- a/packages/gitbook/src/components/primitives/ScrollContainer.tsx +++ b/packages/gitbook/src/components/primitives/ScrollContainer.tsx @@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind'; * A container that encapsulates a scrollable area with usability features. * - Faded edges when there is more content than the container can display. * - Buttons to advance the scroll position. - * - Auto-scroll to the active item when it's initially active. + * - Auto-scroll to the active item on mount and when it changes. */ export type ScrollContainerProps = { children: React.ReactNode; @@ -42,6 +42,12 @@ export type ScrollContainerProps = { /** The ID or ref of the active item to scroll to. */ active?: string | React.RefObject<HTMLElement | null>; + /** + * Only scroll to the active item when it is not fully visible, and keep following it + * when it changes later (requires `active` to be a selector). + */ + followActive?: boolean; + /** Scroll by one page of fully visible direct children instead of one viewport. */ scrollByVisibleItems?: boolean; } & React.HTMLAttributes<HTMLDivElement>; @@ -53,6 +59,7 @@ export function ScrollContainer(props: ScrollContainerProps) { contentClassName, orientation, active, + followActive = false, scrollByVisibleItems = false, leading = { fade: true, button: true }, trailing = { fade: true, button: true }, @@ -80,8 +87,52 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!activeItem || !container.contains(activeItem)) { return; } + if (followActive && isElementFullyVisibleInContainer(activeItem, container)) { + return; + } scrollToElementInContainer(activeItem, container); - }, [active]); + }, [active, followActive]); + + React.useEffect(() => { + const container = containerRef.current; + if (!followActive || !container || typeof active !== 'string') { + return; + } + + let frame = 0; + let previousActiveItems = new Set(container.querySelectorAll(active)); + // Active items can mount only after a collapsed group expands. + const observer = new MutationObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const activeItems = new Set(container.querySelectorAll(active)); + const newActiveItems = [...activeItems].filter( + (item) => !previousActiveItems.has(item) + ); + previousActiveItems = activeItems; + // Only follow newly active items so unrelated changes (e.g. expanding another + // group) don't pull the user back to the current page. + for (const activeItem of newActiveItems) { + if (!isElementFullyVisibleInContainer(activeItem, container)) { + scrollToElementInContainer(activeItem, container, 'smooth'); + return; + } + } + }); + }); + + observer.observe(container, { + attributes: true, + attributeFilter: ['data-active'], + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [active, followActive]); const scrollFurther = () => { const container = containerRef.current; @@ -335,7 +386,11 @@ function scrollByViewport( /** * Scroll to an element in a container. */ -function scrollToElementInContainer(element: Element, container: HTMLElement) { +export function scrollToElementInContainer( + element: Element, + container: HTMLElement, + behavior: ScrollBehavior = 'auto' +) { const containerRect = container.getBoundingClientRect(); const rect = element.getBoundingClientRect(); @@ -350,8 +405,26 @@ function scrollToElementInContainer(element: Element, container: HTMLElement) { (rect.left - containerRect.left) - container.clientWidth / 2 + rect.width / 2, - // Use 'auto' to avoid additional scroll animations when scrolling to an element - // as this may be called during layout/initialization when the page is not fully loaded. - behavior: 'auto', + behavior, }); } + +function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) { + if ( + !element.getClientRects().length || + container.clientHeight === 0 || + container.clientWidth === 0 + ) { + return true; + } + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + return ( + elementRect.top >= containerRect.top && + elementRect.bottom <= containerRect.bottom && + elementRect.left >= containerRect.left && + elementRect.right <= containerRect.right + ); +} diff --git a/packages/gitbook/src/components/utils/getURLForLLM.ts b/packages/gitbook/src/components/utils/getURLForLLM.ts index 0eb72479b..0fd4c9b8f 100644 --- a/packages/gitbook/src/components/utils/getURLForLLM.ts +++ b/packages/gitbook/src/components/utils/getURLForLLM.ts @@ -3,15 +3,13 @@ import assertNever from 'assert-never'; /** * Returns the URL to open the page in a LLM with a pre-filled prompt. */ -export function getURLForLLM(provider: 'chatgpt' | 'claude' | 'cursor', prompt: string) { +export function getURLForLLM(provider: 'chatgpt' | 'claude', prompt: string) { const encodedPrompt = encodeURIComponent(prompt); switch (provider) { case 'chatgpt': return `https://chat.openai.com/?q=${encodedPrompt}`; case 'claude': return `https://claude.ai/new?q=${encodedPrompt}`; - case 'cursor': - return `https://cursor.com/link/prompt?text=${encodedPrompt}`; default: assertNever(provider); } diff --git a/packages/gitbook/src/intl/translations/ar.ts b/packages/gitbook/src/intl/translations/ar.ts index 6ae2a353c..28f41bd97 100644 --- a/packages/gitbook/src/intl/translations/ar.ts +++ b/packages/gitbook/src/intl/translations/ar.ts @@ -11,6 +11,8 @@ export const ar: TranslationLanguage = { switch_to_system_theme: 'التبديل إلى سمة النظام', search: 'بحث', clear: 'مسح', + table_filtered_by_selection: 'يطابق الاختيار: ${1}', + table_clear_selection: 'مسح الاختيار', tags: 'الوسوم', search_back: 'العودة إلى نتائج البحث', search_or_ask: 'اسأل أو ابحث', @@ -75,10 +77,14 @@ export const ar: TranslationLanguage = { notfound_title: 'الصفحة غير موجودة', notfound_link: 'يشير هذا الرابط إلى صفحة تمت إزالتها أو لم تعد موجودة.', notfound: 'الصفحة التي تبحث عنها غير موجودة.', - notfound_adaptive_title: 'الصفحة غير متاحة', - notfound_adaptive: 'قد تكون هذه الصفحة موجودة، لكن قد تحتاج إلى تسجيل الدخول للوصول إليها.', + notfound_adaptive_title: 'سجّل الدخول لعرض هذه الصفحة', + notfound_adaptive: 'قد تتطلب هذه الصفحة تسجيل الدخول. إذا اتبعت رابطًا، فتحقق من صحته.', notfound_adaptive_login: 'تسجيل الدخول', notfound_adaptive_registration_hint: 'قد يكون التسجيل متاحًا حسب المؤسسة.', + notfound_adaptive_no_access_title: 'قد لا تملك صلاحية الوصول إلى هذه الصفحة', + notfound_adaptive_no_access: + 'قد لا تكون هذه الصفحة موجودة، أو قد لا يملك حسابك صلاحية الوصول إليها. إذا اتبعت رابطًا، فتحقق من صحته.', + notfound_adaptive_login_again: 'تسجيل الدخول مجددًا', notfound_goto_home: 'الانتقال إلى الصفحة الرئيسية', notfound_suggestions_title: 'ربما تبحث عن', unexpected_error_title: 'حدث خطأ', @@ -148,6 +154,7 @@ export const ar: TranslationLanguage = { ai_chat_tools_submit_feedback: 'إرسال الملاحظات', ai_chat_tools_submit_assistant_feedback: 'قيّم رسالة المساعد السابقة بأنها ${1}', ai_chat_tools_submitted_feedback: 'تم إرسال ملاحظاتك', + ai_chat_tools_shared_feedback_with_team: 'تم إرسال الملاحظات إلى فريق التوثيق', ai_chat_tools_mcp_tool: 'تم استدعاء ${1}', ai_chat_ask: 'اسأل ${1}', ai_chat_ask_about: 'اسأل ${1} عن ${2}', diff --git a/packages/gitbook/src/intl/translations/bg.ts b/packages/gitbook/src/intl/translations/bg.ts index a8b702ad6..d93adee1b 100644 --- a/packages/gitbook/src/intl/translations/bg.ts +++ b/packages/gitbook/src/intl/translations/bg.ts @@ -11,6 +11,8 @@ export const bg: TranslationLanguage = { switch_to_system_theme: 'Превключване към системната тема', search: 'Търсене', clear: 'Изчистване', + table_filtered_by_selection: 'Съответства на избора: ${1}', + table_clear_selection: 'Изчисти избора', tags: 'Етикети', search_back: 'Назад към резултатите от търсенето', search_or_ask: 'Попитайте или търсете', @@ -76,12 +78,16 @@ export const bg: TranslationLanguage = { notfound_title: 'Страницата не е намерена', notfound_link: 'Тази връзка сочи към страница, която е премахната или вече не съществува.', notfound: 'Страницата, която търсите, не съществува.', - notfound_adaptive_title: 'Страницата не е налична', + notfound_adaptive_title: 'Влезте, за да видите тази страница', notfound_adaptive: - 'Тази страница може да съществува, но може да трябва да влезете, за да я достъпите.', + 'Тази страница може да изисква вход. Ако сте последвали връзка, проверете дали е правилна.', notfound_adaptive_login: 'Вход', notfound_adaptive_registration_hint: 'Регистрацията може да е възможна в зависимост от организацията.', + notfound_adaptive_no_access_title: 'Възможно е да нямате достъп до тази страница', + notfound_adaptive_no_access: + 'Тази страница може да не съществува или профилът ви може да няма достъп до нея. Ако сте последвали връзка, проверете дали е правилна.', + notfound_adaptive_login_again: 'Повторен вход', notfound_goto_home: 'Към началната страница', notfound_suggestions_title: 'Може би търсите', unexpected_error_title: 'Възникна грешка', @@ -152,6 +158,8 @@ export const bg: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Изпращане на обратна връзка', ai_chat_tools_submit_assistant_feedback: 'Оценете предишното съобщение на асистента като ${1}', ai_chat_tools_submitted_feedback: 'Обратната връзка е изпратена', + ai_chat_tools_shared_feedback_with_team: + 'Обратната връзка е изпратена на екипа по документацията', ai_chat_tools_mcp_tool: 'Извика ${1}', ai_chat_ask: 'Попитайте ${1}', ai_chat_ask_about: 'Попитайте ${1} за ${2}', diff --git a/packages/gitbook/src/intl/translations/cs.ts b/packages/gitbook/src/intl/translations/cs.ts index 487aa4ac6..19afb2bde 100644 --- a/packages/gitbook/src/intl/translations/cs.ts +++ b/packages/gitbook/src/intl/translations/cs.ts @@ -11,6 +11,8 @@ export const cs: TranslationLanguage = { switch_to_system_theme: 'Přepnout na systémový motiv', search: 'Hledat', clear: 'Vymazat', + table_filtered_by_selection: 'Odpovídá výběru: ${1}', + table_clear_selection: 'Zrušit výběr', tags: 'Štítky', search_back: 'Zpět na výsledky hledání', search_or_ask: 'Zeptat se nebo hledat', @@ -75,11 +77,15 @@ export const cs: TranslationLanguage = { notfound_title: 'Stránka nenalezena', notfound_link: 'Tento odkaz vede na stránku, která byla odstraněna nebo již neexistuje.', notfound: 'Stránka, kterou hledáte, neexistuje.', - notfound_adaptive_title: 'Stránka není dostupná', + notfound_adaptive_title: 'Pro zobrazení této stránky se přihlaste', notfound_adaptive: - 'Tato stránka může existovat, ale pro přístup se možná budete muset přihlásit.', + 'Tato stránka může vyžadovat přihlášení. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.', notfound_adaptive_login: 'Přihlásit se', notfound_adaptive_registration_hint: 'Registrace může být dostupná v závislosti na organizaci.', + notfound_adaptive_no_access_title: 'K této stránce možná nemáte přístup', + notfound_adaptive_no_access: + 'Tato stránka možná neexistuje nebo k ní váš účet nemá přístup. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.', + notfound_adaptive_login_again: 'Přihlásit se znovu', notfound_goto_home: 'Přejít na domovskou stránku', notfound_suggestions_title: 'Možná hledáte', unexpected_error_title: 'Došlo k chybě', @@ -150,6 +156,7 @@ export const cs: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Odeslat zpětnou vazbu', ai_chat_tools_submit_assistant_feedback: 'Ohodnotit předchozí zprávu asistenta jako ${1}', ai_chat_tools_submitted_feedback: 'Zpětná vazba odeslána', + ai_chat_tools_shared_feedback_with_team: 'Zpětná vazba odeslána týmu dokumentace', ai_chat_tools_mcp_tool: 'Zavolal ${1}', ai_chat_ask: 'Zeptat se ${1}', ai_chat_ask_about: 'Zeptat se ${1} na ${2}', diff --git a/packages/gitbook/src/intl/translations/da.ts b/packages/gitbook/src/intl/translations/da.ts index 521aa4f93..c4a187880 100644 --- a/packages/gitbook/src/intl/translations/da.ts +++ b/packages/gitbook/src/intl/translations/da.ts @@ -11,6 +11,8 @@ export const da: TranslationLanguage = { switch_to_system_theme: 'Skift til systemtema', search: 'Søg', clear: 'Ryd', + table_filtered_by_selection: 'Matcher valg: ${1}', + table_clear_selection: 'Ryd valg', tags: 'Tags', search_back: 'Tilbage til søgeresultater', search_or_ask: 'Spørg eller søg', @@ -75,11 +77,15 @@ export const da: TranslationLanguage = { notfound_title: 'Siden blev ikke fundet', notfound_link: 'Dette link peger på en side, der er blevet fjernet eller ikke længere findes.', notfound: 'Siden, du leder efter, findes ikke.', - notfound_adaptive_title: 'Siden er ikke tilgængelig', + notfound_adaptive_title: 'Log ind for at se denne side', notfound_adaptive: - 'Denne side findes muligvis, men du skal muligvis logge ind for at få adgang.', + 'Denne side kræver muligvis, at du logger ind. Hvis du har fulgt et link, så tjek, at det er korrekt.', notfound_adaptive_login: 'Log ind', notfound_adaptive_registration_hint: 'Registrering kan være mulig afhængigt af organisationen.', + notfound_adaptive_no_access_title: 'Du har muligvis ikke adgang til denne side', + notfound_adaptive_no_access: + 'Denne side findes muligvis ikke, eller din konto har muligvis ikke adgang til den. Hvis du har fulgt et link, så tjek, at det er korrekt.', + notfound_adaptive_login_again: 'Log ind igen', notfound_goto_home: 'Gå til forsiden', notfound_suggestions_title: 'Du leder måske efter', unexpected_error_title: 'Der opstod en fejl', @@ -149,6 +155,7 @@ export const da: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Send feedback', ai_chat_tools_submit_assistant_feedback: 'Bedøm assistentens forrige besked som ${1}', ai_chat_tools_submitted_feedback: 'Feedback sendt', + ai_chat_tools_shared_feedback_with_team: 'Feedback sendt til dokumentationsteamet', ai_chat_tools_mcp_tool: 'Kaldte ${1}', ai_chat_ask: 'Spørg ${1}', ai_chat_ask_about: 'Spørg ${1} om ${2}', diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 1b5d930e0..a3a1be952 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -11,6 +11,8 @@ export const de: TranslationLanguage = { switch_to_system_theme: 'Zum Systemmodus wechseln', search: 'Suche', clear: 'Löschen', + table_filtered_by_selection: 'Entspricht Auswahl: ${1}', + table_clear_selection: 'Auswahl zurücksetzen', tags: 'Tags', search_back: 'Zurück zu den Suchergebnissen', search_or_ask: 'Fragen oder Suchen', @@ -78,12 +80,16 @@ export const de: TranslationLanguage = { notfound_link: 'Dieser Link verweist auf eine Seite, die entfernt wurde oder nicht mehr existiert.', notfound: 'Die gesuchte Seite existiert nicht.', - notfound_adaptive_title: 'Seite nicht verfügbar', + notfound_adaptive_title: 'Melden Sie sich an, um diese Seite anzuzeigen', notfound_adaptive: - 'Diese Seite existiert möglicherweise, aber Sie müssen sich eventuell anmelden, um darauf zuzugreifen.', + 'Für diese Seite ist möglicherweise eine Anmeldung erforderlich. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.', notfound_adaptive_login: 'Anmelden', notfound_adaptive_registration_hint: 'Die Registrierung ist je nach Organisation möglicherweise verfügbar.', + notfound_adaptive_no_access_title: 'Sie haben möglicherweise keinen Zugriff auf diese Seite', + notfound_adaptive_no_access: + 'Diese Seite existiert möglicherweise nicht, oder Ihr Konto hat keinen Zugriff darauf. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.', + notfound_adaptive_login_again: 'Erneut anmelden', notfound_goto_home: 'Zur Startseite', notfound_suggestions_title: 'Vielleicht suchen Sie nach', unexpected_error_title: 'Ein Fehler ist aufgetreten', @@ -156,6 +162,7 @@ export const de: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Feedback senden', ai_chat_tools_submit_assistant_feedback: 'Vorherige Assistenten-Nachricht als ${1} bewerten', ai_chat_tools_submitted_feedback: 'Feedback gesendet', + ai_chat_tools_shared_feedback_with_team: 'Feedback an das Dokumentationsteam gesendet', ai_chat_tools_mcp_tool: '${1} aufgerufen', ai_chat_ask: '${1} fragen', ai_chat_ask_about: '${1} zu ${2} befragen', diff --git a/packages/gitbook/src/intl/translations/el.ts b/packages/gitbook/src/intl/translations/el.ts index 9843fcc2d..05ddd0955 100644 --- a/packages/gitbook/src/intl/translations/el.ts +++ b/packages/gitbook/src/intl/translations/el.ts @@ -11,6 +11,8 @@ export const el: TranslationLanguage = { switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος', search: 'Αναζήτηση', clear: 'Εκκαθάριση', + table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}', + table_clear_selection: 'Εκκαθάριση επιλογής', tags: 'Ετικέτες', search_back: 'Επιστροφή στα αποτελέσματα αναζήτησης', search_or_ask: 'Ρωτήστε ή αναζητήστε', @@ -76,12 +78,16 @@ export const el: TranslationLanguage = { notfound_title: 'Η σελίδα δεν βρέθηκε', notfound_link: 'Αυτός ο σύνδεσμος οδηγεί σε μια σελίδα που έχει αφαιρεθεί ή δεν υπάρχει πλέον.', notfound: 'Η σελίδα που αναζητάτε δεν υπάρχει.', - notfound_adaptive_title: 'Η σελίδα δεν είναι διαθέσιμη', + notfound_adaptive_title: 'Συνδεθείτε για να δείτε αυτή τη σελίδα', notfound_adaptive: - 'Αυτή η σελίδα μπορεί να υπάρχει, αλλά ίσως χρειαστεί να συνδεθείτε για πρόσβαση.', + 'Αυτή η σελίδα ενδέχεται να απαιτεί σύνδεση. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.', notfound_adaptive_login: 'Σύνδεση', notfound_adaptive_registration_hint: 'Η εγγραφή ενδέχεται να είναι διαθέσιμη ανάλογα με τον οργανισμό.', + notfound_adaptive_no_access_title: 'Ίσως δεν έχετε πρόσβαση σε αυτή τη σελίδα', + notfound_adaptive_no_access: + 'Αυτή η σελίδα ενδέχεται να μην υπάρχει ή ο λογαριασμός σας ενδέχεται να μην έχει πρόσβαση σε αυτήν. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.', + notfound_adaptive_login_again: 'Επανασύνδεση', notfound_goto_home: 'Μετάβαση στην αρχική σελίδα', notfound_suggestions_title: 'Ίσως αναζητάτε', unexpected_error_title: 'Παρουσιάστηκε σφάλμα', @@ -155,6 +161,7 @@ export const el: TranslationLanguage = { ai_chat_tools_submit_assistant_feedback: 'Αξιολόγηση του προηγούμενου μηνύματος του Βοηθού ως ${1}', ai_chat_tools_submitted_feedback: 'Τα σχόλια υποβλήθηκαν', + ai_chat_tools_shared_feedback_with_team: 'Τα σχόλια στάλθηκαν στην ομάδα τεκμηρίωσης', ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}', ai_chat_ask: 'Ρωτήστε ${1}', ai_chat_ask_about: 'Ρωτήστε ${1} για ${2}', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index 66757241c..7a128dd4a 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -9,6 +9,8 @@ export const en = { switch_to_system_theme: 'Switch to system theme', search: 'Search', clear: 'Clear', + table_filtered_by_selection: 'Matches selection: ${1}', + table_clear_selection: 'Clear selection', tags: 'Tags', search_back: 'Back to search results', search_or_ask: 'Ask or search', @@ -73,11 +75,16 @@ export const en = { notfound_title: 'Page not found', notfound_link: 'This link points to a page that has been removed or no longer exists.', notfound: "The page you're looking for doesn't exist.", - notfound_adaptive_title: 'Page unavailable', - notfound_adaptive: 'This page may exist, but you may need to log in to access it.', + notfound_adaptive_title: 'Log in to view this page', + notfound_adaptive: + "This page may require you to log in. If you followed a link, check that it's correct.", notfound_adaptive_login: 'Log in', notfound_adaptive_registration_hint: 'Registration may be available depending on the organization.', + notfound_adaptive_no_access_title: 'You may not have access to this page', + notfound_adaptive_no_access: + "This page may not exist, or your account may not have access to it. If you followed a link, check that it's correct.", + notfound_adaptive_login_again: 'Log in again', notfound_goto_home: 'Go to homepage', notfound_suggestions_title: 'You might be looking for', unexpected_error_title: 'An error occurred', @@ -147,6 +154,7 @@ export const en = { ai_chat_tools_submit_feedback: 'Submit feedback', ai_chat_tools_submit_assistant_feedback: "Rate the Assistant's previous message as ${1}", ai_chat_tools_submitted_feedback: 'Submitted your feedback', + ai_chat_tools_shared_feedback_with_team: 'Shared feedback with the docs team', ai_chat_tools_mcp_tool: 'Called ${1}', ai_chat_ask: 'Ask ${1}', ai_chat_ask_about: 'Ask ${1} about ${2}', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 5a8263f7b..3402d87af 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -11,6 +11,8 @@ export const es: TranslationLanguage = { switch_to_system_theme: 'Cambiar a tema del sistema', search: 'Buscar', clear: 'Limpiar', + table_filtered_by_selection: 'Coincide con la selección: ${1}', + table_clear_selection: 'Borrar selección', tags: 'Etiquetas', search_back: 'Volver a los resultados de búsqueda', search_or_ask: 'Preguntar o Buscar', @@ -77,12 +79,16 @@ export const es: TranslationLanguage = { notfound_title: 'Página no encontrada', notfound_link: 'Este enlace apunta a una página que ha sido eliminada o ya no existe.', notfound: 'La página que buscas no existe.', - notfound_adaptive_title: 'Página no disponible', + notfound_adaptive_title: 'Inicia sesión para ver esta página', notfound_adaptive: - 'Esta página puede existir, pero es posible que debas iniciar sesión para acceder.', + 'Es posible que esta página requiera iniciar sesión. Si has seguido un enlace, comprueba que sea correcto.', notfound_adaptive_login: 'Iniciar sesión', notfound_adaptive_registration_hint: 'El registro puede estar disponible según la organización.', + notfound_adaptive_no_access_title: 'Es posible que no tengas acceso a esta página', + notfound_adaptive_no_access: + 'Es posible que esta página no exista o que tu cuenta no tenga acceso a ella. Si has seguido un enlace, comprueba que sea correcto.', + notfound_adaptive_login_again: 'Volver a iniciar sesión', notfound_goto_home: 'Ir al inicio', notfound_suggestions_title: 'Quizás estés buscando', unexpected_error_title: 'Ocurrió un error', @@ -154,6 +160,7 @@ export const es: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Enviar comentarios', ai_chat_tools_submit_assistant_feedback: 'Valorar el mensaje anterior del asistente como ${1}', ai_chat_tools_submitted_feedback: 'Comentarios enviados', + ai_chat_tools_shared_feedback_with_team: 'Comentarios enviados al equipo de documentación', ai_chat_tools_mcp_tool: 'Llamó a ${1}', ai_chat_ask: 'Preguntar a ${1}', ai_chat_ask_about: 'Preguntar a ${1} sobre ${2}', diff --git a/packages/gitbook/src/intl/translations/et.ts b/packages/gitbook/src/intl/translations/et.ts index bdc92000b..0166b79e2 100644 --- a/packages/gitbook/src/intl/translations/et.ts +++ b/packages/gitbook/src/intl/translations/et.ts @@ -11,6 +11,8 @@ export const et: TranslationLanguage = { switch_to_system_theme: 'Lülitu süsteemi teemale', search: 'Otsi', clear: 'Tühjenda', + table_filtered_by_selection: 'Vastab valikule: ${1}', + table_clear_selection: 'Tühjenda valik', tags: 'Sildid', search_back: 'Tagasi otsingutulemuste juurde', search_or_ask: 'Küsi või otsi', @@ -75,11 +77,16 @@ export const et: TranslationLanguage = { notfound_title: 'Lehte ei leitud', notfound_link: 'See link viitab lehele, mis on eemaldatud või mida enam ei eksisteeri.', notfound: 'Lehte, mida otsite, ei eksisteeri.', - notfound_adaptive_title: 'Leht pole saadaval', - notfound_adaptive: 'See leht võib olemas olla, kuid juurdepääsuks võib olla vaja sisse logida.', + notfound_adaptive_title: 'Selle lehe vaatamiseks logi sisse', + notfound_adaptive: + 'See leht võib nõuda sisselogimist. Kui järgisid linki, kontrolli, et see oleks õige.', notfound_adaptive_login: 'Logi sisse', notfound_adaptive_registration_hint: 'Registreerimine võib olla saadaval olenevalt organisatsioonist.', + notfound_adaptive_no_access_title: 'Sul ei pruugi olla sellele lehele juurdepääsu', + notfound_adaptive_no_access: + 'Seda lehte ei pruugi olla olemas või sinu kontol ei pruugi olla sellele juurdepääsu. Kui järgisid linki, kontrolli, et see oleks õige.', + notfound_adaptive_login_again: 'Logi uuesti sisse', notfound_goto_home: 'Mine avalehele', notfound_suggestions_title: 'Võib-olla otsite', unexpected_error_title: 'Ilmnes tõrge', @@ -149,6 +156,7 @@ export const et: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Saada tagasiside', ai_chat_tools_submit_assistant_feedback: 'Hinda assistendi eelmist sõnumit kui ${1}', ai_chat_tools_submitted_feedback: 'Tagasiside saadetud', + ai_chat_tools_shared_feedback_with_team: 'Tagasiside saadetud dokumentatsioonimeeskonnale', ai_chat_tools_mcp_tool: 'Kutsus ${1}', ai_chat_ask: 'Küsi ${1}', ai_chat_ask_about: 'Küsi ${1}: ${2}', diff --git a/packages/gitbook/src/intl/translations/fi.ts b/packages/gitbook/src/intl/translations/fi.ts index f58f2a3cd..b00a32785 100644 --- a/packages/gitbook/src/intl/translations/fi.ts +++ b/packages/gitbook/src/intl/translations/fi.ts @@ -11,6 +11,8 @@ export const fi: TranslationLanguage = { switch_to_system_theme: 'Vaihda järjestelmän teemaan', search: 'Haku', clear: 'Tyhjennä', + table_filtered_by_selection: 'Vastaa valintaa: ${1}', + table_clear_selection: 'Tyhjennä valinta', tags: 'Tunnisteet', search_back: 'Takaisin hakutuloksiin', search_or_ask: 'Kysy tai hae', @@ -76,12 +78,16 @@ export const fi: TranslationLanguage = { notfound_title: 'Sivua ei löytynyt', notfound_link: 'Tämä linkki osoittaa sivulle, joka on poistettu tai jota ei enää ole.', notfound: 'Etsimääsi sivua ei ole olemassa.', - notfound_adaptive_title: 'Sivu ei ole saatavilla', + notfound_adaptive_title: 'Kirjaudu sisään nähdäksesi tämän sivun', notfound_adaptive: - 'Tämä sivu voi olla olemassa, mutta sen käyttö voi edellyttää kirjautumista.', + 'Tämä sivu saattaa vaatia kirjautumisen. Jos seurasit linkkiä, tarkista, että se on oikein.', notfound_adaptive_login: 'Kirjaudu sisään', notfound_adaptive_registration_hint: 'Rekisteröityminen voi olla mahdollista organisaatiosta riippuen.', + notfound_adaptive_no_access_title: 'Sinulla ei ehkä ole pääsyä tälle sivulle', + notfound_adaptive_no_access: + 'Tätä sivua ei ehkä ole olemassa, tai tililläsi ei ehkä ole pääsyä siihen. Jos seurasit linkkiä, tarkista, että se on oikein.', + notfound_adaptive_login_again: 'Kirjaudu uudelleen', notfound_goto_home: 'Siirry etusivulle', notfound_suggestions_title: 'Saatat etsiä', unexpected_error_title: 'Tapahtui virhe', @@ -151,6 +157,7 @@ export const fi: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Lähetä palaute', ai_chat_tools_submit_assistant_feedback: 'Arvioi avustajan edellinen viesti: ${1}', ai_chat_tools_submitted_feedback: 'Palaute lähetetty', + ai_chat_tools_shared_feedback_with_team: 'Palaute lähetetty dokumentaatiotiimille', ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}', ai_chat_ask: 'Kysy ${1}', ai_chat_ask_about: 'Kysy ${1}: ${2}', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 8cdf018ee..c3a844d4e 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -11,6 +11,8 @@ export const fr: TranslationLanguage = { switch_to_system_theme: 'Utiliser le thème du système', search: 'Rechercher', clear: 'Effacer', + table_filtered_by_selection: 'Correspond à la sélection : ${1}', + table_clear_selection: 'Effacer la sélection', tags: 'Étiquettes', search_back: 'Retour aux résultats de recherche', search_or_ask: 'Rechercher', @@ -76,11 +78,15 @@ export const fr: TranslationLanguage = { notfound_title: 'Page introuvable', notfound_link: "Ce lien pointe vers une page qui a été supprimée ou n'existe plus.", notfound: 'La page que vous cherchez n’existe pas.', - notfound_adaptive_title: 'Page inaccessible', + notfound_adaptive_title: 'Connectez-vous pour afficher cette page', notfound_adaptive: - 'Cette page existe peut-être, mais vous devez peut-être vous connecter pour y accéder.', + "Cette page peut nécessiter une connexion. Si vous avez suivi un lien, vérifiez qu'il est correct.", notfound_adaptive_login: 'Se connecter', notfound_adaptive_registration_hint: "L'inscription peut être disponible selon l'organisation.", + notfound_adaptive_no_access_title: "Vous n'avez peut-être pas accès à cette page", + notfound_adaptive_no_access: + "Cette page n'existe peut-être pas, ou votre compte n'y a peut-être pas accès. Si vous avez suivi un lien, vérifiez qu'il est correct.", + notfound_adaptive_login_again: 'Se reconnecter', notfound_goto_home: "Aller à l'accueil", notfound_suggestions_title: 'Vous cherchez peut-être', unexpected_error_title: 'Erreur inattendue', @@ -151,6 +157,7 @@ export const fr: TranslationLanguage = { ai_chat_tools_submit_assistant_feedback: "Évaluer le message précédent de l'assistant comme ${1}", ai_chat_tools_submitted_feedback: 'Merci pour votre retour', + ai_chat_tools_shared_feedback_with_team: "Retour envoyé à l'équipe de documentation", ai_chat_tools_mcp_tool: 'A appelé ${1}', ai_chat_ask: 'Demander à ${1}', ai_chat_ask_about: 'Demander à ${1} à propos de ${2}', diff --git a/packages/gitbook/src/intl/translations/he.ts b/packages/gitbook/src/intl/translations/he.ts index 0e1a151b1..609e39c42 100644 --- a/packages/gitbook/src/intl/translations/he.ts +++ b/packages/gitbook/src/intl/translations/he.ts @@ -11,6 +11,8 @@ export const he: TranslationLanguage = { switch_to_system_theme: 'מעבר לערכת הנושא של המערכת', search: 'חיפוש', clear: 'ניקוי', + table_filtered_by_selection: 'תואם לבחירה: ${1}', + table_clear_selection: 'נקה בחירה', tags: 'תגיות', search_back: 'חזרה לתוצאות החיפוש', search_or_ask: 'שאלה או חיפוש', @@ -75,10 +77,14 @@ export const he: TranslationLanguage = { notfound_title: 'הדף לא נמצא', notfound_link: 'קישור זה מפנה לדף שהוסר או אינו קיים עוד.', notfound: 'הדף שחיפשת אינו קיים.', - notfound_adaptive_title: 'הדף אינו זמין', - notfound_adaptive: 'ייתכן שהדף קיים, אך ייתכן שעליך להתחבר כדי לגשת אליו.', + notfound_adaptive_title: 'יש להתחבר כדי לצפות בדף זה', + notfound_adaptive: 'ייתכן שדף זה דורש התחברות. אם הגעת דרך קישור, יש לוודא שהוא נכון.', notfound_adaptive_login: 'התחברות', notfound_adaptive_registration_hint: 'ייתכן שההרשמה זמינה בהתאם לארגון.', + notfound_adaptive_no_access_title: 'ייתכן שאין לך גישה לדף זה', + notfound_adaptive_no_access: + 'ייתכן שדף זה אינו קיים, או שלחשבון שלך אין גישה אליו. אם הגעת דרך קישור, יש לוודא שהוא נכון.', + notfound_adaptive_login_again: 'התחברות מחדש', notfound_goto_home: 'מעבר לדף הבית', notfound_suggestions_title: 'אולי אתה מחפש', unexpected_error_title: 'אירעה שגיאה', @@ -147,6 +153,7 @@ export const he: TranslationLanguage = { ai_chat_tools_submit_feedback: 'שליחת משוב', ai_chat_tools_submit_assistant_feedback: 'דרג את ההודעה הקודמת של העוזר בתור ${1}', ai_chat_tools_submitted_feedback: 'המשוב נשלח', + ai_chat_tools_shared_feedback_with_team: 'המשוב נשלח לצוות התיעוד', ai_chat_tools_mcp_tool: 'קרא ל-${1}', ai_chat_ask: 'שאל את ${1}', ai_chat_ask_about: 'שאל את ${1} על ${2}', diff --git a/packages/gitbook/src/intl/translations/hi.ts b/packages/gitbook/src/intl/translations/hi.ts index f45cf38db..7f4e7010d 100644 --- a/packages/gitbook/src/intl/translations/hi.ts +++ b/packages/gitbook/src/intl/translations/hi.ts @@ -11,6 +11,8 @@ export const hi: TranslationLanguage = { switch_to_system_theme: 'सिस्टम थीम पर जाएं', search: 'खोजें', clear: 'साफ करें', + table_filtered_by_selection: 'चयन से मेल खाता है: ${1}', + table_clear_selection: 'चयन साफ़ करें', tags: 'टैग', search_back: 'खोज परिणामों पर वापस जाएं', search_or_ask: 'पूछें या खोजें', @@ -75,10 +77,15 @@ export const hi: TranslationLanguage = { notfound_title: 'पृष्ठ नहीं मिला', notfound_link: 'यह लिंक ऐसे पृष्ठ पर जाता है जिसे हटा दिया गया है या जो अब मौजूद नहीं है।', notfound: 'आप जिस पृष्ठ को खोज रहे हैं वह मौजूद नहीं है।', - notfound_adaptive_title: 'पृष्ठ उपलब्ध नहीं', - notfound_adaptive: 'यह पृष्ठ मौजूद हो सकता है, लेकिन इसे देखने के लिए आपको लॉग इन करना पड़ सकता है।', + notfound_adaptive_title: 'यह पृष्ठ देखने के लिए लॉग इन करें', + notfound_adaptive: + 'इस पृष्ठ के लिए लॉग इन करना आवश्यक हो सकता है। यदि आप किसी लिंक से आए हैं, तो जांच लें कि वह सही है।', notfound_adaptive_login: 'लॉग इन करें', notfound_adaptive_registration_hint: 'संगठन के आधार पर पंजीकरण उपलब्ध हो सकता है।', + notfound_adaptive_no_access_title: 'हो सकता है कि आपके पास इस पृष्ठ की पहुंच न हो', + notfound_adaptive_no_access: + 'हो सकता है कि यह पृष्ठ मौजूद न हो, या आपके खाते के पास इसकी पहुंच न हो। यदि आप किसी लिंक से आए हैं, तो जांच लें कि वह सही है।', + notfound_adaptive_login_again: 'फिर से लॉग इन करें', notfound_goto_home: 'मुखपृष्ठ पर जाएं', notfound_suggestions_title: 'शायद आप यह ढूँढ रहे हैं', unexpected_error_title: 'त्रुटि हुई', @@ -148,6 +155,7 @@ export const hi: TranslationLanguage = { ai_chat_tools_submit_feedback: 'प्रतिक्रिया सबमिट करें', ai_chat_tools_submit_assistant_feedback: 'सहायक के पिछले संदेश को ${1} के रूप में रेट करें', ai_chat_tools_submitted_feedback: 'आपकी प्रतिक्रिया सबमिट कर दी गई', + ai_chat_tools_shared_feedback_with_team: 'दस्तावेज़ीकरण टीम को प्रतिक्रिया भेजी गई', ai_chat_tools_mcp_tool: '${1} को कॉल किया', ai_chat_ask: '${1} से पूछें', ai_chat_ask_about: '${1} से ${2} के बारे में पूछें', diff --git a/packages/gitbook/src/intl/translations/hr.ts b/packages/gitbook/src/intl/translations/hr.ts index 4d659bbd6..448da4ab2 100644 --- a/packages/gitbook/src/intl/translations/hr.ts +++ b/packages/gitbook/src/intl/translations/hr.ts @@ -11,6 +11,8 @@ export const hr: TranslationLanguage = { switch_to_system_theme: 'Prebaci na temu sustava', search: 'Pretraži', clear: 'Očisti', + table_filtered_by_selection: 'Odgovara odabiru: ${1}', + table_clear_selection: 'Očisti odabir', tags: 'Oznake', search_back: 'Natrag na rezultate pretraživanja', search_or_ask: 'Pitaj ili pretraži', @@ -75,11 +77,15 @@ export const hr: TranslationLanguage = { notfound_title: 'Stranica nije pronađena', notfound_link: 'Ova poveznica vodi na stranicu koja je uklonjena ili više ne postoji.', notfound: 'Stranica koju tražite ne postoji.', - notfound_adaptive_title: 'Stranica nije dostupna', + notfound_adaptive_title: 'Prijavite se za prikaz ove stranice', notfound_adaptive: - 'Ova stranica možda postoji, ali možda se morate prijaviti da biste joj pristupili.', + 'Ova stranica možda zahtijeva prijavu. Ako ste slijedili poveznicu, provjerite je li ispravna.', notfound_adaptive_login: 'Prijava', notfound_adaptive_registration_hint: 'Registracija može biti dostupna ovisno o organizaciji.', + notfound_adaptive_no_access_title: 'Možda nemate pristup ovoj stranici', + notfound_adaptive_no_access: + 'Ova stranica možda ne postoji ili vaš račun nema pristup njoj. Ako ste slijedili poveznicu, provjerite je li ispravna.', + notfound_adaptive_login_again: 'Ponovna prijava', notfound_goto_home: 'Idi na početnu stranicu', notfound_suggestions_title: 'Možda tražite', unexpected_error_title: 'Došlo je do pogreške', @@ -150,6 +156,7 @@ export const hr: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Pošalji povratne informacije', ai_chat_tools_submit_assistant_feedback: 'Ocijenite prethodnu poruku asistenta kao ${1}', ai_chat_tools_submitted_feedback: 'Povratne informacije poslane', + ai_chat_tools_shared_feedback_with_team: 'Povratne informacije poslane timu za dokumentaciju', ai_chat_tools_mcp_tool: 'Pozvao ${1}', ai_chat_ask: 'Pitaj ${1}', ai_chat_ask_about: 'Pitaj ${1} o ${2}', diff --git a/packages/gitbook/src/intl/translations/hu.ts b/packages/gitbook/src/intl/translations/hu.ts index 5799f1546..9ae420113 100644 --- a/packages/gitbook/src/intl/translations/hu.ts +++ b/packages/gitbook/src/intl/translations/hu.ts @@ -11,6 +11,8 @@ export const hu: TranslationLanguage = { switch_to_system_theme: 'Váltás rendszer témára', search: 'Keresés', clear: 'Törlés', + table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}', + table_clear_selection: 'Kijelölés törlése', tags: 'Címkék', search_back: 'Vissza a keresési eredményekhez', search_or_ask: 'Kérdezés vagy keresés', @@ -76,10 +78,15 @@ export const hu: TranslationLanguage = { notfound_title: 'Az oldal nem található', notfound_link: 'Ez a hivatkozás egy eltávolított vagy már nem létező oldalra mutat.', notfound: 'A keresett oldal nem létezik.', - notfound_adaptive_title: 'Az oldal nem érhető el', - notfound_adaptive: 'Ez az oldal létezhet, de a hozzáféréshez lehet, hogy be kell jelentkeznie.', + notfound_adaptive_title: 'Jelentkezzen be az oldal megtekintéséhez', + notfound_adaptive: + 'Ehhez az oldalhoz bejelentkezés szükséges lehet. Ha egy hivatkozást követett, ellenőrizze, hogy helyes-e.', notfound_adaptive_login: 'Bejelentkezés', notfound_adaptive_registration_hint: 'A regisztráció a szervezettől függően elérhető lehet.', + notfound_adaptive_no_access_title: 'Lehet, hogy nincs hozzáférése ehhez az oldalhoz', + notfound_adaptive_no_access: + 'Lehet, hogy ez az oldal nem létezik, vagy a fiókja nem fér hozzá. Ha egy hivatkozást követett, ellenőrizze, hogy helyes-e.', + notfound_adaptive_login_again: 'Újbóli bejelentkezés', notfound_goto_home: 'Ugrás a kezdőlapra', notfound_suggestions_title: 'Talán ezt keresi', unexpected_error_title: 'Hiba történt', @@ -151,6 +158,7 @@ export const hu: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Visszajelzés küldése', ai_chat_tools_submit_assistant_feedback: 'Az asszisztens előző üzenetének értékelése: ${1}', ai_chat_tools_submitted_feedback: 'Visszajelzés elküldve', + ai_chat_tools_shared_feedback_with_team: 'Visszajelzés elküldve a dokumentációs csapatnak', ai_chat_tools_mcp_tool: '${1} meghívva', ai_chat_ask: '${1} kérdezése', ai_chat_ask_about: '${1} megkérdezése ${2} témában', diff --git a/packages/gitbook/src/intl/translations/id.ts b/packages/gitbook/src/intl/translations/id.ts index 0dcc10deb..c9c6b2f51 100644 --- a/packages/gitbook/src/intl/translations/id.ts +++ b/packages/gitbook/src/intl/translations/id.ts @@ -11,6 +11,8 @@ export const id: TranslationLanguage = { switch_to_system_theme: 'Beralih ke tema sistem', search: 'Cari', clear: 'Bersihkan', + table_filtered_by_selection: 'Sesuai pilihan: ${1}', + table_clear_selection: 'Hapus pilihan', tags: 'Tag', search_back: 'Kembali ke hasil pencarian', search_or_ask: 'Tanya atau cari', @@ -75,11 +77,15 @@ export const id: TranslationLanguage = { notfound_title: 'Halaman tidak ditemukan', notfound_link: 'Tautan ini mengarah ke halaman yang telah dihapus atau sudah tidak ada.', notfound: 'Halaman yang Anda cari tidak ada.', - notfound_adaptive_title: 'Halaman tidak tersedia', + notfound_adaptive_title: 'Masuk untuk melihat halaman ini', notfound_adaptive: - 'Halaman ini mungkin ada, tetapi Anda mungkin perlu masuk untuk mengaksesnya.', + 'Halaman ini mungkin mengharuskan Anda masuk. Jika Anda mengikuti tautan, periksa apakah tautan tersebut benar.', notfound_adaptive_login: 'Masuk', notfound_adaptive_registration_hint: 'Pendaftaran mungkin tersedia tergantung pada organisasi.', + notfound_adaptive_no_access_title: 'Anda mungkin tidak memiliki akses ke halaman ini', + notfound_adaptive_no_access: + 'Halaman ini mungkin tidak ada, atau akun Anda mungkin tidak memiliki akses ke halaman ini. Jika Anda mengikuti tautan, periksa apakah tautan tersebut benar.', + notfound_adaptive_login_again: 'Masuk lagi', notfound_goto_home: 'Buka beranda', notfound_suggestions_title: 'Mungkin Anda mencari', unexpected_error_title: 'Terjadi kesalahan', @@ -149,6 +155,7 @@ export const id: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Kirim masukan', ai_chat_tools_submit_assistant_feedback: 'Nilai pesan sebelumnya dari Asisten sebagai ${1}', ai_chat_tools_submitted_feedback: 'Masukan terkirim', + ai_chat_tools_shared_feedback_with_team: 'Masukan dikirim ke tim dokumentasi', ai_chat_tools_mcp_tool: 'Memanggil ${1}', ai_chat_ask: 'Tanya ${1}', ai_chat_ask_about: 'Tanya ${1} tentang ${2}', diff --git a/packages/gitbook/src/intl/translations/index.test.ts b/packages/gitbook/src/intl/translations/index.test.ts new file mode 100644 index 000000000..4441ccc5a --- /dev/null +++ b/packages/gitbook/src/intl/translations/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; + +import { es } from './es'; +import { fr } from './fr'; +import { isAvailableLanguage, languages, loadLanguage } from './index'; + +describe('regional language variants', () => { + it.each([ + ['fr-ca', fr], + ['es-mx', es], + ['es-419', es], + ] as const)('loads %s with its own metadata and the base strings', async (locale, base) => { + expect(isAvailableLanguage(locale)).toBe(true); + + const language = await loadLanguage(locale); + + expect(language).toEqual({ ...base, ...languages[locale] }); + expect(language.locale).toBe(locale); + }); +}); diff --git a/packages/gitbook/src/intl/translations/index.ts b/packages/gitbook/src/intl/translations/index.ts index 18b55b4d7..691f9eb69 100644 --- a/packages/gitbook/src/intl/translations/index.ts +++ b/packages/gitbook/src/intl/translations/index.ts @@ -25,6 +25,10 @@ const languageDefinitions = { metadata: { locale: 'fr', language: 'Français', flag: '🇫🇷' }, load: () => import('./fr').then((module) => module.fr), }, + 'fr-ca': { + metadata: { locale: 'fr-ca', language: 'Français (Canada)', flag: '🇨🇦' }, + load: () => import('./fr').then((module) => withMetadata(module.fr, 'fr-ca')), + }, de: { metadata: { locale: 'de', language: 'Deutsch', flag: '🇩🇪' }, load: () => import('./de').then((module) => module.de), @@ -33,6 +37,14 @@ const languageDefinitions = { metadata: { locale: 'es', language: 'Español', flag: '🇪🇸' }, load: () => import('./es').then((module) => module.es), }, + 'es-mx': { + metadata: { locale: 'es-mx', language: 'Español (México)', flag: '🇲🇽' }, + load: () => import('./es').then((module) => withMetadata(module.es, 'es-mx')), + }, + 'es-419': { + metadata: { locale: 'es-419', language: 'Español (Latinoamérica)', flag: '🌎' }, + load: () => import('./es').then((module) => withMetadata(module.es, 'es-419')), + }, it: { metadata: { locale: 'it', language: 'Italiano', flag: '🇮🇹' }, load: () => import('./it').then((module) => module.it), @@ -177,6 +189,14 @@ const languageDefinitions = { export type TranslationLocale = keyof typeof languageDefinitions; +// Regional variants reuse their base language's strings but keep their own locale, name and flag. +function withMetadata( + base: TranslationLanguage, + locale: 'fr-ca' | 'es-mx' | 'es-419' +): TranslationLanguage { + return { ...base, ...languageDefinitions[locale].metadata }; +} + export const languages = Object.fromEntries( Object.entries(languageDefinitions).map(([locale, definition]) => [locale, definition.metadata]) ) as { diff --git a/packages/gitbook/src/intl/translations/it.ts b/packages/gitbook/src/intl/translations/it.ts index 2349812b8..55a3c495d 100644 --- a/packages/gitbook/src/intl/translations/it.ts +++ b/packages/gitbook/src/intl/translations/it.ts @@ -11,6 +11,8 @@ export const it: TranslationLanguage = { switch_to_system_theme: 'Passa al tema di sistema', search: 'Cerca', clear: 'Cancella', + table_filtered_by_selection: 'Corrisponde alla selezione: ${1}', + table_clear_selection: 'Cancella selezione', tags: 'Tag', search_back: 'Torna ai risultati di ricerca', search_or_ask: 'Chiedi o cerca', @@ -77,12 +79,16 @@ export const it: TranslationLanguage = { notfound_title: 'Pagina non trovata', notfound_link: 'Questo link punta a una pagina che è stata rimossa o non esiste più.', notfound: 'La pagina che cerchi non esiste.', - notfound_adaptive_title: 'Pagina non disponibile', + notfound_adaptive_title: 'Accedi per visualizzare questa pagina', notfound_adaptive: - "Questa pagina potrebbe esistere, ma potrebbe essere necessario effettuare l'accesso per visualizzarla.", + "Questa pagina potrebbe richiedere l'accesso. Se hai seguito un link, verifica che sia corretto.", notfound_adaptive_login: 'Accedi', notfound_adaptive_registration_hint: "La registrazione potrebbe essere disponibile a seconda dell'organizzazione.", + notfound_adaptive_no_access_title: 'Potresti non avere accesso a questa pagina', + notfound_adaptive_no_access: + 'Questa pagina potrebbe non esistere, oppure il tuo account potrebbe non avervi accesso. Se hai seguito un link, verifica che sia corretto.', + notfound_adaptive_login_again: 'Accedi di nuovo', notfound_goto_home: 'Vai alla home', notfound_suggestions_title: 'Forse stai cercando', unexpected_error_title: 'Si è verificato un errore', @@ -154,6 +160,7 @@ export const it: TranslationLanguage = { ai_chat_tools_submit_assistant_feedback: "Valuta il messaggio precedente dell'assistente come ${1}", ai_chat_tools_submitted_feedback: 'Feedback inviato', + ai_chat_tools_shared_feedback_with_team: 'Feedback inviato al team di documentazione', ai_chat_tools_mcp_tool: 'Ha chiamato ${1}', ai_chat_ask: 'Chiedi a ${1}', ai_chat_ask_about: 'Chiedi a ${1} riguardo a ${2}', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 6b1ad1a94..edf1fd1cc 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -11,6 +11,8 @@ export const ja: TranslationLanguage = { switch_to_system_theme: 'システムのテーマに切り替え', search: '検索', clear: 'クリア', + table_filtered_by_selection: '選択に一致: ${1}', + table_clear_selection: '選択をクリア', tags: 'タグ', search_back: '検索結果に戻る', search_or_ask: '質問または検索', @@ -76,11 +78,15 @@ export const ja: TranslationLanguage = { notfound_title: 'ページが見つかりません', notfound_link: 'このリンクは、削除されたか、もはや存在しないページを指しています。', notfound: 'お探しのページは存在しません。', - notfound_adaptive_title: 'ページにアクセスできません', + notfound_adaptive_title: 'このページを表示するにはログインしてください', notfound_adaptive: - 'このページは存在する可能性がありますが、アクセスするにはログインが必要な場合があります。', + 'このページの閲覧にはログインが必要な場合があります。リンクからアクセスした場合は、リンクが正しいか確認してください。', notfound_adaptive_login: 'ログイン', notfound_adaptive_registration_hint: '組織によっては登録が可能な場合があります。', + notfound_adaptive_no_access_title: 'このページへのアクセス権がない可能性があります', + notfound_adaptive_no_access: + 'このページが存在しないか、お使いのアカウントにアクセス権がない可能性があります。リンクからアクセスした場合は、リンクが正しいか確認してください。', + notfound_adaptive_login_again: '再ログイン', notfound_goto_home: 'ホームへ移動', notfound_suggestions_title: 'お探しのページはこちらですか', unexpected_error_title: 'エラーが発生しました', @@ -150,6 +156,7 @@ export const ja: TranslationLanguage = { ai_chat_tools_submit_feedback: 'フィードバックを送信', ai_chat_tools_submit_assistant_feedback: 'アシスタントの前のメッセージを${1}と評価する', ai_chat_tools_submitted_feedback: 'フィードバックを送信しました', + ai_chat_tools_shared_feedback_with_team: 'ドキュメントチームにフィードバックを送信しました', ai_chat_tools_mcp_tool: '${1} を呼び出しました', ai_chat_ask: '${1} に質問する', ai_chat_ask_about: '${1}に${2}について質問する', diff --git a/packages/gitbook/src/intl/translations/ko.ts b/packages/gitbook/src/intl/translations/ko.ts index 4e75de561..132e32545 100644 --- a/packages/gitbook/src/intl/translations/ko.ts +++ b/packages/gitbook/src/intl/translations/ko.ts @@ -11,6 +11,8 @@ export const ko: TranslationLanguage = { switch_to_system_theme: '시스템 테마로 전환', search: '검색', clear: '지우기', + table_filtered_by_selection: '선택과 일치: ${1}', + table_clear_selection: '선택 지우기', tags: '태그', search_back: '검색 결과로 돌아가기', search_or_ask: '질문 또는 검색', @@ -76,10 +78,15 @@ export const ko: TranslationLanguage = { notfound_title: '페이지를 찾을 수 없음', notfound_link: '이 링크는 삭제되었거나 더 이상 존재하지 않는 페이지를 가리킵니다.', notfound: '찾으시는 페이지가 존재하지 않습니다.', - notfound_adaptive_title: '페이지에 접근할 수 없음', - notfound_adaptive: '이 페이지는 존재할 수 있지만, 접근하려면 로그인해야 할 수 있습니다.', + notfound_adaptive_title: '이 페이지를 보려면 로그인하세요', + notfound_adaptive: + '이 페이지는 로그인이 필요할 수 있습니다. 링크를 통해 접속했다면 링크가 올바른지 확인하세요.', notfound_adaptive_login: '로그인', notfound_adaptive_registration_hint: '조직에 따라 등록이 가능할 수 있습니다.', + notfound_adaptive_no_access_title: '이 페이지에 대한 접근 권한이 없을 수 있습니다', + notfound_adaptive_no_access: + '이 페이지가 존재하지 않거나 계정에 이 페이지에 대한 접근 권한이 없을 수 있습니다. 링크를 통해 접속했다면 링크가 올바른지 확인하세요.', + notfound_adaptive_login_again: '다시 로그인', notfound_goto_home: '홈으로 이동', notfound_suggestions_title: '찾고 계신 내용일 수 있어요', unexpected_error_title: '오류가 발생했습니다', @@ -149,6 +156,7 @@ export const ko: TranslationLanguage = { ai_chat_tools_submit_feedback: '피드백 제출', ai_chat_tools_submit_assistant_feedback: '어시스턴트의 이전 메시지를 ${1}(으)로 평가하기', ai_chat_tools_submitted_feedback: '피드백을 제출했습니다', + ai_chat_tools_shared_feedback_with_team: '문서 팀에 피드백을 보냈습니다', ai_chat_tools_mcp_tool: '${1} 호출함', ai_chat_ask: '${1}에게 질문', ai_chat_ask_about: '${1}에게 ${2}에 대해 질문', diff --git a/packages/gitbook/src/intl/translations/lt.ts b/packages/gitbook/src/intl/translations/lt.ts index b65808e19..ed1235e56 100644 --- a/packages/gitbook/src/intl/translations/lt.ts +++ b/packages/gitbook/src/intl/translations/lt.ts @@ -11,6 +11,8 @@ export const lt: TranslationLanguage = { switch_to_system_theme: 'Perjungti į sistemos temą', search: 'Ieškoti', clear: 'Išvalyti', + table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}', + table_clear_selection: 'Išvalyti pasirinkimą', tags: 'Žymos', search_back: 'Grįžti į paieškos rezultatus', search_or_ask: 'Klausti arba ieškoti', @@ -75,12 +77,16 @@ export const lt: TranslationLanguage = { notfound_title: 'Puslapis nerastas', notfound_link: 'Ši nuoroda veda į puslapį, kuris buvo pašalintas arba nebeegzistuoja.', notfound: 'Puslapis, kurio ieškote, neegzistuoja.', - notfound_adaptive_title: 'Puslapis nepasiekiamas', + notfound_adaptive_title: 'Prisijunkite, kad peržiūrėtumėte šį puslapį', notfound_adaptive: - 'Šis puslapis gali egzistuoti, bet norint jį pasiekti gali reikėti prisijungti.', + 'Šiam puslapiui gali reikėti prisijungti. Jei sekėte nuoroda, patikrinkite, ar ji teisinga.', notfound_adaptive_login: 'Prisijungti', notfound_adaptive_registration_hint: 'Registracija gali būti galima priklausomai nuo organizacijos.', + notfound_adaptive_no_access_title: 'Galbūt neturite prieigos prie šio puslapio', + notfound_adaptive_no_access: + 'Šis puslapis gali neegzistuoti arba jūsų paskyra gali neturėti prieigos prie jo. Jei sekėte nuoroda, patikrinkite, ar ji teisinga.', + notfound_adaptive_login_again: 'Prisijungti iš naujo', notfound_goto_home: 'Eiti į pagrindinį puslapį', notfound_suggestions_title: 'Galbūt ieškote', unexpected_error_title: 'Įvyko klaida', @@ -150,6 +156,7 @@ export const lt: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Siųsti atsiliepimą', ai_chat_tools_submit_assistant_feedback: 'Įvertinkite ankstesnę asistento žinutę kaip ${1}', ai_chat_tools_submitted_feedback: 'Atsiliepimas išsiųstas', + ai_chat_tools_shared_feedback_with_team: 'Atsiliepimas išsiųstas dokumentacijos komandai', ai_chat_tools_mcp_tool: 'Iškviesta ${1}', ai_chat_ask: 'Klausti ${1}', ai_chat_ask_about: 'Klausti ${1} apie ${2}', diff --git a/packages/gitbook/src/intl/translations/lv.ts b/packages/gitbook/src/intl/translations/lv.ts index 8f9229e6d..8aae8bc33 100644 --- a/packages/gitbook/src/intl/translations/lv.ts +++ b/packages/gitbook/src/intl/translations/lv.ts @@ -11,6 +11,8 @@ export const lv: TranslationLanguage = { switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu', search: 'Meklēt', clear: 'Notīrīt', + table_filtered_by_selection: 'Atbilst izvēlei: ${1}', + table_clear_selection: 'Notīrīt izvēli', tags: 'Birkas', search_back: 'Atpakaļ uz meklēšanas rezultātiem', search_or_ask: 'Jautāt vai meklēt', @@ -75,10 +77,15 @@ export const lv: TranslationLanguage = { notfound_title: 'Lapa nav atrasta', notfound_link: 'Šī saite norāda uz lapu, kas ir noņemta vai vairs nepastāv.', notfound: 'Meklētā lapa nepastāv.', - notfound_adaptive_title: 'Lapa nav pieejama', - notfound_adaptive: 'Šī lapa var pastāvēt, bet, iespējams, jums jāpierakstās, lai tai piekļūtu.', + notfound_adaptive_title: 'Pierakstieties, lai skatītu šo lapu', + notfound_adaptive: + 'Šai lapai, iespējams, nepieciešams pierakstīties. Ja sekojāt saitei, pārbaudiet, vai tā ir pareiza.', notfound_adaptive_login: 'Pierakstīties', notfound_adaptive_registration_hint: 'Reģistrācija var būt pieejama atkarībā no organizācijas.', + notfound_adaptive_no_access_title: 'Iespējams, jums nav piekļuves šai lapai', + notfound_adaptive_no_access: + 'Šī lapa, iespējams, neeksistē, vai arī jūsu kontam nav piekļuves tai. Ja sekojāt saitei, pārbaudiet, vai tā ir pareiza.', + notfound_adaptive_login_again: 'Pierakstīties vēlreiz', notfound_goto_home: 'Doties uz sākumlapu', notfound_suggestions_title: 'Iespējams, meklējat', unexpected_error_title: 'Radās kļūda', @@ -148,6 +155,7 @@ export const lv: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Nosūtīt atsauksmi', ai_chat_tools_submit_assistant_feedback: 'Novērtējiet asistenta iepriekšējo ziņojumu kā ${1}', ai_chat_tools_submitted_feedback: 'Atsauksme nosūtīta', + ai_chat_tools_shared_feedback_with_team: 'Atsauksme nosūtīta dokumentācijas komandai', ai_chat_tools_mcp_tool: 'Izsauca ${1}', ai_chat_ask: 'Jautāt ${1}', ai_chat_ask_about: 'Jautāt ${1} par ${2}', diff --git a/packages/gitbook/src/intl/translations/ms.ts b/packages/gitbook/src/intl/translations/ms.ts index b800aa7a2..3c3f17d33 100644 --- a/packages/gitbook/src/intl/translations/ms.ts +++ b/packages/gitbook/src/intl/translations/ms.ts @@ -11,6 +11,8 @@ export const ms: TranslationLanguage = { switch_to_system_theme: 'Tukar kepada tema sistem', search: 'Cari', clear: 'Kosongkan', + table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}', + table_clear_selection: 'Kosongkan pilihan', tags: 'Tag', search_back: 'Kembali ke hasil carian', search_or_ask: 'Tanya atau cari', @@ -75,11 +77,15 @@ export const ms: TranslationLanguage = { notfound_title: 'Halaman tidak ditemui', notfound_link: 'Pautan ini menghala ke halaman yang telah dialih keluar atau tidak lagi wujud.', notfound: 'Halaman yang anda cari tidak wujud.', - notfound_adaptive_title: 'Halaman tidak tersedia', + notfound_adaptive_title: 'Log masuk untuk melihat halaman ini', notfound_adaptive: - 'Halaman ini mungkin wujud, tetapi anda mungkin perlu log masuk untuk mengaksesnya.', + 'Halaman ini mungkin memerlukan anda log masuk. Jika anda mengikuti pautan, semak sama ada pautan itu betul.', notfound_adaptive_login: 'Log masuk', notfound_adaptive_registration_hint: 'Pendaftaran mungkin tersedia bergantung pada organisasi.', + notfound_adaptive_no_access_title: 'Anda mungkin tidak mempunyai akses ke halaman ini', + notfound_adaptive_no_access: + 'Halaman ini mungkin tidak wujud, atau akaun anda mungkin tidak mempunyai akses kepadanya. Jika anda mengikuti pautan, semak sama ada pautan itu betul.', + notfound_adaptive_login_again: 'Log masuk semula', notfound_goto_home: 'Pergi ke halaman utama', notfound_suggestions_title: 'Mungkin anda mencari', unexpected_error_title: 'Ralat berlaku', @@ -150,6 +156,7 @@ export const ms: TranslationLanguage = { ai_chat_tools_submit_assistant_feedback: 'Nilai mesej sebelumnya daripada Pembantu sebagai ${1}', ai_chat_tools_submitted_feedback: 'Maklum balas dihantar', + ai_chat_tools_shared_feedback_with_team: 'Maklum balas dihantar kepada pasukan dokumentasi', ai_chat_tools_mcp_tool: 'Memanggil ${1}', ai_chat_ask: 'Tanya ${1}', ai_chat_ask_about: 'Tanya ${1} tentang ${2}', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index c22f60dae..9711db541 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -11,6 +11,8 @@ export const nl: TranslationLanguage = { switch_to_system_theme: 'Schakel over naar systeemmodus', search: 'Zoeken', clear: 'Wissen', + table_filtered_by_selection: 'Komt overeen met selectie: ${1}', + table_clear_selection: 'Selectie wissen', tags: 'Tags', search_back: 'Terug naar zoekresultaten', search_or_ask: 'Zoek of vraag', @@ -77,12 +79,16 @@ export const nl: TranslationLanguage = { notfound_title: 'Pagina niet gevonden', notfound_link: 'Deze link verwijst naar een pagina die is verwijderd of niet meer bestaat.', notfound: 'De pagina die je zoekt, bestaat niet.', - notfound_adaptive_title: 'Pagina niet beschikbaar', + notfound_adaptive_title: 'Log in om deze pagina te bekijken', notfound_adaptive: - 'Deze pagina bestaat mogelijk, maar je moet mogelijk inloggen om toegang te krijgen.', + 'Voor deze pagina moet je mogelijk inloggen. Als je een link hebt gevolgd, controleer dan of deze klopt.', notfound_adaptive_login: 'Inloggen', notfound_adaptive_registration_hint: 'Registratie is mogelijk beschikbaar, afhankelijk van de organisatie.', + notfound_adaptive_no_access_title: 'Je hebt mogelijk geen toegang tot deze pagina', + notfound_adaptive_no_access: + 'Deze pagina bestaat mogelijk niet, of je account heeft er geen toegang toe. Als je een link hebt gevolgd, controleer dan of deze klopt.', + notfound_adaptive_login_again: 'Opnieuw inloggen', notfound_goto_home: 'Naar startpagina', notfound_suggestions_title: 'Misschien zoek je', unexpected_error_title: 'Er is een fout opgetreden', @@ -152,6 +158,7 @@ export const nl: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Feedback verzenden', ai_chat_tools_submit_assistant_feedback: 'Vorig bericht van de assistent beoordelen als ${1}', ai_chat_tools_submitted_feedback: 'Feedback verzonden', + ai_chat_tools_shared_feedback_with_team: 'Feedback naar het documentatieteam gestuurd', ai_chat_tools_mcp_tool: '${1} aangeroepen', ai_chat_ask: 'Vraag het aan ${1}', ai_chat_ask_about: 'Stel ${1} een vraag over ${2}', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 6ca43f78a..bf9155b00 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -11,6 +11,8 @@ export const no: TranslationLanguage = { switch_to_system_theme: 'Bytt til systemtema', search: 'Søk', clear: 'Tøm', + table_filtered_by_selection: 'Samsvarer med valg: ${1}', + table_clear_selection: 'Fjern valg', tags: 'Tagger', search_back: 'Tilbake til søkeresultater', search_or_ask: 'Spør eller søk', @@ -77,11 +79,16 @@ export const no: TranslationLanguage = { notfound_link: 'Denne lenken peker til en side som har blitt fjernet eller ikke lenger eksisterer.', notfound: 'Siden du leter etter eksisterer ikke.', - notfound_adaptive_title: 'Siden er ikke tilgjengelig', - notfound_adaptive: 'Denne siden kan finnes, men du må kanskje logge inn for å få tilgang.', + notfound_adaptive_title: 'Logg inn for å se denne siden', + notfound_adaptive: + 'Denne siden kan kreve at du logger inn. Hvis du fulgte en lenke, sjekk at den er riktig.', notfound_adaptive_login: 'Logg inn', notfound_adaptive_registration_hint: 'Registrering kan være tilgjengelig avhengig av organisasjonen.', + notfound_adaptive_no_access_title: 'Du har kanskje ikke tilgang til denne siden', + notfound_adaptive_no_access: + 'Denne siden finnes kanskje ikke, eller kontoen din har kanskje ikke tilgang til den. Hvis du fulgte en lenke, sjekk at den er riktig.', + notfound_adaptive_login_again: 'Logg inn på nytt', notfound_goto_home: 'Gå til forsiden', notfound_suggestions_title: 'Kanskje du leter etter', unexpected_error_title: 'En feil oppstod', @@ -151,6 +158,7 @@ export const no: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Send tilbakemelding', ai_chat_tools_submit_assistant_feedback: 'Vurder assistentens forrige melding som ${1}', ai_chat_tools_submitted_feedback: 'Tilbakemelding sendt', + ai_chat_tools_shared_feedback_with_team: 'Tilbakemelding sendt til dokumentasjonsteamet', ai_chat_tools_mcp_tool: 'Kalte ${1}', ai_chat_ask: 'Spør ${1}', ai_chat_ask_about: 'Spør ${1} om ${2}', diff --git a/packages/gitbook/src/intl/translations/pl.ts b/packages/gitbook/src/intl/translations/pl.ts index 7d409df7e..977e06c98 100644 --- a/packages/gitbook/src/intl/translations/pl.ts +++ b/packages/gitbook/src/intl/translations/pl.ts @@ -11,6 +11,8 @@ export const pl: TranslationLanguage = { switch_to_system_theme: 'Przełącz na motyw systemowy', search: 'Szukaj', clear: 'Wyczyść', + table_filtered_by_selection: 'Zgodne z wyborem: ${1}', + table_clear_selection: 'Wyczyść wybór', tags: 'Tagi', search_back: 'Wróć do wyników wyszukiwania', search_or_ask: 'Zapytaj lub wyszukaj', @@ -75,11 +77,16 @@ export const pl: TranslationLanguage = { notfound_title: 'Nie znaleziono strony', notfound_link: 'Ten link prowadzi do strony, która została usunięta lub już nie istnieje.', notfound: 'Strona, której szukasz, nie istnieje.', - notfound_adaptive_title: 'Strona niedostępna', - notfound_adaptive: 'Ta strona może istnieć, ale dostęp do niej może wymagać zalogowania.', + notfound_adaptive_title: 'Zaloguj się, aby wyświetlić tę stronę', + notfound_adaptive: + 'Ta strona może wymagać zalogowania. Jeśli przechodzisz z linku, sprawdź, czy jest poprawny.', notfound_adaptive_login: 'Zaloguj się', notfound_adaptive_registration_hint: 'Rejestracja może być dostępna w zależności od organizacji.', + notfound_adaptive_no_access_title: 'Możesz nie mieć dostępu do tej strony', + notfound_adaptive_no_access: + 'Ta strona może nie istnieć lub Twoje konto może nie mieć do niej dostępu. Jeśli przechodzisz z linku, sprawdź, czy jest poprawny.', + notfound_adaptive_login_again: 'Zaloguj się ponownie', notfound_goto_home: 'Przejdź do strony głównej', notfound_suggestions_title: 'Być może szukasz', unexpected_error_title: 'Wystąpił błąd', @@ -150,6 +157,7 @@ export const pl: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Wyślij opinię', ai_chat_tools_submit_assistant_feedback: 'Oceń poprzednią wiadomość asystenta jako ${1}', ai_chat_tools_submitted_feedback: 'Przesłano opinię', + ai_chat_tools_shared_feedback_with_team: 'Opinia przesłana zespołowi dokumentacji', ai_chat_tools_mcp_tool: 'Wywołano ${1}', ai_chat_ask: 'Zapytaj ${1}', ai_chat_ask_about: 'Zapytaj ${1} o ${2}', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 2b3276032..8823c7b0a 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -11,6 +11,8 @@ export const pt_br: TranslationLanguage = { switch_to_system_theme: 'Mudar para configuração do sistema', search: 'Buscar', clear: 'Limpar', + table_filtered_by_selection: 'Corresponde à seleção: ${1}', + table_clear_selection: 'Limpar seleção', tags: 'Tags', search_back: 'Voltar aos resultados da busca', search_or_ask: 'Perguntar ou buscar', @@ -77,12 +79,16 @@ export const pt_br: TranslationLanguage = { notfound_title: 'Página não encontrada', notfound_link: 'Este link aponta para uma página que foi removida ou não existe mais.', notfound: 'A página que você está procurando não existe.', - notfound_adaptive_title: 'Página indisponível', + notfound_adaptive_title: 'Faça login para ver esta página', notfound_adaptive: - 'Esta página pode existir, mas talvez você precise fazer login para acessá-la.', + 'Esta página pode exigir login. Se você seguiu um link, verifique se ele está correto.', notfound_adaptive_login: 'Fazer login', notfound_adaptive_registration_hint: 'O registro pode estar disponível dependendo da organização.', + notfound_adaptive_no_access_title: 'Talvez você não tenha acesso a esta página', + notfound_adaptive_no_access: + 'Esta página pode não existir ou sua conta pode não ter acesso a ela. Se você seguiu um link, verifique se ele está correto.', + notfound_adaptive_login_again: 'Fazer login novamente', notfound_goto_home: 'Ir para a página inicial', notfound_suggestions_title: 'Você pode estar procurando por', unexpected_error_title: 'Aconteceu um erro', @@ -154,6 +160,7 @@ export const pt_br: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Enviar feedback', ai_chat_tools_submit_assistant_feedback: 'Avalie a mensagem anterior do assistente como ${1}', ai_chat_tools_submitted_feedback: 'Feedback enviado', + ai_chat_tools_shared_feedback_with_team: 'Feedback enviado à equipe de documentação', ai_chat_tools_mcp_tool: 'Chamou ${1}', ai_chat_ask: 'Perguntar a ${1}', ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}', diff --git a/packages/gitbook/src/intl/translations/pt.ts b/packages/gitbook/src/intl/translations/pt.ts index 7b982ab5f..40b21c905 100644 --- a/packages/gitbook/src/intl/translations/pt.ts +++ b/packages/gitbook/src/intl/translations/pt.ts @@ -11,6 +11,8 @@ export const pt: TranslationLanguage = { switch_to_system_theme: 'Mudar para o tema do sistema', search: 'Pesquisar', clear: 'Limpar', + table_filtered_by_selection: 'Corresponde à seleção: ${1}', + table_clear_selection: 'Limpar seleção', tags: 'Etiquetas', search_back: 'Voltar aos resultados da pesquisa', search_or_ask: 'Perguntar ou pesquisar', @@ -76,12 +78,16 @@ export const pt: TranslationLanguage = { notfound_title: 'Página não encontrada', notfound_link: 'Esta ligação aponta para uma página que foi removida ou já não existe.', notfound: 'A página que procura não existe.', - notfound_adaptive_title: 'Página indisponível', + notfound_adaptive_title: 'Inicie sessão para ver esta página', notfound_adaptive: - 'Esta página pode existir, mas talvez tenha de iniciar sessão para lhe aceder.', + 'Esta página pode exigir que inicie sessão. Se seguiu uma ligação, verifique se está correta.', notfound_adaptive_login: 'Iniciar sessão', notfound_adaptive_registration_hint: 'O registo pode estar disponível dependendo da organização.', + notfound_adaptive_no_access_title: 'Pode não ter acesso a esta página', + notfound_adaptive_no_access: + 'Esta página pode não existir ou a sua conta pode não ter acesso à mesma. Se seguiu uma ligação, verifique se está correta.', + notfound_adaptive_login_again: 'Iniciar sessão novamente', notfound_goto_home: 'Ir para a página inicial', notfound_suggestions_title: 'Talvez esteja à procura de', unexpected_error_title: 'Ocorreu um erro', @@ -151,6 +157,7 @@ export const pt: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Enviar feedback', ai_chat_tools_submit_assistant_feedback: 'Avaliar a mensagem anterior do assistente como ${1}', ai_chat_tools_submitted_feedback: 'Feedback enviado', + ai_chat_tools_shared_feedback_with_team: 'Feedback enviado à equipa de documentação', ai_chat_tools_mcp_tool: 'Chamou ${1}', ai_chat_ask: 'Perguntar a ${1}', ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}', diff --git a/packages/gitbook/src/intl/translations/ro.ts b/packages/gitbook/src/intl/translations/ro.ts index 5a0752162..315cf3553 100644 --- a/packages/gitbook/src/intl/translations/ro.ts +++ b/packages/gitbook/src/intl/translations/ro.ts @@ -11,6 +11,8 @@ export const ro: TranslationLanguage = { switch_to_system_theme: 'Comută la tema sistemului', search: 'Caută', clear: 'Șterge', + table_filtered_by_selection: 'Se potrivește cu selecția: ${1}', + table_clear_selection: 'Șterge selecția', tags: 'Etichete', search_back: 'Înapoi la rezultatele căutării', search_or_ask: 'Întreabă sau caută', @@ -76,12 +78,16 @@ export const ro: TranslationLanguage = { notfound_title: 'Pagina nu a fost găsită', notfound_link: 'Acest link indică o pagină care a fost eliminată sau nu mai există.', notfound: 'Pagina pe care o cauți nu există.', - notfound_adaptive_title: 'Pagina nu este disponibilă', + notfound_adaptive_title: 'Autentifică-te pentru a vedea această pagină', notfound_adaptive: - 'Această pagină poate exista, dar este posibil să trebuiască să te autentifici pentru a o accesa.', + 'Această pagină poate necesita autentificare. Dacă ai urmat un link, verifică dacă este corect.', notfound_adaptive_login: 'Autentificare', notfound_adaptive_registration_hint: 'Înregistrarea poate fi disponibilă în funcție de organizație.', + notfound_adaptive_no_access_title: 'Este posibil să nu ai acces la această pagină', + notfound_adaptive_no_access: + 'Este posibil ca această pagină să nu existe sau contul tău să nu aibă acces la ea. Dacă ai urmat un link, verifică dacă este corect.', + notfound_adaptive_login_again: 'Reautentificare', notfound_goto_home: 'Mergi la pagina principală', notfound_suggestions_title: 'Poate cauți', unexpected_error_title: 'A apărut o eroare', @@ -153,6 +159,7 @@ export const ro: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Trimite feedback', ai_chat_tools_submit_assistant_feedback: 'Evaluează mesajul anterior al Asistentului ca ${1}', ai_chat_tools_submitted_feedback: 'Feedback trimis', + ai_chat_tools_shared_feedback_with_team: 'Feedback trimis echipei de documentație', ai_chat_tools_mcp_tool: 'A apelat ${1}', ai_chat_ask: 'Întreabă ${1}', ai_chat_ask_about: 'Întreabă ${1} despre ${2}', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index a59f0da45..56d07909f 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -11,6 +11,8 @@ export const ru: TranslationLanguage = { switch_to_system_theme: 'Переключиться на системную тему', search: 'Поиск', clear: 'Очистить', + table_filtered_by_selection: 'Соответствует выбору: ${1}', + table_clear_selection: 'Очистить выбор', tags: 'Теги', search_back: 'Вернуться к результатам поиска', search_or_ask: 'Найти или спросить', @@ -77,12 +79,16 @@ export const ru: TranslationLanguage = { notfound_title: 'Страница не найдена', notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.', notfound: 'Страница, которую вы ищете, не существует.', - notfound_adaptive_title: 'Страница недоступна', + notfound_adaptive_title: 'Войдите, чтобы просмотреть эту страницу', notfound_adaptive: - 'Эта страница может существовать, но для доступа к ней может потребоваться вход.', + 'Для просмотра этой страницы может потребоваться вход. Если вы перешли по ссылке, проверьте, что она верна.', notfound_adaptive_login: 'Войти', notfound_adaptive_registration_hint: 'Регистрация может быть доступна в зависимости от организации.', + notfound_adaptive_no_access_title: 'Возможно, у вас нет доступа к этой странице', + notfound_adaptive_no_access: + 'Возможно, эта страница не существует или у вашей учётной записи нет к ней доступа. Если вы перешли по ссылке, проверьте, что она верна.', + notfound_adaptive_login_again: 'Войти снова', notfound_goto_home: 'Перейти на главную', notfound_suggestions_title: 'Возможно, вы ищете', unexpected_error_title: 'Произошла ошибка', @@ -153,6 +159,7 @@ export const ru: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Отправить отзыв', ai_chat_tools_submit_assistant_feedback: 'Оцените предыдущее сообщение ассистента как ${1}', ai_chat_tools_submitted_feedback: 'Отзыв отправлен', + ai_chat_tools_shared_feedback_with_team: 'Отзыв отправлен команде документации', ai_chat_tools_mcp_tool: 'Вызван ${1}', ai_chat_ask: 'Спросить у ${1}', ai_chat_ask_about: 'Спросить у ${1} о ${2}', diff --git a/packages/gitbook/src/intl/translations/sk.ts b/packages/gitbook/src/intl/translations/sk.ts index b8bfc1e94..30a2bb424 100644 --- a/packages/gitbook/src/intl/translations/sk.ts +++ b/packages/gitbook/src/intl/translations/sk.ts @@ -11,6 +11,8 @@ export const sk: TranslationLanguage = { switch_to_system_theme: 'Prepnúť na systémový motív', search: 'Hľadať', clear: 'Vymazať', + table_filtered_by_selection: 'Zodpovedá výberu: ${1}', + table_clear_selection: 'Zrušiť výber', tags: 'Značky', search_back: 'Späť na výsledky vyhľadávania', search_or_ask: 'Opýtať sa alebo hľadať', @@ -76,12 +78,16 @@ export const sk: TranslationLanguage = { notfound_title: 'Stránka sa nenašla', notfound_link: 'Tento odkaz smeruje na stránku, ktorá bola odstránená alebo už neexistuje.', notfound: 'Stránka, ktorú hľadáte, neexistuje.', - notfound_adaptive_title: 'Stránka nie je dostupná', + notfound_adaptive_title: 'Na zobrazenie tejto stránky sa prihláste', notfound_adaptive: - 'Táto stránka môže existovať, ale na prístup sa možno budete musieť prihlásiť.', + 'Táto stránka môže vyžadovať prihlásenie. Ak ste prešli z odkazu, skontrolujte, či je správny.', notfound_adaptive_login: 'Prihlásiť sa', notfound_adaptive_registration_hint: 'Registrácia môže byť dostupná v závislosti od organizácie.', + notfound_adaptive_no_access_title: 'K tejto stránke možno nemáte prístup', + notfound_adaptive_no_access: + 'Táto stránka možno neexistuje alebo k nej váš účet nemá prístup. Ak ste prešli z odkazu, skontrolujte, či je správny.', + notfound_adaptive_login_again: 'Prihlásiť sa znova', notfound_goto_home: 'Prejsť na domovskú stránku', notfound_suggestions_title: 'Možno hľadáte', unexpected_error_title: 'Vyskytla sa chyba', @@ -152,6 +158,7 @@ export const sk: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Odoslať spätnú väzbu', ai_chat_tools_submit_assistant_feedback: 'Ohodnotiť predchádzajúcu správu asistenta ako ${1}', ai_chat_tools_submitted_feedback: 'Spätná väzba odoslaná', + ai_chat_tools_shared_feedback_with_team: 'Spätná väzba odoslaná tímu dokumentácie', ai_chat_tools_mcp_tool: 'Zavolal ${1}', ai_chat_ask: 'Opýtať sa ${1}', ai_chat_ask_about: 'Opýtať sa ${1} na ${2}', diff --git a/packages/gitbook/src/intl/translations/sl.ts b/packages/gitbook/src/intl/translations/sl.ts index 00704e7f1..feeeecc48 100644 --- a/packages/gitbook/src/intl/translations/sl.ts +++ b/packages/gitbook/src/intl/translations/sl.ts @@ -11,6 +11,8 @@ export const sl: TranslationLanguage = { switch_to_system_theme: 'Preklopi na sistemsko temo', search: 'Išči', clear: 'Počisti', + table_filtered_by_selection: 'Ustreza izbiri: ${1}', + table_clear_selection: 'Počisti izbiro', tags: 'Oznake', search_back: 'Nazaj na rezultate iskanja', search_or_ask: 'Vprašaj ali išči', @@ -76,10 +78,15 @@ export const sl: TranslationLanguage = { notfound_title: 'Strani ni bilo mogoče najti', notfound_link: 'Ta povezava kaže na stran, ki je bila odstranjena ali ne obstaja več.', notfound: 'Stran, ki jo iščete, ne obstaja.', - notfound_adaptive_title: 'Stran ni na voljo', - notfound_adaptive: 'Ta stran morda obstaja, vendar se boste za dostop morda morali prijaviti.', + notfound_adaptive_title: 'Za ogled te strani se prijavite', + notfound_adaptive: + 'Ta stran morda zahteva prijavo. Če ste sledili povezavi, preverite, ali je pravilna.', notfound_adaptive_login: 'Prijava', notfound_adaptive_registration_hint: 'Registracija je morda na voljo, odvisno od organizacije.', + notfound_adaptive_no_access_title: 'Morda nimate dostopa do te strani', + notfound_adaptive_no_access: + 'Ta stran morda ne obstaja ali pa vaš račun nima dostopa do nje. Če ste sledili povezavi, preverite, ali je pravilna.', + notfound_adaptive_login_again: 'Ponovna prijava', notfound_goto_home: 'Pojdi na domačo stran', notfound_suggestions_title: 'Morda iščete', unexpected_error_title: 'Prišlo je do napake', @@ -150,6 +157,7 @@ export const sl: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Pošlji povratne informacije', ai_chat_tools_submit_assistant_feedback: 'Ocenite prejšnje sporočilo pomočnika kot ${1}', ai_chat_tools_submitted_feedback: 'Povratne informacije poslane', + ai_chat_tools_shared_feedback_with_team: 'Povratne informacije poslane ekipi za dokumentacijo', ai_chat_tools_mcp_tool: 'Poklicano ${1}', ai_chat_ask: 'Vprašaj ${1}', ai_chat_ask_about: 'Vprašaj ${1} o ${2}', diff --git a/packages/gitbook/src/intl/translations/sv.ts b/packages/gitbook/src/intl/translations/sv.ts index 5496fbb27..555beb12c 100644 --- a/packages/gitbook/src/intl/translations/sv.ts +++ b/packages/gitbook/src/intl/translations/sv.ts @@ -11,6 +11,8 @@ export const sv: TranslationLanguage = { switch_to_system_theme: 'Byt till systemtema', search: 'Sök', clear: 'Rensa', + table_filtered_by_selection: 'Matchar val: ${1}', + table_clear_selection: 'Rensa val', tags: 'Taggar', search_back: 'Tillbaka till sökresultat', search_or_ask: 'Fråga eller sök', @@ -75,11 +77,16 @@ export const sv: TranslationLanguage = { notfound_title: 'Sidan hittades inte', notfound_link: 'Den här länken pekar på en sida som har tagits bort eller inte längre finns.', notfound: 'Sidan du letar efter finns inte.', - notfound_adaptive_title: 'Sidan är inte tillgänglig', - notfound_adaptive: 'Sidan kan finnas, men du kan behöva logga in för att få åtkomst.', + notfound_adaptive_title: 'Logga in för att visa den här sidan', + notfound_adaptive: + 'Den här sidan kan kräva att du loggar in. Om du följde en länk, kontrollera att den är korrekt.', notfound_adaptive_login: 'Logga in', notfound_adaptive_registration_hint: 'Registrering kan vara tillgänglig beroende på organisationen.', + notfound_adaptive_no_access_title: 'Du kanske inte har åtkomst till den här sidan', + notfound_adaptive_no_access: + 'Den här sidan kanske inte finns, eller så har ditt konto inte åtkomst till den. Om du följde en länk, kontrollera att den är korrekt.', + notfound_adaptive_login_again: 'Logga in igen', notfound_goto_home: 'Gå till startsidan', notfound_suggestions_title: 'Du kanske letar efter', unexpected_error_title: 'Ett fel uppstod', @@ -151,6 +158,7 @@ export const sv: TranslationLanguage = { ai_chat_tools_submit_assistant_feedback: 'Betygsätt assistentens föregående meddelande som ${1}', ai_chat_tools_submitted_feedback: 'Feedback skickad', + ai_chat_tools_shared_feedback_with_team: 'Feedback skickad till dokumentationsteamet', ai_chat_tools_mcp_tool: 'Anropade ${1}', ai_chat_ask: 'Fråga ${1}', ai_chat_ask_about: 'Fråga ${1} om ${2}', diff --git a/packages/gitbook/src/intl/translations/th.ts b/packages/gitbook/src/intl/translations/th.ts index 34be47a99..af363034f 100644 --- a/packages/gitbook/src/intl/translations/th.ts +++ b/packages/gitbook/src/intl/translations/th.ts @@ -11,6 +11,8 @@ export const th: TranslationLanguage = { switch_to_system_theme: 'เปลี่ยนเป็นธีมของระบบ', search: 'ค้นหา', clear: 'ล้าง', + table_filtered_by_selection: 'ตรงกับการเลือก: ${1}', + table_clear_selection: 'ล้างการเลือก', tags: 'แท็ก', search_back: 'กลับไปยังผลการค้นหา', search_or_ask: 'ถามหรือค้นหา', @@ -74,10 +76,14 @@ export const th: TranslationLanguage = { notfound_title: 'ไม่พบหน้า', notfound_link: 'ลิงก์นี้ชี้ไปยังหน้าที่ถูกลบหรือไม่มีอยู่อีกต่อไป', notfound: 'หน้าที่คุณกำลังค้นหาไม่มีอยู่', - notfound_adaptive_title: 'หน้าไม่พร้อมใช้งาน', - notfound_adaptive: 'หน้านี้อาจมีอยู่ แต่คุณอาจต้องเข้าสู่ระบบเพื่อเข้าถึง', + notfound_adaptive_title: 'เข้าสู่ระบบเพื่อดูหน้านี้', + notfound_adaptive: 'หน้านี้อาจต้องเข้าสู่ระบบ หากคุณเปิดจากลิงก์ โปรดตรวจสอบว่าลิงก์ถูกต้อง', notfound_adaptive_login: 'เข้าสู่ระบบ', notfound_adaptive_registration_hint: 'การลงทะเบียนอาจใช้ได้ ขึ้นอยู่กับองค์กร', + notfound_adaptive_no_access_title: 'คุณอาจไม่มีสิทธิ์เข้าถึงหน้านี้', + notfound_adaptive_no_access: + 'หน้านี้อาจไม่มีอยู่ หรือบัญชีของคุณอาจไม่มีสิทธิ์เข้าถึง หากคุณเปิดจากลิงก์ โปรดตรวจสอบว่าลิงก์ถูกต้อง', + notfound_adaptive_login_again: 'เข้าสู่ระบบอีกครั้ง', notfound_goto_home: 'ไปที่หน้าแรก', notfound_suggestions_title: 'คุณอาจกำลังมองหา', unexpected_error_title: 'เกิดข้อผิดพลาด', @@ -146,6 +152,7 @@ export const th: TranslationLanguage = { ai_chat_tools_submit_feedback: 'ส่งความคิดเห็น', ai_chat_tools_submit_assistant_feedback: 'ให้คะแนนข้อความก่อนหน้าของผู้ช่วยเป็น ${1}', ai_chat_tools_submitted_feedback: 'ส่งความคิดเห็นแล้ว', + ai_chat_tools_shared_feedback_with_team: 'ส่งความคิดเห็นให้ทีมเอกสารแล้ว', ai_chat_tools_mcp_tool: 'เรียกใช้ ${1}', ai_chat_ask: 'ถาม ${1}', ai_chat_ask_about: 'ถาม ${1} เกี่ยวกับ ${2}', diff --git a/packages/gitbook/src/intl/translations/tr.ts b/packages/gitbook/src/intl/translations/tr.ts index 60c3e45d9..abac29785 100644 --- a/packages/gitbook/src/intl/translations/tr.ts +++ b/packages/gitbook/src/intl/translations/tr.ts @@ -11,6 +11,8 @@ export const tr: TranslationLanguage = { switch_to_system_theme: 'Sistem temasına geç', search: 'Ara', clear: 'Temizle', + table_filtered_by_selection: 'Seçimle eşleşiyor: ${1}', + table_clear_selection: 'Seçimi temizle', tags: 'Etiketler', search_back: 'Arama sonuçlarına geri dön', search_or_ask: 'Sor veya ara', @@ -75,10 +77,15 @@ export const tr: TranslationLanguage = { notfound_title: 'Sayfa bulunamadı', notfound_link: 'Bu bağlantı kaldırılmış veya artık mevcut olmayan bir sayfaya işaret ediyor.', notfound: 'Aradığınız sayfa mevcut değil.', - notfound_adaptive_title: 'Sayfa kullanılamıyor', - notfound_adaptive: 'Bu sayfa mevcut olabilir, ancak erişmek için oturum açmanız gerekebilir.', + notfound_adaptive_title: 'Bu sayfayı görüntülemek için oturum açın', + notfound_adaptive: + 'Bu sayfa oturum açmanızı gerektirebilir. Bir bağlantıyı izlediyseniz doğru olduğunu kontrol edin.', notfound_adaptive_login: 'Oturum aç', notfound_adaptive_registration_hint: 'Kayıt, kuruluşa bağlı olarak mevcut olabilir.', + notfound_adaptive_no_access_title: 'Bu sayfaya erişiminiz olmayabilir', + notfound_adaptive_no_access: + 'Bu sayfa mevcut olmayabilir veya hesabınızın bu sayfaya erişimi olmayabilir. Bir bağlantıyı izlediyseniz doğru olduğunu kontrol edin.', + notfound_adaptive_login_again: 'Tekrar oturum aç', notfound_goto_home: 'Ana sayfaya git', notfound_suggestions_title: 'Şunu arıyor olabilirsiniz', unexpected_error_title: 'Bir hata oluştu', @@ -148,6 +155,7 @@ export const tr: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Geri bildirim gönder', ai_chat_tools_submit_assistant_feedback: "Asistan'ın önceki mesajını ${1} olarak değerlendir", ai_chat_tools_submitted_feedback: 'Geri bildiriminiz gönderildi', + ai_chat_tools_shared_feedback_with_team: 'Geri bildirim dokümantasyon ekibine gönderildi', ai_chat_tools_mcp_tool: '${1} çağrıldı', ai_chat_ask: '${1} sor', ai_chat_ask_about: '${1} için ${2} hakkında sor', diff --git a/packages/gitbook/src/intl/translations/uk.ts b/packages/gitbook/src/intl/translations/uk.ts index 5bf06a0b9..5b3a11abd 100644 --- a/packages/gitbook/src/intl/translations/uk.ts +++ b/packages/gitbook/src/intl/translations/uk.ts @@ -11,6 +11,8 @@ export const uk: TranslationLanguage = { switch_to_system_theme: 'Перемкнути на системну тему', search: 'Пошук', clear: 'Очистити', + table_filtered_by_selection: 'Відповідає вибору: ${1}', + table_clear_selection: 'Очистити вибір', tags: 'Теги', search_back: 'Назад до результатів пошуку', search_or_ask: 'Запитати або шукати', @@ -75,10 +77,15 @@ export const uk: TranslationLanguage = { notfound_title: 'Сторінку не знайдено', notfound_link: 'Це посилання веде на сторінку, яку видалено або якої більше не існує.', notfound: 'Сторінка, яку ви шукаєте, не існує.', - notfound_adaptive_title: 'Сторінка недоступна', - notfound_adaptive: 'Ця сторінка може існувати, але для доступу до неї може знадобитися вхід.', + notfound_adaptive_title: 'Увійдіть, щоб переглянути цю сторінку', + notfound_adaptive: + 'Для перегляду цієї сторінки може знадобитися вхід. Якщо ви перейшли за посиланням, перевірте, чи воно правильне.', notfound_adaptive_login: 'Увійти', notfound_adaptive_registration_hint: 'Реєстрація може бути доступною залежно від організації.', + notfound_adaptive_no_access_title: 'Можливо, у вас немає доступу до цієї сторінки', + notfound_adaptive_no_access: + 'Можливо, ця сторінка не існує або ваш обліковий запис не має до неї доступу. Якщо ви перейшли за посиланням, перевірте, чи воно правильне.', + notfound_adaptive_login_again: 'Увійти знову', notfound_goto_home: 'Перейти на головну', notfound_suggestions_title: 'Можливо, ви шукаєте', unexpected_error_title: 'Сталася помилка', @@ -148,6 +155,7 @@ export const uk: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Надіслати відгук', ai_chat_tools_submit_assistant_feedback: 'Оцініть попереднє повідомлення асистента як ${1}', ai_chat_tools_submitted_feedback: 'Відгук надіслано', + ai_chat_tools_shared_feedback_with_team: 'Відгук надіслано команді документації', ai_chat_tools_mcp_tool: 'Викликано ${1}', ai_chat_ask: 'Запитати ${1}', ai_chat_ask_about: 'Запитати ${1} про ${2}', diff --git a/packages/gitbook/src/intl/translations/vi.ts b/packages/gitbook/src/intl/translations/vi.ts index 654df6acb..8b71c793e 100644 --- a/packages/gitbook/src/intl/translations/vi.ts +++ b/packages/gitbook/src/intl/translations/vi.ts @@ -11,6 +11,8 @@ export const vi: TranslationLanguage = { switch_to_system_theme: 'Chuyển sang giao diện hệ thống', search: 'Tìm kiếm', clear: 'Xóa', + table_filtered_by_selection: 'Khớp với lựa chọn: ${1}', + table_clear_selection: 'Xóa lựa chọn', tags: 'Thẻ', search_back: 'Quay lại kết quả tìm kiếm', search_or_ask: 'Hỏi hoặc tìm kiếm', @@ -75,10 +77,15 @@ export const vi: TranslationLanguage = { notfound_title: 'Không tìm thấy trang', notfound_link: 'Liên kết này trỏ đến một trang đã bị xóa hoặc không còn tồn tại.', notfound: 'Trang bạn đang tìm kiếm không tồn tại.', - notfound_adaptive_title: 'Trang không khả dụng', - notfound_adaptive: 'Trang này có thể tồn tại, nhưng bạn có thể cần đăng nhập để truy cập.', + notfound_adaptive_title: 'Đăng nhập để xem trang này', + notfound_adaptive: + 'Trang này có thể yêu cầu bạn đăng nhập. Nếu bạn truy cập qua một liên kết, hãy kiểm tra xem liên kết đó có đúng không.', notfound_adaptive_login: 'Đăng nhập', notfound_adaptive_registration_hint: 'Việc đăng ký có thể khả dụng tùy theo tổ chức.', + notfound_adaptive_no_access_title: 'Có thể bạn không có quyền truy cập trang này', + notfound_adaptive_no_access: + 'Trang này có thể không tồn tại hoặc tài khoản của bạn có thể không có quyền truy cập. Nếu bạn truy cập qua một liên kết, hãy kiểm tra xem liên kết đó có đúng không.', + notfound_adaptive_login_again: 'Đăng nhập lại', notfound_goto_home: 'Đi tới trang chủ', notfound_suggestions_title: 'Có thể bạn đang tìm', unexpected_error_title: 'Đã xảy ra lỗi', @@ -148,6 +155,7 @@ export const vi: TranslationLanguage = { ai_chat_tools_submit_feedback: 'Gửi phản hồi', ai_chat_tools_submit_assistant_feedback: 'Đánh giá tin nhắn trước đó của Trợ lý là ${1}', ai_chat_tools_submitted_feedback: 'Đã gửi phản hồi', + ai_chat_tools_shared_feedback_with_team: 'Đã gửi phản hồi cho nhóm tài liệu', ai_chat_tools_mcp_tool: 'Đã gọi ${1}', ai_chat_ask: 'Hỏi ${1}', ai_chat_ask_about: 'Hỏi ${1} về ${2}', diff --git a/packages/gitbook/src/intl/translations/yue.ts b/packages/gitbook/src/intl/translations/yue.ts index 34b70519a..a41b887b2 100644 --- a/packages/gitbook/src/intl/translations/yue.ts +++ b/packages/gitbook/src/intl/translations/yue.ts @@ -11,6 +11,8 @@ export const yue: TranslationLanguage = { switch_to_system_theme: '切換到系統主題', search: '搜尋', clear: '清除', + table_filtered_by_selection: '符合選擇:${1}', + table_clear_selection: '清除選擇', tags: '標籤', search_back: '返回搜尋結果', search_or_ask: '發問或搜尋', @@ -73,10 +75,14 @@ export const yue: TranslationLanguage = { notfound_title: '搵唔到頁面', notfound_link: '呢條連結指向嘅頁面已被移除或已經唔存在。', notfound: '你搵緊嘅頁面唔存在。', - notfound_adaptive_title: '頁面無法使用', - notfound_adaptive: '呢個頁面可能存在,但你可能需要登入先可以存取。', + notfound_adaptive_title: '登入先可以睇呢個頁面', + notfound_adaptive: '呢個頁面可能需要登入。如果你係經連結過嚟,請檢查連結係咪正確。', notfound_adaptive_login: '登入', notfound_adaptive_registration_hint: '視乎機構,可能可以註冊。', + notfound_adaptive_no_access_title: '你可能冇權限存取呢個頁面', + notfound_adaptive_no_access: + '呢個頁面可能唔存在,或者你嘅帳戶可能冇權限存取。如果你係經連結過嚟,請檢查連結係咪正確。', + notfound_adaptive_login_again: '重新登入', notfound_goto_home: '前往首頁', notfound_suggestions_title: '你可能喺度搵緊', unexpected_error_title: '發生錯誤', @@ -145,6 +151,7 @@ export const yue: TranslationLanguage = { ai_chat_tools_submit_feedback: '提交反饋', ai_chat_tools_submit_assistant_feedback: '將助手嘅上一則訊息評為 ${1}', ai_chat_tools_submitted_feedback: '已提交反饋', + ai_chat_tools_shared_feedback_with_team: '已將意見發送給文件團隊', ai_chat_tools_mcp_tool: '已呼叫 ${1}', ai_chat_ask: '問 ${1}', ai_chat_ask_about: '問 ${1} 關於${2}', diff --git a/packages/gitbook/src/intl/translations/zh-tw.ts b/packages/gitbook/src/intl/translations/zh-tw.ts index 09ef175c6..de65588c6 100644 --- a/packages/gitbook/src/intl/translations/zh-tw.ts +++ b/packages/gitbook/src/intl/translations/zh-tw.ts @@ -11,6 +11,8 @@ export const zh_tw: TranslationLanguage = { switch_to_system_theme: '切換至系統主題', search: '搜尋', clear: '清除', + table_filtered_by_selection: '符合選擇:${1}', + table_clear_selection: '清除選擇', tags: '標籤', search_back: '返回搜尋結果', search_or_ask: '詢問或搜尋', @@ -73,10 +75,14 @@ export const zh_tw: TranslationLanguage = { notfound_title: '找不到頁面', notfound_link: '此連結指向的頁面已被移除或不再存在。', notfound: '您要尋找的頁面不存在。', - notfound_adaptive_title: '頁面無法使用', - notfound_adaptive: '此頁面可能存在,但您可能需要登入才能存取。', + notfound_adaptive_title: '登入以檢視此頁面', + notfound_adaptive: '此頁面可能需要登入。如果您是透過連結前往,請確認連結是否正確。', notfound_adaptive_login: '登入', notfound_adaptive_registration_hint: '視組織而定,可能可以註冊。', + notfound_adaptive_no_access_title: '您可能沒有此頁面的存取權限', + notfound_adaptive_no_access: + '此頁面可能不存在,或您的帳戶可能沒有存取權限。如果您是透過連結前往,請確認連結是否正確。', + notfound_adaptive_login_again: '重新登入', notfound_goto_home: '前往首頁', notfound_suggestions_title: '您可能在尋找', unexpected_error_title: '發生錯誤', @@ -145,6 +151,7 @@ export const zh_tw: TranslationLanguage = { ai_chat_tools_submit_feedback: '提交意見回饋', ai_chat_tools_submit_assistant_feedback: '將助理的上一則訊息評為 ${1}', ai_chat_tools_submitted_feedback: '已提交意見回饋', + ai_chat_tools_shared_feedback_with_team: '已將意見回饋傳送給文件團隊', ai_chat_tools_mcp_tool: '已呼叫 ${1}', ai_chat_ask: '詢問 ${1}', ai_chat_ask_about: '詢問 ${1} 關於${2}', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 0f9834378..9e780e7d2 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -11,6 +11,8 @@ export const zh: TranslationLanguage = { switch_to_system_theme: '切换到系统主题', search: '搜索', clear: '清除', + table_filtered_by_selection: '符合选择:${1}', + table_clear_selection: '清除选择', tags: '标签', search_back: '返回搜索结果', search_or_ask: '询问或搜索', @@ -74,10 +76,14 @@ export const zh: TranslationLanguage = { notfound_title: '页面未找到', notfound_link: '此链接指向已被删除或不再存在的页面。', notfound: '您要找的页面不存在。', - notfound_adaptive_title: '页面无法访问', - notfound_adaptive: '该页面可能存在,但您可能需要登录后才能访问。', + notfound_adaptive_title: '登录以查看此页面', + notfound_adaptive: '此页面可能需要登录。如果您是通过链接访问的,请检查链接是否正确。', notfound_adaptive_login: '登录', notfound_adaptive_registration_hint: '视组织而定,可能可以注册。', + notfound_adaptive_no_access_title: '您可能没有此页面的访问权限', + notfound_adaptive_no_access: + '此页面可能不存在,或您的账户可能没有访问权限。如果您是通过链接访问的,请检查链接是否正确。', + notfound_adaptive_login_again: '重新登录', notfound_goto_home: '前往首页', notfound_suggestions_title: '您可能在寻找', unexpected_error_title: '发生错误', @@ -146,6 +152,7 @@ export const zh: TranslationLanguage = { ai_chat_tools_submit_feedback: '提交反馈', ai_chat_tools_submit_assistant_feedback: '将助手的上一条消息评为 ${1}', ai_chat_tools_submitted_feedback: '已提交反馈', + ai_chat_tools_shared_feedback_with_team: '已将反馈发送给文档团队', ai_chat_tools_mcp_tool: '调用了 ${1}', ai_chat_ask: '向 ${1} 提问', ai_chat_ask_about: '向 ${1} 询问${2}', diff --git a/packages/gitbook/src/lib/agentFeedback/index.test.ts b/packages/gitbook/src/lib/agentFeedback/index.test.ts new file mode 100644 index 000000000..ca8f33d4f --- /dev/null +++ b/packages/gitbook/src/lib/agentFeedback/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'bun:test'; + +import { formatAgentFeedbackPageRef, parseAgentFeedbackPageURL } from './index'; + +const siteURL = 'https://docs.example.com/product'; + +describe('parseAgentFeedbackPageURL', () => { + it('keeps an absolute URL', () => { + expect(parseAgentFeedbackPageURL(`${siteURL}/getting-started`, siteURL)).toBe( + `${siteURL}/getting-started` + ); + }); + + it('resolves a relative reference against the site', () => { + expect(parseAgentFeedbackPageURL('getting-started', siteURL)).toBe( + 'https://docs.example.com/getting-started' + ); + }); + + it('accepts a URL outside of the site, which the caller then has to reject', () => { + expect(parseAgentFeedbackPageURL('https://example.com/other', siteURL)).toBe( + 'https://example.com/other' + ); + }); + + it('rejects a non-HTTP protocol', () => { + expect(parseAgentFeedbackPageURL('javascript:alert(1)', siteURL)).toBeNull(); + expect(parseAgentFeedbackPageURL('mailto:team@example.com', siteURL)).toBeNull(); + }); + + it('rejects a relative reference when the site has no published URL', () => { + expect(parseAgentFeedbackPageURL('getting-started', undefined)).toBeNull(); + }); +}); + +describe('formatAgentFeedbackPageRef', () => { + it('formats a page pointer as a content ref', () => { + expect(formatAgentFeedbackPageRef({ spaceId: 'space-1', pageId: 'page-1' })).toBe( + '/spaces/space-1/pages/page-1' + ); + }); +}); diff --git a/packages/gitbook/src/lib/agentFeedback/index.ts b/packages/gitbook/src/lib/agentFeedback/index.ts new file mode 100644 index 000000000..84540ab8a --- /dev/null +++ b/packages/gitbook/src/lib/agentFeedback/index.ts @@ -0,0 +1,55 @@ +import { checkIsHttpURL } from '../urls'; + +/** Matches the `feedback` length accepted by `submitSiteAgentFeedback`. */ +export const AGENT_FEEDBACK_MAX_LENGTH = 2048; + +/** Matches the `AgentGoal` length accepted by `submitSiteAgentFeedback`. */ +export const AGENT_FEEDBACK_GOAL_MAX_LENGTH = 1024; + +/** Shared by every agent surface (MCP server, AI assistant) so findings arrive in one shape. */ +export const agentFeedbackDescriptions = { + finding: `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 ${AGENT_FEEDBACK_MAX_LENGTH} characters.`, + + goal: 'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.', + + pageURL: (siteURL: string | undefined) => + `The full URL of the page the issue is about${siteURL ? ` (e.g. ${siteURL}/getting-started)` : ''}, so the finding is linked to the exact page.`, + + pageReference: + 'The URL of the page the issue is about, so the finding is linked to the exact page. Omit it to report about the page the user is currently viewing.', +}; + +/** Page a finding was recorded against, so callers can attribute an insights event to it. */ +export type AgentFeedbackPage = { + url: string; + spaceId: string; + pageId: string; + revisionId: string; +}; + +export type AgentFeedbackResult = + | { submitted: true; page: AgentFeedbackPage } + | { submitted: false; error: string }; + +/** Normalize a page reference into an absolute HTTP URL, resolving relative ones against the site. */ +export function parseAgentFeedbackPageURL( + value: string, + siteURL: string | undefined +): string | null { + const candidate = URL.canParse(value) + ? new URL(value) + : siteURL && URL.canParse(value, siteURL) + ? new URL(value, siteURL) + : null; + + if (!candidate || !checkIsHttpURL(candidate)) { + return null; + } + + return candidate.toString(); +} + +/** Format a page as the content-ref string the assistant uses to reference pages. */ +export function formatAgentFeedbackPageRef(page: { spaceId: string; pageId: string }): string { + return `/spaces/${page.spaceId}/pages/${page.pageId}`; +} diff --git a/packages/gitbook/src/lib/agentFeedback/server.ts b/packages/gitbook/src/lib/agentFeedback/server.ts new file mode 100644 index 000000000..1ab7c8202 --- /dev/null +++ b/packages/gitbook/src/lib/agentFeedback/server.ts @@ -0,0 +1,115 @@ +import 'server-only'; +import type { AgentFeedbackSource } from '@gitbook/api'; + +import { type AgentFeedbackPage, type AgentFeedbackResult, parseAgentFeedbackPageURL } from '.'; +import type { GitBookSiteContext } from '../context'; +import { throwIfDataError } from '../data'; +import { resolveContentRef, resolveStringContentRef, toContentRefPath } from '../references'; +import { findSiteSpaceByUrl, resolveSiteSpacePagePath } from '../sites'; + +/** Record a finding about the content of the site, submitted by an agent while helping a user. */ +export async function submitAgentFeedback( + context: GitBookSiteContext, + input: { + feedback: string; + goal?: string; + /** Page the finding is about, as a URL on the site or a `/spaces/…/pages/…` ref. */ + page: string; + source: AgentFeedbackSource; + } +): Promise<AgentFeedbackResult> { + const feedback = input.feedback.trim(); + if (!feedback) { + return { submitted: false, error: 'The feedback cannot be empty.' }; + } + + const page = await resolveAgentFeedbackPage(context, input.page); + if (!page) { + return { submitted: false, error: `Page not found: "${input.page}"` }; + } + + const goal = input.goal?.trim(); + + const api = await context.dataFetcher.api(); + await api.orgs.submitSiteAgentFeedback(context.organizationId, context.site.id, { + feedback, + url: page.url, + spaceId: page.spaceId, + pageId: page.pageId, + ...(goal ? { goal } : {}), + source: input.source, + }); + + return { submitted: true, page }; +} + +/** Resolve the page a finding is about, from a URL on the site or a `/spaces/…/pages/…` ref. */ +async function resolveAgentFeedbackPage( + context: GitBookSiteContext, + reference: string +): Promise<AgentFeedbackPage | null> { + return ( + (await resolvePageFromContentRef(context, reference)) ?? + (await resolvePageFromURL(context, reference)) + ); +} + +async function resolvePageFromContentRef( + context: GitBookSiteContext, + reference: string +): Promise<AgentFeedbackPage | null> { + const contentRef = resolveStringContentRef(toContentRefPath(reference)); + if (contentRef?.kind !== 'page') { + return null; + } + + const resolved = await resolveContentRef(contentRef, context); + if (!resolved) { + return null; + } + + // A ref pointing at a page group resolves to its first document, so trust the resolved page + // and space over the ids in the ref itself. + return { + url: URL.canParse(resolved.href) + ? resolved.href + : context.linker.toAbsoluteURL(resolved.href), + spaceId: resolved.space?.id ?? contentRef.space ?? context.space.id, + pageId: resolved.page?.id ?? contentRef.page, + revisionId: resolved.space?.revision ?? context.revisionId, + }; +} + +async function resolvePageFromURL( + context: GitBookSiteContext, + reference: string +): Promise<AgentFeedbackPage | null> { + const url = parseAgentFeedbackPageURL(reference, context.siteSpace.urls.published); + if (!url) { + return null; + } + + const match = findSiteSpaceByUrl(context.structure, url); + if (!match) { + return null; + } + + const revision = await throwIfDataError( + context.dataFetcher.getRevision({ + spaceId: match.siteSpace.space.id, + revisionId: match.siteSpace.space.revision, + }) + ); + + const resolved = resolveSiteSpacePagePath(match.siteSpace, revision.pages, match.pagePath); + if (!resolved) { + return null; + } + + return { + url, + spaceId: match.siteSpace.space.id, + pageId: resolved.page.id, + revisionId: match.siteSpace.space.revision, + }; +} diff --git a/packages/gitbook/src/lib/ai-agents.test.ts b/packages/gitbook/src/lib/ai-agents.test.ts new file mode 100644 index 000000000..c91e73086 --- /dev/null +++ b/packages/gitbook/src/lib/ai-agents.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test'; + +import { AI_AGENTS, getAIAgent, isAIAgentId } from './ai-agents'; + +describe('AI_AGENTS', () => { + it('builds a deep link for each agent', () => { + expect(AI_AGENTS.map((agent) => agent.getURL('hello'))).toEqual([ + 'claude://code/new?q=hello', + 'codex://new?prompt=hello', + 'cursor://anysphere.cursor-deeplink/prompt?text=hello', + ]); + }); + + it('encodes prompts so their content never leaks into the query string', () => { + const prompt = 'Fix the bug in a/b.ts?\n#1 & be nice'; + + for (const agent of AI_AGENTS) { + const url = agent.getURL(prompt); + // Everything past the single `=` is the encoded prompt, so the separators a prompt can + // contain (`?`, `#`, `&`, newlines) can't be read as URL syntax. + const [prefix, ...rest] = url.split('='); + expect(rest).toHaveLength(1); + expect(prefix).not.toContain(' '); + expect(decodeURIComponent(rest.join(''))).toBe(prompt); + } + }); +}); + +describe('isAIAgentId', () => { + it('accepts known agents', () => { + expect(isAIAgentId('claude')).toBe(true); + expect(isAIAgentId('codex')).toBe(true); + expect(isAIAgentId('cursor')).toBe(true); + }); + + it('rejects anything else', () => { + expect(isAIAgentId('copy')).toBe(false); + expect(isAIAgentId('chatgpt')).toBe(false); + expect(isAIAgentId(undefined)).toBe(false); + expect(isAIAgentId({ id: 'claude' })).toBe(false); + }); +}); + +describe('getAIAgent', () => { + it('returns the agent for a known id', () => { + expect(getAIAgent('cursor').label).toBe('Cursor'); + }); +}); diff --git a/packages/gitbook/src/lib/ai-agents.ts b/packages/gitbook/src/lib/ai-agents.ts new file mode 100644 index 000000000..63d94e706 --- /dev/null +++ b/packages/gitbook/src/lib/ai-agents.ts @@ -0,0 +1,70 @@ +import type { IconName } from '@gitbook/icons'; + +/** + * A coding agent a prompt can be handed to. + */ +export interface AIAgent { + id: string; + label: string; + icon: IconName; + /** The agent's deep link, opening it with the prompt typed in but not sent. */ + getURL: (prompt: string) => string; +} + +/** + * Coding agents a prompt can be handed to, in the order menus offer them. + * + * Each link opens the agent installed on the visitor's machine, so the prompt never leaves their + * computer. Each also caps how much it carries — around 14,000 characters for Claude, 8,000 for + * Cursor. + * + * Claude Code's own `claude-cli://` scheme is deliberately absent: on macOS it launches by having + * AppleScript *type* its command into a terminal, where the tty cuts the line at 1,024 bytes and + * silently truncates the prompt. `claude://code/new` opens the same session inside the desktop app, + * with no terminal in between. + */ +export const AI_AGENTS = [ + { + id: 'claude', + label: 'Claude', + icon: 'claude', + // https://support.claude.com/en/articles/14729294-open-claude-desktop-with-a-link + getURL: (prompt: string) => `claude://code/new?q=${encodeURIComponent(prompt)}`, + }, + { + id: 'codex', + label: 'Codex', + // No Codex icon in the library; the OpenAI mark it shares with ChatGPT stands in, as it + // already does for the Codex MCP page action. + icon: 'chatgpt', + getURL: (prompt: string) => `codex://new?prompt=${encodeURIComponent(prompt)}`, + }, + { + id: 'cursor', + label: 'Cursor', + icon: 'cursor', + // https://cursor.com/docs/integrations/deeplinks + getURL: (prompt: string) => + `cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(prompt)}`, + }, +] as const satisfies readonly AIAgent[]; + +export type AIAgentId = (typeof AI_AGENTS)[number]['id']; + +/** + * Find an agent by its identifier. + */ +export function getAIAgent(id: AIAgentId): (typeof AI_AGENTS)[number] { + const agent = AI_AGENTS.find((agent) => agent.id === id); + if (!agent) { + throw new Error(`Unknown AI agent: ${id}`); + } + return agent; +} + +/** + * Check that a value is a known agent identifier, to validate persisted or external input. + */ +export function isAIAgentId(value: unknown): value is AIAgentId { + return AI_AGENTS.some((agent) => agent.id === value); +} diff --git a/packages/gitbook/src/lib/aiCatalog/index.test.ts b/packages/gitbook/src/lib/aiCatalog/index.test.ts new file mode 100644 index 000000000..62a3e34f7 --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/index.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'bun:test'; + +import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api'; + +import { + AI_CATALOG_MEDIA_TYPE, + AI_CATALOG_SPEC_VERSION, + type AiCatalog, + buildSiteAiCatalog, + serveSiteAiCatalog, +} from '.'; +import type { GitBookSiteContext } from '@/lib/context'; +import { createLinker } from '@/lib/links'; + +/** + * `urn:air:{publisher}:{namespace}:{name}`, per the AI Catalog specification. + */ +const AIR_URN_PATTERN = /^urn:air:[^:]+:[^:]+:[^:]+$/; + +function makeContext( + options: { + host?: string; + siteBasePath?: string; + siteId?: string; + visibility?: SiteVisibility; + pageActions?: CustomizationPageActionType[]; + } = {} +): GitBookSiteContext { + const { + host = 'docs.acme.org', + siteBasePath = '/', + siteId = 'site_123', + visibility = SiteVisibility.Public, + pageActions = [CustomizationPageActionType.Mcp], + } = options; + + return { + site: { id: siteId, title: 'Acme', visibility }, + customization: { pageActions: { items: pageActions } }, + linker: createLinker({ host, siteBasePath, spaceBasePath: siteBasePath }), + } as unknown as GitBookSiteContext; +} + +describe('buildSiteAiCatalog', () => { + it('advertises the site MCP server card', () => { + const catalog = buildSiteAiCatalog(makeContext()); + + expect(catalog).toEqual({ + specVersion: AI_CATALOG_SPEC_VERSION, + entries: [ + { + identifier: 'urn:air:docs.acme.org:mcp:site_123', + type: 'application/mcp-server-card+json', + url: 'https://docs.acme.org/~gitbook/mcp/server-card', + }, + ], + }); + }); + + it('builds an identifier the specification can parse', () => { + const catalog = buildSiteAiCatalog(makeContext({ siteId: 'site_A1b2-c3' })); + + expect(catalog?.entries[0]?.identifier).toMatch(AIR_URN_PATTERN); + }); + + it('omits fields the referenced card is authoritative for', () => { + const entry = buildSiteAiCatalog(makeContext())?.entries[0]; + + expect(entry).not.toHaveProperty('displayName'); + expect(entry).not.toHaveProperty('description'); + expect(entry).not.toHaveProperty('version'); + }); + + it.each([ + { + scenario: 'a custom domain', + options: { host: 'docs.acme.org', siteBasePath: '/' }, + identifier: 'urn:air:docs.acme.org:mcp:site_123', + url: 'https://docs.acme.org/~gitbook/mcp/server-card', + }, + { + scenario: 'a gitbook.io subdomain', + options: { host: 'acme.gitbook.io', siteBasePath: '/' }, + identifier: 'urn:air:acme.gitbook.io:mcp:site_123', + url: 'https://acme.gitbook.io/~gitbook/mcp/server-card', + }, + { + scenario: 'a subpath site', + options: { host: 'gitbook.com', siteBasePath: '/docs/' }, + identifier: 'urn:air:gitbook.com:mcp:site_123', + url: 'https://gitbook.com/docs/~gitbook/mcp/server-card', + }, + ])('anchors the entry to $scenario', ({ options, identifier, url }) => { + const entry = buildSiteAiCatalog(makeContext(options))?.entries[0]; + + expect(entry?.identifier).toBe(identifier); + expect(entry?.url).toBe(url); + }); + + it('drops the port from the publisher, which a URN would read as a separator', () => { + const context = makeContext(); + const linker = createLinker({ + host: 'localhost:3000', + siteBasePath: '/', + spaceBasePath: '/', + }); + const entry = buildSiteAiCatalog({ ...context, linker })?.entries[0]; + + expect(entry?.identifier).toBe('urn:air:localhost:mcp:site_123'); + expect(entry?.identifier).toMatch(AIR_URN_PATTERN); + }); + + it('advertises nothing when the site publishes no MCP server', () => { + expect(buildSiteAiCatalog(makeContext({ pageActions: [] }))).toBeNull(); + }); +}); + +describe('serveSiteAiCatalog', () => { + const catalogRequest = (init?: RequestInit) => + new Request('https://docs.acme.org/.well-known/ai-catalog.json', init); + + it('serves the catalog with its media type and CORS headers', async () => { + const res = await serveSiteAiCatalog(makeContext(), catalogRequest()); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe(`${AI_CATALOG_MEDIA_TYPE}; charset=utf-8`); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('cache-control')).toBe('public, max-age=3600'); + + const catalog = (await res.json()) as AiCatalog; + expect(catalog.specVersion).toBe(AI_CATALOG_SPEC_VERSION); + expect(catalog.entries).toHaveLength(1); + }); + + it('answers a preflight with a 204', async () => { + const res = await serveSiteAiCatalog(makeContext(), catalogRequest({ method: 'OPTIONS' })); + + expect(res.status).toBe(204); + }); + + it('returns 304 when the client already has the current catalog', async () => { + const context = makeContext(); + const first = await serveSiteAiCatalog(context, catalogRequest()); + const etag = first.headers.get('etag'); + + const revalidated = await serveSiteAiCatalog( + context, + catalogRequest({ headers: { 'If-None-Match': `W/${etag}` } }) + ); + + expect(revalidated.status).toBe(304); + }); + + it.each([SiteVisibility.ShareLink, SiteVisibility.VisitorAuth])( + 'never caches the catalog of a %s site', + async (visibility) => { + const res = await serveSiteAiCatalog(makeContext({ visibility }), catalogRequest()); + + expect(res.headers.get('cache-control')).toBe('no-store'); + } + ); + + it('does not serve a catalog when there is nothing to advertise', async () => { + const res = await serveSiteAiCatalog(makeContext({ pageActions: [] }), catalogRequest()); + + expect(res.status).toBe(404); + }); +}); diff --git a/packages/gitbook/src/lib/aiCatalog/index.ts b/packages/gitbook/src/lib/aiCatalog/index.ts new file mode 100644 index 000000000..9a92f624e --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/index.ts @@ -0,0 +1,90 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { serveDiscoveryDocument } from '@/lib/discoveryDocument'; +import { isSiteMcpEnabled } from '@/lib/mcp/endpoints'; +import { MCP_SERVER_CARD_PATH } from '@/lib/mcp/paths'; +import { SERVER_CARD_MEDIA_TYPE } from '@/lib/mcp/serverCard'; + +export const AI_CATALOG_MEDIA_TYPE = 'application/ai-catalog+json'; +export const AI_CATALOG_SPEC_VERSION = '1.0'; + +/** + * The AI Catalog a site publishes at `/.well-known/ai-catalog.json`, per the + * [AI Catalog specification](https://github.com/Agent-Card/ai-catalog). + */ +export interface AiCatalog { + /** + * Required. The specification version, in "Major.Minor" form. + */ + specVersion: string; + /** + * Required, and may be empty. + */ + entries: AiCatalogEntry[]; +} + +export interface AiCatalogEntry { + /** + * Required. Domain-anchored `urn:air:{publisher}:{namespace}:{name}`. + */ + identifier: string; + /** + * Required. The media type of the artifact the entry points at. + */ + type: string; + /** + * Where the artifact can be retrieved. Exactly one of `url` or `data` is used. + */ + url: string; +} + +/** + * Build the catalog of AI artifacts the site advertises, or `null` when it advertises none. + */ +export function buildSiteAiCatalog(context: GitBookSiteContext): AiCatalog | null { + const { linker, site } = context; + + const entries: AiCatalogEntry[] = []; + + if (isSiteMcpEnabled(context)) { + entries.push({ + identifier: buildCatalogIdentifier(context, 'mcp', site.id), + type: SERVER_CARD_MEDIA_TYPE, + url: linker.toAbsoluteURL(linker.toPathInSite(MCP_SERVER_CARD_PATH)), + }); + } + + if (entries.length === 0) { + return null; + } + + return { specVersion: AI_CATALOG_SPEC_VERSION, entries }; +} + +/** + * Serve the site's catalog, or a 404 when the site advertises nothing to discover. + */ +export async function serveSiteAiCatalog( + context: GitBookSiteContext, + request: Request +): Promise<Response> { + const catalog = buildSiteAiCatalog(context); + if (!catalog) { + return new Response('Not Found', { status: 404 }); + } + + return serveDiscoveryDocument(context, request, { + document: catalog, + mediaType: AI_CATALOG_MEDIA_TYPE, + }); +} + +function buildCatalogIdentifier( + context: GitBookSiteContext, + namespace: string, + name: string +): string { + const publisher = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(''))) + .hostname; + + return `urn:air:${publisher}:${namespace}:${name}`; +} diff --git a/packages/gitbook/src/lib/aiCatalog/paths.ts b/packages/gitbook/src/lib/aiCatalog/paths.ts new file mode 100644 index 000000000..c0a254aa4 --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/paths.ts @@ -0,0 +1,10 @@ +/** + * Internal route the catalog is served from, which the well-known path rewrites to. + */ +export const AI_CATALOG_PATH = '~gitbook/ai-catalog'; + +/** + * Where domain-level discovery looks. Unlike a server card, a catalog *is* site-wide metadata, so + * `.well-known` is the spec's own home for it rather than a concession to scanners. + */ +export const AI_CATALOG_WELL_KNOWN_PATH = '.well-known/ai-catalog.json'; diff --git a/packages/gitbook/src/lib/ask-prompt.ts b/packages/gitbook/src/lib/ask-prompt.ts new file mode 100644 index 000000000..ef73b6a63 --- /dev/null +++ b/packages/gitbook/src/lib/ask-prompt.ts @@ -0,0 +1,59 @@ +import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import type { GitBookSiteContext } from '@/lib/context'; +import { linkerWithMarkdownPages } from '@/lib/links'; + +/** + * Render the "Querying This Documentation" section of the agent instructions. + * `pageUrl` is the URL of the current page, which the `ask` and `goal` parameters are appended to. + */ +export function renderQueryingDocumentation(options: { pageUrl: string }): string { + const { pageUrl } = options; + return `Perform an HTTP GET request on the following URL with the \`ask\` and \`goal\` query parameters: + +\`\`\` +GET ${pageUrl}?ask=<question>&goal=<user_goal> +\`\`\` + +\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language. +\`goal\` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with \`ask=how do I create an API token\`, a goal like \`automate deployments from our CI pipeline\` lets GitBook tailor the answer to that use case. + +The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.`; +} + +const AGENT_INSTRUCTIONS_INTRO = `# Agent Instructions +This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com. + +## Querying This Documentation`; + +/** + * Render the "Agent Instructions" of a markdown page (callers add the separator). + */ +export function renderPageAgentInstructions(options: { pageUrl: string }): string { + return `${AGENT_INSTRUCTIONS_INTRO} +If you need additional information that is not directly available in this page, you can query the documentation by asking a question. + +${renderQueryingDocumentation(options)} + +Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections. +`; +} + +/** + * Render the "Agent Instructions" of the site-wide llms.txt and llms-full.txt files. + * Returns `undefined` when AI is disabled for the site. + */ +export function renderSiteAgentInstructions(context: GitBookSiteContext): string | undefined { + if (!isAIEnabled(context.customization.ai.mode)) { + return undefined; + } + + // The ask endpoint ignores the page path, so the top-level `index.md` works on every site. + const linker = linkerWithMarkdownPages(context.linker); + const pageUrl = linker.toAbsoluteURL(linker.toPathForPagePath({ path: 'index' })); + + return `${AGENT_INSTRUCTIONS_INTRO} +This site has an agentic ask interface you may use to query the documentation dynamically by asking a question. + +${renderQueryingDocumentation({ pageUrl })} +`; +} diff --git a/packages/gitbook/src/lib/cache.test.ts b/packages/gitbook/src/lib/cache.test.ts index 4a54f6499..fb155ad4f 100644 --- a/packages/gitbook/src/lib/cache.test.ts +++ b/packages/gitbook/src/lib/cache.test.ts @@ -67,6 +67,17 @@ describe('withStableRef', () => { expect(ref1).not.toBe(ref2); }); + it('should preserve value types in object references', () => { + const toStableRef = withStableRef(); + const numeric = { id: 1 }; + const textual = { id: '1' }; + + expect(toStableRef(numeric)).toBe(numeric); + expect(toStableRef(textual)).toBe(textual); + expect(toStableRef({ id: 1 })).toBe(numeric); + expect(toStableRef({ id: '1' })).toBe(textual); + }); + it('should maintain reference stability across multiple calls', () => { const toStableRef = withStableRef(); diff --git a/packages/gitbook/src/lib/chatgpt.ts b/packages/gitbook/src/lib/chatgpt.ts index ee0e3083e..d46a02ca3 100644 --- a/packages/gitbook/src/lib/chatgpt.ts +++ b/packages/gitbook/src/lib/chatgpt.ts @@ -6,6 +6,7 @@ export function isChatGPTRequest(request: Pick<Request, 'headers'>): boolean { return ( normalizedUserAgent.includes('chatgpt-user') || normalizedUserAgent.includes('chatgpt agent') || + normalizedUserAgent.includes('oai-searchbot') || normalizedSignatureAgent.includes('chatgpt.com') ); } diff --git a/packages/gitbook/src/lib/colors.ts b/packages/gitbook/src/lib/colors.ts index 0e6c7b422..9347ff040 100644 --- a/packages/gitbook/src/lib/colors.ts +++ b/packages/gitbook/src/lib/colors.ts @@ -1,9 +1,7 @@ -import type { DocumentMarkColor } from '@gitbook/api'; +import type { DocumentTextColor } from '@gitbook/api'; import type { ClassValue } from '@/lib/tailwind'; -type DocumentTextColor = DocumentMarkColor['data']['text'] | 'pink' | 'violet' | 'cyan' | '$tint'; - export const textColorToStyle = { default: [], blue: ['text-[#0067d1] dark:text-[#7dbcff]'], diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 97cd2adec..6e969f2ae 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -519,6 +519,7 @@ export async function fetchSpaceContextByIds( shareKey: string | undefined; changeRequest: string | undefined; revision: string | undefined; + revisionMetadata?: boolean; } ): Promise<GitBookSpaceContext> { const { dataFetcher } = baseContext; @@ -552,6 +553,7 @@ export async function fetchSpaceContextByIds( dataFetcher.getRevision({ spaceId: ids.space, revisionId, + ...(ids.revisionMetadata ? { metadata: true } : {}), }), // When trying to render a revision with an invalid / non-existing ID, diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index 29a77d0f4..ebacb9bdf 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -80,6 +80,7 @@ export function createDataFetcher( return getRevision(input, { spaceId: params.spaceId, revisionId: params.revisionId, + metadata: params.metadata ?? false, }); }, getRevisionPageByPath(params) { @@ -87,6 +88,8 @@ export function createDataFetcher( spaceId: params.spaceId, revisionId: params.revisionId, path: params.path, + metadata: params.metadata, + cachedMetadata: params.cachedMetadata, }); }, getRevisionPageMarkdown(params) { @@ -317,8 +320,15 @@ const getChangeRequest = cache( // We don't use remote cache on vercel because of the 2Mb limit on cache size that makes some route crash const getRevision = cache( - async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => { + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { 'use cache'; + if (params.metadata) { + // Git paths can change without changing the content revision. + cacheTag(getCacheTag({ tag: 'space', space: params.spaceId })); + } return wrapDataFetcherError(async () => { return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { const api = apiClient(input); @@ -326,7 +336,7 @@ const getRevision = cache( params.spaceId, params.revisionId, { - metadata: false, + metadata: params.metadata, }, { ...noCacheFetchOptions, @@ -526,7 +536,13 @@ const getRevisionReusableContentDocument = cache( const getRevisionPageByPath = cache( async ( input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string } + params: { + spaceId: string; + revisionId: string; + path: string; + metadata?: boolean; + cachedMetadata?: boolean; + } ) => { 'use cache'; return wrapDataFetcherError(async () => { @@ -535,13 +551,15 @@ const getRevisionPageByPath = cache( async () => { const encodedPath = encodeURIComponent(params.path); const api = apiClient(input); + const query = { + metadata: params.metadata ?? false, + cachedMetadata: params.cachedMetadata ?? false, + }; const res = await api.spaces.getPageInRevisionByPath( params.spaceId, params.revisionId, encodedPath, - { - metadata: false, - }, + query, { ...noCacheFetchOptions, } diff --git a/packages/gitbook/src/lib/data/lookup.test.ts b/packages/gitbook/src/lib/data/lookup.test.ts new file mode 100644 index 000000000..878a3a5ce --- /dev/null +++ b/packages/gitbook/src/lib/data/lookup.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; + +import type { PublishedSiteContentLookup } from '@gitbook/api'; + +import { GITBOOK_PREVIEW_BASE_URL } from '../env'; +import * as api from './api'; +import { lookupPublishedContentByUrl } from './lookup'; + +describe('preview auth redirects', () => { + afterEach(() => { + apiClientSpy?.mockRestore(); + }); + + let apiClientSpy: ReturnType<typeof spyOn> | undefined; + + it.each([ + 'site_foo/~/changes/66', + 'site_foo/~/revisions/revision_123', + 'site_foo', + 'site_foo/~/changes/66/hello%20world?theme=dark&value=a%26b&value=c%2Bd', + 'site_foo/~/revisions/revision_123/guide?next=%2Fsome%3Fpath%3D1&empty=', + 'site_foo?theme=dark', + ])('preserves the requested URL for %s', async (path) => { + const requestURL = new URL(path, GITBOOK_PREVIEW_BASE_URL); + const authURL = new URL('https://app.gitbook.com/o/org_foo/sites/site_foo/preview/auth'); + apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({ + urls: { + async resolvePublishedContentByUrl({ url }: { url: string }) { + // The API uses the lookup URL as the return target for preview authentication. + const redirect = new URL(authURL); + redirect.searchParams.set('redirect', url); + return { data: { target: 'application', redirect: redirect.toString() } }; + }, + }, + } as ReturnType<typeof api.apiClient>); + + const result = await lookupPublishedContentByUrl({ + url: requestURL.toString(), + apiToken: null, + redirectOnError: false, + visitorPayload: {}, + }); + + expect(result.error).toBeUndefined(); + if (!result.data || !('redirect' in result.data)) { + throw new Error('Expected an authentication redirect'); + } + const redirect = new URL(result.data.redirect); + expect(redirect.origin + redirect.pathname).toBe(authURL.toString()); + expect(redirect.searchParams.get('redirect')).toBe(requestURL.toString()); + }); + + it.each([ + { + name: 'non-preview application redirects', + requestURL: 'https://docs.example.com/~/changes/66', + target: 'application', + redirect: 'https://app.gitbook.com/o/org_foo/sites/site_foo', + expectedRedirect: 'https://app.gitbook.com/o/org_foo/sites/site_foo', + }, + { + name: 'preview content redirects with a remaining page path', + requestURL: new URL( + 'site_foo/~/changes/66/hello%20world', + GITBOOK_PREVIEW_BASE_URL + ).toString(), + target: 'content', + redirect: 'https://docs.example.com/section?theme=dark', + expectedRedirect: 'https://docs.example.com/section/hello%20world?theme=dark', + }, + { + name: 'preview external redirects with a remaining page path', + requestURL: new URL( + 'site_foo/~/revisions/revision_123/hello%20world', + GITBOOK_PREVIEW_BASE_URL + ).toString(), + target: 'external', + redirect: 'https://auth.example.com/login?location=%2Fsection&state=keep', + expectedRedirect: + 'https://auth.example.com/login?location=%2Fsection%2Fhello%2520world&state=keep', + }, + ] as const)( + 'preserves handling of $name', + async ({ requestURL, target, redirect, expectedRedirect }) => { + const data: PublishedSiteContentLookup = + target === 'external' + ? { target, redirect, site: 'site_foo' } + : { target, redirect }; + apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({ + urls: { + async resolvePublishedContentByUrl() { + return { data }; + }, + }, + } as unknown as ReturnType<typeof api.apiClient>); + + const result = await lookupPublishedContentByUrl({ + url: requestURL, + apiToken: null, + redirectOnError: false, + visitorPayload: {}, + }); + + expect(result).toEqual({ data: { ...data, redirect: expectedRedirect } }); + } + ); +}); + +describe('visitor payload', () => { + it('forwards the visitor country to the API', async () => { + const calls: { visitor?: unknown }[] = []; + const apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({ + urls: { + async resolvePublishedContentByUrl(body: { visitor?: unknown }) { + calls.push(body); + return { data: { target: 'external', redirect: 'https://example.com' } }; + }, + }, + } as unknown as ReturnType<typeof api.apiClient>); + + try { + await lookupPublishedContentByUrl({ + url: 'https://docs.example.com', + apiToken: null, + redirectOnError: false, + visitorPayload: { type: 'human', country: 'FR' }, + }); + } finally { + apiClientSpy.mockRestore(); + } + + expect(calls[0]?.visitor).toEqual({ type: 'human', country: 'FR' }); + }); +}); diff --git a/packages/gitbook/src/lib/data/lookup.ts b/packages/gitbook/src/lib/data/lookup.ts index aa7a0fd17..081d49162 100644 --- a/packages/gitbook/src/lib/data/lookup.ts +++ b/packages/gitbook/src/lib/data/lookup.ts @@ -3,11 +3,12 @@ import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from import { apiClient } from './api'; import { getExposableError } from './errors'; import type { DataFetcherResponse } from './types'; -import { getURLLookupAlternatives, stripURLSearch } from './urls'; +import { getURLLookupAlternatives, getURLLookupPathname, stripURLSearch } from './urls'; import { isAPITokenExpired } from '@/lib/api-token'; import { race, tryCatch } from '@/lib/async'; import { getLogger } from '@/lib/logger'; import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; +import { isPreviewRequest } from '@/lib/preview'; import { trace } from '@/lib/tracing'; type ResolveBody = Parameters<GitBookAPI['urls']['resolvePublishedContentByUrl']>[0]; @@ -91,6 +92,13 @@ export async function lookupPublishedContentByUrl( if ('redirect' in data) { if (alternative.primary) { + if (data.target === 'application' && isPreviewRequest(lookupURL)) { + // The cached lookup omits content selectors and query params needed after login. + const redirect = new URL(data.redirect); + redirect.searchParams.set('redirect', lookupURL.toString()); + return { data: { ...data, redirect: redirect.toString() } }; + } + // Append the path to the redirect URL // because we might have matched a shorter path and the redirect is relative to it if (alternative.extraPath) { @@ -134,7 +142,7 @@ export async function lookupPublishedContentByUrl( ...data, canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath), basePath: joinPath(data.basePath, lookup.basePath ?? ''), - pathname: joinPath(data.pathname, alternative.extraPath), + pathname: getURLLookupPathname(alternative, data), ...(changeRequest ? { changeRequest } : {}), ...(revision ? { revision } : {}), }; diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index 4dcaf76c0..203b3cbbd 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -79,6 +79,7 @@ export interface GitBookDataFetcher { getRevision(params: { spaceId: string; revisionId: string; + metadata?: boolean; }): Promise<DataFetcherResponse<api.Revision>>; /** @@ -97,6 +98,8 @@ export interface GitBookDataFetcher { spaceId: string; revisionId: string; path: string; + metadata?: boolean; + cachedMetadata?: boolean; }): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>; /** diff --git a/packages/gitbook/src/lib/data/urls.test.ts b/packages/gitbook/src/lib/data/urls.test.ts index 350dec446..72dd5f840 100644 --- a/packages/gitbook/src/lib/data/urls.test.ts +++ b/packages/gitbook/src/lib/data/urls.test.ts @@ -1,6 +1,108 @@ import { describe, expect, it } from 'bun:test'; -import { getURLLookupAlternatives, normalizeURL } from './urls'; +import { + getURLLookupAlternatives, + getURLLookupPathname, + normalizeURL, + shouldBypassLookupAlternatives, +} from './urls'; + +describe('getURLLookupPathname', () => { + const previewRoot = 'https://sites.gitbook.com/preview/site_example/section'; + const pagePath = 'guides/access/setup'; + const homepagePath = '/welcome/overview'; + + it.each(['revisions', 'changes'])( + 'resolves a page in a %s preview from the site-space root', + (kind) => { + const lookup = getURLLookupAlternatives( + new URL(`${previewRoot}/~/${kind}/revision-id/${pagePath}`) + ); + const alternative = lookup.urls[0]!; + + expect( + getURLLookupPathname(alternative, { + basePath: '/preview/site_example/section/', + pathname: homepagePath, + }) + ).toBe(`/${pagePath}`); + } + ); + + it.each(['revisions', 'changes'])( + 'preserves the custom homepage at a %s preview root', + (kind) => { + const lookup = getURLLookupAlternatives( + new URL(`${previewRoot}/~/${kind}/revision-id/`) + ); + + expect( + getURLLookupPathname(lookup.urls[0]!, { + basePath: '/preview/site_example/section/', + pathname: homepagePath, + }) + ).toBe(`${homepagePath}/`); + } + ); + + it.each(['', '/section/variant'])('resolves a published page below the %s root', (basePath) => { + const rootURL = `https://docs.example.com${basePath}`; + const lookup = getURLLookupAlternatives(new URL(`${rootURL}/${pagePath}`)); + const alternative = lookup.urls.find(({ url }) => url === new URL(rootURL).toString())!; + + expect(alternative).toBeDefined(); + expect( + getURLLookupPathname(alternative, { + basePath: `${basePath}/`, + pathname: homepagePath, + }) + ).toBe(`/${pagePath}`); + }); + + it('preserves page resolution without a custom homepage', () => { + const lookup = getURLLookupAlternatives( + new URL(`${previewRoot}/~/revisions/revision-id/${pagePath}`) + ); + + expect( + getURLLookupPathname(lookup.urls[0]!, { + basePath: '/preview/site_example/section/', + pathname: '/', + }) + ).toBe(`/${pagePath}`); + }); + + it.each(['/section/variant', '/section/variant/'])( + 'recognizes a root lookup with base path %s', + (basePath) => { + expect( + getURLLookupPathname( + { + url: 'https://docs.example.com/section/variant/', + extraPath: 'guide/page', + }, + { basePath, pathname: homepagePath } + ) + ).toBe('/guide/page'); + } + ); + + it('preserves a legitimate page prefix for a non-root lookup', () => { + const lookup = getURLLookupAlternatives( + new URL('https://docs.example.com/section/guide/page') + ); + const alternative = lookup.urls.find( + ({ url }) => url === 'https://docs.example.com/section/guide' + )!; + + expect( + getURLLookupPathname(alternative, { + basePath: '/section/', + pathname: '/guide', + }) + ).toBe('/guide/page'); + }); +}); describe('getURLLookupAlternatives', () => { it('should return all URLs up to the root', () => { @@ -735,3 +837,54 @@ describe('normalizeURL with encoded paths', () => { expect(result.searchParams.get('filter')).toBe(risonValue); }); }); + +describe('getURLLookupAlternatives with bypass', () => { + it('only looks up the full URL', () => { + expect( + getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c'), { bypass: true }) + ).toEqual({ + revision: undefined, + changeRequest: undefined, + basePath: undefined, + urls: [{ url: 'https://docs.mycompany.com/a/b/c', extraPath: '', primary: true }], + }); + }); + + it('only looks up the full URL for a variant', () => { + expect( + getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space'), { + bypass: true, + }).urls + ).toEqual([ + { url: 'https://test.gitbook.io/v/variant/space', extraPath: '', primary: true }, + ]); + }); + + it.each(['revisions', 'changes'])('keeps the alternatives for %s', (kind) => { + const url = new URL(`https://docs.mycompany.com/a/~/${kind}/id/page`); + expect(getURLLookupAlternatives(url, { bypass: true })).toEqual( + getURLLookupAlternatives(url, { bypass: false }) + ); + }); +}); + +describe('shouldBypassLookupAlternatives', () => { + const bypassURLs = ['https://docs.mycompany.com/section', 'https://other.mycompany.com/']; + + it.each([ + 'https://docs.mycompany.com/section', + 'https://docs.mycompany.com/section/page', + 'https://other.mycompany.com/', + 'https://other.mycompany.com/page', + ])('matches %s', (url) => { + expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(true); + }); + + it.each([ + 'https://docs.mycompany.com/sectionpage', + 'https://docs.mycompany.com/', + 'https://unknown.mycompany.com/section', + ])('does not match %s', (url) => { + expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(false); + }); +}); diff --git a/packages/gitbook/src/lib/data/urls.ts b/packages/gitbook/src/lib/data/urls.ts index bede35e85..bad5f2b2f 100644 --- a/packages/gitbook/src/lib/data/urls.ts +++ b/packages/gitbook/src/lib/data/urls.ts @@ -1,3 +1,5 @@ +import { GITBOOK_DISABLE_LOOKUP_ALTERNATIVES } from '../env'; +import { joinPath, removeTrailingSlash } from '../paths'; import { isProxyRootRequest } from '../proxy'; import { DataFetcherError, getExposableError } from './errors'; @@ -41,6 +43,34 @@ function getContentPathSegments(pathSegments: string[]): string[] { return pathSegments; } +/** + * Site URL prefixes resolved with the full URL only, for sites where a shorter alternative + * would resolve to the wrong content. + */ +const LOOKUP_ALTERNATIVES_BYPASS_URLS: string[] = ['https://proxy.gitbook.site/sites/site_p4Xo4']; + +/** + * Whether the lookup of this (normalized) URL should skip the shorter alternatives. + */ +export function shouldBypassLookupAlternatives( + url: URL, + bypassURLs: string[] = LOOKUP_ALTERNATIVES_BYPASS_URLS +): boolean { + if (GITBOOK_DISABLE_LOOKUP_ALTERNATIVES) { + return true; + } + + return bypassURLs.some((bypassURL) => { + const prefix = normalizeURL(new URL(bypassURL)); + const prefixPath = removeTrailingSlash(prefix.pathname); + return ( + prefix.origin === url.origin && + (removeTrailingSlash(url.pathname) === prefixPath || + url.pathname.startsWith(`${prefixPath}/`)) + ); + }); +} + /** * For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content. * The approach is optimized to aim at reusing cached lookup results as much as possible. @@ -61,8 +91,9 @@ function getContentPathSegments(pathSegments: string[]): string[] { * - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path> * - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path> */ -export function getURLLookupAlternatives(input: URL) { +export function getURLLookupAlternatives(input: URL, options: { bypass?: boolean } = {}) { const url = normalizeURL(input); + const bypass = options.bypass ?? shouldBypassLookupAlternatives(url); let basePath: string | undefined = undefined; let changeRequest: string | undefined = undefined; @@ -122,6 +153,11 @@ export function getURLLookupAlternatives(input: URL) { pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/')); } + // Revisions and changes above still need their alternatives to extract the base path. + else if (bypass) { + pushAlternative(url, ''); + } + // URL looks like a collection url (with /v/ in the path) // We only start matching after the /v/ segment and we ignore everything before it // to avoid potentially matching as a page not found under the default space in the collection @@ -160,6 +196,23 @@ export function getURLLookupAlternatives(input: URL) { return { urls: alternatives, basePath, changeRequest, revision }; } +/** Combine a resolved lookup with the remaining requested page path. */ +export function getURLLookupPathname( + alternative: { url: string; extraPath: string }, + resolved: { basePath: string; pathname: string } +) { + if ( + alternative.extraPath && + removeTrailingSlash(new URL(alternative.url).pathname) === + removeTrailingSlash(resolved.basePath) + ) { + // A root lookup can resolve to a custom homepage, which is not a prefix for other pages. + return joinPath('/', alternative.extraPath); + } + + return joinPath(resolved.pathname, alternative.extraPath); +} + /** * Normalize the URL in a request and redirect if the normalized URL is different from the original one. */ @@ -180,7 +233,7 @@ export function normalizeRequestURL(url: URL): Response | null { /** * Normalize a URL to remove duplicate slashes and trailing slashes - * and transform the pathname to lowercase. + * and decode the pathname to its canonical encoding. */ export function normalizeURL(url: URL) { const result = new URL(url); diff --git a/packages/gitbook/src/lib/discoveryDocument.ts b/packages/gitbook/src/lib/discoveryDocument.ts new file mode 100644 index 000000000..204a1f8ad --- /dev/null +++ b/packages/gitbook/src/lib/discoveryDocument.ts @@ -0,0 +1,70 @@ +import { SiteVisibility } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; + +/** + * CORS headers the server-card extension requires on a discovery endpoint. + */ +const CORS_HEADERS = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET', + 'access-control-allow-headers': 'Content-Type, If-None-Match', + 'access-control-expose-headers': 'ETag', +}; + +const CACHE_CONTROL = 'public, max-age=3600'; + +/** + * Serve a discovery document with the caching and CORS headers the extension asks a host for, or + * `304` when the client's copy is still current. + */ +export async function serveDiscoveryDocument( + context: GitBookSiteContext, + request: Request, + options: { + document: unknown; + mediaType: string; + } +): Promise<Response> { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const body = JSON.stringify(options.document); + const etag = `"${await hashDocument(body)}"`; + + const isPubliclyReachable = + context.site.visibility === SiteVisibility.Public || + context.site.visibility === SiteVisibility.Unlisted; + + const headers = { + ...CORS_HEADERS, + 'cache-control': isPubliclyReachable ? CACHE_CONTROL : 'no-store', + etag, + }; + + if (matchesEtag(request.headers.get('if-none-match'), etag)) { + return new Response(null, { status: 304, headers }); + } + + return new Response(body, { + headers: { ...headers, 'content-type': `${options.mediaType}; charset=utf-8` }, + }); +} + +async function hashDocument(body: string): Promise<string> { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function matchesEtag(ifNoneMatch: string | null, etag: string): boolean { + if (!ifNoneMatch) { + return false; + } + if (ifNoneMatch.trim() === '*') { + return true; + } + return ifNoneMatch + .split(',') + .some((candidate) => candidate.trim().replace(/^W\//, '') === etag); +} diff --git a/packages/gitbook/src/lib/document.tsx b/packages/gitbook/src/lib/document.tsx index 7ddc988d2..7b3c08f2e 100644 --- a/packages/gitbook/src/lib/document.tsx +++ b/packages/gitbook/src/lib/document.tsx @@ -91,7 +91,7 @@ export function hasMoreThan( if (traverse(child)) return true; } - if (child.object === 'block' && 'fragments' in child) { + if (child.object === 'block' && 'fragments' in child && child.fragments) { for (const fragment of child.fragments) { if (traverse(fragment)) return true; } @@ -159,7 +159,7 @@ export function getNodeFragmentByType( if (!('fragments' in node)) { return null; } - const fragment = node.fragments.find((child: any) => child.type === type); + const fragment = node.fragments?.find((child: any) => child.type === type); return fragment ?? null; } @@ -301,7 +301,7 @@ function findBlocks<Block extends DocumentBlock>( } } - if (block.object === 'block' && 'fragments' in block) { + if (block.object === 'block' && 'fragments' in block && block.fragments) { for (const fragment of block.fragments) { const result = findBlocks<Block>(fragment, test); if (result.length > 0) { @@ -341,7 +341,7 @@ function findBlock( } } - if (block.object === 'block' && 'fragments' in block) { + if (block.object === 'block' && 'fragments' in block && block.fragments) { for (const fragment of block.fragments) { const result = findBlock(fragment, test); if (result) { diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index b7d97778b..0f5d0271e 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -14,6 +14,15 @@ export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as | 'cloudflare' | 'unknown'; +/** + * Deployment stage (`STAGE` from the Cloudflare wrangler configs, the target env on Vercel), + * defaulting to `dev` locally. + */ +export const GITBOOK_STAGE = + process.env.STAGE ?? + process.env.VERCEL_TARGET_ENV ?? + (process.env.NODE_ENV === 'development' ? 'dev' : undefined); + /** * Main host on which GitBook is running. */ @@ -78,6 +87,18 @@ export const GITBOOK_DISABLE_TRACKING = Boolean( !!process.env.GITBOOK_DISABLE_TRACKING || process.env.NODE_ENV !== 'production' ); +/** + * Whether insights events must not reach the sites' analytics, while the pages otherwise behave + * like production. Set on the preview deployments, whose traffic is e2e runs, not the sites'. + */ +export const GITBOOK_DISABLE_INSIGHTS = process.env.GITBOOK_DISABLE_INSIGHTS === 'true'; + +/** + * Whether to resolve site content with the full URL only, skipping the shorter lookup alternatives. + */ +export const GITBOOK_DISABLE_LOOKUP_ALTERNATIVES = + process.env.GITBOOK_DISABLE_LOOKUP_ALTERNATIVES === 'true'; + /** * Hostname serving the integrations. */ diff --git a/packages/gitbook/src/lib/gitPageURL.test.ts b/packages/gitbook/src/lib/gitPageURL.test.ts new file mode 100644 index 000000000..fd47eef3a --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from 'bun:test'; + +import { + findGitPageURLTarget, + findPageByGitPath, + findPageForGitPageURLTarget, + matchesGitPageURLTargetPath, +} from './gitPageURL'; + +const SPACES = [ + { + id: 'a', + gitSync: { + url: 'https://github.com/acme/docs/tree/main', + installationProjectDirectory: 'guides', + }, + }, + { + id: 'b', + gitSync: { + url: 'https://github.com/acme/docs/tree/main', + installationProjectDirectory: '/api/', + }, + }, +]; + +describe('findGitPageURLTarget', () => { + it.each(['header%201', '%E6%97%A5%E6%9C%AC', 'part%2Fone', 'percent%2520'])( + 'decodes anchor %s once', + (anchor) => { + expect( + findGitPageURLTarget( + `https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`, + SPACES + )?.anchor + ).toBe(decodeURIComponent(anchor)); + } + ); + + it.each(['%ZZ', '%E0%A4'])('keeps malformed anchor %s unresolved', (anchor) => { + expect( + findGitPageURLTarget( + `https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`, + SPACES + ) + ).toBeNull(); + }); + + it('accepts the www host alias without accepting unrelated hosts', () => { + expect( + findGitPageURLTarget('https://www.github.com/acme/docs/tree/main/api/auth.md', SPACES) + ?.space + ).toBe('b'); + expect( + findGitPageURLTarget( + 'https://www.github.com.evil.test/acme/docs/tree/main/api/auth.md', + SPACES + ) + ).toBeNull(); + }); + + it('matches the repository, ref and directory and preserves anchors', () => { + expect( + findGitPageURLTarget( + 'https://github.com/acme/docs/tree/main/api/auth.md#tokens', + SPACES + ) + ).toEqual({ space: 'b', path: 'api/auth.md', anchor: 'tokens' }); + }); + + it.each([ + 'https://github.com/other/docs/tree/main/api/auth.md', + 'https://github.com/acme/docs/tree/preview/api/auth.md', + 'https://github.com.evil.test/acme/docs/tree/main/api/auth.md', + 'https://github.com/acme/docs/tree/main/api-other/auth.md', + 'https://github.com/acme/docs/tree/main/api/auth.md?raw=1', + 'https://github.com/acme/docs/tree/main/api/%ZZ.md', + 'https://github.com/acme/docs/tree/main/api/%2Fsecret.md', + ])('does not reinterpret %s', (url) => { + expect(findGitPageURLTarget(url, SPACES)).toBeNull(); + }); + + it('supports blob URLs and encoded file names', () => { + expect( + findGitPageURLTarget( + 'https://github.com/acme/docs/blob/main/api/hello%20world.md', + SPACES + ) + ).toEqual({ space: 'b', path: 'api/hello world.md', anchor: undefined }); + }); + + it('matches self-hosted GitLab with nested groups and a slash in the branch', () => { + const spaces = [ + { + id: 'b', + gitSync: { + url: 'https://git.example.com/group/sub/docs/-/tree/release/v2', + installationProjectDirectory: 'api', + }, + }, + ]; + expect( + findGitPageURLTarget( + 'https://git.example.com/group/sub/docs/-/blob/release/v2/api/auth.md', + spaces + )?.space + ).toBe('b'); + }); + + it('rejects ambiguous owners and ambiguous branch prefixes', () => { + expect( + findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [ + ...SPACES, + { ...SPACES[1]!, id: 'duplicate' }, + ]) + ).toBeNull(); + expect( + findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [ + ...SPACES, + { + id: 'other-ref', + gitSync: { + url: 'https://github.com/acme/docs/tree/main/api', + installationProjectDirectory: '', + }, + }, + ]) + ).toBeNull(); + }); + + it('prefers the most specific directory and deduplicates site placements', () => { + expect( + findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [ + ...SPACES, + SPACES[1]!, + { + id: 'root', + gitSync: { url: SPACES[0]!.gitSync.url, installationProjectDirectory: '' }, + }, + ])?.space + ).toBe('b'); + }); + + it('treats an omitted live project directory as the repository root', () => { + expect( + findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [ + { id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } }, + ]) + ).toEqual({ space: 'old', path: 'api/auth.md', anchor: undefined }); + }); + + it.each(['live', 'disconnected'])('keeps similar space directories distinct (%s)', (state) => { + const spaces = ['docs/space-a', 'docs/space-b', 'api-reference'].map((directory) => ({ + id: directory, + ...(state === 'live' + ? { + gitSync: { + url: SPACES[0]!.gitSync.url, + installationProjectDirectory: `/${directory}`, + }, + } + : { previousGitSync: { url: `${SPACES[0]!.gitSync.url}/${directory}` } }), + })); + const url = new URL( + '../docs/space-b/page.md#details', + `${SPACES[0]!.gitSync.url}/api-reference/README.md` + ).href; + const target = findGitPageURLTarget(url, spaces); + expect(target?.space).toBe('docs/space-b'); + expect(target?.anchor).toBe('details'); + expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/page.md')).toBe(true); + expect( + findGitPageURLTarget( + url, + spaces.filter((space) => space.id !== 'docs/space-b') + ) + ).toBeNull(); + }); + + it.each([ + [ + 'https://github.com/old/repo/tree/main/docs/space-b', + 'https://github.com/old/repo/blob/main/docs/space-b/hello%20world.md#details', + ], + [ + 'https://git.example.com/group/repo/-/tree/release/v2/docs/space-b', + 'https://git.example.com/group/repo/-/blob/release/v2/docs/space-b/hello%20world.md#details', + ], + ])('matches the remembered project URL %s', (previousURL, href) => { + const target = findGitPageURLTarget(href, [ + { id: 'b', previousGitSync: { url: previousURL } }, + ]); + expect(target?.space).toBe('b'); + expect(target?.anchor).toBe('details'); + expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/hello world.md')).toBe( + true + ); + expect(target && matchesGitPageURLTargetPath(target, 'docs/space-a/hello world.md')).toBe( + false + ); + }); + + it.each([ + 'https://github.com/someone-else/example/blob/main/docs/space-b/page.md', + 'https://github.com/old/repo/tree/other/docs/space-b/page.md', + 'https://github.com.evil.test/old/repo/tree/main/docs/space-b/page.md', + 'https://github.com/old/repo/tree/main/docs/space-b-other/page.md', + 'https://github.com/old/repo/tree/main/other-docs/space-b/page.md', + 'https://github.com/old/repo/tree/main/docs/space-b/%2Fsecret.md', + 'https://github.com/old/repo/tree/main/docs/space-b/%ZZ.md', + ])('does not reinterpret an unrelated or invalid URL: %s', (href) => { + expect( + findGitPageURLTarget(href, [ + { + id: 'b', + previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' }, + }, + ]) + ).toBeNull(); + }); + + it('requires a valid previous URL', () => { + for (const url of [undefined, 'invalid', 'https://github.com/old/repo']) { + expect( + findGitPageURLTarget('https://github.com/old/repo/tree/main/docs/space-b/page.md', [ + { id: 'b', previousGitSync: { url } }, + ]) + ).toBeNull(); + } + }); + + it('supports disconnected repository roots and directory README links', () => { + const target = findGitPageURLTarget( + 'https://github.com/old/repo/tree/release/v2/docs/space-b/', + [{ id: 'b', previousGitSync: { url: 'https://github.com/old/repo/tree/release/v2' } }] + ); + expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/README.md')).toBe(true); + }); + + it('rejects duplicate previous owners and ignores stale metadata on live installations', () => { + const previous = { + previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' }, + }; + const url = 'https://github.com/old/repo/tree/main/docs/space-b/page.md'; + expect( + findGitPageURLTarget(url, [ + { id: 'b', ...previous }, + { id: 'copy', ...previous }, + ]) + ).toBeNull(); + expect(findGitPageURLTarget(url, [{ ...SPACES[1]!, ...previous }])).toBeNull(); + }); + + it('compares live and previous project URLs at the same directory boundary', () => { + const href = 'https://github.com/acme/docs/tree/main/api/auth.md'; + const disconnected = { + id: 'old-api', + previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' }, + }; + expect(findGitPageURLTarget(href, [SPACES[1]!, disconnected])).toBeNull(); + expect( + findGitPageURLTarget(href, [ + disconnected, + { id: 'root', gitSync: { url: SPACES[0]!.gitSync.url } }, + ])?.space + ).toBe('old-api'); + }); + + it('accepts the www alias for a remembered project URL', () => { + expect( + findGitPageURLTarget('https://www.github.com/acme/docs/blob/main/api/auth.md', [ + { id: 'b', previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' } }, + ])?.space + ).toBe('b'); + }); +}); + +describe('findPageByGitPath', () => { + const pages = [ + { id: 'auth', git: { path: 'api/auth.md' }, pages: [] }, + { id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] }, + ]; + it('finds nested pages and directory README links', () => { + expect(findPageByGitPath(pages, 'api/auth.md')?.id).toBe('auth'); + expect(findPageByGitPath(pages, 'api/11.8/')?.id).toBe('readme'); + expect(findPageByGitPath(pages, 'api/missing.md')).toBeNull(); + }); + it('does not select between duplicate paths', () => { + expect( + findPageByGitPath( + [...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }], + 'api/auth.md' + ) + ).toBeNull(); + }); +}); + +describe('findPageForGitPageURLTarget', () => { + const pages = [ + { id: 'auth', git: { path: 'api/auth.md' }, pages: [] }, + { id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] }, + ]; + it('finds the page of a live target, including directory README links', () => { + expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/auth.md' })?.id).toBe( + 'auth' + ); + expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/11.8/' })?.id).toBe( + 'readme' + ); + }); + it('finds the page of a remembered target whose path still includes the ref', () => { + expect( + findPageForGitPageURLTarget(pages, { + space: 'b', + path: 'release/v2/api/auth.md', + pathIncludesRef: true, + })?.id + ).toBe('auth'); + }); + it('does not select between duplicate paths', () => { + expect( + findPageForGitPageURLTarget( + [...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }], + { space: 'b', path: 'api/auth.md' } + ) + ).toBeNull(); + }); +}); diff --git a/packages/gitbook/src/lib/gitPageURL.ts b/packages/gitbook/src/lib/gitPageURL.ts new file mode 100644 index 000000000..c01c24003 --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.ts @@ -0,0 +1,196 @@ +export interface GitPageURLSpace { + id: string; + gitSync?: { + url?: string; + installationProjectDirectory?: string; + }; + previousGitSync?: { url?: string }; +} + +export interface GitPageURLTarget { + space: string; + path: string; + anchor?: string; + /** Previous project URLs do not distinguish a slash-containing ref from the file path. */ + pathIncludesRef?: boolean; +} + +/** Locate a unique owning space without fetching any revisions. */ +export function findGitPageURLTarget( + href: string, + spaces: readonly GitPageURLSpace[] +): GitPageURLTarget | null { + const url = parseURL(href); + if (!url || url.search) { + return null; + } + let anchor: string | undefined; + try { + anchor = decodeURIComponent(url.hash.slice(1)) || undefined; + } catch { + return null; + } + + const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>(); + for (const space of spaces) { + const directory = space.gitSync?.installationProjectDirectory ?? ''; + let root = directory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, ''); + let filePath: string | null; + let treeKey: string | undefined; + if (space.gitSync) { + const tree = space.gitSync.url ? parseURL(space.gitSync.url) : null; + if (!tree || (tree.host !== url.host && url.host !== `www.${tree.host}`)) { + continue; + } + const prefix = tree.pathname.replace(/\/$/, ''); + const blobPrefix = prefix + .replace('/-/tree/', '/-/blob/') + .replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/'); + const matchedPrefix = [prefix, blobPrefix].find((candidate) => + url.pathname.startsWith(`${candidate}/`) + ); + if (!matchedPrefix) { + continue; + } + filePath = decodeGitPath(url.pathname.slice(matchedPrefix.length + 1)); + treeKey = `${tree.host}${prefix}`; + if (!filePath || (root && filePath !== root && !filePath.startsWith(`${root}/`))) { + continue; + } + root = `${prefix}/${root}`.replace(/\/$/, ''); + } else { + const previous = space.previousGitSync?.url + ? parseURL(space.previousGitSync.url) + : null; + filePath = previous ? findPreviousGitPath(url, previous) : null; + if (!filePath || !previous) { + continue; + } + root = previous.pathname.replace(/\/$/, ''); + } + matches.set(space.id, { + space: space.id, + path: filePath, + anchor, + root, + tree: treeKey, + ...(!space.gitSync ? { pathIncludesRef: true } : {}), + }); + } + + const candidates = [...matches.values()]; + if ( + new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size > + 1 + ) { + return null; + } + const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length)); + const owners = candidates.filter((candidate) => candidate.root.length === longestRoot); + if (owners.length !== 1) { + return null; + } + const owner = owners[0]!; + return { + space: owner.space, + path: owner.path, + anchor: owner.anchor, + ...(owner.pathIncludesRef ? { pathIncludesRef: true } : {}), + }; +} + +/** Match stored page paths after verifying the owning repository URL; callers must reject multiple pages. */ +export function matchesGitPageURLTargetPath(target: GitPageURLTarget, filePath: string): boolean { + const paths = [target.path, `${target.path.replace(/\/$/, '')}/README.md`]; + return paths.some((path) => + target.pathIncludesRef ? path.endsWith(`/${filePath}`) : path === filePath + ); +} + +/** Match an API revision's nested page tree, including directory README links. */ +export function findPageByGitPath<T extends { id: string; git?: { path: string }; pages?: T[] }>( + pages: readonly T[], + filePath: string +): T | null { + const paths = [filePath, `${filePath.replace(/\/$/, '')}/README.md`]; + const matches: T[] = []; + const visit = (children: readonly T[]) => { + for (const page of children) { + if (page.git && paths.includes(page.git.path)) { + matches.push(page); + } + visit(page.pages ?? []); + } + }; + visit(pages); + return matches.length === 1 ? matches[0]! : null; +} + +/** Find the only page of an API revision's page tree at the path a target points to. */ +export function findPageForGitPageURLTarget< + T extends { id: string; git?: { path: string }; pages?: T[] }, +>(pages: readonly T[], target: GitPageURLTarget): T | null { + const matches: T[] = []; + const visit = (children: readonly T[]) => { + for (const page of children) { + if (page.git && matchesGitPageURLTargetPath(target, page.git.path)) { + matches.push(page); + } + visit(page.pages ?? []); + } + }; + visit(pages); + return matches.length === 1 ? matches[0]! : null; +} + +function parseURL(href: string): URL | null { + try { + const url = new URL(href); + return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password + ? url + : null; + } catch { + return null; + } +} + +function decodeGitPath(encoded: string): string | null { + try { + // Encoded separators make repository/ref boundaries ambiguous. + if (/%2f|%5c/i.test(encoded)) { + return null; + } + const decoded = decodeURIComponent(encoded); + return decoded.includes('\\') || + decoded.split('/').some((part) => part === '.' || part === '..') + ? null + : decoded; + } catch { + return null; + } +} + +function findPreviousGitPath(url: URL, previous: URL): string | null { + if ( + previous.search || + previous.hash || + !/\/(?:tree|blob)\/.+/.test(previous.pathname) || + (url.host !== previous.host && url.host !== `www.${previous.host}`) + ) { + return null; + } + const prefix = previous.pathname.replace(/\/$/, ''); + const blobPrefix = prefix + .replace('/-/tree/', '/-/blob/') + .replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/'); + if ( + ![prefix, blobPrefix].some( + (candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`) + ) + ) { + return null; + } + // Keep the ref until revision lookup: its slash boundary is not recorded separately. + const suffix = url.pathname.match(/\/(?:tree|blob)\/(.+)$/)?.[1]; + return suffix ? decodeGitPath(suffix) : null; +} diff --git a/packages/gitbook/src/lib/markdown-content-type.test.ts b/packages/gitbook/src/lib/markdown-content-type.test.ts index 835a8f3a5..24f41f294 100644 --- a/packages/gitbook/src/lib/markdown-content-type.test.ts +++ b/packages/gitbook/src/lib/markdown-content-type.test.ts @@ -9,6 +9,14 @@ describe('ChatGPT Markdown compatibility', () => { it('detects ChatGPT requests without matching other agents', () => { expect(isChatGPTRequest(requestWith({ 'user-agent': 'ChatGPT-User/1.0' }))).toBe(true); expect(isChatGPTRequest(requestWith({ 'user-agent': 'ChatGPT Agent' }))).toBe(true); + expect( + isChatGPTRequest( + requestWith({ + 'user-agent': + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot', + }) + ) + ).toBe(true); expect( isChatGPTRequest( requestWith({ diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts index 73a619d6c..75590995e 100644 --- a/packages/gitbook/src/lib/markdownPage.ts +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -1,4 +1,4 @@ -import type { Definition, Html, Image, Link, Root } from 'mdast'; +import type { Definition, Html, Image, Link, Paragraph, Root } from 'mdast'; import { fromMarkdown } from 'mdast-util-from-markdown'; import { frontmatterFromMarkdown } from 'mdast-util-frontmatter'; import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'; @@ -70,6 +70,7 @@ export async function getMarkdownForPage( markdown: rawMarkdown, pagePath: page.path, }); + insertDescriptionAfterHeading(tree, page.description); // Handle empty document pages which have children if (isEmptyMarkdownPage(tree) && page.pages.length > 0) { @@ -106,6 +107,7 @@ export async function getMarkdownForPageInSpace( markdown: rawMarkdown, pagePath: page.path, }); + insertDescriptionAfterHeading(tree, page.description); // Handle empty document pages which have children (same as getMarkdownForPage) if (isEmptyMarkdownPage(tree) && page.pages.length > 0) { @@ -146,6 +148,26 @@ export function toPageMarkdown(tree: Root): string { return toMarkdown(tree, { extensions: [gfmToMarkdown()] }); } +/** Keep page metadata immediately after the title for Markdown consumers. */ +function insertDescriptionAfterHeading(tree: Root, description?: string) { + if (!description) { + return; + } + + const headingIndex = tree.children.findIndex( + (node) => node.type === 'heading' && node.depth === 1 + ); + if (headingIndex === -1) { + return; + } + + const descriptionNode: Paragraph = { + type: 'paragraph', + children: [{ type: 'text', value: description }], + }; + tree.children.splice(headingIndex + 1, 0, descriptionNode); +} + /** * Determine if a page is empty. * A page is empty if it has no content or only a title. @@ -203,6 +225,7 @@ async function renderGroupPageMarkdown(args: { }): Promise<string> { const { linker, page } = args; const indexablePages = getIndexablePages(page.pages); + const description = page.type === RevisionPageType.Document ? page.description : undefined; const markdownTree: Root = { type: 'root', @@ -212,6 +235,14 @@ async function renderGroupPageMarkdown(args: { depth: 1, children: [{ type: 'text', value: page.title }], }, + ...(description + ? [ + { + type: 'paragraph', + children: [{ type: 'text', value: description }], + } satisfies Paragraph, + ] + : []), ...(await getMarkdownForPagesTree(indexablePages, linker)), ], }; diff --git a/packages/gitbook/src/lib/mcp/endpoints.ts b/packages/gitbook/src/lib/mcp/endpoints.ts new file mode 100644 index 000000000..edb189383 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/endpoints.ts @@ -0,0 +1,29 @@ +import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; + +/** + * Whether the site publishes an MCP server at all. Gates the endpoint and everything describing it. + */ +export function isSiteMcpEnabled(context: GitBookSiteContext): boolean { + return context.customization.pageActions.items.includes(CustomizationPageActionType.Mcp); +} + +/** + * Whether `~gitbook/mcp` endpoint requires auth for the site (i.e VA site). + */ +export function mcpEndpointRequiresAuth(context: GitBookSiteContext): boolean { + return context.site.visibility === SiteVisibility.VisitorAuth; +} + +/** + * Whether the site exposes `~gitbook/mcp/auth`, the endpoint a visitor authenticates against to + * read adaptive content. + */ +export function hasAdaptiveMcpEndpoint(context: GitBookSiteContext): boolean { + return Boolean( + !mcpEndpointRequiresAuth(context) && + context.site.adaptiveContent?.enabled && + context.site.urls.login + ); +} diff --git a/packages/gitbook/src/lib/mcp/paths.ts b/packages/gitbook/src/lib/mcp/paths.ts new file mode 100644 index 000000000..a71f88f44 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/paths.ts @@ -0,0 +1,12 @@ +/** + * Path the card is served on, appended to the Streamable HTTP endpoint as the extension reserves + * (`GET <streamable-http-url>/server-card`). + */ +export const MCP_SERVER_CARD_PATH = '~gitbook/mcp/server-card'; + +/** + * Path crawlers probe today, kept alongside the reserved one. The extension argues against + * `.well-known` for a single server's card (it is application-level, not site-wide metadata), but + * the scanners in the wild look here, so the card answers on both. + */ +export const MCP_SERVER_CARD_WELL_KNOWN_PATH = '.well-known/mcp/server-card.json'; diff --git a/packages/gitbook/src/lib/mcp/serverCard.test.ts b/packages/gitbook/src/lib/mcp/serverCard.test.ts new file mode 100644 index 000000000..9f10f74a6 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/serverCard.test.ts @@ -0,0 +1,418 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, expect, it } from 'bun:test'; + +import { CustomizationAIMode, CustomizationPageActionType, SiteVisibility } from '@gitbook/api'; + +import { hasAdaptiveMcpEndpoint, mcpEndpointRequiresAuth } from './endpoints'; +import { + MCP_SERVER_VERSION, + SERVER_CARD_MEDIA_TYPE, + SERVER_CARD_SCHEMA_URL, + type SiteMcpServerCard, + buildMcpServerInfo, + buildSiteMcpServerCard, + serveSiteMcpServerCard, +} from './serverCard'; +import { createSiteMcpTools, registerSiteMcpTools } from './tools'; +import type { GitBookSiteContext } from '@/lib/context'; +import { createLinker } from '@/lib/links'; + +const SERVER_CARD_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; +const MAX_TEXT_LENGTH = 100; + +function makeContext( + options: { + host?: string; + siteBasePath?: string; + spaceBasePath?: string; + siteId?: string; + title?: string; + visibility?: SiteVisibility; + adaptiveContent?: boolean; + aiMode?: CustomizationAIMode; + pageActions?: CustomizationPageActionType[]; + } = {} +): GitBookSiteContext { + const { + host = 'docs.acme.org', + siteBasePath = '/', + spaceBasePath = siteBasePath, + siteId = 'site_123', + title = 'Acme', + visibility = SiteVisibility.Public, + adaptiveContent = true, + aiMode = CustomizationAIMode.Search, + pageActions = [CustomizationPageActionType.Mcp], + } = options; + + return { + organizationId: 'org_123', + site: { + id: siteId, + title, + visibility, + adaptiveContent: { enabled: adaptiveContent }, + urls: { login: `https://${host}/~gitbook/auth/login` }, + }, + siteSpace: { urls: { published: `https://${host}${siteBasePath}` } }, + customization: { + ai: { mode: aiMode }, + pageActions: { items: pageActions }, + }, + linker: createLinker({ host, siteBasePath, spaceBasePath }), + } as unknown as GitBookSiteContext; +} + +const request = new Request('https://docs.acme.org/~gitbook/mcp/server-card'); + +function buildCard(options?: Parameters<typeof makeContext>[0]): SiteMcpServerCard { + const context = makeContext(options); + return buildSiteMcpServerCard(context, createSiteMcpTools(context, { request })); +} + +describe('buildSiteMcpServerCard', () => { + describe('schema constraints', () => { + it('declares every required member', () => { + const card = buildCard(); + + expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL); + expect(card.name).toBeString(); + expect(card.version).toBeString(); + expect(card.description).toBeString(); + }); + + it('names the server with a reverse-DNS namespace and exactly one slash', () => { + const card = buildCard({ siteId: 'site_A1b2-c3' }); + + expect(card.name).toBe('com.gitbook.sites.mcp/site_A1b2-c3'); + expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN); + expect(card.name.split('/')).toHaveLength(2); + }); + + it('keeps the description and title within 100 characters', () => { + const card = buildCard({ title: 'Acme' }); + + expect(card.title).toBe('Acme MCP Server'); + expect(card.description).toBe('Search and read the Acme documentation over MCP.'); + expect(card.description.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH); + expect(card.title.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH); + }); + + it('does not repeat an article a customer title already carries', () => { + const card = buildCard({ title: 'The Acme Handbook' }); + + expect(card.title).toBe('The Acme Handbook MCP Server'); + expect(card.description).toBe( + 'Search and read The Acme Handbook documentation over MCP.' + ); + }); + + it('truncates an arbitrarily long customer title without losing what the server is', () => { + const card = buildCard({ title: 'A'.repeat(500) }); + + expect(card.title.length).toBe(MAX_TEXT_LENGTH); + expect(card.title.endsWith('… MCP Server')).toBe(true); + expect(card.description.length).toBe(MAX_TEXT_LENGTH); + expect(card.description.startsWith('Search and read the ')).toBe(true); + expect(card.description.endsWith('… documentation over MCP.')).toBe(true); + }); + + it('publishes an exact version rather than a range', () => { + const card = buildCard(); + + expect(card.version).toMatch(/^\d+\.\d+\.\d+/); + expect(card.version).toBe(MCP_SERVER_VERSION); + }); + + it('declares a transport type the extension allows on every remote', () => { + const card = buildCard(); + + for (const remote of card.remotes) { + expect(['streamable-http', 'sse']).toContain(remote.type); + expect(URL.canParse(remote.url)).toBe(true); + expect(remote.supportedProtocolVersions.length).toBeGreaterThan(0); + } + }); + }); + + describe('server identity', () => { + it('reports the same identity the transport reports at initialize', () => { + const context = makeContext(); + const card = buildSiteMcpServerCard(context, createSiteMcpTools(context, { request })); + + expect(card.serverInfo).toEqual(buildMcpServerInfo(context)); + expect(card.serverInfo.title).toBe(card.title); + expect(card.serverInfo.version).toBe(card.version); + }); + + it('names the running server after its endpoint', () => { + const card = buildCard(); + + expect(card.serverInfo.name).toBe('docs.acme.org/~gitbook/mcp'); + expect(card.endpoint).toBe(`https://${card.serverInfo.name}`); + }); + + it('keeps the card name stable and schema-valid, unlike the endpoint', () => { + const card = buildCard(); + + expect(card.name).toBe('com.gitbook.sites.mcp/site_123'); + expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN); + // The endpoint cannot be the card name: two slashes and a `~`. + expect(card.serverInfo.name).not.toMatch(SERVER_CARD_NAME_PATTERN); + }); + + it('names the server after the site rather than the software', () => { + const acme = buildMcpServerInfo(makeContext({ host: 'docs.acme.com', title: 'Acme' })); + const other = buildMcpServerInfo( + makeContext({ host: 'gitbook.com', siteBasePath: '/other/', title: 'Other' }) + ); + + expect(acme.name).toBe('docs.acme.com/~gitbook/mcp'); + expect(other.name).toBe('gitbook.com/other/~gitbook/mcp'); + expect(acme.title).toBe('Acme MCP Server'); + expect(other.title).toBe('Other MCP Server'); + }); + + it('declares the capabilities the SDK reports at initialize', () => { + const card = buildCard(); + + // Verified against a live server: `McpServer` declares listChanged for its tools. + expect(card.capabilities).toEqual({ tools: { listChanged: true } }); + }); + + it('points `endpoint` at the same URL as the public remote', () => { + const card = buildCard(); + + expect(card.endpoint).toBe(card.remotes[0]?.url ?? ''); + }); + }); + + describe('remotes', () => { + it('publishes one card with both endpoints when the site serves adaptive content', () => { + const card = buildCard({ adaptiveContent: true }); + + expect(card.remotes.map((remote) => remote.url)).toEqual([ + 'https://docs.acme.org/~gitbook/mcp', + 'https://docs.acme.org/~gitbook/mcp/auth', + ]); + expect(card.remotes[0]?.headers).toBeUndefined(); + expect(card.remotes[1]?.headers).toEqual([ + { + name: 'Authorization', + description: expect.stringContaining('Bearer token'), + isRequired: true, + isSecret: true, + }, + ]); + }); + + it('omits the adaptive endpoint when the site has no adaptive content', () => { + const card = buildCard({ adaptiveContent: false }); + + expect(card.remotes.map((remote) => remote.url)).toEqual([ + 'https://docs.acme.org/~gitbook/mcp', + ]); + expect(card.remotes[0]?.headers).toBeUndefined(); + }); + + it.each([SiteVisibility.Public, SiteVisibility.Unlisted, SiteVisibility.ShareLink])( + 'leaves the public endpoint unauthenticated on a %s site', + (visibility) => { + const card = buildCard({ visibility, adaptiveContent: false }); + + expect(card.remotes[0]?.headers).toBeUndefined(); + } + ); + + it('requires a token on the public endpoint of a visitor-auth site, and offers no adaptive endpoint', () => { + const card = buildCard({ visibility: SiteVisibility.VisitorAuth }); + + expect(card.remotes.map((remote) => remote.url)).toEqual([ + 'https://docs.acme.org/~gitbook/mcp', + ]); + expect(card.remotes[0]?.headers).toEqual([ + { + name: 'Authorization', + description: expect.stringContaining('restricted to authenticated visitors'), + isRequired: true, + isSecret: true, + }, + ]); + }); + }); + + describe('urls', () => { + it.each([ + { + scenario: 'a custom domain', + options: { host: 'docs.acme.org', siteBasePath: '/' }, + websiteUrl: 'https://docs.acme.org', + endpoint: 'https://docs.acme.org/~gitbook/mcp', + icon: 'https://docs.acme.org/~gitbook/icon?size=medium', + }, + { + scenario: 'a gitbook.io subdomain', + options: { host: 'acme.gitbook.io', siteBasePath: '/' }, + websiteUrl: 'https://acme.gitbook.io', + endpoint: 'https://acme.gitbook.io/~gitbook/mcp', + icon: 'https://acme.gitbook.io/~gitbook/icon?size=medium', + }, + { + scenario: 'a subpath site', + options: { + host: 'gitbook.com', + siteBasePath: '/docs/', + spaceBasePath: '/docs/v1/', + }, + websiteUrl: 'https://gitbook.com/docs', + endpoint: 'https://gitbook.com/docs/~gitbook/mcp', + icon: 'https://gitbook.com/docs/~gitbook/icon?size=medium', + }, + ])('builds absolute URLs for $scenario', ({ options, websiteUrl, endpoint, icon }) => { + const card = buildCard(options); + + expect(card.websiteUrl).toBe(websiteUrl); + expect(card.endpoint).toBe(endpoint); + expect(card.remotes[1]?.url).toBe(`${endpoint}/auth`); + expect(card.icons[0]?.src).toBe(icon); + }); + }); + + describe('endpoint predicates', () => { + it.each([ + { + scenario: 'a public site with adaptive content', + options: { visibility: SiteVisibility.Public, adaptiveContent: true }, + requiresAuth: false, + hasAdaptive: true, + }, + { + scenario: 'a public site without adaptive content', + options: { visibility: SiteVisibility.Public, adaptiveContent: false }, + requiresAuth: false, + hasAdaptive: false, + }, + { + scenario: 'a visitor-auth site', + options: { visibility: SiteVisibility.VisitorAuth, adaptiveContent: true }, + requiresAuth: true, + hasAdaptive: false, + }, + ])('resolves the endpoints of $scenario', ({ options, requiresAuth, hasAdaptive }) => { + const context = makeContext(options); + + expect(mcpEndpointRequiresAuth(context)).toBe(requiresAuth); + expect(hasAdaptiveMcpEndpoint(context)).toBe(hasAdaptive); + }); + }); + + describe('tools', () => { + it('advertises exactly the tools registered on the server', () => { + const context = makeContext(); + const request = new Request('https://docs.acme.org/~gitbook/mcp'); + const tools = createSiteMcpTools(context, { request }); + + const registered: string[] = []; + registerSiteMcpTools( + { tool: (name: string) => registered.push(name) } as unknown as McpServer, + tools + ); + + const card = buildSiteMcpServerCard(context, tools); + expect(card.tools.map((tool) => tool.name)).toEqual(registered); + }); + + it('carries a description and annotations, but not input schemas', () => { + const card = buildCard(); + const search = card.tools.find((tool) => tool.name === 'searchDocumentation'); + + expect(search?.description).toContain('Acme'); + expect(search?.annotations).toMatchObject({ + title: 'Search documentation', + readOnlyHint: true, + }); + expect(search).not.toHaveProperty('inputSchema'); + }); + + it('omits askQuestion when the site has no AI mode', () => { + const withAI = buildCard({ aiMode: CustomizationAIMode.Assistant }); + const withoutAI = buildCard({ aiMode: CustomizationAIMode.None }); + + expect(withAI.tools.map((tool) => tool.name)).toEqual([ + 'searchDocumentation', + 'getPage', + 'askQuestion', + 'sendFeedback', + ]); + expect(withoutAI.tools.map((tool) => tool.name)).toEqual([ + 'searchDocumentation', + 'getPage', + 'sendFeedback', + ]); + }); + }); +}); + +describe('serveSiteMcpServerCard', () => { + const cardRequest = (init?: RequestInit) => + new Request('https://docs.acme.org/~gitbook/mcp/server-card', init); + + it('serves the card with the media type and CORS headers the extension requires', async () => { + const res = await serveSiteMcpServerCard(makeContext(), cardRequest()); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe(`${SERVER_CARD_MEDIA_TYPE}; charset=utf-8`); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('access-control-allow-methods')).toBe('GET'); + expect(res.headers.get('access-control-allow-headers')).toBe('Content-Type, If-None-Match'); + expect(res.headers.get('access-control-expose-headers')).toBe('ETag'); + + const card = (await res.json()) as SiteMcpServerCard; + expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL); + }); + + it('answers a preflight with a 204', async () => { + const res = await serveSiteMcpServerCard(makeContext(), cardRequest({ method: 'OPTIONS' })); + + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('returns 304 when the client already has the current card', async () => { + const context = makeContext(); + const first = await serveSiteMcpServerCard(context, cardRequest()); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + + const revalidated = await serveSiteMcpServerCard( + context, + cardRequest({ headers: { 'If-None-Match': `W/${etag}` } }) + ); + + expect(revalidated.status).toBe(304); + expect(revalidated.headers.get('etag')).toBe(etag); + }); + + it.each([ + { visibility: SiteVisibility.Public, cacheControl: 'public, max-age=3600' }, + { visibility: SiteVisibility.Unlisted, cacheControl: 'public, max-age=3600' }, + { visibility: SiteVisibility.ShareLink, cacheControl: 'no-store' }, + { visibility: SiteVisibility.VisitorAuth, cacheControl: 'no-store' }, + ])('caches a $visibility site with $cacheControl', async ({ visibility, cacheControl }) => { + const res = await serveSiteMcpServerCard(makeContext({ visibility }), cardRequest()); + + expect(res.headers.get('cache-control')).toBe(cacheControl); + }); + + it.each([ + { scenario: 'no page action is enabled', pageActions: [] }, + { + scenario: 'another page action is enabled', + pageActions: [CustomizationPageActionType.Markdown], + }, + ])('does not serve a card when $scenario', async ({ pageActions }) => { + const res = await serveSiteMcpServerCard(makeContext({ pageActions }), cardRequest()); + + expect(res.status).toBe(404); + }); +}); diff --git a/packages/gitbook/src/lib/mcp/serverCard.ts b/packages/gitbook/src/lib/mcp/serverCard.ts new file mode 100644 index 000000000..11b087821 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/serverCard.ts @@ -0,0 +1,245 @@ +import type { Implementation, ServerCapabilities, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk/types.js'; + +import packageJSON from '../../../package.json'; +import { hasAdaptiveMcpEndpoint, isSiteMcpEnabled, mcpEndpointRequiresAuth } from './endpoints'; +import { type SiteMcpTool, createSiteMcpTools } from './tools'; +import type { GitBookSiteContext } from '@/lib/context'; +import { serveDiscoveryDocument } from '@/lib/discoveryDocument'; + +export const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json'; +export const SERVER_CARD_SCHEMA_URL = + 'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'; + +/** + * Namespace for every published site's card. + */ +const SERVER_CARD_NAMESPACE = 'com.gitbook.sites.mcp'; + +/** + * Version reported on the card and at `initialize` alike, so the two cannot drift. + */ +export const MCP_SERVER_VERSION = packageJSON.version; + +const MAX_TEXT_LENGTH = 100; + +const TITLE_SUFFIX = ' MCP Server'; +const DESCRIPTION_PREFIX = 'Search and read '; +const DESCRIPTION_ARTICLE = 'the '; +const DESCRIPTION_SUFFIX = ' documentation over MCP.'; + +/** + * A site's MCP Server Card, as defined by + * [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127). + * + * Its schema is still experimental and published neither on npm nor at the `$schema` URL it pins, + * so the fields we serve are declared here and their constraints are checked in `serverCard.test.ts`. + */ +export interface SiteMcpServerCard { + /** + * Required, and exactly {@link SERVER_CARD_SCHEMA_URL}. + */ + $schema: string; + /** + * Required. Reverse-DNS, with exactly one slash separating namespace from server name. + */ + name: string; + /** + * Required. The version the server reports at `initialize`. + */ + version: string; + /** + * Required, and at most 100 characters. + */ + description: string; + /** + * Display name, at most 100 characters. + */ + title: string; + websiteUrl: string; + icons: { + src: string; + mimeType?: string; + sizes?: string[]; + }[]; + remotes: SiteMcpServerCardRemote[]; + serverInfo: SiteMcpServerInfo; + /** + * The Streamable HTTP endpoint, the same URL as `remotes[0].url`. + */ + endpoint: string; + capabilities: ServerCapabilities; + tools: SiteMcpServerCardTool[]; +} + +/** + * Identity the server reports at `initialize`, republished on the card so a card. + */ +export type SiteMcpServerInfo = Implementation & { + /** + * Required here, though the SDK leaves it optional: a client with no card to read has nothing + * else to display, since `name` is the Streamable HTTP endpoint without its scheme. + */ + title: string; +}; + +export interface SiteMcpServerCardRemote { + type: 'streamable-http' | 'sse'; + url: string; + supportedProtocolVersions: string[]; + headers?: { + name: string; + description: string; + isRequired: boolean; + isSecret: boolean; + }[]; +} + +/** + * One tool the server registers, as a client sees it from `tools/list` minus the input schema: + * `tools/list` stays authoritative for that. + */ +export type SiteMcpServerCardTool = Pick<Tool, 'name' | 'description' | 'annotations'>; + +/** + * Build the card for a site's MCP server: the metadata an agent can read before it connects. + */ +export function buildSiteMcpServerCard( + context: GitBookSiteContext, + tools: SiteMcpTool[] +): SiteMcpServerCard { + const { linker, site } = context; + + const endpoint = getSiteMcpEndpoint(context); + const serverInfo = buildMcpServerInfo(context); + + return { + $schema: SERVER_CARD_SCHEMA_URL, + // Not the endpoint: the schema allows exactly one slash and no `~`, and a registry keys on + // this, so it stays put when a customer moves the site to another domain. + name: `${SERVER_CARD_NAMESPACE}/${site.id}`, + version: serverInfo.version, + title: serverInfo.title, + description: buildDescription(site.title), + websiteUrl: linker.toAbsoluteURL(linker.toPathInSite('')), + icons: [ + { + src: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/icon?size=medium')), + mimeType: 'image/png', + sizes: ['180x180'], + }, + ], + remotes: buildRemotes(context, endpoint), + serverInfo, + endpoint, + // What the SDK declares at `initialize` for a server with registered tools. A card that + // said otherwise would contradict the live connection. + capabilities: { tools: { listChanged: true } }, + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + annotations: tool.annotations, + })), + }; +} + +/** + * Build the identity the server reports at `initialize`, which the card republishes verbatim. + */ +export function buildMcpServerInfo(context: GitBookSiteContext): SiteMcpServerInfo { + const endpoint = new URL(getSiteMcpEndpoint(context)); + + return { + name: `${endpoint.host}${endpoint.pathname}`, + title: buildTitle(context.site.title), + version: MCP_SERVER_VERSION, + }; +} + +export function getSiteMcpEndpoint(context: GitBookSiteContext): string { + return context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp')); +} + +/** + * Serve a site's MCP server card. + * + * A card names the site and lists its tools, so it is gated exactly like the MCP endpoint it + * describes. + */ +export async function serveSiteMcpServerCard( + context: GitBookSiteContext, + request: Request +): Promise<Response> { + if (!isSiteMcpEnabled(context)) { + return new Response('Not Found', { status: 404 }); + } + + return serveDiscoveryDocument(context, request, { + document: buildSiteMcpServerCard(context, createSiteMcpTools(context, { request })), + mediaType: SERVER_CARD_MEDIA_TYPE, + }); +} + +/** + * Returns the info of the MCP endpoints a client can connect to. + */ +function buildRemotes(context: GitBookSiteContext, endpoint: string): SiteMcpServerCardRemote[] { + const { linker } = context; + + const remotes: SiteMcpServerCardRemote[] = [ + { + type: 'streamable-http', + url: endpoint, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + ...(mcpEndpointRequiresAuth(context) + ? { + headers: authorizationHeader( + 'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp. This site is restricted to authenticated visitors.' + ), + } + : {}), + }, + ]; + + if (hasAdaptiveMcpEndpoint(context)) { + remotes.push({ + type: 'streamable-http', + url: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/mcp/auth')), + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + headers: authorizationHeader( + 'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp/auth. Serves the content adapted to the authenticated visitor; connect to the public endpoint to read the site as an anonymous one.' + ), + }); + } + + return remotes; +} + +function authorizationHeader(description: string): SiteMcpServerCardRemote['headers'] { + return [{ name: 'Authorization', description, isRequired: true, isSecret: true }]; +} + +/** + * Name the server after the site it serves, within the 100 characters the schema allows a `title`. + */ +function buildTitle(siteTitle: string): string { + return `${truncate(siteTitle, MAX_TEXT_LENGTH - TITLE_SUFFIX.length)}${TITLE_SUFFIX}`; +} + +/** + * Fit the site's title into the 100 characters the schema allows a `description`. + */ +function buildDescription(siteTitle: string): string { + // Drop the article for a title that carries its own, which would read "read the The Acme Docs". + const article = /^the\s/i.test(siteTitle.trim()) ? '' : DESCRIPTION_ARTICLE; + const budget = + MAX_TEXT_LENGTH - DESCRIPTION_PREFIX.length - article.length - DESCRIPTION_SUFFIX.length; + return `${DESCRIPTION_PREFIX}${article}${truncate(siteTitle, budget)}${DESCRIPTION_SUFFIX}`; +} + +function truncate(value: string, maxLength: number): string { + const normalized = value.trim(); + return normalized.length <= maxLength + ? normalized + : `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} diff --git a/packages/gitbook/src/lib/mcp/tools.ts b/packages/gitbook/src/lib/mcp/tools.ts new file mode 100644 index 000000000..1935e70f7 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/tools.ts @@ -0,0 +1,456 @@ +import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ZodRawShape } from 'zod'; +import { z } from 'zod'; + +import { AgentFeedbackSource, SiteInsightsDisplayContext } from '@gitbook/api'; + +import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { + AGENT_FEEDBACK_GOAL_MAX_LENGTH, + AGENT_FEEDBACK_MAX_LENGTH, + agentFeedbackDescriptions, + parseAgentFeedbackPageURL, +} from '@/lib/agentFeedback'; +import { submitAgentFeedback } from '@/lib/agentFeedback/server'; +import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask'; +import type { GitBookSiteContext } from '@/lib/context'; +import { getExposableError, throwIfDataError } from '@/lib/data'; +import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage'; +import { joinPathWithBaseURL } from '@/lib/paths'; +import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites'; +import { trackServerInsightsEvents } from '@/lib/tracking'; +import { waitUntil } from '@/lib/waitUntil'; + +/** + * One tool the site's MCP server exposes. + */ +export interface SiteMcpTool<Args extends ZodRawShape = ZodRawShape> { + name: string; + description: string; + /** + * Zod shape of the tool arguments, passed as-is to `server.tool`. + */ + inputSchema: Args; + annotations: ToolAnnotations; + handler: ToolCallback<Args>; +} + +/** + * Build the tools the site's MCP server exposes, for the site the request is served from. + */ +export function createSiteMcpTools( + context: GitBookSiteContext, + options: { + /** + * The MCP request being served, attached to the insights events the tools emit. + */ + request: Request; + } +): SiteMcpTool[] { + const { request } = options; + const { dataFetcher, linker, site } = context; + const siteUrl = context.siteSpace.urls.published; + + const tools: SiteMcpTool[] = [ + defineTool({ + name: 'searchDocumentation', + description: `Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`, + inputSchema: { + query: z.string(), + }, + annotations: { + title: 'Search documentation', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async ({ query }) => { + const results = await throwIfDataError( + dataFetcher.searchSiteContent({ + organizationId: context.organizationId, + siteId: site.id, + query, + scope: { mode: 'all' }, + }) + ); + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'search_type_query', + query, + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + }, + }, + ], + request, + }); + + return { + content: results.flatMap((result) => { + if (result.type === 'record') { + return { + type: 'text', + text: [ + `Title: ${result.title}`, + `Link: ${result.url}`, + result.description ? `Content: ${result.description}` : '', + ] + .filter(Boolean) + .join('\n'), + }; + } + + const found = findSiteSpaceBy( + context.structure, + (siteSpace) => siteSpace.space.id === result.id + ); + const spaceURL = found?.siteSpace.urls.published; + if (!spaceURL) { + return []; + } + + return result.pages.map((pageResult) => { + const pageURL = linker.toAbsoluteURL( + linker.toLinkForContent( + joinPathWithBaseURL(spaceURL, pageResult.path) + ) + ); + + // The search API returns sections ordered highest-score-first, so + // the first section with a body is the best-scoring preview. + const body = (pageResult.sections ?? []).find( + (section) => section.body + )?.body; + + return { + type: 'text', + text: [ + `Title: ${pageResult.title}`, + `Link: ${pageURL}`, + body ? `Content: ${body}` : '', + ] + .filter(Boolean) + .join('\n'), + }; + }); + }), + }; + }, + }), + + defineTool({ + name: 'getPage', + description: `Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`, + inputSchema: { + url: z + .string() + .describe('The URL of the page to fetch') + .transform((value, ctx) => { + if (URL.canParse(value)) { + return value; + } + if (URL.canParse(`https://${value}`)) { + return `https://${value}`; + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`, + }); + return z.NEVER; + }), + }, + annotations: { + title: 'Get page content', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async ({ url }) => { + try { + const match = findSiteSpaceByUrl(context.structure, url); + if (!match) { + return { + content: [{ type: 'text', text: `Page not found: "${url}"` }], + isError: true, + }; + } + + const revision = await throwIfDataError( + dataFetcher.getRevision({ + spaceId: match.siteSpace.space.id, + revisionId: match.siteSpace.space.revision, + }) + ); + + const resolved = resolveSiteSpacePagePath( + match.siteSpace, + revision.pages, + match.pagePath + ); + if (!resolved) { + return { + content: [{ type: 'text', text: `Page not found: "${url}"` }], + isError: true, + }; + } + + const markdown = await getMarkdownForPageInSpace( + context, + match.siteSpace, + resolved.page + ); + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'page_view', + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + page: resolved.page.id, + space: match.siteSpace.space.id, + revision: match.siteSpace.space.revision, + }, + }, + ], + request, + }); + + return { content: [{ type: 'text', text: markdown }] }; + } catch (error) { + const exposable = getExposableError(error); + return { + content: [{ type: 'text', text: exposable.message }], + isError: true, + }; + } + }, + }), + ]; + + // Only expose the answer tool when the site has AI enabled, since it relies on + // the same AI search backend that powers the site's "ask a question" experience. + if (isAIEnabled(context.customization.ai.mode)) { + tools.push( + defineTool({ + name: 'askQuestion', + description: `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.`, + inputSchema: { + 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.' + ), + }, + annotations: { + title: 'Ask a question', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + handler: 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, + }; + } + }, + }) + ); + } + + tools.push( + defineTool({ + name: 'sendFeedback', + description: `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.`, + inputSchema: { + content: z + .string() + .min(1) + .max(AGENT_FEEDBACK_MAX_LENGTH) + .describe(agentFeedbackDescriptions.finding), + pageUrl: z + .string() + .describe(agentFeedbackDescriptions.pageURL(siteUrl)) + .transform((value, ctx) => { + const url = parseAgentFeedbackPageURL(value, siteUrl); + if (!url) { + 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 url; + }), + goal: z + .string() + .max(AGENT_FEEDBACK_GOAL_MAX_LENGTH) + .optional() + .describe(agentFeedbackDescriptions.goal), + }, + annotations: { + title: 'Send feedback', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + handler: async ({ content, pageUrl, goal }) => { + try { + const result = await submitAgentFeedback(context, { + feedback: content, + goal, + page: pageUrl, + source: AgentFeedbackSource.Mcp, + }); + + if (!result.submitted) { + return { + content: [{ type: 'text', text: result.error }], + isError: true, + }; + } + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'agent_feedback', + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + page: result.page.pageId, + space: result.page.spaceId, + revision: result.page.revisionId, + }, + }, + ], + 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, + }; + } + }, + }) + ); + + return tools; +} + +/** + * Erase a tool's argument shape so differently-shaped tools can share one array. + */ +function defineTool<Args extends ZodRawShape>(tool: SiteMcpTool<Args>): SiteMcpTool { + return tool as SiteMcpTool; +} + +/** + * Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the + * insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error. + */ +export function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) { + waitUntil( + trackServerInsightsEvents(args).catch((error) => { + console.error('Failed to track MCP insights event:', error); + }) + ); +} + +/** + * Register tools on the MCP server. Only used by the transport: the server card reads the same + * definitions without registering anything. + */ +export function registerSiteMcpTools(server: McpServer, tools: SiteMcpTool[]) { + for (const tool of tools) { + server.tool(tool.name, tool.description, tool.inputSchema, tool.annotations, tool.handler); + } +} diff --git a/packages/gitbook/src/lib/pages.test.ts b/packages/gitbook/src/lib/pages.test.ts index f74da9131..6b3306cea 100644 --- a/packages/gitbook/src/lib/pages.test.ts +++ b/packages/gitbook/src/lib/pages.test.ts @@ -89,6 +89,7 @@ describe('resolveFirstDocument', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ], @@ -143,6 +144,7 @@ describe('resolveFirstDocument', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ]; @@ -190,6 +192,7 @@ describe('resolvePagePath', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ]; @@ -267,6 +270,7 @@ describe('resolvePagePath', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ], @@ -386,6 +390,7 @@ function createDocumentPage(id: string, path: string, hidden = false): RevisionP metadata: true, tags: true, actions: true, + anchors: true, }, }; } diff --git a/packages/gitbook/src/lib/paths.test.ts b/packages/gitbook/src/lib/paths.test.ts index 18cf3ce6c..e5a9a1d92 100644 --- a/packages/gitbook/src/lib/paths.test.ts +++ b/packages/gitbook/src/lib/paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { getExtension } from './paths'; +import { getExtension, isGitBookInternalPath } from './paths'; describe('getExtension', () => { it('should return the extension of a path', () => { @@ -15,3 +15,17 @@ describe('getExtension', () => { expect(getExtension('test.with.multiple.dots.txt')).toBe('.txt'); }); }); + +describe('isGitBookInternalPath', () => { + it('should match paths with a ~gitbook segment', () => { + expect(isGitBookInternalPath('/2024.4/~gitbook/pdf')).toBe(true); + expect(isGitBookInternalPath('/docs/~gitbook')).toBe(true); + expect(isGitBookInternalPath('~gitbook/pdf')).toBe(true); + }); + + it('should not match paths that only contain ~gitbook as part of a segment', () => { + expect(isGitBookInternalPath('/docs/getting-started')).toBe(false); + expect(isGitBookInternalPath('/docs/~gitbooks/pdf')).toBe(false); + expect(isGitBookInternalPath('/docs/my~gitbook/pdf')).toBe(false); + }); +}); diff --git a/packages/gitbook/src/lib/paths.ts b/packages/gitbook/src/lib/paths.ts index ebdf35cd3..efe991a53 100644 --- a/packages/gitbook/src/lib/paths.ts +++ b/packages/gitbook/src/lib/paths.ts @@ -69,3 +69,10 @@ export function getExtension(path: string): string { } return ''; } + +/** + * Check if a path contains a `~gitbook` segment, i.e. targets an internal GitBook route. + */ +export function isGitBookInternalPath(path: string): boolean { + return /(^|\/)~gitbook(\/|$)/.test(path); +} diff --git a/packages/gitbook/src/lib/preview.test.ts b/packages/gitbook/src/lib/preview.test.ts index 09c0543a4..fdb85eb9d 100644 --- a/packages/gitbook/src/lib/preview.test.ts +++ b/packages/gitbook/src/lib/preview.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { getPreviewRequestIdentifier, isPreviewRequest } from './preview'; +import type { PublishedSiteContent } from '@gitbook/api'; + +import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview'; describe('isPreviewRequest', () => { it('should return true for preview requests', () => { @@ -23,3 +25,43 @@ describe('getPreviewRequestIdentifier', () => { expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo'); }); }); + +describe('getPreviewCookieResponse', () => { + // The site's canonical base path — deliberately different from the preview route prefix so the + // test fails if the cookie is scoped to it instead of the preview route. + const siteURLData = { siteBasePath: '/docs/' } as unknown as PublishedSiteContent; + + it('scopes the cookie to the preview route prefix in url-host mode, not the site base path', () => { + const cookie = getPreviewCookieResponse({ + name: 'gitbook-customization', + value: 'v', + mode: 'url-host', + siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'), + siteURLData, + }); + expect(cookie.options?.path).toBe('/preview/site_foo'); + expect(cookie.options?.sameSite).toBe('lax'); + }); + + it('scopes the cookie to the proxied preview route prefix in url mode', () => { + const cookie = getPreviewCookieResponse({ + name: 'gitbook-customization', + value: 'v', + mode: 'url', + siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'), + siteURLData, + }); + expect(cookie.options?.path).toBe('/url/sites.gitbook.com/preview/site_foo'); + }); + + it('falls back to the site base path for a non-preview url-host request', () => { + const cookie = getPreviewCookieResponse({ + name: 'gitbook-customization', + value: 'v', + mode: 'url-host', + siteRequestURL: new URL('https://example.com/docs/hello/world'), + siteURLData, + }); + expect(cookie.options?.path).toBe('/docs/'); + }); +}); diff --git a/packages/gitbook/src/lib/preview.ts b/packages/gitbook/src/lib/preview.ts index 0bba2a9b0..d13b95d89 100644 --- a/packages/gitbook/src/lib/preview.ts +++ b/packages/gitbook/src/lib/preview.ts @@ -51,8 +51,21 @@ export function getPreviewCookieResponse(args: { gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, ''); return `/url/${gitbookPreviewHostPath}/${getPreviewRequestIdentifier(siteRequestURL)}`; } - case 'url-host': - return siteURLData.siteBasePath; + case 'url-host': { + // The whole site preview (all variants/sections/pages) is served under the preview + // route prefix `<previewBasePath>/<siteId>` (e.g. `/preview/<siteId>`). Scope the + // cookie to that prefix, derived from the request, so it is resent on every + // in-preview navigation. The resolved `siteBasePath` is the site's canonical base + // path and does not prefix the preview route, so relying on it makes the browser + // drop the cookie on the next page — the actual cause of the override being lost. + // Mirrors getVisitorAuthBasePath, which uses the request identifier for proxy + // requests. Fall back to `siteBasePath` for any preview not served under the route. + if (!isPreviewRequest(siteRequestURL)) { + return siteURLData.siteBasePath; + } + const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL); + return `${gitbookPreviewBaseURL.pathname.replace(/\/$/, '')}/${getPreviewRequestIdentifier(siteRequestURL)}`; + } default: assertNever(mode); } diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 4342f5a78..d8bce5bc4 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, mock } from 'bun:test'; import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api'; @@ -627,12 +627,11 @@ describe('resolveContentRef for direct space links', () => { { label: 'API guides' }, { label: 'Authentification' }, { label: 'Référence', href: targetSiteSpace.urls.published }, - { label: 'Getting started', icon: null, href: expect.any(String) }, + { label: 'Getting started', icon: null, href: result?.href }, ]); expect(result?.ancestors?.[0]?.href).toBeUndefined(); expect(result?.ancestors?.[1]?.href).toBeUndefined(); expect(result?.ancestors?.[2]?.href).toBeUndefined(); - expect(result?.ancestors?.[4]?.href).toBeTruthy(); }); it('uses the first document as the target for a page-group reference', async () => { @@ -738,3 +737,221 @@ describe('resolveContentRef for direct space links', () => { ]); }); }); + +describe('repository page links', () => { + function fixture( + options: { + denied?: boolean; + missing?: boolean; + draft?: boolean; + gitSync?: object | null; + previousGitSync?: object; + pageGitPath?: string; + } = {} + ) { + const page = { + id: 'target-page', + type: 'document', + title: 'Authentication', + path: 'authentication', + slug: 'authentication', + pages: [], + git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' }, + } as unknown as RevisionPageDocument; + const targetSpace = { + id: 'target', + title: 'API', + organization: 'org', + revision: 'target-main', + gitSync: + options.gitSync === null + ? undefined + : (options.gitSync ?? { + url: 'https://github.com/acme/docs/tree/main', + installationProjectDirectory: 'api', + }), + previousGitSync: options.previousGitSync, + urls: { + app: 'https://app.gitbook.com/s/target', + published: 'https://docs.example.com/api/', + }, + } as unknown as Space; + const targetSiteSpace = { + id: 'site-target', + title: 'API', + space: targetSpace, + path: 'api', + draft: options.draft ?? false, + urls: { published: 'https://docs.example.com/api/' }, + } as unknown as SiteSpace; + const getSpace = mock(async () => + options.denied ? { error: { code: 403, message: 'Forbidden' } } : { data: targetSpace } + ); + const getRevision = mock(async () => ({ + data: { + id: 'target-main', + pages: options.missing + ? [] + : [{ ...page, id: 'home', path: '', slug: '', git: undefined }, page], + files: [], + reusableContents: [], + }, + })); + const context = { + organizationId: 'org', + site: { id: 'site' }, + space: { id: 'source', revision: 'source-main' }, + revision: { pages: [] }, + revisionId: 'source-main', + changeRequest: null, + structure: { type: 'siteSpaces', structure: [targetSiteSpace] }, + linker: createLinker({ + host: 'docs.example.com', + siteBasePath: '/', + spaceBasePath: '/source/', + }), + dataFetcher: { getSpace, getRevision }, + } as unknown as GitBookAnyContext; + return { context, getSpace, getRevision }; + } + + const ref = { + kind: 'url' as const, + url: 'https://github.com/acme/docs/tree/main/api/auth.md#tokens', + }; + + it('renders a matching repository URL as a site page link with its anchor', async () => { + const { context, getRevision } = fixture(); + const result = await resolveContentRef(ref, context); + expect(result?.href).toBe('/api/authentication#tokens'); + expect(result?.text).toBe('Authentication'); + expect(result?.ancestors?.[0]?.label).toBe('API'); + expect(result?.resolvedRef).toEqual({ + kind: 'anchor', + space: 'target', + page: 'target-page', + anchor: 'tokens', + }); + expect(getRevision).toHaveBeenCalledWith({ + spaceId: 'target', + revisionId: 'target-main', + metadata: true, + }); + expect(ref.kind).toBe('url'); + }); + + it('preserves asset URLs that do not match a page', async () => { + const { context } = fixture(); + const assetRef = { + kind: 'url' as const, + url: ref.url.replace('auth.md#tokens', 'diagram.png'), + }; + expect((await resolveContentRef(assetRef, context))?.href).toBe(assetRef.url); + }); + + it('resolves a link to a folder with a dot in its name to its README page', async () => { + const { context } = fixture({ pageGitPath: 'api/11.8/README.md' }); + expect( + ( + await resolveContentRef( + { kind: 'url', url: ref.url.replace('auth.md', '11.8') }, + context + ) + )?.href + ).toBe('/api/authentication#tokens'); + }); + + it('resolves pages stored with another Markdown extension', async () => { + const { context } = fixture({ pageGitPath: 'api/Auth.MARKDOWN' }); + expect( + ( + await resolveContentRef( + { kind: 'url', url: ref.url.replace('auth.md', 'Auth.MARKDOWN') }, + context + ) + )?.href + ).toBe('/api/authentication#tokens'); + }); + + it('resolves a space syncing from the repository root', async () => { + const { context } = fixture({ + gitSync: { url: 'https://github.com/acme/docs/tree/main' }, + }); + expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens'); + }); + + it('resolves a space whose Git Sync was removed from its remembered project URL', async () => { + const { context } = fixture({ + gitSync: null, + previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' }, + }); + expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens'); + }); + + it('decodes the anchor of the repository URL', async () => { + const { context } = fixture(); + const result = await resolveContentRef( + { kind: 'url', url: ref.url.replace('#tokens', '#access%20tokens') }, + context + ); + expect(result?.resolvedRef).toEqual({ + kind: 'anchor', + space: 'target', + page: 'target-page', + anchor: 'access tokens', + }); + }); + + it('reads only the matching space in a 500-space site', async () => { + const { context, getSpace, getRevision } = fixture(); + if (!('site' in context) || context.structure.type !== 'siteSpaces') { + throw new Error('Expected a site fixture'); + } + const target = context.structure.structure[0]!; + context.structure.structure.push( + ...Array.from({ length: 499 }, (_, index) => ({ + ...target, + id: `site-${index}`, + space: { + ...target.space, + id: `space-${index}`, + gitSync: { + ...target.space.gitSync!, + installationProjectDirectory: `other-${index}`, + }, + }, + })) + ); + expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens'); + expect(getSpace).toHaveBeenCalledTimes(1); + expect(getRevision).toHaveBeenCalledTimes(1); + }); + + it.each([{ denied: true }, { missing: true }, { draft: true }])( + 'preserves the fallback for unavailable content: %j', + async (state) => { + const { context } = fixture(state); + const result = await resolveContentRef(ref, context); + expect(result).toEqual({ href: ref.url, text: ref.url, active: false }); + } + ); + + it('does not fetch revisions for a different repository or branch', async () => { + const { context, getRevision } = fixture(); + for (const url of [ + ref.url.replace('/main/', '/preview/'), + ref.url.replace('/acme/', '/other/'), + ]) { + expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url); + } + expect(getRevision).not.toHaveBeenCalled(); + }); + + it('resolves the unchanged stored URL when the target becomes available', async () => { + const state = { missing: true }; + const { context } = fixture(state); + expect((await resolveContentRef(ref, context))?.href).toBe(ref.url); + state.missing = false; + expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens'); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index 79eae72be..7ee2db0a0 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -9,6 +9,7 @@ import type { RevisionReusableContent, SiteSection, SiteSpace, + SiteStructure, Space, TranslationLanguage, } from '@gitbook/api'; @@ -16,12 +17,19 @@ import type { Filesystem } from '@gitbook/openapi-parser'; import { getGitBookAppHref } from './app'; import { getBlockById, getBlockTitle } from './document'; -import { resolvePageId } from './pages'; +import { + type GitPageURLSpace, + type GitPageURLTarget, + findGitPageURLTarget, + findPageForGitPageURLTarget, +} from './gitPageURL'; +import { resolveFirstDocument, resolvePageId } from './pages'; import { findSiteSpaceBy, getFallbackSiteSpacePath, getLinkerForSiteSpace, getLocalizedTitle, + listAllSiteSpaces, } from './sites'; import { getRevisionTags, resolveTag } from './tags'; import type { ClassValue } from './tailwind'; @@ -42,7 +50,15 @@ import { } from '@/lib/data'; import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links'; +// The spaces of each site that can own a repository URL, and the hosts of their repositories. +const siteGitSpaces = new WeakMap< + SiteStructure, + { spaces: GitPageURLSpace[]; hosts: Set<string> } +>(); + export interface ResolvedContentRef { + /** Effective destination when a repository URL resolves to a site page. */ + resolvedRef?: ContentRef; /** Text to render in the content ref */ text: string; /** Additional sub text to render in the content ref */ @@ -143,6 +159,61 @@ export async function resolveContentRef( switch (contentRef.kind) { case 'url': { + if ('site' in context) { + const target = findSiteGitPageURLTarget(context.structure, contentRef.url); + if (target) { + try { + // Site CRs must select the target member's revision here instead of main. + const targetContext = await createContextForSpace( + target.space, + context, + true + ); + const page = + targetContext && + findPageForGitPageURLTarget( + targetContext.spaceContext.revision.pages, + target + ); + if (page?.type === 'document' && targetContext) { + const resolvedRef: ContentRef = target.anchor + ? { + kind: 'anchor', + space: target.space, + page: page.id, + anchor: target.anchor, + } + : { kind: 'page', space: target.space, page: page.id }; + const resolved = await resolveContentRef( + resolvedRef, + targetContext.spaceContext, + options + ); + if (resolved) { + const foundSiteSpace = findSiteSpaceBy( + context.structure, + (entry) => entry.space.id === target.space + ); + return { + ...resolved, + resolvedRef, + ancestors: [ + ...resolvePageAncestors( + context, + resolvedRef, + foundSiteSpace, + targetContext + ), + ...(resolved.ancestors ?? []), + ], + }; + } + } + } catch { + // An unavailable or forbidden target must not prevent rendering the source page. + } + } + } return { href: contentRef.url, text: contentRef.url, @@ -205,7 +276,14 @@ export async function resolveContentRef( style={iconStyle} /> ) : null, - href: linker.toPathForPage({ page: ancestor, pages: revision.pages }), + // A group's own URL redirects to its first document, so link straight to it. + href: linker.toPathForPage({ + page: + ancestor.type === 'group' + ? (resolveFirstDocument([ancestor], [])?.page ?? ancestor) + : ancestor, + pages: revision.pages, + }), })) ?? []; if (!page) { return null; @@ -619,6 +697,44 @@ async function resolveContentRefInSpace( } } +/** + * Locate the site space owning a repository URL. Links to other hosts, which most are, skip + * matching against every space of the site. + */ +function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null { + let site = siteGitSpaces.get(structure); + if (!site) { + const spaces: GitPageURLSpace[] = listAllSiteSpaces(structure) + .filter((siteSpace) => !siteSpace.draft) + .map((siteSpace) => siteSpace.space); + const hosts = new Set( + spaces.flatMap((space) => { + const host = getURLHost(space.gitSync?.url ?? space.previousGitSync?.url); + return host ? [host, `www.${host}`] : []; + }) + ); + site = { spaces, hosts }; + siteGitSpaces.set(structure, site); + } + + const host = getURLHost(href); + if (!host || !site.hosts.has(host)) { + return null; + } + return findGitPageURLTarget(href, site.spaces); +} + +function getURLHost(href: string | undefined): string | null { + if (!href) { + return null; + } + try { + return new URL(href).host; + } catch { + return null; + } +} + /** * Create a new context for a specific spaceId. * @@ -627,7 +743,8 @@ async function resolveContentRefInSpace( */ async function createContextForSpace( spaceId: string, - context: GitBookAnyContext + context: GitBookAnyContext, + revisionMetadata = false ): Promise<{ spaceContext: GitBookSpaceContext; baseURL: URL; @@ -639,6 +756,7 @@ async function createContextForSpace( shareKey: context?.shareKey, changeRequest: undefined, revision: undefined, + revisionMetadata, }) ), getBestTargetSpace(context, spaceId), @@ -699,6 +817,16 @@ export function resolveStringContentRef(src: string): ContentRef | null { return null; } +/** Strip the origin from an absolute URL, as content refs are resolved on the path only. */ +export function toContentRefPath(src: string): string { + if (!URL.canParse(src)) { + return src; + } + + const parsed = new URL(src); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + const RESOLVERS: { [kind in ContentRef['kind']]: { resolve: (src: string) => Extract<ContentRef, { kind: kind }> | null; diff --git a/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts b/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts new file mode 100644 index 000000000..1a357f998 --- /dev/null +++ b/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolveSelectSlug } from './slug'; + +// Mirrors packages/doc-core/src/select/__tests__/resolveSelectSlug.test.ts in the gitbook-x repo, so +// a tab resolves to the same slug in the editor preview as it does on the published site. +describe('resolveSelectSlug', () => { + it('prefers an explicit slug over the title', () => { + expect(resolveSelectSlug({ slug: 'macos', title: 'macOS 15' })).toBe('macos'); + }); + + it('slugifies an explicit slug, since it reaches CSS selectors and stored state', () => { + expect(resolveSelectSlug({ slug: 'On Prem!', title: 'Self hosted' })).toBe('on-prem'); + }); + + it('derives from the title when no slug is set', () => { + expect(resolveSelectSlug({ title: 'macOS 15' })).toBe('macos-15'); + }); + + it('keeps titles syncing across a site: same title, same slug', () => { + expect(resolveSelectSlug({ title: 'macOS' })).toBe(resolveSelectSlug({ title: 'macOS' })); + }); + + it('falls back to the title when the slug is empty or unaddressable', () => { + expect(resolveSelectSlug({ slug: '', title: 'Windows' })).toBe('windows'); + // An emoji-only slug reduces to nothing, which means "not set", not "clear the slug". + expect(resolveSelectSlug({ slug: '🎉', title: 'Windows' })).toBe('windows'); + }); + + it('returns an empty slug when neither field is addressable', () => { + expect(resolveSelectSlug({})).toBe(''); + expect(resolveSelectSlug({ title: '🎉' })).toBe(''); + expect(resolveSelectSlug({ slug: '🎉', title: '🎉' })).toBe(''); + }); + + it('is idempotent, so a resolved slug can be fed back in', () => { + const once = resolveSelectSlug({ title: 'macOS 15' }); + expect(resolveSelectSlug({ slug: once })).toBe(once); + }); +}); diff --git a/packages/gitbook/src/lib/select/slug.ts b/packages/gitbook/src/lib/select/slug.ts index b4864b76e..ca8f528b9 100644 --- a/packages/gitbook/src/lib/select/slug.ts +++ b/packages/gitbook/src/lib/select/slug.ts @@ -49,3 +49,21 @@ export function slugifySelectValue(name: string): string { // trailing `-` the cut may have exposed. return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, ''); } + +/** + * Resolve the `select` slug for one option — a tab, a variant, a picker entry. + * + * An explicit slug wins but is still slugified, since it reaches generated CSS attribute selectors + * and is compared against stored state. Falling back to the title is what lets same-named options + * across a site select together with no authoring, so an absent slug is the normal case. + * + * DUPLICATED, ON PURPOSE, alongside {@link slugifySelectValue}: the editor owns the same function at + * `packages/doc-core/src/select/slug.ts` in the gitbook-x repo, and the two must agree or a tab + * resolves differently in the editor preview than on the published site. + */ +export function resolveSelectSlug(option: { slug?: string; title?: string }): string { + return ( + (option.slug ? slugifySelectValue(option.slug) : '') || + (option.title ? slugifySelectValue(option.title) : '') + ); +} diff --git a/packages/gitbook/src/lib/tracking.ts b/packages/gitbook/src/lib/tracking.ts index 2bb066d87..fa561ecc3 100644 --- a/packages/gitbook/src/lib/tracking.ts +++ b/packages/gitbook/src/lib/tracking.ts @@ -3,7 +3,7 @@ import type { headers as nextHeaders } from 'next/headers'; import type * as api from '@gitbook/api'; import { apiClient } from './data/api'; -import { GITBOOK_DISABLE_TRACKING } from './env'; +import { GITBOOK_DISABLE_INSIGHTS, GITBOOK_DISABLE_TRACKING } from './env'; import { getLogger } from './logger'; /** @@ -18,9 +18,7 @@ export function shouldTrackEvents(headers?: Awaited<ReturnType<typeof nextHeader return false; } - const disableTrackingHeader = headers?.get('x-gitbook-disable-tracking'); - - if (disableTrackingHeader === 'true') { + if (headers?.get('x-gitbook-disable-tracking') === 'true') { return false; } @@ -85,7 +83,7 @@ export async function trackServerInsightsEvents(args: { `Tracking ${args.events.length} events at ${request.url} for site ${args.siteId} (enabled=${!GITBOOK_DISABLE_TRACKING})` ); - if (GITBOOK_DISABLE_TRACKING) { + if (GITBOOK_DISABLE_TRACKING || GITBOOK_DISABLE_INSIGHTS) { return; } diff --git a/packages/gitbook/src/lib/updates.test.ts b/packages/gitbook/src/lib/updates.test.ts index cd3a8148b..a132e60e6 100644 --- a/packages/gitbook/src/lib/updates.test.ts +++ b/packages/gitbook/src/lib/updates.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test'; import type { JSONDocument, Revision } from '@gitbook/api'; -import { getDocumentFilterableTags } from './updates'; +import { + UPDATES_TAG_FILTER_CAP, + getDocumentFilterableTags, + normalizeUpdatesFilterTags, +} from './updates'; describe('getDocumentFilterableTags', () => { it('returns unique update tags in document order', () => { @@ -28,6 +32,26 @@ describe('getDocumentFilterableTags', () => { }); }); +describe('normalizeUpdatesFilterTags', () => { + it('trims, validates, deduplicates, and caps tags', () => { + const availableTags = new Set([ + 'fixes', + ...Array.from({ length: UPDATES_TAG_FILTER_CAP }, (_, index) => `tag-${index}`), + ]); + const tags = [ + ' fixes ', + 'unknown', + 'fixes', + ...Array.from({ length: UPDATES_TAG_FILTER_CAP }, (_, index) => `tag-${index}`), + ]; + + expect(normalizeUpdatesFilterTags(tags, availableTags)).toEqual([ + 'fixes', + ...Array.from({ length: UPDATES_TAG_FILTER_CAP - 1 }, (_, index) => `tag-${index}`), + ]); + }); +}); + function createUpdatesDocument(entryTags: string[][]): JSONDocument { return { object: 'document', diff --git a/packages/gitbook/src/lib/updates.ts b/packages/gitbook/src/lib/updates.ts index 41d91e57e..a7fa9e8b6 100644 --- a/packages/gitbook/src/lib/updates.ts +++ b/packages/gitbook/src/lib/updates.ts @@ -3,6 +3,65 @@ import type { JSONDocument, Revision, RevisionTag } from '@gitbook/api'; import { getBlocksByType } from './document'; import { getRevisionTags, resolveBlockTags } from './tags'; +/** URL search param carrying the active tag filter, e.g. `?tag=changelog`. */ +export const UPDATES_FILTER_SEARCH_PARAM = 'tag'; + +/** Attribute on `<html>` with the active filter's tag slugs, space-separated (e.g. `"a b"`). */ +export const UPDATES_TAG_FILTER_ATTR = 'data-updates-tag-filter'; + +/** + * Attribute on `<html>` identifying which page's filter stylesheet is active (see + * `updatesFilterStyleKey`) — scopes the generated CSS so a stale, hoisted stylesheet from a + * previously visited page never matches the page that's actually mounted. + */ +export const UPDATES_FILTER_KEY_ATTR = 'data-updates-filter-key'; + +/** Attribute carrying an update entry's own tag slugs, space-separated. Read by the generated CSS. */ +export const UPDATES_TAG_ATTR = 'data-update-tags'; + +/** Attribute carrying a "page contents" section's own tag slugs, space-separated (only set on sections that have tags). */ +export const UPDATES_TAG_SECTION_ATTR = 'data-tag-section'; + +/** Safety cap on how many active tags are accepted from the URL or filter controls. */ +export const UPDATES_TAG_FILTER_CAP = 20; + +/** Keep in sync with the duplicated logic in `applyUpdatesFilterScript`. */ +export function normalizeUpdatesFilterTags( + tags: string[], + availableTags: ReadonlySet<string> +): string[] { + const next: string[] = []; + const seen = new Set<string>(); + + for (const value of tags) { + const tag = value.trim(); + if ( + !tag || + seen.has(tag) || + next.length >= UPDATES_TAG_FILTER_CAP || + !availableTags.has(tag) + ) { + continue; + } + + next.push(tag); + seen.add(tag); + } + + return next; +} + +// Chip look variants (see TagChip in UpdatesFilter.tsx) — CSS-toggled, not React state, for a +// flash-free first paint. +/** Marks a chip's "selected" span variant, valued with its own tag slug. */ +export const UPDATES_TAG_CHIP_SELECTED_ATTR = 'data-tag-chip-selected'; +/** Marks a chip's "unselected, dimmed" span variant. */ +export const UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR = 'data-tag-chip-unselected-dimmed'; +/** Marks a chip's "unselected, plain" span variant — visible by default. */ +export const UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR = 'data-tag-chip-unselected-plain'; +/** Marks the "clear filter" button — hidden until a filter is active. */ +export const UPDATES_TAG_CLEAR_ATTR = 'data-updates-tag-clear'; + /** * Get the unique tags used by update entries in a document, preserving document order. */ @@ -29,3 +88,69 @@ export function getDocumentFilterableTags( return tags; } + +/** + * Escape a slug for interpolation into a CSS string literal (a quoted attribute-selector value). + */ +function escapeCssString(value: string): string { + return value.replace(/["\\]/g, '\\$&'); +} + +// FNV-1a (32-bit) constants — see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function +const FNV_OFFSET_BASIS_32 = 0x811c9dc5; +const FNV_PRIME_32 = 0x01000193; + +/** Stable, order-independent identifier for a page's set of filterable tags. */ +export function updatesFilterStyleKey(tagSlugs: string[]): string { + const key = [...new Set(tagSlugs)].sort().join(' '); + let hash = FNV_OFFSET_BASIS_32; + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME_32); + } + return (hash >>> 0).toString(36); +} + +/** React dedupes/hoists `<style href>` tags by this value, so each tag set needs its own href. */ +export function updatesFilterStyleHref(tagSlugs: string[]): string { + return `gb-updates-filter-${updatesFilterStyleKey(tagSlugs)}`; +} + +/** + * Generate the CSS that filters `updates` entries, "page contents" sections, and tag-filter chips by + * the active `?tag=` filter, purely via attribute selectors — so filtering applies before hydration + * even though the page's HTML doesn't vary by query string. Byte-identical per page, no cache impact. + */ +export function generateUpdatesFilterCSS(tagSlugs: RevisionTag['slug'][]): string { + const slugs = [...new Set(tagSlugs.filter(Boolean))]; + if (slugs.length === 0) { + return ''; + } + + const key = escapeCssString(updatesFilterStyleKey(tagSlugs)); + // Scoped behind this page's own key so a stale, hoisted stylesheet from another page never matches. + const scope = `html[${UPDATES_FILTER_KEY_ATTR}="${key}"]`; + const entry = `[${UPDATES_TAG_ATTR}]`; + const section = `[${UPDATES_TAG_SECTION_ATTR}]`; + // Leaves the `[` dangling — only ever used as `${scope}[${filtering} …` or `…:not(…`. + const filtering = `${UPDATES_TAG_FILTER_ATTR}]:not([${UPDATES_TAG_FILTER_ATTR}=""])`; + const rules = [ + `${scope}[${filtering} ${entry}{display:none}`, + `${scope}[${filtering} ${section}{display:none}`, + `${scope}[${filtering} [${UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR}]{display:none}`, + `${scope}[${filtering} [${UPDATES_TAG_CLEAR_ATTR}]{visibility:visible;pointer-events:auto}`, + ]; + + for (const slug of slugs) { + const value = escapeCssString(slug); + rules.push( + // `flex`, not `revert`, to match the entry's/section's own layout class. + `${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] ${entry}[${UPDATES_TAG_ATTR}~="${value}"]{display:flex}`, + `${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] ${section}[${UPDATES_TAG_SECTION_ATTR}~="${value}"]{display:flex}`, + `${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] [${UPDATES_TAG_CHIP_SELECTED_ATTR}="${value}"]{display:inline-flex}`, + `${scope}[${filtering}:not([${UPDATES_TAG_FILTER_ATTR}~="${value}"]) [${UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR}="${value}"]{display:inline-flex}` + ); + } + + return rules.join(''); +} diff --git a/packages/gitbook/src/lib/visitors.test.ts b/packages/gitbook/src/lib/visitors.test.ts index d70ec19ad..f5e2f3c0b 100644 --- a/packages/gitbook/src/lib/visitors.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -7,10 +7,14 @@ import { getVisitorAuthCookieMaxAge, getVisitorAuthCookieName, getVisitorAuthCookieValue, + getVisitorCountry, getVisitorToken, getVisitorType, getVisitorUnsignedClaims, + isRevalidationRequest, normalizeVisitorURL, + parseHostnameList, + shouldSendVisitorCountry, } from './visitors'; describe('getVisitorAuthToken', () => { @@ -228,6 +232,27 @@ describe('getVisitorAuthToken', () => { // The chunking mechanics themselves are covered in api-token-cookie.test.ts; // these tests cover the wiring into the visitor auth cookie. +describe('getResponseCookiesForVisitorAuth write policy', () => { + const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const token = `${base64url({ alg: 'none' })}.${base64url({ exp: 2_000_000_000 })}.sig`; + + it('persists a token that arrived in the URL', () => { + expect(getResponseCookiesForVisitorAuth('/', { source: 'url', token })).toHaveLength(1); + }); + + it('only accepts a token that arrived in the URL', () => { + const fromVACookie = { source: 'visitor-auth-cookie', basePath: '/', token } as const; + const fromCustomCookie = { source: 'gitbook-visitor-cookie', token } as const; + + // @ts-expect-error a token read back from a VA cookie must never be persisted again + const buildFromVACookie = () => getResponseCookiesForVisitorAuth('/', fromVACookie); + // @ts-expect-error a custom visitor cookie is owned by the customer's backend + const buildFromCustom = () => getResponseCookiesForVisitorAuth('/', fromCustomCookie); + + expect([buildFromVACookie, buildFromCustom]).toHaveLength(2); + }); +}); + describe('getResponseCookiesForVisitorAuth chunking', () => { const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); @@ -564,3 +589,105 @@ describe('getVisitorType', () => { expect(getVisitorType(requestWith({ 'user-agent': '' }))).toBe('human'); }); }); + +describe('isRevalidationRequest', () => { + it('should detect the revalidation worker regardless of casing', () => { + expect( + isRevalidationRequest(new Headers({ 'User-Agent': 'GitBook-Open-Revalidation-Worker' })) + ).toBe(true); + }); + + it('should not detect a regular request', () => { + expect(isRevalidationRequest(new Headers({ 'User-Agent': 'Mozilla/5.0' }))).toBe(false); + expect(isRevalidationRequest(new Headers())).toBe(false); + }); +}); + +describe('getVisitorCountry', () => { + const requestWith = (headers: Record<string, string>) => ({ headers: new Headers(headers) }); + + it('should prefer the OpenNext country header', () => { + expect( + getVisitorCountry( + requestWith({ 'x-open-next-country': 'FR', 'x-vercel-ip-country': 'US' }) + ) + ).toBe('FR'); + }); + + it('should fall back to the Vercel country header', () => { + expect(getVisitorCountry(requestWith({ 'x-vercel-ip-country': 'US' }))).toBe('US'); + }); + + it('should normalize the country code to uppercase', () => { + expect(getVisitorCountry(requestWith({ 'x-open-next-country': ' fr ' }))).toBe('FR'); + }); + + it.each(['XX', 'T1', 'FRA', 'F', ''])('should ignore the invalid country code %p', (value) => { + expect(getVisitorCountry(requestWith({ 'x-open-next-country': value }))).toBeUndefined(); + }); + + it('should return undefined when no country header is present', () => { + expect(getVisitorCountry(requestWith({}))).toBeUndefined(); + }); + + it('should prefer the forwarded country header on revalidation requests', () => { + expect( + getVisitorCountry( + requestWith({ + 'user-agent': 'gitbook-open-revalidation-worker', + 'x-gitbook-country': 'DE', + 'x-open-next-country': 'FR', + }) + ) + ).toBe('DE'); + }); + + it('should ignore the forwarded country header outside revalidation requests', () => { + expect( + getVisitorCountry( + requestWith({ 'x-gitbook-country': 'DE', 'x-open-next-country': 'FR' }) + ) + ).toBe('FR'); + expect(getVisitorCountry(requestWith({ 'x-gitbook-country': 'DE' }))).toBeUndefined(); + }); + + it('should fall back to geolocation headers on revalidation requests without a forwarded country', () => { + expect( + getVisitorCountry( + requestWith({ + 'user-agent': 'gitbook-open-revalidation-worker', + 'x-open-next-country': 'FR', + }) + ) + ).toBe('FR'); + }); +}); + +describe('shouldSendVisitorCountry', () => { + it.each(['dev', 'preview', 'staging'])('should be enabled on the %p stage', (stage) => { + expect(shouldSendVisitorCountry('docs.example.com', stage)).toBe(true); + }); + + it.each(['production', undefined])( + 'should be disabled for other hostnames on the %p stage', + (stage) => { + expect(shouldSendVisitorCountry('docs.example.com', stage)).toBe(false); + } + ); +}); + +describe('parseHostnameList', () => { + it.each([undefined, '', ' , '])('should return an empty set for %p', (value) => { + expect(parseHostnameList(value).size).toBe(0); + }); + + it('should parse a single entry', () => { + expect(parseHostnameList('docs.foo.com')).toEqual(new Set(['docs.foo.com'])); + }); + + it('should trim, lowercase and drop empty entries', () => { + expect(parseHostnameList(' Docs.Foo.com , ,bar.io')).toEqual( + new Set(['docs.foo.com', 'bar.io']) + ); + }); +}); diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index 29d92e145..33a7ed937 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -106,6 +106,56 @@ export function getVisitorType(request: { return detection.detected && detection.method !== 'heuristic' ? 'agent' : 'human'; } +// Production hostnames for which the visitor country is sent while the feature is rolled out. +// Set at build time from a GitHub Actions variable to keep customer hostnames out of the code. +const VISITOR_COUNTRY_HOSTNAMES = parseHostnameList(process.env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES); +const VISITOR_COUNTRY_STAGES = new Set(['dev', 'preview', 'staging']); + +/** + * Parse a comma-separated list of hostnames. + */ +export function parseHostnameList(value: string | undefined): Set<string> { + return new Set( + (value ?? '') + .split(',') + .map((hostname) => hostname.trim().toLowerCase()) + .filter(Boolean) + ); +} + +/** + * Whether the visitor country should be sent when resolving the site URL. + */ +export function shouldSendVisitorCountry(hostname: string, stage: string | undefined): boolean { + return ( + (!!stage && VISITOR_COUNTRY_STAGES.has(stage)) || + VISITOR_COUNTRY_HOSTNAMES.has(hostname.toLowerCase()) + ); +} + +/** + * Get the ISO 3166-1 alpha-2 country code of the visitor from the geolocation headers, + * or from `x-gitbook-country` when the request comes from the revalidation worker. + */ +export function getVisitorCountry(request: { headers: Headers }): string | undefined { + // The revalidation worker forwards the original visitor country; only trust it from there. + const country = ( + (isRevalidationRequest(request.headers) && request.headers.get('x-gitbook-country')) || + request.headers.get('x-open-next-country') || + request.headers.get('x-vercel-ip-country') || + '' + ) + .trim() + .toUpperCase(); + + // Cloudflare uses XX for unknown and T1 for Tor, which are not ISO codes. + if (!/^[A-Z]{2}$/.test(country) || country === 'XX' || country === 'T1') { + return undefined; + } + + return country; +} + /** * Get the visitor data for the request potentially including: * - a JWT token that may contain signed claims or can be used for VA authentication. @@ -132,6 +182,14 @@ export function getVisitorData({ }; } +/** + * Check if the request is coming from our revalidation worker. Such requests carry the visitor + * data they want to revalidate in the URL, so we must not redirect them to a normalized URL. + */ +export function isRevalidationRequest(headers: Headers): boolean { + return headers.get('user-agent')?.toLowerCase() === 'gitbook-open-revalidation-worker'; +} + /** * Get the visitor token for the request. This token can either be in the * query parameters or stored as a cookie. @@ -154,7 +212,7 @@ export function getVisitorToken({ // Allow the empty string to come through if (fromUrl !== null && fromUrl !== undefined) { - if (headers.get('user-agent')?.toLowerCase() === 'gitbook-open-revalidation-worker') { + if (isRevalidationRequest(headers)) { return { source: 'revalidation', token: fromUrl }; } @@ -305,17 +363,19 @@ function getResponseCookieForVisitorParams( } /** - * Return the lookup result for content served with visitor auth. + * Build the cookies persisting a visitor auth token that arrived in the URL. + * + * Only a token from URL is ever persisted into the VA cookie: a token read back from a VA cookie is already + * stored and its expiry derives from the token itself, so rewriting it achieves nothing except + * letting an in-flight request resurrect a cookie `~gitbook/auth/logout` has just deleted. A + * custom `gitbook-visitor-token` cookie is owned by the customer's backend and is never copied, + * to keep a single source of truth. */ export function getResponseCookiesForVisitorAuth( basePath: string, - visitorTokenLookup: VisitorTokenLookup, + visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>, requestCookies: RequestCookies = [] ): ResponseCookies { - if (!visitorTokenLookup) { - return []; - } - let decoded: JwtPayload; try { decoded = jwtDecode(visitorTokenLookup.token); @@ -324,43 +384,29 @@ export function getResponseCookiesForVisitorAuth( return []; } - /** - * If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it - * as a cookie on the response. - * - * Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth. - */ - if ( - visitorTokenLookup?.source === 'url' || - (visitorTokenLookup?.source === 'visitor-auth-cookie' && - visitorTokenLookup.basePath === basePath) - ) { - const name = getVisitorAuthCookieName(basePath); - const value = getVisitorAuthCookieValue(basePath, visitorTokenLookup.token); - const options = { - httpOnly: true, - sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : undefined, - secure: process.env.NODE_ENV === 'production', - maxAge: getVisitorAuthCookieMaxAge(decoded), - }; + const name = getVisitorAuthCookieName(basePath); + const value = getVisitorAuthCookieValue(basePath, visitorTokenLookup.token); + const options = { + httpOnly: true, + sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : undefined, + secure: process.env.NODE_ENV === 'production', + maxAge: getVisitorAuthCookieMaxAge(decoded), + }; - // Chunk the cookie when the value exceeds the single-cookie size limit, - // as browsers silently drop oversized Set-Cookie headers which causes - // an infinite auth redirect loop. - if (value.length > MAX_CHUNKED_COOKIE_LENGTH) { - // Too large even for chunking: fall back to a single cookie (best effort). - return [{ name, value, options }]; - } - - return getChunkedResponseCookies({ - cookies: requestCookies, - cookieName: name, - value, - options, - }); + // Chunk the cookie when the value exceeds the single-cookie size limit, + // as browsers silently drop oversized Set-Cookie headers which causes + // an infinite auth redirect loop. + if (value.length > MAX_CHUNKED_COOKIE_LENGTH) { + // Too large even for chunking: fall back to a single cookie (best effort). + return [{ name, value, options }]; } - return []; + return getChunkedResponseCookies({ + cookies: requestCookies, + cookieName: name, + value, + options, + }); } /** diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index f1e491808..e346b9c8f 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -21,6 +21,7 @@ import { serveProxyAnalyticsEvent, trackServerInsightsEvents, } from './lib/tracking'; +import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths'; import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie'; import { isChatGPTRequest } from '@/lib/chatgpt'; import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies'; @@ -34,9 +35,15 @@ import { normalizeRequestURL, throwIfDataError, } from '@/lib/data'; -import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; +import { + GITBOOK_DISABLE_INSIGHTS, + GITBOOK_STAGE, + isGitBookAssetsHostURL, + isGitBookHostURL, +} from '@/lib/env'; import { getImageResizingContextId } from '@/lib/images'; import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers'; +import { MCP_SERVER_CARD_PATH, MCP_SERVER_CARD_WELL_KNOWN_PATH } from '@/lib/mcp/paths'; import { MiddlewareHeaders } from '@/lib/middleware'; import { createOAuthProtectedResourceMetadataResponse, @@ -54,10 +61,13 @@ import { type ResponseCookies, getPathScopedCookieName, getResponseCookiesForVisitorAuth, + getVisitorCountry, getVisitorData, getVisitorType, + isRevalidationRequest, normalizeVisitorURL, serveVisitorClaimsDataRequest, + shouldSendVisitorCountry, } from '@/lib/visitors'; import { waitUntil } from '@/lib/waitUntil'; import { serveResizedImage } from '@/routes/image'; @@ -184,6 +194,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { //Forwards analytics events if (siteRequestURL.pathname.endsWith('/~gitbook/__evt')) { + if (GITBOOK_DISABLE_INSIGHTS) { + return new Response(null, { status: 204 }); + } return await serveProxyAnalyticsEvent(request); } @@ -214,6 +227,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // request.headers.delete('x-gitbook-disable-tracking'); + const visitorCountry = shouldSendVisitorCountry(siteRequestURL.hostname, GITBOOK_STAGE) + ? getVisitorCountry(request) + : undefined; + const withAPIToken = async (apiToken: string | null) => { const siteURLData = await throwIfDataError( lookupPublishedContentByUrl({ @@ -222,6 +239,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { jwtToken: visitorToken?.token ?? undefined, unsignedClaims, type: getVisitorType(request), + ...(visitorCountry ? { country: visitorCountry } : {}), }, // When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie. @@ -311,19 +329,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { }); } - const normalizedSitePathname = removeLeadingSlash( - removeTrailingSlash(siteURLData.pathname) - ); - if (normalizedSitePathname !== '~gitbook/auth/logout') { - cookies.push( - ...getResponseCookiesForVisitorAuth( - getVisitorAuthBasePath(siteRequestURL, siteURLData), - visitorToken, - request.cookies.getAll() - ) - ); - } - // We use the host/origin from the canonical URL to ensure the links are // correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/... const siteCanonicalURL = new URL(siteURLData.canonicalUrl); @@ -339,7 +344,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // Make sure the URL is clean of any va token after a successful lookup, // and of any visitor.* params that may have been passed to the URL. // - // We only redirect if the visitor token is not coming from a revalidation request, as we don't want to redirect in that case. + // We only redirect if the request is not coming from the revalidation worker, as we don't + // want to redirect in that case. It can carry unsigned claims without any token, so we rely + // on the request headers rather than on the visitor token source. // // The token and the visitor.* params value are stored in cookies that are set // on the redirect response. @@ -347,8 +354,18 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { const normalizedVisitorURL = normalizeVisitorURL(incomingURL); if ( normalizedVisitorURL.toString() !== incomingURL.toString() && - visitorToken?.source !== 'revalidation' + !isRevalidationRequest(request.headers) ) { + if (visitorToken?.source === 'url') { + cookies.push( + ...getResponseCookiesForVisitorAuth( + getVisitorAuthBasePath(siteRequestURL, siteURLData), + visitorToken, + request.cookies.getAll() + ) + ); + } + return writeResponseCookies( NextResponse.redirect(normalizedVisitorURL.toString()), cookies @@ -479,7 +496,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { themeMode === CustomizationThemeMode.Dark ? CustomizationDefaultThemeMode.Dark : CustomizationDefaultThemeMode.Light; - } else { + } else if (routeTypeFromPathname !== 'static') { + // Markdown, llms.txt, RSS and sitemap routes only exist under `sites/static`, so + // forcing them dynamic 404s. They don't render a theme anyway. RND-12749. routeType = 'dynamic'; requestHeaders.set(MiddlewareHeaders.Theme, themeMode); } @@ -503,7 +522,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { siteId: siteURLData.site, events, request: { - url: siteRequestURL.toString(), + // A proxied site's request URL is the proxy's (`/sites/site_xxx/...`), not its public one. + url: siteCanonicalURL.toString(), headers: requestHeaders, }, }) @@ -767,6 +787,10 @@ const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/; const PATH_ALIASES: Record<string, string> = { 'sitemap.md': 'llms.txt', '.well-known/sitemap.md': 'llms.txt', + // Scanners probe `.well-known` for a server card even though the MCP extension reserves + // `<streamable-http-url>/server-card`; both paths serve the same document. + [MCP_SERVER_CARD_WELL_KNOWN_PATH]: MCP_SERVER_CARD_PATH, + [AI_CATALOG_WELL_KNOWN_PATH]: AI_CATALOG_PATH, }; /** @@ -886,6 +910,8 @@ function encodePathInSiteContent( return { pathname, routeType: 'static' }; case '~gitbook/mcp': case '~gitbook/mcp/auth': + case MCP_SERVER_CARD_PATH: + case AI_CATALOG_PATH: case '~gitbook/pdf': case '~gitbook/search': case '~gitbook/auth/login': @@ -930,6 +956,7 @@ function encodePathInSiteContent( { type: 'ask_question', query: ask, + goal: typeof goal === 'string' ? goal : undefined, location: { displayContext: SiteInsightsDisplayContext.Server, }, diff --git a/packages/gitbook/src/routes/llms-full.ts b/packages/gitbook/src/routes/llms-full.ts index 64fbfbf08..2535cc159 100644 --- a/packages/gitbook/src/routes/llms-full.ts +++ b/packages/gitbook/src/routes/llms-full.ts @@ -4,6 +4,7 @@ import pMap, { pMapIterable } from 'p-map'; import type { RevisionPageDocument, SiteSection, SiteSpace } from '@gitbook/api'; +import { renderSiteAgentInstructions } from '@/lib/ask-prompt'; import { type GitBookSiteContext, checkIsRootSiteContext, @@ -151,6 +152,32 @@ async function streamMarkdownPageEntries( const pagesToProcess = allPages.slice(offset, offset + DEFAULT_PAGE_LIMIT); let totalPagesProcessed = offset; + // Agents often grep the file or only read its start, so announce the other parts up front. + const part = Math.floor(offset / DEFAULT_PAGE_LIMIT); + const partCount = Math.ceil(allPages.length / DEFAULT_PAGE_LIMIT); + if (partCount > 1) { + const header = [ + `> This is part ${part + 1} of ${partCount} of the full documentation (pages ${offset + 1}–${offset + pagesToProcess.length} of ${allPages.length}).`, + '> The content is paginated: fetch every part to see all of it.', + ]; + if (part > 0) { + header.push(`> Previous part: ${getPartURL(context, part - 1)}`); + } + if (part < partCount - 1) { + header.push(`> Next part: ${getPartURL(context, part + 1)}`); + } + header.push( + `> Page index: ${context.linker.toAbsoluteURL(context.linker.toPathInSite('llms.txt'))}` + ); + stream.enqueue(new TextEncoder().encode(`${header.join('\n')}\n\n`)); + } + + // Rendered up front, on every part, since agents may only read the start of the file. + const agentInstructions = renderSiteAgentInstructions(context); + if (agentInstructions) { + stream.enqueue(new TextEncoder().encode(`${agentInstructions}\n---\n\n`)); + } + // Process the pages for await (const markdown of pMapIterable( pagesToProcess, @@ -168,8 +195,7 @@ async function streamMarkdownPageEntries( // Check if there are more pages and add next page link if needed const hasMorePages = allPages.length > offset + DEFAULT_PAGE_LIMIT; if (hasMorePages) { - const nextPage = Math.floor(offset / DEFAULT_PAGE_LIMIT) + 1; - const nextPageUrl = context.linker.toPathInSite(`llms-full.txt/${nextPage}`); + const nextPageUrl = getPartURL(context, part + 1); const nextPageLink = `\n\n---\n\n[Next Page](${nextPageUrl})\n\n`; stream.enqueue(new TextEncoder().encode(nextPageLink)); } @@ -177,6 +203,14 @@ async function streamMarkdownPageEntries( return { currentPageIndex: totalPagesProcessed, reachedLimit: hasMorePages }; } +/** + * Get the absolute URL of a part of llms-full.txt. + */ +function getPartURL(context: GitBookSiteContext, part: number) { + const path = part === 0 ? 'llms-full.txt' : `llms-full.txt/${part}`; + return context.linker.toAbsoluteURL(context.linker.toPathInSite(path)); +} + /** * Get markdown from a page. */ diff --git a/packages/gitbook/src/routes/llms.ts b/packages/gitbook/src/routes/llms.ts index ebd51d401..a77026ee5 100644 --- a/packages/gitbook/src/routes/llms.ts +++ b/packages/gitbook/src/routes/llms.ts @@ -4,12 +4,11 @@ import { toMarkdown } from 'mdast-util-to-markdown'; import type { SiteSection, SiteSpace } from '@gitbook/api'; -import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { renderSiteAgentInstructions } from '@/lib/ask-prompt'; import { type GitBookSiteContext, checkIsRootSiteContext } from '@/lib/context'; import { throwIfDataError } from '@/lib/data'; import { type GitBookLinker, linkerWithMarkdownPages } from '@/lib/links'; import { getMarkdownContentType } from '@/lib/markdown-content-type'; -import { resolveFirstDocument } from '@/lib/pages'; import { type FlatPageEntry, getIndexablePages } from '@/lib/sitemap'; import { filterSiteSpacesByLocale, @@ -49,7 +48,10 @@ export async function serveLLMsTxt(baseContext: GitBookSiteContext) { bullet: '-', }); - output += renderAskFooter(context); + const agentInstructions = renderSiteAgentInstructions(context); + if (agentInstructions) { + output += `\n\n---\n\n${agentInstructions}`; + } return new Response(output, { headers: { @@ -205,27 +207,3 @@ export async function getMarkdownForPagesTree( }); return nodes; } - -function renderAskFooter(context: GitBookSiteContext) { - if (!isAIEnabled(context.customization.ai.mode)) { - return ''; - } - - return `\n\n---\n\n# Agent Instructions -This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com. - -## Querying This Documentation -If you need additional information, you can query the documentation dynamically by asking a question. -Perform an HTTP GET request on a page URL with the \`ask\` query parameter: -\`\`\` -GET ${context.linker.toAbsoluteURL( - context.linker.toPathForPagePath({ - path: resolveFirstDocument(context.revision.pages, [])?.page.path ?? 'index', - }) - )}?ask=<question> -\`\`\` -The question should be specific, self-contained, and written in natural language. -The response will contain a direct answer to the question and relevant excerpts and sources from the documentation. -Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections. -`; -} diff --git a/packages/gitbook/src/routes/markdownPage.test.ts b/packages/gitbook/src/routes/markdownPage.test.ts new file mode 100644 index 000000000..1be218f1f --- /dev/null +++ b/packages/gitbook/src/routes/markdownPage.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, mock } from 'bun:test'; + +import type { RevisionPageDocument } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; +import { createLinker } from '@/lib/links'; + +mock.module('server-only', () => ({})); + +const { servePageMarkdown, toMarkdownDestination } = await import('./markdownPage'); + +const page = { + id: 'page-1', + title: 'New page', + kind: 'sheet', + type: 'document', + path: 'new-page', + slug: 'new-page', + pages: [], +} as unknown as RevisionPageDocument; + +function createContext(options: { + siteRedirect?: { target: string; permanent?: boolean }; + spaceRedirectPageId?: string; +}) { + return { + organizationId: 'org-1', + site: { id: 'site-1' }, + siteSpace: { id: 'site-space-1' }, + space: { id: 'space-1', revision: 'revision-1' }, + revisionId: 'revision-1', + revision: { pages: [page] }, + linker: createLinker({ + host: 'docs.example.com', + siteBasePath: '/docs/', + spaceBasePath: '/docs/', + }), + dataFetcher: { + getSiteRedirectBySource: async ({ source }: { source: string }) => + options.siteRedirect && source === '/old-page' + ? { + data: { + target: options.siteRedirect.target, + redirect: { permanent: options.siteRedirect.permanent ?? false }, + }, + } + : { error: { code: 404, message: 'Not found' } }, + getRevisionPageByPath: async () => + options.spaceRedirectPageId + ? { data: { id: options.spaceRedirectPageId } } + : { error: { code: 404, message: 'Not found' } }, + }, + } as unknown as GitBookSiteContext; +} + +describe('servePageMarkdown', () => { + it('redirects to the markdown version of a site redirect target', async () => { + const context = createContext({ + siteRedirect: { target: 'https://docs.example.com/docs/new-page', permanent: true }, + }); + + const response = await servePageMarkdown(context, 'old-page'); + + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe('/docs/new-page.md'); + }); + + it('redirects to the markdown version of a space redirect target', async () => { + const context = createContext({ spaceRedirectPageId: page.id }); + + const response = await servePageMarkdown(context, 'old-page'); + + expect(response.status).toBe(307); + expect(response.headers.get('Location')).toBe('/docs/new-page.md'); + }); +}); + +describe('toMarkdownDestination', () => { + it('appends .md to same-site paths', () => { + expect(toMarkdownDestination('/docs/new-page')).toBe('/docs/new-page.md'); + expect(toMarkdownDestination('/docs/new-page/?a=1#b')).toBe('/docs/new-page.md?a=1#b'); + }); + + it('points the site root to its markdown route', () => { + expect(toMarkdownDestination('/')).toBe('/.md'); + expect(toMarkdownDestination('/?a=1#b')).toBe('/.md?a=1#b'); + }); + + it('leaves markdown paths and external URLs untouched', () => { + expect(toMarkdownDestination('/docs/new-page.md')).toBe('/docs/new-page.md'); + expect(toMarkdownDestination('https://example.com/page')).toBe('https://example.com/page'); + }); +}); diff --git a/packages/gitbook/src/routes/markdownPage.ts b/packages/gitbook/src/routes/markdownPage.ts index c5460f4a3..d78bcfd4d 100644 --- a/packages/gitbook/src/routes/markdownPage.ts +++ b/packages/gitbook/src/routes/markdownPage.ts @@ -1,6 +1,8 @@ import type { RevisionPageDocument, RevisionPageGroup } from '@gitbook/api'; +import { resolveMissingPagePath } from '@/components/SitePage/fetch'; import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { renderPageAgentInstructions, renderQueryingDocumentation } from '@/lib/ask-prompt'; import type { GitBookSiteContext } from '@/lib/context'; import { getExposableError } from '@/lib/data'; import { linkerWithMarkdownPages } from '@/lib/links'; @@ -22,12 +24,36 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat linker: linkerWithMarkdownPages(baseContext.linker), }; - const pageLookup = resolveSiteSpacePagePathDocumentOrGroup( - context.siteSpace, - context.revision.pages, - pagePath - ); + const pageLookup = + resolveSiteSpacePagePathDocumentOrGroup( + context.siteSpace, + context.revision.pages, + pagePath + ) ?? + // Page paths are lowercase, match the case-insensitive lookup of HTML pages. + resolveSiteSpacePagePathDocumentOrGroup( + context.siteSpace, + context.revision.pages, + pagePath.toLowerCase() + ); if (!pageLookup) { + const fallback = await resolveMissingPagePath(baseContext, pagePath); + if (fallback?.type === 'redirect') { + return markdownRedirect( + toMarkdownDestination(fallback.destination), + fallback.permanent + ); + } + if (fallback?.type === 'page') { + return markdownRedirect( + context.linker.toPathForPage({ + pages: context.revision.pages, + page: fallback.page.page, + }), + false + ); + } + // Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.= return { markdown: renderNotFoundMarkdown(context, pagePath), @@ -63,12 +89,44 @@ function getMarkdownRobots( return context.isAiAgent ? 'index, follow' : 'noindex'; } +/** + * Point a redirect destination to its markdown version, so agents keep receiving markdown. + * Destinations outside the site are returned as full URLs and left untouched. + */ +export function toMarkdownDestination(destination: string): string { + if (!destination.startsWith('/')) { + return destination; + } + + const url = new URL(destination, 'https://gitbook.invalid'); + const pathname = url.pathname.replace(/\/+$/, ''); + if (pathname.endsWith('.md')) { + return destination; + } + + // A root destination trims to an empty pathname; its markdown route is `/.md`. + return `${pathname || '/'}.md${url.search}${url.hash}`; +} + +function markdownRedirect(location: string, permanent: boolean) { + // Same status codes as Next's `redirect` / `permanentRedirect`. + return new Response(null, { + status: permanent ? 308 : 307, + headers: { Location: location, Vary: 'Accept' }, + }); +} + function renderNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) { const similarPages = getSimilarPages(context.revision.pages, pagePath, 5); const sitemapUrl = context.linker.toAbsoluteURL(context.linker.toPathInSite('sitemap.md')); const fullContentUrl = context.linker.toAbsoluteURL( context.linker.toPathInSite('llms-full.txt') ); + const askPageUrl = context.linker.toAbsoluteURL( + context.linker.toPathForPagePath({ + path: similarPages[0]?.path ?? 'docs/example', + }) + ); return `# Page Not Found @@ -85,20 +143,7 @@ If the exact page cannot be found, you can still retrieve the information using ### Option 1 — Ask a question (recommended) -Perform an HTTP GET request on the documentation index with the \`ask\` parameter, and the optional \`goal\` parameter: - -\`\`\` -GET ${context.linker.toAbsoluteURL( - context.linker.toPathForPagePath({ - path: similarPages[0]?.path ?? 'docs/example', - }) - )}?ask=<question>&goal=<end_goal> -\`\`\` - -\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language. -\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal. - -The response will contain a direct answer to the question and relevant excerpts and sources from the documentation. +${renderQueryingDocumentation({ pageUrl: askPageUrl })} ### Option 2 — Browse the documentation index @@ -139,36 +184,21 @@ function renderAskFooter( }) ); - return `\n\n---\n\n# Agent Instructions -This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com. - -## Querying This Documentation -If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question. - -Perform an HTTP GET request on the current page URL with the \`ask\` query parameter, and the optional \`goal\` query parameter: - -\`\`\` -GET ${pageUrl}?ask=<question>&goal=<endgoal> -\`\`\` - -\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language. -\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal. - -The response will contain a direct answer to the question and relevant excerpts and sources from the documentation. - -Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections. -`; + return `\n\n---\n\n${renderPageAgentInstructions({ pageUrl })}`; } /** * Return a markdown content. */ export async function serveMarkdown( - fn: () => Promise<string | { markdown: string; robots: string }>, + fn: () => Promise<string | { markdown: string; robots: string } | Response>, isChatGPT?: boolean ) { try { const result = await fn(); + if (result instanceof Response) { + return result; + } const { markdown, robots } = typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result; return new Response(markdown, { diff --git a/packages/gitbook/tests/insights.test.ts b/packages/gitbook/tests/insights.test.ts new file mode 100644 index 000000000..333b4414b --- /dev/null +++ b/packages/gitbook/tests/insights.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; + +import { getContentTestURL } from './utils'; + +describe('Insights', () => { + // The harness serves the site from BASE_URL while it is configured on gitbook.gitbook.io, + // reproducing the split (apex vs www, alias, CDN) that turns an absolute URL cross-origin. + const TEST_URL = getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open'); + + it('should reference the insights endpoints relative to the served origin', async () => { + const response = await fetch(TEST_URL); + expect(response.status).toBe(200); + + const html = await response.text(); + + for (const endpoint of ['__evt', 'visitor']) { + expect(html).toContain(`~gitbook/${endpoint}`); + expect(html).not.toMatch(new RegExp(`https?://[^"'\\\\\\s]*~gitbook/${endpoint}`)); + } + }); +}); diff --git a/packages/gitbook/tests/llms.test.ts b/packages/gitbook/tests/llms.test.ts index e8b6f1564..735ad593e 100644 --- a/packages/gitbook/tests/llms.test.ts +++ b/packages/gitbook/tests/llms.test.ts @@ -55,6 +55,22 @@ describe('llms.txt', () => { expect(await response.text()).toContain('# E2E Tests GitBook Open'); }); + it('should serve plain text to ChatGPT Search', async () => { + const response = await fetch( + getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt'), + { + headers: { + 'User-Agent': + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot', + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + expect(await response.text()).toContain('# E2E Tests GitBook Open'); + }); + it('should expose llms.txt from sitemap.md', async () => { const response = await fetch( getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/sitemap.md') @@ -152,6 +168,21 @@ describe('llms-full.txt', () => { { timeout: 30_000 } ); + it( + 'should not add a pagination header to a single-part llms-full.txt', + async () => { + const response = await fetch( + getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt') + ); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).not.toContain('The content is paginated'); + expect(text).not.toContain('[Next Page]'); + }, + { timeout: 30_000 } + ); + it( 'should return 404 when a llms-full.txt page has no content', async () => { diff --git a/packages/gitbook/tests/markdown.test.ts b/packages/gitbook/tests/markdown.test.ts index 07405d8b8..17986462e 100644 --- a/packages/gitbook/tests/markdown.test.ts +++ b/packages/gitbook/tests/markdown.test.ts @@ -38,6 +38,18 @@ describe('markdown serving based on user agent', () => { expect(response.headers.get('content-type')).toContain('text/plain'); }); + it('should serve plain text to ChatGPT Search', async () => { + const response = await fetch(getContentTestURL(TEST_PAGE_URL), { + headers: { + 'User-Agent': + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot', + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + }); + it('should NOT serve markdown to Slackbot (heuristic detection only)', async () => { const response = await fetch(getContentTestURL(TEST_PAGE_URL), { headers: { diff --git a/packages/gitbook/tests/rss.test.ts b/packages/gitbook/tests/rss.test.ts index 7b0ad5845..016b4411a 100644 --- a/packages/gitbook/tests/rss.test.ts +++ b/packages/gitbook/tests/rss.test.ts @@ -29,3 +29,22 @@ it('should not expose a RSS feed for a page without updates (root page)', async expect(response.status).toBe(404); expect(await response.text()).toBe('No updates found in page'); }); + +for (const path of ['', '/text-page', '/blocks/updates']) { + it(`only advertises an RSS feed when the page has updates (${path || '/'})`, async () => { + const response = await fetch( + getContentTestURL(`https://gitbook.gitbook.io/test-gitbook-open${path}`), + { headers: { 'User-Agent': 'Googlebot' } } + ); + expect(response.status).toBe(200); + const html = await response.text(); + const links = Array.from(html.matchAll(/<link\b[^>]*>/g), ([link]) => link).filter((link) => + link.includes('type="application/rss+xml"') + ); + expect(links).toHaveLength(path === '/blocks/updates' ? 1 : 0); + if (path === '/blocks/updates') { + expect(links[0]).toContain('/blocks/updates/rss.xml'); + expect(html.slice(0, html.indexOf('</head>'))).toContain('type="application/rss+xml"'); + } + }); +} diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index 381e337cc..895f31a4e 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@types/react": "catalog:", + "bun-types": "catalog:", "react": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" @@ -31,6 +32,7 @@ "scripts": { "build": "tsdown", "typecheck": "tsc --noEmit", + "unit": "bun test", "dev": "bun run build -- --watch ./src", "clean": "rm -rf ./dist", "publish-to-npm": "../../scripts/publish-if-new.sh" diff --git a/packages/react-contentkit/src/ContentKit.tsx b/packages/react-contentkit/src/ContentKit.tsx index 7a7ccec57..41bda2f31 100644 --- a/packages/react-contentkit/src/ContentKit.tsx +++ b/packages/react-contentkit/src/ContentKit.tsx @@ -192,18 +192,21 @@ export function ContentKit<RenderContext>(props: { }; }, [update, security, clientContext, current.state, current.input.context, setCurrent, render]); - const onSubViewAction = React.useCallback(async (action: ContentKitAction) => { - switch (action.action) { - case '@ui.modal.close': { - update({ - action, - }); + const onSubViewAction = React.useCallback( + async (action: ContentKitAction) => { + switch (action.action) { + case '@ui.modal.close': { + update({ + action, + }); - setSubView(null); - break; + setSubView(null); + break; + } } - } - }, []); + }, + [update] + ); return ( <> diff --git a/packages/react-contentkit/src/ElementButton.tsx b/packages/react-contentkit/src/ElementButton.tsx index 46dfcbd93..d55296c19 100644 --- a/packages/react-contentkit/src/ElementButton.tsx +++ b/packages/react-contentkit/src/ElementButton.tsx @@ -103,7 +103,7 @@ function ConfirmDialog({ open, onCancel, onConfirm, style, title, text, confirm <div className="contentkit-modal-footer"> <button type="button" - className="contentkit-button contentkit-button-confirm contentkit-button-style-secondary" + className="contentkit-button contentkit-button-style-secondary" onClick={onCancel} > Cancel @@ -111,7 +111,7 @@ function ConfirmDialog({ open, onCancel, onConfirm, style, title, text, confirm <button type="button" className={classNames( - 'contentkit-button contentkit-button-confirm', + 'contentkit-button', `contentkit-button-style-${style ?? 'primary'}` )} onClick={onConfirm} diff --git a/packages/react-contentkit/src/ElementModal.tsx b/packages/react-contentkit/src/ElementModal.tsx index 267393eaf..f8e7606dd 100644 --- a/packages/react-contentkit/src/ElementModal.tsx +++ b/packages/react-contentkit/src/ElementModal.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import React from 'react'; import type { ContentKitModal } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; import { useContentKitClientContext } from './context'; import type { ContentKitClientElementProps } from './types'; @@ -18,7 +19,6 @@ export function ElementModal( const clientContext = useContentKitClientContext(); // TODO: - // - close button // - invalid rendering on close? // - submit @@ -45,13 +45,41 @@ export function ElementModal( event.stopPropagation(); }} > - <div className={classNames('contentkit-modal-header')}> - {element.title ? ( - <h1 className={classNames('contentkit-modal-title')}>{element.title}</h1> - ) : null} - {subtitle ? <div className="contentkit-modal-subtitle">{subtitle}</div> : null} + <div + className={classNames( + 'contentkit-modal-header', + 'contentkit-modal-header-with-close' + )} + > + <div className="contentkit-modal-header-content"> + {element.title ? ( + <h1 className={classNames('contentkit-modal-title')}> + {element.title} + </h1> + ) : null} + {subtitle ? ( + <div className="contentkit-modal-subtitle">{subtitle}</div> + ) : null} + </div> + <button + type="button" + aria-label="Close" + className="contentkit-modal-close" + onClick={onClose} + > + <Icon icon="xmark" className="contentkit-modal-close-icon" /> + </button> </div> <div className={classNames('contentkit-modal-body')}>{children}</div> + <div className="contentkit-modal-footer"> + <button + type="button" + className="contentkit-button contentkit-button-confirm contentkit-button-style-secondary" + onClick={onClose} + > + Cancel + </button> + </div> </div> </div> ); diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 57356c843..229fe93bf 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -143,16 +143,16 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe } break; default: - renderer.update({ - action: message.action, - }); + // Go through dispatchAction so webframes can trigger the built-in + // `@ui.*` actions, like a ContentKit button does. + renderer.dispatchAction(message.action); } } }; window.addEventListener('message', callback); - // We only render the iframe once we have added the event listener + // We only load the iframe once we have added the event listener // otherwise during SSR, we'll miss messages setMounted(true); @@ -186,27 +186,27 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe ? iframeRef.current.clientWidth / height : undefined); - if (!mounted) { - return <Icon icon="spinner" className="contentkit-button-loading" style={{ height }} />; - } - // only use height measurement if no aspect ratio is defined const useHeightMeasurement = !aspectRatio && height; return ( - <iframe - ref={iframeRef} - src={element.source.url} - title={element.source.url} - allowFullScreen - allow="clipboard-write" - className="contentkit-webframe h-full w-full max-w-full border-none" - style={{ - aspectRatio, - height: useHeightMeasurement ? height : undefined, - maxHeight: height, - }} - /> + <div className="contentkit-webframe-container h-full w-full max-w-full"> + <iframe + ref={iframeRef} + // Rendered without a source before mount so it reserves its size in the SSR layout. + src={mounted ? element.source.url : undefined} + title={element.source.url} + allowFullScreen + allow="clipboard-write" + className="contentkit-webframe block h-full w-full max-w-full border-none" + style={{ + aspectRatio, + height: useHeightMeasurement ? height : undefined, + maxHeight: height, + }} + /> + {mounted ? null : <Icon icon="spinner" className="contentkit-webframe-loading" />} + </div> ); } diff --git a/packages/react-contentkit/src/dynamic.test.ts b/packages/react-contentkit/src/dynamic.test.ts new file mode 100644 index 000000000..fdf005e4b --- /dev/null +++ b/packages/react-contentkit/src/dynamic.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolveDynamicBinding } from './dynamic'; + +const state = { name: 'Nolann', count: 3, missing: undefined }; + +describe('resolveDynamicBinding', () => { + it('resolves a binding against the state', () => { + expect( + resolveDynamicBinding<Record<string, unknown>>(state, { label: { $state: 'name' } }) + ).toEqual({ label: 'Nolann' }); + }); + + it('resolves bindings nested in arrays', () => { + expect( + resolveDynamicBinding<Record<string, unknown>>(state, { + items: [{ $state: 'count' }, 'x'], + }) + ).toEqual({ items: [3, 'x'] }); + }); + + it('passes primitives through', () => { + expect(resolveDynamicBinding(state, 'text')).toEqual('text'); + expect(resolveDynamicBinding(state, 42)).toEqual(42); + expect(resolveDynamicBinding(state, false)).toEqual(false); + expect(resolveDynamicBinding(state, { a: undefined })).toEqual({ a: undefined }); + }); + + // Webframes post arbitrary JSON, so `null` is common and used to throw on the `in` check. + it('passes null through, at any depth', () => { + expect(resolveDynamicBinding(state, { action: 'foo', data: null })).toEqual({ + action: 'foo', + data: null, + }); + expect(resolveDynamicBinding(state, { items: [1, null] })).toEqual({ items: [1, null] }); + }); + + // A structured clone carries real Date instances; walking their entries would yield `{}`. + it('leaves class instances intact', () => { + const date = new Date('2020-01-01'); + expect(resolveDynamicBinding(state, { at: date })).toEqual({ at: date }); + }); + + it('leaves an action without any binding unchanged', () => { + const action = { action: 'my.custom.action', props: { a: 1, b: ['x'] } }; + expect(resolveDynamicBinding(state, action)).toEqual(action); + }); +}); diff --git a/packages/react-contentkit/src/dynamic.ts b/packages/react-contentkit/src/dynamic.ts index 540e8c9ab..908fbfb72 100644 --- a/packages/react-contentkit/src/dynamic.ts +++ b/packages/react-contentkit/src/dynamic.ts @@ -23,21 +23,20 @@ export function resolveDynamicBinding<T extends {}>( state: object, value: ContentKitDynamicBinding | T ): T { - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'undefined' - ) { - // Primitives - return value; - } - if (Array.isArray(value)) { // @ts-ignore return value.map((v) => resolveDynamicBinding(state, v)); } + // Only an object literal can hold a binding: `'$state' in value` throws on `null`, and the + // entries walk below would flatten a class instance (a webframe can post a `Date`) to `{}`. + const prototype = + typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined; + if (prototype !== Object.prototype && prototype !== null) { + // @ts-ignore + return value; + } + if ('$state' in value && typeof value.$state === 'string') { // @ts-ignore return state[value.$state]; diff --git a/packages/react-contentkit/tsconfig.json b/packages/react-contentkit/tsconfig.json index 3576a5729..60c6d5bf7 100644 --- a/packages/react-contentkit/tsconfig.json +++ b/packages/react-contentkit/tsconfig.json @@ -15,7 +15,8 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true + "incremental": true, + "types": ["bun-types"] }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] diff --git a/packages/react-openapi/src/OpenAPISchemaServer.test.tsx b/packages/react-openapi/src/OpenAPISchemaServer.test.tsx new file mode 100644 index 000000000..fbe85c8e9 --- /dev/null +++ b/packages/react-openapi/src/OpenAPISchemaServer.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'bun:test'; +import { renderToReadableStream } from 'react-dom/server'; + +import { parseOpenAPI } from '@gitbook/openapi-parser'; + +import type { OpenAPIClientContext } from './context'; +import { OpenAPIRootSchema } from './OpenAPISchemaServer'; +import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas'; +import { translations } from './translations'; + +const context: OpenAPIClientContext = { + translation: translations.en, + icons: { + chevronDown: null, + chevronRight: null, + plus: null, + copy: null, + check: null, + lock: null, + mcp: null, + hashtag: null, + }, + expandAllModelSections: true, + scalarRuntimeURL: '', + $$isClientContext$$: true, +}; + +describe('OpenAPIRootSchema', () => { + it('should stop at a self-reference behind a nullable union when expanded', async () => { + const { filesystem } = await parseOpenAPI({ + value: JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: { + FilterClause: { + type: 'object', + properties: { + AND: { + anyOf: [ + { + type: 'array', + items: { $ref: '#/components/schemas/FilterClause' }, + }, + { type: 'null' }, + ], + }, + }, + }, + }, + }, + }), + rootURL: 'memory://spec.json', + }); + const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] }); + const schema = resolved?.schemas[0]?.schema; + if (!schema) { + throw new Error('FilterClause not resolved'); + } + + const stream = await renderToReadableStream( + <OpenAPIRootSchema schema={schema} context={context} /> + ); + const html = await new Response(stream).text(); + + expect(html).toContain('AND'); + }); +}); diff --git a/packages/react-openapi/src/utils.test.ts b/packages/react-openapi/src/utils.test.ts index 6c9722d48..8345f9f72 100644 --- a/packages/react-openapi/src/utils.test.ts +++ b/packages/react-openapi/src/utils.test.ts @@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => { }); describe('normalizeNullableUnion', () => { + it('should return the same object for the same input', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema)); + }); + it('should collapse anyOf with a single non-null member into a nullable schema', () => { const schema: OpenAPIV3_1.SchemaObject = { anyOf: [{ type: 'string' }, { type: 'null' }], diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index b8d67ad4b..2f130c9b8 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject return type === 'null'; } +// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a +// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded. +const normalizedNullableUnions = new WeakMap<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>(); + /** * Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf` * (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema, @@ -301,7 +305,17 @@ export function normalizeNullableUnion( schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject ): OpenAPIV3.SchemaObject { const typed = schema as OpenAPIV3.SchemaObject; + const cached = normalizedNullableUnions.get(typed); + if (cached) { + return cached; + } + const normalized = normalizeNullableUnionUncached(typed); + normalizedNullableUnions.set(typed, normalized); + return normalized; +} + +function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject { const isAnyOf = Array.isArray(typed.anyOf); const isOneOf = !isAnyOf && Array.isArray(typed.oneOf); if (!isAnyOf && !isOneOf) { diff --git a/patches/mermaid@11.16.1.patch b/patches/mermaid@11.16.1.patch new file mode 100644 index 000000000..c5986bcee --- /dev/null +++ b/patches/mermaid@11.16.1.patch @@ -0,0 +1,26 @@ +diff --git a/dist/chunks/mermaid.core/blockDiagram-VBNYF7ZC.mjs b/dist/chunks/mermaid.core/blockDiagram-VBNYF7ZC.mjs +index 8960ddd..a5cf3b5 100644 +--- a/dist/chunks/mermaid.core/blockDiagram-VBNYF7ZC.mjs ++++ b/dist/chunks/mermaid.core/blockDiagram-VBNYF7ZC.mjs +@@ -3693,7 +3693,7 @@ async function calculateBlockSize(elem, block, db2) { + const nodeEl = await insertNode(elem, node, { config }); + const boundingBox = nodeEl.node().getBBox(); + const obj = db2.getBlock(node.id); +- obj.size = { width: boundingBox.width, height: boundingBox.height, x: 0, y: 0, node: nodeEl }; ++ obj.size = { width: boundingBox.width, height: boundingBox.height, x: 0, y: 0 }; + db2.setBlock(obj); + nodeEl.remove(); + } +diff --git a/dist/chunks/mermaid.core/chunk-WRU74C26.mjs b/dist/chunks/mermaid.core/chunk-WRU74C26.mjs +index 271fe5c..1bf17c7 100644 +--- a/dist/chunks/mermaid.core/chunk-WRU74C26.mjs ++++ b/dist/chunks/mermaid.core/chunk-WRU74C26.mjs +@@ -233,7 +233,7 @@ async function addHtmlSpan(element, node, width, classes, addBackground = false, + div.attr("class", "labelBkg"); + } + let bbox = div.node().getBoundingClientRect(); +- if (bbox.width === width) { ++ if (Math.round(bbox.width) === width) { + div.style("display", "table"); + div.style("white-space", "break-spaces"); + div.style("width", width + "px"); diff --git a/patches/next@16.3.3.patch b/patches/next@16.3.8.patch similarity index 100% rename from patches/next@16.3.3.patch rename to patches/next@16.3.8.patch