mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c08d05ade5 | |||
| b9453a92f8 | |||
| 2c4d40ad97 | |||
| 688515efcf | |||
| 931cbe717e | |||
| 3ef1802e7e | |||
| 8a0e0df84e | |||
| a9041a309a | |||
| 625c108196 | |||
| 8e87856501 | |||
| 9b822abbb6 | |||
| 81dba6455b | |||
| 1faa57b812 | |||
| 40150f0a6c | |||
| bf29570bd5 | |||
| e8e979b6c0 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Include published page descriptions in the page's Markdown output.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Preserve the full site preview path and query parameters when redirecting users to log in.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove the fallback query parameter after successful page navigation without adding a browser history entry.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Restore Edit on Git page actions for Git-synced pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
+149
-3
@@ -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', () => {
|
||||
|
||||
+34
-12
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface GitBookDataFetcher {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
path: string;
|
||||
metadata?: boolean;
|
||||
cachedMetadata?: boolean;
|
||||
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function isMentionLike(node: Link) {
|
||||
if (node.title === 'mention') {
|
||||
return true;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,5 +92,5 @@ export async function serveAskMarkdown(
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}, context.isChatGPT);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user