mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 12:10:34 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49bbe04213 | |||
| 872321f44d | |||
| 4519ea183d | |||
| db9fbab15e |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Include published page descriptions in the page's Markdown output.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix inline Ask AI buttons opening a configured custom assistant.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support buttons that open an integration's UI, so an integration can be used in places an integration block cannot go, such as a table cell.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Serve Markdown responses to ChatGPT with a `text/plain` Content-Type for compatibility.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep current-space search results inside revision previews.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Render horizontal and vertical merged table cells on published pages.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Scroll to the top when selecting a search result for the page already being viewed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add end-to-end coverage for root and nested external links in site section navigation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix section links in search results opening the page without scrolling to the section.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip redundant class and style writes on `<html>` and the site header during initial load; each one re-styled the whole document.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove the fallback query parameter after successful page navigation without adding a browser history entry.
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.200.0",
|
||||
"@gitbook/api": "0.198.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.200.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-JgPosRwabDqw8FzTpSZdEWCUdwUxO2vIHJH9A8CXYXBmubjjj/Ft01qnzbL2T5q1CSLSQKe9PvW9t5V+R/8iJA=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.198.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZGXqYip6YFsCM5rqlJ+n8/Esw1bMNK4whqiMoWMeQgMKEX5YXVQaBuVRxU1jDEI49kup+T56xPq3I9g6Wcayng=="],
|
||||
|
||||
"@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.200.0",
|
||||
"@gitbook/api": "0.198.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -245,43 +245,6 @@ 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({
|
||||
@@ -550,24 +513,6 @@ 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',
|
||||
@@ -590,11 +535,8 @@ 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'
|
||||
) && !url.searchParams.has('fallback')
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -765,150 +707,6 @@ const testCases: TestsCase[] = [
|
||||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootSections = page.locator('[data-gb-sections]');
|
||||
const rootItems = rootSections.locator(':scope > li');
|
||||
|
||||
await expect(rootItems).toHaveCount(4);
|
||||
await expect(rootItems.nth(0)).toContainText('Home');
|
||||
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
|
||||
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
|
||||
await expect(rootItems.last()).toContainText('Gitbook Docs');
|
||||
await expect(
|
||||
rootSections.getByRole('link', { name: 'Gitbook Docs' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootLink = page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' });
|
||||
|
||||
await expect(rootLink).toBeVisible();
|
||||
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
|
||||
await expect(rootLink).not.toHaveAttribute('target');
|
||||
await expect(rootLink).not.toHaveAttribute('rel');
|
||||
await expect(rootLink).toHaveAttribute('data-active', 'false');
|
||||
await expect(rootLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
|
||||
const nestedItems = nestedLink
|
||||
.locator('xpath=ancestor::ul[1]')
|
||||
.locator(':scope > li');
|
||||
await expect(nestedItems).toHaveCount(3);
|
||||
await expect(nestedItems.nth(0)).toContainText('Section C');
|
||||
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
|
||||
await expect(nestedItems.last()).toContainText('Gitbook Site');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
|
||||
await expect(nestedLink).not.toHaveAttribute('target');
|
||||
await expect(nestedLink).not.toHaveAttribute('rel');
|
||||
await expect(nestedLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'External links use the configured window open behavior',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
|
||||
const windowOpenCalls: {
|
||||
url: string;
|
||||
target: string;
|
||||
features: string | undefined;
|
||||
}[] = [];
|
||||
await page.exposeFunction(
|
||||
'recordExternalWindowOpen',
|
||||
(url: string, target: string, features?: string) => {
|
||||
windowOpenCalls.push({ url, target, features });
|
||||
}
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const recordExternalWindowOpen = (
|
||||
window as unknown as {
|
||||
recordExternalWindowOpen: (
|
||||
url: string,
|
||||
target: string,
|
||||
features?: string
|
||||
) => void;
|
||||
}
|
||||
).recordExternalWindowOpen;
|
||||
window.open = ((url, target, features) => {
|
||||
void recordExternalWindowOpen(
|
||||
url?.toString() ?? '',
|
||||
target ?? '',
|
||||
features
|
||||
);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
|
||||
const initialURL = page.url();
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' })
|
||||
.click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(1);
|
||||
expect(windowOpenCalls[0]).toEqual({
|
||||
url: 'https://gitbook.com/docs',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
await page.getByRole('link', { name: 'Gitbook Site' }).click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(2);
|
||||
expect(windowOpenCalls[1]).toEqual({
|
||||
url: 'https://gitbook.com',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -236,99 +236,3 @@ test.describe('select syncing across groups (click-driven)', () => {
|
||||
await expectGroupShows(page, 'b', other, 'go');
|
||||
});
|
||||
});
|
||||
|
||||
interface NestedSpec {
|
||||
outer: string[];
|
||||
inner: string[];
|
||||
/** Which of the outer options hosts the nested group. */
|
||||
host: string;
|
||||
/**
|
||||
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
|
||||
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
|
||||
*/
|
||||
innerStyleFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
|
||||
* produces: panes are direct children of the element carrying the set class, and a pane's body is
|
||||
* wrapped in a padding div before the nested group.
|
||||
*/
|
||||
async function renderNestedGroups(page: Page, spec: NestedSpec) {
|
||||
const { outer, inner, host, innerStyleFirst = false } = spec;
|
||||
const outerScope = selectSetClassName(outer);
|
||||
const innerScope = selectSetClassName(inner);
|
||||
|
||||
const innerPanes = inner
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
|
||||
|
||||
const outerPanes = outer
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
|
||||
if (innerStyleFirst) {
|
||||
styles.reverse();
|
||||
}
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
|
||||
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
|
||||
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
|
||||
*/
|
||||
test.describe('select CSS visibility in nested groups', () => {
|
||||
const outer = ['macos', 'windows'];
|
||||
const inner = ['npm', 'yarn'];
|
||||
|
||||
test('shows both defaults when nothing is selected', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'windows' });
|
||||
await applySelection(page, ['windows']);
|
||||
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves each group against its own options', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 e2e/tabs-overflow.spec.ts --project=chromium",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.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",
|
||||
|
||||
+3
-149
@@ -1,12 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type {
|
||||
RevisionPage,
|
||||
RevisionPageDocument,
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSpace,
|
||||
} from '@gitbook/api';
|
||||
import type { SearchPageResult, SearchSpaceResult, SiteSpace } from '@gitbook/api';
|
||||
|
||||
import { orderSearchResultGroups } from './orderSearchResults';
|
||||
import { createLinker } from '@/lib/links';
|
||||
@@ -22,13 +16,6 @@ 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',
|
||||
@@ -76,33 +63,6 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,92 +83,12 @@ describe('transformSitePageResult', () => {
|
||||
expect(transformPage(createPageResult({ path: destination }), true).href).toBe(destination);
|
||||
});
|
||||
|
||||
it('keeps published search results on their published destination', () => {
|
||||
it('resolves a relative page path through the published site URL', () => {
|
||||
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);
|
||||
|
||||
@@ -246,33 +126,7 @@ describe('transformSitePageResult', () => {
|
||||
spaceItem,
|
||||
});
|
||||
|
||||
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');
|
||||
expect(result.href).toBe('/guides/getting-started');
|
||||
});
|
||||
|
||||
it('preserves ranks, scores, and result ordering', () => {
|
||||
|
||||
+12
-34
@@ -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, getLinkerForSiteSpace } from '@/lib/sites';
|
||||
import { findSiteSpaceBy } 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 }, revision] = await Promise.all([
|
||||
const [searchResults, { structure }] = await Promise.all([
|
||||
throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: siteURLData.organization,
|
||||
@@ -38,24 +38,8 @@ 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') {
|
||||
@@ -71,31 +55,25 @@ export async function POST(request: NextRequest) {
|
||||
return { type: 'context' as const, results: [result] };
|
||||
}
|
||||
|
||||
const isCurrentRevisionSpace = Boolean(revision && resultItem.id === siteURLData.space);
|
||||
const found =
|
||||
isCurrentRevisionSpace && currentSiteSpace
|
||||
? currentSiteSpace
|
||||
: findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'pages' as const,
|
||||
results: resultItem.pages.flatMap((pageItem) => {
|
||||
const result = transformSitePageResult({
|
||||
results: resultItem.pages.map((pageItem) => ({
|
||||
rank: pageItem.rank,
|
||||
result: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
|
||||
linker: 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 }] : [];
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
+1
-3
@@ -53,10 +53,8 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
|
||||
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
|
||||
// Never shown on touch / hover-less contexts.
|
||||
'not-pointer-fine:hidden',
|
||||
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
|
||||
// tables, record cards).
|
||||
// Hidden where an overflow-clipped ancestor would cut it off (tables, record cards).
|
||||
'in-[[role=table]]:hidden',
|
||||
'in-[table]:hidden',
|
||||
'in-[[data-card]]:hidden',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useAI, useAIChatState } from '../AI';
|
||||
import { useAI, useAIChatController, useAIChatState } from '../AI';
|
||||
import { Button, type ButtonProps, Input } from '../primitives';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
@@ -18,13 +18,17 @@ export function InlineActionButton(
|
||||
const { action, query, buttonProps } = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
const chatState = useAIChatState();
|
||||
const setSearchState = useSetSearchState();
|
||||
const language = useLanguage();
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
if (action === 'ask') {
|
||||
assistants[0]?.open(value || query);
|
||||
chatController.open();
|
||||
if (value ?? query) {
|
||||
chatController.postMessage({ message: value ?? query });
|
||||
}
|
||||
} else if (action === 'search') {
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -7,13 +7,10 @@ import { Button, type ButtonProps } from '../primitives';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { InlineActionButton } from './InlineActionButton';
|
||||
import { getIntegrationAction } from './integrationAction';
|
||||
import { IntegrationActionButton } from './IntegrationActionButton';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
import { getSelectAction } from './selectAction';
|
||||
import { SelectActionButton } from './SelectActionButton';
|
||||
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
|
||||
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
|
||||
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
|
||||
|
||||
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
|
||||
@@ -44,29 +41,6 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
return <SelectActionButton value={selectAction.value} buttonProps={buttonProps} />;
|
||||
}
|
||||
|
||||
// Skip in print/PDF: the integration renders into a dialog, which a static render can't show.
|
||||
const integrationAction =
|
||||
context.mode !== 'print' ? getIntegrationAction(inline.data) : null;
|
||||
const spaceId = context.contentContext?.space?.id;
|
||||
if (integrationAction && spaceId) {
|
||||
return (
|
||||
<IntegrationActionButton
|
||||
integration={integrationAction.integration}
|
||||
block={integrationAction.block}
|
||||
spaceId={spaceId}
|
||||
security={{
|
||||
firstPartyDomains: [
|
||||
...new Set([
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
]),
|
||||
],
|
||||
}}
|
||||
buttonProps={buttonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
|
||||
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
|
||||
return (
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { ContentKitRenderOutputElement, RequestRenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, type ContentKitSecurity } from '@gitbook/react-contentkit/client';
|
||||
|
||||
import { Button, type ButtonProps } from '../primitives';
|
||||
import { renderIntegrationUi } from './Integration/server-actions';
|
||||
|
||||
/**
|
||||
* Button that hands the click to an integration: the integration renders its component in modal
|
||||
* mode and decides what the reader sees. This is how an integration reaches places an integration
|
||||
* block cannot go, such as a table cell.
|
||||
*/
|
||||
export function IntegrationActionButton(props: {
|
||||
integration: string;
|
||||
block: string;
|
||||
spaceId: string;
|
||||
security: ContentKitSecurity;
|
||||
buttonProps: ButtonProps;
|
||||
}) {
|
||||
const { integration, block, spaceId, security, buttonProps } = props;
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [modal, setModal] = React.useState<null | {
|
||||
input: RequestRenderIntegrationUI;
|
||||
output: ContentKitRenderOutputElement;
|
||||
children: React.ReactNode;
|
||||
}>(null);
|
||||
|
||||
const renderContext = React.useMemo(() => ({ integrationName: integration }), [integration]);
|
||||
|
||||
const onClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const input: RequestRenderIntegrationUI = {
|
||||
componentId: block,
|
||||
props: {},
|
||||
context: {
|
||||
type: 'document',
|
||||
spaceId,
|
||||
editable: false,
|
||||
theme: 'light', // Same limitation as the integration block: rendering is server-side.
|
||||
},
|
||||
};
|
||||
|
||||
const result = await renderIntegrationUi({ renderContext, request: input });
|
||||
// Anything but a modal has no place to go here: a `block` would render loose next to
|
||||
// the button, and a `complete` means the integration handled the click on its own.
|
||||
if (result.output?.type === 'element' && result.output.element.type === 'modal') {
|
||||
setModal({ input, output: result.output, children: result.children });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button {...buttonProps} disabled={loading} onClick={onClick} />
|
||||
{modal ? (
|
||||
<ContentKit
|
||||
renderContext={renderContext}
|
||||
security={security}
|
||||
initialInput={modal.input}
|
||||
initialOutput={modal.output}
|
||||
render={renderIntegrationUi}
|
||||
onAction={(action) => {
|
||||
if (action.action === '@ui.modal.close') {
|
||||
setModal(null);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setModal(null)}
|
||||
>
|
||||
{modal.children}
|
||||
</ContentKit>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { getTableCellMerge } from './cellMerges';
|
||||
import { getColumnWidth, getViewGridLayout, hasVisibleHeader } from './layout';
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import { StickyViewGrid } from './StickyViewGrid';
|
||||
import type { TableGridViewProps } from './Table';
|
||||
import { TableHoverTable } from './TableHoverTable';
|
||||
import { TableSearchTableBody } from './TableSearch';
|
||||
import { type VerticalAlignment, getColumnAlignment, getColumnVerticalAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/** Semantic table renderer used when a cell spans multiple records. */
|
||||
export function NativeViewGrid(props: TableGridViewProps) {
|
||||
const { block, view, context, style } = props;
|
||||
const { tableWidth } = getViewGridLayout({
|
||||
block,
|
||||
view,
|
||||
mode: context.mode,
|
||||
});
|
||||
const tableContainerClassName = tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
|
||||
const withHeader = hasVisibleHeader(block, view);
|
||||
const withStickyHeader = withHeader && context.mode !== 'print' && view.stickyHeader === true;
|
||||
const withStickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
|
||||
const header = withHeader ? (
|
||||
<NativeViewGridHeader
|
||||
{...props}
|
||||
stickyHeader={withStickyHeader}
|
||||
stickyFirstColumn={withStickyFirstColumn}
|
||||
className={tcls(
|
||||
withStickyHeader
|
||||
? [
|
||||
'mb-0 border-t border-r border-l',
|
||||
'group-data-[scrollable=false]/table:mb-1',
|
||||
'group-data-[scrollable=false]/table:rounded-b-lg',
|
||||
'group-data-[scrollable=true]/table:border-t-0',
|
||||
'group-data-[scrollable=true]/table:border-x-0',
|
||||
]
|
||||
: undefined
|
||||
)}
|
||||
/>
|
||||
) : undefined;
|
||||
const body = (
|
||||
<NativeViewGridBody
|
||||
{...props}
|
||||
withHeader={withHeader}
|
||||
stickyFirstColumn={withStickyFirstColumn}
|
||||
/>
|
||||
);
|
||||
|
||||
if (withStickyHeader || withStickyFirstColumn) {
|
||||
return (
|
||||
<StickyViewGrid
|
||||
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
|
||||
stickyHeader={withStickyHeader}
|
||||
tableClassName={tableContainerClassName}
|
||||
withTableRole={false}
|
||||
header={header}
|
||||
>
|
||||
{body}
|
||||
</StickyViewGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
<div className={tcls('flex flex-col', tableContainerClassName)}>
|
||||
{header}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridHeader(
|
||||
props: TableGridViewProps & {
|
||||
stickyHeader: boolean;
|
||||
stickyFirstColumn: boolean;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const { block, view, stickyHeader, stickyFirstColumn, className } = props;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={tcls(
|
||||
'mb-1 rounded-lg border border-tint-subtle bg-tint',
|
||||
stickyHeader || !stickyFirstColumn ? 'overflow-hidden' : undefined,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<table className="w-full table-fixed border-separate border-spacing-0">
|
||||
<NativeViewGridColumns {...props} />
|
||||
<thead>
|
||||
<tr>
|
||||
{view.columns.map((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isStickyFirstColumn =
|
||||
stickyFirstColumn && column === firstVisibleColumn;
|
||||
return (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
className={tcls(
|
||||
'relative px-3 py-2 font-medium text-sm text-tint-strong',
|
||||
isStickyFirstColumn
|
||||
? stickyHeader
|
||||
? 'z-20 bg-tint'
|
||||
: 'sticky left-0 z-20 bg-tint'
|
||||
: undefined,
|
||||
getColumnAlignment(definition)
|
||||
)}
|
||||
style={{
|
||||
left:
|
||||
stickyHeader && isStickyFirstColumn
|
||||
? 'calc(-1 * var(--table-sticky-scroll-left, 0px))'
|
||||
: undefined,
|
||||
}}
|
||||
title={definition.title}
|
||||
>
|
||||
{definition.title}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridBody(
|
||||
props: TableGridViewProps & {
|
||||
withHeader: boolean;
|
||||
stickyFirstColumn: boolean;
|
||||
}
|
||||
) {
|
||||
const { block, view, records, withHeader, stickyFirstColumn, cellMergeLayout } = props;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
const lastVisibleColumn = view.columns.at(-1);
|
||||
const recordsById = new Map(records.map((record) => [record[0], record] as const));
|
||||
const recordIndexes = new Map(records.map((record, index) => [record[0], index] as const));
|
||||
|
||||
return (
|
||||
<TableHoverTable
|
||||
className={tcls(
|
||||
'w-full table-fixed border-separate border-spacing-0',
|
||||
'[&>tbody>tr>td[data-table-hovered]]:bg-tint-hover',
|
||||
'[&>tbody>tr+tr>td]:border-t',
|
||||
'[&>tbody+tbody>tr:first-child>td]:border-t'
|
||||
)}
|
||||
>
|
||||
<NativeViewGridColumns {...props} />
|
||||
{withHeader ? (
|
||||
<thead className="sr-only">
|
||||
<tr>
|
||||
{view.columns.map((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
return definition ? (
|
||||
<th key={column} scope="col">
|
||||
{definition.title}
|
||||
</th>
|
||||
) : null;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
) : null}
|
||||
{cellMergeLayout.recordGroups.map((recordGroup) => (
|
||||
<TableSearchTableBody key={recordGroup[0]} recordIds={recordGroup}>
|
||||
{recordGroup.map((recordId) => {
|
||||
const record = recordsById.get(recordId);
|
||||
const recordIndex = recordIndexes.get(recordId);
|
||||
if (!record || recordIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={recordId}>
|
||||
{view.columns.map((column) => {
|
||||
const cellMerge = getTableCellMerge(
|
||||
cellMergeLayout,
|
||||
recordId,
|
||||
column
|
||||
);
|
||||
if (cellMerge && !cellMerge.isAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = block.data.definition[column];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verticalAlignment =
|
||||
getColumnVerticalAlignment(definition);
|
||||
const renderedColumns = cellMerge?.merge.columns ?? [column];
|
||||
const isLastColumn =
|
||||
renderedColumns.at(-1) === lastVisibleColumn;
|
||||
const isStickyFirstColumn =
|
||||
stickyFirstColumn && column === firstVisibleColumn;
|
||||
const rowSpan =
|
||||
cellMerge && cellMerge.merge.rowSpan > 1
|
||||
? cellMerge.merge.rowSpan
|
||||
: undefined;
|
||||
const colSpan =
|
||||
cellMerge && cellMerge.merge.colSpan > 1
|
||||
? cellMerge.merge.colSpan
|
||||
: undefined;
|
||||
return (
|
||||
<td
|
||||
key={column}
|
||||
rowSpan={rowSpan}
|
||||
colSpan={colSpan}
|
||||
aria-rowspan={rowSpan}
|
||||
aria-colspan={colSpan}
|
||||
data-table-row-start={recordIndex}
|
||||
data-table-row-end={
|
||||
recordIndex + (cellMerge?.merge.rowSpan ?? 1) - 1
|
||||
}
|
||||
className={tcls(
|
||||
'relative px-3 py-2 text-sm transition-colors',
|
||||
!isLastColumn
|
||||
? 'border-tint-subtle border-r'
|
||||
: undefined,
|
||||
getNativeCellVerticalAlignment(verticalAlignment),
|
||||
isStickyFirstColumn
|
||||
? 'sticky left-0 z-10 bg-tint-base'
|
||||
: undefined
|
||||
)}
|
||||
>
|
||||
<RecordColumnValue
|
||||
{...props}
|
||||
record={record}
|
||||
column={column}
|
||||
verticalAlignment={verticalAlignment}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableSearchTableBody>
|
||||
))}
|
||||
</TableHoverTable>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridColumns(props: TableGridViewProps) {
|
||||
const { block, view, context } = props;
|
||||
const { columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
|
||||
block,
|
||||
view,
|
||||
mode: context.mode,
|
||||
});
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{view.columns.map((column) => (
|
||||
<col
|
||||
key={column}
|
||||
style={{
|
||||
width: getColumnWidth({
|
||||
column,
|
||||
columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
function getNativeCellVerticalAlignment(verticalAlignment: VerticalAlignment) {
|
||||
switch (verticalAlignment) {
|
||||
case 'self-start':
|
||||
return 'align-top';
|
||||
case 'self-end':
|
||||
return 'align-bottom';
|
||||
case 'self-center':
|
||||
return 'align-middle';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { getMergedCellWidth, getTableCellMerge } from './cellMerges';
|
||||
import type { DocumentTableViewGrid } from '@gitbook/api';
|
||||
|
||||
import { getColumnWidth } from './layout';
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import type { TableGridViewProps, TableRecordKV } from './Table';
|
||||
import type { TableRecordKV, TableViewProps } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
import { getColumnVerticalAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function RecordRow(
|
||||
props: TableGridViewProps & {
|
||||
props: TableViewProps<DocumentTableViewGrid> & {
|
||||
record: TableRecordKV;
|
||||
autoSizedColumns: string[];
|
||||
fixedColumns: string[];
|
||||
}
|
||||
) {
|
||||
const { view, record, autoSizedColumns, fixedColumns, block, context, cellMergeLayout } = props;
|
||||
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
|
||||
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
|
||||
@@ -30,22 +31,12 @@ export function RecordRow(
|
||||
)}
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
|
||||
if (cellMerge && !cellMerge.isAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedColumns = cellMerge?.merge.columns ?? [column];
|
||||
const columnWidth = getMergedCellWidth(
|
||||
renderedColumns.map((mergedColumn) =>
|
||||
getColumnWidth({
|
||||
column: mergedColumn,
|
||||
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
})
|
||||
)
|
||||
);
|
||||
const columnWidth = getColumnWidth({
|
||||
column,
|
||||
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
});
|
||||
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
|
||||
// @ts-expect-error
|
||||
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
|
||||
@@ -54,7 +45,6 @@ export function RecordRow(
|
||||
<div
|
||||
key={column}
|
||||
role="cell"
|
||||
aria-colspan={cellMerge?.merge.colSpan}
|
||||
className={tcls(
|
||||
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
|
||||
'border-tint-subtle',
|
||||
|
||||
@@ -9,7 +9,6 @@ interface StickyViewGridProps {
|
||||
header?: ReactNode;
|
||||
stickyHeader?: boolean;
|
||||
tableClassName?: string;
|
||||
withTableRole?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -24,7 +23,6 @@ function DefaultHeaderScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
withTableRole = true,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const resolvedTableClassName = tableClassName ?? 'w-fit';
|
||||
@@ -32,7 +30,7 @@ function DefaultHeaderScrollGrid({
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
role={withTableRole ? 'table' : undefined}
|
||||
role="table"
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle"
|
||||
>
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
@@ -50,7 +48,6 @@ function StickyHeaderOverlayScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
withTableRole = true,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -144,7 +141,7 @@ function StickyHeaderOverlayScrollGrid({
|
||||
ref={rootRef}
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
|
||||
data-scrollable="false"
|
||||
role={withTableRole ? 'table' : undefined}
|
||||
role="table"
|
||||
>
|
||||
{header ? (
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
|
||||
import type { DocumentBlockTable } from '@gitbook/api';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { isBlockOffscreen } from '../utils';
|
||||
import { type TableCellMergeLayout, createTableCellMergeLayout } from './cellMerges';
|
||||
import { getViewGridLayout, hasVisibleHeader } from './layout';
|
||||
import { NativeViewGrid } from './NativeViewGrid';
|
||||
import {
|
||||
type TableRecordKV,
|
||||
getTableCheckboxColumns,
|
||||
@@ -28,10 +26,6 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
|
||||
isOffscreen: boolean;
|
||||
}
|
||||
|
||||
export interface TableGridViewProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
cellMergeLayout: TableCellMergeLayout;
|
||||
}
|
||||
|
||||
export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const { block, ancestorBlocks, document, context, style } = props;
|
||||
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
|
||||
@@ -51,18 +45,9 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const searchRecords = showSearch
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const cellMergeLayout = createTableCellMergeLayout(
|
||||
block,
|
||||
records.map(([recordId]) => recordId)
|
||||
);
|
||||
|
||||
return (
|
||||
<TableSearchProvider
|
||||
records={searchRecords}
|
||||
recordGroups={
|
||||
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
|
||||
}
|
||||
>
|
||||
<TableSearchProvider records={searchRecords}>
|
||||
<div className={tcls(style, 'flex flex-col gap-3')}>
|
||||
{showSearch ? (
|
||||
<TableSearchInput
|
||||
@@ -70,12 +55,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
checkboxColumns={getTableCheckboxColumns(block)}
|
||||
/>
|
||||
) : null}
|
||||
<TableView
|
||||
{...props}
|
||||
isOffscreen={isOffscreen}
|
||||
records={records}
|
||||
cellMergeLayout={cellMergeLayout}
|
||||
/>
|
||||
<TableView {...props} isOffscreen={isOffscreen} records={records} />
|
||||
<TableSearchEmpty />
|
||||
</div>
|
||||
</TableSearchProvider>
|
||||
@@ -88,13 +68,8 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
function TableView({
|
||||
isOffscreen,
|
||||
records,
|
||||
cellMergeLayout,
|
||||
...props
|
||||
}: BlockProps<DocumentBlockTable> & {
|
||||
isOffscreen: boolean;
|
||||
records: TableRecordKV[];
|
||||
cellMergeLayout: TableCellMergeLayout;
|
||||
}) {
|
||||
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
switch (block.data.view.type) {
|
||||
@@ -113,7 +88,6 @@ function TableView({
|
||||
view: block.data.view,
|
||||
isOffscreen,
|
||||
records,
|
||||
cellMergeLayout,
|
||||
};
|
||||
const { tableWidth } = getViewGridLayout({
|
||||
block,
|
||||
@@ -128,10 +102,6 @@ function TableView({
|
||||
const withStickyFirstColumn =
|
||||
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
|
||||
|
||||
if (cellMergeLayout.hasVerticalMerges) {
|
||||
return <NativeViewGrid {...gridProps} />;
|
||||
}
|
||||
|
||||
if (withStickyHeader || withStickyFirstColumn) {
|
||||
return (
|
||||
<StickyViewGrid
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { type TableRowRange, tableRowRangesIntersect } from './TableHoverTable';
|
||||
|
||||
const CELL_RANGES = {
|
||||
A: { rowStart: 0, rowEnd: 1 },
|
||||
B: { rowStart: 0, rowEnd: 0 },
|
||||
C: { rowStart: 0, rowEnd: 0 },
|
||||
D: { rowStart: 1, rowEnd: 1 },
|
||||
E: { rowStart: 1, rowEnd: 2 },
|
||||
F: { rowStart: 2, rowEnd: 2 },
|
||||
G: { rowStart: 2, rowEnd: 2 },
|
||||
} satisfies Record<string, TableRowRange>;
|
||||
|
||||
function getHighlightedCells(hoveredCell: keyof typeof CELL_RANGES) {
|
||||
const hoveredRange = CELL_RANGES[hoveredCell];
|
||||
return Object.entries(CELL_RANGES)
|
||||
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, hoveredRange))
|
||||
.map(([cell]) => cell);
|
||||
}
|
||||
|
||||
describe('tableRowRangesIntersect', () => {
|
||||
it.each([
|
||||
['A', ['A', 'B', 'C', 'D', 'E']],
|
||||
['B', ['A', 'B', 'C']],
|
||||
['D', ['A', 'D', 'E']],
|
||||
['E', ['A', 'D', 'E', 'F', 'G']],
|
||||
] as const)(
|
||||
'highlights cells intersecting %s without transitive expansion',
|
||||
(cell, expected) => {
|
||||
expect(getHighlightedCells(cell)).toEqual([...expected]);
|
||||
}
|
||||
);
|
||||
|
||||
it('treats a horizontal merge as a single-row range', () => {
|
||||
const horizontalMerge = { rowStart: 1, rowEnd: 1 };
|
||||
|
||||
expect(
|
||||
Object.entries(CELL_RANGES)
|
||||
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, horizontalMerge))
|
||||
.map(([cell]) => cell)
|
||||
).toEqual(['A', 'D', 'E']);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, useRef } from 'react';
|
||||
|
||||
const TABLE_CELL_SELECTOR = 'td[data-table-row-start][data-table-row-end]';
|
||||
|
||||
export interface TableRowRange {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
}
|
||||
|
||||
export function tableRowRangesIntersect(left: TableRowRange, right: TableRowRange) {
|
||||
return left.rowStart <= right.rowEnd && right.rowStart <= left.rowEnd;
|
||||
}
|
||||
|
||||
/** Applies hover feedback to cells intersecting the hovered cell's original row span. */
|
||||
export function TableHoverTable(props: ComponentPropsWithoutRef<'table'>) {
|
||||
const { onMouseOver, onMouseLeave, ...rest } = props;
|
||||
const hoveredCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||
const highlightedCellsRef = useRef<Set<HTMLTableCellElement>>(new Set());
|
||||
|
||||
const clearHighlightedCells = () => {
|
||||
for (const cell of highlightedCellsRef.current) {
|
||||
cell.removeAttribute('data-table-hovered');
|
||||
}
|
||||
highlightedCellsRef.current.clear();
|
||||
hoveredCellRef.current = null;
|
||||
};
|
||||
|
||||
const handleMouseOver = (event: ReactMouseEvent<HTMLTableElement>) => {
|
||||
onMouseOver?.(event);
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredCell = target.closest<HTMLTableCellElement>(TABLE_CELL_SELECTOR);
|
||||
if (!hoveredCell || !event.currentTarget.contains(hoveredCell)) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
if (hoveredCellRef.current === hoveredCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredRange = getTableCellRowRange(hoveredCell);
|
||||
if (!hoveredRange) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightedCells = new Set<HTMLTableCellElement>();
|
||||
for (const cell of event.currentTarget.querySelectorAll<HTMLTableCellElement>(
|
||||
TABLE_CELL_SELECTOR
|
||||
)) {
|
||||
const cellRange = getTableCellRowRange(cell);
|
||||
const highlighted =
|
||||
cellRange !== null && tableRowRangesIntersect(cellRange, hoveredRange);
|
||||
cell.toggleAttribute('data-table-hovered', highlighted);
|
||||
if (highlighted) {
|
||||
highlightedCells.add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cell of highlightedCellsRef.current) {
|
||||
if (!highlightedCells.has(cell)) {
|
||||
cell.removeAttribute('data-table-hovered');
|
||||
}
|
||||
}
|
||||
|
||||
hoveredCellRef.current = hoveredCell;
|
||||
highlightedCellsRef.current = highlightedCells;
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: ReactMouseEvent<HTMLTableElement>) => {
|
||||
onMouseLeave?.(event);
|
||||
clearHighlightedCells();
|
||||
};
|
||||
|
||||
return <table {...rest} onMouseOver={handleMouseOver} onMouseLeave={handleMouseLeave} />;
|
||||
}
|
||||
|
||||
function getTableCellRowRange(cell: HTMLTableCellElement): TableRowRange | null {
|
||||
const rowStart = Number(cell.dataset.tableRowStart);
|
||||
const rowEnd = Number(cell.dataset.tableRowEnd);
|
||||
if (!Number.isInteger(rowStart) || !Number.isInteger(rowEnd) || rowEnd < rowStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { rowStart, rowEnd };
|
||||
}
|
||||
@@ -5,11 +5,7 @@ import React from 'react';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import type { TableCheckboxColumn, TableSelectColumn } from './search';
|
||||
import {
|
||||
type SelectedOptions,
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
} from './searchMatch';
|
||||
import { type SelectedOptions, recordMatches } from './searchMatch';
|
||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -22,6 +18,15 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
* once and exposes the set of visible ids; each row/card just looks itself up by id.
|
||||
*/
|
||||
|
||||
/** Per-record matching data, computed on the server. */
|
||||
export interface TableSearchRecordData {
|
||||
/** Record key, matching the `key` passed to `<TableSearchRecord>`. */
|
||||
id: string;
|
||||
searchText: string;
|
||||
selectValues?: Record<string, string[]>;
|
||||
checkboxValues?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
type TableSearchContextValue = {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
@@ -46,10 +51,9 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
|
||||
*/
|
||||
export function TableSearchProvider(props: {
|
||||
records?: TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { records = [], recordGroups = [] } = props;
|
||||
const { records = [] } = props;
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
|
||||
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
|
||||
@@ -87,18 +91,32 @@ export function TableSearchProvider(props: {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
|
||||
|
||||
// Match every record once, here, rather than in each row — rows just look themselves up by id.
|
||||
const visibleIds = React.useMemo(
|
||||
() =>
|
||||
getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups,
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns,
|
||||
}),
|
||||
[records, recordGroups, query, selectedOptions, checkedColumns]
|
||||
);
|
||||
const visibleIds = React.useMemo(() => {
|
||||
if (!hasActiveFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (
|
||||
recordMatches(
|
||||
record.searchText,
|
||||
record.selectValues,
|
||||
record.checkboxValues,
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns
|
||||
)
|
||||
) {
|
||||
ids.add(record.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [records, query, selectedOptions, checkedColumns, hasActiveFilters]);
|
||||
|
||||
const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
|
||||
|
||||
@@ -292,13 +310,3 @@ export function TableSearchRecord(props: TableSearchRecordProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps a complete native table row group visible when any of its records matches. */
|
||||
export function TableSearchTableBody(
|
||||
props: React.HTMLAttributes<HTMLTableSectionElement> & { recordIds: readonly string[] }
|
||||
) {
|
||||
const { recordIds, children, ...rest } = props;
|
||||
const { visibleIds } = useTableSearch();
|
||||
const matches = visibleIds === null || recordIds.some((recordId) => visibleIds.has(recordId));
|
||||
return matches ? <tbody {...rest}>{children}</tbody> : null;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IconsProvider } from '@gitbook/icons';
|
||||
|
||||
import { TranslateContext } from '@/intl/client';
|
||||
import { en } from '@/intl/translations/en';
|
||||
|
||||
mock.module('./RecordCard', () => ({
|
||||
RecordCard: () => <div data-testid="record-card" />,
|
||||
}));
|
||||
|
||||
mock.module('./TableSearch', () => ({
|
||||
TableSearchRecord: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const { ViewCards } = await import('./ViewCards');
|
||||
|
||||
function makeProps(wrap: boolean) {
|
||||
return {
|
||||
block: { data: { fullWidth: false } },
|
||||
view: { type: 'cards', wrap, cardSize: 'medium' },
|
||||
records: [
|
||||
['record-1', {}],
|
||||
['record-2', {}],
|
||||
['record-3', {}],
|
||||
] as any,
|
||||
context: { mode: 'default' },
|
||||
style: undefined,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function renderWithContext(children: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<IconsProvider assetsURL="https://icons.example.com">
|
||||
<TranslateContext.Provider value={en}>{children}</TranslateContext.Provider>
|
||||
</IconsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewCards', () => {
|
||||
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
|
||||
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
|
||||
|
||||
expect(markup).toContain('-mx-12');
|
||||
expect(markup).toContain('px-12');
|
||||
expect(markup).toContain('scroll-px-12');
|
||||
expect(markup).toContain('left-0');
|
||||
expect(markup).toContain('ml-8');
|
||||
expect(markup).toContain('right-0');
|
||||
expect(markup).toContain('mr-8');
|
||||
expect(markup).not.toContain('before:bg-linear-to-r');
|
||||
expect(markup).not.toContain('after:bg-linear-to-l');
|
||||
expect(markup).toContain('snap-mandatory');
|
||||
});
|
||||
|
||||
it('renders the wrapping grid by default', () => {
|
||||
const markup = renderWithContext(<ViewCards {...makeProps(true)} />);
|
||||
|
||||
expect(markup).toContain('inline-grid');
|
||||
});
|
||||
});
|
||||
@@ -53,9 +53,11 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
*
|
||||
* The row extends by a small, symmetric peek on each side. The matching padding keeps
|
||||
* the first and last cards aligned with the content column while the edge masks fade only
|
||||
* the cards in that peek area.
|
||||
* Rather than fading the edges, the row breaks out of the content column so it can
|
||||
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
|
||||
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
|
||||
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
|
||||
* See `bleedVars` below for how far each side reaches.
|
||||
*/
|
||||
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { view, records } = props;
|
||||
@@ -67,10 +69,65 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
|
||||
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
|
||||
|
||||
// Break the row out of the content column so it bleeds to the page edges instead of
|
||||
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
|
||||
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
|
||||
// scroller), so the first/last cards stay aligned with the body text at rest while cards
|
||||
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
|
||||
//
|
||||
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
|
||||
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
|
||||
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
|
||||
// row past where every other block ends, jutting into the window frame.
|
||||
//
|
||||
// On the default layout:
|
||||
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
|
||||
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
|
||||
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
|
||||
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
|
||||
// `html` clips horizontal overflow, so a small overshoot is harmless.
|
||||
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
|
||||
// cards never slide under it.
|
||||
const bleedVars = tcls(
|
||||
'[--cards-bleed-l:1rem]',
|
||||
'sm:[--cards-bleed-l:1.5rem]',
|
||||
'md:[--cards-bleed-l:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
|
||||
'xl:[--cards-bleed-l:3rem]',
|
||||
|
||||
'[--cards-bleed-r:1rem]',
|
||||
'sm:[--cards-bleed-r:1.5rem]',
|
||||
'md:[--cards-bleed-r:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
|
||||
'layout-default:xl:[--cards-bleed-r:3rem]',
|
||||
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
|
||||
// Default centered
|
||||
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
|
||||
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
|
||||
|
||||
// Full width, no outline
|
||||
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
|
||||
|
||||
// Full width centered
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls('-mx-12', 'hover:z-11')}
|
||||
className={tcls(
|
||||
bleedVars,
|
||||
'ml-[calc(var(--cards-bleed-l)*-1)]',
|
||||
'mr-[calc(var(--cards-bleed-r)*-1)]',
|
||||
'xl:transition-[margin]',
|
||||
'hover:z-11'
|
||||
)}
|
||||
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
|
||||
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
|
||||
contentClassName={tcls(
|
||||
@@ -79,19 +136,21 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'px-12',
|
||||
'scroll-px-12',
|
||||
'pl-[var(--cards-bleed-l)]',
|
||||
'pr-[var(--cards-bleed-r)]',
|
||||
'scroll-pl-[var(--cards-bleed-l)]',
|
||||
'scroll-pr-[var(--cards-bleed-r)]',
|
||||
'snap-x',
|
||||
'snap-mandatory'
|
||||
'snap-mandatory',
|
||||
'xl:transition-[padding]'
|
||||
)}
|
||||
scrollByVisibleItems
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-8' },
|
||||
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-8' },
|
||||
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { DocumentTableViewGrid } from '@gitbook/api';
|
||||
|
||||
import { getColumnWidth, getViewGridLayout } from './layout';
|
||||
import { RecordRow } from './RecordRow';
|
||||
import type { TableGridViewProps } from './Table';
|
||||
import type { TableViewProps } from './Table';
|
||||
import { getColumnAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
interface ViewGridHeaderProps extends TableGridViewProps {
|
||||
interface ViewGridHeaderProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
className?: string;
|
||||
tableClassName?: string;
|
||||
}
|
||||
|
||||
interface ViewGridProps extends TableGridViewProps {
|
||||
interface ViewGridProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
tableClassName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { DocumentBlockTable, DocumentTableDefinition } from '@gitbook/api';
|
||||
|
||||
import { createTableCellMergeLayout, getMergedCellWidth, getTableCellMerge } from './cellMerges';
|
||||
|
||||
const RECORD_ORDER = ['first', 'second', 'third'];
|
||||
const COLUMN_ORDER = ['a', 'b', 'c'];
|
||||
|
||||
function createBlock(
|
||||
cellMerges?: unknown[],
|
||||
options: {
|
||||
columns?: string[];
|
||||
definitions?: Record<string, DocumentTableDefinition>;
|
||||
recordOrder?: string[];
|
||||
viewType?: 'grid' | 'cards';
|
||||
} = {}
|
||||
) {
|
||||
const columns = options.columns ?? COLUMN_ORDER;
|
||||
const recordOrder = options.recordOrder ?? RECORD_ORDER;
|
||||
const definitions =
|
||||
options.definitions ??
|
||||
Object.fromEntries(columns.map((column) => [column, createDefinition('text', column)]));
|
||||
return {
|
||||
data: {
|
||||
view:
|
||||
options.viewType === 'cards'
|
||||
? { type: 'cards', columns }
|
||||
: { type: 'grid', columns },
|
||||
records: Object.fromEntries(
|
||||
recordOrder.map((recordId, index) => [
|
||||
recordId,
|
||||
{ orderIndex: `${index}`, values: {} },
|
||||
])
|
||||
),
|
||||
definition: definitions,
|
||||
...(cellMerges ? { cellMerges } : {}),
|
||||
},
|
||||
} as unknown as DocumentBlockTable;
|
||||
}
|
||||
|
||||
function createDefinition(type: DocumentTableDefinition['type'], id: string) {
|
||||
const base = { id, title: id };
|
||||
return type === 'text'
|
||||
? ({ ...base, type, textAlignment: 'left' } as DocumentTableDefinition)
|
||||
: ({ ...base, type } as DocumentTableDefinition);
|
||||
}
|
||||
|
||||
describe('createTableCellMergeLayout', () => {
|
||||
it('classifies horizontal and vertical Text and Number merges', () => {
|
||||
for (const type of ['text', 'number'] as const) {
|
||||
const layout = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2), verticalMerge('second', 'c', 2)], {
|
||||
definitions: Object.fromEntries(
|
||||
COLUMN_ORDER.map((column) => [column, createDefinition(type, column)])
|
||||
),
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(getTableCellMerge(layout, 'first', 'a')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { rowSpan: 1, colSpan: 2, columns: ['a', 'b'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'first', 'b')).toMatchObject({ isAnchor: false });
|
||||
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { rowSpan: 2, colSpan: 1, records: ['second', 'third'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
|
||||
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
|
||||
expect(layout.hasVerticalMerges).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats missing metadata and Cards as unmerged layouts', () => {
|
||||
const missing = createTableCellMergeLayout(createBlock(), RECORD_ORDER);
|
||||
const cards = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
viewType: 'cards',
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(missing.cells.size).toBe(0);
|
||||
expect(missing.recordGroups).toEqual([['first'], ['second'], ['third']]);
|
||||
expect(missing.hasVerticalMerges).toBe(false);
|
||||
expect(cards.cells.size).toBe(0);
|
||||
expect(cards.recordGroups).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid shape', { anchor: 'first', rowSpan: 1, colSpan: 2 }],
|
||||
['single cell', { anchor: { record: 'first', column: 'a' }, rowSpan: 1, colSpan: 1 }],
|
||||
['unknown record', horizontalMerge('missing', 'a', 2)],
|
||||
['hidden column', horizontalMerge('first', 'hidden', 2)],
|
||||
['out-of-bounds span', horizontalMerge('first', 'c', 2)],
|
||||
['rectangle', { anchor: { record: 'first', column: 'a' }, rowSpan: 2, colSpan: 2 }],
|
||||
])('ignores malformed metadata: %s', (_name, merge) => {
|
||||
const block = createBlock([merge], {
|
||||
definitions: {
|
||||
a: createDefinition('text', 'a'),
|
||||
b: createDefinition('text', 'b'),
|
||||
c: createDefinition('text', 'c'),
|
||||
hidden: createDefinition('text', 'hidden'),
|
||||
},
|
||||
});
|
||||
const layout = createTableCellMergeLayout(block, RECORD_ORDER);
|
||||
|
||||
expect(layout.cells.size).toBe(0);
|
||||
expect(layout.hasVerticalMerges).toBe(false);
|
||||
});
|
||||
|
||||
it('drops every conflicting merge regardless of order while preserving unrelated merges', () => {
|
||||
const horizontal = horizontalMerge('first', 'a', 2);
|
||||
const vertical = verticalMerge('first', 'b', 2);
|
||||
const duplicate = horizontalMerge('first', 'a', 2);
|
||||
const unrelated = verticalMerge('second', 'c', 2);
|
||||
|
||||
for (const cellMerges of [
|
||||
[horizontal, vertical, duplicate, unrelated],
|
||||
[vertical, duplicate, horizontal, unrelated],
|
||||
]) {
|
||||
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
|
||||
|
||||
expect(getTableCellMerge(layout, 'first', 'a')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'first', 'b')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'second', 'b')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { records: ['second', 'third'], columns: ['c'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
|
||||
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
|
||||
}
|
||||
});
|
||||
|
||||
it('partitions disjoint vertical merges and singleton records in record order', () => {
|
||||
const recordOrder = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'];
|
||||
const layout = createTableCellMergeLayout(
|
||||
createBlock([verticalMerge('fourth', 'b', 2), verticalMerge('first', 'a', 2)], {
|
||||
recordOrder,
|
||||
}),
|
||||
recordOrder
|
||||
);
|
||||
|
||||
expect(layout.recordGroups).toEqual([
|
||||
['first', 'second'],
|
||||
['third'],
|
||||
['fourth', 'fifth'],
|
||||
['sixth'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('combines transitively connected vertical merges regardless of metadata order', () => {
|
||||
for (const cellMerges of [
|
||||
[verticalMerge('first', 'a', 2), verticalMerge('second', 'b', 2)],
|
||||
[verticalMerge('second', 'b', 2), verticalMerge('first', 'a', 2)],
|
||||
]) {
|
||||
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
|
||||
|
||||
expect(layout.recordGroups).toEqual([['first', 'second', 'third']]);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores merges for unsupported or mixed field types', () => {
|
||||
const unsupportedDefinitions = {
|
||||
a: createDefinition('checkbox', 'a'),
|
||||
b: createDefinition('checkbox', 'b'),
|
||||
};
|
||||
const unsupportedHorizontal = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: unsupportedDefinitions,
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
const unsupportedVertical = createTableCellMergeLayout(
|
||||
createBlock([verticalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: unsupportedDefinitions,
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
const mixed = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: {
|
||||
a: createDefinition('text', 'a'),
|
||||
b: createDefinition('number', 'b'),
|
||||
},
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(unsupportedHorizontal.cells.size).toBe(0);
|
||||
expect(unsupportedVertical.cells.size).toBe(0);
|
||||
expect(unsupportedVertical.hasVerticalMerges).toBe(false);
|
||||
expect(mixed.cells.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function horizontalMerge(record: string, column: string, colSpan: number) {
|
||||
return { anchor: { record, column }, rowSpan: 1, colSpan };
|
||||
}
|
||||
|
||||
function verticalMerge(record: string, column: string, rowSpan: number) {
|
||||
return { anchor: { record, column }, rowSpan, colSpan: 1 };
|
||||
}
|
||||
|
||||
describe('getMergedCellWidth', () => {
|
||||
it('adds fixed and automatic column widths in CSS', () => {
|
||||
expect(getMergedCellWidth(['120px', 'clamp(100px, calc(100% / 2), 100%)'])).toBe(
|
||||
'calc(120px + clamp(100px, calc(100% / 2), 100%))'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import type { DocumentBlockTable } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Temporary compatibility type until the published `@gitbook/api` includes `cellMerges`.
|
||||
*/
|
||||
export interface TableCellMerge {
|
||||
anchor: {
|
||||
record: string;
|
||||
column: string;
|
||||
};
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type TableDataWithCellMerges = DocumentBlockTable['data'] & {
|
||||
cellMerges?: TableCellMerge[];
|
||||
};
|
||||
|
||||
export interface ResolvedTableCellMerge {
|
||||
records: readonly string[];
|
||||
columns: readonly string[];
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
export interface TableCellMergeSlot {
|
||||
merge: ResolvedTableCellMerge;
|
||||
isAnchor: boolean;
|
||||
}
|
||||
|
||||
export interface TableCellMergeLayout {
|
||||
cells: ReadonlyMap<string, ReadonlyMap<string, TableCellMergeSlot>>;
|
||||
recordGroups: readonly (readonly string[])[];
|
||||
hasVerticalMerges: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve valid merge metadata once for a rendered grid.
|
||||
*
|
||||
* Invalid external metadata is ignored before any cell can be classified as covered, ensuring a
|
||||
* malformed merge always degrades to ordinary cells instead of hiding content.
|
||||
*/
|
||||
export function createTableCellMergeLayout(
|
||||
block: DocumentBlockTable,
|
||||
recordOrder: readonly string[]
|
||||
): TableCellMergeLayout {
|
||||
if (block.data.view.type !== 'grid') {
|
||||
return {
|
||||
cells: new Map(),
|
||||
recordGroups: [],
|
||||
hasVerticalMerges: false,
|
||||
};
|
||||
}
|
||||
|
||||
const columnOrder = block.data.view.columns;
|
||||
const candidates: { merge: ResolvedTableCellMerge; cellKeys: string[] }[] = [];
|
||||
const cells = new Map<string, Map<string, TableCellMergeSlot>>();
|
||||
const verticalRecordGroups: string[][] = [];
|
||||
|
||||
for (const candidate of getRawTableCellMerges(block)) {
|
||||
const merge = resolveTableCellMerge(candidate, block, recordOrder, columnOrder);
|
||||
if (!merge) continue;
|
||||
|
||||
const cellKeys = merge.records.flatMap((recordId) =>
|
||||
merge.columns.map((columnId) => getCellKey(recordId, columnId))
|
||||
);
|
||||
candidates.push({ merge, cellKeys });
|
||||
}
|
||||
|
||||
const cellOwnerCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
for (const cellKey of candidate.cellKeys) {
|
||||
cellOwnerCounts.set(cellKey, (cellOwnerCounts.get(cellKey) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.cellKeys.some((cellKey) => cellOwnerCounts.get(cellKey) !== 1)) continue;
|
||||
const { merge } = candidate;
|
||||
|
||||
for (const recordId of merge.records) {
|
||||
let row = cells.get(recordId);
|
||||
if (!row) {
|
||||
row = new Map();
|
||||
cells.set(recordId, row);
|
||||
}
|
||||
|
||||
for (const columnId of merge.columns) {
|
||||
row.set(columnId, {
|
||||
merge,
|
||||
isAnchor: recordId === merge.records[0] && columnId === merge.columns[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (merge.rowSpan > 1) {
|
||||
verticalRecordGroups.push([...merge.records]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cells,
|
||||
recordGroups: groupConnectedRecords(recordOrder, verticalRecordGroups),
|
||||
hasVerticalMerges: verticalRecordGroups.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the resolved merge classification for a logical table cell. */
|
||||
export function getTableCellMerge(
|
||||
layout: TableCellMergeLayout,
|
||||
recordId: string,
|
||||
columnId: string
|
||||
): TableCellMergeSlot | undefined {
|
||||
return layout.cells.get(recordId)?.get(columnId);
|
||||
}
|
||||
|
||||
/** Combine the independent column widths used by a horizontal merged cell. */
|
||||
export function getMergedCellWidth(widths: string[]): string {
|
||||
return widths.length === 1 && widths[0] ? widths[0] : `calc(${widths.join(' + ')})`;
|
||||
}
|
||||
|
||||
function getRawTableCellMerges(block: DocumentBlockTable): unknown[] {
|
||||
const data = block.data as TableDataWithCellMerges;
|
||||
return Array.isArray(data.cellMerges) ? data.cellMerges : [];
|
||||
}
|
||||
|
||||
function resolveTableCellMerge(
|
||||
candidate: unknown,
|
||||
block: DocumentBlockTable,
|
||||
recordOrder: readonly string[],
|
||||
columnOrder: readonly string[]
|
||||
): ResolvedTableCellMerge | null {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merge = candidate as {
|
||||
anchor?: { record?: unknown; column?: unknown };
|
||||
rowSpan?: unknown;
|
||||
colSpan?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof merge.anchor?.record !== 'string' ||
|
||||
typeof merge.anchor.column !== 'string' ||
|
||||
typeof merge.rowSpan !== 'number' ||
|
||||
!Number.isInteger(merge.rowSpan) ||
|
||||
merge.rowSpan < 1 ||
|
||||
typeof merge.colSpan !== 'number' ||
|
||||
!Number.isInteger(merge.colSpan) ||
|
||||
merge.colSpan < 1 ||
|
||||
!(
|
||||
(merge.rowSpan === 1 && merge.colSpan >= 2) ||
|
||||
(merge.rowSpan >= 2 && merge.colSpan === 1)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recordStart = recordOrder.indexOf(merge.anchor.record);
|
||||
const columnStart = columnOrder.indexOf(merge.anchor.column);
|
||||
if (recordStart < 0 || columnStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const records = recordOrder.slice(recordStart, recordStart + merge.rowSpan);
|
||||
const columns = columnOrder.slice(columnStart, columnStart + merge.colSpan);
|
||||
const recordsValid =
|
||||
records.length === merge.rowSpan &&
|
||||
records.every((recordId) => Boolean(block.data.records[recordId]));
|
||||
const definitions = columns.map((columnId) => block.data.definition[columnId]);
|
||||
const columnsValid =
|
||||
columns.length === merge.colSpan &&
|
||||
definitions.every(
|
||||
(definition) => definition?.type === 'text' || definition?.type === 'number'
|
||||
) &&
|
||||
definitions.every((definition) => definition?.type === definitions[0]?.type);
|
||||
if (!recordsValid || !columnsValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
records: [...records],
|
||||
columns: [...columns],
|
||||
rowSpan: merge.rowSpan,
|
||||
colSpan: merge.colSpan,
|
||||
};
|
||||
}
|
||||
|
||||
function getCellKey(recordId: string, columnId: string): string {
|
||||
return `${recordId.length}:${recordId}${columnId}`;
|
||||
}
|
||||
|
||||
/** Partition records into ordered groups connected by one or more vertical merges. */
|
||||
function groupConnectedRecords(
|
||||
recordOrder: readonly string[],
|
||||
connectedRecordGroups: readonly (readonly string[])[]
|
||||
): string[][] {
|
||||
const parents = new Map(recordOrder.map((recordId) => [recordId, recordId]));
|
||||
|
||||
const findRoot = (recordId: string): string => {
|
||||
const parent = parents.get(recordId);
|
||||
if (!parent || parent === recordId) {
|
||||
return recordId;
|
||||
}
|
||||
|
||||
const root = findRoot(parent);
|
||||
parents.set(recordId, root);
|
||||
return root;
|
||||
};
|
||||
|
||||
for (const group of connectedRecordGroups) {
|
||||
const firstRecordId = group[0];
|
||||
if (!firstRecordId) continue;
|
||||
|
||||
for (const recordId of group.slice(1)) {
|
||||
parents.set(findRoot(recordId), findRoot(firstRecordId));
|
||||
}
|
||||
}
|
||||
|
||||
const groupedRecords = new Map<string, string[]>();
|
||||
for (const recordId of recordOrder) {
|
||||
const root = findRoot(recordId);
|
||||
const group = groupedRecords.get(root);
|
||||
if (group) {
|
||||
group.push(recordId);
|
||||
} else {
|
||||
groupedRecords.set(root, [recordId]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...groupedRecords.values()];
|
||||
}
|
||||
@@ -1,34 +1,10 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
type SelectedOptions,
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
matchesText,
|
||||
recordMatches,
|
||||
} from './searchMatch';
|
||||
import { type SelectedOptions, matchesText, recordMatches } from './searchMatch';
|
||||
|
||||
const NO_OPTIONS: SelectedOptions = {};
|
||||
const NO_CHECKBOXES: ReadonlySet<string> = new Set();
|
||||
|
||||
function visibleIds(
|
||||
records: TableSearchRecordData[],
|
||||
filters: {
|
||||
query?: string;
|
||||
selectedOptions?: SelectedOptions;
|
||||
checkedColumns?: ReadonlySet<string>;
|
||||
},
|
||||
recordGroups: string[][] = []
|
||||
) {
|
||||
return getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups,
|
||||
query: filters.query ?? '',
|
||||
selectedOptions: filters.selectedOptions ?? NO_OPTIONS,
|
||||
checkedColumns: filters.checkedColumns ?? NO_CHECKBOXES,
|
||||
});
|
||||
}
|
||||
|
||||
function match(
|
||||
record: {
|
||||
searchText?: string;
|
||||
@@ -173,75 +149,3 @@ describe('recordMatches', () => {
|
||||
expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleTableRecordIds', () => {
|
||||
const records: TableSearchRecordData[] = [
|
||||
{
|
||||
id: 'first',
|
||||
searchText: 'Anchor value',
|
||||
selectValues: { status: ['active'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
searchText: 'Covered value',
|
||||
selectValues: { status: ['archived'] },
|
||||
checkboxValues: { featured: true },
|
||||
},
|
||||
{
|
||||
id: 'third',
|
||||
searchText: 'Connected value',
|
||||
selectValues: { status: ['pending'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
{
|
||||
id: 'unrelated',
|
||||
searchText: 'Unrelated value',
|
||||
selectValues: { status: ['archived'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
];
|
||||
const verticalGroup = [['first', 'second']];
|
||||
|
||||
it('returns null when no filter is active', () => {
|
||||
expect(visibleIds(records, {}, verticalGroup)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a complete vertical group when text matches its anchor', () => {
|
||||
expect(visibleIds(records, { query: 'anchor' }, verticalGroup)).toEqual(
|
||||
new Set(['first', 'second'])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a complete vertical group when select or checkbox filters match a grouped row', () => {
|
||||
expect(
|
||||
visibleIds(records, { selectedOptions: { status: new Set(['active']) } }, verticalGroup)
|
||||
).toEqual(new Set(['first', 'second']));
|
||||
expect(
|
||||
visibleIds(records, { checkedColumns: new Set(['featured']) }, verticalGroup)
|
||||
).toEqual(new Set(['second', 'first']));
|
||||
});
|
||||
|
||||
it('excludes completely unmatched and disconnected groups', () => {
|
||||
expect(visibleIds(records, { query: 'unrelated' }, verticalGroup)).toEqual(
|
||||
new Set(['unrelated'])
|
||||
);
|
||||
});
|
||||
|
||||
it('expands transitively connected groups in reversed metadata order without mutation', () => {
|
||||
const groups = [
|
||||
['second', 'third'],
|
||||
['first', 'second'],
|
||||
];
|
||||
const originalGroups = groups.map((group) => [...group]);
|
||||
|
||||
expect(visibleIds(records, { query: 'anchor' }, groups)).toEqual(
|
||||
new Set(['first', 'second', 'third'])
|
||||
);
|
||||
expect(groups).toEqual(originalGroups);
|
||||
});
|
||||
|
||||
it('leaves cards independently filtered when no merge groups are provided', () => {
|
||||
expect(visibleIds(records, { query: 'anchor' })).toEqual(new Set(['first']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,72 +8,6 @@
|
||||
/** Selected option values per select column, keyed by column id. */
|
||||
export type SelectedOptions = Readonly<Record<string, ReadonlySet<string>>>;
|
||||
|
||||
/** Per-record data used by the client-side table search. */
|
||||
export interface TableSearchRecordData {
|
||||
id: string;
|
||||
searchText: string;
|
||||
selectValues?: Record<string, string[]>;
|
||||
checkboxValues?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match records and expand matching vertical merge groups to a fixed point.
|
||||
*/
|
||||
export function getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups = [],
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns,
|
||||
}: {
|
||||
records: readonly TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
query: string;
|
||||
selectedOptions: SelectedOptions;
|
||||
checkedColumns: ReadonlySet<string>;
|
||||
}): ReadonlySet<string> | null {
|
||||
const hasActiveFilters =
|
||||
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
|
||||
if (!hasActiveFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleIds = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (
|
||||
recordMatches(
|
||||
record.searchText,
|
||||
record.selectValues,
|
||||
record.checkboxValues,
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns
|
||||
)
|
||||
) {
|
||||
visibleIds.add(record.id);
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const group of recordGroups) {
|
||||
if (!group.some((id) => visibleIds.has(id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of group) {
|
||||
if (!visibleIds.has(id)) {
|
||||
visibleIds.add(id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record passes the current filters.
|
||||
*
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type * as api from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Detect an "integration" action on a button's data, returning the integration and the component
|
||||
* it targets, or `null` when the button is not an integration action.
|
||||
*/
|
||||
export function getIntegrationAction(data: api.DocumentInlineButton['data']) {
|
||||
if (!('action' in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: drop the cast once `@gitbook/api` ships the `integration` variant of `DocumentAction`.
|
||||
const action = data.action as { action: string; integration?: unknown; block?: unknown };
|
||||
if (
|
||||
action.action === 'integration' &&
|
||||
typeof action.integration === 'string' &&
|
||||
typeof action.block === 'string' &&
|
||||
action.integration &&
|
||||
action.block
|
||||
) {
|
||||
return { integration: action.integration, block: action.block };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import QuickLRU from 'quick-lru';
|
||||
import React from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import { type GitSyncState, SiteInsightsMarkdownSource } from '@gitbook/api';
|
||||
import type { GitSyncState } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
|
||||
import { useAIChatController, useAIChatState } from '@/components/AI';
|
||||
@@ -98,14 +98,9 @@ const createCopiedStateStore = () => {
|
||||
set({ copied: true });
|
||||
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
onSuccess?.();
|
||||
|
||||
// Delay resetting the label past the dropdown's closing animation (`scaleOut`,
|
||||
// 200ms) so the "Copied" label doesn't flip back while still visible mid-fade.
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
timeoutRef = null;
|
||||
}, 200);
|
||||
timeoutRef = null;
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
@@ -125,10 +120,6 @@ function useCopiedStore(stateKey: string) {
|
||||
return useStore(getOrCreateCopiedStoreByKey(stateKey));
|
||||
}
|
||||
|
||||
function getReaderMarkdownURL(markdownPageURL: string) {
|
||||
return `${markdownPageURL}?displayAgentInstructions=false&markdownSource=${SiteInsightsMarkdownSource.PageAction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the markdown version of the page.
|
||||
*/
|
||||
@@ -153,7 +144,7 @@ export function ActionCopyMarkdown(props: {
|
||||
const fetchMarkdown = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const humanURL = getReaderMarkdownURL(markdownPageURL);
|
||||
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
|
||||
const result = await fetch(humanURL).then((res) => res.text());
|
||||
markdownCache.set(markdownPageURL, result);
|
||||
|
||||
@@ -162,7 +153,13 @@ export function ActionCopyMarkdown(props: {
|
||||
return result;
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
const onClick = async (e: React.MouseEvent) => {
|
||||
// Prevent default behavior for non-default actions to avoid closing the dropdown.
|
||||
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
|
||||
if (!isDefaultAction) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
copy(markdownCache.get(markdownPageURL) || (await fetchMarkdown()), {
|
||||
onSuccess: () => {
|
||||
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
|
||||
@@ -182,7 +179,6 @@ export function ActionCopyMarkdown(props: {
|
||||
description={tString(language, 'copy_page_markdown')}
|
||||
onClick={onClick}
|
||||
loading={loading}
|
||||
closeOnClick={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -200,7 +196,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
|
||||
icon="markdown"
|
||||
label={tString(language, 'view_page_markdown')}
|
||||
description={tString(language, 'view_page_plaintext')}
|
||||
href={getReaderMarkdownURL(markdownPageURL)}
|
||||
href={`${markdownPageURL}?displayAgentInstructions=false`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -425,7 +421,9 @@ export function CopyToClipboard(props: {
|
||||
icon={copied ? 'check' : icon}
|
||||
label={copied ? tString(language, 'code_copied') : label}
|
||||
description={description}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
copy(data, {
|
||||
onSuccess: () => {
|
||||
if (type === 'dropdown-menu-item') {
|
||||
@@ -434,7 +432,6 @@ export function CopyToClipboard(props: {
|
||||
},
|
||||
});
|
||||
}}
|
||||
closeOnClick={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -456,7 +453,6 @@ function PageActionWrapper(props: {
|
||||
target?: React.HTMLAttributeAnchorTarget;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
@@ -469,7 +465,6 @@ function PageActionWrapper(props: {
|
||||
description,
|
||||
disabled,
|
||||
loading,
|
||||
closeOnClick,
|
||||
} = props;
|
||||
|
||||
if (type === 'button') {
|
||||
@@ -503,7 +498,6 @@ function PageActionWrapper(props: {
|
||||
target={target}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
closeOnClick={closeOnClick}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center text-tint">
|
||||
{loading ? (
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -199,36 +198,17 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
const itemKey = getResultKey(item);
|
||||
const shouldAnimateItem =
|
||||
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
|
||||
const handleResultSelect = (
|
||||
event: React.MouseEvent<HTMLAnchorElement>
|
||||
) => {
|
||||
const isPageResult =
|
||||
item.type === 'local-page' ||
|
||||
item.type === 'page' ||
|
||||
item.type === 'record';
|
||||
|
||||
const handleResultSelect = () => {
|
||||
if (
|
||||
isPageResult &&
|
||||
!event.currentTarget.hash &&
|
||||
event.currentTarget.pathname === window.location.pathname
|
||||
query &&
|
||||
siteSpaceId &&
|
||||
(item.type === 'local-page' ||
|
||||
item.type === 'page' ||
|
||||
item.type === 'record')
|
||||
) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (query && siteSpaceId && isPageResult) {
|
||||
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 = {
|
||||
@@ -396,19 +376,3 @@ 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,13 +5,18 @@ import { useCallback, useSyncExternalStore } from 'react';
|
||||
import { selectStore } from '@/lib/select';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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}.
|
||||
*/
|
||||
export function useSelect() {
|
||||
const slugs = useSyncExternalStore(
|
||||
selectStore.subscribe,
|
||||
selectStore.getState,
|
||||
selectStore.getState
|
||||
).slugs;
|
||||
|
||||
return {
|
||||
slugs,
|
||||
activate: selectStore.activate,
|
||||
deactivate: selectStore.deactivate,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import type { PageMetaLinks } from './SitePage';
|
||||
@@ -33,6 +33,7 @@ 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();
|
||||
|
||||
@@ -40,14 +41,9 @@ function useStripFallbackQueryParam() {
|
||||
if (searchParams?.has('fallback')) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('fallback');
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${pathname}${query ? `?${query}` : ''}${window.location.hash}`
|
||||
);
|
||||
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
}, [router, pathname, searchParams]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { permanentRedirect, redirect } from 'next/navigation';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
|
||||
@@ -89,21 +89,7 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
|
||||
})
|
||||
));
|
||||
if (resolvedSiteRedirect) {
|
||||
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);
|
||||
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import { useEffect } from 'react';
|
||||
export function TableOfContentsScript() {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
// Writing the <html> style attribute re-styles the whole document, so skip unchanged values.
|
||||
const setVar = (name: string, value: string) => {
|
||||
if (root.style.getPropertyValue(name) !== value) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate and set TOC dimensions
|
||||
const updateTocLayout = () => {
|
||||
@@ -42,8 +48,8 @@ export function TableOfContentsScript() {
|
||||
}
|
||||
|
||||
// Update height
|
||||
root.style.setProperty('--toc-height', `${Math.max(height, 0)}px`);
|
||||
root.style.setProperty('--toc-top-offset', `${Math.max(offset, 0)}px`);
|
||||
setVar('--toc-height', `${Math.max(height, 0)}px`);
|
||||
setVar('--toc-top-offset', `${Math.max(offset, 0)}px`);
|
||||
|
||||
// Subtract visible pageCover (if any)
|
||||
if (
|
||||
@@ -64,8 +70,8 @@ export function TableOfContentsScript() {
|
||||
}
|
||||
}
|
||||
|
||||
root.style.setProperty('--outline-height', `${Math.max(height, 0)}px`);
|
||||
root.style.setProperty('--outline-top-offset', `${Math.max(offset, 0)}px`);
|
||||
setVar('--outline-height', `${Math.max(height, 0)}px`);
|
||||
setVar('--outline-top-offset', `${Math.max(offset, 0)}px`);
|
||||
};
|
||||
|
||||
// Initial update
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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,8 +1,6 @@
|
||||
'use client';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { resolveOverflowingItems } from './listOverflow';
|
||||
|
||||
interface OverflowState {
|
||||
/**
|
||||
* Ref for the container element.
|
||||
@@ -26,23 +24,12 @@ 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. 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.
|
||||
* In the measuring phase indicated by `isMeasuring`, all items must be rendered.
|
||||
*/
|
||||
export function useListOverflow(): OverflowState {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
|
||||
// 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 [isMeasuring, setIsMeasuring] = useState(false);
|
||||
const itemRefs = useRef(new Map<string, HTMLElement>());
|
||||
const rafRef = useRef(0);
|
||||
|
||||
@@ -56,19 +43,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;
|
||||
}
|
||||
|
||||
requestMeasure();
|
||||
setIsMeasuring(true);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(requestMeasure);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setIsMeasuring(true);
|
||||
});
|
||||
});
|
||||
|
||||
ro.observe(containerRef.current);
|
||||
@@ -77,7 +64,7 @@ export function useListOverflow(): OverflowState {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [requestMeasure]);
|
||||
}, []);
|
||||
|
||||
// Measure which items are overflowing
|
||||
useLayoutEffect(() => {
|
||||
@@ -85,32 +72,29 @@ export function useListOverflow(): OverflowState {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOverflowing = resolveOverflowingItems(
|
||||
containerRef.current.getBoundingClientRect(),
|
||||
Array.from(itemRefs.current, ([id, element]) => ({
|
||||
id,
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
);
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newOverflowing = new Set<string>();
|
||||
|
||||
// `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) {
|
||||
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)) {
|
||||
return newOverflowing;
|
||||
}
|
||||
for (const id of previous) {
|
||||
if (!newOverflowing.has(id)) {
|
||||
return newOverflowing;
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
});
|
||||
}
|
||||
|
||||
setMeasureCompleted(measureRequest);
|
||||
}, [isMeasuring, measureRequest]);
|
||||
}
|
||||
return previous;
|
||||
});
|
||||
setIsMeasuring(false);
|
||||
}, [isMeasuring]);
|
||||
|
||||
return { containerRef, itemRef, overflowing, isMeasuring };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useLayoutEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { useIsNavigating } from '../hooks';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -15,8 +15,14 @@ export const NavigationLoader = () => {
|
||||
}, []);
|
||||
|
||||
// On route changes, add a transient class for the first paint of the new page.
|
||||
// Skipped on the initial mount: toggling a class on <html> re-styles the whole document.
|
||||
const isInitialMount = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
void pathname;
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.classList.add('route-change');
|
||||
let raf2 = 0;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { scrollByItemsInContainer } from './ScrollContainer';
|
||||
|
||||
type MockRect = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
class MockElement {
|
||||
constructor(private readonly rect: MockRect) {}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
}
|
||||
}
|
||||
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
value: MockElement,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
value: originalHTMLElement,
|
||||
});
|
||||
});
|
||||
|
||||
function rect(left: number, right: number): MockRect {
|
||||
return { left, right, top: 0, bottom: 100, width: right - left, height: 100 };
|
||||
}
|
||||
|
||||
function makeContainer(
|
||||
childRects: MockRect[],
|
||||
options: { scrollLeft?: number; scrollWidth?: number } = {}
|
||||
) {
|
||||
const scrollCalls: Record<string, unknown>[] = [];
|
||||
const container = Object.assign(new MockElement(rect(0, 300)), {
|
||||
children: childRects.map((childRect) => new MockElement(childRect)),
|
||||
clientHeight: 100,
|
||||
clientWidth: 300,
|
||||
scrollHeight: 100,
|
||||
scrollLeft: options.scrollLeft ?? 0,
|
||||
scrollTop: 0,
|
||||
scrollWidth: options.scrollWidth ?? 1000,
|
||||
scrollTo: (options: Record<string, unknown>) => scrollCalls.push(options),
|
||||
});
|
||||
|
||||
return { container: container as unknown as HTMLElement, scrollCalls };
|
||||
}
|
||||
|
||||
describe('scrollByItemsInContainer', () => {
|
||||
it('advances by fully visible items and excludes a partial preview', () => {
|
||||
const { container, scrollCalls } = makeContainer([
|
||||
rect(0, 100),
|
||||
rect(110, 210),
|
||||
rect(220, 320),
|
||||
rect(330, 430),
|
||||
]);
|
||||
|
||||
scrollByItemsInContainer(container, 'horizontal', 'forward');
|
||||
|
||||
expect(scrollCalls).toEqual([{ top: undefined, left: 220, behavior: 'smooth' }]);
|
||||
});
|
||||
|
||||
it('moves backward by the visible page size', () => {
|
||||
const { container, scrollCalls } = makeContainer(
|
||||
[rect(-220, -120), rect(-110, -10), rect(0, 100), rect(110, 210), rect(220, 320)],
|
||||
{ scrollLeft: 220 }
|
||||
);
|
||||
|
||||
scrollByItemsInContainer(container, 'horizontal', 'backward');
|
||||
|
||||
expect(scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
|
||||
});
|
||||
|
||||
it('clamps at the first and last scroll positions', () => {
|
||||
const firstPage = makeContainer([rect(0, 100), rect(110, 210), rect(220, 320)]);
|
||||
scrollByItemsInContainer(firstPage.container, 'horizontal', 'backward');
|
||||
|
||||
const lastPage = makeContainer(
|
||||
[rect(-240, -140), rect(-130, -30), rect(-20, 80), rect(90, 190), rect(200, 300)],
|
||||
{ scrollLeft: 240, scrollWidth: 540 }
|
||||
);
|
||||
scrollByItemsInContainer(lastPage.container, 'horizontal', 'forward');
|
||||
|
||||
expect(firstPage.scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
|
||||
expect(lastPage.scrollCalls).toEqual([{ top: undefined, left: 240, behavior: 'smooth' }]);
|
||||
});
|
||||
});
|
||||
@@ -41,9 +41,6 @@ export type ScrollContainerProps = {
|
||||
|
||||
/** The ID or ref of the active item to scroll to. */
|
||||
active?: string | React.RefObject<HTMLElement | null>;
|
||||
|
||||
/** Scroll by one page of fully visible direct children instead of one viewport. */
|
||||
scrollByVisibleItems?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function ScrollContainer(props: ScrollContainerProps) {
|
||||
@@ -53,7 +50,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
contentClassName,
|
||||
orientation,
|
||||
active,
|
||||
scrollByVisibleItems = false,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
...rest
|
||||
@@ -89,11 +85,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollByVisibleItems) {
|
||||
scrollByItemsInContainer(container, orientation, 'forward');
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
|
||||
@@ -107,11 +98,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollByVisibleItems) {
|
||||
scrollByItemsInContainer(container, orientation, 'backward');
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
|
||||
@@ -205,133 +191,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const FULLY_VISIBLE_EDGE_TOLERANCE_PX = 1;
|
||||
|
||||
/**
|
||||
* Scroll a direct-child track by the number of items currently visible in the snapport.
|
||||
* Scroll padding is excluded from the measurement because it is the carousel's peek area.
|
||||
*/
|
||||
export function scrollByItemsInContainer(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
direction: 'forward' | 'backward'
|
||||
) {
|
||||
const children = Array.from(container.children).filter(
|
||||
(child): child is HTMLElement => child instanceof HTMLElement
|
||||
);
|
||||
const bounds = getScrollBounds(container, orientation);
|
||||
const items = children
|
||||
.map((element, index) => ({ element, index, rect: element.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => {
|
||||
const size = orientation === 'horizontal' ? rect.width : rect.height;
|
||||
return size > 0;
|
||||
})
|
||||
.map((item, index) => ({ ...item, index }));
|
||||
const visibleItems = items.filter(({ rect }) => {
|
||||
const start = orientation === 'horizontal' ? rect.left : rect.top;
|
||||
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
|
||||
return (
|
||||
start >= bounds.start - FULLY_VISIBLE_EDGE_TOLERANCE_PX &&
|
||||
end <= bounds.end + FULLY_VISIBLE_EDGE_TOLERANCE_PX
|
||||
);
|
||||
});
|
||||
|
||||
// A track narrower than its viewport, or one whose children have not laid out yet, should
|
||||
// retain the regular viewport behavior rather than getting stuck at its current position.
|
||||
if (visibleItems.length === 0) {
|
||||
scrollByViewport(container, orientation, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = visibleItems.length;
|
||||
const firstVisibleItem = visibleItems[0];
|
||||
const lastVisibleItem = visibleItems[visibleItems.length - 1];
|
||||
if (!firstVisibleItem || !lastVisibleItem) {
|
||||
scrollByViewport(container, orientation, direction);
|
||||
return;
|
||||
}
|
||||
const targetIndex =
|
||||
direction === 'forward' ? lastVisibleItem.index + 1 : firstVisibleItem.index - pageSize;
|
||||
const maxScroll = getMaxScroll(container, orientation);
|
||||
|
||||
if (targetIndex < 0) {
|
||||
scrollToPosition(container, orientation, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = items.find((item) => item.index === targetIndex);
|
||||
if (!target) {
|
||||
scrollToPosition(container, orientation, maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetStart = orientation === 'horizontal' ? target.rect.left : target.rect.top;
|
||||
const targetPosition =
|
||||
(orientation === 'horizontal' ? container.scrollLeft : container.scrollTop) +
|
||||
targetStart -
|
||||
bounds.start;
|
||||
|
||||
scrollToPosition(container, orientation, Math.min(Math.max(targetPosition, 0), maxScroll));
|
||||
}
|
||||
|
||||
function getScrollBounds(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const computedStyle = typeof window !== 'undefined' ? window.getComputedStyle(container) : null;
|
||||
const leadingPadding = Number.parseFloat(
|
||||
computedStyle?.[orientation === 'horizontal' ? 'scrollPaddingLeft' : 'scrollPaddingTop'] ??
|
||||
''
|
||||
);
|
||||
const trailingPadding = Number.parseFloat(
|
||||
computedStyle?.[
|
||||
orientation === 'horizontal' ? 'scrollPaddingRight' : 'scrollPaddingBottom'
|
||||
] ?? ''
|
||||
);
|
||||
const start = orientation === 'horizontal' ? rect.left : rect.top;
|
||||
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
|
||||
|
||||
return {
|
||||
start: start + (Number.isFinite(leadingPadding) ? leadingPadding : 0),
|
||||
end: end - (Number.isFinite(trailingPadding) ? trailingPadding : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function getMaxScroll(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
|
||||
return Math.max(
|
||||
orientation === 'horizontal'
|
||||
? container.scrollWidth - container.clientWidth
|
||||
: container.scrollHeight - container.clientHeight,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToPosition(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
position: number
|
||||
) {
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? position : undefined,
|
||||
left: orientation === 'horizontal' ? position : undefined,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
function scrollByViewport(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
direction: 'forward' | 'backward'
|
||||
) {
|
||||
const position = orientation === 'horizontal' ? container.scrollLeft : container.scrollTop;
|
||||
const distance = orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
|
||||
const maxScroll = getMaxScroll(container, orientation);
|
||||
const target = Math.min(
|
||||
Math.max(position + (direction === 'forward' ? distance : -distance), 0),
|
||||
maxScroll
|
||||
);
|
||||
|
||||
scrollToPosition(container, orientation, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/** 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,9 +101,6 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -204,17 +201,11 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -296,10 +287,8 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -321,10 +310,8 @@ export async function fetchSiteContextByIds(
|
||||
isFallback: boolean;
|
||||
noIndexSearch: boolean;
|
||||
isLoggedInVisitor: boolean;
|
||||
preview: boolean;
|
||||
displayAgentInstructions?: boolean;
|
||||
isAiAgent?: boolean;
|
||||
isChatGPT?: boolean;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
@@ -452,10 +439,8 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, getURLLookupPathname, stripURLSearch } from './urls';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
import { isAPITokenExpired } from '@/lib/api-token';
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { getLogger } from '@/lib/logger';
|
||||
@@ -134,7 +134,7 @@ export async function lookupPublishedContentByUrl(
|
||||
...data,
|
||||
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: getURLLookupPathname(alternative, data),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
...(changeRequest ? { changeRequest } : {}),
|
||||
...(revision ? { revision } : {}),
|
||||
};
|
||||
|
||||
@@ -1,103 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
import { getURLLookupAlternatives, normalizeURL } from './urls';
|
||||
|
||||
describe('getURLLookupAlternatives', () => {
|
||||
it('should return all URLs up to the root', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { joinPath, removeTrailingSlash } from '../paths';
|
||||
import { isProxyRootRequest } from '../proxy';
|
||||
import { DataFetcherError, getExposableError } from './errors';
|
||||
|
||||
@@ -161,23 +160,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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 { Definition, Html, Image, Link, Paragraph, Root } from 'mdast';
|
||||
import type { Link, Root } from 'mdast';
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
|
||||
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';
|
||||
@@ -29,10 +29,6 @@ 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).
|
||||
@@ -70,7 +66,6 @@ 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) {
|
||||
@@ -107,7 +102,6 @@ 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) {
|
||||
@@ -148,26 +142,6 @@ 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.
|
||||
@@ -225,7 +199,6 @@ 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',
|
||||
@@ -235,14 +208,6 @@ 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)),
|
||||
],
|
||||
};
|
||||
@@ -254,8 +219,7 @@ 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(
|
||||
@@ -282,13 +246,30 @@ async function rewriteMarkdownLinks(
|
||||
pending.push(
|
||||
(async () => {
|
||||
const resolved = await resolveContentRef(contentRef, context);
|
||||
node.url = resolved?.href ?? toBrokenURL(original);
|
||||
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}`;
|
||||
}
|
||||
|
||||
if (isMention) {
|
||||
// Replace the text for mentions as otherwise it contains the raw ref
|
||||
node.children = [
|
||||
{ type: 'text', value: resolved?.text ?? 'Broken mention' },
|
||||
];
|
||||
if (resolved) {
|
||||
node.children = [
|
||||
{
|
||||
type: 'text',
|
||||
value: resolved.text,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
node.children = [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Broken mention',
|
||||
},
|
||||
];
|
||||
}
|
||||
node.title = undefined;
|
||||
}
|
||||
})()
|
||||
@@ -307,20 +288,6 @@ 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);
|
||||
}
|
||||
@@ -328,82 +295,6 @@ 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,6 +1,4 @@
|
||||
import type {
|
||||
Revision,
|
||||
RevisionPageDocument,
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
@@ -12,7 +10,6 @@ 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';
|
||||
@@ -25,32 +22,13 @@ export function transformSitePageResult(args: {
|
||||
siteSpace?: SiteSpace;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup | null;
|
||||
revisionPages?: Revision['pages'];
|
||||
}): ComputedPageResult | null {
|
||||
const {
|
||||
asEmbeddable,
|
||||
pageItem,
|
||||
spaceItem,
|
||||
siteSection,
|
||||
siteSectionGroup,
|
||||
siteSpace,
|
||||
linker,
|
||||
revisionPages,
|
||||
} = args;
|
||||
}): ComputedPageResult {
|
||||
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||
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,
|
||||
@@ -85,9 +63,13 @@ export function transformSitePageResult(args: {
|
||||
}))
|
||||
);
|
||||
|
||||
const pageHref = revisionTarget
|
||||
? linker.toPathForPage(revisionTarget)
|
||||
: getPublishedPageHref({ asEmbeddable, linker, pagePath: pageItem.path, spaceURL });
|
||||
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 page: ComputedPageResult = {
|
||||
type: 'page',
|
||||
@@ -108,20 +90,21 @@ export function transformSitePageResult(args: {
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
const anchor = revisionTarget
|
||||
? getRetainableSectionAnchor(section.path, pageItem.path)
|
||||
: undefined;
|
||||
const sectionHref = revisionTarget
|
||||
? linker.toPathForPage({
|
||||
...revisionTarget,
|
||||
anchor,
|
||||
})
|
||||
: getPublishedPageHref({
|
||||
asEmbeddable,
|
||||
linker,
|
||||
pagePath: section.path,
|
||||
spaceURL,
|
||||
});
|
||||
let sectionHref = linker.toPathInSpace(section.path);
|
||||
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
sectionHref = toEmbeddableLinkForPublishedContent(
|
||||
linker,
|
||||
spaceURL,
|
||||
section.path
|
||||
);
|
||||
} else {
|
||||
sectionHref = linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, section.path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'section',
|
||||
@@ -148,33 +131,3 @@ 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));
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export function selectRankAttribute(rank: number): string {
|
||||
}
|
||||
|
||||
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
|
||||
// Option panes must be direct children of the element carrying the set class: the generated
|
||||
// selectors use a child combinator, so a group never resolves the panes of a group nested in it.
|
||||
|
||||
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
|
||||
export const SELECT_GROUP_ATTR = 'data-select-group';
|
||||
|
||||
@@ -73,10 +73,6 @@ function escapeCssString(value: string): string {
|
||||
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
|
||||
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
|
||||
*
|
||||
* Every rule matches `& > …` rather than a descendant: a nested group's panes are also
|
||||
* descendants of the outer group, so a descendant combinator would let the outer sheet hide them.
|
||||
* The child combinator adds no specificity, leaving the source-order priority above intact.
|
||||
*
|
||||
* Returns `''` for an empty/degenerate set.
|
||||
*/
|
||||
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
|
||||
@@ -90,20 +86,20 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
|
||||
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
|
||||
const rules: string[] = [
|
||||
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
|
||||
`& > ${option}{display:none}`,
|
||||
`& > [${SELECT_DEFAULT_ATTR}]{display:block}`,
|
||||
`${option}{display:none}`,
|
||||
`[${SELECT_DEFAULT_ATTR}]{display:block}`,
|
||||
];
|
||||
|
||||
for (let rank = depth - 1; rank >= 0; rank--) {
|
||||
const attr = selectRankAttribute(rank);
|
||||
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
|
||||
// When any of the set's options sits at this rank, hide the group's panes...
|
||||
rules.push(`html:is(${anyAtRank}) & > ${option}{display:none}`);
|
||||
rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
|
||||
// ...then reveal whichever one matches (correlated, so a per-option list).
|
||||
const show = slugs
|
||||
.map((slug) => {
|
||||
const value = escapeCssString(slug);
|
||||
return `html[${attr}="${value}"] & > [${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
})
|
||||
.join(',');
|
||||
rules.push(`${show}{display:block}`);
|
||||
@@ -116,14 +112,14 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
|
||||
for (const slug of slugs) {
|
||||
const value = escapeCssString(slug);
|
||||
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
rules.push(`html & > ${pane} ~ ${pane}{display:none}`);
|
||||
rules.push(`html & ${pane} ~ ${pane}{display:none}`);
|
||||
}
|
||||
|
||||
// A client click can override that first-match default: it pins the picked pane and unpins its
|
||||
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
|
||||
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
|
||||
rules.push(`html & > ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
|
||||
rules.push(`html & > ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
|
||||
rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
|
||||
rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
|
||||
|
||||
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getVisitorToken,
|
||||
getVisitorType,
|
||||
getVisitorUnsignedClaims,
|
||||
isRevalidationRequest,
|
||||
normalizeVisitorURL,
|
||||
} from './visitors';
|
||||
|
||||
@@ -565,16 +564,3 @@ 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,14 +132,6 @@ 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.
|
||||
@@ -162,7 +154,7 @@ export function getVisitorToken({
|
||||
|
||||
// Allow the empty string to come through
|
||||
if (fromUrl !== null && fromUrl !== undefined) {
|
||||
if (isRevalidationRequest(headers)) {
|
||||
if (headers.get('user-agent')?.toLowerCase() === 'gitbook-open-revalidation-worker') {
|
||||
return { source: 'revalidation', token: fromUrl };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SiteInsightsDisplayContext,
|
||||
type SiteInsightsEventLocation,
|
||||
SiteInsightsLLMSVariant,
|
||||
SiteInsightsMarkdownSource,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import {
|
||||
@@ -22,7 +21,6 @@ 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';
|
||||
@@ -56,7 +54,6 @@ import {
|
||||
getResponseCookiesForVisitorAuth,
|
||||
getVisitorData,
|
||||
getVisitorType,
|
||||
isRevalidationRequest,
|
||||
normalizeVisitorURL,
|
||||
serveVisitorClaimsDataRequest,
|
||||
} from '@/lib/visitors';
|
||||
@@ -340,9 +337,7 @@ 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 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.
|
||||
// We only redirect if the visitor token is not coming from a revalidation request, as we don't want to redirect in that case.
|
||||
//
|
||||
// The token and the visitor.* params value are stored in cookies that are set
|
||||
// on the redirect response.
|
||||
@@ -350,7 +345,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
const normalizedVisitorURL = normalizeVisitorURL(incomingURL);
|
||||
if (
|
||||
normalizedVisitorURL.toString() !== incomingURL.toString() &&
|
||||
!isRevalidationRequest(request.headers)
|
||||
visitorToken?.source !== 'revalidation'
|
||||
) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(normalizedVisitorURL.toString()),
|
||||
@@ -463,12 +458,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
routeType: routeTypeFromPathname,
|
||||
events,
|
||||
isAiAgent,
|
||||
isChatGPT,
|
||||
} = encodePathInSiteContent(siteURLData, request);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
|
||||
// Only set for markdown routes, so it becomes part of their static cache key.
|
||||
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
|
||||
@@ -540,9 +533,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
if (rewrittenURL.searchParams.has('displayAgentInstructions')) {
|
||||
rewrittenURL.searchParams.delete('displayAgentInstructions');
|
||||
}
|
||||
if (rewrittenURL.searchParams.has('markdownSource')) {
|
||||
rewrittenURL.searchParams.delete('markdownSource');
|
||||
}
|
||||
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
@@ -785,8 +775,6 @@ 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));
|
||||
|
||||
@@ -828,7 +816,6 @@ function encodePathInSiteContent(
|
||||
return {
|
||||
pathname,
|
||||
routeType: 'static',
|
||||
isChatGPT: isChatGPTRequest(request) || undefined,
|
||||
events: [
|
||||
{
|
||||
type: 'llms_request',
|
||||
@@ -864,7 +851,6 @@ function encodePathInSiteContent(
|
||||
return {
|
||||
pathname,
|
||||
routeType: 'static',
|
||||
isChatGPT: isChatGPTRequest(request) || undefined,
|
||||
events: [
|
||||
{
|
||||
type: 'llms_request',
|
||||
@@ -911,10 +897,6 @@ function encodePathInSiteContent(
|
||||
// It is encoded as a second path segment (the route is statically rendered, so it can't
|
||||
// read query params at runtime — the question is path-encoded for the same reason).
|
||||
const goal = searchParams.get('goal');
|
||||
// Validated: this is user input going into insights.
|
||||
const markdownSource = Object.values(SiteInsightsMarkdownSource).find(
|
||||
(source) => source === searchParams.get('markdownSource')
|
||||
);
|
||||
return {
|
||||
pathname:
|
||||
typeof ask === 'string'
|
||||
@@ -925,7 +907,6 @@ 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
|
||||
@@ -941,7 +922,6 @@ function encodePathInSiteContent(
|
||||
: [
|
||||
{
|
||||
type: 'page_markdown_request',
|
||||
...(markdownSource ? { markdownSource } : {}),
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -49,7 +48,7 @@ export async function serveLLMsFullTxt(context: GitBookSiteContext, page = 0) {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': getMarkdownContentType(context.isChatGPT),
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 {
|
||||
@@ -53,7 +52,7 @@ export async function serveLLMsTxt(baseContext: GitBookSiteContext) {
|
||||
|
||||
return new Response(output, {
|
||||
headers: {
|
||||
'Content-Type': getMarkdownContentType(baseContext.isChatGPT),
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,5 +92,5 @@ export async function serveAskMarkdown(
|
||||
}
|
||||
|
||||
return result;
|
||||
}, context.isChatGPT);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -45,7 +44,7 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
|
||||
markdown: `${renderLLMsTxtMarkdownDirective(context, pageLookup.page)}\n\n${markdownPage}${renderAskFooter(context, pageLookup)}`,
|
||||
robots,
|
||||
};
|
||||
}, baseContext.isChatGPT);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,8 +163,7 @@ 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 }>,
|
||||
isChatGPT?: boolean
|
||||
fn: () => Promise<string | { markdown: string; robots: string }>
|
||||
) {
|
||||
try {
|
||||
const result = await fn();
|
||||
@@ -173,7 +171,7 @@ export async function serveMarkdown(
|
||||
typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result;
|
||||
return new Response(markdown, {
|
||||
headers: {
|
||||
'Content-Type': getMarkdownContentType(isChatGPT),
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'X-Robots-Tag': robots,
|
||||
Vary: 'Accept',
|
||||
},
|
||||
|
||||
@@ -40,21 +40,6 @@ 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')
|
||||
@@ -133,25 +118,6 @@ 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,17 +27,6 @@ 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: {
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// Client-safe entrypoint: avoid pulling server rendering exports into Client Components.
|
||||
export { ContentKit } from './ContentKit';
|
||||
export type { ContentKitClientContextData, ContentKitSecurity } from './context';
|
||||
export type { ContentKitClientContextData } from './context';
|
||||
|
||||
Reference in New Issue
Block a user