Compare commits

...

16 Commits

Author SHA1 Message Date
Brett Jephson c08d05ade5 Fix select filters on table and cards blocks (#4608)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:33:02 +00:00
Peter White b9453a92f8 Fix preview auth redirects dropping content paths (#4602) 2026-09-11 08:42:28 +02:00
Peter White 2c4d40ad97 Expose the site MCP tools through WebMCP in published docs (#4604) 2026-09-11 08:30:36 +02:00
Tomek 688515efcf Support fit-content width sizing for ContentKit buttons (#4605)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-10 15:50:37 +02:00
Tomek 931cbe717e Restore Edit on Git page actions (#4597)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Viktor Renkema <49148610+viktorrenkema@users.noreply.github.com>
Co-authored-by: conico974 <nicodorseuil@yahoo.fr>
2026-09-10 12:37:37 +02:00
Tomek 3ef1802e7e Add close button for modals in published sites (#4600)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-09 12:23:17 +02:00
Greg Bergé 8a0e0df84e Fix search section links not scrolling to the target section (#4598)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:40:58 +02:00
Peter White a9041a309a Fix fallback URL cleanup after successful page navigation (#4599) 2026-09-09 11:22:09 +02:00
conico974 625c108196 Add isRevalidationRequest function to handle specific requests (#4596) 2026-09-08 14:18:32 +02:00
Viktor Renkema 8e87856501 Fix page not found in preview links for sites with a custom homepage (#4594) 2026-09-08 11:27:07 +00:00
Tomek 9b822abbb6 Include page descriptions in markdown output (#4595)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-08 12:38:54 +02:00
Peter White 81dba6455b Resolve stable refs in HTML blocks of published markdown pages (#4592) 2026-09-08 09:45:14 +02:00
Johan Preynat 1faa57b812 Keep revision search results in preview (#4582) 2026-09-07 19:46:57 +02:00
Tomek 40150f0a6c Serve markdown with text/plain content-type for ChatGPT (#4591)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-07 17:13:19 +02:00
Brett Jephson bf29570bd5 RND-12706: measuring tabs fix (#4588)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:40:43 +01:00
Peter White e8e979b6c0 Serve permanent site redirects (#4586) 2026-09-07 16:07:40 +02:00
58 changed files with 1659 additions and 148 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Include published page descriptions in the page's Markdown output.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix select filters not working on table and cards blocks.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ContentKit buttons to size to their content instead of stretching to the full container width.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve the full site preview path and query parameters when redirecting users to log in.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep current-space search results inside revision previews.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Resolve stable page, space and file refs in images, definitions and HTML blocks of published markdown pages, instead of leaking internal `/pages/{id}`, `/spaces/{id}` and `/files/{id}` URLs.
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Show a close button on ContentKit modals rendered on published sites.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix section links in search results opening the page without scrolling to the section.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the tabs "more" dropdown showing when no tab is overflowing, and stop a tab click re-rendering every tab group on the page.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the fallback query parameter after successful page navigation without adding a browser history entry.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Restore Edit on Git page actions for Git-synced pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
+2 -2
View File
@@ -354,7 +354,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.201.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +726,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.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="],
"@gitbook/api": ["@gitbook/api@0.201.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-bI+FyExZrz9V8yaMBOo4k2QHb0nJvrHYKXE+Svb3Dr1/kYA1bdyL5swCDCH9MxaRQjjJlN3VilvPmblKOYLuVA=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -48,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.201.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+125 -2
View File
@@ -10,6 +10,7 @@ import {
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationPageActionType,
CustomizationSidebarListStyle,
SiteSocialAccountPlatform,
} from '@gitbook/api';
@@ -245,6 +246,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 +551,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 +591,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')
);
},
},
@@ -1572,6 +1631,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/',
+178
View File
@@ -0,0 +1,178 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific module (not the package barrel) so this stays free of the `@/` path alias,
// which Playwright's loader doesn't resolve — same reason as `select.spec.ts`.
import { resolveOverflowingItems } from '../src/components/hooks/listOverflow';
/**
* Behaviour tests for the tab bar's overflow rule (`useListOverflow`), which decides which tabs move
* into the "more" dropdown. Rects are measured in a real browser so the geometry is genuine — the
* layout below mirrors the tab bar in `DynamicTabs`: a non-wrapping flex row of `shrink-0` items,
* clipped by `overflow: hidden`, measured with the dropdown rendered ahead of the tabs.
*
* The regression these guard is a dropdown appearing when nothing actually overflowed: measuring
* with the dropdown present consumes `MENU` pixels, so a bar whose tabs total just under the
* container would hand its last tab to a menu it never needed.
*/
/** Width of the ellipsis button, matching the `px-3.5` + `size-4` icon of the real one. */
const MENU = 44;
interface Row {
/** Container width in px. */
container: number;
/** Tab widths in px, in order. */
tabs: number[];
/** Whether the dropdown is reserving space ahead of the tabs, as during a measure pass. */
withMenu?: boolean;
/** Hide an ancestor, so the row measures with no box at all. */
hidden?: boolean;
}
/**
* Lay the row out in the browser and run the real rule over the rects it produces.
* Returns the ids reported as overflowing, or `null` when the measurement carried no information.
*/
async function measure(page: Page, row: Row): Promise<string[] | null> {
const { container, tabs, withMenu = true, hidden = false } = row;
const items = tabs
.map((w, i) => `<div class="item" id="tab-${i}" style="width:${w}px">${i}</div>`)
.join('');
const menu = withMenu ? `<div class="item" style="width:${MENU}px">…</div>` : '';
await page.setContent(
`<!doctype html><html><head><style>
* { box-sizing: border-box; }
body { margin: 0; }
.pane { ${hidden ? 'display: none;' : ''} }
.bar {
width: ${container}px;
display: inline-flex;
overflow: hidden;
}
.bar::after { content: ""; flex: 1; }
.item { flex-shrink: 0; max-width: 100%; }
</style></head><body>
<div class="pane"><div class="bar" id="bar">${menu}${items}</div></div>
</body></html>`
);
const measured = await page.evaluate(() => {
const bar = document.getElementById('bar');
if (!bar) {
throw new Error('missing bar');
}
const rect = bar.getBoundingClientRect();
return {
container: { left: rect.left, right: rect.right, width: rect.width },
items: [...bar.querySelectorAll<HTMLElement>('.item[id]')].map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, rect: { left: r.left, right: r.right } };
}),
};
});
const result = resolveOverflowingItems(measured.container, measured.items);
return result ? [...result].sort() : null;
}
test.describe('tab overflow rule', () => {
test('reports nothing when every tab fits with room to spare', async ({ page }) => {
// 3 x 100 = 300 of 600, so even with the menu reserved there is slack.
expect(await measure(page, { container: 600, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when the tabs fit exactly', async ({ page }) => {
expect(await measure(page, { container: 300, tabs: [100, 100, 100] })).toEqual([]);
});
test('reports nothing when only the reserved menu made the row overflow', async ({ page }) => {
// The regression: tabs total 300 and the container is 320, so they fit — but measuring
// reserves 44 for the menu, which used to push the last tab out and show a needless
// dropdown. Every width in `container - MENU < 300 <= container` must stay empty.
for (const container of [300, 305, 320, 330, 343]) {
expect(
await measure(page, { container, tabs: [100, 100, 100] }),
`container ${container}px`
).toEqual([]);
}
});
test('reports the tabs that genuinely do not fit alongside the menu', async ({ page }) => {
// 300 of tabs into 290: the row really does overflow, so the menu is warranted and the
// remaining tabs must fit beside it (100 + 100 + 44 = 244 <= 290).
expect(await measure(page, { container: 290, tabs: [100, 100, 100] })).toEqual(['tab-2']);
});
test('gives up as many tabs as the width demands', async ({ page }) => {
expect(await measure(page, { container: 190, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
expect(await measure(page, { container: 150, tabs: [100, 100, 100] })).toEqual([
'tab-1',
'tab-2',
]);
});
test('moves every tab into the menu once not even the first fits beside it', async ({
page,
}) => {
// 100 + 44 > 120, so no tab can share the row with the menu. Everything goes in, leaving a
// bar that is only the menu — deliberately, since the menu is then the sole route to any
// tab. Forcing the first tab to stay would push the menu past the clipped edge and strand
// the rest.
expect(await measure(page, { container: 120, tabs: [100, 100, 100] })).toEqual([
'tab-0',
'tab-1',
'tab-2',
]);
});
test('keeps a single tab that fills the bar rather than hiding it behind a menu', async ({
page,
}) => {
// `max-width: 100%` truncates it to the container, so it fits — a lone tab should never be
// the only thing in the dropdown.
expect(await measure(page, { container: 200, tabs: [400] })).toEqual([]);
});
test('cuts a nested bar earlier, since its pane padding narrows it', async ({ page }) => {
// A nested tab bar sits inside a `p-4` pane, so it has 32px less to work with. At 330 the
// outer bar keeps all three tabs; the nested one at 330 - 32 cannot.
expect(await measure(page, { container: 330, tabs: [100, 100, 100] })).toEqual([]);
expect(await measure(page, { container: 330 - 32, tabs: [100, 100, 100] })).toEqual([
'tab-2',
]);
});
test('progressively fills the menu as a long list is squeezed', async ({ page }) => {
const tabs = Array.from({ length: 12 }, () => 100);
let previous = -1;
for (const container of [1300, 1200, 1000, 800, 600, 400, 200]) {
const overflowing = await measure(page, { container, tabs });
expect(overflowing, `container ${container}px`).not.toBeNull();
const hidden = overflowing?.length ?? 0;
// Never loses a tab, and never un-hides one as the space shrinks.
expect(hidden, `container ${container}px`).toBeGreaterThanOrEqual(previous);
expect(hidden, `container ${container}px`).toBeLessThanOrEqual(tabs.length);
previous = hidden;
}
// Widest fits everything; at 200 only the first tab still fits beside the menu.
expect(await measure(page, { container: 1300, tabs })).toEqual([]);
expect((await measure(page, { container: 200, tabs }))?.length).toBe(11);
});
test('reports nothing measurable while an ancestor is hidden', async ({ page }) => {
// A bar behind an inactive tab has no box, so every rect is zero. That says nothing about
// what fits, and must not be mistaken for "everything overflows".
expect(
await measure(page, { container: 200, tabs: [100, 100, 100], hidden: true })
).toBeNull();
});
test('reports nothing measurable for an empty list', async ({ page }) => {
expect(await measure(page, { container: 600, tabs: [] })).toBeNull();
});
});
+5 -1
View File
@@ -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);
+1 -1
View File
@@ -132,7 +132,7 @@
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts e2e/tabs-overflow.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"e2e-style-perf": "playwright test e2e/style-perf.spec.ts --project=chromium --reporter=list",
"unit": "bun run generate:assets && bun test {src,packages} --preload ./tests/preload-bun.ts",
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'bun:test';
import type { SearchPageResult, SearchSpaceResult, SiteSpace } from '@gitbook/api';
import type {
RevisionPage,
RevisionPageDocument,
SearchPageResult,
SearchSpaceResult,
SiteSpace,
} from '@gitbook/api';
import { orderSearchResultGroups } from './orderSearchResults';
import { createLinker } from '@/lib/links';
@@ -16,6 +22,13 @@ const linker = createLinker({
spaceBasePath: '/',
});
const revisionLinker = createLinker({
protocol: 'https:',
host: 'docs.runway.team',
siteBasePath: '/handbook/',
spaceBasePath: '/handbook/api/~/revisions/revision_preview/',
});
const spaceItem: SearchSpaceResult = {
type: 'space',
id: 'space_runway',
@@ -63,6 +76,33 @@ function transformPage(pageItem: SearchPageResult, asEmbeddable = false) {
pageItem,
spaceItem,
siteSpace,
})!;
}
function createRevisionPage(id: string, path: string): RevisionPageDocument {
return {
id,
type: 'document',
path,
pages: [],
} as RevisionPageDocument;
}
function createRevisionPages(pagePath: string): RevisionPage[] {
return [
createRevisionPage('page_home', 'home'),
createRevisionPage('page_api_reference', pagePath),
];
}
function transformRevisionPage(pageItem: SearchPageResult, revisionPages: RevisionPage[]) {
return transformSitePageResult({
asEmbeddable: false,
linker: revisionLinker,
pageItem,
spaceItem,
siteSpace,
revisionPages,
});
}
@@ -83,12 +123,92 @@ describe('transformSitePageResult', () => {
expect(transformPage(createPageResult({ path: destination }), true).href).toBe(destination);
});
it('resolves a relative page path through the published site URL', () => {
it('keeps published search results on their published destination', () => {
const result = transformPage(createPageResult({ path: 'guides/getting-started' }));
expect(result.href).toBe('/guides/getting-started');
});
it('keeps a current-space page result inside the revision being previewed', () => {
const result = transformRevisionPage(
createPageResult({ path: 'guides/getting-started' }),
createRevisionPages('guides/getting-started')
);
expect(result?.href).toBe(
'/handbook/api/~/revisions/revision_preview/guides/getting-started'
);
});
it('uses the current revision path when a page moved after the indexed revision', () => {
const result = transformRevisionPage(
createPageResult({ path: 'guides/getting-started' }),
createRevisionPages('start/quickstart')
);
expect(result?.href).toBe('/handbook/api/~/revisions/revision_preview/start/quickstart');
});
it('keeps a section result and its anchor inside the current revision', () => {
const result = transformRevisionPage(
createPageResult({
path: 'guides/getting-started',
sections: [
{
id: 'section_authentication',
title: 'Authentication',
body: 'Synthetic section excerpt',
path: 'guides/getting-started#authentication',
score: 10,
resultType: 'section',
urls: {
app: 'https://app.gitbook.com/o/example/s/example',
},
},
],
}),
createRevisionPages('guides/getting-started')
);
expect(result?.bestSection?.href).toBe(
'/handbook/api/~/revisions/revision_preview/guides/getting-started#authentication'
);
});
it('falls back to the revision page root when a section anchor cannot be safely retained', () => {
const result = transformRevisionPage(
createPageResult({
path: 'guides/getting-started',
sections: [
{
id: 'section_authentication',
title: 'Authentication',
body: 'Synthetic section excerpt',
path: 'another-page#authentication',
score: 10,
resultType: 'section',
urls: {
app: 'https://app.gitbook.com/o/example/s/example',
},
},
],
}),
createRevisionPages('start/quickstart')
);
expect(result?.bestSection?.href).toBe(
'/handbook/api/~/revisions/revision_preview/start/quickstart'
);
});
it('excludes a main-index result that does not exist in the current revision', () => {
const result = transformRevisionPage(createPageResult({ path: 'guides/getting-started' }), [
createRevisionPage('page_home', 'home'),
]);
expect(result).toBeNull();
});
it('keeps embeddable GitBook page links in the embeddable route', () => {
const result = transformPage(createPageResult({ path: 'guides/getting-started' }), true);
@@ -126,7 +246,33 @@ describe('transformSitePageResult', () => {
spaceItem,
});
expect(result.href).toBe('/guides/getting-started');
expect(result?.href).toBe('/guides/getting-started');
});
it('keeps cross-space results on their published destination during a revision preview', () => {
const otherSpaceItem = { ...spaceItem, id: 'space_sdk', title: 'SDK' };
const otherSiteSpace = {
...siteSpace,
id: 'site_space_sdk',
path: 'sdk',
space: {
...siteSpace.space,
id: otherSpaceItem.id,
title: otherSpaceItem.title,
},
urls: {
published: 'https://docs.runway.team/handbook/sdk/',
},
} as SiteSpace;
const result = transformSitePageResult({
asEmbeddable: false,
linker: revisionLinker,
pageItem: createPageResult({ path: 'guides/getting-started' }),
spaceItem: otherSpaceItem,
siteSpace: otherSiteSpace,
});
expect(result?.href).toBe('/handbook/sdk/guides/getting-started');
});
it('preserves ranks, scores, and result ordering', () => {
@@ -9,7 +9,7 @@ import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { transformSitePageResult } from '@/lib/search';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
const [searchResults, { structure }, revision] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
@@ -38,8 +38,24 @@ export async function POST(request: NextRequest) {
siteShareKey: siteURLData.shareKey,
})
),
siteURLData.revision
? throwIfDataError(
context.dataFetcher.getRevision({
spaceId: siteURLData.space,
revisionId: siteURLData.revision,
})
)
: Promise.resolve(undefined),
]);
const currentSiteSpace = revision
? findSiteSpaceBy(structure, (siteSpace) => siteSpace.id === siteURLData.siteSpace)
: null;
const revisionLinker =
revision && currentSiteSpace
? getLinkerForSiteSpace(context.linker, currentSiteSpace.siteSpace, revision.pages)
: context.linker;
const results = orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
@@ -55,25 +71,31 @@ export async function POST(request: NextRequest) {
return { type: 'context' as const, results: [result] };
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const isCurrentRevisionSpace = Boolean(revision && resultItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
return {
type: 'pages' as const,
results: resultItem.pages.map((pageItem) => ({
rank: pageItem.rank,
result: transformSitePageResult({
results: resultItem.pages.flatMap((pageItem) => {
const result = transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
})),
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
return result ? [{ rank: pageItem.rank, result }] : [];
}),
};
})
);
@@ -95,7 +95,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 +130,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 +231,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;
}
@@ -226,11 +226,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>
@@ -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)
);
}
@@ -5,18 +5,13 @@ import { useCallback, useSyncExternalStore } from 'react';
import { selectStore } from '@/lib/select';
/**
* Subscribe to the site-wide `select` state. Returns the current recency list plus the setters.
* Consumers that only need "which of my options is active" should prefer {@link useResolvedSlug}.
* Setters for the site-wide `select` state. Deliberately does not subscribe: the store notifies on
* every activation anywhere on the page, so returning the recency list here would re-render every
* block that only ever wanted to *write* a selection. To read one, use {@link useResolvedSlug},
* which re-renders a block only when its own resolved option changes.
*/
export function useSelect() {
const slugs = useSyncExternalStore(
selectStore.subscribe,
selectStore.getState,
selectStore.getState
).slugs;
return {
slugs,
activate: selectStore.activate,
deactivate: selectStore.deactivate,
};
@@ -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" />
@@ -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]);
}
/**
@@ -0,0 +1,94 @@
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 } = await import('./fetch');
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();
});
});
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation';
import { permanentRedirect, redirect } from 'next/navigation';
import {
CustomizationPageActionType,
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
SITE_REDIRECT_SOURCE_PATH_PATTERN,
} from '@gitbook/api';
@@ -26,7 +27,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: {
@@ -89,7 +117,21 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
})
));
if (resolvedSiteRedirect) {
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
if (
resolvedSiteRedirect.redirect?.permanent &&
!resolvedSiteRedirect.redirect.draft &&
isPublicLiveContext
) {
return permanentRedirect(destination);
}
return redirect(destination);
}
}
@@ -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;
}
@@ -0,0 +1,53 @@
/**
* Geometry rule behind {@link useListOverflow}, kept free of React so it can be tested directly
* against rects measured in a real browser.
*/
export interface MeasuredRect {
left: number;
right: number;
}
export interface MeasuredItem {
id: string;
rect: MeasuredRect;
}
/** Sub-pixel tolerance, so a row that fits exactly isn't reported as overflowing. */
const EPSILON = 1;
/**
* Decide which items don't fit the container.
*
* Items are expected to be measured with the overflow affordance (a "more" menu, say) already
* rendered *ahead* of them, which is what lets a single measurement answer both questions: the space
* before the first item is the width that affordance is reserving, so subtracting it gives the
* position each item would have without it. The list only has to make room for the affordance if it
* overflows without one — otherwise a row that fits on its own would give up its last item to a menu
* it never needed.
*
* Returns `null` when the measurement carries no information — an empty list, or a container with no
* width because an ancestor is hidden (a pane behind an inactive tab). Callers should keep their
* previous result and re-measure once it is visible.
*/
export function resolveOverflowingItems(
container: MeasuredRect & { width: number },
items: MeasuredItem[]
): Set<string> | null {
if (container.width <= 0 || items.length === 0) {
return null;
}
const reserved = Math.min(...items.map((item) => item.rect.left)) - container.left;
const fitsUnaided = items.every(
(item) => item.rect.right - reserved <= container.right + EPSILON
);
if (fitsUnaided) {
return new Set();
}
return new Set(
items.filter((item) => item.rect.right > container.right + EPSILON).map((item) => item.id)
);
}
@@ -1,6 +1,8 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { resolveOverflowingItems } from './listOverflow';
interface OverflowState {
/**
* Ref for the container element.
@@ -24,12 +26,23 @@ interface OverflowState {
* Detects which items are overflowing in a horizontal list.
* The items must have unique IDs set on their elements.
*
* In the measuring phase indicated by `isMeasuring`, all items must be rendered.
* In the measuring phase indicated by `isMeasuring`, all items must be rendered. Whatever the list
* shows *because* items overflow (a "more" menu, say) has to be rendered ahead of the items in that
* phase: the measurement discounts the space taken before the first item, so a list that fits on its
* own is not reported as overflowing merely because that menu was reserving room for itself.
*/
export function useListOverflow(): OverflowState {
const containerRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
const [isMeasuring, setIsMeasuring] = useState(false);
// Measuring is a request/completed pair of counters rather than a boolean, because a boolean
// reset can be swallowed: the observer re-arms measuring from a rAF, and when that lands in the
// same batch as the measure effect's reset the net value is unchanged, so React bails out and the
// effect — keyed on that value — never runs again, leaving the list stuck measuring with its
// "more" menu permanently on show. Counters only increase, so a request can't cancel a
// completion; it just queues another pass.
const [measureRequest, setMeasureRequest] = useState(0);
const [measureCompleted, setMeasureCompleted] = useState(0);
const isMeasuring = measureRequest !== measureCompleted;
const itemRefs = useRef(new Map<string, HTMLElement>());
const rafRef = useRef(0);
@@ -43,19 +56,19 @@ export function useListOverflow(): OverflowState {
};
}, []);
const requestMeasure = useCallback(() => setMeasureRequest((request) => request + 1), []);
// Measure on mount and when container size changes
useEffect(() => {
if (!containerRef.current) {
return;
}
setIsMeasuring(true);
requestMeasure();
const ro = new ResizeObserver(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setIsMeasuring(true);
});
rafRef.current = requestAnimationFrame(requestMeasure);
});
ro.observe(containerRef.current);
@@ -64,7 +77,7 @@ export function useListOverflow(): OverflowState {
ro.disconnect();
cancelAnimationFrame(rafRef.current);
};
}, []);
}, [requestMeasure]);
// Measure which items are overflowing
useLayoutEffect(() => {
@@ -72,29 +85,32 @@ export function useListOverflow(): OverflowState {
return;
}
const containerRect = containerRef.current.getBoundingClientRect();
const newOverflowing = new Set<string>();
const newOverflowing = resolveOverflowingItems(
containerRef.current.getBoundingClientRect(),
Array.from(itemRefs.current, ([id, element]) => ({
id,
rect: element.getBoundingClientRect(),
}))
);
itemRefs.current.forEach((el, id) => {
const elRect = el.getBoundingClientRect();
if (elRect.right > containerRect.right + 1) {
newOverflowing.add(id);
}
});
setOverflowing((previous) => {
if (previous.size !== newOverflowing.size) {
return newOverflowing;
}
for (const id of previous) {
if (!newOverflowing.has(id)) {
// `null` means the measurement said nothing (hidden ancestor, or no items) — keep what we
// had and wait for the observer to fire once it is visible.
if (newOverflowing) {
setOverflowing((previous) => {
if (previous.size !== newOverflowing.size) {
return newOverflowing;
}
}
return previous;
});
setIsMeasuring(false);
}, [isMeasuring]);
for (const id of previous) {
if (!newOverflowing.has(id)) {
return newOverflowing;
}
}
return previous;
});
}
setMeasureCompleted(measureRequest);
}, [isMeasuring, measureRequest]);
return { containerRef, itemRef, overflowing, isMeasuring };
}
+11
View File
@@ -0,0 +1,11 @@
/** Request markers for ChatGPT's server-side fetchers. */
export function isChatGPTRequest(request: Pick<Request, 'headers'>): boolean {
const normalizedUserAgent = request.headers.get('user-agent')?.toLowerCase() ?? '';
const normalizedSignatureAgent = request.headers.get('signature-agent')?.toLowerCase() ?? '';
return (
normalizedUserAgent.includes('chatgpt-user') ||
normalizedUserAgent.includes('chatgpt agent') ||
normalizedSignatureAgent.includes('chatgpt.com')
);
}
+15
View File
@@ -101,6 +101,9 @@ export type SiteURLData = Pick<
* the static cache of the other routes.
*/
isAiAgent?: boolean;
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
isChatGPT?: boolean;
};
/**
@@ -201,11 +204,17 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Whether the request included a visitor token. */
isLoggedInVisitor: boolean;
/** Whether the site is rendered from a preview URL. */
preview: boolean;
/** Whether to display agent instructions in the markdown output. Defaults to true when undefined. */
displayAgentInstructions?: boolean;
/** Whether the request comes from a detected AI agent. Only set for markdown routes. */
isAiAgent?: boolean;
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
isChatGPT?: boolean;
};
/**
@@ -287,8 +296,10 @@ export async function fetchSiteContextByURLLookup(
isFallback: data.isFallback ?? false,
noIndexSearch: data.noIndexSearch ?? false,
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
preview: data.preview ?? false,
displayAgentInstructions: data.displayAgentInstructions,
isAiAgent: data.isAiAgent,
isChatGPT: data.isChatGPT,
});
}
@@ -310,8 +321,10 @@ export async function fetchSiteContextByIds(
isFallback: boolean;
noIndexSearch: boolean;
isLoggedInVisitor: boolean;
preview: boolean;
displayAgentInstructions?: boolean;
isAiAgent?: boolean;
isChatGPT?: boolean;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
@@ -439,8 +452,10 @@ export async function fetchSiteContextByIds(
isFallback: ids.isFallback,
noIndexSearch: ids.noIndexSearch,
isLoggedInVisitor: ids.isLoggedInVisitor,
preview: ids.preview,
displayAgentInstructions: ids.displayAgentInstructions,
isAiAgent: ids.isAiAgent,
isChatGPT: ids.isChatGPT,
};
}
+14 -4
View File
@@ -87,6 +87,8 @@ export function createDataFetcher(
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
metadata: params.metadata,
cachedMetadata: params.cachedMetadata,
});
},
getRevisionPageMarkdown(params) {
@@ -526,7 +528,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 +543,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,
}
@@ -0,0 +1,107 @@
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 } });
}
);
});
+10 -2
View File
@@ -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 } : {}),
};
+2
View File
@@ -97,6 +97,8 @@ export interface GitBookDataFetcher {
spaceId: string;
revisionId: string;
path: string;
metadata?: boolean;
cachedMetadata?: boolean;
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
/**
+98 -1
View File
@@ -1,6 +1,103 @@
import { describe, expect, it } from 'bun:test';
import { getURLLookupAlternatives, normalizeURL } from './urls';
import { getURLLookupAlternatives, getURLLookupPathname, normalizeURL } 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', () => {
+18
View File
@@ -1,3 +1,4 @@
import { joinPath, removeTrailingSlash } from '../paths';
import { isProxyRootRequest } from '../proxy';
import { DataFetcherError, getExposableError } from './errors';
@@ -160,6 +161,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.
*/
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'bun:test';
import { isChatGPTRequest } from './chatgpt';
import { getMarkdownContentType } from './markdown-content-type';
describe('ChatGPT Markdown compatibility', () => {
const requestWith = (headers: Record<string, string>) => ({ headers: new Headers(headers) });
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': 'SomeClient/1.0',
'signature-agent': '"https://chatgpt.com"',
})
)
).toBe(true);
expect(isChatGPTRequest(requestWith({ 'user-agent': 'GPTBot/1.2' }))).toBe(false);
expect(isChatGPTRequest(requestWith({ 'user-agent': 'ClaudeBot/1.0' }))).toBe(false);
});
it('uses plain text only for ChatGPT responses', () => {
expect(getMarkdownContentType(true)).toBe('text/plain; charset=utf-8');
expect(getMarkdownContentType(false)).toBe('text/markdown; charset=utf-8');
expect(getMarkdownContentType()).toBe('text/markdown; charset=utf-8');
});
});
@@ -0,0 +1,7 @@
/**
* ChatGPT currently rejects text/markdown responses, so use text/plain for its Markdown output.
* See RND-12762: https://linear.app/gitbook-x/issue/RND-12762/chatgpt-cant-read-docs-on-gitbook
*/
export function getMarkdownContentType(isChatGPT?: boolean): string {
return isChatGPT ? 'text/plain; charset=utf-8' : 'text/markdown; charset=utf-8';
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'bun:test';
import type { GitBookAnyContext } from './context';
import { createLinker, linkerWithDirectPagePaths, linkerWithMarkdownPages } from './links';
import { fromPageMarkdown, toPageMarkdown } from './markdownPage';
const page = {
id: 'designer',
type: 'document',
title: 'About the Workflow Designer',
path: 'about-the-workflow-designer',
slug: 'about-the-workflow-designer',
pages: [],
layout: {},
};
const linker = linkerWithMarkdownPages(
linkerWithDirectPagePaths(
createLinker({ host: 'docs.example.com', spaceBasePath: '/workflows/', siteBasePath: '/' })
)
);
const context = {
space: {
id: 'workflows',
title: 'Workflows',
urls: { published: 'https://docs.example.com/workflows/' },
},
revision: { pages: [page], files: [] },
linker,
} as unknown as GitBookAnyContext;
async function rewrite(markdown: string) {
return toPageMarkdown(await fromPageMarkdown(context, { markdown, pagePath: 'workflows' }));
}
describe('HTML links in page markdown', () => {
it.each([
['/pages/designer', linker.toPathForPagePath({ path: page.path })],
['/spaces/workflows', context.space.urls.published],
['/pages/missing', 'broken://pages/missing'],
])(
'resolves an inline button targeting %s without changing its label or attributes',
async (ref, expected) => {
const button = `<a href="${ref}" class="button primary" data-icon="triple-chevrons-right">About the designer</a>`;
const markdown = `{% columns %}\n{% column %}\n\n${button}\n{% endcolumn %}\n{% endcolumns %}\n`;
expect(await rewrite(markdown)).toBe(
markdown.replace(`href="${ref}"`, `href="${expected}"`)
);
}
);
it('preserves title replacement for complete card anchors', async () => {
const markdown =
'<table><tr><td><a href="/pages/designer">/pages/designer</a></td></tr></table>\n';
expect(await rewrite(markdown)).toBe(
`<table><tr><td><a href="${linker.toPathForPagePath({ path: page.path })}">${page.title}</a></td></tr></table>\n`
);
});
});
+132 -23
View File
@@ -1,4 +1,4 @@
import type { 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';
@@ -29,6 +29,10 @@ import type { ResolvedPagePath } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
import { getMarkdownForPagesTree } from '@/routes/llms';
const HTML_ANCHOR_RE = /<a\b([^>]*?)href="([^"]*)"([^>]*)>([\s\S]*?)<\/a>/g;
const HTML_ANCHOR_OPEN_RE = /<a\b([^>]*?)href="([^"]*)"([^>]*)>/g;
const HTML_SRC_RE = /\bsrc="([^"]*)"/g;
/**
* Generate a markdown version of a page.
* Handles both regular document pages and group pages (pages with child pages).
@@ -66,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) {
@@ -102,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) {
@@ -142,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.
@@ -199,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',
@@ -208,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)),
],
};
@@ -219,7 +254,8 @@ async function renderGroupPageMarkdown(args: {
/**
* Re-writes URLs in a markdown content:
* -
* - stable content refs (`/pages/:id`, `/spaces/:id/pages/:id`, `/files/:id`...) in links,
* images, definitions and in the `href`/`src` of raw HTML blocks are resolved to site URLs.
* - the URL of every relative <a> link so it is expressed from the site-root.
*/
async function rewriteMarkdownLinks(
@@ -246,30 +282,13 @@ async function rewriteMarkdownLinks(
pending.push(
(async () => {
const resolved = await resolveContentRef(contentRef, context);
if (resolved?.href) {
node.url = resolved.href;
} else {
// We use an absolute URL so that crawler don't follow it.
node.url = `broken://${original.startsWith('/') ? original.slice(1) : original}`;
}
node.url = resolved?.href ?? toBrokenURL(original);
if (isMention) {
// Replace the text for mentions as otherwise it contains the raw ref
if (resolved) {
node.children = [
{
type: 'text',
value: resolved.text,
},
];
} else {
node.children = [
{
type: 'text',
value: 'Broken mention',
},
];
}
node.children = [
{ type: 'text', value: resolved?.text ?? 'Broken mention' },
];
node.title = undefined;
}
})()
@@ -288,6 +307,20 @@ async function rewriteMarkdownLinks(
}
});
visit(tree, 'image', (node: Image) => {
pending.push(rewriteNodeURL(context, node));
});
visit(tree, 'definition', (node: Definition) => {
pending.push(rewriteNodeURL(context, node));
});
// Blocks markdown cannot express (tables, cards, figures...) are emitted as raw HTML,
// with the same stable refs in their anchors and images.
visit(tree, 'html', (node: Html) => {
pending.push(rewriteHTMLRefs(context, node));
});
if (pending.length > 0) {
await Promise.all(pending);
}
@@ -295,6 +328,82 @@ async function rewriteMarkdownLinks(
return tree;
}
/**
* Resolve a URL if it is a stable content ref. Returns null for anything else
* (external URLs, anchors, plain paths) so the caller leaves it untouched.
*/
async function resolveRefURL(
context: GitBookAnyContext,
url: string
): Promise<{ url: string; text: string | null } | null> {
if (checkIsExternalURL(url) || checkIsAnchor(url)) {
return null;
}
const contentRef = resolveStringContentRef(url);
if (!contentRef) {
return null;
}
const resolved = await resolveContentRef(contentRef, context);
return { url: resolved?.href ?? toBrokenURL(url), text: resolved?.text ?? null };
}
async function rewriteNodeURL(context: GitBookAnyContext, node: Image | Definition) {
const resolved = await resolveRefURL(context, node.url);
if (resolved) {
node.url = resolved.url;
}
}
async function rewriteHTMLRefs(context: GitBookAnyContext, node: Html): Promise<void> {
node.value = await replaceAsync(node.value, HTML_ANCHOR_RE, async (match) => {
const [full, before = '', href = '', after = '', text = ''] = match;
const resolved = await resolveRefURL(context, href);
if (!resolved) {
return full;
}
// The API emits the raw ref as the text; swap it for the resolved title.
const content = text === href ? escapeHTML(resolved.text ?? 'Broken link') : text;
return `<a${before}href="${escapeHTML(resolved.url)}"${after}>${content}</a>`;
});
node.value = await replaceAsync(node.value, HTML_ANCHOR_OPEN_RE, async (match) => {
const [full, before = '', href = '', after = ''] = match;
const resolved = await resolveRefURL(context, href);
return resolved ? `<a${before}href="${escapeHTML(resolved.url)}"${after}>` : full;
});
node.value = await replaceAsync(node.value, HTML_SRC_RE, async (match) => {
const [full, src = ''] = match;
const resolved = await resolveRefURL(context, src);
return resolved ? `src="${escapeHTML(resolved.url)}"` : full;
});
}
async function replaceAsync(
value: string,
re: RegExp,
replacer: (match: RegExpMatchArray) => Promise<string>
): Promise<string> {
const replacements = await Promise.all(Array.from(value.matchAll(re), replacer));
let index = 0;
return value.replace(re, () => replacements[index++]!);
}
/**
* Use an absolute URL so that crawlers don't follow it.
*/
function toBrokenURL(original: string): string {
return `broken://${original.startsWith('/') ? original.slice(1) : original}`;
}
function escapeHTML(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function isMentionLike(node: Link) {
if (node.title === 'mention') {
return true;
+72 -25
View File
@@ -1,4 +1,6 @@
import type {
Revision,
RevisionPageDocument,
SearchPageResult,
SearchSpaceResult,
SiteSection,
@@ -10,6 +12,7 @@ import type { IconName } from '@gitbook/icons';
import type { ComputedPageResult, ComputedSectionResult } from '@/components/Search/search-types';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import type { GitBookLinker } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getLocalizedTitle } from '@/lib/sites';
import { checkIsHttpURL } from '@/lib/urls';
@@ -22,13 +25,32 @@ export function transformSitePageResult(args: {
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): ComputedPageResult {
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
args;
revisionPages?: Revision['pages'];
}): ComputedPageResult | null {
const {
asEmbeddable,
pageItem,
spaceItem,
siteSection,
siteSectionGroup,
siteSpace,
linker,
revisionPages,
} = args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
let revisionTarget: { pages: Revision['pages']; page: RevisionPageDocument } | undefined;
if (revisionPages) {
const resolved = resolvePageId(revisionPages, pageItem.id);
if (!resolved || resolved.page.id !== pageItem.id) {
return null;
}
revisionTarget = { pages: revisionPages, page: resolved.page };
}
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
@@ -63,13 +85,9 @@ export function transformSitePageResult(args: {
}))
);
const pageHref = checkIsHttpURL(pageItem.path)
? pageItem.path
: !spaceURL
? linker.toPathInSpace(pageItem.path)
: asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
const pageHref = revisionTarget
? linker.toPathForPage(revisionTarget)
: getPublishedPageHref({ asEmbeddable, linker, pagePath: pageItem.path, spaceURL });
const page: ComputedPageResult = {
type: 'page',
@@ -90,21 +108,20 @@ export function transformSitePageResult(args: {
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(section.path);
if (spaceURL) {
if (asEmbeddable) {
sectionHref = toEmbeddableLinkForPublishedContent(
linker,
spaceURL,
section.path
);
} else {
sectionHref = linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, section.path)
);
}
}
const anchor = revisionTarget
? getRetainableSectionAnchor(section.path, pageItem.path)
: undefined;
const sectionHref = revisionTarget
? linker.toPathForPage({
...revisionTarget,
anchor,
})
: getPublishedPageHref({
asEmbeddable,
linker,
pagePath: section.path,
spaceURL,
});
return {
type: 'section',
@@ -131,3 +148,33 @@ export function transformSitePageResult(args: {
return page;
}
function getRetainableSectionAnchor(sectionPath: string, pagePath: string): string | undefined {
const hashIndex = sectionPath.indexOf('#');
if (hashIndex === -1 || sectionPath.slice(0, hashIndex) !== pagePath) {
return undefined;
}
return sectionPath.slice(hashIndex + 1) || undefined;
}
function getPublishedPageHref(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
pagePath: string;
spaceURL: string | undefined;
}) {
const { asEmbeddable, linker, pagePath, spaceURL } = input;
if (checkIsHttpURL(pagePath)) {
return pagePath;
}
if (!spaceURL) {
return linker.toPathInSpace(pagePath);
}
return asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pagePath)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pagePath));
}
+14
View File
@@ -10,6 +10,7 @@ import {
getVisitorToken,
getVisitorType,
getVisitorUnsignedClaims,
isRevalidationRequest,
normalizeVisitorURL,
} from './visitors';
@@ -564,3 +565,16 @@ 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);
});
});
+9 -1
View File
@@ -132,6 +132,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 +162,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 };
}
+14 -3
View File
@@ -22,6 +22,7 @@ import {
trackServerInsightsEvents,
} from './lib/tracking';
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
import { isChatGPTRequest } from '@/lib/chatgpt';
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
import type { SiteURLData } from '@/lib/context';
import { getContentSecurityPolicy } from '@/lib/csp';
@@ -55,6 +56,7 @@ import {
getResponseCookiesForVisitorAuth,
getVisitorData,
getVisitorType,
isRevalidationRequest,
normalizeVisitorURL,
serveVisitorClaimsDataRequest,
} from '@/lib/visitors';
@@ -338,7 +340,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.
@@ -346,7 +350,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const normalizedVisitorURL = normalizeVisitorURL(incomingURL);
if (
normalizedVisitorURL.toString() !== incomingURL.toString() &&
visitorToken?.source !== 'revalidation'
!isRevalidationRequest(request.headers)
) {
return writeResponseCookies(
NextResponse.redirect(normalizedVisitorURL.toString()),
@@ -459,10 +463,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
routeType: routeTypeFromPathname,
events,
isAiAgent,
isChatGPT,
} = encodePathInSiteContent(siteURLData, request);
routeType = routeTypeFromPathname ?? routeType;
// Only set for markdown routes, so it becomes part of their static cache key.
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
stableSiteURLData.isAiAgent = isAiAgent;
stableSiteURLData.isChatGPT = isChatGPT;
// Apply a forced theme (`?theme=`/cookie). For the docs embed we thread it through the
// route context (`embedTheme`) so those routes stay statically rendered — it becomes part
@@ -779,6 +785,8 @@ function encodePathInSiteContent(
events?: ServerInsightsEventInput[] | undefined;
/** Only set for markdown routes, where the output depends on the visitor being an agent. */
isAiAgent?: boolean;
/** Only set for Markdown and LLM routes, where the output content type depends on ChatGPT. */
isChatGPT?: boolean;
} {
let pathname = removeLeadingSlash(removeTrailingSlash(siteURLData.pathname));
@@ -820,6 +828,7 @@ function encodePathInSiteContent(
return {
pathname,
routeType: 'static',
isChatGPT: isChatGPTRequest(request) || undefined,
events: [
{
type: 'llms_request',
@@ -855,6 +864,7 @@ function encodePathInSiteContent(
return {
pathname,
routeType: 'static',
isChatGPT: isChatGPTRequest(request) || undefined,
events: [
{
type: 'llms_request',
@@ -915,6 +925,7 @@ function encodePathInSiteContent(
routeType: 'static',
// Left undefined for non-agents to avoid splitting the static cache for them.
isAiAgent: isAiAgent || undefined,
isChatGPT: isChatGPTRequest(request) || undefined,
// TODO: track pageId / spaceId when possible
// We don't do it at the moment as we can't easily extract it from the URL.
events: ask
+2 -1
View File
@@ -10,6 +10,7 @@ import {
fetchSiteContextForSiteSpace,
} from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { getMarkdownContentType } from '@/lib/markdown-content-type';
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
import { getIndexablePages } from '@/lib/sitemap';
import { filterSiteSpacesByLocale, getSiteStructureSections } from '@/lib/sites';
@@ -48,7 +49,7 @@ export async function serveLLMsFullTxt(context: GitBookSiteContext, page = 0) {
}),
{
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Content-Type': getMarkdownContentType(context.isChatGPT),
},
}
);
+2 -1
View File
@@ -8,6 +8,7 @@ import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
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 {
@@ -52,7 +53,7 @@ export async function serveLLMsTxt(baseContext: GitBookSiteContext) {
return new Response(output, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Content-Type': getMarkdownContentType(baseContext.isChatGPT),
},
});
}
+1 -1
View File
@@ -92,5 +92,5 @@ export async function serveAskMarkdown(
}
return result;
});
}, context.isChatGPT);
}
+5 -3
View File
@@ -5,6 +5,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { getExposableError } from '@/lib/data';
import { linkerWithMarkdownPages } from '@/lib/links';
import { renderLLMsTxtMarkdownDirective } from '@/lib/llms-directive';
import { getMarkdownContentType } from '@/lib/markdown-content-type';
import { getMarkdownForPage } from '@/lib/markdownPage';
import { type ResolvedPagePath, getSimilarPages } from '@/lib/pages';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
@@ -44,7 +45,7 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
markdown: `${renderLLMsTxtMarkdownDirective(context, pageLookup.page)}\n\n${markdownPage}${renderAskFooter(context, pageLookup)}`,
robots,
};
});
}, baseContext.isChatGPT);
}
/**
@@ -163,7 +164,8 @@ Use this mechanism when the answer is not explicitly present in the current page
* Return a markdown content.
*/
export async function serveMarkdown(
fn: () => Promise<string | { markdown: string; robots: string }>
fn: () => Promise<string | { markdown: string; robots: string }>,
isChatGPT?: boolean
) {
try {
const result = await fn();
@@ -171,7 +173,7 @@ export async function serveMarkdown(
typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result;
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Content-Type': getMarkdownContentType(isChatGPT),
'X-Robots-Tag': robots,
Vary: 'Accept',
},
+34
View File
@@ -40,6 +40,21 @@ describe('llms.txt', () => {
expect(await response.text()).toContain('# E2E Tests GitBook Open');
});
it('should serve plain text to ChatGPT', async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt'),
{
headers: {
'User-Agent': 'ChatGPT-User/1.0',
},
}
);
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')
@@ -118,6 +133,25 @@ describe('llms-full.txt', () => {
{ timeout: 30_000 }
);
it(
'should serve plain text to ChatGPT',
async () => {
const response = await fetch(
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt'),
{
headers: {
'User-Agent': 'ChatGPT-User/1.0',
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/plain');
expect(await response.text()).toContain('# Welcome');
},
{ timeout: 30_000 }
);
it(
'should return 404 when a llms-full.txt page has no content',
async () => {
+11
View File
@@ -27,6 +27,17 @@ describe('markdown serving based on user agent', () => {
expect(response.headers.get('content-type')).toContain('text/markdown');
});
it('should serve plain text to ChatGPT', async () => {
const response = await fetch(getContentTestURL(TEST_PAGE_URL), {
headers: {
'User-Agent': 'ChatGPT-User/1.0',
},
});
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: {
@@ -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}
+34 -6
View File
@@ -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>
);