Compare commits

...

43 Commits

Author SHA1 Message Date
Brett Jephson 1ae09b1ec5 Merge branch 'main' into brett/fa-icons-rendering 2026-05-05 09:49:03 +01:00
conico974 f71ab8426c Bust page document data cache (#4234) 2026-05-04 16:15:27 +02:00
conico974 a3d1558f99 Disable regional cache for debugging purposes (#4233) 2026-05-04 14:03:29 +02:00
conico974 ca53869e2d Reduce cache TTL for regional cache and log cache debug (#4232) 2026-05-04 13:32:36 +02:00
conico974 33598e665b Add logging for cache retrieval in GitbookIncrementalCache (#4231) 2026-05-04 11:47:47 +02:00
Samy Pessé 9045ba5248 Remove progressive rollout for markdown stable links (#4230) 2026-05-01 21:56:28 +02:00
Brett Jephson 6cd9f2aaaa update 2026-04-30 20:38:57 +01:00
Brett Jephson 6d4a1653a2 use sprites to parse symbols 2026-04-30 18:52:05 +01:00
Brett Jephson 9ce2759e1b move spritesheet up 2026-04-30 18:05:38 +01:00
Brett Jephson 5663064b46 Speed up icon rendering 2026-04-30 17:32:51 +01:00
Brett Jephson 6246b106af formatting 2026-04-30 15:11:15 +01:00
Brett Jephson 9ad85cb30c tidy 2026-04-30 15:07:46 +01:00
Brett Jephson 25bcff6eff Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 15:02:00 +01:00
Brett Jephson f232b8fff5 e2e tests 2026-04-30 15:01:32 +01:00
Brett Jephson e7f9bc2565 use route to transform svg to local symbol 2026-04-30 14:57:34 +01:00
Rodrigo Castro 7061fc2223 Fix hidden pages in sitemap exports (#4216) 2026-04-30 14:04:37 +01:00
Brett Jephson 21053f56fb Update method of loading symbols 2026-04-30 13:47:16 +01:00
Brett Jephson 2746ffc000 test fix 2026-04-30 12:01:53 +01:00
Brett Jephson 568ee3a660 tidy and update icon loading in test 2026-04-30 11:31:00 +01:00
Brett Jephson 420bf4a6f6 Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 10:52:51 +01:00
Brett Jephson 72cffdd14b tidy 2026-04-30 10:45:25 +01:00
Samy Pessé 0c9e3952b7 Increase rollout of markdown stable links to 50% (#4228) 2026-04-30 11:37:37 +02:00
Brett Jephson 07be0e224c use shared supported style info 2026-04-30 10:16:35 +01:00
Brett Jephson c765394914 tidy 2026-04-30 10:16:35 +01:00
Brett Jephson ab96ee806b changeset 2026-04-30 10:16:35 +01:00
Brett Jephson 7cc6090e03 load icons as symbols 2026-04-30 10:16:35 +01:00
Samy Pessé 4b78672135 Fix links to other site-spaces / sections in llms.txt (#4222) 2026-04-30 08:43:32 +02:00
conico974 10289e4881 Revert search worker changes and add error handling (#4226)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:59:05 +02:00
conico974 8b6a6df1d6 Move search into a web worker (#4223)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:42:44 +02:00
conico974 83df84ac28 Improve markdown serving logic (#4224)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:28:45 +02:00
Greg Bergé 5e5075a137 Make Mermaid controls shown only on hover (#4221) 2026-04-29 17:13:31 +02:00
Greg Bergé 90c1f68e19 Speed up Mermaid rendering (#4219) 2026-04-29 15:40:24 +02:00
Zeno Kapitein 0e10124626 Adapt max logo height (#4218) 2026-04-28 14:59:45 +02:00
Zeno Kapitein 01e1c2ae6a Add space between ask button and content (#4217) 2026-04-28 14:59:14 +02:00
Zeno Kapitein 65558cfbb1 Redesign search results (#4198)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2026-04-28 14:49:46 +02:00
conico974 10d894ce19 Integrate local search functionality (#4078)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:14:47 +02:00
Samy Pessé d1a2958212 Pass visitor session to streamAskQuestion (#4211) 2026-04-24 21:07:46 +02:00
Viktor Renkema 894b6adc4c Support marks colors for text and background (#4212) 2026-04-24 17:23:10 +02:00
Nolann B. b3b819f8e5 Disable MCP server when MCP page action is off (#4210) 2026-04-23 18:01:43 +02:00
spastorelli 2b98af0cc0 Fix not found pages showing duplicate trademark badges (#4209) 2026-04-23 16:31:40 +01:00
Samy Pessé 54a56ed772 Remove rollout for agent ask (#4208) 2026-04-23 14:48:06 +02:00
Samy Pessé 071627d4f0 Improve the footer for agent to query the docs (#4206) 2026-04-22 22:56:28 +02:00
Samy Pessé 7595706b92 Allow AI agents to ask questions using ?ask= (#4205) 2026-04-22 08:43:57 +02:00
104 changed files with 4307 additions and 1437 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Update icon usage to render svg symbols rather than file.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces/sections in the llms.txt.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
+15 -8
View File
@@ -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
View File
@@ -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": {
+1 -1
View File
@@ -36,4 +36,4 @@ screenshots/
# cloudflare
.open-next
.wrangler
worker-configuration.d.ts
worker-configuration.d.ts
+12 -10
View File
@@ -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',
+1 -2
View File
@@ -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',
+28 -7
View File
@@ -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();
+3 -2
View File
@@ -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",
@@ -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;
@@ -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);
}
@@ -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);
}
@@ -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: '来源',
+26 -18
View File
@@ -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>;
+54 -1
View File
@@ -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.
*/
+16 -6
View File
@@ -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 () => {
+201
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
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),
};
}
+85 -1
View File
@@ -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', () => {
+57 -42
View File
@@ -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;
}
+120 -45
View File
@@ -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;
}
+138
View File
@@ -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();
});
});
+215
View File
@@ -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}`,
},
};
+1 -1
View File
@@ -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;
}
+90
View File
@@ -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']);
});
});
+19 -11
View File
@@ -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));
}
+37 -15
View File
@@ -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);
});
});
+15 -28
View File
@@ -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,
});
+66 -67
View File
@@ -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.
`;
}
+138
View File
@@ -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');
}
+118 -43
View File
@@ -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',
},
});
}
+60 -29
View File
@@ -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: {
+64 -21
View File
@@ -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 }
);
});
+93 -30
View File
@@ -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');
});
+74 -35
View File
@@ -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 }
);
+63
View File
@@ -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"'
);
});
});
+44 -3
View File
@@ -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"
+155
View File
@@ -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