mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-27 20:48:47 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae09b1ec5 | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 6cd9f2aaaa | |||
| 6d4a1653a2 | |||
| 9ce2759e1b | |||
| 5663064b46 | |||
| 6246b106af | |||
| 9ad85cb30c | |||
| 25bcff6eff | |||
| f232b8fff5 | |||
| e7f9bc2565 | |||
| 7061fc2223 | |||
| 21053f56fb | |||
| 2746ffc000 | |||
| 568ee3a660 | |||
| 420bf4a6f6 | |||
| 72cffdd14b | |||
| 0c9e3952b7 | |||
| 07be0e224c | |||
| c765394914 | |||
| ab96ee806b | |||
| 7cc6090e03 | |||
| 4b78672135 | |||
| 10289e4881 | |||
| 8b6a6df1d6 | |||
| 83df84ac28 | |||
| 5e5075a137 | |||
| 90c1f68e19 | |||
| 0e10124626 | |||
| 01e1c2ae6a | |||
| 65558cfbb1 | |||
| 10d894ce19 | |||
| d1a2958212 | |||
| 894b6adc4c | |||
| b3b819f8e5 | |||
| 2b98af0cc0 | |||
| 54a56ed772 | |||
| 071627d4f0 | |||
| 7595706b92 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update icon usage to render svg symbols rather than file.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix links to other spaces/sections in the llms.txt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pass visitor session to Ask AI.
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gitbook",
|
||||
@@ -139,7 +140,7 @@
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.2.1",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -147,6 +148,7 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
@@ -158,7 +160,7 @@
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
@@ -354,7 +356,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.177.0",
|
||||
"@gitbook/api": "0.180.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -369,6 +371,7 @@
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||
@@ -749,7 +752,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.177.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-krrkGWBQZkH+0pvtxKMjT9i2j/A23n8ZlJEWItX6LTxNaSJk5YfzK/qze/vS9F1M6WeXOi+nwgwoIRIVwUvYUw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.180.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-jGPP9cVGDLqVv1YjuZMpoVNfD0Yj30vVbkYwnVc4B2GRhYENJm/27x+nAzG7379YNilWOneqE245NyIWNgTu1w=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -907,7 +910,7 @@
|
||||
|
||||
"@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="],
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="],
|
||||
|
||||
@@ -1719,7 +1722,9 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
|
||||
|
||||
"@vercel/agent-readability": ["@vercel/agent-readability@0.2.1", "", { "peerDependencies": { "next": ">=14" }, "optionalPeers": ["next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw=="],
|
||||
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
|
||||
|
||||
"@vercel/agent-readability": ["@vercel/agent-readability@0.4.0", "", { "peerDependencies": { "@sveltejs/kit": ">=2", "h3": ">=1.8 <2", "next": ">=14" }, "optionalPeers": ["@sveltejs/kit", "h3", "next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-+qcRxDEbKKJzRlLqsZUnet63kepr8RikEOlT9pVEru/GNkiSinwZEJxyfUhdUpzav8El2v6DZybpyQuvlynAhQ=="],
|
||||
|
||||
"@vercel/backends": ["@vercel/backends@0.0.53", "", { "dependencies": { "@vercel/build-utils": "13.12.0", "@vercel/nft": "1.5.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-f8JK9Jatlco6kT/NsLYVR88iqS7oZxUXzXW8TofLmyTT5o+OEXSY2hzAQSjul6r0qz/9yAhtQkMhcHG8Ne01ZQ=="],
|
||||
|
||||
@@ -2119,7 +2124,7 @@
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
|
||||
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
@@ -2329,6 +2334,8 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
@@ -2739,7 +2746,7 @@
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="],
|
||||
"mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
|
||||
+3
-2
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.177.0",
|
||||
"@gitbook/api": "0.180.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -55,7 +55,8 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -36,4 +36,4 @@ screenshots/
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
worker-configuration.d.ts
|
||||
|
||||
@@ -110,11 +110,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Disabling for now, one of the image is making the tests timeout
|
||||
// {
|
||||
// name: 'docs.keeper.io',
|
||||
// contentBaseURL: 'https://docs.keeper.io',
|
||||
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
@@ -222,11 +223,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// This one redirects to binance now
|
||||
// {
|
||||
// name: 'docs.meshchain.ai',
|
||||
// contentBaseURL: 'https://docs.meshchain.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
|
||||
@@ -82,8 +82,6 @@ const searchTestCases: Test[] = [
|
||||
});
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -876,6 +874,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
|
||||
@@ -75,6 +75,10 @@ export type TestsCase = {
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
/**
|
||||
* Whether screenshots in this test case should capture the full scrollable page by default.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
export const allLocales: CustomizationLocale[] = [
|
||||
@@ -142,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
|
||||
type IconStateWindow = Window & {
|
||||
__ICONS_STATES__?: Record<string, IconURLState>;
|
||||
};
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible({
|
||||
@@ -240,9 +249,9 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
@@ -392,11 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
const iconWindow = window as IconStateWindow;
|
||||
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
|
||||
iconWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -429,6 +436,20 @@ export async function waitForIcons(page: Page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
|
||||
if (svgSymbol) {
|
||||
if (icon.dataset.gbIconSymbolState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
|
||||
if (!href?.startsWith('#')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.getElementById(href.slice(1)) instanceof SVGElement;
|
||||
}
|
||||
|
||||
const state = icon.getAttribute('data-argos-state');
|
||||
|
||||
if (state === 'pending') {
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -136,7 +137,8 @@
|
||||
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
console.log(`[GitbookIncrementalCache] Getting cache for key: ${cacheKey}`, key, cacheType);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
// export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
// mode: 'long-lived',
|
||||
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
// bypassTagCacheOnCacheHit: false,
|
||||
// //TODO: remove, reducing cache ttl of regional cache to help debugging
|
||||
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
|
||||
// // We don't want to update the cache entry on every cache hit
|
||||
// shouldLazilyUpdateOnCacheHit: false,
|
||||
// });
|
||||
|
||||
//TODO: reenable regional cache once we know what's going on
|
||||
export default new GitbookIncrementalCache();
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.2.1",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -40,6 +40,7 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
@@ -51,7 +52,7 @@
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
|
||||
+4
@@ -20,6 +20,10 @@ export async function handleMcpRequest(
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
if (!context.customization.pageActions.mcp) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
|
||||
requestURL.search = rawRequest.nextUrl.search;
|
||||
|
||||
+15
-1
@@ -186,5 +186,19 @@ function transformSitePageResult(args: {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return [page, ...pageSections];
|
||||
// Find the best-scoring section to use as a body preview on the page result.
|
||||
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
|
||||
(best, section) => (!best || section.score > best.score ? section : best),
|
||||
undefined
|
||||
);
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
title: bestSection.title,
|
||||
body: bestSection.body,
|
||||
score: bestSection.score,
|
||||
};
|
||||
}
|
||||
|
||||
return [page];
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export async function GET(
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type RevisionPage,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
SiteVisibility,
|
||||
} from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from '@/lib/sites';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
|
||||
|
||||
interface IndexPageEntry {
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorPage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the page tree and return all indexable document pages together with
|
||||
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
|
||||
*/
|
||||
function getIndexablePagesWithAncestors(
|
||||
rootPages: RevisionPage[],
|
||||
ancestors: AncestorPage[] = []
|
||||
): IndexPageEntry[] {
|
||||
const results: IndexPageEntry[] = [];
|
||||
|
||||
for (const page of rootPages) {
|
||||
if (page.type === 'link' || page.type === 'computed') continue;
|
||||
if (page.hidden || !isPageIndexable([], page)) continue;
|
||||
|
||||
if (page.type === 'document') {
|
||||
results.push({ page, ancestors });
|
||||
// Recurse into children with this document as an ancestor
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
} else if (page.type === 'group') {
|
||||
// Groups themselves are not documents — push them only as ancestors
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // 1 day in seconds
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, structure } = context;
|
||||
|
||||
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
|
||||
|
||||
const revisions = await Promise.all(
|
||||
visibleSpaces.map((ss) =>
|
||||
throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ss.space.id,
|
||||
revisionId: ss.space.revision,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pages: RawIndexPage[] = [];
|
||||
|
||||
for (let i = 0; i < visibleSpaces.length; i++) {
|
||||
const siteSpace = visibleSpaces[i]!;
|
||||
const revision = revisions[i]!;
|
||||
const forkedLinker = linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
|
||||
});
|
||||
|
||||
const lang = siteSpace.space.language ?? undefined;
|
||||
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
|
||||
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
|
||||
|
||||
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
|
||||
const cacheKey = `${siteSpace.id}:${page.id}`;
|
||||
if (seen.has(cacheKey)) continue;
|
||||
seen.add(cacheKey);
|
||||
|
||||
const breadcrumbs: Breadcrumb[] = [
|
||||
siteSectionGroup
|
||||
? {
|
||||
label: getLocalizedTitle(siteSectionGroup, lang),
|
||||
icon: siteSectionGroup.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
siteSection
|
||||
? {
|
||||
label: getLocalizedTitle(siteSection, lang),
|
||||
icon: siteSection.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
...ancestors.map((a) => ({
|
||||
label: a.title,
|
||||
icon: a.icon ?? undefined,
|
||||
emoji: a.emoji ?? undefined,
|
||||
})),
|
||||
].filter((c) => c !== undefined);
|
||||
|
||||
pages.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
|
||||
siteSpaceId: siteSpace.id,
|
||||
lang,
|
||||
icon: page.icon ?? undefined,
|
||||
emoji: page.emoji ?? undefined,
|
||||
description: page.description ?? undefined,
|
||||
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
|
||||
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
|
||||
const shouldCacheOnClient =
|
||||
context.site.visibility === SiteVisibility.Public ||
|
||||
context.site.visibility === SiteVisibility.Unlisted;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
|
||||
version: 1,
|
||||
pages,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
|
||||
'Cache-Control': shouldCacheOnClient
|
||||
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
|
||||
: 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getIconSymbol } from '@/lib/icons/symbols';
|
||||
import { getIconSymbolId } from '@gitbook/icons';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ style: string; icon: string }> }
|
||||
) {
|
||||
const { style, icon } = await params;
|
||||
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
|
||||
|
||||
if (!symbol) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Symbol not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(symbol.document, {
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml; charset=utf-8',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getVisitor, useTrackEvent } from '../Insights';
|
||||
import { getSession } from '../Insights/sessions';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
@@ -277,10 +276,7 @@ export function AIChatProvider(props: {
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SkeletonHeading,
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
@@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'file':
|
||||
return <SkeletonHeading id={id} style={style} />;
|
||||
case 'paragraph':
|
||||
return <SkeletonSmall id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'list-ordered':
|
||||
case 'list-unordered':
|
||||
case 'list-tasks':
|
||||
@@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'tabs':
|
||||
case 'stepper-step':
|
||||
case 'if':
|
||||
return <SkeletonParagraph id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
@@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
return <SkeletonUpdate id={id} className={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
import { Loading } from '@/components/primitives/Loading';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import Panzoom from '@panzoom/panzoom';
|
||||
import type { RenderResult } from 'mermaid';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
@@ -14,8 +16,9 @@ import { getPlainCodeBlock } from './highlight';
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style } = props;
|
||||
const { block, mode, style } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
|
||||
@@ -24,8 +27,14 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
|
||||
const shouldRender = mode === 'print' || hasBeenInViewport;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = diagramRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!container || !wrapper) {
|
||||
@@ -36,46 +45,67 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
let cleanupPanZoom: (() => void) | undefined;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setPanZoom(null);
|
||||
container.innerHTML = '';
|
||||
|
||||
const cancelScheduledRender = scheduleMermaidWork(() => {
|
||||
enqueueMermaidRender(async () => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderMermaidDiagram({
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.svg;
|
||||
if (container.querySelector('svg')) {
|
||||
result.bindFunctions?.(container);
|
||||
}
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
cleanupPanZoom = initPanzoom({
|
||||
container,
|
||||
wrapper,
|
||||
onInit: setPanZoom,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelScheduledRender();
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={tcls('group/mermaid relative', style)}
|
||||
contentEditable={false}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={
|
||||
@@ -100,18 +130,12 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram(args: {
|
||||
container: HTMLElement;
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
}) {
|
||||
const { container, source, id, darkMode } = args;
|
||||
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
|
||||
import('mermaid'),
|
||||
import('@mermaid-js/mermaid-zenuml'),
|
||||
]);
|
||||
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
}): Promise<RenderResult> {
|
||||
const { source, id, darkMode } = args;
|
||||
const { mermaid } = await loadMermaid();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -120,11 +144,115 @@ async function renderMermaidDiagram(args: {
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
});
|
||||
|
||||
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
|
||||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
const renderContainer = createMermaidRenderContainer();
|
||||
|
||||
try {
|
||||
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
|
||||
} finally {
|
||||
renderContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid measures labels while rendering, so the temporary render target must be
|
||||
* connected to the document. Keep it fixed and contained so those layout reads do
|
||||
* not walk the visible document flow.
|
||||
*/
|
||||
function createMermaidRenderContainer() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(container.style, {
|
||||
contain: 'strict',
|
||||
height: '100vh',
|
||||
isolation: 'isolate',
|
||||
left: '0',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
visibility: 'hidden',
|
||||
width: '100vw',
|
||||
zIndex: '-1',
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
let mermaidLoadPromise: Promise<{
|
||||
mermaid: typeof import('mermaid')['default'];
|
||||
}> | null = null;
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidLoadPromise) {
|
||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
return { mermaid };
|
||||
})
|
||||
.catch((error) => {
|
||||
mermaidLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return mermaidLoadPromise;
|
||||
}
|
||||
|
||||
let mermaidRenderQueue = Promise.resolve();
|
||||
|
||||
function enqueueMermaidRender<T>(task: () => Promise<T>) {
|
||||
const result = mermaidRenderQueue.then(task, task);
|
||||
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function waitForNextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleMermaidWork(callback: () => void) {
|
||||
const win = window as WindowWithIdleCallback;
|
||||
let cancelled = false;
|
||||
|
||||
if (win.requestIdleCallback && win.cancelIdleCallback) {
|
||||
const handle = win.requestIdleCallback(
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
win.cancelIdleCallback?.(handle);
|
||||
};
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}
|
||||
|
||||
type WindowWithIdleCallback = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize panzoom on the diagram container.
|
||||
*/
|
||||
@@ -138,7 +266,6 @@ function initPanzoom(args: {
|
||||
const instance = Panzoom(container, {
|
||||
maxScale: 5,
|
||||
minScale: 0.5,
|
||||
contain: 'outside',
|
||||
cursor: 'grab',
|
||||
panOnlyWhenZoomed: true,
|
||||
});
|
||||
|
||||
@@ -11,70 +11,42 @@ const PAN_STEP = 50;
|
||||
*/
|
||||
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
|
||||
const { panZoom } = props;
|
||||
const btnProps = {
|
||||
variant: 'secondary' as const,
|
||||
size: 'xsmall' as const,
|
||||
iconOnly: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan up"
|
||||
{...btnProps}
|
||||
icon="chevron-up"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom in"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomIn()}
|
||||
/>
|
||||
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button
|
||||
label="Pan left"
|
||||
{...btnProps}
|
||||
icon="chevron-left"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
|
||||
<Button
|
||||
label="Reset view"
|
||||
icon="refresh"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.reset()}
|
||||
/>
|
||||
<Button
|
||||
label="Pan right"
|
||||
{...btnProps}
|
||||
icon="chevron-right"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan down"
|
||||
{...btnProps}
|
||||
icon="chevron-down"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom out"
|
||||
icon="minus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomOut()}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
fillHeight={true}
|
||||
query={query}
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
@@ -79,6 +80,9 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
withAI={withSearchAI}
|
||||
isOpen={true}
|
||||
mode="frame"
|
||||
resultsCount={results.length}
|
||||
fetching={fetching}
|
||||
showAsk={showAsk}
|
||||
>
|
||||
<SearchLiveResultsAnnouncer
|
||||
count={results.length}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function Header(props: {
|
||||
'flex',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'@2xl:basis-56',
|
||||
'md:@2xl:basis-56',
|
||||
'justify-self-end',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
@@ -130,15 +130,15 @@ export function Header(props: {
|
||||
'search' in customization.styling &&
|
||||
customization.styling.search === 'prominent'
|
||||
? [
|
||||
'@2xl:grow-[0.8]',
|
||||
'@4xl:basis-40',
|
||||
'@2xl:max-w-[50%]',
|
||||
'@4xl:max-w-lg',
|
||||
'md:@2xl:grow-[0.8]',
|
||||
'md:@4xl:basis-40',
|
||||
'md:@2xl:max-w-[50%]',
|
||||
'md:@4xl:max-w-lg',
|
||||
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
|
||||
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'@2xl:mr-auto',
|
||||
'md:@2xl:mr-auto',
|
||||
'order-last',
|
||||
'@2xl:order-[unset]',
|
||||
'md:@2xl:order-[unset]',
|
||||
]
|
||||
: ['order-last']
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,14 @@ interface HeaderLogoProps {
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* We previously gave the logo too much height, making it too big to look good.
|
||||
* As a response, many orgs added padding inside their logos to make them look better.
|
||||
* The new logo max-height looks better but might make existing logos look too small.
|
||||
* To avoid disruption, we only use the new (correct) logo height for logos updated after this date.
|
||||
*/
|
||||
const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
|
||||
|
||||
/**
|
||||
* Render the logo for a space using the customization settings.
|
||||
*/
|
||||
@@ -18,6 +26,10 @@ interface HeaderLogoProps {
|
||||
export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
const { context } = props;
|
||||
const { customization, linker } = context;
|
||||
const canUseCompactHeaderLogo =
|
||||
customization.updatedAt &&
|
||||
!Number.isNaN(Date.parse(customization.updatedAt)) &&
|
||||
Date.parse(customization.updatedAt) >= Date.parse(HEADER_COMPACT_LOGO_DATE);
|
||||
|
||||
const primaryLink = customization.header.primaryLink
|
||||
? await resolveContentRef(customization.header.primaryLink, context)
|
||||
@@ -59,7 +71,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
'max-w-40',
|
||||
'lg:max-w-64',
|
||||
'lg:site-header-none:page-no-toc:max-w-56',
|
||||
'max-h-10',
|
||||
canUseCompactHeaderLogo ? 'max-h-8' : 'max-h-10',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'object-contain',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { SiteInsightsSession } from '@gitbook/api';
|
||||
import type { MaybePromise } from 'p-map';
|
||||
import React from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
@@ -9,9 +12,8 @@ import {
|
||||
setLocalStorageItem,
|
||||
} from '@/lib/browser';
|
||||
|
||||
import type { MaybePromise } from 'p-map';
|
||||
import React from 'react';
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { getSession } from './sessions';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
@@ -126,6 +128,16 @@ export function useVisitor() {
|
||||
return useStore(visitorStore, (state) => state.visitor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor session for insights.
|
||||
*/
|
||||
export async function getInsightsSession(): Promise<SiteInsightsSession> {
|
||||
return {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current visitor ID.
|
||||
*/
|
||||
|
||||
@@ -106,7 +106,7 @@ export function PageBody(props: {
|
||||
<SuspenseLoadedHint />
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="clear-both flex flex-col [&>*+*]:mt-5"
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function PageHeader(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={tcls(CONTENT_STYLE, 'mb-6', 'space-y-3')}>
|
||||
<header className={tcls(CONTENT_STYLE, 'mb-6 space-y-3 after:clear-both after:block')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'float-right ml-4 flex gap-2',
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getContentLocale, getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { IconSpriteDefinitions } from './IconSpriteDefinitions';
|
||||
import { RootLayoutClientContexts } from './RootLayoutClientContexts';
|
||||
|
||||
import './globals.css';
|
||||
@@ -76,6 +77,9 @@ export async function CustomizationRootLayout(props: {
|
||||
const sidebarStyles = getSidebarStyles(customization);
|
||||
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
|
||||
const fontData = getFontData(customization.styling.font, 'content');
|
||||
const iconStyle =
|
||||
('icons' in customization.styling ? apiToIconsStyles[customization.styling.icons] : null) ||
|
||||
IconStyle.Regular;
|
||||
// Temporarily add a if here while the cache is being warmed up.
|
||||
// We can remove the condition after 14-07-2025.
|
||||
const monospaceFontData = customization.styling.monospaceFont
|
||||
@@ -194,15 +198,14 @@ export async function CustomizationRootLayout(props: {
|
||||
assetsURL: getAssetURL('icons'),
|
||||
},
|
||||
}}
|
||||
iconStyle={
|
||||
('icons' in customization.styling
|
||||
? apiToIconsStyles[customization.styling.icons]
|
||||
: null) || IconStyle.Regular
|
||||
}
|
||||
renderMode="symbol"
|
||||
symbolLoaderURL="/~gitbook/icons/symbol"
|
||||
iconStyle={iconStyle}
|
||||
>
|
||||
<RootLayoutClientContexts language={language}>
|
||||
{children}
|
||||
</RootLayoutClientContexts>
|
||||
<IconSpriteDefinitions />
|
||||
</IconsProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getIconSymbol } from '@/lib/icons/symbols';
|
||||
import {
|
||||
type RegisteredIconSymbol,
|
||||
clearRegisteredServerIconSymbols,
|
||||
getRegisteredServerIconSymbols,
|
||||
} from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Emits the subset of icon symbols that were rendered during the current request.
|
||||
*/
|
||||
export async function IconSpriteDefinitions() {
|
||||
const registered: Map<string, RegisteredIconSymbol> = new Map(
|
||||
getRegisteredServerIconSymbols().map((symbol: RegisteredIconSymbol) => [
|
||||
`${symbol.style}/${symbol.icon}`,
|
||||
symbol,
|
||||
])
|
||||
);
|
||||
|
||||
if (registered.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definitions = (
|
||||
await Promise.all(
|
||||
[...registered.values()].map((symbol) => {
|
||||
return getIconSymbol(symbol.style, symbol.icon, symbol.symbolId);
|
||||
})
|
||||
)
|
||||
).filter((symbol): symbol is NonNullable<typeof symbol> => !!symbol);
|
||||
|
||||
clearRegisteredServerIconSymbols();
|
||||
|
||||
if (!definitions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
id="gb-icon-sprite-root"
|
||||
data-testid="icon-sprite-root"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 0,
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: definitions.map((symbol) => symbol.symbol).join(''),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { Emoji } from '../primitives/Emoji/Emoji';
|
||||
import { Link } from '../primitives/Link';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
|
||||
/**
|
||||
* Renders local search results above the main server results.
|
||||
*
|
||||
* Layout:
|
||||
* - While server results are still fetching → 2-column wrapping row (cards fill width)
|
||||
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
|
||||
*
|
||||
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
|
||||
*/
|
||||
export function LocalSearchResults({
|
||||
results,
|
||||
fetching,
|
||||
}: {
|
||||
results: LocalPageResult[];
|
||||
fetching: boolean;
|
||||
}) {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'py-2',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-2',
|
||||
fetching
|
||||
? 'flex-wrap'
|
||||
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
|
||||
)}
|
||||
>
|
||||
{results.map((result) => (
|
||||
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalSearchResultCard({
|
||||
result,
|
||||
fetching,
|
||||
}: {
|
||||
result: LocalPageResult;
|
||||
fetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={result.pathname}
|
||||
className={tcls(
|
||||
'group',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-1',
|
||||
'p-3',
|
||||
'rounded-corners:rounded-lg',
|
||||
'circular-corners:rounded-2xl',
|
||||
'text-tint',
|
||||
'bg-tint-subtle',
|
||||
'hover:bg-tint',
|
||||
'hover:text-tint-strong',
|
||||
'transition-colors',
|
||||
'cursor-pointer',
|
||||
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
|
||||
{result.emoji ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Emoji code={result.emoji} style="text-base leading-none" />
|
||||
</span>
|
||||
) : result.icon ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Icon icon={result.icon as IconName} className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
<p className="grow truncate font-semibold text-sm text-tint-strong leading-snug">
|
||||
{result.title}
|
||||
</p>
|
||||
<span className="ml-auto shrink-0 text-tint-subtle">
|
||||
<Icon icon="chevron-right" className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
{result.description ? (
|
||||
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import React from 'react';
|
||||
|
||||
import { AIResponseFeedback, AISearchIcon } from '../AIChat';
|
||||
import { HoldMessage } from '../AIChat/AIChatMessages';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
@@ -49,7 +49,11 @@ export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }
|
||||
query,
|
||||
});
|
||||
|
||||
const { stream } = await streamAskQuestion({ question: query, asEmbeddable });
|
||||
const { stream } = await streamAskQuestion({
|
||||
question: query,
|
||||
asEmbeddable,
|
||||
session: await getInsightsSession(),
|
||||
});
|
||||
for await (const chunk of readStreamableValue(stream)) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -92,7 +96,7 @@ export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full p-4">
|
||||
<div className="flex grow p-4">
|
||||
{askState?.type === 'answer' ? (
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import { useSearchLink } from './useSearch';
|
||||
|
||||
/**
|
||||
* Sticky single-line bar at the bottom of the search frame.
|
||||
* Clicking opens the AI assistant with the current query.
|
||||
*/
|
||||
export function SearchAskBar(props: {
|
||||
query: string;
|
||||
assistant: Assistant;
|
||||
active?: boolean;
|
||||
withShortcut?: boolean;
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const { query, assistant, active = false, withShortcut = false, onSelect } = props;
|
||||
const language = useLanguage();
|
||||
const getSearchLinkProps = useSearchLink();
|
||||
|
||||
const linkProps = getSearchLinkProps(
|
||||
{
|
||||
ask: query,
|
||||
query: null,
|
||||
open: assistant.mode === 'search',
|
||||
},
|
||||
() => {
|
||||
onSelect?.();
|
||||
assistant.open(query);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResultItem
|
||||
{...linkProps}
|
||||
active={active}
|
||||
action={tString(language, 'ask')}
|
||||
leadingIcon={assistant.icon}
|
||||
data-testid="search-ask-question"
|
||||
className="gutter-stable shrink-0 overflow-y-scroll rounded-none! border-tint-subtle border-t pr-5 pl-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="line-clamp-1">
|
||||
{t(language, 'ai_chat_ask_query', assistant.label, query)}
|
||||
</div>
|
||||
{withShortcut ? (
|
||||
<KeyboardShortcut className="bg-tint-base" keys={['mod', 'i']} />
|
||||
) : null}
|
||||
</div>
|
||||
</SearchResultItem>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Popover } from '../primitives';
|
||||
import { Button, Popover } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { SearchFrame } from './SearchFrame';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
@@ -29,10 +33,14 @@ export function SearchContainer({
|
||||
...searchProps
|
||||
}: SearchContainerProps) {
|
||||
const searchInputRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const language = useLanguage();
|
||||
const usesSideSheet = useIsMobile(768);
|
||||
const {
|
||||
assistants,
|
||||
askQuery,
|
||||
askCount,
|
||||
askInAssistant,
|
||||
abort,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
@@ -68,6 +76,10 @@ export function SearchContainer({
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
if (state?.open && query && askCount > 0) {
|
||||
askInAssistant();
|
||||
return;
|
||||
}
|
||||
assistants[0]?.open();
|
||||
},
|
||||
{
|
||||
@@ -88,81 +100,159 @@ export function SearchContainer({
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
||||
const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined;
|
||||
const visible =
|
||||
viewport === 'desktop' ? !usesSideSheet : viewport === 'mobile' ? usesSideSheet : true;
|
||||
const searchResultsActiveDescendant =
|
||||
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
|
||||
const isSearchOpen = Boolean(visible && (state?.open ?? false));
|
||||
const shouldFillHeight = Boolean(query || showAsk);
|
||||
const shouldShowSearchFrame = usesSideSheet
|
||||
? Boolean(state?.open || state?.query || withAI)
|
||||
: Boolean(state?.query || withAI);
|
||||
const scopeControlNode =
|
||||
searchProps.withVariants || searchProps.withSections ? (
|
||||
<SearchScopeControl {...scopeControl} />
|
||||
) : null;
|
||||
|
||||
const frameInput = (
|
||||
<SearchInput
|
||||
aria-activedescendant={searchResultsActiveDescendant}
|
||||
aria-controls={resultsId}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
value={searchValue}
|
||||
withAI={withSearchAI}
|
||||
isOpen={isSearchOpen}
|
||||
mode="frame"
|
||||
resultsCount={results.length}
|
||||
fetching={fetching}
|
||||
showAsk={showAsk}
|
||||
>
|
||||
<SearchLiveResultsAnnouncer
|
||||
count={results.length}
|
||||
showing={Boolean(searchValue) && !fetching}
|
||||
/>
|
||||
</SearchInput>
|
||||
);
|
||||
|
||||
const searchFrame = shouldShowSearchFrame ? (
|
||||
<SearchFrame
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
input={usesSideSheet ? frameInput : undefined}
|
||||
query={query}
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
onResultSelect={abort}
|
||||
showAsk={showAsk}
|
||||
scopeControl={scopeControlNode}
|
||||
fillHeight={usesSideSheet || shouldFillHeight}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
content={
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<SearchFrame
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
query={query}
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
showAsk={showAsk}
|
||||
scopeControl={
|
||||
searchProps.withVariants || searchProps.withSections ? (
|
||||
<SearchScopeControl {...scopeControl} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
rootProps={{
|
||||
open: Boolean(visible && (state?.open ?? false)),
|
||||
modal: false,
|
||||
}}
|
||||
contentProps={{
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
sideOffset: 8,
|
||||
collisionPadding: {
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
left: 16,
|
||||
},
|
||||
hideWhenDetached: true,
|
||||
}}
|
||||
triggerProps={{
|
||||
asChild: true,
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
aria-activedescendant={searchResultsActiveDescendant}
|
||||
aria-controls={resultsId}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
value={searchValue}
|
||||
withAI={withSearchAI}
|
||||
isOpen={state?.open ?? false}
|
||||
{usesSideSheet ? (
|
||||
<Button
|
||||
data-testid="search-button"
|
||||
icon="search"
|
||||
variant="header"
|
||||
size="medium"
|
||||
iconOnly
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, withSearchAI ? 'search_or_ask' : 'search')}
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'k']}
|
||||
className="border-tint-11 text-tint-1"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
active={isSearchOpen}
|
||||
onClick={open}
|
||||
className={className}
|
||||
onFocus={open}
|
||||
aria-expanded={isSearchOpen}
|
||||
aria-controls={resultsId}
|
||||
/>
|
||||
) : (
|
||||
<Popover
|
||||
content={searchFrame}
|
||||
rootProps={{
|
||||
open: isSearchOpen,
|
||||
modal: false,
|
||||
}}
|
||||
contentProps={{
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className: tcls(
|
||||
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
shouldFillHeight
|
||||
? 'h-[min(32rem,var(--radix-popover-content-available-height))]'
|
||||
: 'max-h-[min(32rem,var(--radix-popover-content-available-height))]'
|
||||
),
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
sideOffset: 8,
|
||||
collisionPadding: {
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
left: 16,
|
||||
},
|
||||
hideWhenDetached: true,
|
||||
}}
|
||||
triggerProps={{
|
||||
asChild: true,
|
||||
}}
|
||||
>
|
||||
<SearchLiveResultsAnnouncer
|
||||
count={results.length}
|
||||
showing={Boolean(searchValue) && !fetching}
|
||||
/>
|
||||
</SearchInput>
|
||||
</Popover>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
aria-activedescendant={searchResultsActiveDescendant}
|
||||
aria-controls={resultsId}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
value={searchValue}
|
||||
withAI={withSearchAI}
|
||||
isOpen={isSearchOpen}
|
||||
className={className}
|
||||
onFocus={open}
|
||||
resultsCount={results.length}
|
||||
fetching={fetching}
|
||||
showAsk={showAsk}
|
||||
>
|
||||
<SearchLiveResultsAnnouncer
|
||||
count={results.length}
|
||||
showing={Boolean(searchValue) && !fetching}
|
||||
/>
|
||||
</SearchInput>
|
||||
</Popover>
|
||||
)}
|
||||
{usesSideSheet ? (
|
||||
<SideSheet
|
||||
side="right"
|
||||
open={isSearchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
modal
|
||||
withOverlay
|
||||
withCloseButton
|
||||
className="mx-auto w-96 border-tint-subtle border-l bg-tint-base"
|
||||
>
|
||||
{searchFrame}
|
||||
</SideSheet>
|
||||
) : null}
|
||||
{uiAssistants.map((assistant, index) => (
|
||||
<AIChatButton
|
||||
key={assistant.id}
|
||||
|
||||
@@ -2,29 +2,34 @@
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { useAI } from '../AI';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSidebar,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskBar } from './SearchAskBar';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
|
||||
export function SearchFrame(props: {
|
||||
asEmbeddable?: boolean;
|
||||
askQuery: string;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
fetching: boolean;
|
||||
fillHeight?: boolean;
|
||||
input?: React.ReactNode;
|
||||
query: string;
|
||||
results: ResultType[];
|
||||
resultsId: string;
|
||||
resultsRef: React.Ref<SearchResultsRef>;
|
||||
onResultSelect?: () => void;
|
||||
scopeControl?: React.ReactNode;
|
||||
showAsk: boolean;
|
||||
sidebar?: React.ReactNode;
|
||||
@@ -39,52 +44,134 @@ export function SearchFrame(props: {
|
||||
dataTestId,
|
||||
error,
|
||||
fetching,
|
||||
fillHeight = false,
|
||||
input,
|
||||
query,
|
||||
results,
|
||||
resultsId,
|
||||
resultsRef,
|
||||
onResultSelect,
|
||||
scopeControl,
|
||||
showAsk,
|
||||
sidebar,
|
||||
} = props;
|
||||
|
||||
const searchAsk = useSearchAskState();
|
||||
const { assistants } = useAI();
|
||||
|
||||
return (
|
||||
<SearchAskProvider value={searchAsk}>
|
||||
<EmbeddableFrame
|
||||
className={tcls('bg-tint-base from-transparent to-transparent', className)}
|
||||
className={tcls(
|
||||
'min-h-0 bg-tint-base from-transparent to-transparent',
|
||||
fillHeight ? 'h-full' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{sidebar ? <EmbeddableFrameSidebar>{sidebar}</EmbeddableFrameSidebar> : null}
|
||||
<EmbeddableFrameMain data-testid={dataTestId}>
|
||||
<EmbeddableFrameMain className="min-h-0" data-testid={dataTestId}>
|
||||
<div
|
||||
className={tcls(
|
||||
'pointer-events-none absolute inset-x-0 top-0 z-50 h-0.5 overflow-hidden',
|
||||
fetching ? 'block animate-fade-in' : 'hidden animate-fade-out-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: fetching ? '2s' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'h-full w-full origin-left animate-crawl bg-primary-solid'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{input ? (
|
||||
<EmbeddableFrameHeader className="p-3 pb-0">{input}</EmbeddableFrameHeader>
|
||||
) : null}
|
||||
<React.Suspense fallback={null}>
|
||||
<ScrollContainer orientation="vertical" contentClassName="p-3">
|
||||
{showAsk ? (
|
||||
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
|
||||
) : (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={query}
|
||||
id={resultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
/>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex flex-col overflow-hidden',
|
||||
fillHeight ? 'min-h-0 flex-1' : ''
|
||||
)}
|
||||
</ScrollContainer>
|
||||
{scopeControl && !showAsk ? (
|
||||
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
|
||||
{scopeControl}
|
||||
>
|
||||
<ScrollContainer
|
||||
orientation="vertical"
|
||||
className={tcls(fillHeight ? 'min-h-0 flex-1' : '')}
|
||||
contentClassName={tcls(
|
||||
'gutter-stable scroll-py-3 p-3',
|
||||
fillHeight ? 'min-h-full' : ''
|
||||
)}
|
||||
>
|
||||
{showAsk ? (
|
||||
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
|
||||
) : (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={query}
|
||||
id={resultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
onResultSelect={onResultSelect}
|
||||
/>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
{!showAsk && query && assistants.length > 0
|
||||
? assistants.map((assistant, index) => (
|
||||
<SearchAskBar
|
||||
key={assistant.id}
|
||||
query={query}
|
||||
assistant={assistant}
|
||||
active={cursor === results.length + index}
|
||||
withShortcut={assistant === assistants[0]}
|
||||
onSelect={onResultSelect}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 border-tint-subtle border-t bg-tint-subtle px-4 py-1.5',
|
||||
!scopeControl ? 'not-pointer-fine:hidden' : '',
|
||||
showAsk ? 'hidden' : ''
|
||||
)}
|
||||
>
|
||||
{scopeControl && !showAsk ? scopeControl : null}
|
||||
<SearchFrameKeyboardHints />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</React.Suspense>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
</SearchAskProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchFrameKeyboardHints = () => {
|
||||
return (
|
||||
<div className="@container/keyboard-hint flex not-pointer-fine:hidden w-full items-center justify-end py-1.5">
|
||||
{/* Compact view */}
|
||||
<div className="flex @max-[6rem]/keyboard-hint:hidden @min-[12rem]/keyboard-hint:hidden items-center gap-2">
|
||||
<Tooltip label="Navigate">
|
||||
<div>
|
||||
<KeyboardShortcut className="bg-tint-base" keys={['up', 'down']} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
/
|
||||
<Tooltip label="Close">
|
||||
<div>
|
||||
<KeyboardShortcut className="bg-tint-base" keys={['esc']} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Normal view */}
|
||||
<div className="flex @max-[12rem]/keyboard-hint:hidden items-center gap-2">
|
||||
<KeyboardShortcut className="bg-tint-base" keys={['up', 'down']} />
|
||||
Navigate
|
||||
<KeyboardShortcut className="ml-1 bg-tint-base" keys={['esc']} />
|
||||
Close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { AISearchIcon } from '../AIChat';
|
||||
import { Input } from '../primitives';
|
||||
|
||||
interface SearchInputProps {
|
||||
@@ -17,6 +18,9 @@ interface SearchInputProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
mode?: 'header' | 'frame';
|
||||
resultsCount: number;
|
||||
fetching: boolean;
|
||||
showAsk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +38,9 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
className,
|
||||
children,
|
||||
mode = 'header',
|
||||
resultsCount,
|
||||
fetching,
|
||||
showAsk,
|
||||
...rest
|
||||
} = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -42,17 +49,26 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
const language = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (!isOpen) {
|
||||
inputRef.current?.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
if (document.activeElement !== inputRef.current) {
|
||||
// Refocus the input and move the caret to the end – do this only once to avoid scroll jumps on every keystroke
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
// Place cursor at the end of the input
|
||||
inputRef.current?.setSelectionRange(value.length, value.length);
|
||||
}
|
||||
} else {
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
if (!isFrame) {
|
||||
focusInput();
|
||||
return;
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
const timeout = window.setTimeout(focusInput, 150);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [isFrame, isOpen, value.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -76,14 +92,18 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
leading={
|
||||
<Icon
|
||||
icon="search"
|
||||
className={
|
||||
isFrame
|
||||
? 'size-text-lg shrink-0 text-tint-subtle'
|
||||
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
|
||||
}
|
||||
/>
|
||||
showAsk ? (
|
||||
<AISearchIcon />
|
||||
) : (
|
||||
<Icon
|
||||
icon="search"
|
||||
className={
|
||||
isFrame
|
||||
? 'size-text-lg shrink-0 text-tint-subtle'
|
||||
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
@@ -100,14 +120,21 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
|
||||
}
|
||||
}
|
||||
trailing={
|
||||
!showAsk && value && resultsCount > 0 ? (
|
||||
<div className="mr-2 animate-blur-in text-sm text-tint-subtle">
|
||||
{t(language, 'search_results_count', resultsCount.toString())}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
keyboardShortcut={
|
||||
isFrame
|
||||
? undefined
|
||||
: {
|
||||
!value && !isFrame && !isOpen
|
||||
? {
|
||||
keys: ['mod', 'k'],
|
||||
className:
|
||||
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
|
||||
keys: isOpen ? ['esc'] : ['mod', 'k'],
|
||||
'bg-tint-base site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
type="text"
|
||||
|
||||
@@ -2,51 +2,134 @@ import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { SkeletonParagraph } from '../primitives';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { Emoji } from '../primitives/Emoji/Emoji';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import type { MergedPageResult } from './reciprocalRankFusion';
|
||||
import type { ComputedPageResult } from './search-types';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
|
||||
type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult;
|
||||
|
||||
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
|
||||
props: {
|
||||
query: string;
|
||||
item: ComputedPageResult;
|
||||
item: PageItem;
|
||||
active: boolean;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { query, item, active, ...rest } = props;
|
||||
const { query, item, active, style, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const bestSection = item.type === 'page' ? item.bestSection : undefined;
|
||||
const href = (() => {
|
||||
if (item.type !== 'page' || !item.bestSection || item.bestSection.score <= item.score) {
|
||||
return 'href' in item ? item.href : item.pathname;
|
||||
}
|
||||
return item.bestSection.href;
|
||||
})();
|
||||
|
||||
const emoji = 'emoji' in item ? item.emoji : undefined;
|
||||
const icon = 'icon' in item ? item.icon : undefined;
|
||||
|
||||
const leadingIcon = emoji ? (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
<Emoji code={emoji} style="text-base leading-none" />
|
||||
</span>
|
||||
) : icon ? (
|
||||
<Icon icon={icon as IconName} className="size-4" />
|
||||
) : (
|
||||
<Icon icon="file-lines" className="size-4" />
|
||||
);
|
||||
|
||||
const insights =
|
||||
item.type === 'page'
|
||||
? {
|
||||
type: 'search_open_result' as const,
|
||||
query,
|
||||
result: {
|
||||
pageId: item.pageId,
|
||||
spaceId: item.spaceId,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SearchResultItem
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
href={href}
|
||||
active={active}
|
||||
data-testid="search-page-result"
|
||||
action={tString(language, 'view')}
|
||||
leadingIcon={<Icon icon="memo" className="size-4" />}
|
||||
insights={{
|
||||
type: 'search_open_result',
|
||||
query,
|
||||
result: {
|
||||
pageId: item.pageId,
|
||||
spaceId: item.spaceId,
|
||||
},
|
||||
}}
|
||||
leadingIcon={leadingIcon}
|
||||
insights={insights}
|
||||
aria-label={tString(language, 'search_page_result_title', item.title)}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Breadcrumbs breadcrumbs={item.breadcrumbs} />
|
||||
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
|
||||
<p className="line-clamp-1 font-semibold text-base text-tint-strong leading-snug">
|
||||
<HighlightQuery query={query} text={item.title} />
|
||||
</p>
|
||||
<div
|
||||
className={tcls(
|
||||
'relative h-5 w-full transition-[height] duration-300',
|
||||
item.type === 'local-page' && !bestSection?.body && !item.description
|
||||
? '[[aria-busy=false]_&]:h-0'
|
||||
: ''
|
||||
)}
|
||||
style={{ transitionDelay: style?.animationDelay }}
|
||||
>
|
||||
{bestSection?.body ? (
|
||||
<p
|
||||
className="absolute inset-0 line-clamp-1 origin-left animate-blur-in text-sm"
|
||||
style={{ animationDelay: style?.animationDelay }}
|
||||
>
|
||||
<HighlightQuery
|
||||
query={query}
|
||||
text={`${bestSection.title ? `${bestSection.title} · ` : ''}${bestSection.body}`}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{'description' in item && item.description ? (
|
||||
<p
|
||||
className={tcls(
|
||||
'absolute inset-0 line-clamp-1 origin-left text-sm',
|
||||
bestSection?.body ? 'hidden animate-blur-out' : ''
|
||||
)}
|
||||
style={{ animationDelay: style?.animationDelay }}
|
||||
>
|
||||
<HighlightQuery query={query} text={item.description} />
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{item.type === 'local-page' ? (
|
||||
<SkeletonParagraph
|
||||
size="small"
|
||||
lines={1}
|
||||
className={tcls(
|
||||
'absolute inset-0 origin-left',
|
||||
bestSection?.body || item.description
|
||||
? 'hidden animate-blur-out'
|
||||
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
|
||||
)}
|
||||
style={{ animationDelay: style?.animationDelay }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SearchResultItem>
|
||||
);
|
||||
});
|
||||
|
||||
const Breadcrumbs = (props: {
|
||||
breadcrumbs: ComputedPageResult['breadcrumbs'];
|
||||
breadcrumbs: PageItem['breadcrumbs'];
|
||||
withOverflow?: boolean;
|
||||
}) => {
|
||||
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
|
||||
@@ -109,6 +192,6 @@ const Breadcrumbs = (props: {
|
||||
...crumbs.slice(-1),
|
||||
]
|
||||
: crumbs,
|
||||
'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint mb-1 text-xs uppercase leading-snug'
|
||||
'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint text-xs'
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import { useSearchLink } from './useSearch';
|
||||
|
||||
@@ -11,19 +11,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
active: boolean;
|
||||
recommended?: boolean;
|
||||
assistant: Assistant;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { question, recommended = false, active, assistant, ...rest } = props;
|
||||
const { question, recommended = false, active, assistant, style, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
const getLinkProp = useSearchLink();
|
||||
|
||||
return (
|
||||
<SearchResultItem
|
||||
size={recommended ? 'small' : 'medium'}
|
||||
size="small"
|
||||
action={tString(language, 'ask', '')}
|
||||
ref={ref}
|
||||
data-testid={recommended ? 'search-recommended-question' : 'search-ask-question'}
|
||||
data-testid="search-recommended-question"
|
||||
scroll={false}
|
||||
{...getLinkProp(
|
||||
{
|
||||
@@ -36,22 +37,10 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
}
|
||||
)}
|
||||
active={active}
|
||||
leadingIcon={recommended ? 'search' : assistant.icon}
|
||||
className={recommended ? 'pr-1.5' : ''}
|
||||
leadingIcon="search"
|
||||
{...rest}
|
||||
>
|
||||
{recommended ? (
|
||||
question
|
||||
) : (
|
||||
<>
|
||||
<div className="font-semibold text-base text-tint-strong leading-tight">
|
||||
{t(language, 'search_ask', [question])}
|
||||
</div>
|
||||
<div className="text-sm text-tint-subtle">
|
||||
{t(language, 'search_ask_description', assistant.label)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{question}
|
||||
</SearchResultItem>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,10 +11,11 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu
|
||||
query: string;
|
||||
item: ComputedRecordResult;
|
||||
active: boolean;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { query, item, active, ...rest } = props;
|
||||
const { query, item, active, style, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const domain = getDomain(item.href);
|
||||
@@ -41,6 +42,7 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu
|
||||
},
|
||||
}}
|
||||
aria-label={tString(language, 'search_page_result_title', item.title)}
|
||||
style={{ ...style }}
|
||||
{...rest}
|
||||
>
|
||||
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
||||
className?: string;
|
||||
size?: 'small' | 'medium';
|
||||
leadingIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
} & LinkInsightsProps &
|
||||
Omit<React.ComponentProps<typeof Link>, keyof LinkInsightsProps>,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
@@ -25,23 +26,25 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
||||
leadingIcon,
|
||||
size = 'medium',
|
||||
action,
|
||||
disabled,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={href}
|
||||
href={href ?? '#'}
|
||||
aria-disabled={disabled}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-3',
|
||||
'group',
|
||||
'px-4',
|
||||
size === 'small' ? 'py-1.5' : 'py-3',
|
||||
'pl-3',
|
||||
'pr-2',
|
||||
'py-2',
|
||||
'text-tint',
|
||||
'hover:bg-tint',
|
||||
'hover:text-tint-strong',
|
||||
disabled ? ['pointer-events-none'] : ['hover:bg-tint', 'hover:text-tint-strong'],
|
||||
'group',
|
||||
'transition-colors',
|
||||
'rounded-corners:rounded-md',
|
||||
@@ -62,16 +65,17 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
||||
)}
|
||||
</div>
|
||||
<div className="grow">{children}</div>
|
||||
{active ? (
|
||||
{disabled ? null : active ? (
|
||||
<Button
|
||||
label={action}
|
||||
iconOnly
|
||||
variant="primary"
|
||||
size="small"
|
||||
size="xsmall"
|
||||
icon="arrow-turn-down-left"
|
||||
className="size-7"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center text-tint-subtle">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center text-tint-subtle">
|
||||
<Icon icon="chevron-right" className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import assertNever from 'assert-never';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { type Assistant, useAI } from '@/components/AI';
|
||||
import { useAI } from '@/components/AI';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Button, Loading } from '../primitives';
|
||||
import { Button, SkeletonParagraph, SkeletonSmall } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
||||
import { SearchRecordResultItem } from './SearchRecordResultItem';
|
||||
import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import type { OrderedComputedResult } from './search-types';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
select(): void;
|
||||
select(): boolean;
|
||||
}
|
||||
|
||||
type ResultType =
|
||||
| OrderedComputedResult
|
||||
| { type: 'question'; id: string; query: string; assistant: Assistant }
|
||||
| LocalPageResult
|
||||
| { type: 'recommended-question'; id: string; question: string };
|
||||
|
||||
function getResultKey(item: ResultType): string {
|
||||
switch (item.type) {
|
||||
case 'local-page':
|
||||
return `page:${item.id}`;
|
||||
case 'page':
|
||||
return `page:${item.pageId}`;
|
||||
case 'record':
|
||||
return `record:${item.id}`;
|
||||
case 'recommended-question':
|
||||
return `question:${item.id}`;
|
||||
default:
|
||||
return assertNever(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the results of the keyboard navigable elements to display for a query:
|
||||
* - Recommended questions if no query is provided.
|
||||
* - Search results if a query is provided.
|
||||
* - If withAI is true, add a question result.
|
||||
*/
|
||||
export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
@@ -38,15 +54,31 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
fetching: boolean;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
onResultSelect?: () => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, id, query, results, fetching, cursor, error } = props;
|
||||
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const shouldAnimateResults = !query || fetching;
|
||||
const previousCursor = React.useRef<number | null>(cursor);
|
||||
const seenResultKeys = React.useRef(new Set<string>());
|
||||
const lastQuery = React.useRef(query);
|
||||
|
||||
if (lastQuery.current !== query) {
|
||||
lastQuery.current = query;
|
||||
seenResultKeys.current.clear();
|
||||
}
|
||||
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const item of results) {
|
||||
seenResultKeys.current.add(getResultKey(item));
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
// Scroll to the active result.
|
||||
React.useEffect(() => {
|
||||
if (cursor === null || !refs.current[cursor]) {
|
||||
@@ -56,15 +88,22 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
refs.current[cursor]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [cursor]);
|
||||
|
||||
const shouldDisableLayoutAnimation = previousCursor.current !== cursor;
|
||||
React.useEffect(() => {
|
||||
previousCursor.current = cursor;
|
||||
}, [cursor]);
|
||||
|
||||
const select = React.useCallback(() => {
|
||||
if (cursor === null || !refs.current[cursor]) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
refs.current[cursor]?.click();
|
||||
return true;
|
||||
}, [cursor]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
@@ -76,18 +115,19 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
);
|
||||
|
||||
const { assistants } = useAI();
|
||||
const primaryAssistant = assistants[0];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
<output
|
||||
className={tcls(
|
||||
'flex',
|
||||
'grow',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-center',
|
||||
'py-8',
|
||||
'h-full',
|
||||
'gap-4'
|
||||
)}
|
||||
>
|
||||
@@ -100,27 +140,28 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
>
|
||||
{t(language, 'unexpected_error_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
const noResults = (
|
||||
<div
|
||||
<output
|
||||
className={tcls(
|
||||
'flex',
|
||||
'grow',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-center',
|
||||
'py-8',
|
||||
'h-full'
|
||||
'animate-blur-in-slow'
|
||||
)}
|
||||
>
|
||||
{t(language, 'search_no_results_for', query)}
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('min-h-full')}>
|
||||
<output className="flex grow flex-col" aria-busy={fetching}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
fetching ? null : query ? (
|
||||
@@ -132,104 +173,183 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
<>
|
||||
<div
|
||||
data-testid="search-results"
|
||||
className="flex flex-col gap-y-1"
|
||||
className="flex flex-col gap-1"
|
||||
id={id}
|
||||
role="listbox"
|
||||
aria-live="polite"
|
||||
>
|
||||
{results.map((item, index) => {
|
||||
const resultItemProps = {
|
||||
'aria-posinset': index + 1,
|
||||
'aria-setsize': results.length,
|
||||
id: `${id}-${index}`,
|
||||
};
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
);
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{results.map((item, index) => {
|
||||
const itemKey = getResultKey(item);
|
||||
const shouldAnimateItem =
|
||||
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
|
||||
const resultItemProps = {
|
||||
'aria-posinset': index + 1,
|
||||
'aria-setsize': results.length,
|
||||
id: `${id}-${index}`,
|
||||
onClickCapture: () => onResultSelect?.(),
|
||||
};
|
||||
switch (item.type) {
|
||||
case 'local-page':
|
||||
case 'page': {
|
||||
return (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={
|
||||
shouldDisableLayoutAnimation
|
||||
? { layout: { duration: 0 } }
|
||||
: { duration: 0.3, ease: 'circInOut' }
|
||||
}
|
||||
key={itemKey}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
shouldAnimateItem
|
||||
? 'animate-blur-in-height'
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
if (!primaryAssistant) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
className={
|
||||
shouldAnimateItem
|
||||
? 'animate-blur-in'
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
shouldAnimateItem
|
||||
? {
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
key={itemKey}
|
||||
>
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
assistant={primaryAssistant}
|
||||
recommended
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
case 'record': {
|
||||
return (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={
|
||||
shouldDisableLayoutAnimation
|
||||
? { layout: { duration: 0 } }
|
||||
: { duration: 0.3, ease: 'circInOut' }
|
||||
}
|
||||
key={itemKey}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
shouldAnimateItem
|
||||
? 'animate-blur-in-height'
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<SearchRecordResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={itemKey}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
|
||||
}}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
assistant={item.assistant}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
assistant={assistants[0]!}
|
||||
recommended
|
||||
{...resultItemProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'record': {
|
||||
return (
|
||||
<SearchRecordResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
{...resultItemProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
})}
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{!fetching && !results.some((result) => result.type !== 'question')
|
||||
? noResults
|
||||
: null}
|
||||
{!fetching && results.length === 0 ? noResults : null}
|
||||
</>
|
||||
)}
|
||||
{fetching ? (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-6')}>
|
||||
<Loading className={tcls('w-6', 'text-tint/6')} />
|
||||
<div
|
||||
className={tcls(
|
||||
results.length > 0 ? 'mt-1' : '',
|
||||
'flex animate-blur-in flex-col gap-1'
|
||||
)}
|
||||
style={{ animationDelay: `${results.length * 25}ms` }}
|
||||
>
|
||||
<SearchResultsSkeleton items={Math.max(3, 5 - results.length)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
});
|
||||
|
||||
const SearchResultsSkeleton = (props: { items: number }) => {
|
||||
const { items } = props;
|
||||
const skeletonKeys = Array.from({ length: items }, (_, index) => `skeleton:${index}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonKeys.map((key, index) => (
|
||||
<SearchResultItem
|
||||
key={key}
|
||||
active={false}
|
||||
href="#"
|
||||
action=""
|
||||
disabled
|
||||
data-testid="search-page-result"
|
||||
leadingIcon={
|
||||
<SkeletonSmall
|
||||
className="size-4"
|
||||
style={{ animationDelay: `${index * 0.3}s` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SkeletonParagraph size="small" start={index * 3} />
|
||||
</SearchResultItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
|
||||
|
||||
const state = useSearchState();
|
||||
|
||||
if (!state) {
|
||||
if (!state?.open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
|
||||
const variantScopeIsExtended = ['extended', 'all'].includes(state.scope);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
<SearchScopeTitle />
|
||||
{withSections ? (
|
||||
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import type { ComputedSectionResult } from './search-types';
|
||||
|
||||
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
|
||||
props: {
|
||||
query: string;
|
||||
item: ComputedSectionResult;
|
||||
active: boolean;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { query, item, active, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<SearchResultItem
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
size="small"
|
||||
active={active}
|
||||
action={tString(language, 'view')}
|
||||
data-testid="search-page-section-result"
|
||||
insights={{
|
||||
type: 'search_open_result',
|
||||
query,
|
||||
result: {
|
||||
pageId: item.pageId,
|
||||
spaceId: item.spaceId,
|
||||
},
|
||||
}}
|
||||
aria-label={
|
||||
item.title
|
||||
? tString(language, 'search_section_result_title', item.title)
|
||||
: item.body
|
||||
? tString(
|
||||
language,
|
||||
'search_section_result_content',
|
||||
getAbbreviatedBody(item.body, query)
|
||||
)
|
||||
: tString(language, 'search_section_result_default')
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
<div className="grow border-tint-subtle border-l-2 pl-4">
|
||||
{item.title ? (
|
||||
<p className="font-semibold">
|
||||
<HighlightQuery query={query} text={item.title} />
|
||||
</p>
|
||||
) : null}
|
||||
{item.body ? highlightQueryInBody(item.body, query) : null}
|
||||
</div>
|
||||
</SearchResultItem>
|
||||
);
|
||||
});
|
||||
|
||||
export function highlightQueryInBody(body: string, query: string) {
|
||||
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
|
||||
|
||||
// Ensure the query to be highlighted is visible in the body.
|
||||
return (
|
||||
<p className="wrap-anywhere relative line-clamp-3 text-sm">
|
||||
<HighlightQuery query={query} text={idx < 20 ? body : `…${body.slice(idx - 10)}`} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getAbbreviatedBody(body: string, query: string) {
|
||||
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
|
||||
return idx < 20 ? body.slice(0, 100) : `…${body.slice(idx - 10, idx + query.length + 30)}…`;
|
||||
}
|
||||
@@ -5,4 +5,5 @@ export * from './SearchContainer';
|
||||
export * from './SearchScopeControl';
|
||||
export * from './search-props';
|
||||
export * from './useSearch';
|
||||
export * from './useLocalSearchResults';
|
||||
export * from './useSearchController';
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ComputedPageResult, OrderedComputedResult } from './search-types';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
|
||||
/**
|
||||
* Jaro-Winkler similarity between two strings, in the range [0, 1].
|
||||
* Higher is more similar. 1 means an exact match.
|
||||
* https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
|
||||
*/
|
||||
function jaroWinkler(s1: string, s2: string, p = 0.1): number {
|
||||
if (s1 === s2) return 1;
|
||||
const len1 = s1.length;
|
||||
const len2 = s2.length;
|
||||
if (len1 === 0 || len2 === 0) return 0;
|
||||
|
||||
const maxDist = Math.floor(Math.max(len1, len2) / 2) - 1;
|
||||
|
||||
const matched1 = new Array<boolean>(len1);
|
||||
const matched2 = new Array<boolean>(len2);
|
||||
for (let i = 0; i < len1; i++) matched1[i] = false;
|
||||
for (let i = 0; i < len2; i++) matched2[i] = false;
|
||||
|
||||
let matches = 0;
|
||||
|
||||
for (let i = 0; i < len1; i++) {
|
||||
const start = Math.max(0, i - maxDist);
|
||||
const end = Math.min(len2 - 1, i + maxDist);
|
||||
|
||||
for (let j = start; j <= end; j++) {
|
||||
if (!matched2[j] && s1.charCodeAt(i) === s2.charCodeAt(j)) {
|
||||
matched1[i] = true;
|
||||
matched2[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches === 0) return 0;
|
||||
|
||||
let t = 0;
|
||||
let k = 0;
|
||||
|
||||
for (let i = 0; i < len1; i++) {
|
||||
if (!matched1[i]) continue;
|
||||
while (!matched2[k]) k++;
|
||||
if (s1.charCodeAt(i) !== s2.charCodeAt(k)) t++;
|
||||
k++;
|
||||
}
|
||||
|
||||
const transpositions = t / 2;
|
||||
const m = matches;
|
||||
const jaro = (m / len1 + m / len2 + (m - transpositions) / m) / 3;
|
||||
|
||||
let prefix = 0;
|
||||
const maxPrefix = 4;
|
||||
const minLen = Math.min(len1, len2);
|
||||
for (let i = 0; i < maxPrefix && i < minLen; i++) {
|
||||
if (s1.charCodeAt(i) === s2.charCodeAt(i)) prefix++;
|
||||
else break;
|
||||
}
|
||||
|
||||
return jaro + prefix * p * (1 - jaro);
|
||||
}
|
||||
|
||||
/** Standard RRF constant — moderates the impact of high-ranked items. */
|
||||
const RRF_K = 60;
|
||||
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
|
||||
const RRF_K_LOCAL = 50;
|
||||
|
||||
/**
|
||||
* A page result that was present in both local and remote lists.
|
||||
* Local fields (description, icon, emoji, pathname) are carried over as a base,
|
||||
* and remote fields (href, pageId, spaceId, title) override them.
|
||||
* Breadcrumbs prefer local (has icon + emoji) and fall back to remote.
|
||||
*/
|
||||
export type MergedPageResult = ComputedPageResult & {
|
||||
pathname?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive a stable deduplication key for any result type.
|
||||
* Pages from both local and remote share the same `page:` namespace so they
|
||||
* can be deduplicated against each other.
|
||||
*/
|
||||
export function getResultKey(
|
||||
result: LocalPageResult | OrderedComputedResult | MergedPageResult
|
||||
): string {
|
||||
switch (result.type) {
|
||||
case 'local-page':
|
||||
return `page:${result.id}`;
|
||||
case 'page':
|
||||
return `page:${result.pageId}`;
|
||||
case 'record':
|
||||
return `record:${result.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
|
||||
|
||||
/**
|
||||
* Merge local (FlexSearch) and remote (API) search results using
|
||||
* Reciprocal Rank Fusion (RRF).
|
||||
*
|
||||
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
|
||||
*
|
||||
* Pages present in both lists are deep-merged: local fields act as the base
|
||||
* (preserving description, icon, emoji, pathname) and remote fields override
|
||||
* (providing href, pageId, spaceId, title). Breadcrumbs prefer local (has icon
|
||||
* + emoji) and fall back to remote. Their rank contributions from both lists are summed.
|
||||
*
|
||||
* Sections and records have no local equivalent and only accumulate their own
|
||||
* rank contribution.
|
||||
*
|
||||
* Additionally, a Jaro-Winkler title-match bonus is applied per query word:
|
||||
* for each query word that appears as a substring in the title, the best
|
||||
* Jaro-Winkler similarity score against any individual title word is added.
|
||||
*/
|
||||
export function reciprocalRankFusion(
|
||||
localResults: LocalPageResult[],
|
||||
remoteResults: OrderedComputedResult[],
|
||||
query: string
|
||||
): Array<RRFResult> {
|
||||
// Map from dedup key → { result, score }
|
||||
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
|
||||
|
||||
// Process local results first (1-indexed rank)
|
||||
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
|
||||
localResults.forEach((result, index) => {
|
||||
const rank = index + 1;
|
||||
const key = getResultKey(result);
|
||||
const contribution = 1 / (RRF_K_LOCAL + rank);
|
||||
|
||||
const existing = scoreMap.get(key);
|
||||
if (existing) {
|
||||
existing.score += contribution;
|
||||
} else {
|
||||
scoreMap.set(key, { result, score: contribution });
|
||||
}
|
||||
});
|
||||
|
||||
// Process remote results, deduplicating against local pages
|
||||
remoteResults.forEach((result, index) => {
|
||||
const rank = index + 1;
|
||||
const contribution = 1 / (RRF_K + rank);
|
||||
|
||||
const key = getResultKey(result);
|
||||
|
||||
const existing = scoreMap.get(key);
|
||||
if (existing) {
|
||||
// Page found in both lists: sum rank contributions and deep-merge.
|
||||
// Local is the base (description, icon, emoji, pathname), remote overrides
|
||||
// (href, pageId, spaceId, breadcrumbs, title).
|
||||
existing.score += contribution;
|
||||
if (existing.result.type === 'local-page' && result.type === 'page') {
|
||||
existing.result = {
|
||||
...existing.result,
|
||||
...result,
|
||||
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
|
||||
breadcrumbs: existing.result.breadcrumbs ?? result.breadcrumbs,
|
||||
} as MergedPageResult;
|
||||
} else {
|
||||
existing.result = result;
|
||||
}
|
||||
} else {
|
||||
scoreMap.set(key, { result, score: contribution });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply a title-match bonus per query word using Jaro-Winkler similarity.
|
||||
// For each query word that appears (as a substring) in the title, compute the
|
||||
// best Jaro-Winkler score against individual title words and add it to the score.
|
||||
const queryWords = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0);
|
||||
|
||||
if (queryWords.length > 0) {
|
||||
for (const entry of scoreMap.values()) {
|
||||
// Only apply the bonus to page or local-page results
|
||||
if (entry.result.type === 'record') continue;
|
||||
const title = entry.result.title?.toLowerCase() ?? '';
|
||||
const titleWords = title.split(/\s+/).filter((w) => w.length > 0);
|
||||
for (const word of queryWords) {
|
||||
// Only apply when there is at least a substring match in the title.
|
||||
if (!title.includes(word)) continue;
|
||||
let best = 0;
|
||||
for (const titleWord of titleWords) {
|
||||
const sim = jaroWinkler(word, titleWord);
|
||||
if (sim > best) best = sim;
|
||||
}
|
||||
entry.score += best;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by RRF score
|
||||
return Array.from(scoreMap.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ result }) => result);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface SearchBaseProps {
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
indexURL: string;
|
||||
searchURL: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
|
||||
visibleSections?.list.some(
|
||||
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
|
||||
) ?? false,
|
||||
indexURL: context.linker.toPathInSite('~gitbook/site-index'),
|
||||
withVariants: visibleSiteSpaces.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
export type OrderedComputedResult =
|
||||
| ComputedPageResult
|
||||
| ComputedSectionResult
|
||||
| ComputedRecordResult;
|
||||
export type OrderedComputedResult = ComputedPageResult | ComputedRecordResult;
|
||||
|
||||
export type BaseComputedResult = {
|
||||
id: string;
|
||||
@@ -24,6 +21,13 @@ export type ComputedPageResult = BaseComputedResult & {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
|
||||
/** The highest-scoring section for this page, used as a body snippet preview. */
|
||||
bestSection?: {
|
||||
href: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
score: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ComputedRecordResult = BaseComputedResult & {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
RevisionPage,
|
||||
SearchAIAnswer,
|
||||
SearchAIRecommendedQuestionStream,
|
||||
SiteInsightsSession,
|
||||
} from '@gitbook/api';
|
||||
import { createStreamableValue } from 'ai/rsc';
|
||||
import type * as React from 'react';
|
||||
@@ -40,9 +41,11 @@ export interface AskAnswerResult {
|
||||
export async function streamAskQuestion({
|
||||
asEmbeddable,
|
||||
question,
|
||||
session,
|
||||
}: {
|
||||
asEmbeddable?: boolean;
|
||||
question: string;
|
||||
session: SiteInsightsSession;
|
||||
}) {
|
||||
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||
@@ -66,6 +69,7 @@ export async function streamAskQuestion({
|
||||
mode: 'default',
|
||||
currentSiteSpace: context.siteSpace.id,
|
||||
},
|
||||
session,
|
||||
},
|
||||
{ format: 'document' }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { Document, type DocumentValue } from 'flexsearch';
|
||||
import React from 'react';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Raw entry from the `~gitbook/index` JSON response */
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
/** BCP-47 language code emitted by the index route, absent when no language is set. */
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
|
||||
interface IndexPage {
|
||||
[key: string]: DocumentValue | DocumentValue[];
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
siteSpaceId: string;
|
||||
}
|
||||
|
||||
/** Result type returned by this hook */
|
||||
export interface LocalPageResult {
|
||||
type: 'local-page';
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type LocalSearchState = {
|
||||
results: LocalPageResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
// Module-level singletons — one Document per language per session.
|
||||
// Keys are the page's `lang` value, or `''` when no language is set.
|
||||
const cachedIndexes = new Map<string, Document<IndexPage>>();
|
||||
|
||||
// Side-map for data that doesn't belong in the FlexSearch index.
|
||||
// Keyed by page id, shared across all language groups.
|
||||
const cachedPageData = new Map<
|
||||
string,
|
||||
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] }
|
||||
>();
|
||||
|
||||
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
||||
|
||||
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
|
||||
const index = new Document<IndexPage>({
|
||||
document: {
|
||||
id: 'id',
|
||||
index: ['title', 'description'],
|
||||
store: ['id', 'title', 'description', 'siteSpaceId'],
|
||||
tag: 'siteSpaceId',
|
||||
},
|
||||
tokenize: 'full',
|
||||
resolution: 15,
|
||||
encoder: 'Normalize',
|
||||
});
|
||||
|
||||
for (const page of pages) {
|
||||
index
|
||||
.addAsync({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
description: page.description ?? null,
|
||||
siteSpaceId: page.siteSpaceId,
|
||||
})
|
||||
.catch(() => {
|
||||
// We just ignore these errors, it's not worth failing the whole index for a single bad record
|
||||
// And we still have remote search as a fallback for these cases
|
||||
});
|
||||
|
||||
cachedPageData.set(`${page.siteSpaceId}:${page.id}`, {
|
||||
pathname: page.pathname,
|
||||
icon: page.icon,
|
||||
emoji: page.emoji,
|
||||
breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document<IndexPage>>> {
|
||||
if (cachedIndexes.size > 0) {
|
||||
return cachedIndexes;
|
||||
}
|
||||
|
||||
if (pendingFetch) {
|
||||
return pendingFetch;
|
||||
}
|
||||
|
||||
pendingFetch = (async () => {
|
||||
const response = await fetch(indexURL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: { version: 1; pages: RawIndexPage[] } = await response.json();
|
||||
|
||||
// Group pages by their `lang` value (empty string for pages without one)
|
||||
const pagesByLang = new Map<string, RawIndexPage[]>();
|
||||
for (const page of data.pages) {
|
||||
const key = page.lang ?? '';
|
||||
const bucket = pagesByLang.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(page);
|
||||
} else {
|
||||
pagesByLang.set(key, [page]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build one FlexSearch Document per language group
|
||||
for (const [lang, pages] of pagesByLang) {
|
||||
cachedIndexes.set(lang, buildLangIndex(pages));
|
||||
}
|
||||
|
||||
return cachedIndexes;
|
||||
})();
|
||||
|
||||
// Clear pendingFetch on error so a retry is possible
|
||||
pendingFetch.catch(() => {
|
||||
console.error('Error fetching/building search index', indexURL);
|
||||
pendingFetch = null;
|
||||
});
|
||||
|
||||
return pendingFetch;
|
||||
}
|
||||
|
||||
export function useLocalSearchResults(props: {
|
||||
query: string;
|
||||
indexURL: string;
|
||||
/** BCP-47 language code of the current site space. When provided, only results
|
||||
* from pages with a matching language are returned. */
|
||||
lang?: string;
|
||||
/** When provided, only results from pages belonging to one of these site space IDs
|
||||
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
||||
filterSiteSpaceIds?: string[];
|
||||
disabled?: boolean;
|
||||
}): LocalSearchState {
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false } = props;
|
||||
|
||||
const [state, setState] = React.useState<LocalSearchState>({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
// Track whether the indexes are loaded so the search effect re-runs after load
|
||||
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
|
||||
|
||||
// Load the indexes once
|
||||
React.useEffect(() => {
|
||||
if (cachedIndexes.size > 0) {
|
||||
setIndexReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
||||
|
||||
getOrBuildIndexes(indexURL)
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setIndexReady(true);
|
||||
setState((prev) => ({ ...prev, fetching: false }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setState({ results: [], fetching: false, error: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [indexURL]);
|
||||
|
||||
// Perform instant local search whenever query, lang, or index readiness changes
|
||||
React.useEffect(() => {
|
||||
// Resolve the per-language index to query. When `lang` is not set we fall
|
||||
// back to the `''` bucket (pages with no language tag).
|
||||
const langKey = lang ?? '';
|
||||
const index = cachedIndexes.get(langKey);
|
||||
|
||||
if (disabled || !indexReady || !index) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
setState({ results: [], fetching: false, error: false });
|
||||
return;
|
||||
}
|
||||
|
||||
//@ts-ignore - Typing is wrong here, tags can be arrays when using the `tag` filter option
|
||||
const rawResults = index.search(query, {
|
||||
enrich: true,
|
||||
limit: 5,
|
||||
suggest: true,
|
||||
...(filterSiteSpaceIds
|
||||
? {
|
||||
tag: {
|
||||
siteSpaceId: filterSiteSpaceIds,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Flatten and deduplicate results across fields (flexsearch returns one array per indexed field)
|
||||
const seen = new Set<string>();
|
||||
const results: LocalPageResult[] = [];
|
||||
|
||||
for (const fieldResult of rawResults) {
|
||||
for (const item of fieldResult.result) {
|
||||
const doc = (item as { id: string; doc: IndexPage }).doc;
|
||||
const cacheKey = `${doc.siteSpaceId}:${doc.id}`;
|
||||
if (!seen.has(cacheKey)) {
|
||||
seen.add(cacheKey);
|
||||
const extra = cachedPageData.get(cacheKey);
|
||||
results.push({
|
||||
type: 'local-page',
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
pathname: extra?.pathname ?? '',
|
||||
icon: extra?.icon,
|
||||
emoji: extra?.emoji,
|
||||
description: (doc.description as string | null) ?? undefined,
|
||||
breadcrumbs: extra?.breadcrumbs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState({ results, fetching: false, error: false });
|
||||
}, [query, lang, filterSiteSpaceIds, indexReady, disabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -62,11 +62,15 @@ function useSearchKeyboardNavigation(props: {
|
||||
query: string;
|
||||
results: ReturnType<typeof useSearchResults>['results'];
|
||||
resultsRef: React.RefObject<SearchResultsRef | null>;
|
||||
abort: () => void;
|
||||
askCount: number;
|
||||
onAskSelect: (index: number) => void;
|
||||
}) {
|
||||
const { query, results, resultsRef } = props;
|
||||
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
|
||||
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
||||
query,
|
||||
results,
|
||||
resultCount: results.length,
|
||||
totalCount: results.length + askCount,
|
||||
});
|
||||
|
||||
const onInputKeyDown = React.useCallback(
|
||||
@@ -79,10 +83,18 @@ function useSearchKeyboardNavigation(props: {
|
||||
moveCursorBy(1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.select();
|
||||
if (cursor !== null && cursor >= results.length) {
|
||||
onAskSelect(cursor - results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultsRef.current?.select()) {
|
||||
// Stop any in-flight search request only when activating a result.
|
||||
abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveCursorBy, resultsRef]
|
||||
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -101,6 +113,7 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
withSections,
|
||||
siteSpaces,
|
||||
searchURL,
|
||||
indexURL,
|
||||
} = props;
|
||||
|
||||
const { assistants, config } = useAI();
|
||||
@@ -178,7 +191,7 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
language: siteSpace.space.language,
|
||||
});
|
||||
|
||||
const { results, fetching, error } = useSearchResults({
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(state?.query || withAI),
|
||||
query: normalizedQuery,
|
||||
@@ -187,23 +200,52 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
scope: state?.scope ?? 'default',
|
||||
suggestions: config.suggestions,
|
||||
searchURL,
|
||||
indexURL,
|
||||
lang: siteSpace.space.language,
|
||||
});
|
||||
|
||||
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
||||
const searchResultsId = `search-results-${React.useId()}`;
|
||||
|
||||
const askInAssistant = React.useCallback(
|
||||
(assistantIndex = 0) => {
|
||||
const assistant = assistants[assistantIndex];
|
||||
if (!assistant || !normalizedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort();
|
||||
assistant.open(normalizedQuery);
|
||||
setSearchState({
|
||||
ask: normalizedQuery,
|
||||
query: null,
|
||||
scope: state?.scope ?? 'default',
|
||||
open: assistant.mode === 'search',
|
||||
});
|
||||
},
|
||||
[abort, assistants, normalizedQuery, setSearchState, state?.scope]
|
||||
);
|
||||
|
||||
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
|
||||
|
||||
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
|
||||
query: normalizedQuery,
|
||||
results,
|
||||
resultsRef,
|
||||
abort,
|
||||
askCount,
|
||||
onAskSelect: askInAssistant,
|
||||
});
|
||||
|
||||
return {
|
||||
assistants,
|
||||
askQuery: normalizedAsk,
|
||||
askCount,
|
||||
askInAssistant,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
abort,
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
query: normalizedQuery,
|
||||
|
||||
@@ -6,18 +6,21 @@ import { assert } from 'ts-essentials';
|
||||
import type { OrderedComputedResult } from './search-types';
|
||||
import { streamRecommendedQuestions } from './server-actions';
|
||||
|
||||
import { type Assistant, useAI } from '@/components/AI';
|
||||
import { useAI } from '@/components/AI';
|
||||
import assertNever from 'assert-never';
|
||||
import { useEventCallback } from 'usehooks-ts';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { isQuestion } from './isQuestion';
|
||||
import { type MergedPageResult, reciprocalRankFusion } from './reciprocalRankFusion';
|
||||
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
export type ResultType =
|
||||
| OrderedComputedResult
|
||||
| { type: 'question'; id: string; query: string; assistant: Assistant }
|
||||
| LocalPageResult
|
||||
| MergedPageResult
|
||||
| { type: 'recommended-question'; id: string; question: string };
|
||||
|
||||
export type { LocalPageResult, MergedPageResult };
|
||||
|
||||
/**
|
||||
* We cache the recommended questions globally to avoid calling the API multiple times
|
||||
* when re-opening the search modal. The cache is per space, so that we can
|
||||
@@ -36,6 +39,10 @@ export function useSearchResults(props: {
|
||||
suggestions?: string[];
|
||||
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
||||
searchURL: string;
|
||||
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */
|
||||
indexURL: string;
|
||||
/** BCP-47 language code of the current site space, used to filter local search results. */
|
||||
lang?: string;
|
||||
}) {
|
||||
const {
|
||||
asEmbeddable,
|
||||
@@ -46,18 +53,42 @@ export function useSearchResults(props: {
|
||||
scope,
|
||||
suggestions,
|
||||
searchURL,
|
||||
indexURL,
|
||||
lang,
|
||||
} = props;
|
||||
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
const [resultsState, setResultsState] = React.useState<{
|
||||
results: ResultType[];
|
||||
const filterSiteSpaceIds = React.useMemo(() => {
|
||||
switch (scope) {
|
||||
case 'current':
|
||||
return [siteSpaceId];
|
||||
case 'extended':
|
||||
return siteSpaceIds;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [scope, siteSpaceId, siteSpaceIds]);
|
||||
|
||||
const { results: localResults } = useLocalSearchResults({
|
||||
query,
|
||||
indexURL,
|
||||
lang,
|
||||
disabled,
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
|
||||
// Track the current in-flight fetch so it can be aborted imperatively
|
||||
// when the user navigates away before the request completes.
|
||||
const abortRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const { assistants } = useAI();
|
||||
const getAssistants = useEventCallback(() => assistants);
|
||||
const withAI = assistants.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -66,7 +97,7 @@ export function useSearchResults(props: {
|
||||
}
|
||||
if (!query) {
|
||||
if (!withAI) {
|
||||
setResultsState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,11 +107,12 @@ export function useSearchResults(props: {
|
||||
results,
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
setResultsState({ results, fetching: false, error: false });
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setResultsState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -93,15 +125,7 @@ export function useSearchResults(props: {
|
||||
suggestions.forEach((question) => {
|
||||
questions.add(question);
|
||||
});
|
||||
setResultsState({
|
||||
results: suggestions.map((question, index) => ({
|
||||
type: 'recommended-question',
|
||||
id: `recommended-question-${index}`,
|
||||
question,
|
||||
})),
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,11 +154,8 @@ export function useSearchResults(props: {
|
||||
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||
|
||||
if (!cancelled) {
|
||||
setResultsState({
|
||||
results: [...recommendedQuestions],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -144,8 +165,8 @@ export function useSearchResults(props: {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
setResultsState({
|
||||
results: withAI ? withAskTriggers([], query, getAssistants()) : [],
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
@@ -191,15 +212,11 @@ export function useSearchResults(props: {
|
||||
// One time when this one returns undefined is when it cannot find the server action and returns the html from the page.
|
||||
// In that case, we want to avoid being stuck in a loading state, but it is an error.
|
||||
// We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience.
|
||||
setResultsState({ results: [], fetching: false, error: true });
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const aiEnrichedResults = withAI
|
||||
? withAskTriggers(results, query, getAssistants())
|
||||
: results;
|
||||
|
||||
setResultsState({ results: aiEnrichedResults, fetching: false, error: false });
|
||||
setRemoteState({ results, fetching: false, error: false });
|
||||
|
||||
trackEvent({
|
||||
type: 'search_type_query',
|
||||
@@ -210,14 +227,21 @@ export function useSearchResults(props: {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setResultsState({ results: [], fetching: false, error: true });
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
}
|
||||
}, 200);
|
||||
|
||||
abortRef.current = () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
abortController.abort();
|
||||
abortRef.current = null;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
@@ -230,10 +254,43 @@ export function useSearchResults(props: {
|
||||
suggestions,
|
||||
searchURL,
|
||||
asEmbeddable,
|
||||
getAssistants,
|
||||
]);
|
||||
|
||||
return resultsState;
|
||||
const abort = React.useCallback(() => {
|
||||
abortRef.current?.();
|
||||
abortRef.current = null;
|
||||
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
|
||||
}, []);
|
||||
|
||||
// Merge local and remote results.
|
||||
// Re-runs immediately whenever either result set changes.
|
||||
const results = React.useMemo<ResultType[]>(() => {
|
||||
if (!query) {
|
||||
// No query: show recommended questions (AI-only path) or nothing.
|
||||
if (withAI && cachedRecommendedQuestions.has(siteSpaceId)) {
|
||||
return cachedRecommendedQuestions.get(siteSpaceId) ?? [];
|
||||
}
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
return suggestions.map((question, index) => ({
|
||||
type: 'recommended-question' as const,
|
||||
id: `recommended-question-${index}`,
|
||||
question,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const merged = reciprocalRankFusion(localResults, remoteState.results, query);
|
||||
|
||||
return merged;
|
||||
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions]);
|
||||
|
||||
return {
|
||||
results,
|
||||
fetching: remoteState.fetching,
|
||||
error: remoteState.error,
|
||||
abort,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,31 +323,3 @@ async function fetchSearchResults(
|
||||
|
||||
return response.json() as Promise<OrderedComputedResult[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withAskTriggers(
|
||||
results: ResultType[],
|
||||
query: string,
|
||||
assistants: Assistant[]
|
||||
): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
}
|
||||
|
||||
const queryIsQuestion = isQuestion(query);
|
||||
|
||||
return [
|
||||
...(queryIsQuestion ? [] : (without ?? [])),
|
||||
...assistants.map((assistant, index) => ({
|
||||
type: 'question' as const,
|
||||
id: `question-${index}`,
|
||||
query,
|
||||
assistant,
|
||||
})),
|
||||
...(!queryIsQuestion ? [] : (without ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
|
||||
export function useSearchResultsCursor(props: { query: string; results: ResultType[] }) {
|
||||
export function useSearchResultsCursor(props: {
|
||||
query: string;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const { query, results } = props;
|
||||
const { query, resultCount, totalCount } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -13,22 +16,48 @@ export function useSearchResultsCursor(props: { query: string; results: ResultTy
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setCursor(0);
|
||||
if (totalCount === 0) {
|
||||
return;
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
setCursor((prev) => {
|
||||
if (resultCount > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return prev ?? 0;
|
||||
});
|
||||
}, [resultCount, totalCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCursor((prev) => {
|
||||
if (prev === null) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(prev, totalCount - 1);
|
||||
});
|
||||
}, [totalCount]);
|
||||
|
||||
const moveBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
setCursor((prev) => {
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prev === null) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(Math.min(prev + delta, results.length - 1), 0);
|
||||
|
||||
return Math.max(Math.min(prev + delta, totalCount - 1), 0);
|
||||
});
|
||||
},
|
||||
[results]
|
||||
[totalCount]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LoadIntegrations } from '@/components/Integrations';
|
||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL } from '@/lib/env';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { isSiteIndexable } from '@/lib/seo';
|
||||
import { AIContextProvider } from '../AI';
|
||||
@@ -34,11 +34,16 @@ export async function SiteLayout(props: {
|
||||
const scripts = withTracking ? context.scripts : [];
|
||||
|
||||
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
|
||||
ReactDOM.preconnect(GITBOOK_ICONS_URL);
|
||||
if (GITBOOK_ASSETS_URL) {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
|
||||
// We also preload the site index
|
||||
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
|
||||
as: 'fetch',
|
||||
type: 'application/json',
|
||||
});
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
ReactDOM.preload(script, {
|
||||
as: 'script',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import { useSiteAdaptiveAuthLoginHref, useSpaceBasePath } from '../SpaceLayout/SpaceLayoutContext';
|
||||
import { CurrentPageProvider } from '../hooks';
|
||||
@@ -40,6 +41,8 @@ export function SitePageNotFound() {
|
||||
<CurrentPageProvider page={null}>
|
||||
<div
|
||||
className={tcls(
|
||||
'layout-default',
|
||||
'page-has-toc',
|
||||
'flex-1',
|
||||
'flex',
|
||||
'flex-row',
|
||||
@@ -49,6 +52,7 @@ export function SitePageNotFound() {
|
||||
'min-h-[calc(100vh-64px)] lg:min-h-fit'
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout wideLayout={false} pageHasToc={true} />
|
||||
<div className={tcls('max-w-80')}>
|
||||
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
|
||||
{t(
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SitePageSkeleton() {
|
||||
>
|
||||
<div className={tcls('flex-1', CONTENT_STYLE)}>
|
||||
<SkeletonHeading style={tcls('mb-8')} />
|
||||
<SkeletonParagraph style={tcls('mb-4')} />
|
||||
<SkeletonParagraph className={tcls('mb-4')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex'
|
||||
'shortcut flex not-pointer-fine:hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong'
|
||||
)}
|
||||
>
|
||||
{keys.map((key, index) => {
|
||||
@@ -33,6 +33,14 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
case 'enter':
|
||||
element = <Icon icon="arrow-turn-down-left" className="size-[.9em]" />;
|
||||
break;
|
||||
|
||||
case 'up':
|
||||
element = <Icon icon="arrow-up" className="size-[.9em]" />;
|
||||
break;
|
||||
|
||||
case 'down':
|
||||
element = <Icon icon="arrow-down" className="size-[.9em]" />;
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<kbd
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Popover(props: {
|
||||
collisionPadding={contentProps?.collisionPadding ?? 16}
|
||||
sideOffset={contentProps?.sideOffset ?? 4}
|
||||
className={tcls(
|
||||
'z-50 max-h-(--radix-popover-content-available-height) max-w-xs animate-scale-in overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-xl shadow-tint-12/4 outline-hidden ring-1 ring-tint transition-all empty:hidden data-[state="closed"]:animate-scale-out motion-reduce:transition-none dark:shadow-tint-1/6',
|
||||
'z-50 max-h-(--radix-popover-content-available-height) max-w-xs animate-scale-in overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-xl shadow-tint-12/4 outline-hidden ring-1 ring-tint transition-all empty:hidden data-[state="closed"]:animate-scale-out motion-reduce:transition-none dark:shadow-tint-1/6',
|
||||
contentProps?.className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function SideSheet(
|
||||
const isMobile = useIsMobile();
|
||||
const isModal = modal === 'mobile' ? isMobile : modal;
|
||||
const asideRef = React.useRef<HTMLElement>(null);
|
||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
// Internal state for uncontrolled mode (only used when open prop is undefined)
|
||||
const [open, setOpen] = React.useState(openState ?? false);
|
||||
@@ -72,6 +74,15 @@ export function SideSheet(
|
||||
const [shouldHide, setShouldHide] = React.useState(!isOpen);
|
||||
|
||||
// Track if component has been opened to prevent initial animation
|
||||
React.useEffect(() => {
|
||||
if (isModal) {
|
||||
setPortalTarget(document.body);
|
||||
return;
|
||||
}
|
||||
|
||||
setPortalTarget(null);
|
||||
}, [isModal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (isOpen) {
|
||||
@@ -191,7 +202,7 @@ export function SideSheet(
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isModal, isOpen]);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<>
|
||||
{withOverlay ? (
|
||||
<SideSheetOverlay
|
||||
@@ -206,7 +217,7 @@ export function SideSheet(
|
||||
'side-sheet',
|
||||
'fixed inset-y-0 z-41', // Above the side sheet overlay on z-40
|
||||
side === 'left' ? 'left-0' : 'right-0',
|
||||
withCloseButton ? 'max-w-[calc(100%-4rem)]' : 'max-w-[calc(100%-3rem)]',
|
||||
withCloseButton ? 'max-w-[calc(100%-4.5rem)]' : 'max-w-[calc(100%-3rem)]',
|
||||
isOpen
|
||||
? side === 'left'
|
||||
? 'hydrated:animate-enter-from-left'
|
||||
@@ -240,6 +251,12 @@ export function SideSheet(
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isModal && portalTarget) {
|
||||
return createPortal(content, portalTarget);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Backdrop overlay shown behind the modal sheet */
|
||||
|
||||
@@ -7,13 +7,49 @@ import { LoadingPane } from './LoadingPane';
|
||||
* Placeholder to be used when a content is not yet loaded (in a React.Suspense boundary).
|
||||
* It's used when streaming the content of a page.
|
||||
*/
|
||||
export function SkeletonParagraph(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
export function SkeletonParagraph(props: {
|
||||
lines?: number;
|
||||
id?: string;
|
||||
size?: 'xsmall' | 'small' | 'medium';
|
||||
start?: number;
|
||||
className?: ClassValue;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { lines = 3, id, size = 'medium', start = 0, className, style } = props;
|
||||
|
||||
const lineHeight = size === 'small' ? 'h-5' : 'h-6';
|
||||
const itemHeight = size === 'small' ? 'h-3' : 'h-4';
|
||||
const wordGap = size === 'small' ? 'gap-1' : 'gap-2';
|
||||
|
||||
return (
|
||||
<div id={id} role="status" aria-busy className="skeleton-paragraph">
|
||||
<LoadingPane
|
||||
style={['rounded-md', 'h-[calc(15rem-1px)]', 'max-w-[calc(48rem-1px)]', style]}
|
||||
/>
|
||||
<div
|
||||
className={tcls('flex flex-col', className)}
|
||||
id={id}
|
||||
role="status"
|
||||
aria-busy
|
||||
style={style}
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, line) => {
|
||||
const itemsPerLine = (line % 2) + 2;
|
||||
return (
|
||||
<div
|
||||
key={line + start}
|
||||
className={tcls('flex items-center', lineHeight, wordGap)}
|
||||
style={{ width: `${(4 - ((line + start) % 4)) * 8 + 62}%` }}
|
||||
>
|
||||
{Array.from({ length: itemsPerLine }).map((_, item) => (
|
||||
<LoadingItem
|
||||
key={item}
|
||||
className={itemHeight}
|
||||
style={{
|
||||
flexGrow: ((line + start + item) % 3) + 1,
|
||||
animationDelay: `${(item + line + start) * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,29 +98,45 @@ export function SkeletonCard(props: { id?: string; style?: ClassValue }) {
|
||||
/**
|
||||
* Placeholder when loading small elements
|
||||
*/
|
||||
export function SkeletonSmall(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div id={id} role="status" aria-busy className={tcls(style)}>
|
||||
<LoadingPane tile={12} style={['rounded-md', 'h-[35px]', 'max-w-[calc(48rem-1px)]']} />
|
||||
</div>
|
||||
);
|
||||
export function SkeletonSmall(
|
||||
props: { id?: string; className?: ClassValue } & React.ComponentProps<'div'>
|
||||
) {
|
||||
const { id, className, ...rest } = props;
|
||||
|
||||
return <LoadingItem role="status" aria-busy id={id} className={tcls(className)} {...rest} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder when loading an Update block
|
||||
*/
|
||||
export function SkeletonUpdate(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
export function SkeletonUpdate(props: { id?: string; className?: ClassValue }) {
|
||||
const { id, className } = props;
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="status"
|
||||
aria-busy
|
||||
className={tcls('flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20', style)}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SkeletonSmall id={id} style={['w-48', style]} />
|
||||
<SkeletonSmall id={id} className={tcls('w-48', className)} />
|
||||
<LoadingPane tile={96} delay={0} style={['rounded-md', 'w-full']} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingItem(props: React.ComponentProps<'div'>): React.ReactNode {
|
||||
const { className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'animate-[blurIn_500ms_ease-out_both,pulse_2s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const de = {
|
||||
search_scope_variant_all: 'Alle Inhalte',
|
||||
search_scope_variant_all_description: 'Jede verfügbare Variante',
|
||||
ask: 'Fragen',
|
||||
ai_chat_ask_query: 'An ${1} fragen: "${2}"',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_description: 'Finden Sie die Antwort mit ${1}',
|
||||
search_ask_sources: 'Quellen',
|
||||
|
||||
@@ -134,6 +134,7 @@ export const en = {
|
||||
ai_chat_tools_mcp_tool: 'Called ${1}',
|
||||
ai_chat_ask: 'Ask ${1}',
|
||||
ai_chat_ask_about_page: 'Ask ${1} about this page',
|
||||
ai_chat_ask_query: 'Ask ${1} "${2}"',
|
||||
copy_for_llms: 'Copy for LLMs',
|
||||
copy_page_markdown: 'Copy page as Markdown for LLMs',
|
||||
copy_page: 'Copy page',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const es: TranslationLanguage = {
|
||||
search_scope_variant_all: 'Todo el contenido',
|
||||
search_scope_variant_all_description: 'Cada variante disponible',
|
||||
ask: 'Preguntar',
|
||||
ai_chat_ask_query: 'Preguntar a ${1} "${2}"',
|
||||
search_ask: 'Preguntar "${1}"',
|
||||
search_ask_description: 'Encuentra la respuesta con ${1}',
|
||||
search_ask_sources: 'Fuentes',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const fr = {
|
||||
search_scope_variant_all: 'Tout le contenu',
|
||||
search_scope_variant_all_description: 'Chaque variante disponible',
|
||||
ask: 'Une question ?',
|
||||
ai_chat_ask_query: 'Demander à ${1} « ${2} »',
|
||||
search_ask: 'Demander « ${1} »',
|
||||
search_ask_description: 'Trouvez la réponse avec ${1}',
|
||||
search_ask_sources: 'Sources',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const it: TranslationLanguage = {
|
||||
search_scope_variant_all: 'Tutti i contenuti',
|
||||
search_scope_variant_all_description: 'Ogni variante disponibile',
|
||||
ask: 'Chiedi',
|
||||
ai_chat_ask_query: 'Chiedi a ${1} "${2}"',
|
||||
search_ask: 'Chiedi "${1}"',
|
||||
search_ask_description: 'Trova la risposta con ${1}',
|
||||
search_ask_sources: 'Fonti',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ja: TranslationLanguage = {
|
||||
search_scope_variant_all: 'すべてのコンテンツ',
|
||||
search_scope_variant_all_description: '利用可能なすべてのバリエーション',
|
||||
ask: '質問する',
|
||||
ai_chat_ask_query: '"${2}" を ${1} に質問する',
|
||||
search_ask: '"${1}" を質問する',
|
||||
search_ask_description: '${1}で答えを見つける',
|
||||
search_ask_sources: '情報源',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ko: TranslationLanguage = {
|
||||
search_scope_variant_all: '전체 콘텐츠',
|
||||
search_scope_variant_all_description: '사용 가능한 모든 변형',
|
||||
ask: '질문',
|
||||
ai_chat_ask_query: '${1}에게 "${2}" 질문하기',
|
||||
search_ask: '"${1}" 질문하기',
|
||||
search_ask_description: '${1}로 답변 찾기',
|
||||
search_ask_sources: '출처',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const nl: TranslationLanguage = {
|
||||
search_scope_variant_all: 'Alle inhoud',
|
||||
search_scope_variant_all_description: 'Elke beschikbare variant',
|
||||
ask: 'Vragen',
|
||||
ai_chat_ask_query: 'Vraag ${1} "${2}"',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met ${1}',
|
||||
search_ask_sources: 'Bronnen',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const no: TranslationLanguage = {
|
||||
search_scope_variant_all: 'Alt innhold',
|
||||
search_scope_variant_all_description: 'Hver tilgjengelig variant',
|
||||
ask: 'Spør',
|
||||
ai_chat_ask_query: 'Spør ${1} "${2}"',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_description: 'Finn svaret med ${1}',
|
||||
search_ask_sources: 'Kilder',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const pt_br = {
|
||||
search_scope_variant_all: 'Todo o conteúdo',
|
||||
search_scope_variant_all_description: 'Cada variante disponível',
|
||||
ask: 'Perguntar',
|
||||
ai_chat_ask_query: 'Perguntar a ${1} "${2}"',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_description: 'Encontre a resposta com ${1}',
|
||||
search_ask_sources: 'Fontes',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ru = {
|
||||
search_scope_variant_all: 'Весь контент',
|
||||
search_scope_variant_all_description: 'Каждый доступный вариант',
|
||||
ask: 'Спросить',
|
||||
ai_chat_ask_query: 'Спросить у ${1} "${2}"',
|
||||
search_ask: 'Спросить "${1}"',
|
||||
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||
search_ask_sources: 'Источники',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const zh: TranslationLanguage = {
|
||||
search_scope_variant_all: '所有内容',
|
||||
search_scope_variant_all_description: '每个可用的变体',
|
||||
ask: '询问',
|
||||
ai_chat_ask_query: '向${1}询问"${2}"',
|
||||
search_ask: '询问"${1}"',
|
||||
search_ask_description: '利用${1}找到答案',
|
||||
search_ask_sources: '来源',
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
import type { DocumentMarkColor } from '@gitbook/api';
|
||||
|
||||
export const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
|
||||
type DocumentTextColor = DocumentMarkColor['data']['text'] | 'pink' | 'violet' | 'cyan' | '$tint';
|
||||
|
||||
export const textColorToStyle = {
|
||||
default: [],
|
||||
blue: ['text-blue-500 contrast-more:text-blue-800'],
|
||||
red: ['text-red-500 contrast-more:text-red-800'],
|
||||
green: ['text-green-500 contrast-more:text-green-800'],
|
||||
yellow: ['text-yellow-600 contrast-more:text-yellow-800'],
|
||||
purple: ['text-purple-500 contrast-more:text-purple-800'],
|
||||
orange: ['text-orange-500 contrast-more:text-orange-800'],
|
||||
blue: ['text-[#0067d1] dark:text-[#7dbcff]'],
|
||||
red: ['text-[#c01d27] dark:text-[#fb9890]'],
|
||||
green: ['text-[#097f23] dark:text-[#8fc990]'],
|
||||
yellow: ['text-[#7d6700] dark:text-[#c7b77c]'],
|
||||
purple: ['text-[#4a5cc6] dark:text-[#9fb3fe]'],
|
||||
pink: ['text-[#ab278b] dark:text-[#ee95d1]'],
|
||||
violet: ['text-[#873fbb] dark:text-[#cda2f3]'],
|
||||
cyan: ['text-[#007c7c] dark:text-[#6bcac9]'],
|
||||
orange: ['text-[#ae4300] dark:text-[#eea471]'],
|
||||
$primary: ['text-primary-subtle contrast-more:text-primary'],
|
||||
$info: ['text-info-subtle contrast-more:text-info'],
|
||||
$success: ['text-success-subtle contrast-more:text-success'],
|
||||
$warning: ['text-warning-subtle contrast-more:text-warning'],
|
||||
$danger: ['text-danger-subtle contrast-more:text-danger'],
|
||||
};
|
||||
$tint: ['text-tint-subtle contrast-more:text-tint'],
|
||||
} satisfies Record<DocumentTextColor, ClassValue>;
|
||||
|
||||
export const backgroundColorToStyle: {
|
||||
[color in DocumentMarkColor['data']['background']]: ClassValue;
|
||||
} = {
|
||||
export const backgroundColorToStyle = {
|
||||
default: [],
|
||||
blue: ['bg-mark-blue'],
|
||||
red: ['bg-mark-red'],
|
||||
green: ['bg-mark-green'],
|
||||
yellow: ['bg-mark-yellow'],
|
||||
purple: ['bg-mark-purple'],
|
||||
orange: ['bg-mark-orange'],
|
||||
blue: ['bg-[#dff4ff] dark:bg-[#183453]'],
|
||||
red: ['bg-[#ffe9e5] dark:bg-[#4f2422]'],
|
||||
green: ['bg-[#e4f9e4] dark:bg-[#203a21]'],
|
||||
yellow: ['bg-[#f8f2dc] dark:bg-[#3a3316]'],
|
||||
purple: ['bg-[#e9f1ff] dark:bg-[#283051]'],
|
||||
pink: ['bg-[#ffe8fa] dark:bg-[#4a233e]'],
|
||||
violet: ['bg-[#f9ebff] dark:bg-[#3c294c]'],
|
||||
cyan: ['bg-[#ddf8f8] dark:bg-[#093b3b]'],
|
||||
orange: ['bg-[#ffecdc] dark:bg-[#492a13]'],
|
||||
$primary: ['bg-primary'],
|
||||
$info: ['bg-info'],
|
||||
$success: ['bg-success'],
|
||||
$warning: ['bg-warning'],
|
||||
$danger: ['bg-danger'],
|
||||
};
|
||||
$tint: ['bg-tint'],
|
||||
} satisfies Record<DocumentTextColor, ClassValue>;
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
throwIfDataError,
|
||||
} from '@/lib/data';
|
||||
import { getLogger } from '@/lib/logger';
|
||||
import { getLocalizedTitle, getSiteStructureSections } from '@/lib/sites';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
getSiteStructureSections,
|
||||
} from '@/lib/sites';
|
||||
import type {
|
||||
ChangeRequest,
|
||||
PublishedSiteContent,
|
||||
@@ -386,6 +391,54 @@ export async function fetchSiteContextByIds(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context scoped to a specific site space.
|
||||
* This keeps the site structure from the current context while resolving content
|
||||
* against the target space revision.
|
||||
*/
|
||||
export async function fetchSiteContextForSiteSpace(
|
||||
baseContext: GitBookSiteContext,
|
||||
siteSpace: SiteSpace
|
||||
): Promise<GitBookSiteContext> {
|
||||
const found = findSiteSpaceBy(baseContext.structure, (entry) => entry.id === siteSpace.id);
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`Site space "${siteSpace.id}" not found in site structure`);
|
||||
}
|
||||
|
||||
const spaceContext = await fetchSpaceContextByIds(baseContext, {
|
||||
space: siteSpace.space.id,
|
||||
shareKey: baseContext.shareKey,
|
||||
changeRequest: undefined,
|
||||
revision: siteSpace.space.revision,
|
||||
});
|
||||
|
||||
const siteSpaces =
|
||||
baseContext.structure.type === 'siteSpaces'
|
||||
? baseContext.structure.structure
|
||||
: (found.siteSection?.siteSpaces ?? baseContext.siteSpaces);
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
...spaceContext,
|
||||
locale: siteSpace.space.language ?? spaceContext.locale,
|
||||
linker: baseContext.linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(baseContext, siteSpace),
|
||||
}),
|
||||
siteSpace,
|
||||
siteSpaces,
|
||||
visibleSiteSpaces: filterHiddenSiteSpaces(siteSpaces),
|
||||
sections:
|
||||
baseContext.sections && found.siteSection
|
||||
? { ...baseContext.sections, current: found.siteSection }
|
||||
: baseContext.sections,
|
||||
visibleSections:
|
||||
baseContext.visibleSections && found.siteSection
|
||||
? { ...baseContext.visibleSections, current: found.siteSection }
|
||||
: baseContext.visibleSections,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a space context by IDs.
|
||||
*/
|
||||
|
||||
@@ -94,11 +94,18 @@ export function createDataFetcher(
|
||||
});
|
||||
},
|
||||
getRevisionPageDocument(params) {
|
||||
return getRevisionPageDocument(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
pageId: params.pageId,
|
||||
});
|
||||
return getRevisionPageDocument(
|
||||
input,
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
pageId: params.pageId,
|
||||
},
|
||||
// We pass the name of the function to distinguish between cache entries
|
||||
// By default Next only uses the arguments, the filename and the position of the function inside the file
|
||||
// By adding a dummy argument with the function name, we can change the order without breaking anything
|
||||
'getRevisionPageDocument'
|
||||
);
|
||||
},
|
||||
getRevisionReusableContentDocument(params) {
|
||||
return getRevisionReusableContentDocument(input, {
|
||||
@@ -325,6 +332,7 @@ const getRevisionPageMarkdown = cache(
|
||||
params.pageId,
|
||||
{
|
||||
format: 'markdown',
|
||||
'format.markdown.refs': 'stable',
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
@@ -350,7 +358,9 @@ const getRevisionPageMarkdown = cache(
|
||||
const getRevisionPageDocument = cache(
|
||||
async (
|
||||
input: DataFetcherInput,
|
||||
params: { spaceId: string; revisionId: string; pageId: string }
|
||||
params: { spaceId: string; revisionId: string; pageId: string },
|
||||
// used only to bust the cache
|
||||
_functionName: string
|
||||
) => {
|
||||
'use cache: remote';
|
||||
return wrapCacheDataFetcherError(async () => {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'server-only';
|
||||
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||
|
||||
const ICON_ASSET_VERSION = '2';
|
||||
const rawSvgPromises = new Map<string, Promise<string | null>>();
|
||||
const styleSpritePromises = new Map<string, Promise<Map<string, IconSymbolSource> | null>>();
|
||||
const svgPattern = /<svg\b([^>]*)>([\s\S]*?)<\/svg>\s*$/i;
|
||||
const symbolPattern = /<symbol\b([^>]*)>([\s\S]*?)<\/symbol>/gi;
|
||||
const viewBoxPattern = /\bviewBox="([^"]+)"/i;
|
||||
const idPattern = /\bid="([^"]+)"/i;
|
||||
const commentPattern = /<!--[\s\S]*?-->/g;
|
||||
|
||||
interface IconSymbolSource {
|
||||
viewBox: string;
|
||||
markup: string;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function getIconAssetBaseURL(style: string): string {
|
||||
if (style === 'custom-icons') {
|
||||
return getAssetURL('icons');
|
||||
}
|
||||
|
||||
return GITBOOK_ICONS_URL;
|
||||
}
|
||||
|
||||
function getIconAssetURL(style: string, icon: string): string {
|
||||
const url = new URL(
|
||||
joinPathWithBaseURL(getIconAssetBaseURL(style), joinPath('svgs', style, `${icon}.svg`))
|
||||
);
|
||||
url.searchParams.set('v', ICON_ASSET_VERSION);
|
||||
|
||||
if (style !== 'custom-icons' && GITBOOK_ICONS_TOKEN) {
|
||||
url.searchParams.set('token', GITBOOK_ICONS_TOKEN);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getStyleSpriteAssetURL(style: string): string {
|
||||
const url = new URL(
|
||||
joinPathWithBaseURL(getIconAssetBaseURL(style), joinPath('sprites', `${style}.svg`))
|
||||
);
|
||||
url.searchParams.set('v', ICON_ASSET_VERSION);
|
||||
|
||||
if (style !== 'custom-icons' && GITBOOK_ICONS_TOKEN) {
|
||||
url.searchParams.set('token', GITBOOK_ICONS_TOKEN);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseRawSVG(document: string): IconSymbolSource | null {
|
||||
const svgMatch = document.match(svgPattern);
|
||||
if (!svgMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const svgAttributes = svgMatch[1];
|
||||
const rawMarkup = svgMatch[2];
|
||||
if (!svgAttributes || rawMarkup === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewBoxMatch = svgAttributes.match(viewBoxPattern);
|
||||
const viewBox = viewBoxMatch?.[1];
|
||||
if (!viewBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
viewBox,
|
||||
markup: rawMarkup.replace(commentPattern, '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStyleSprite(document: string): Map<string, IconSymbolSource> | null {
|
||||
const symbols = new Map<string, IconSymbolSource>();
|
||||
|
||||
for (const match of document.matchAll(symbolPattern)) {
|
||||
const symbolAttributes = match[1];
|
||||
const rawMarkup = match[2];
|
||||
|
||||
if (!symbolAttributes || rawMarkup === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idMatch = symbolAttributes.match(idPattern);
|
||||
const viewBoxMatch = symbolAttributes.match(viewBoxPattern);
|
||||
const icon = idMatch?.[1];
|
||||
const viewBox = viewBoxMatch?.[1];
|
||||
|
||||
if (!icon || !viewBox) {
|
||||
continue;
|
||||
}
|
||||
|
||||
symbols.set(icon, {
|
||||
viewBox,
|
||||
markup: rawMarkup.replace(commentPattern, '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return symbols.size > 0 ? symbols : null;
|
||||
}
|
||||
|
||||
function buildSymbolMarkup(symbolId: string, viewBox: string, markup: string) {
|
||||
return `<symbol id="${escapeAttribute(symbolId)}" viewBox="${escapeAttribute(viewBox)}" overflow="visible">${markup}</symbol>`;
|
||||
}
|
||||
|
||||
function buildSymbolDocument(symbolId: string, symbol: string) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbol}</defs><use href="#${escapeAttribute(symbolId)}"/></svg>`;
|
||||
}
|
||||
|
||||
async function fetchRawSVG(style: string, icon: string): Promise<string | null> {
|
||||
const cacheKey = `${style}/${icon}`;
|
||||
const existing = rawSvgPromises.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetch(getIconAssetURL(style, icon), {
|
||||
cache: 'force-cache',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
rawSvgPromises.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function fetchStyleSprite(style: string): Promise<Map<string, IconSymbolSource> | null> {
|
||||
const existing = styleSpritePromises.get(style);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetch(getStyleSpriteAssetURL(style), {
|
||||
cache: 'force-cache',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseStyleSprite(await response.text());
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
styleSpritePromises.set(style, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one icon entry from the raw SVG source and serialize it for sprite injection or
|
||||
* same-origin lazy loading.
|
||||
*/
|
||||
export async function getIconSymbol(style: string, icon: string, symbolId: string) {
|
||||
const spriteSymbols = await fetchStyleSprite(style);
|
||||
let source = spriteSymbols?.get(icon) ?? null;
|
||||
|
||||
if (!source) {
|
||||
const rawSVG = await fetchRawSVG(style, icon);
|
||||
if (!rawSVG) {
|
||||
return null;
|
||||
}
|
||||
|
||||
source = parseRawSVG(rawSVG);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const symbol = buildSymbolMarkup(symbolId, source.viewBox, source.markup);
|
||||
|
||||
return {
|
||||
style,
|
||||
icon,
|
||||
symbolId,
|
||||
viewBox: source.viewBox,
|
||||
markup: source.markup,
|
||||
symbol,
|
||||
document: buildSymbolDocument(symbolId, symbol),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createLinker, linkerForPublishedURL, linkerWithAbsoluteURLs } from './links';
|
||||
import {
|
||||
createLinker,
|
||||
linkerForPublishedURL,
|
||||
linkerWithAbsoluteURLs,
|
||||
linkerWithMarkdownPages,
|
||||
} from './links';
|
||||
|
||||
const root = createLinker({
|
||||
host: 'docs.company.com',
|
||||
@@ -77,6 +82,22 @@ describe('toRelativePathInSite', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathForPagePath', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathForPagePath({ path: 'some/path' })).toBe('/some/path');
|
||||
expect(variantInSection.toPathForPagePath({ path: 'some/path' })).toBe(
|
||||
'/section/variant/some/path'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve anchors and resolve index pages', () => {
|
||||
expect(root.toPathForPagePath({ path: '', anchor: 'intro' })).toBe('/#intro');
|
||||
expect(variantInSection.toPathForPagePath({ path: '', anchor: 'intro' })).toBe(
|
||||
'/section/variant#intro'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAbsoluteURL', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toAbsoluteURL('some/path')).toBe('https://docs.company.com/some/path');
|
||||
@@ -115,6 +136,33 @@ describe('linkerWithAbsoluteURLs', () => {
|
||||
);
|
||||
expect(absoluteLinker.toPathInSite('some/path')).toBe('https://docs.company.com/some/path');
|
||||
});
|
||||
|
||||
it('should return absolute URLs for toPathForPage', () => {
|
||||
const absoluteLinker = linkerWithAbsoluteURLs(variantInSection);
|
||||
const pages = [
|
||||
{
|
||||
id: 'page-intro',
|
||||
type: 'document',
|
||||
title: 'Intro',
|
||||
path: 'intro',
|
||||
pages: [],
|
||||
},
|
||||
{
|
||||
id: 'page-editor',
|
||||
type: 'document',
|
||||
title: 'Editor',
|
||||
path: 'editor',
|
||||
pages: [],
|
||||
},
|
||||
] as any;
|
||||
|
||||
expect(
|
||||
absoluteLinker.toPathForPage({
|
||||
pages,
|
||||
page: pages[1],
|
||||
})
|
||||
).toBe('https://docs.company.com/section/variant/editor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linker.withOtherSiteSpace', () => {
|
||||
@@ -149,6 +197,42 @@ describe('linker.withOtherSiteSpace', () => {
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path');
|
||||
});
|
||||
|
||||
it('should resolve toPathForPagePath using the overridden spaceBasePath', () => {
|
||||
const otherSpaceBasePathLinker = root.withOtherSiteSpace({
|
||||
spaceBasePath: '/section/variant',
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathForPagePath({ path: 'some/path' })).toBe(
|
||||
'/section/variant/some/path'
|
||||
);
|
||||
expect(otherSpaceBasePathLinker.toPathForPagePath({ path: '', anchor: 'intro' })).toBe(
|
||||
'/section/variant#intro'
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve page paths relative to the overridden spaceBasePath', () => {
|
||||
const otherSpaceBasePathLinker = siteGitBookIO.withOtherSiteSpace({
|
||||
spaceBasePath: '/a/b',
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathForPagePath({ path: 'some/path' })).toBe(
|
||||
'/sitename/a/b/some/path'
|
||||
);
|
||||
expect(otherSpaceBasePathLinker.toPathForPagePath({ path: '', anchor: 'intro' })).toBe(
|
||||
'/sitename/a/b#intro'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkerWithMarkdownPages', () => {
|
||||
it('should append .md to page paths and preserve anchors', () => {
|
||||
const markdownLinker = linkerWithMarkdownPages(variantInSection);
|
||||
expect(markdownLinker.toPathForPagePath({ path: 'some/path' })).toBe(
|
||||
'/section/variant/some/path.md'
|
||||
);
|
||||
expect(markdownLinker.toPathForPagePath({ path: 'some/path', anchor: 'intro' })).toBe(
|
||||
'/section/variant/some/path.md#intro'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkerForPublishedURL', () => {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import type { Link, Root } from 'mdast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import warnOnce from 'warn-once';
|
||||
import { checkIsAnchor, checkIsExternalURL } from './urls';
|
||||
|
||||
/**
|
||||
* Generic interface to generate links based on a given context.
|
||||
@@ -45,6 +41,15 @@ export interface GitBookLinker {
|
||||
anchor?: string;
|
||||
}): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute path for a page path in the current content.
|
||||
* The result should NOT be passed to `toPathInSpace`.
|
||||
*/
|
||||
toPathForPagePath(input: {
|
||||
path: string;
|
||||
anchor?: string;
|
||||
}): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute URL for a given path relative to the host of the current content.
|
||||
*/
|
||||
@@ -134,7 +139,14 @@ export function createLinker(
|
||||
},
|
||||
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
return linker.toPathForPagePath({
|
||||
path: getPagePath(pages, page),
|
||||
anchor,
|
||||
});
|
||||
},
|
||||
|
||||
toPathForPagePath({ path, anchor }) {
|
||||
return linker.toPathInSpace(path) + (anchor ? `#${anchor}` : '');
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
@@ -171,10 +183,13 @@ export function createLinker(
|
||||
// implementation matches the base linker toPathForPage, but decouples from using `this` to
|
||||
// ensure we always use the updates `toPathInSpace` method.
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return (
|
||||
newLinker.toPathInSpace(getPagePath(pages, page)) +
|
||||
(anchor ? `#${anchor}` : '')
|
||||
);
|
||||
return newLinker.toPathForPagePath({
|
||||
path: getPagePath(pages, page),
|
||||
anchor,
|
||||
});
|
||||
},
|
||||
toPathForPagePath({ path, anchor }) {
|
||||
return newLinker.toPathInSpace(path) + (anchor ? `#${anchor}` : '');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -220,12 +235,43 @@ export function linkerForPublishedURL(linker: GitBookLinker, rawSitePublishedURL
|
||||
* Create a new linker that always returns absolute URLs.
|
||||
*/
|
||||
export function linkerWithAbsoluteURLs(linker: GitBookLinker): GitBookLinker {
|
||||
return {
|
||||
const self: GitBookLinker = {
|
||||
...linker,
|
||||
toPathInSpace: (path) => linker.toAbsoluteURL(linker.toPathInSpace(path)),
|
||||
toPathInSite: (path) => linker.toAbsoluteURL(linker.toPathInSite(path)),
|
||||
toPathForPage: (input) => linker.toAbsoluteURL(linker.toPathForPage(input)),
|
||||
toPathForPage: (input) => {
|
||||
return self.toPathForPagePath({
|
||||
path: getPagePath(input.pages, input.page),
|
||||
anchor: input.anchor,
|
||||
});
|
||||
},
|
||||
toPathForPagePath: (input) => linker.toAbsoluteURL(linker.toPathForPagePath(input)),
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new linker that resolves pages to their markdown version.
|
||||
*/
|
||||
export function linkerWithMarkdownPages(linker: GitBookLinker): GitBookLinker {
|
||||
const self: GitBookLinker = {
|
||||
...linker,
|
||||
fork: (override) => linkerWithMarkdownPages(linker.fork(override)),
|
||||
withOtherSiteSpace: (override) =>
|
||||
linkerWithMarkdownPages(linker.withOtherSiteSpace(override)),
|
||||
toPathForPage: (input) => {
|
||||
return self.toPathForPagePath({
|
||||
path: input.page.path,
|
||||
anchor: input.anchor,
|
||||
});
|
||||
},
|
||||
toPathForPagePath: (input) => {
|
||||
return `${linker.toPathInSpace(input.path)}.md${input.anchor ? `#${input.anchor}` : ''}`;
|
||||
},
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
function joinPaths(prefix: string, path: string): string {
|
||||
@@ -238,34 +284,3 @@ function joinPaths(prefix: string, path: string): string {
|
||||
function removeTrailingSlash(path: string): string {
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-writes the URL of every relative <a> link so it is expressed from the site-root.
|
||||
*/
|
||||
export function relativeToAbsoluteLinks(
|
||||
linker: GitBookLinker,
|
||||
tree: Root,
|
||||
currentPagePath: string
|
||||
): Root {
|
||||
const currentDir = path.posix.dirname(currentPagePath);
|
||||
|
||||
visit(tree, 'link', (node: Link) => {
|
||||
const original = node.url;
|
||||
|
||||
// Skip anchors, mailto:, http(s):, protocol-like, or already-rooted paths
|
||||
if (checkIsExternalURL(original) || checkIsAnchor(original) || original.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve against the current page’s directory and strip any leading “/” or "../"
|
||||
// Sometimes the path can be "../" if we are on the default section
|
||||
// but it means we are just at the root of the site.
|
||||
const pathInPage = path.posix
|
||||
.normalize(path.posix.join(currentDir, original))
|
||||
.replace(/^[\/\.]+/, '');
|
||||
|
||||
node.url = linker.toAbsoluteURL(linker.toPathInSpace(pathInPage));
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
type GitBookAnyContext,
|
||||
type GitBookSiteContext,
|
||||
fetchSiteContextForSiteSpace,
|
||||
} from '@/lib/context';
|
||||
import { DataFetcherError, throwIfDataError } from '@/lib/data';
|
||||
import type { ResolvedPagePath } from '@/lib/pages';
|
||||
import { getIndexablePages } from '@/lib/sitemap';
|
||||
import { getFallbackSiteSpacePath } from '@/lib/sites';
|
||||
import { getMarkdownForPagesTree } from '@/routes/llms';
|
||||
import {
|
||||
type RevisionPageDocument,
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
RevisionPageType,
|
||||
type SiteSpace,
|
||||
} from '@gitbook/api';
|
||||
import type { 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';
|
||||
@@ -18,7 +22,10 @@ import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
import { frontmatter } from 'micromark-extension-frontmatter';
|
||||
import { gfm } from 'micromark-extension-gfm';
|
||||
import { remove } from 'unist-util-remove';
|
||||
import { type GitBookLinker, relativeToAbsoluteLinks } from './links';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import type { GitBookLinker } from './links';
|
||||
import { resolveContentRef, resolveStringContentRef } from './references';
|
||||
import { checkIsAnchor, checkIsExternalURL } from './urls';
|
||||
|
||||
/**
|
||||
* Generate a markdown version of a page.
|
||||
@@ -53,8 +60,7 @@ export async function getMarkdownForPage(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tree = fromPageMarkdown({
|
||||
linker: context.linker,
|
||||
const tree = await fromPageMarkdown(context, {
|
||||
markdown: rawMarkdown,
|
||||
pagePath: page.path,
|
||||
});
|
||||
@@ -75,42 +81,29 @@ export async function getMarkdownForPageInSpace(
|
||||
siteSpace: SiteSpace,
|
||||
page: RevisionPageDocument | RevisionPageGroup
|
||||
): Promise<string> {
|
||||
const { dataFetcher } = context;
|
||||
const spaceBasePath = getFallbackSiteSpacePath(context, siteSpace);
|
||||
const linker = context.linker.withOtherSiteSpace({
|
||||
spaceBasePath,
|
||||
});
|
||||
const siteSpaceContext = await fetchSiteContextForSiteSpace(context, siteSpace);
|
||||
|
||||
// Handle group pages (pages with no content that list their children)
|
||||
if (page.type === RevisionPageType.Group) {
|
||||
const siteSpaceUrl = siteSpace.urls.published;
|
||||
if (!siteSpaceUrl) {
|
||||
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
|
||||
}
|
||||
return renderGroupPageMarkdown({ siteSpaceUrl, linker, page });
|
||||
return renderGroupPageMarkdown({ linker: siteSpaceContext.linker, page });
|
||||
}
|
||||
|
||||
const rawMarkdown = await throwIfDataError(
|
||||
dataFetcher.getRevisionPageMarkdown({
|
||||
spaceId: siteSpace.space.id,
|
||||
revisionId: siteSpace.space.revision,
|
||||
siteSpaceContext.dataFetcher.getRevisionPageMarkdown({
|
||||
spaceId: siteSpaceContext.space.id,
|
||||
revisionId: siteSpaceContext.revisionId,
|
||||
pageId: page.id,
|
||||
})
|
||||
);
|
||||
|
||||
const tree = fromPageMarkdown({
|
||||
linker,
|
||||
const tree = await fromPageMarkdown(siteSpaceContext, {
|
||||
markdown: rawMarkdown,
|
||||
pagePath: page.path,
|
||||
});
|
||||
|
||||
// Handle empty document pages which have children (same as getMarkdownForPage)
|
||||
if (isEmptyMarkdownPage(tree) && page.pages.length > 0) {
|
||||
const siteSpaceUrl = siteSpace.urls.published;
|
||||
if (!siteSpaceUrl) {
|
||||
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
|
||||
}
|
||||
return renderGroupPageMarkdown({ siteSpaceUrl, linker, page });
|
||||
return renderGroupPageMarkdown({ linker: siteSpaceContext.linker, page });
|
||||
}
|
||||
|
||||
return toPageMarkdown(tree);
|
||||
@@ -120,11 +113,13 @@ export async function getMarkdownForPageInSpace(
|
||||
* Parse markdown from a page, removing frontmatter and rewriting relative links to absolute links.
|
||||
* Returns the markdown AST that can be further processed or converted back to markdown using `toPageMarkdown`.
|
||||
*/
|
||||
export function fromPageMarkdown(args: {
|
||||
linker: GitBookLinker;
|
||||
markdown: string;
|
||||
pagePath: string;
|
||||
}): Root {
|
||||
export async function fromPageMarkdown(
|
||||
context: GitBookAnyContext,
|
||||
args: {
|
||||
markdown: string;
|
||||
pagePath: string;
|
||||
}
|
||||
): Promise<Root> {
|
||||
const tree = fromMarkdown(args.markdown, {
|
||||
extensions: [frontmatter(['yaml']), gfm()],
|
||||
mdastExtensions: [frontmatterFromMarkdown(['yaml']), gfmFromMarkdown()],
|
||||
@@ -133,7 +128,7 @@ export function fromPageMarkdown(args: {
|
||||
// Remove frontmatter
|
||||
remove(tree, 'yaml');
|
||||
|
||||
relativeToAbsoluteLinks(args.linker, tree, args.pagePath);
|
||||
await rewriteMarkdownLinks(context, tree, args.pagePath);
|
||||
|
||||
return tree;
|
||||
}
|
||||
@@ -186,13 +181,7 @@ async function servePageGroup(
|
||||
context: GitBookSiteContext,
|
||||
page: RevisionPageDocument | RevisionPageGroup
|
||||
): Promise<string> {
|
||||
const siteSpaceUrl = context.space.urls.published;
|
||||
if (!siteSpaceUrl) {
|
||||
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
|
||||
}
|
||||
|
||||
return renderGroupPageMarkdown({
|
||||
siteSpaceUrl,
|
||||
linker: context.linker,
|
||||
page,
|
||||
});
|
||||
@@ -203,11 +192,10 @@ async function servePageGroup(
|
||||
* Use this when rendering a group page from a different space than the current context.
|
||||
*/
|
||||
async function renderGroupPageMarkdown(args: {
|
||||
siteSpaceUrl: string;
|
||||
linker: GitBookLinker;
|
||||
page: RevisionPageDocument | RevisionPageGroup;
|
||||
}): Promise<string> {
|
||||
const { siteSpaceUrl, linker, page } = args;
|
||||
const { linker, page } = args;
|
||||
const indexablePages = getIndexablePages(page.pages);
|
||||
|
||||
const markdownTree: Root = {
|
||||
@@ -218,11 +206,7 @@ async function renderGroupPageMarkdown(args: {
|
||||
depth: 1,
|
||||
children: [{ type: 'text', value: page.title }],
|
||||
},
|
||||
...(await getMarkdownForPagesTree(indexablePages, {
|
||||
siteSpaceUrl,
|
||||
linker,
|
||||
withMarkdownPages: true,
|
||||
})),
|
||||
...(await getMarkdownForPagesTree(indexablePages, linker)),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -230,3 +214,94 @@ async function renderGroupPageMarkdown(args: {
|
||||
bullet: '-',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-writes URLs in a markdown content:
|
||||
* -
|
||||
* - the URL of every relative <a> link so it is expressed from the site-root.
|
||||
*/
|
||||
async function rewriteMarkdownLinks(
|
||||
context: GitBookAnyContext,
|
||||
tree: Root,
|
||||
currentPagePath: string
|
||||
): Promise<Root> {
|
||||
const currentDir = path.posix.dirname(currentPagePath);
|
||||
|
||||
const pending: Array<Promise<void>> = [];
|
||||
|
||||
visit(tree, 'link', (node: Link) => {
|
||||
const isMention = isMentionLike(node);
|
||||
const original = node.url;
|
||||
|
||||
// Skip anchors, mailto:, http(s):, protocol-like
|
||||
if (checkIsExternalURL(original) || checkIsAnchor(original)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentRef = resolveStringContentRef(original);
|
||||
|
||||
if (contentRef) {
|
||||
pending.push(
|
||||
(async () => {
|
||||
const resolved = await resolveContentRef(contentRef, context);
|
||||
if (resolved?.href) {
|
||||
node.url = resolved.href;
|
||||
} else {
|
||||
// We use an absolute URL so that crawler don't follow it.
|
||||
node.url = `broken://${original.startsWith('/') ? original.slice(1) : original}`;
|
||||
}
|
||||
|
||||
if (isMention) {
|
||||
// Replace the text for mentions as otherwise it contains the raw ref
|
||||
if (resolved) {
|
||||
node.children = [
|
||||
{
|
||||
type: 'text',
|
||||
value: resolved.text,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
node.children = [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Broken mention',
|
||||
},
|
||||
];
|
||||
}
|
||||
node.title = undefined;
|
||||
}
|
||||
})()
|
||||
);
|
||||
} else {
|
||||
// DEPRECATED: to be removed once rollout for getRevisionPageMarkdown is done
|
||||
//
|
||||
// Resolve against the current page’s directory and strip any leading “/” or "../"
|
||||
// Sometimes the path can be "../" if we are on the default section
|
||||
// but it means we are just at the root of the site.
|
||||
const pathInPage = path.posix
|
||||
.normalize(path.posix.join(currentDir, original))
|
||||
.replace(/^[\/\.]+/, '');
|
||||
|
||||
node.url = context.linker.toAbsoluteURL(context.linker.toPathInSpace(pathInPage));
|
||||
}
|
||||
});
|
||||
|
||||
if (pending.length > 0) {
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
function isMentionLike(node: Link) {
|
||||
if (node.title === 'mention') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const singleText =
|
||||
node.children.length === 1 && node.children[0]?.type === 'text' ? node.children[0] : null;
|
||||
if (!singleText) {
|
||||
return false;
|
||||
}
|
||||
return singleText?.value === node.url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { resolveStringContentRef } from './references';
|
||||
|
||||
describe('resolveStringContentRef', () => {
|
||||
it.each([
|
||||
{
|
||||
label: 'an external URL',
|
||||
input: 'https://docs.gitbook.com/product-tour',
|
||||
expected: {
|
||||
kind: 'url',
|
||||
url: 'https://docs.gitbook.com/product-tour',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a page ref in the current space',
|
||||
input: '/pages/page_123',
|
||||
expected: {
|
||||
kind: 'page',
|
||||
page: 'page_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a page ref in another space',
|
||||
input: '/spaces/space-123/pages/page_123',
|
||||
expected: {
|
||||
kind: 'page',
|
||||
space: 'space-123',
|
||||
page: 'page_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'an anchor in the current page',
|
||||
input: '#heading-1',
|
||||
expected: {
|
||||
kind: 'anchor',
|
||||
anchor: 'heading-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'an anchor on a page in another space',
|
||||
input: '/spaces/space-123/pages/page-123#heading_1',
|
||||
expected: {
|
||||
kind: 'anchor',
|
||||
space: 'space-123',
|
||||
page: 'page-123',
|
||||
anchor: 'heading_1',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a file ref',
|
||||
input: '/spaces/space-123/files/file_123',
|
||||
expected: {
|
||||
kind: 'file',
|
||||
space: 'space-123',
|
||||
file: 'file_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a space ref',
|
||||
input: '/spaces/space-123',
|
||||
expected: {
|
||||
kind: 'space',
|
||||
space: 'space-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a collection ref',
|
||||
input: '/collections/collection-123',
|
||||
expected: {
|
||||
kind: 'collection',
|
||||
collection: 'collection-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a user ref',
|
||||
input: '/users/user_123',
|
||||
expected: {
|
||||
kind: 'user',
|
||||
user: 'user_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a reusable content ref',
|
||||
input: '/spaces/space-123/reusable-content/reusable_123',
|
||||
expected: {
|
||||
kind: 'reusable-content',
|
||||
space: 'space-123',
|
||||
reusableContent: 'reusable_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'a tag ref',
|
||||
input: '/spaces/space-123/tags/tag_123',
|
||||
expected: {
|
||||
kind: 'tag',
|
||||
space: 'space-123',
|
||||
tag: 'tag_123',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'an OpenAPI ref',
|
||||
input: '/openapi/spec-v1',
|
||||
expected: {
|
||||
kind: 'openapi',
|
||||
spec: 'spec-v1',
|
||||
},
|
||||
},
|
||||
])('parses $label', ({ input, expected }) => {
|
||||
// @ts-expect-error
|
||||
expect(resolveStringContentRef(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'a relative page path',
|
||||
input: 'getting-started',
|
||||
},
|
||||
{
|
||||
label: 'a page path with an extra segment',
|
||||
input: '/pages/page-123/child',
|
||||
},
|
||||
{
|
||||
label: 'a missing page identifier',
|
||||
input: '/pages/',
|
||||
},
|
||||
{
|
||||
label: 'a space path with a trailing slash',
|
||||
input: '/spaces/space-123/',
|
||||
},
|
||||
{
|
||||
label: 'an OpenAPI path with nested segments',
|
||||
input: '/openapi/spec/v1',
|
||||
},
|
||||
])('returns null for $label', ({ input }) => {
|
||||
expect(resolveStringContentRef(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { findSiteSpaceBy, getFallbackSiteSpacePath, getLocalizedTitle } from './
|
||||
import { getRevisionTags, resolveTag } from './tags';
|
||||
import type { ClassValue } from './tailwind';
|
||||
import { filterOutNullable } from './typescript';
|
||||
import { checkIsExternalURL } from './urls';
|
||||
|
||||
export interface ResolvedContentRef {
|
||||
/** Text to render in the content ref */
|
||||
@@ -583,3 +584,217 @@ async function createContextForSpace(
|
||||
baseURL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When the API outputs markdown with `format.markdown.refs: stable`, the content refs are formatted this way.
|
||||
*/
|
||||
export function resolveStringContentRef(src: string): ContentRef | null {
|
||||
for (const resolver of Object.values(RESOLVERS)) {
|
||||
const ref = resolver.resolve(src);
|
||||
if (ref) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const RESOLVERS: {
|
||||
[kind in ContentRef['kind']]: {
|
||||
resolve: (src: string) => Extract<ContentRef, { kind: kind }> | null;
|
||||
format: (ref: Extract<ContentRef, { kind: kind }>) => string;
|
||||
};
|
||||
} = {
|
||||
url: {
|
||||
resolve: (src) => {
|
||||
if (checkIsExternalURL(src)) {
|
||||
return { kind: 'url', url: src };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
return ref.url;
|
||||
},
|
||||
},
|
||||
|
||||
page: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^(\/spaces\/([\w-]+))?\/pages\/([\w-]+)$/);
|
||||
if (match?.[3]) {
|
||||
return {
|
||||
kind: 'page',
|
||||
...(match[2] ? { space: match[2] } : {}),
|
||||
page: match[3],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
const pagePath = `/pages/${ref.page}`;
|
||||
if (ref.space) {
|
||||
return `/spaces/${ref.space}${pagePath}`;
|
||||
}
|
||||
return pagePath;
|
||||
},
|
||||
},
|
||||
|
||||
anchor: {
|
||||
resolve: (src) => {
|
||||
if (src.startsWith('#')) {
|
||||
return {
|
||||
kind: 'anchor',
|
||||
anchor: src.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
const match = src.match(/^((\/spaces\/([\w-]+))?\/pages\/([\w-]+))?#([\w-]+)$/);
|
||||
if (match?.[5]) {
|
||||
return {
|
||||
kind: 'anchor',
|
||||
...(match[3] ? { space: match[3] } : {}),
|
||||
...(match[4] ? { page: match[4] } : {}),
|
||||
anchor: match[5],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
const anchorPath = `#${ref.anchor}`;
|
||||
if (!ref.page) {
|
||||
return anchorPath;
|
||||
}
|
||||
if (ref.space && ref.page) {
|
||||
return `/spaces/${ref.space}/pages/${ref.page}${anchorPath}`;
|
||||
}
|
||||
return `/pages/${ref.page}${anchorPath}`;
|
||||
},
|
||||
},
|
||||
|
||||
file: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^(\/spaces\/([\w-]+))?\/files\/([\w-]+)$/);
|
||||
if (match?.[3]) {
|
||||
return {
|
||||
kind: 'file',
|
||||
file: match[3],
|
||||
...(match[2] ? { space: match[2] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
const filePath = `/files/${ref.file}`;
|
||||
if (ref.space) {
|
||||
return `/spaces/${ref.space}${filePath}`;
|
||||
}
|
||||
return filePath;
|
||||
},
|
||||
},
|
||||
|
||||
space: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^\/spaces\/([\w-]+)$/);
|
||||
if (match?.[1]) {
|
||||
return {
|
||||
kind: 'space',
|
||||
space: match[1],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => `/spaces/${ref.space}`,
|
||||
},
|
||||
|
||||
collection: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^\/collections\/([\w-]+)$/);
|
||||
if (match?.[1]) {
|
||||
return {
|
||||
kind: 'collection',
|
||||
collection: match[1],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => `/collections/${ref.collection}`,
|
||||
},
|
||||
|
||||
user: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^\/users\/([\w-]+)$/);
|
||||
if (match?.[1]) {
|
||||
return {
|
||||
kind: 'user',
|
||||
user: match[1],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => `/users/${ref.user}`,
|
||||
},
|
||||
|
||||
'reusable-content': {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^(\/spaces\/([\w-]+))?\/reusable-content\/([\w-]+)$/);
|
||||
if (match?.[3]) {
|
||||
return {
|
||||
kind: 'reusable-content',
|
||||
...(match[2] ? { space: match[2] } : {}),
|
||||
reusableContent: match[3],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
const reusableContentPath = `/reusable-content/${ref.reusableContent}`;
|
||||
if (ref.space) {
|
||||
return `/spaces/${ref.space}${reusableContentPath}`;
|
||||
}
|
||||
return reusableContentPath;
|
||||
},
|
||||
},
|
||||
|
||||
tag: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^(\/spaces\/([\w-]+))?\/tags\/([\w-]+)$/);
|
||||
if (match?.[3]) {
|
||||
return {
|
||||
kind: 'tag',
|
||||
...(match[2] ? { space: match[2] } : {}),
|
||||
tag: match[3],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => {
|
||||
const tagPath = `/tags/${ref.tag}`;
|
||||
if (ref.space) {
|
||||
return `/spaces/${ref.space}${tagPath}`;
|
||||
}
|
||||
return tagPath;
|
||||
},
|
||||
},
|
||||
|
||||
openapi: {
|
||||
resolve: (src) => {
|
||||
const match = src.match(/^\/openapi\/([^/]+)$/);
|
||||
if (match?.[1]) {
|
||||
return {
|
||||
kind: 'openapi',
|
||||
spec: match[1],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
format: (ref) => `/openapi/${ref.spec}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export function isRollout({
|
||||
discriminator: string;
|
||||
percentageRollout: number;
|
||||
}): boolean {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.VERCEL_ENV === 'preview') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { RevisionPage } from '@gitbook/api';
|
||||
|
||||
import { getIndexablePages } from './sitemap';
|
||||
|
||||
describe('getIndexablePages', () => {
|
||||
it('includes hidden pages when they remain indexable', () => {
|
||||
const pages: RevisionPage[] = [
|
||||
{
|
||||
id: 'visible-page',
|
||||
type: 'document',
|
||||
title: 'Visible page',
|
||||
path: 'visible-page',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'hidden-page',
|
||||
type: 'document',
|
||||
title: 'Hidden page',
|
||||
path: 'hidden-page',
|
||||
pages: [],
|
||||
hidden: true,
|
||||
},
|
||||
] as RevisionPage[];
|
||||
|
||||
expect(getIndexablePages(pages).map(({ page }) => page.id)).toEqual([
|
||||
'visible-page',
|
||||
'hidden-page',
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes descendants of pages blocked from indexing', () => {
|
||||
const pages: RevisionPage[] = [
|
||||
{
|
||||
id: 'parent',
|
||||
type: 'group',
|
||||
title: 'Parent',
|
||||
path: 'parent',
|
||||
hidden: false,
|
||||
noRobotsIndex: true,
|
||||
pages: [
|
||||
{
|
||||
id: 'child',
|
||||
type: 'document',
|
||||
title: 'Child',
|
||||
path: 'parent/child',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as RevisionPage[];
|
||||
|
||||
expect(getIndexablePages(pages)).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes documents nested under groups', () => {
|
||||
const pages: RevisionPage[] = [
|
||||
{
|
||||
id: 'outer-group',
|
||||
type: 'group',
|
||||
title: 'Outer group',
|
||||
path: 'outer-group',
|
||||
hidden: false,
|
||||
pages: [
|
||||
{
|
||||
id: 'inner-group',
|
||||
type: 'group',
|
||||
title: 'Inner group',
|
||||
path: 'outer-group/inner-group',
|
||||
hidden: false,
|
||||
pages: [
|
||||
{
|
||||
id: 'nested-page',
|
||||
type: 'document',
|
||||
title: 'Nested page',
|
||||
path: 'outer-group/inner-group/nested-page',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as RevisionPage[];
|
||||
|
||||
expect(getIndexablePages(pages).map(({ page }) => page.id)).toEqual(['nested-page']);
|
||||
});
|
||||
});
|
||||
@@ -9,27 +9,35 @@ export type FlatPageEntry = { page: RevisionPageDocument; depth: number };
|
||||
*/
|
||||
function flattenPages(
|
||||
rootPages: RevisionPage[],
|
||||
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean
|
||||
filter: (
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>
|
||||
) => boolean
|
||||
): FlatPageEntry[] {
|
||||
const flattenPage = (
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
depth: number
|
||||
depth: number,
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>
|
||||
): FlatPageEntry[] => {
|
||||
const allowed = filter(page);
|
||||
const allowed = filter(page, ancestors);
|
||||
if (!allowed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...(page.type === 'document' ? [{ page, depth }] : []),
|
||||
...page.pages.flatMap((child) =>
|
||||
child.type === 'document' ? flattenPage(child, depth + 1) : []
|
||||
),
|
||||
];
|
||||
const children: FlatPageEntry[] = [];
|
||||
for (const child of page.pages) {
|
||||
if (child.type === 'link' || child.type === 'computed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
children.push(...flattenPage(child, depth + 1, [...ancestors, page]));
|
||||
}
|
||||
|
||||
return [...(page.type === 'document' ? [{ page, depth }] : []), ...children];
|
||||
};
|
||||
|
||||
return rootPages.flatMap((page) =>
|
||||
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : []
|
||||
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0, []) : []
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,5 +45,5 @@ function flattenPages(
|
||||
* Get all indexable pages from a revision in a flat list.
|
||||
*/
|
||||
export function getIndexablePages(rootPages: RevisionPage[]) {
|
||||
return flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
return flattenPages(rootPages, (page, ancestors) => isPageIndexable(ancestors, page));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
SiteInsightsDisplayContext,
|
||||
SiteInsightsLLMSVariant,
|
||||
} from '@gitbook/api';
|
||||
import { shouldServeMarkdown } from '@vercel/agent-readability';
|
||||
import { acceptsMarkdown, isAIAgent } from '@vercel/agent-readability';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
@@ -500,7 +500,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
|
||||
// When we use adaptive content, we want to ensure that the cache is not used at all on the client side.
|
||||
// Vercel already set this header, this is needed in OpenNext.
|
||||
if (siteURLData.contextId) {
|
||||
if (siteURLData.contextId && !siteRequestURL.pathname.endsWith('~gitbook/site-index')) {
|
||||
response.headers.set('cache-control', 'public, max-age=0, must-revalidate');
|
||||
}
|
||||
|
||||
@@ -759,6 +759,9 @@ function encodePathInSiteContent(
|
||||
case 'robots.txt':
|
||||
case '~gitbook/embed/script.js':
|
||||
case '~gitbook/embed/demo':
|
||||
case '~gitbook/site-index':
|
||||
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
||||
// as they only depend on the site structure / pages.
|
||||
return { pathname, routeType: 'static' };
|
||||
case '~gitbook/mcp':
|
||||
case '~gitbook/mcp/auth':
|
||||
@@ -772,22 +775,41 @@ function encodePathInSiteContent(
|
||||
default: {
|
||||
// If the pathname is a markdown file or the request is ing markdown,
|
||||
// we rewrite it to ~gitbook/markdown/:pathname
|
||||
if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown(request).serve) {
|
||||
const aiAgentDetection = isAIAgent(request);
|
||||
// Using heuristic detection incorrectly detects some legitimate bot requests as AI agents (e.g. Slackbot)
|
||||
// We don't want to serve markdown for these requests as it can cause issues like breaking slack unfurling.
|
||||
const shouldServeMarkdown =
|
||||
(aiAgentDetection.detected && aiAgentDetection.method !== 'heuristic') ||
|
||||
acceptsMarkdown(request);
|
||||
if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown) {
|
||||
const pagePathWithoutMD = pathname.replace(MARKDOWN_PATH_REGEX, '');
|
||||
const ask = new URL(request.url).searchParams.get('ask');
|
||||
return {
|
||||
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
|
||||
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
|
||||
pathname:
|
||||
typeof ask === 'string'
|
||||
? `~gitbook/markdown-ask/${encodeURIComponent(ask)}`
|
||||
: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
|
||||
routeType: 'static',
|
||||
events: [
|
||||
{
|
||||
type: 'page_markdown_request',
|
||||
// TODO: track pageId / spaceId when possible
|
||||
// We don't do it at the moment as we can't easily extract it from the URL.
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
},
|
||||
],
|
||||
// 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
|
||||
? [
|
||||
{
|
||||
type: 'ask_question',
|
||||
query: ask,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'page_markdown_request',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { pathname: encodePagePath(pathname) };
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { SiteSpace } from '@gitbook/api';
|
||||
|
||||
import { streamMarkdownFromSiteSpaces } from './llms-full';
|
||||
|
||||
function createMockLinker(args?: { spaceBasePath?: string }) {
|
||||
return {
|
||||
toAbsoluteURL: mock((path: string) => `https://example.com${path}`),
|
||||
toPathInSite: mock((path: string) => `/site/${args?.spaceBasePath ?? ''}${path}`),
|
||||
fork: (args: { spaceBasePath: string }) => createMockLinker(args),
|
||||
};
|
||||
}
|
||||
|
||||
describe('streamMarkdownFromSiteSpaces', () => {
|
||||
// Test with real mocks of the dependencies
|
||||
it('processes pages correctly with pagination', async () => {
|
||||
// Mock the dependencies by replacing them in the module
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
id: 'revision-1',
|
||||
pages: [
|
||||
{
|
||||
id: 'page-1',
|
||||
type: 'document',
|
||||
title: 'Page 1',
|
||||
path: 'page-1',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-2',
|
||||
type: 'document',
|
||||
title: 'Page 2',
|
||||
path: 'page-2',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-3',
|
||||
type: 'document',
|
||||
title: 'Page 3',
|
||||
path: 'page-3',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-4',
|
||||
type: 'document',
|
||||
title: 'Page 4',
|
||||
path: 'page-4',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-5',
|
||||
type: 'document',
|
||||
title: 'Page 5',
|
||||
path: 'page-5',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
),
|
||||
getRevisionPageMarkdown: mock(() =>
|
||||
Promise.resolve({
|
||||
data: '# Test Page\n\nSome content\n',
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
id: 'space-1',
|
||||
space: {
|
||||
id: 'space-1',
|
||||
revision: 'rev-1',
|
||||
},
|
||||
urls: {
|
||||
published: 'https://example.com',
|
||||
},
|
||||
path: 'test-space',
|
||||
} as SiteSpace;
|
||||
|
||||
// Capture stream output
|
||||
const chunks: string[] = [];
|
||||
const mockController = {
|
||||
enqueue: mock((chunk: Uint8Array) => {
|
||||
chunks.push(new TextDecoder().decode(chunk));
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
[mockSiteSpace],
|
||||
'base-path',
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Verify results
|
||||
expect(result.currentPageIndex).toBe(5); // Should process 5 pages
|
||||
expect(result.reachedLimit).toBe(false); // Under limit
|
||||
expect(chunks.length).toBe(5); // Should have 5 markdown chunks
|
||||
expect(mockDataFetcher.getRevision).toHaveBeenCalledTimes(1);
|
||||
expect(mockDataFetcher.getRevisionPageMarkdown).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('applies offset correctly', async () => {
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
pages: Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
type: 'document',
|
||||
title: `Page ${i + 1}`,
|
||||
path: `page-${i + 1}`,
|
||||
pages: [],
|
||||
hidden: false,
|
||||
})),
|
||||
},
|
||||
})
|
||||
),
|
||||
getRevisionPageMarkdown: mock(() => Promise.resolve({ data: 'content\n' })),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
space: { id: 'space-1', revision: 'rev-1' },
|
||||
urls: { published: 'https://example.com' },
|
||||
path: 'test-space',
|
||||
} as SiteSpace;
|
||||
|
||||
const chunks: string[] = [];
|
||||
const mockController = {
|
||||
enqueue: mock((chunk: Uint8Array) => {
|
||||
chunks.push(new TextDecoder().decode(chunk));
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
[mockSiteSpace],
|
||||
'base-path',
|
||||
3, // offset = 3
|
||||
0
|
||||
);
|
||||
|
||||
// Should process pages from index 3 onwards (7 pages)
|
||||
expect(result.currentPageIndex).toBe(10);
|
||||
expect(chunks.length).toBe(7); // 10 total - 3 offset = 7 processed
|
||||
});
|
||||
|
||||
it('handles pagination when there are more than 100 pages', async () => {
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
pages: Array.from({ length: 150 }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
type: 'document',
|
||||
title: `Page ${i + 1}`,
|
||||
path: `page-${i + 1}`,
|
||||
pages: [],
|
||||
hidden: false,
|
||||
})),
|
||||
},
|
||||
})
|
||||
),
|
||||
getRevisionPageMarkdown: mock(() => Promise.resolve({ data: 'content\n' })),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
space: { id: 'space-1', revision: 'rev-1' },
|
||||
urls: { published: 'https://example.com' },
|
||||
path: 'test-space',
|
||||
} as SiteSpace;
|
||||
|
||||
const chunks: string[] = [];
|
||||
const mockController = {
|
||||
enqueue: mock((chunk: Uint8Array) => {
|
||||
chunks.push(new TextDecoder().decode(chunk));
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
[mockSiteSpace],
|
||||
'base-path',
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Should only process 100 pages (default limit)
|
||||
expect(result.currentPageIndex).toBe(100);
|
||||
expect(result.reachedLimit).toBe(true);
|
||||
expect(chunks.length).toBe(101); // 100 pages + 1 next page link
|
||||
|
||||
// Check that next page link is included
|
||||
const fullContent = chunks.join('');
|
||||
expect(fullContent).toContain('[Next Page]');
|
||||
expect(fullContent).toContain('/site/llms-full.txt/1');
|
||||
});
|
||||
|
||||
it('handles multiple site spaces', async () => {
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock()
|
||||
.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
id: 'page-1',
|
||||
type: 'document',
|
||||
title: 'Space 1 Page 1',
|
||||
path: 'page-1',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-2',
|
||||
type: 'document',
|
||||
title: 'Space 1 Page 2',
|
||||
path: 'page-2',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
id: 'page-3',
|
||||
type: 'document',
|
||||
title: 'Space 2 Page 1',
|
||||
path: 'page-3',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-4',
|
||||
type: 'document',
|
||||
title: 'Space 2 Page 2',
|
||||
path: 'page-4',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'page-5',
|
||||
type: 'document',
|
||||
title: 'Space 2 Page 3',
|
||||
path: 'page-5',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
),
|
||||
getRevisionPageMarkdown: mock(() => Promise.resolve({ data: 'content\n' })),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpaces: SiteSpace[] = [
|
||||
{
|
||||
space: { id: 'space-1', revision: 'rev-1' },
|
||||
urls: { published: 'https://example1.com' },
|
||||
path: 'space-1',
|
||||
},
|
||||
{
|
||||
space: { id: 'space-2', revision: 'rev-2' },
|
||||
urls: { published: 'https://example2.com' },
|
||||
path: 'space-2',
|
||||
},
|
||||
] as SiteSpace[];
|
||||
|
||||
const chunks: string[] = [];
|
||||
const mockController = {
|
||||
enqueue: mock((chunk: Uint8Array) => {
|
||||
chunks.push(new TextDecoder().decode(chunk));
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const { streamMarkdownFromSiteSpaces } = await import('./llms-full');
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
mockSiteSpaces,
|
||||
'base-path',
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Should process all pages from both spaces (2 + 3 = 5)
|
||||
expect(result.currentPageIndex).toBe(5);
|
||||
expect(result.reachedLimit).toBe(false);
|
||||
expect(chunks.length).toBe(5);
|
||||
expect(mockDataFetcher.getRevision).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips site spaces without published URLs', async () => {
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock(),
|
||||
getRevisionPageMarkdown: mock(),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
space: { id: 'space-1', revision: 'rev-1' },
|
||||
urls: { published: undefined }, // No published URL
|
||||
path: 'test-space',
|
||||
} as SiteSpace;
|
||||
|
||||
const mockController = {
|
||||
enqueue: mock(),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
[mockSiteSpace],
|
||||
'base-path',
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Should not process any pages
|
||||
expect(result.currentPageIndex).toBe(0);
|
||||
expect(result.reachedLimit).toBe(false);
|
||||
expect(mockDataFetcher.getRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters only document type pages', async () => {
|
||||
const mockDataFetcher = {
|
||||
getRevision: mock(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
type: 'document',
|
||||
title: 'Document 1',
|
||||
path: 'doc-1',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'group-1',
|
||||
type: 'group',
|
||||
title: 'Group 1',
|
||||
path: 'group-1',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
type: 'document',
|
||||
title: 'Document 2',
|
||||
path: 'doc-2',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'link-1',
|
||||
type: 'link',
|
||||
title: 'Link 1',
|
||||
path: 'link-1',
|
||||
pages: [],
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
),
|
||||
getRevisionPageMarkdown: mock(() => Promise.resolve({ data: 'content\n' })),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
space: { id: 'space-1', revision: 'rev-1' },
|
||||
urls: { published: 'https://example.com' },
|
||||
path: 'test-space',
|
||||
} as SiteSpace;
|
||||
|
||||
const chunks: string[] = [];
|
||||
const mockController = {
|
||||
enqueue: mock((chunk: Uint8Array) => {
|
||||
chunks.push(new TextDecoder().decode(chunk));
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const result = await streamMarkdownFromSiteSpaces(
|
||||
mockContext,
|
||||
mockController,
|
||||
[mockSiteSpace],
|
||||
'base-path',
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Should only process the 2 document pages
|
||||
expect(result.currentPageIndex).toBe(2);
|
||||
expect(chunks.length).toBe(2);
|
||||
expect(mockDataFetcher.getRevisionPageMarkdown).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type GitBookSiteContext, checkIsRootSiteContext } from '@/lib/context';
|
||||
import {
|
||||
type GitBookSiteContext,
|
||||
checkIsRootSiteContext,
|
||||
fetchSiteContextForSiteSpace,
|
||||
} from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { getIndexablePages } from '@/lib/sitemap';
|
||||
import { filterSiteSpacesByLocale, getSiteStructureSections } from '@/lib/sites';
|
||||
import type { RevisionPageDocument, SiteSection, SiteSpace } from '@gitbook/api';
|
||||
@@ -63,7 +66,6 @@ async function streamMarkdownFromSiteStructure(
|
||||
context,
|
||||
stream,
|
||||
context.structure.structure,
|
||||
'',
|
||||
offset
|
||||
);
|
||||
return;
|
||||
@@ -88,7 +90,6 @@ async function streamMarkdownFromSections(
|
||||
context,
|
||||
stream,
|
||||
siteSection.siteSpaces,
|
||||
siteSection.path,
|
||||
offset,
|
||||
currentPageIndex
|
||||
);
|
||||
@@ -107,16 +108,13 @@ export async function streamMarkdownFromSiteSpaces(
|
||||
context: GitBookSiteContext,
|
||||
stream: ReadableStreamDefaultController<Uint8Array>,
|
||||
siteSpaces: SiteSpace[],
|
||||
basePath: string,
|
||||
offset = 0,
|
||||
initialPageIndex = 0
|
||||
): Promise<{ currentPageIndex: number; reachedLimit: boolean }> {
|
||||
const { dataFetcher } = context;
|
||||
let totalPagesProcessed = initialPageIndex;
|
||||
|
||||
// Collect all pages first
|
||||
const allPages: Array<{ page: RevisionPageDocument; siteSpace: SiteSpace; basePath: string }> =
|
||||
[];
|
||||
const allPages: Array<{ context: GitBookSiteContext; page: RevisionPageDocument }> = [];
|
||||
|
||||
const filteredSiteSpaces = filterSiteSpacesByLocale(siteSpaces, context.locale);
|
||||
|
||||
@@ -125,21 +123,15 @@ export async function streamMarkdownFromSiteSpaces(
|
||||
if (!siteSpaceUrl) {
|
||||
continue;
|
||||
}
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: siteSpace.space.id,
|
||||
revisionId: siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
const pages = getIndexablePages(revision.pages);
|
||||
const siteSpaceContext = await fetchSiteContextForSiteSpace(context, siteSpace);
|
||||
const pages = getIndexablePages(siteSpaceContext.revision.pages);
|
||||
|
||||
// Add document pages to our collection
|
||||
for (const { page } of pages) {
|
||||
if (page.type === 'document') {
|
||||
allPages.push({
|
||||
context: siteSpaceContext,
|
||||
page,
|
||||
siteSpace,
|
||||
basePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -152,8 +144,8 @@ export async function streamMarkdownFromSiteSpaces(
|
||||
// Process the pages
|
||||
for await (const markdown of pMapIterable(
|
||||
pagesToProcess,
|
||||
async ({ page, siteSpace, basePath }) => {
|
||||
return getMarkdownForPage(context, siteSpace, page, basePath);
|
||||
async ({ context: siteSpaceContext, page }) => {
|
||||
return getMarkdownForPage(siteSpaceContext, page);
|
||||
},
|
||||
{
|
||||
concurrency: MAX_CONCURRENCY,
|
||||
@@ -180,24 +172,19 @@ export async function streamMarkdownFromSiteSpaces(
|
||||
*/
|
||||
async function getMarkdownForPage(
|
||||
context: GitBookSiteContext,
|
||||
siteSpace: SiteSpace,
|
||||
page: RevisionPageDocument,
|
||||
basePath: string
|
||||
page: RevisionPageDocument
|
||||
): Promise<string> {
|
||||
const { dataFetcher } = context;
|
||||
|
||||
const pageMarkdown = await throwIfDataError(
|
||||
dataFetcher.getRevisionPageMarkdown({
|
||||
spaceId: siteSpace.space.id,
|
||||
revisionId: siteSpace.space.revision,
|
||||
spaceId: context.space.id,
|
||||
revisionId: context.revisionId,
|
||||
pageId: page.id,
|
||||
})
|
||||
);
|
||||
|
||||
const tree = fromPageMarkdown({
|
||||
linker: context.linker.fork({
|
||||
spaceBasePath: joinPath(context.linker.siteBasePath, basePath),
|
||||
}),
|
||||
const tree = await fromPageMarkdown(context, {
|
||||
markdown: pageMarkdown,
|
||||
pagePath: page.path,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { type GitBookSiteContext, checkIsRootSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import type { GitBookLinker } from '@/lib/links';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { type GitBookLinker, linkerWithMarkdownPages } from '@/lib/links';
|
||||
import { resolveFirstDocument } from '@/lib/pages';
|
||||
import { type FlatPageEntry, getIndexablePages } from '@/lib/sitemap';
|
||||
import { filterSiteSpacesByLocale, getLocalizedTitle, getSiteStructureSections } from '@/lib/sites';
|
||||
import {
|
||||
filterSiteSpacesByLocale,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
getSiteStructureSections,
|
||||
} from '@/lib/sites';
|
||||
import type { SiteSection, SiteSpace } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
|
||||
@@ -12,23 +17,18 @@ import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
/**
|
||||
* Generate a llms.txt file for the site.
|
||||
*/
|
||||
export async function serveLLMsTxt(
|
||||
context: GitBookSiteContext,
|
||||
{
|
||||
withMarkdownPages = false,
|
||||
}: {
|
||||
/**
|
||||
* If true, a markdown extension will be added to the page path.
|
||||
*/
|
||||
withMarkdownPages?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const { site } = context;
|
||||
export async function serveLLMsTxt(baseContext: GitBookSiteContext) {
|
||||
const { site } = baseContext;
|
||||
|
||||
if (!checkIsRootSiteContext(context)) {
|
||||
if (!checkIsRootSiteContext(baseContext)) {
|
||||
return new Response('llms.txt is only served from the root of the site', { status: 404 });
|
||||
}
|
||||
|
||||
const context = {
|
||||
...baseContext,
|
||||
linker: linkerWithMarkdownPages(baseContext.linker),
|
||||
};
|
||||
|
||||
const tree: Root = {
|
||||
type: 'root',
|
||||
children: [
|
||||
@@ -37,42 +37,36 @@ export async function serveLLMsTxt(
|
||||
depth: 1,
|
||||
children: [{ type: 'text', value: site.title }],
|
||||
},
|
||||
...(await getNodesFromSiteStructure(context, { withMarkdownPages })),
|
||||
...(await getNodesFromSiteStructure(context)),
|
||||
],
|
||||
};
|
||||
|
||||
return new Response(
|
||||
toMarkdown(tree, {
|
||||
bullet: '-',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
let output = toMarkdown(tree, {
|
||||
bullet: '-',
|
||||
});
|
||||
|
||||
output += renderAskFooter(context);
|
||||
|
||||
return new Response(output, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site structure.
|
||||
*/
|
||||
async function getNodesFromSiteStructure(
|
||||
context: GitBookSiteContext,
|
||||
options: {
|
||||
withMarkdownPages: boolean;
|
||||
}
|
||||
): Promise<RootContent[]> {
|
||||
async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<RootContent[]> {
|
||||
switch (context.structure.type) {
|
||||
case 'sections':
|
||||
return getNodesFromSections(
|
||||
context,
|
||||
getSiteStructureSections(context.structure, { ignoreGroups: true }),
|
||||
{ withMarkdownPages: options.withMarkdownPages }
|
||||
getSiteStructureSections(context.structure, { ignoreGroups: true })
|
||||
);
|
||||
case 'siteSpaces':
|
||||
return getNodesFromSiteSpaces(context, context.structure.structure, {
|
||||
heading: true,
|
||||
withMarkdownPages: options.withMarkdownPages,
|
||||
});
|
||||
default:
|
||||
assertNever(context.structure);
|
||||
@@ -84,17 +78,13 @@ async function getNodesFromSiteStructure(
|
||||
*/
|
||||
async function getNodesFromSections(
|
||||
context: GitBookSiteContext,
|
||||
siteSections: SiteSection[],
|
||||
options: {
|
||||
withMarkdownPages: boolean;
|
||||
}
|
||||
siteSections: SiteSection[]
|
||||
): Promise<RootContent[]> {
|
||||
const currentLanguage = context.locale;
|
||||
const all = await Promise.all(
|
||||
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
||||
const siteSpaceNodes = await getNodesFromSiteSpaces(context, siteSection.siteSpaces, {
|
||||
heading: false,
|
||||
withMarkdownPages: options.withMarkdownPages,
|
||||
});
|
||||
return [
|
||||
{
|
||||
@@ -122,11 +112,6 @@ async function getNodesFromSiteSpaces(
|
||||
* Includes a heading for each site space.
|
||||
*/
|
||||
heading?: boolean;
|
||||
|
||||
/**
|
||||
* If true, a markdown extension will be added to the page path.
|
||||
*/
|
||||
withMarkdownPages: boolean;
|
||||
}
|
||||
): Promise<RootContent[]> {
|
||||
const { dataFetcher, linker } = context;
|
||||
@@ -163,15 +148,15 @@ async function getNodesFromSiteSpaces(
|
||||
});
|
||||
}
|
||||
|
||||
// Add the pages as a list
|
||||
nodes.push(
|
||||
...(await getMarkdownForPagesTree(pages, {
|
||||
siteSpaceUrl,
|
||||
linker,
|
||||
withMarkdownPages: options.withMarkdownPages,
|
||||
}))
|
||||
const siteSpaceLinker = linkerWithMarkdownPages(
|
||||
linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
|
||||
})
|
||||
);
|
||||
|
||||
// Add the pages as a list
|
||||
nodes.push(...(await getMarkdownForPagesTree(pages, siteSpaceLinker)));
|
||||
|
||||
return nodes;
|
||||
})
|
||||
);
|
||||
@@ -183,23 +168,15 @@ async function getNodesFromSiteSpaces(
|
||||
*/
|
||||
export async function getMarkdownForPagesTree(
|
||||
pages: FlatPageEntry[],
|
||||
options: {
|
||||
siteSpaceUrl: string;
|
||||
linker: GitBookLinker;
|
||||
withMarkdownPages?: boolean;
|
||||
}
|
||||
linker: GitBookLinker
|
||||
): Promise<RootContent[]> {
|
||||
const { siteSpaceUrl, linker } = options;
|
||||
|
||||
const listChildren = await Promise.all(
|
||||
pages.map(async ({ page }): Promise<ListItem> => {
|
||||
const pageURL = new URL(siteSpaceUrl);
|
||||
pageURL.pathname = joinPath(pageURL.pathname, page.path);
|
||||
if (options.withMarkdownPages) {
|
||||
pageURL.pathname = `${pageURL.pathname}.md`;
|
||||
}
|
||||
|
||||
const url = linker.toLinkForContent(pageURL.toString());
|
||||
const url = linker.toAbsoluteURL(
|
||||
linker.toPathForPagePath({
|
||||
path: page.path,
|
||||
})
|
||||
);
|
||||
const children: Paragraph['children'] = [
|
||||
{
|
||||
type: 'link',
|
||||
@@ -224,3 +201,25 @@ export async function getMarkdownForPagesTree(
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function renderAskFooter(context: GitBookSiteContext) {
|
||||
return `\n\n---\n\n# Agent Instructions: Querying This Documentation
|
||||
|
||||
If you need additional information, you can query the documentation dynamically by asking a question.
|
||||
|
||||
Perform an HTTP GET request on a page URL with the \`ask\` query parameter:
|
||||
|
||||
\`\`\`
|
||||
GET ${context.linker.toAbsoluteURL(
|
||||
context.linker.toPathForPagePath({
|
||||
path: resolveFirstDocument(context.revision.pages, [])?.page.path ?? 'index',
|
||||
})
|
||||
)}?ask=<question>
|
||||
\`\`\`
|
||||
|
||||
The question should be specific, self-contained, and written in natural language.
|
||||
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
|
||||
|
||||
Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { linkerWithMarkdownPages } from '@/lib/links';
|
||||
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { findSiteSpaceBy, getFallbackSiteSpacePath } from '@/lib/sites';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { serveMarkdown } from '@/routes/markdownPage';
|
||||
import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Serve an AI answer as markdown for a page.
|
||||
*/
|
||||
export async function serveAskMarkdown(context: GitBookSiteContext, rawQuestion: string) {
|
||||
return serveMarkdown(async () => {
|
||||
const question = rawQuestion.trim();
|
||||
|
||||
if (
|
||||
!question ||
|
||||
// Some crawlers just follows the example URL
|
||||
question === '<question>' ||
|
||||
question === '<question'
|
||||
) {
|
||||
return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=<question>` search parameter to get a complete answer and associated sources.';
|
||||
}
|
||||
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
const stream = apiClient.orgs.streamAskInSite(
|
||||
context.organizationId,
|
||||
context.site.id,
|
||||
{
|
||||
question,
|
||||
context: {
|
||||
siteSpaceId: context.siteSpace.id,
|
||||
},
|
||||
scope: {
|
||||
mode: 'default',
|
||||
currentSiteSpace: context.siteSpace.id,
|
||||
},
|
||||
},
|
||||
{ format: 'markdown' }
|
||||
);
|
||||
|
||||
let latestAnswer: SearchAIAnswer | null = null;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'answer') {
|
||||
latestAnswer = chunk.answer;
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
|
||||
return `We couldn't answer this question.`;
|
||||
}
|
||||
|
||||
const answerMarkdown = toPageMarkdown(
|
||||
await fromPageMarkdown(
|
||||
{
|
||||
...context,
|
||||
linker: linkerWithMarkdownPages(context.linker),
|
||||
},
|
||||
{
|
||||
markdown: latestAnswer.answer.markdown,
|
||||
pagePath: '',
|
||||
}
|
||||
)
|
||||
);
|
||||
const sourcesMarkdown = await renderAskSourcesMarkdown(
|
||||
context,
|
||||
latestAnswer?.sources ?? []
|
||||
);
|
||||
|
||||
let result = `# ${question}\n\n`;
|
||||
result += answerMarkdown.trim();
|
||||
result += '\n\n';
|
||||
|
||||
if (sourcesMarkdown) {
|
||||
result += '# Sources:\n\n';
|
||||
result += sourcesMarkdown;
|
||||
result += '\n\n';
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderAskSourcesMarkdown(
|
||||
context: GitBookSiteContext,
|
||||
sources: SearchAIAnswerSource[]
|
||||
) {
|
||||
const items = (
|
||||
await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
if (source.type === 'record') {
|
||||
return {
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
};
|
||||
}
|
||||
|
||||
const revision =
|
||||
source.space === context.space.id && source.revision === context.revisionId
|
||||
? context.revision
|
||||
: await throwIfDataError(
|
||||
context.dataFetcher.getRevision({
|
||||
spaceId: source.space,
|
||||
revisionId: source.revision,
|
||||
})
|
||||
);
|
||||
const resolved = resolvePageId(revision.pages, source.page);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === source.space
|
||||
);
|
||||
const linker = found
|
||||
? context.linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, found.siteSpace),
|
||||
})
|
||||
: context.linker;
|
||||
|
||||
return {
|
||||
title: resolved.page.title,
|
||||
url: linker.toAbsoluteURL(`${linker.toPathInSpace(resolved.page.path)}.md`),
|
||||
};
|
||||
})
|
||||
)
|
||||
).filter(filterOutNullable);
|
||||
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return items.map((item) => `- [${item.title}](${item.url})`).join('\n');
|
||||
}
|
||||
@@ -1,24 +1,133 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getExposableError } from '@/lib/data';
|
||||
import { linkerWithMarkdownPages } from '@/lib/links';
|
||||
import { getMarkdownForPage } from '@/lib/markdownPage';
|
||||
import { getSimilarPages, resolvePagePathDocumentOrGroup } from '@/lib/pages';
|
||||
import {
|
||||
type ResolvedPagePath,
|
||||
getSimilarPages,
|
||||
resolvePagePathDocumentOrGroup,
|
||||
} from '@/lib/pages';
|
||||
import type { RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Serve a markdown version of a page.
|
||||
* Returns a 404 if the page is not found.
|
||||
*/
|
||||
export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) {
|
||||
try {
|
||||
export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePath: string) {
|
||||
return serveMarkdown(async () => {
|
||||
const context = {
|
||||
...baseContext,
|
||||
linker: linkerWithMarkdownPages(baseContext.linker),
|
||||
};
|
||||
|
||||
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath);
|
||||
if (!pageLookup) {
|
||||
// Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.
|
||||
const md = generateNotFoundMarkdown(context, pagePath);
|
||||
// Return as 200 so agents read the body
|
||||
return serveMarkdown(md);
|
||||
// Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.=
|
||||
return renderNotFoundMarkdown(context, pagePath);
|
||||
}
|
||||
|
||||
const markdown = await getMarkdownForPage(context, pageLookup);
|
||||
return serveMarkdown(markdown);
|
||||
const markdownPage = await getMarkdownForPage(context, pageLookup);
|
||||
return `${markdownPage}${renderAskFooter(context, pageLookup)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) {
|
||||
const similarPages = getSimilarPages(context.revision.pages, pagePath, 5);
|
||||
const sitemapUrl = context.linker.toAbsoluteURL(context.linker.toPathInSite('sitemap.md'));
|
||||
const fullContentUrl = context.linker.toAbsoluteURL(
|
||||
context.linker.toPathInSite('llms-full.txt')
|
||||
);
|
||||
|
||||
return `# Page Not Found
|
||||
|
||||
The URL \`${pagePath}\` does not exist. This page may have been moved, renamed, or deleted.
|
||||
|
||||
## Suggested Pages
|
||||
|
||||
You may be looking for one of the following:
|
||||
${similarPages.map((page) => `- [${page.title}](${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md)`).join('\n')}
|
||||
|
||||
## How to find the correct page
|
||||
|
||||
If the exact page cannot be found, you can still retrieve the information using the documentation query interface.
|
||||
|
||||
### Option 1 — Ask a question (recommended)
|
||||
|
||||
Perform an HTTP GET request on the documentation index with the \`ask\` parameter:
|
||||
|
||||
\`\`\`
|
||||
GET ${context.linker.toAbsoluteURL(
|
||||
context.linker.toPathForPagePath({
|
||||
path: similarPages[0]?.path ?? 'docs/example',
|
||||
})
|
||||
)}?ask=<question>
|
||||
\`\`\`
|
||||
|
||||
The question should be specific, self-contained, and written in natural language.
|
||||
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
|
||||
|
||||
### Option 2 — Browse the documentation index
|
||||
|
||||
Full index: ${sitemapUrl}
|
||||
|
||||
Use this to discover valid page paths or navigate the documentation structure.
|
||||
|
||||
### Option 3 — Retrieve the full documentation corpus
|
||||
|
||||
Full export: ${fullContentUrl}
|
||||
|
||||
Use this to access all content at once and perform your own parsing or retrieval. It will be more expensive.
|
||||
|
||||
## Tips for requesting documentation
|
||||
|
||||
Prefer \`.md\` URLs for structured content, append \`.md\` to URLs (e.g., \`${context.linker.toPathForPagePath(
|
||||
{
|
||||
path: similarPages[0]?.path ?? 'docs/example',
|
||||
}
|
||||
)}\`).
|
||||
|
||||
You may also use \`Accept: text/markdown\` header for content negotiation.
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAskFooter(
|
||||
context: GitBookSiteContext,
|
||||
pageLookup: ResolvedPagePath<RevisionPageDocument | RevisionPageGroup>
|
||||
) {
|
||||
return `\n\n---\n\n# Agent Instructions: Querying This Documentation
|
||||
|
||||
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.
|
||||
|
||||
Perform an HTTP GET request on the current page URL with the \`ask\` query parameter:
|
||||
|
||||
\`\`\`
|
||||
GET ${context.linker.toAbsoluteURL(
|
||||
context.linker.toPathForPage({
|
||||
page: pageLookup.page,
|
||||
pages: context.revision.pages,
|
||||
})
|
||||
)}?ask=<question>
|
||||
\`\`\`
|
||||
|
||||
The question should be specific, self-contained, and written in natural language.
|
||||
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
|
||||
|
||||
Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a markdown content.
|
||||
*/
|
||||
export async function serveMarkdown(fn: () => Promise<string>) {
|
||||
try {
|
||||
const markdown = await fn();
|
||||
return new Response(markdown, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return new Response(exposable.message, {
|
||||
@@ -29,37 +138,3 @@ export async function servePageMarkdown(context: GitBookSiteContext, pagePath: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function generateNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) {
|
||||
const similarPages = getSimilarPages(context.revision.pages, pagePath, 5);
|
||||
const sitemapUrl = context.linker.toAbsoluteURL(context.linker.toPathInSite('sitemap.md'));
|
||||
const fullContentUrl = context.linker.toAbsoluteURL(
|
||||
context.linker.toPathInSite('llms-full.txt')
|
||||
);
|
||||
|
||||
return `# Page Not Found
|
||||
|
||||
The URL \`${pagePath}\` does not exist.
|
||||
|
||||
You might be looking for one of these pages:
|
||||
${similarPages.map((page) => `- [${page.title}](${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md)`).join('\n')}
|
||||
|
||||
## How to find the correct page
|
||||
|
||||
1. **Browse the full index**: [/sitemap.md](${sitemapUrl}) - Complete documentation index
|
||||
2. **View the full content**: [/llms-full.txt](${fullContentUrl}) - Full content export
|
||||
|
||||
## Tips for requesting documentation
|
||||
|
||||
- For markdown responses, append \`.md\` to URLs (e.g., \`${context.linker.toPathInSpace(similarPages[0]?.path ?? 'docs/example')}.md\`)
|
||||
- Use \`Accept: text/markdown\` header for content negotiation`;
|
||||
}
|
||||
|
||||
function serveMarkdown(markdown: string) {
|
||||
return new Response(markdown, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'X-Robots-Tag': 'noindex',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,12 +121,6 @@ const config: Config = {
|
||||
periwinkle: generateShades('#acc6ee'),
|
||||
},
|
||||
backgroundColor: {
|
||||
'mark-blue': '#89C6DA4D',
|
||||
'mark-purple': '#DAD4FF4D',
|
||||
'mark-orange': '#FFDCBC4D',
|
||||
'mark-red': '#FFCCCB4D',
|
||||
'mark-yellow': '#FFF0854D',
|
||||
'mark-green': '#91EABF4D',
|
||||
primary: generateVarShades('primary', [
|
||||
ColorCategory.backgrounds,
|
||||
ColorCategory.components,
|
||||
@@ -320,23 +314,29 @@ const config: Config = {
|
||||
'fadeIn-slow': 'fadeIn 500ms ease both',
|
||||
fadeOut: 'fadeOut 200ms ease both',
|
||||
'fadeOut-slow': 'fadeOut 500ms ease both',
|
||||
appear: 'appear 200ms ease both allow-discrete',
|
||||
appear: 'fadeIn 1ms ease both',
|
||||
|
||||
blurIn: 'blurIn 200ms ease-out both',
|
||||
blurInSlow: 'blurIn 500ms ease-out both',
|
||||
blurOut: 'blurOut 200ms ease-in both',
|
||||
blurOutSlow: 'blurOut 500ms ease-in both',
|
||||
blurInDisplay: 'blurInDisplay 200ms ease-out both',
|
||||
blurInDisplaySlow: 'blurInDisplay 500ms ease-out both',
|
||||
blurOutDisplay: 'blurOutDisplay 200ms ease-in both',
|
||||
blurOutDisplaySlow: 'blurOutDisplay 500ms ease-in both',
|
||||
blurIn: 'blurIn 200ms both',
|
||||
blurInSlow: 'blurIn 500ms both',
|
||||
blurOut: 'blurOut 200ms both',
|
||||
blurOutSlow: 'blurOut 500ms both',
|
||||
blurInDisplay: 'blurInDisplay 200ms both',
|
||||
blurInDisplaySlow: 'blurInDisplay 500ms both',
|
||||
blurOutDisplay: 'blurOutDisplay 200ms both',
|
||||
blurOutDisplaySlow: 'blurOutDisplay 500ms both',
|
||||
|
||||
blurInHeight: 'heightIn 200ms ease-out both, blurIn 200ms 100ms both',
|
||||
blurInHeightSlow: 'heightIn 500ms ease-out both, blurIn 500ms 300ms both',
|
||||
blurOutHeight: 'blurOut 200ms both, heightOut 200ms 100ms ease-out both',
|
||||
blurOutHeightSlow: 'blurOut 500ms both, heightOut 500ms 300ms ease-out both',
|
||||
|
||||
enterFromLeft: 'enterFromLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
enterFromRight: 'enterFromRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToLeft: 'exitToLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToRight: 'exitToRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
|
||||
heightIn: 'heightIn 200ms ease both',
|
||||
heightIn: 'heightIn 500ms ease both',
|
||||
heightOut: 'heightOut 500ms ease both',
|
||||
crawl: 'crawl 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
@@ -500,28 +500,28 @@ const config: Config = {
|
||||
to: { opacity: '0', transform: 'rotateX(-10deg) scale(0.95)' },
|
||||
},
|
||||
blurIn: {
|
||||
from: { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
|
||||
to: { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
|
||||
'0%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
blurOut: {
|
||||
from: { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
|
||||
to: { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
|
||||
'0%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
|
||||
'100%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
|
||||
},
|
||||
blurInDisplay: {
|
||||
from: {
|
||||
'0%': {
|
||||
filter: 'blur(6px)',
|
||||
opacity: '0',
|
||||
transform: 'scale(0.95)',
|
||||
display: 'none',
|
||||
},
|
||||
to: {
|
||||
'100%': {
|
||||
filter: 'blur(0px)',
|
||||
opacity: '1',
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
},
|
||||
blurOutDisplay: {
|
||||
from: {
|
||||
'0%': {
|
||||
filter: 'blur(0px)',
|
||||
opacity: '1',
|
||||
transform: 'scale(1)',
|
||||
@@ -540,16 +540,25 @@ const config: Config = {
|
||||
heightIn: {
|
||||
from: {
|
||||
height: '0',
|
||||
opacity: '0',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0',
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
gap: '0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
to: { height: 'max-content' },
|
||||
},
|
||||
heightOut: {
|
||||
from: { height: 'max-content' },
|
||||
to: {
|
||||
height: '0',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0',
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
gap: '0',
|
||||
},
|
||||
to: { height: 'max-content', opacity: '1' },
|
||||
},
|
||||
heightOut: {
|
||||
from: { height: 'max-content', opacity: '1' },
|
||||
to: { height: '0', opacity: '0', marginTop: '0', marginBottom: '0', gap: '0' },
|
||||
},
|
||||
crawl: {
|
||||
'0%': {
|
||||
@@ -613,6 +622,28 @@ const config: Config = {
|
||||
'4xl': '2144px',
|
||||
},
|
||||
},
|
||||
animationDuration: {
|
||||
'100': '100ms',
|
||||
'200': '200ms',
|
||||
'300': '300ms',
|
||||
'500': '500ms',
|
||||
'1000': '1000ms',
|
||||
'1500': '1500ms',
|
||||
'2000': '2000ms',
|
||||
'2500': '2500ms',
|
||||
'3000': '3000ms',
|
||||
},
|
||||
animationDelay: {
|
||||
'100': '100ms',
|
||||
'200': '200ms',
|
||||
'300': '300ms',
|
||||
'500': '500ms',
|
||||
'1000': '1000ms',
|
||||
'1500': '1500ms',
|
||||
'2000': '2000ms',
|
||||
'2500': '2500ms',
|
||||
'3000': '3000ms',
|
||||
},
|
||||
},
|
||||
opacity: opacity(),
|
||||
screens: {
|
||||
|
||||
@@ -12,6 +12,18 @@ describe('llms.txt', () => {
|
||||
expect(await response.text()).toContain('# E2E Tests GitBook Open');
|
||||
});
|
||||
|
||||
it('should properly format links', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook-open-e2e-sites.gitbook.io/sections/llms.txt')
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
const content = await response.text();
|
||||
expect(content).toContain('/sections/sections-3/readme.md');
|
||||
expect(content).toContain('/sections/sections-4/getting-started/quickstart.md');
|
||||
});
|
||||
|
||||
it('should expose a llms.txt file with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt'),
|
||||
@@ -49,28 +61,59 @@ describe('llms.txt', () => {
|
||||
});
|
||||
|
||||
describe('llms-full.txt', () => {
|
||||
it('should expose a llms-full.txt file', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt')
|
||||
);
|
||||
it(
|
||||
'should expose a llms-full.txt file',
|
||||
async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt')
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
it('should expose a llms-full.txt file with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should expose cross-space pages from a multi-version site',
|
||||
async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/llms-full.txt'
|
||||
)
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(text).toContain(
|
||||
'gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/quick-start'
|
||||
);
|
||||
expect(text).toContain(
|
||||
'gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page'
|
||||
);
|
||||
expect(text).not.toContain('broken://');
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should expose a llms-full.txt file with the accept header',
|
||||
async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,40 +1,103 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getContentTestURL } from './utils';
|
||||
|
||||
it('should expose a markdown page with the .md extension', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page.md')
|
||||
);
|
||||
describe('markdown pages', () => {
|
||||
it('should expose a markdown page with the .md extension', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page.md')
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(await response.text()).toContain('# Text page');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain('# Text page');
|
||||
});
|
||||
|
||||
it('should expose a markdown page with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain('# Text page');
|
||||
});
|
||||
|
||||
it('should return a 200 for a page not found', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/missing-page.md')
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain('# Page Not Found');
|
||||
});
|
||||
|
||||
it('should rewrite links to markdown URLs', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/blocks/links.md')
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain('gitbook.gitbook.io/test-gitbook-open/text-page.md');
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose a markdown page with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
describe('markdown ask responses', () => {
|
||||
const ASK_QUESTION = 'What is GitBook?';
|
||||
const ASK_QUESTION_HEADING = `# ${ASK_QUESTION}`;
|
||||
|
||||
it(
|
||||
'should expose ask responses for .md requests',
|
||||
async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL(
|
||||
`https://gitbook.com/docs/getting-started/ai-documentation.md?ask=${encodeURIComponent(ASK_QUESTION)}`
|
||||
)
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain(ASK_QUESTION_HEADING);
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(await response.text()).toContain('# Text page');
|
||||
});
|
||||
it(
|
||||
'should expose ask responses for accept header markdown requests',
|
||||
async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL(
|
||||
`https://gitbook.com/docs/getting-started/ai-documentation?ask=${encodeURIComponent(ASK_QUESTION)}`
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
it('should should return a 200 for a page not found', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/missing-page.md')
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(text).toContain(ASK_QUESTION_HEADING);
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(response.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(await response.text()).toContain('# Page Not Found');
|
||||
});
|
||||
|
||||
@@ -3,45 +3,84 @@ import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { getContentTestURL } from './utils';
|
||||
|
||||
it('should expose a MCP server', async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
it(
|
||||
'should expose a MCP server',
|
||||
async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp'))
|
||||
)
|
||||
);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp'))
|
||||
)
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools[0]?.name).toBe('searchDocumentation');
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools[0]?.name).toBe('searchDocumentation');
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'searchDocumentation',
|
||||
arguments: {
|
||||
query: 'git',
|
||||
},
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: 'searchDocumentation',
|
||||
arguments: {
|
||||
query: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('Title:');
|
||||
});
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('Title:');
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it('should expose a MCP server on the authenticated path', async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
it(
|
||||
'should expose a MCP server on the authenticated path',
|
||||
async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth'))
|
||||
)
|
||||
);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth'))
|
||||
)
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools[0]?.name).toBe('searchDocumentation');
|
||||
expect(tools.tools[1]?.name).toBe('getPage');
|
||||
});
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools[0]?.name).toBe('searchDocumentation');
|
||||
expect(tools.tools[1]?.name).toBe('getPage');
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should get a page from another site space through MCP',
|
||||
async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(
|
||||
getContentTestURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/~gitbook/mcp/auth'
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'getPage',
|
||||
arguments: {
|
||||
url: 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page',
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('# Other Page');
|
||||
},
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { IconsProvider } from './IconsProvider';
|
||||
import { clearRegisteredServerIconSymbols, getRegisteredServerIconSymbols } from './symbols';
|
||||
import { IconStyle } from './types';
|
||||
|
||||
afterEach(() => {
|
||||
clearRegisteredServerIconSymbols();
|
||||
});
|
||||
|
||||
function RegisteredSymbolsSummary() {
|
||||
const symbols = getRegisteredServerIconSymbols();
|
||||
return (
|
||||
<output
|
||||
data-testid="registered-symbols"
|
||||
data-count={symbols.length}
|
||||
data-symbol-ids={symbols.map((symbol) => symbol.symbolId).join(',')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
it('renders the mask-image path when symbol mode is disabled', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider assetsURL="https://icons.example.test" iconStyle={IconStyle.Regular}>
|
||||
<Icon icon="github" className="size-4" />
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="mask-image"');
|
||||
expect(html).toContain('https://icons.example.test/svgs/brands/github.svg?v=2');
|
||||
expect(html).not.toContain('data-testid="symbol-use"');
|
||||
});
|
||||
|
||||
it('renders inline symbol references and deduplicates SSR registrations', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IconsProvider
|
||||
assetsURL="https://icons.example.test"
|
||||
iconStyle={IconStyle.Regular}
|
||||
renderMode="symbol"
|
||||
symbolLoaderURL="/~gitbook/icons/symbol"
|
||||
>
|
||||
<>
|
||||
<Icon icon="github" />
|
||||
<Icon icon="github" />
|
||||
<Icon icon="gitbook" />
|
||||
<RegisteredSymbolsSummary />
|
||||
</>
|
||||
</IconsProvider>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="symbol-use"');
|
||||
expect(html).toContain('href="#gb-icon-brands-github"');
|
||||
expect(html).toContain('href="#gb-icon-custom-icons-gitbook"');
|
||||
expect(html).toContain('data-testid="registered-symbols"');
|
||||
expect(html).toContain('data-count="2"');
|
||||
expect(html).toContain(
|
||||
'data-symbol-ids="gb-icon-brands-github,gb-icon-custom-icons-gitbook"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { getIconAssetURL, useIcons } from './IconsProvider';
|
||||
import { IconSymbolLoader } from './IconSymbolLoader';
|
||||
import { getIconAssetURL, getIconSpriteAssetURL, useIcons } from './IconsProvider';
|
||||
import { getIconStyle } from './getIconStyle';
|
||||
import { getIconSymbolId, prefetchServerIconAsset, registerServerIconSymbol } from './symbols';
|
||||
import type { IconName, IconStyle } from './types';
|
||||
|
||||
/**
|
||||
@@ -50,8 +52,47 @@ export const Icon = React.forwardRef(function Icon(
|
||||
} = props;
|
||||
|
||||
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
|
||||
const url = getIconAssetURL(context, iconStyle, icon);
|
||||
const maskId = React.useId();
|
||||
const iconInstanceId = React.useId();
|
||||
const symbolId = getIconSymbolId(iconStyle, icon);
|
||||
const iconAssetURL = getIconAssetURL(context, iconStyle, icon);
|
||||
const iconSpriteAssetURL = getIconSpriteAssetURL(context, iconStyle);
|
||||
|
||||
if (context.renderMode === 'symbol') {
|
||||
prefetchServerIconAsset(iconSpriteAssetURL);
|
||||
registerServerIconSymbol({
|
||||
style: iconStyle,
|
||||
icon,
|
||||
symbolId,
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
{...rest}
|
||||
viewBox="0 0 512 512"
|
||||
data-gb-icon-instance={iconInstanceId}
|
||||
style={{
|
||||
overflow: 'visible',
|
||||
...(size ? { width: size, height: size } : {}),
|
||||
...rest.style,
|
||||
}}
|
||||
className={`gb-icon ${className}`}
|
||||
>
|
||||
<title>{icon}</title>
|
||||
<use data-testid="symbol-use" href={`#${symbolId}`} width="100%" height="100%" />
|
||||
{context.symbolLoaderURL ? (
|
||||
<IconSymbolLoader
|
||||
instanceId={iconInstanceId}
|
||||
symbolId={symbolId}
|
||||
style={iconStyle}
|
||||
icon={icon}
|
||||
loaderURL={context.symbolLoaderURL}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -73,7 +114,7 @@ export const Icon = React.forwardRef(function Icon(
|
||||
>
|
||||
<image
|
||||
data-testid="mask-image"
|
||||
href={url}
|
||||
href={iconAssetURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
||||
const pendingSymbolLoads = new Map<string, Promise<boolean>>();
|
||||
|
||||
/**
|
||||
* Ensure a symbol referenced by an inline `<use>` exists after hydration, fetching it from the
|
||||
* same-origin symbol route only when the SSR sprite did not already include it.
|
||||
*/
|
||||
export function IconSymbolLoader(props: {
|
||||
instanceId: string;
|
||||
symbolId: string;
|
||||
style: string;
|
||||
icon: string;
|
||||
loaderURL: string;
|
||||
}) {
|
||||
const { instanceId, symbolId, style, icon, loaderURL } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
if (hasSymbol(symbolId)) {
|
||||
setIconState(instanceId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
loadSymbol(symbolId, loaderURL, style, icon).then((loaded) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIconState(instanceId, loaded);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [icon, instanceId, loaderURL, style, symbolId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A root SVG element used to host the loaded symbols as a spritesheet. This is used as a reference point for the `<use>` elements in the icons.
|
||||
*/
|
||||
function getSpriteRoot(): SVGSVGElement {
|
||||
const existing = document.getElementById('gb-icon-sprite-root');
|
||||
if (existing instanceof SVGSVGElement) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return createSpriteRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the root SVG element for the spritesheet if it doesn't already exist, and appends it to the document body.
|
||||
*/
|
||||
function createSpriteRoot(): SVGSVGElement {
|
||||
const spriteRoot = document.createElementNS(SVG_NAMESPACE, 'svg');
|
||||
spriteRoot.setAttribute('id', 'gb-icon-sprite-root');
|
||||
spriteRoot.setAttribute('aria-hidden', 'true');
|
||||
spriteRoot.setAttribute('focusable', 'false');
|
||||
spriteRoot.setAttribute(
|
||||
'style',
|
||||
'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'
|
||||
);
|
||||
document.body.prepend(spriteRoot);
|
||||
|
||||
return spriteRoot;
|
||||
}
|
||||
|
||||
function hasSymbol(symbolId: string): boolean {
|
||||
return document.getElementById(symbolId) instanceof SVGElement;
|
||||
}
|
||||
|
||||
function buildSymbolURL(loaderURL: string, style: string, icon: string): string {
|
||||
const normalizedLoaderURL = loaderURL.endsWith('/') ? loaderURL.slice(0, -1) : loaderURL;
|
||||
|
||||
return `${normalizedLoaderURL}/${encodeURIComponent(style)}/${encodeURIComponent(icon)}`;
|
||||
}
|
||||
|
||||
function appendSymbolsFromDocument(markup: string): boolean {
|
||||
const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
|
||||
const symbols = Array.from(parsed.querySelectorAll('symbol'));
|
||||
if (symbols.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const spriteRoot = getSpriteRoot();
|
||||
for (const symbol of symbols) {
|
||||
const symbolId = symbol.getAttribute('id');
|
||||
if (!symbolId || hasSymbol(symbolId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
spriteRoot.appendChild(document.importNode(symbol, true));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadSymbol(symbolId: string, loaderURL: string, style: string, icon: string) {
|
||||
if (hasSymbol(symbolId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cached = pendingSymbolLoads.get(symbolId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const request = fetch(buildSymbolURL(loaderURL, style, icon), {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const symbolMarkup = await response.text();
|
||||
if (hasSymbol(symbolId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!appendSymbolsFromDocument(symbolMarkup)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasSymbol(symbolId);
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
pendingSymbolLoads.delete(symbolId);
|
||||
});
|
||||
|
||||
pendingSymbolLoads.set(symbolId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function setIconState(instanceId: string, loaded: boolean) {
|
||||
const icon = document.querySelector<SVGSVGElement>(
|
||||
`svg[data-gb-icon-instance="${instanceId}"]`
|
||||
);
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
const symbolUse = icon.querySelector<SVGUseElement>('[data-testid="symbol-use"]');
|
||||
if (symbolUse) {
|
||||
symbolUse.style.display = loaded ? '' : 'none';
|
||||
}
|
||||
icon.setAttribute('data-gb-icon-symbol-state', loaded ? 'loaded' : 'failed');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user