();
+ for (const record of records) {
+ if (
+ recordMatches(
+ record.searchText,
+ record.selectValues,
+ record.checkboxValues,
+ query,
+ selectedOptions,
+ checkedColumns
+ )
+ ) {
+ visibleIds.add(record.id);
+ }
+ }
+
+ let expanded = true;
+ while (expanded) {
+ expanded = false;
+ for (const group of recordGroups) {
+ if (!group.some((id) => visibleIds.has(id))) {
+ continue;
+ }
+
+ for (const id of group) {
+ if (!visibleIds.has(id)) {
+ visibleIds.add(id);
+ expanded = true;
+ }
+ }
+ }
+ }
+
+ return visibleIds;
+}
+
/**
* Whether a record passes the current filters.
*
diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx
index 37eaa02ea..df50737f0 100644
--- a/packages/gitbook/src/components/PageActions/PageActions.tsx
+++ b/packages/gitbook/src/components/PageActions/PageActions.tsx
@@ -5,7 +5,7 @@ import QuickLRU from 'quick-lru';
import React from 'react';
import { createStore, useStore } from 'zustand';
-import type { GitSyncState } from '@gitbook/api';
+import { type GitSyncState, SiteInsightsMarkdownSource } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '@/components/AI';
@@ -98,9 +98,14 @@ const createCopiedStateStore = () => {
set({ copied: true });
timeoutRef = setTimeout(() => {
- set({ copied: false });
onSuccess?.();
- timeoutRef = null;
+
+ // Delay resetting the label past the dropdown's closing animation (`scaleOut`,
+ // 200ms) so the "Copied" label doesn't flip back while still visible mid-fade.
+ timeoutRef = setTimeout(() => {
+ set({ copied: false });
+ timeoutRef = null;
+ }, 200);
}, 1500);
},
}));
@@ -120,6 +125,10 @@ function useCopiedStore(stateKey: string) {
return useStore(getOrCreateCopiedStoreByKey(stateKey));
}
+function getReaderMarkdownURL(markdownPageURL: string) {
+ return `${markdownPageURL}?displayAgentInstructions=false&markdownSource=${SiteInsightsMarkdownSource.PageAction}`;
+}
+
/**
* Cache for the markdown version of the page.
*/
@@ -144,7 +153,7 @@ export function ActionCopyMarkdown(props: {
const fetchMarkdown = async () => {
setLoading(true);
- const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
+ const humanURL = getReaderMarkdownURL(markdownPageURL);
const result = await fetch(humanURL).then((res) => res.text());
markdownCache.set(markdownPageURL, result);
@@ -153,13 +162,7 @@ export function ActionCopyMarkdown(props: {
return result;
};
- const onClick = async (e: React.MouseEvent) => {
- // Prevent default behavior for non-default actions to avoid closing the dropdown.
- // This allows showing transient UI (e.g., a "copied" state) inside the menu item.
- if (!isDefaultAction) {
- e.preventDefault();
- }
-
+ const onClick = async () => {
copy(markdownCache.get(markdownPageURL) || (await fetchMarkdown()), {
onSuccess: () => {
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
@@ -179,6 +182,7 @@ export function ActionCopyMarkdown(props: {
description={tString(language, 'copy_page_markdown')}
onClick={onClick}
loading={loading}
+ closeOnClick={false}
/>
);
}
@@ -196,7 +200,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
icon="markdown"
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
- href={`${markdownPageURL}?displayAgentInstructions=false`}
+ href={getReaderMarkdownURL(markdownPageURL)}
/>
);
}
@@ -421,9 +425,7 @@ export function CopyToClipboard(props: {
icon={copied ? 'check' : icon}
label={copied ? tString(language, 'code_copied') : label}
description={description}
- onClick={(e) => {
- e.preventDefault();
-
+ onClick={() => {
copy(data, {
onSuccess: () => {
if (type === 'dropdown-menu-item') {
@@ -432,6 +434,7 @@ export function CopyToClipboard(props: {
},
});
}}
+ closeOnClick={false}
/>
);
}
@@ -453,6 +456,7 @@ function PageActionWrapper(props: {
target?: React.HTMLAttributeAnchorTarget;
disabled?: boolean;
loading?: boolean;
+ closeOnClick?: boolean;
}) {
const {
type,
@@ -465,6 +469,7 @@ function PageActionWrapper(props: {
description,
disabled,
loading,
+ closeOnClick,
} = props;
if (type === 'button') {
@@ -498,6 +503,7 @@ function PageActionWrapper(props: {
target={target}
onClick={onClick}
disabled={disabled || loading}
+ closeOnClick={closeOnClick}
>
{loading ? (
diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css
index 965381d29..c2ecfa8e7 100644
--- a/packages/gitbook/src/components/RootLayout/globals.css
+++ b/packages/gitbook/src/components/RootLayout/globals.css
@@ -564,6 +564,19 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before {
@apply rounded-none!;
}
+@layer components {
+ .paragraph {
+ /* Inline action buttons grow to the available width, which needs a flex parent. */
+ &:has(.button, input) {
+ @apply flex flex-wrap items-center gap-2;
+ }
+
+ &[data-cover-aware-text]:not(:has(.button, input)) {
+ @apply text-contrast-cover;
+ }
+ }
+}
+
/* Zoomable images */
html:has(.zoom-modal) {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- single-value overflow is universal; doiuse flags the whole css-overflow feature */
diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx
index 453a3c167..b7520a4ec 100644
--- a/packages/gitbook/src/components/Search/SearchResults.tsx
+++ b/packages/gitbook/src/components/Search/SearchResults.tsx
@@ -198,14 +198,23 @@ export const SearchResults = React.forwardRef(function SearchResults(
const itemKey = getResultKey(item);
const shouldAnimateItem =
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
- const handleResultSelect = () => {
+ const handleResultSelect = (
+ event: React.MouseEvent
+ ) => {
+ const isPageResult =
+ item.type === 'local-page' ||
+ item.type === 'page' ||
+ item.type === 'record';
+
if (
- query &&
- siteSpaceId &&
- (item.type === 'local-page' ||
- item.type === 'page' ||
- item.type === 'record')
+ isPageResult &&
+ !event.currentTarget.hash &&
+ event.currentTarget.pathname === window.location.pathname
) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+
+ if (query && siteSpaceId && isPageResult) {
addRecentSearchQuery(siteSpaceId, query, 'search');
}
diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.test.ts b/packages/gitbook/src/components/primitives/ScrollContainer.test.ts
new file mode 100644
index 000000000..15afbf10c
--- /dev/null
+++ b/packages/gitbook/src/components/primitives/ScrollContainer.test.ts
@@ -0,0 +1,99 @@
+import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
+
+import { scrollByItemsInContainer } from './ScrollContainer';
+
+type MockRect = {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ width: number;
+ height: number;
+};
+
+class MockElement {
+ constructor(private readonly rect: MockRect) {}
+
+ getBoundingClientRect() {
+ return this.rect;
+ }
+}
+
+const originalHTMLElement = globalThis.HTMLElement;
+
+beforeAll(() => {
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ configurable: true,
+ value: MockElement,
+ });
+});
+
+afterAll(() => {
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ configurable: true,
+ value: originalHTMLElement,
+ });
+});
+
+function rect(left: number, right: number): MockRect {
+ return { left, right, top: 0, bottom: 100, width: right - left, height: 100 };
+}
+
+function makeContainer(
+ childRects: MockRect[],
+ options: { scrollLeft?: number; scrollWidth?: number } = {}
+) {
+ const scrollCalls: Record[] = [];
+ const container = Object.assign(new MockElement(rect(0, 300)), {
+ children: childRects.map((childRect) => new MockElement(childRect)),
+ clientHeight: 100,
+ clientWidth: 300,
+ scrollHeight: 100,
+ scrollLeft: options.scrollLeft ?? 0,
+ scrollTop: 0,
+ scrollWidth: options.scrollWidth ?? 1000,
+ scrollTo: (options: Record) => scrollCalls.push(options),
+ });
+
+ return { container: container as unknown as HTMLElement, scrollCalls };
+}
+
+describe('scrollByItemsInContainer', () => {
+ it('advances by fully visible items and excludes a partial preview', () => {
+ const { container, scrollCalls } = makeContainer([
+ rect(0, 100),
+ rect(110, 210),
+ rect(220, 320),
+ rect(330, 430),
+ ]);
+
+ scrollByItemsInContainer(container, 'horizontal', 'forward');
+
+ expect(scrollCalls).toEqual([{ top: undefined, left: 220, behavior: 'smooth' }]);
+ });
+
+ it('moves backward by the visible page size', () => {
+ const { container, scrollCalls } = makeContainer(
+ [rect(-220, -120), rect(-110, -10), rect(0, 100), rect(110, 210), rect(220, 320)],
+ { scrollLeft: 220 }
+ );
+
+ scrollByItemsInContainer(container, 'horizontal', 'backward');
+
+ expect(scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
+ });
+
+ it('clamps at the first and last scroll positions', () => {
+ const firstPage = makeContainer([rect(0, 100), rect(110, 210), rect(220, 320)]);
+ scrollByItemsInContainer(firstPage.container, 'horizontal', 'backward');
+
+ const lastPage = makeContainer(
+ [rect(-240, -140), rect(-130, -30), rect(-20, 80), rect(90, 190), rect(200, 300)],
+ { scrollLeft: 240, scrollWidth: 540 }
+ );
+ scrollByItemsInContainer(lastPage.container, 'horizontal', 'forward');
+
+ expect(firstPage.scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
+ expect(lastPage.scrollCalls).toEqual([{ top: undefined, left: 240, behavior: 'smooth' }]);
+ });
+});
diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.tsx b/packages/gitbook/src/components/primitives/ScrollContainer.tsx
index 0d9297023..4b9475ca9 100644
--- a/packages/gitbook/src/components/primitives/ScrollContainer.tsx
+++ b/packages/gitbook/src/components/primitives/ScrollContainer.tsx
@@ -41,6 +41,9 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject;
+
+ /** Scroll by one page of fully visible direct children instead of one viewport. */
+ scrollByVisibleItems?: boolean;
} & React.HTMLAttributes;
export function ScrollContainer(props: ScrollContainerProps) {
@@ -50,6 +53,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
+ scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
...rest
@@ -85,6 +89,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
+ if (scrollByVisibleItems) {
+ scrollByItemsInContainer(container, orientation, 'forward');
+ return;
+ }
+
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
@@ -98,6 +107,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
+ if (scrollByVisibleItems) {
+ scrollByItemsInContainer(container, orientation, 'backward');
+ return;
+ }
+
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
@@ -191,6 +205,133 @@ export function ScrollContainer(props: ScrollContainerProps) {
);
}
+const FULLY_VISIBLE_EDGE_TOLERANCE_PX = 1;
+
+/**
+ * Scroll a direct-child track by the number of items currently visible in the snapport.
+ * Scroll padding is excluded from the measurement because it is the carousel's peek area.
+ */
+export function scrollByItemsInContainer(
+ container: HTMLElement,
+ orientation: 'horizontal' | 'vertical',
+ direction: 'forward' | 'backward'
+) {
+ const children = Array.from(container.children).filter(
+ (child): child is HTMLElement => child instanceof HTMLElement
+ );
+ const bounds = getScrollBounds(container, orientation);
+ const items = children
+ .map((element, index) => ({ element, index, rect: element.getBoundingClientRect() }))
+ .filter(({ rect }) => {
+ const size = orientation === 'horizontal' ? rect.width : rect.height;
+ return size > 0;
+ })
+ .map((item, index) => ({ ...item, index }));
+ const visibleItems = items.filter(({ rect }) => {
+ const start = orientation === 'horizontal' ? rect.left : rect.top;
+ const end = orientation === 'horizontal' ? rect.right : rect.bottom;
+ return (
+ start >= bounds.start - FULLY_VISIBLE_EDGE_TOLERANCE_PX &&
+ end <= bounds.end + FULLY_VISIBLE_EDGE_TOLERANCE_PX
+ );
+ });
+
+ // A track narrower than its viewport, or one whose children have not laid out yet, should
+ // retain the regular viewport behavior rather than getting stuck at its current position.
+ if (visibleItems.length === 0) {
+ scrollByViewport(container, orientation, direction);
+ return;
+ }
+
+ const pageSize = visibleItems.length;
+ const firstVisibleItem = visibleItems[0];
+ const lastVisibleItem = visibleItems[visibleItems.length - 1];
+ if (!firstVisibleItem || !lastVisibleItem) {
+ scrollByViewport(container, orientation, direction);
+ return;
+ }
+ const targetIndex =
+ direction === 'forward' ? lastVisibleItem.index + 1 : firstVisibleItem.index - pageSize;
+ const maxScroll = getMaxScroll(container, orientation);
+
+ if (targetIndex < 0) {
+ scrollToPosition(container, orientation, 0);
+ return;
+ }
+
+ const target = items.find((item) => item.index === targetIndex);
+ if (!target) {
+ scrollToPosition(container, orientation, maxScroll);
+ return;
+ }
+
+ const targetStart = orientation === 'horizontal' ? target.rect.left : target.rect.top;
+ const targetPosition =
+ (orientation === 'horizontal' ? container.scrollLeft : container.scrollTop) +
+ targetStart -
+ bounds.start;
+
+ scrollToPosition(container, orientation, Math.min(Math.max(targetPosition, 0), maxScroll));
+}
+
+function getScrollBounds(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
+ const rect = container.getBoundingClientRect();
+ const computedStyle = typeof window !== 'undefined' ? window.getComputedStyle(container) : null;
+ const leadingPadding = Number.parseFloat(
+ computedStyle?.[orientation === 'horizontal' ? 'scrollPaddingLeft' : 'scrollPaddingTop'] ??
+ ''
+ );
+ const trailingPadding = Number.parseFloat(
+ computedStyle?.[
+ orientation === 'horizontal' ? 'scrollPaddingRight' : 'scrollPaddingBottom'
+ ] ?? ''
+ );
+ const start = orientation === 'horizontal' ? rect.left : rect.top;
+ const end = orientation === 'horizontal' ? rect.right : rect.bottom;
+
+ return {
+ start: start + (Number.isFinite(leadingPadding) ? leadingPadding : 0),
+ end: end - (Number.isFinite(trailingPadding) ? trailingPadding : 0),
+ };
+}
+
+function getMaxScroll(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
+ return Math.max(
+ orientation === 'horizontal'
+ ? container.scrollWidth - container.clientWidth
+ : container.scrollHeight - container.clientHeight,
+ 0
+ );
+}
+
+function scrollToPosition(
+ container: HTMLElement,
+ orientation: 'horizontal' | 'vertical',
+ position: number
+) {
+ container.scrollTo({
+ top: orientation === 'vertical' ? position : undefined,
+ left: orientation === 'horizontal' ? position : undefined,
+ behavior: 'smooth',
+ });
+}
+
+function scrollByViewport(
+ container: HTMLElement,
+ orientation: 'horizontal' | 'vertical',
+ direction: 'forward' | 'backward'
+) {
+ const position = orientation === 'horizontal' ? container.scrollLeft : container.scrollTop;
+ const distance = orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
+ const maxScroll = getMaxScroll(container, orientation);
+ const target = Math.min(
+ Math.max(position + (direction === 'forward' ? distance : -distance), 0),
+ maxScroll
+ );
+
+ scrollToPosition(container, orientation, target);
+}
+
/**
* Scroll to an element in a container.
*/
diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts
index e5b223cba..4342f5a78 100644
--- a/packages/gitbook/src/lib/references.test.ts
+++ b/packages/gitbook/src/lib/references.test.ts
@@ -491,4 +491,250 @@ describe('resolveContentRef for direct space links', () => {
expect(result?.href).toBe(guideSiteSpace.urls.published!);
expect(result?.active).toBe(false);
});
+
+ function buildDocumentPage(
+ id: string,
+ title: string,
+ path: string,
+ pages: unknown[] = []
+ ): RevisionPageDocument {
+ return {
+ object: 'page',
+ id,
+ type: 'document',
+ kind: 'sheet',
+ title,
+ path,
+ slug: path.split('/').pop() ?? path,
+ pages,
+ tags: [],
+ layout: {},
+ urls: { app: `https://app.gitbook.com/page/${id}` },
+ } as unknown as RevisionPageDocument;
+ }
+
+ function buildRevision(pages: unknown[]): Revision {
+ return {
+ object: 'revision',
+ id: 'rev-space-target',
+ type: 'edits',
+ pages,
+ files: [],
+ reusableContents: [],
+ tags: [],
+ parents: [],
+ createdAt: '',
+ urls: { app: '' },
+ } as unknown as Revision;
+ }
+
+ function buildCrossSpaceContext(
+ targetSpace: Space,
+ targetRevision: Revision,
+ structure: unknown
+ ) {
+ const currentSiteSpace = buildSiteSpace(
+ buildSpace('space-current', 'Current Space'),
+ 'Current Variant'
+ );
+ const dataFetcher = {
+ getSpace: async ({ spaceId }: { spaceId: string }) =>
+ spaceId === targetSpace.id
+ ? { data: targetSpace }
+ : { error: { code: 404, message: 'Not found' } },
+ getRevision: async ({ spaceId }: { spaceId: string }) =>
+ spaceId === targetSpace.id
+ ? { data: targetRevision }
+ : { error: { code: 404, message: 'Not found' } },
+ getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
+ withToken: function () {
+ return this;
+ },
+ } as unknown as GitBookDataFetcher;
+
+ return buildContext({
+ siteSpace: currentSiteSpace,
+ structure,
+ dataFetcher,
+ });
+ }
+
+ it('prepends every localized section group and section to cross-space page ancestors', async () => {
+ const targetSpace = buildSpace('space-target', 'Target Space');
+ const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
+ targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
+ const pageGroup = {
+ object: 'page',
+ id: 'page-group',
+ type: 'group',
+ kind: 'group',
+ title: 'Getting started',
+ path: 'getting-started',
+ slug: 'getting-started',
+ pages: [buildDocumentPage('page-target', 'Target page', 'getting-started/target')],
+ };
+ const section = {
+ object: 'site-section',
+ id: 'section-target',
+ title: 'Reference',
+ localizedTitle: { fr: 'Référence' },
+ draft: false,
+ path: 'reference',
+ siteSpaces: [targetSiteSpace],
+ urls: {},
+ };
+ const sectionGroup = {
+ object: 'site-section-group',
+ id: 'section-group-target',
+ title: 'Product documentation',
+ localizedTitle: { fr: 'Documentation produit' },
+ draft: false,
+ sections: [],
+ children: [
+ {
+ object: 'site-section-group',
+ id: 'section-group-nested',
+ title: 'API guides',
+ draft: false,
+ sections: [],
+ children: [
+ {
+ object: 'site-section-group',
+ id: 'section-group-child',
+ title: 'Authentication',
+ localizedTitle: { fr: 'Authentification' },
+ draft: false,
+ sections: [section],
+ children: [section],
+ },
+ ],
+ },
+ ],
+ };
+ const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
+ type: 'sections',
+ structure: [sectionGroup],
+ });
+
+ const result = await resolveContentRef(
+ { kind: 'page', space: targetSpace.id, page: 'page-target' },
+ { ...context, locale: 'fr' } as GitBookAnyContext
+ );
+
+ expect(result?.text).toBe('Target page');
+ expect(result?.ancestors).toEqual([
+ { label: 'Documentation produit' },
+ { label: 'API guides' },
+ { label: 'Authentification' },
+ { label: 'Référence', href: targetSiteSpace.urls.published },
+ { label: 'Getting started', icon: null, href: expect.any(String) },
+ ]);
+ 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 () => {
+ const targetSpace = buildSpace('space-target', 'Target Space');
+ const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
+ targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
+ const pageGroup = {
+ object: 'page',
+ id: 'page-group',
+ type: 'group',
+ kind: 'group',
+ title: 'Learn about Cortex Agentix',
+ path: 'cortex-agentix',
+ slug: 'cortex-agentix',
+ pages: [buildDocumentPage('page-target', 'Cortex Agentix docs', 'cortex-agentix/docs')],
+ };
+ const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
+ type: 'siteSpaces',
+ structure: [targetSiteSpace],
+ });
+
+ const result = await resolveContentRef(
+ { kind: 'page', space: targetSpace.id, page: 'page-group' },
+ context
+ );
+
+ expect(result?.page?.id).toBe('page-target');
+ expect(result?.text).toBe('Learn about Cortex Agentix');
+ expect(result?.ancestors?.map((ancestor) => ancestor.label)).toEqual([
+ 'Target Variant',
+ 'Learn about Cortex Agentix',
+ ]);
+ expect(
+ result?.ancestors?.filter(({ label }) => label === 'Learn about Cortex Agentix')
+ ).toHaveLength(1);
+ });
+
+ it('uses the section instead of the variant for an ungrouped cross-space page', async () => {
+ const targetSpace = buildSpace('space-target', 'Target Space');
+ const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
+ targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
+ const section = {
+ object: 'site-section',
+ id: 'section-target',
+ title: 'Reference',
+ draft: false,
+ path: 'reference',
+ siteSpaces: [targetSiteSpace],
+ urls: {},
+ };
+ const context = buildCrossSpaceContext(
+ targetSpace,
+ buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
+ { type: 'sections', structure: [section] }
+ );
+
+ const result = await resolveContentRef(
+ { kind: 'page', space: targetSpace.id, page: 'page-target' },
+ context
+ );
+
+ expect(result?.ancestors).toEqual([
+ { label: 'Reference', href: targetSiteSpace.urls.published },
+ ]);
+ });
+
+ it('falls back to the target variant for a sectionless in-site page', async () => {
+ const targetSpace = buildSpace('space-target', 'Target Space');
+ const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
+ targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
+ const context = buildCrossSpaceContext(
+ targetSpace,
+ buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
+ { type: 'siteSpaces', structure: [targetSiteSpace] }
+ );
+
+ const result = await resolveContentRef(
+ { kind: 'page', space: targetSpace.id, page: 'page-target' },
+ context
+ );
+
+ expect(result?.ancestors).toEqual([
+ { label: 'Target Variant', href: targetSiteSpace.urls.published },
+ ]);
+ });
+
+ it('falls back to the raw space title for an external page', async () => {
+ const targetSpace = buildSpace('space-external', 'External Space');
+ const context = buildCrossSpaceContext(
+ targetSpace,
+ buildRevision([buildDocumentPage('page-target', 'External page', 'target')]),
+ { type: 'siteSpaces', structure: [] }
+ );
+
+ const result = await resolveContentRef(
+ { kind: 'page', space: targetSpace.id, page: 'page-target' },
+ context
+ );
+
+ expect(result?.text).toBe('External page');
+ expect(result?.ancestors).toEqual([
+ { label: 'External Space', href: targetSpace.urls.published },
+ ]);
+ });
});
diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx
index bce57dae9..79eae72be 100644
--- a/packages/gitbook/src/lib/references.tsx
+++ b/packages/gitbook/src/lib/references.tsx
@@ -512,6 +512,42 @@ function getSpaceRefSectionLabel(
return null;
}
+/**
+ * Ancestors to attach to a resolved content ref. Page/anchor links identify their
+ * containing section instead of repeating the target variant.
+ */
+function resolvePageAncestors(
+ context: GitBookAnyContext,
+ contentRef: ContentRef,
+ foundSiteSpace: ReturnType,
+ ctx: { spaceContext: GitBookSpaceContext; baseURL: URL }
+): { label: string; href?: string }[] {
+ const isPageOrAnchorRef = contentRef.kind === 'page' || contentRef.kind === 'anchor';
+
+ if (isPageOrAnchorRef && foundSiteSpace?.siteSection) {
+ return [
+ ...(foundSiteSpace.siteSectionGroups ?? []).map((group) => ({
+ label: getLocalizedTitle(group, context.locale),
+ })),
+ {
+ label: getLocalizedTitle(foundSiteSpace.siteSection, context.locale),
+ href: ctx.baseURL.toString(),
+ },
+ ];
+ }
+
+ if (foundSiteSpace?.siteSpace) {
+ return [
+ {
+ label: getLocalizedTitle(foundSiteSpace.siteSpace, context.locale),
+ href: ctx.baseURL.toString(),
+ },
+ ];
+ }
+
+ return [{ label: ctx.spaceContext.space.title, href: ctx.baseURL.toString() }];
+}
+
async function resolveContentRefInSpace(
spaceId: string,
context: GitBookAnyContext,
@@ -546,19 +582,21 @@ async function resolveContentRefInSpace(
return null;
}
- // Prefer the variant title when available, then the section title, then fallback to the space title.
+ const foundSiteSpace =
+ 'site' in context
+ ? findSiteSpaceBy(context.structure, (siteSpace) => siteSpace.space.id === spaceId)
+ : null;
+
+ const ancestors = resolvePageAncestors(context, contentRef, foundSiteSpace, ctx);
+
+ // Prefer the variant title when available, then the section title, then fallback to the space title for non-page refs.
const ancestorLabel = (() => {
if ('site' in context) {
- const currentLanguage = context.locale;
- const foundSiteSpace = findSiteSpaceBy(
- context.structure,
- (siteSpace) => siteSpace.space.id === spaceId
- );
if (foundSiteSpace?.siteSpace) {
- return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage);
+ return getLocalizedTitle(foundSiteSpace.siteSpace, context.locale);
}
if (foundSiteSpace?.siteSection) {
- return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage);
+ return getLocalizedTitle(foundSiteSpace.siteSection, context.locale);
}
return ctx.spaceContext.space.title;
}
@@ -569,10 +607,9 @@ async function resolveContentRefInSpace(
return {
...resolved,
ancestors: [
- {
- label: ancestorLabel,
- href: ctx.baseURL.toString(),
- },
+ ...(contentRef.kind === 'page' || contentRef.kind === 'anchor'
+ ? ancestors
+ : [{ label: ancestorLabel, href: ctx.baseURL.toString() }]),
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
diff --git a/packages/gitbook/src/lib/select/constants.ts b/packages/gitbook/src/lib/select/constants.ts
index c841c5979..82f17288a 100644
--- a/packages/gitbook/src/lib/select/constants.ts
+++ b/packages/gitbook/src/lib/select/constants.ts
@@ -22,6 +22,8 @@ export function selectRankAttribute(rank: number): string {
}
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
+// Option panes must be direct children of the element carrying the set class: the generated
+// selectors use a child combinator, so a group never resolves the panes of a group nested in it.
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
export const SELECT_GROUP_ATTR = 'data-select-group';
diff --git a/packages/gitbook/src/lib/select/generateSelectCSS.ts b/packages/gitbook/src/lib/select/generateSelectCSS.ts
index 5b1e44ac2..6ea8a2339 100644
--- a/packages/gitbook/src/lib/select/generateSelectCSS.ts
+++ b/packages/gitbook/src/lib/select/generateSelectCSS.ts
@@ -73,6 +73,10 @@ function escapeCssString(value: string): string {
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
*
+ * Every rule matches `& > …` rather than a descendant: a nested group's panes are also
+ * descendants of the outer group, so a descendant combinator would let the outer sheet hide them.
+ * The child combinator adds no specificity, leaving the source-order priority above intact.
+ *
* Returns `''` for an empty/degenerate set.
*/
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
@@ -86,20 +90,20 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
const rules: string[] = [
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
- `${option}{display:none}`,
- `[${SELECT_DEFAULT_ATTR}]{display:block}`,
+ `& > ${option}{display:none}`,
+ `& > [${SELECT_DEFAULT_ATTR}]{display:block}`,
];
for (let rank = depth - 1; rank >= 0; rank--) {
const attr = selectRankAttribute(rank);
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
// When any of the set's options sits at this rank, hide the group's panes...
- rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
+ rules.push(`html:is(${anyAtRank}) & > ${option}{display:none}`);
// ...then reveal whichever one matches (correlated, so a per-option list).
const show = slugs
.map((slug) => {
const value = escapeCssString(slug);
- return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
+ return `html[${attr}="${value}"] & > [${SELECT_OPTION_ATTR}="${value}"]`;
})
.join(',');
rules.push(`${show}{display:block}`);
@@ -112,14 +116,14 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
for (const slug of slugs) {
const value = escapeCssString(slug);
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
- rules.push(`html & ${pane} ~ ${pane}{display:none}`);
+ rules.push(`html & > ${pane} ~ ${pane}{display:none}`);
}
// A client click can override that first-match default: it pins the picked pane and unpins its
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
- rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
- rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
+ rules.push(`html & > ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
+ rules.push(`html & > ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
}
diff --git a/packages/gitbook/src/lib/sites.test.ts b/packages/gitbook/src/lib/sites.test.ts
index d6d412f59..7ca50171c 100644
--- a/packages/gitbook/src/lib/sites.test.ts
+++ b/packages/gitbook/src/lib/sites.test.ts
@@ -13,6 +13,7 @@ import { TranslationLanguage } from '@gitbook/api';
import { createLinker } from './links';
import {
filterSiteSpacesByLocale,
+ findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getSiteStructureSections,
@@ -79,6 +80,38 @@ describe('site structure traversal', () => {
]);
expect(listAllSiteSpaces(structure)).toEqual([rootSpace, nestedSpace]);
});
+
+ it('returns every section group from the root to the immediate parent', () => {
+ const targetSpace = { id: 'target-space' } as SiteSpace;
+ const targetSection = {
+ object: 'site-section',
+ id: 'target-section',
+ siteSpaces: [targetSpace],
+ } as SiteSection;
+ const childGroup = {
+ object: 'site-section-group',
+ id: 'child-group',
+ children: [targetSection],
+ } as SiteSectionGroup;
+ const firstChildGroup = {
+ object: 'site-section-group',
+ id: 'first-child-group',
+ children: [childGroup],
+ } as SiteSectionGroup;
+ const rootGroup = {
+ object: 'site-section-group',
+ id: 'root-group',
+ children: [firstChildGroup],
+ } as SiteSectionGroup;
+
+ const found = findSiteSpaceBy(
+ { type: 'sections', structure: [rootGroup] },
+ (siteSpace) => siteSpace.id === targetSpace.id
+ );
+
+ expect(found?.siteSectionGroup).toBe(childGroup);
+ expect(found?.siteSectionGroups).toEqual([rootGroup, firstChildGroup, childGroup]);
+ });
});
describe('filterSiteSpacesByLocale', () => {
diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts
index b4a2eb7c6..9e735a3d5 100644
--- a/packages/gitbook/src/lib/sites.ts
+++ b/packages/gitbook/src/lib/sites.ts
@@ -264,6 +264,7 @@ export function findSiteSpaceBy(
siteSpace: SiteSpace;
siteSection: SiteSection | null;
siteSectionGroup: SiteSectionGroup | null;
+ siteSectionGroups: SiteSectionGroup[];
} | null {
if (siteStructure.type === 'siteSpaces') {
const siteSpace = siteStructure.structure.find(predicate) ?? null;
@@ -272,6 +273,7 @@ export function findSiteSpaceBy(
siteSpace,
siteSection: null,
siteSectionGroup: null,
+ siteSectionGroups: [],
};
}
@@ -290,6 +292,7 @@ export function findSiteSpaceBy(
siteSpace,
siteSection: sectionOrGroup,
siteSectionGroup: null,
+ siteSectionGroups: [],
};
}
break;
@@ -361,11 +364,14 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace:
function findSiteSpaceByIdInGroupChildren(
children: SiteStructureNode[],
predicate: (siteSpace: SiteSpace) => boolean,
- parentGroup: SiteSectionGroup
+ parentGroup: SiteSectionGroup,
+ rootGroup: SiteSectionGroup = parentGroup,
+ sectionGroups: SiteSectionGroup[] = [parentGroup]
): {
siteSpace: SiteSpace;
siteSection: SiteSection;
siteSectionGroup: SiteSectionGroup;
+ siteSectionGroups: SiteSectionGroup[];
} | null {
for (const child of children) {
switch (child.object) {
@@ -376,12 +382,19 @@ function findSiteSpaceByIdInGroupChildren(
siteSpace,
siteSection: child,
siteSectionGroup: parentGroup,
+ siteSectionGroups: sectionGroups,
};
}
break;
}
case 'site-section-group': {
- const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
+ const found = findSiteSpaceByIdInGroupChildren(
+ child.children,
+ predicate,
+ child,
+ rootGroup,
+ [...sectionGroups, child]
+ );
if (found) {
return found;
}
diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts
index 57bba5dea..d8c760d61 100644
--- a/packages/gitbook/src/middleware.ts
+++ b/packages/gitbook/src/middleware.ts
@@ -13,6 +13,7 @@ import {
SiteInsightsDisplayContext,
type SiteInsightsEventLocation,
SiteInsightsLLMSVariant,
+ SiteInsightsMarkdownSource,
} from '@gitbook/api';
import {
@@ -533,6 +534,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
if (rewrittenURL.searchParams.has('displayAgentInstructions')) {
rewrittenURL.searchParams.delete('displayAgentInstructions');
}
+ if (rewrittenURL.searchParams.has('markdownSource')) {
+ rewrittenURL.searchParams.delete('markdownSource');
+ }
const response = NextResponse.rewrite(rewrittenURL, {
request: {
@@ -897,6 +901,10 @@ function encodePathInSiteContent(
// It is encoded as a second path segment (the route is statically rendered, so it can't
// read query params at runtime — the question is path-encoded for the same reason).
const goal = searchParams.get('goal');
+ // Validated: this is user input going into insights.
+ const markdownSource = Object.values(SiteInsightsMarkdownSource).find(
+ (source) => source === searchParams.get('markdownSource')
+ );
return {
pathname:
typeof ask === 'string'
@@ -922,6 +930,7 @@ function encodePathInSiteContent(
: [
{
type: 'page_markdown_request',
+ ...(markdownSource ? { markdownSource } : {}),
location: {
displayContext: SiteInsightsDisplayContext.Server,
},