Compare commits

...

17 Commits

Author SHA1 Message Date
Brett Jephson 19179f537d Merge branch 'main' into brett/font-clipping 2026-04-29 17:42:05 +01: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
Brett Jephson e56bb116b1 fix icon viewboxes 2026-04-29 12:14:35 +01:00
Brett Jephson 0933175a89 use sprite-full for icons 2026-04-29 09:40:59 +01: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
90 changed files with 4528 additions and 919 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>`.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": patch
---
Icon clipping fix
+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.179.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.179.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DrD/Pdfkdv6WWQb1nE0o91O4+gzIhRh+v8l9fhL5Wq57yt0Tk0kTCUycVpI1+H9qUt39bopN4O+KTns4mR2eLQ=="],
"@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.179.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": {
+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',
+6 -2
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[] = [
@@ -240,9 +244,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) {
+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',
},
}
);
}
@@ -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',
@@ -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,253 @@
'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.add({
id: page.id,
title: page.title,
description: page.description ?? null,
siteSpaceId: page.siteSpaceId,
});
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 {
@@ -39,6 +39,12 @@ export async function SiteLayout(props: {
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>;
+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', () => {
+54 -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,40 @@ 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,
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 +281,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;
}
+73 -36
View File
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import path from 'node:path';
import type { GitBookAnyContext, GitBookSiteContext } from '@/lib/context';
import { DataFetcherError, throwIfDataError } from '@/lib/data';
import type { ResolvedPagePath } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
@@ -10,7 +11,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 +19,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 +57,7 @@ export async function getMarkdownForPage(
throw error;
}
const tree = fromPageMarkdown({
linker: context.linker,
const tree = await fromPageMarkdown(context, {
markdown: rawMarkdown,
pagePath: page.path,
});
@@ -83,11 +86,7 @@ export async function getMarkdownForPageInSpace(
// 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, page });
}
const rawMarkdown = await throwIfDataError(
@@ -98,19 +97,14 @@ export async function getMarkdownForPageInSpace(
})
);
const tree = fromPageMarkdown({
linker,
const tree = await fromPageMarkdown(context, {
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, page });
}
return toPageMarkdown(tree);
@@ -120,11 +114,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 +129,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 +182,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 +193,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 +207,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 +215,55 @@ 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 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 {
// 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;
}
+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, it can sometimes format the content-ref into a strings that can be parsed back.
*/
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}`,
},
};
+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) };
+12 -7
View File
@@ -194,13 +194,18 @@ async function getMarkdownForPage(
})
);
const tree = fromPageMarkdown({
linker: context.linker.fork({
spaceBasePath: joinPath(context.linker.siteBasePath, basePath),
}),
markdown: pageMarkdown,
pagePath: page.path,
});
const tree = await fromPageMarkdown(
{
...context,
linker: context.linker.fork({
spaceBasePath: joinPath(context.linker.siteBasePath, basePath),
}),
},
{
markdown: pageMarkdown,
pagePath: page.path,
}
);
if (page.description) {
// The first node is the page title as a H1, we insert the description as a paragraph
+54 -66
View File
@@ -1,7 +1,7 @@
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 type { SiteSection, SiteSpace } from '@gitbook/api';
@@ -12,23 +12,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 +32,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 +73,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 +107,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;
@@ -164,13 +144,7 @@ async function getNodesFromSiteSpaces(
}
// Add the pages as a list
nodes.push(
...(await getMarkdownForPagesTree(pages, {
siteSpaceUrl,
linker,
withMarkdownPages: options.withMarkdownPages,
}))
);
nodes.push(...(await getMarkdownForPagesTree(pages, linker)));
return nodes;
})
@@ -183,23 +157,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 +190,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: {
+81 -30
View File
@@ -1,40 +1,91 @@
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 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');
});
+43 -35
View File
@@ -3,45 +3,53 @@ 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 }
);
+2
View File
@@ -1,2 +1,4 @@
dist/
src/data/*.json
!src/data/metrics.json
public/
+3
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import url from 'node:url';
import { allStyles, collectNormalizedIconAssets, createMetricsManifest } from './icon-assets.js';
import { getKitPath } from './kit.js';
/**
@@ -12,6 +13,7 @@ async function main() {
const icons = JSON.parse(
await fs.readFile(path.join(source, 'metadata/icon-families.json'), 'utf8')
);
const normalizedIconAssets = await collectNormalizedIconAssets(source, allStyles);
// Only these families have exceptions
const potentialOnly = ['brands', 'custom-icons'];
@@ -53,6 +55,7 @@ async function main() {
await Promise.all([
writeDataFile('styles-map', JSON.stringify(onlyStyles, null, 2)),
writeDataFile('icons', JSON.stringify(result, null, 2)),
writeDataFile('metrics', JSON.stringify(createMetricsManifest(normalizedIconAssets))),
]);
// biome-ignore lint/suspicious/noConsole: We want the CLI to log
+28 -25
View File
@@ -1,12 +1,10 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { allStyles, collectNormalizedIconAssets, createMetricsManifest } from './icon-assets.js';
import { getKitPath } from './kit.js';
const allStyles = ['brands', 'duotone', 'solid', 'regular', 'light', 'thin', 'custom-icons'];
/**
* Scripts to copy the assets to a public folder.
*/
@@ -16,36 +14,41 @@ async function main() {
(style) => allStyles.includes(style)
);
const source = getKitPath();
const iconAssets = await collectNormalizedIconAssets(source, stylesToCopy);
// Create the output folder if it doesn't exist
await fs.mkdir(outputFolder, { recursive: true });
// Copy the assets from
// source/sprites to outputFolder/sprites
// source/svgs to outputFolder/svgs
await Promise.all([
fs.mkdir(outputFolder, { recursive: true }),
...stylesToCopy.map((style) =>
fs.mkdir(path.join(outputFolder, 'svgs', style), { recursive: true })
),
fs.mkdir(path.join(outputFolder, 'sprites'), { recursive: true }),
]);
// Write normalized SVG assets and copy style sprites.
await Promise.all([
...iconAssets.map((asset) =>
fs.writeFile(
path.join(outputFolder, 'svgs', asset.style, `${asset.icon}.svg`),
asset.svg
)
),
...stylesToCopy.map((style) => {
const stylePath = path.join(source, 'svgs', style);
if (!existsSync(stylePath)) {
} else {
return fs.cp(stylePath, path.join(outputFolder, 'svgs', style), {
recursive: true,
});
}
}),
...stylesToCopy.map((style) => {
const spritePath = path.join(source, `sprites/${style}.svg`);
if (existsSync(spritePath)) {
return fs.cp(
path.join(source, `sprites/${style}.svg`),
path.join(outputFolder, 'sprites', `${style}.svg`)
);
}
const spritePath = path.join(source, 'sprites', `${style}.svg`);
return fs
.access(spritePath)
.then(() => fs.cp(spritePath, path.join(outputFolder, 'sprites', `${style}.svg`)));
}),
fs.writeFile(
path.join(outputFolder, 'metrics.json'),
JSON.stringify(createMetricsManifest(iconAssets))
),
]);
// biome-ignore lint/suspicious/noConsole: We want the CLI to log
console.log(`Copied ${stylesToCopy.length} styles to ${outputFolder}`);
console.log(
`Copied ${iconAssets.length} icons across ${stylesToCopy.length} styles to ${outputFolder}`
);
}
main().catch((error) => {
+653
View File
@@ -0,0 +1,653 @@
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { absolutize, parsePath } from './path-data.js';
export const allStyles = ['brands', 'duotone', 'solid', 'regular', 'light', 'thin', 'custom-icons'];
const metadataStyleByOutputStyle = {
'custom-icons': 'custom',
};
const VIEWBOX_PRECISION = 10_000;
const FLOAT_EPSILON = 1e-9;
/**
* Normalize the icon assets from the Font Awesome kit to safe SVGs with metrics.
*/
export async function collectNormalizedIconAssets(source, styles = allStyles) {
const iconsMetadata = await loadIconsMetadata(source);
const records = [];
for (const style of styles) {
const stylePath = path.join(source, 'svgs', style);
if (!existsSync(stylePath)) {
continue;
}
const files = (await fs.readdir(stylePath))
.filter((file) => file.endsWith('.svg'))
.sort((left, right) => left.localeCompare(right));
for (const file of files) {
const icon = file.slice(0, -4);
const svgPath = path.join(stylePath, file);
const svg = await fs.readFile(svgPath, 'utf8');
const originalViewBox = parseSvgViewBox(svg);
const pathData = getIconPaths(iconsMetadata, icon, style);
const safeViewBox = pathData
? getSafeViewBox(pathData, originalViewBox)
: originalViewBox;
records.push({
style,
icon,
originalViewBox,
safeViewBox,
svg: replaceSvgViewBox(svg, safeViewBox),
});
}
}
return records;
}
/**
* Create a manifest keyed by "style/icon" to keep runtime lookups simple.
*/
export function createMetricsManifest(records) {
return Object.fromEntries(
records
.filter((record) => !viewBoxesEqual(record.originalViewBox, record.safeViewBox))
.map((record) => [
`${record.style}/${record.icon}`,
{
originalViewBox: record.originalViewBox,
safeViewBox: record.safeViewBox,
},
])
);
}
/**
* Calculate a safe viewBox that contains both the declared Font Awesome box and the actual paint.
*/
export function getSafeViewBox(paths, originalViewBox) {
const paintBounds = getPaintBounds(paths);
if (!paintBounds) {
return originalViewBox;
}
const [originalX, originalY, originalWidth, originalHeight] = originalViewBox;
const originalMaxX = originalX + originalWidth;
const originalMaxY = originalY + originalHeight;
const minX = preserveMinEdge(originalX, paintBounds.minX);
const minY = preserveMinEdge(originalY, paintBounds.minY);
const maxX = preserveMaxEdge(originalMaxX, paintBounds.maxX);
const maxY = preserveMaxEdge(originalMaxY, paintBounds.maxY);
return [
normalizeNumber(minX),
normalizeNumber(minY),
normalizeNumber(maxX - minX),
normalizeNumber(maxY - minY),
];
}
/**
* Compute the exact painted bounds for filled SVG paths.
*/
export function getPaintBounds(paths) {
const allPaths = Array.isArray(paths) ? paths : [paths];
let bounds = null;
for (const pathData of allPaths) {
const segments = absolutize(parsePath(pathData));
let currentPoint = null;
let subpathStart = null;
let lastType = '';
let lastCubicControl = null;
let lastQuadraticControl = null;
for (const segment of segments) {
switch (segment.key) {
case 'M':
currentPoint = [segment.data[0], segment.data[1]];
subpathStart = currentPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
case 'L': {
if (!currentPoint) {
currentPoint = [segment.data[0], segment.data[1]];
break;
}
const nextPoint = [segment.data[0], segment.data[1]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'H': {
if (!currentPoint) {
currentPoint = [segment.data[0], 0];
break;
}
const nextPoint = [segment.data[0], currentPoint[1]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'V': {
if (!currentPoint) {
currentPoint = [0, segment.data[0]];
break;
}
const nextPoint = [currentPoint[0], segment.data[0]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'C': {
if (!currentPoint) {
currentPoint = [segment.data[4], segment.data[5]];
break;
}
const curveBounds = getCubicBounds(
currentPoint,
[segment.data[0], segment.data[1]],
[segment.data[2], segment.data[3]],
[segment.data[4], segment.data[5]]
);
bounds = mergeBounds(bounds, curveBounds);
currentPoint = [segment.data[4], segment.data[5]];
lastCubicControl = [segment.data[2], segment.data[3]];
lastQuadraticControl = null;
break;
}
case 'S': {
if (!currentPoint) {
currentPoint = [segment.data[2], segment.data[3]];
break;
}
const control1 =
lastType === 'C' || lastType === 'S'
? reflectPoint(currentPoint, lastCubicControl)
: currentPoint;
const control2 = [segment.data[0], segment.data[1]];
const nextPoint = [segment.data[2], segment.data[3]];
bounds = mergeBounds(
bounds,
getCubicBounds(currentPoint, control1, control2, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = control2;
lastQuadraticControl = null;
break;
}
case 'Q': {
if (!currentPoint) {
currentPoint = [segment.data[2], segment.data[3]];
break;
}
const control = [segment.data[0], segment.data[1]];
const nextPoint = [segment.data[2], segment.data[3]];
bounds = mergeBounds(
bounds,
getQuadraticBounds(currentPoint, control, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = control;
break;
}
case 'T': {
if (!currentPoint) {
currentPoint = [segment.data[0], segment.data[1]];
break;
}
const control =
lastType === 'Q' || lastType === 'T'
? reflectPoint(currentPoint, lastQuadraticControl)
: currentPoint;
const nextPoint = [segment.data[0], segment.data[1]];
bounds = mergeBounds(
bounds,
getQuadraticBounds(currentPoint, control, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = control;
break;
}
case 'A': {
if (!currentPoint) {
currentPoint = [segment.data[5], segment.data[6]];
break;
}
const nextPoint = [segment.data[5], segment.data[6]];
bounds = mergeBounds(
bounds,
getArcBounds(
currentPoint,
nextPoint,
segment.data[0],
segment.data[1],
segment.data[2],
segment.data[3],
segment.data[4]
)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'Z':
if (currentPoint && subpathStart) {
bounds = includeLineBounds(bounds, currentPoint, subpathStart);
currentPoint = subpathStart;
}
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
lastType = segment.key;
}
}
return bounds;
}
function getIconPaths(iconsMetadata, icon, style) {
const metadataStyle = metadataStyleByOutputStyle[style] ?? style;
const styleMetadata = iconsMetadata[icon]?.svg?.[metadataStyle];
if (!styleMetadata) {
return null;
}
return Array.isArray(styleMetadata.path) ? styleMetadata.path : [styleMetadata.path];
}
async function loadIconsMetadata(source) {
const metadataFile = path.join(source, 'metadata/icons.json');
return JSON.parse(await fs.readFile(metadataFile, 'utf8'));
}
function parseSvgViewBox(svg) {
const match = svg.match(/\bviewBox="([^"]+)"/);
if (!match) {
throw new Error('SVG is missing a viewBox');
}
const numbers = match[1]
.trim()
.split(/\s+/)
.map((value) => Number.parseFloat(value));
if (numbers.length !== 4 || numbers.some((value) => Number.isNaN(value))) {
throw new Error(`Invalid SVG viewBox: ${match[1]}`);
}
return numbers;
}
function replaceSvgViewBox(svg, viewBox) {
return svg.replace(/\bviewBox="[^"]+"/, `viewBox="${formatViewBox(viewBox)}"`);
}
function formatViewBox(viewBox) {
return viewBox.map((value) => formatNumber(value)).join(' ');
}
function formatNumber(value) {
const normalized = normalizeNumber(value);
return Number.isInteger(normalized) ? `${normalized}` : `${normalized}`;
}
function preserveMinEdge(originalMin, paintMin) {
if (paintMin >= originalMin - FLOAT_EPSILON) {
return originalMin;
}
return roundDown(paintMin);
}
function preserveMaxEdge(originalMax, paintMax) {
if (paintMax <= originalMax + FLOAT_EPSILON) {
return originalMax;
}
return roundUp(paintMax);
}
function roundDown(value) {
return Math.floor(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function roundUp(value) {
return Math.ceil(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function normalizeNumber(value) {
return Math.round(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function viewBoxesEqual(left, right) {
return left.every((value, index) => Math.abs(value - right[index]) < FLOAT_EPSILON);
}
function includeLineBounds(bounds, start, end) {
return mergeBounds(bounds, {
minX: Math.min(start[0], end[0]),
minY: Math.min(start[1], end[1]),
maxX: Math.max(start[0], end[0]),
maxY: Math.max(start[1], end[1]),
});
}
function mergeBounds(bounds, nextBounds) {
if (!bounds) {
return nextBounds;
}
return {
minX: Math.min(bounds.minX, nextBounds.minX),
minY: Math.min(bounds.minY, nextBounds.minY),
maxX: Math.max(bounds.maxX, nextBounds.maxX),
maxY: Math.max(bounds.maxY, nextBounds.maxY),
};
}
function reflectPoint(origin, point) {
if (!point) {
return origin;
}
return [2 * origin[0] - point[0], 2 * origin[1] - point[1]];
}
function getQuadraticBounds(start, control, end) {
const cubicControl1 = [
start[0] + (2 * (control[0] - start[0])) / 3,
start[1] + (2 * (control[1] - start[1])) / 3,
];
const cubicControl2 = [
end[0] + (2 * (control[0] - end[0])) / 3,
end[1] + (2 * (control[1] - end[1])) / 3,
];
return getCubicBounds(start, cubicControl1, cubicControl2, end);
}
function getCubicBounds(start, control1, control2, end) {
const candidates = [
0,
1,
...getCubicExtrema(start[0], control1[0], control2[0], end[0]),
...getCubicExtrema(start[1], control1[1], control2[1], end[1]),
];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const t of candidates) {
if (t < 0 || t > 1) {
continue;
}
const point = evaluateCubic(start, control1, control2, end, t);
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return { minX, minY, maxX, maxY };
}
function getArcBounds(start, end, rawRadiusX, rawRadiusY, angle, largeArcFlag, sweepFlag) {
const arc = endpointToCenterArc(
start[0],
start[1],
end[0],
end[1],
rawRadiusX,
rawRadiusY,
angle,
largeArcFlag,
sweepFlag
);
if (!arc) {
return {
minX: Math.min(start[0], end[0]),
minY: Math.min(start[1], end[1]),
maxX: Math.max(start[0], end[0]),
maxY: Math.max(start[1], end[1]),
};
}
const extrema = getArcExtremaAngles(arc.radiusX, arc.radiusY, arc.rotation);
const candidates = [arc.startAngle, arc.startAngle + arc.deltaAngle, ...extrema];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const candidate of candidates) {
if (!isAngleOnArc(candidate, arc.startAngle, arc.deltaAngle)) {
continue;
}
const point = pointOnArc(arc, candidate);
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return { minX, minY, maxX, maxY };
}
function endpointToCenterArc(
startX,
startY,
endX,
endY,
rawRadiusX,
rawRadiusY,
angle,
largeArcFlag,
sweepFlag
) {
let radiusX = Math.abs(rawRadiusX);
let radiusY = Math.abs(rawRadiusY);
if (
radiusX < FLOAT_EPSILON ||
radiusY < FLOAT_EPSILON ||
(Math.abs(startX - endX) < FLOAT_EPSILON && Math.abs(startY - endY) < FLOAT_EPSILON)
) {
return null;
}
const rotation = degToRad(angle % 360);
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
const translatedX = (startX - endX) / 2;
const translatedY = (startY - endY) / 2;
const primeX = cosine * translatedX + sine * translatedY;
const primeY = -sine * translatedX + cosine * translatedY;
const lambda =
(primeX * primeX) / (radiusX * radiusX) + (primeY * primeY) / (radiusY * radiusY);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
radiusX *= scale;
radiusY *= scale;
}
const radiusXSquared = radiusX * radiusX;
const radiusYSquared = radiusY * radiusY;
const primeXSquared = primeX * primeX;
const primeYSquared = primeY * primeY;
const numerator =
radiusXSquared * radiusYSquared -
radiusXSquared * primeYSquared -
radiusYSquared * primeXSquared;
const denominator = radiusXSquared * primeYSquared + radiusYSquared * primeXSquared;
const factor =
(largeArcFlag === sweepFlag ? -1 : 1) * Math.sqrt(Math.max(0, numerator / denominator));
const centerPrimeX = (factor * radiusX * primeY) / radiusY;
const centerPrimeY = (-factor * radiusY * primeX) / radiusX;
const centerX = cosine * centerPrimeX - sine * centerPrimeY + (startX + endX) / 2;
const centerY = sine * centerPrimeX + cosine * centerPrimeY + (startY + endY) / 2;
const startVector = [(primeX - centerPrimeX) / radiusX, (primeY - centerPrimeY) / radiusY];
const endVector = [(-primeX - centerPrimeX) / radiusX, (-primeY - centerPrimeY) / radiusY];
const startAngle = vectorAngle([1, 0], startVector);
let deltaAngle = vectorAngle(startVector, endVector);
if (!sweepFlag && deltaAngle > 0) {
deltaAngle -= 2 * Math.PI;
}
if (sweepFlag && deltaAngle < 0) {
deltaAngle += 2 * Math.PI;
}
return {
centerX,
centerY,
radiusX,
radiusY,
rotation,
startAngle,
deltaAngle,
};
}
function getArcExtremaAngles(radiusX, radiusY, rotation) {
const xAngle = Math.atan2(-radiusY * Math.sin(rotation), radiusX * Math.cos(rotation));
const yAngle = Math.atan2(radiusY * Math.cos(rotation), radiusX * Math.sin(rotation));
return [xAngle, xAngle + Math.PI, yAngle, yAngle + Math.PI];
}
function isAngleOnArc(angle, startAngle, deltaAngle) {
const fullTurn = 2 * Math.PI;
const endAngle = startAngle + deltaAngle;
if (deltaAngle >= 0) {
let normalized = angle;
while (normalized < startAngle - FLOAT_EPSILON) {
normalized += fullTurn;
}
while (normalized > startAngle + fullTurn + FLOAT_EPSILON) {
normalized -= fullTurn;
}
return normalized <= endAngle + FLOAT_EPSILON;
}
let normalized = angle;
while (normalized > startAngle + FLOAT_EPSILON) {
normalized -= fullTurn;
}
while (normalized < startAngle - fullTurn - FLOAT_EPSILON) {
normalized += fullTurn;
}
return normalized >= endAngle - FLOAT_EPSILON;
}
function pointOnArc(arc, angle) {
const cosine = Math.cos(arc.rotation);
const sine = Math.sin(arc.rotation);
const localX = arc.radiusX * Math.cos(angle);
const localY = arc.radiusY * Math.sin(angle);
return [
arc.centerX + localX * cosine - localY * sine,
arc.centerY + localX * sine + localY * cosine,
];
}
function vectorAngle(left, right) {
const dot = left[0] * right[0] + left[1] * right[1];
const magnitude = Math.hypot(left[0], left[1]) * Math.hypot(right[0], right[1]);
const sign = left[0] * right[1] - left[1] * right[0] < 0 ? -1 : 1;
return sign * Math.acos(clamp(dot / magnitude, -1, 1));
}
function degToRad(degrees) {
return (Math.PI * degrees) / 180;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getCubicExtrema(start, control1, control2, end) {
const a = -start + 3 * control1 - 3 * control2 + end;
const b = 2 * (start - 2 * control1 + control2);
const c = -start + control1;
if (Math.abs(a) < FLOAT_EPSILON) {
if (Math.abs(b) < FLOAT_EPSILON) {
return [];
}
return [-c / b].filter((value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON);
}
const discriminant = b * b - 4 * a * c;
if (discriminant < -FLOAT_EPSILON) {
return [];
}
if (Math.abs(discriminant) < FLOAT_EPSILON) {
return [-b / (2 * a)].filter((value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON);
}
const root = Math.sqrt(discriminant);
return [(-b + root) / (2 * a), (-b - root) / (2 * a)].filter(
(value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON
);
}
function evaluateCubic(start, control1, control2, end, t) {
const oneMinusT = 1 - t;
return [
oneMinusT ** 3 * start[0] +
3 * oneMinusT ** 2 * t * control1[0] +
3 * oneMinusT * t ** 2 * control2[0] +
t ** 3 * end[0],
oneMinusT ** 3 * start[1] +
3 * oneMinusT ** 2 * t * control1[1] +
3 * oneMinusT * t ** 2 * control2[1] +
t ** 3 * end[1],
];
}
+531
View File
@@ -0,0 +1,531 @@
// Adapted from path-data-parser (MIT) to keep the icon CLI self-contained.
const COMMAND = 0;
const NUMBER = 1;
const EOD = 2;
const PARAMS = {
A: 7,
a: 7,
C: 6,
c: 6,
H: 1,
h: 1,
L: 2,
l: 2,
M: 2,
m: 2,
Q: 4,
q: 4,
S: 4,
s: 4,
T: 2,
t: 2,
V: 1,
v: 1,
Z: 0,
z: 0,
};
/**
* Parse an SVG path string into segment commands.
*/
export function parsePath(d) {
const segments = [];
const tokens = tokenize(d);
let mode = 'BOD';
let index = 0;
let token = tokens[index];
while (!isType(token, EOD)) {
let paramsCount = 0;
const params = [];
if (mode === 'BOD') {
if (token.text === 'M' || token.text === 'm') {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
} else {
return parsePath(`M0,0${d}`);
}
} else if (isType(token, NUMBER)) {
paramsCount = PARAMS[mode];
} else {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
}
if (index + paramsCount >= tokens.length) {
throw new Error('Path data ended short');
}
for (let i = index; i < index + paramsCount; i++) {
const numberToken = tokens[i];
if (!isType(numberToken, NUMBER)) {
throw new Error(`Param not a number: ${mode},${numberToken.text}`);
}
params.push(Number(numberToken.text));
}
if (typeof PARAMS[mode] !== 'number') {
throw new Error(`Bad segment: ${mode}`);
}
segments.push({ key: mode, data: params });
index += paramsCount;
token = tokens[index];
if (mode === 'M') {
mode = 'L';
}
if (mode === 'm') {
mode = 'l';
}
}
return segments;
}
/**
* Translate relative SVG commands to absolute commands.
*/
export function absolutize(segments) {
let currentX = 0;
let currentY = 0;
let subpathX = 0;
let subpathY = 0;
const output = [];
for (const { key, data } of segments) {
switch (key) {
case 'M':
output.push({ key: 'M', data: [...data] });
[currentX, currentY] = data;
[subpathX, subpathY] = data;
break;
case 'm':
currentX += data[0];
currentY += data[1];
output.push({ key: 'M', data: [currentX, currentY] });
subpathX = currentX;
subpathY = currentY;
break;
case 'L':
output.push({ key: 'L', data: [...data] });
[currentX, currentY] = data;
break;
case 'l':
currentX += data[0];
currentY += data[1];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'C':
output.push({ key: 'C', data: [...data] });
currentX = data[4];
currentY = data[5];
break;
case 'c': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'C', data: nextData });
currentX = nextData[4];
currentY = nextData[5];
break;
}
case 'Q':
output.push({ key: 'Q', data: [...data] });
currentX = data[2];
currentY = data[3];
break;
case 'q': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'Q', data: nextData });
currentX = nextData[2];
currentY = nextData[3];
break;
}
case 'A':
output.push({ key: 'A', data: [...data] });
currentX = data[5];
currentY = data[6];
break;
case 'a':
currentX += data[5];
currentY += data[6];
output.push({
key: 'A',
data: [data[0], data[1], data[2], data[3], data[4], currentX, currentY],
});
break;
case 'H':
output.push({ key: 'H', data: [...data] });
currentX = data[0];
break;
case 'h':
currentX += data[0];
output.push({ key: 'H', data: [currentX] });
break;
case 'V':
output.push({ key: 'V', data: [...data] });
currentY = data[0];
break;
case 'v':
currentY += data[0];
output.push({ key: 'V', data: [currentY] });
break;
case 'S':
output.push({ key: 'S', data: [...data] });
currentX = data[2];
currentY = data[3];
break;
case 's': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'S', data: nextData });
currentX = nextData[2];
currentY = nextData[3];
break;
}
case 'T':
output.push({ key: 'T', data: [...data] });
currentX = data[0];
currentY = data[1];
break;
case 't':
currentX += data[0];
currentY += data[1];
output.push({ key: 'T', data: [currentX, currentY] });
break;
case 'Z':
case 'z':
output.push({ key: 'Z', data: [] });
currentX = subpathX;
currentY = subpathY;
break;
}
}
return output;
}
/**
* Normalize an absolute path to M/L/C/Z commands only.
*/
export function normalize(segments) {
const output = [];
let lastType = '';
let currentX = 0;
let currentY = 0;
let subpathX = 0;
let subpathY = 0;
let lastControlX = 0;
let lastControlY = 0;
for (const { key, data } of segments) {
switch (key) {
case 'M':
output.push({ key: 'M', data: [...data] });
[currentX, currentY] = data;
[subpathX, subpathY] = data;
break;
case 'C':
output.push({ key: 'C', data: [...data] });
currentX = data[4];
currentY = data[5];
lastControlX = data[2];
lastControlY = data[3];
break;
case 'L':
output.push({ key: 'L', data: [...data] });
[currentX, currentY] = data;
break;
case 'H':
currentX = data[0];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'V':
currentY = data[0];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'S': {
let controlX = currentX;
let controlY = currentY;
if (lastType === 'C' || lastType === 'S') {
controlX = currentX + (currentX - lastControlX);
controlY = currentY + (currentY - lastControlY);
}
output.push({ key: 'C', data: [controlX, controlY, ...data] });
lastControlX = data[0];
lastControlY = data[1];
currentX = data[2];
currentY = data[3];
break;
}
case 'T': {
const [x, y] = data;
let reflectedX = currentX;
let reflectedY = currentY;
if (lastType === 'Q' || lastType === 'T') {
reflectedX = currentX + (currentX - lastControlX);
reflectedY = currentY + (currentY - lastControlY);
}
const control1X = currentX + (2 * (reflectedX - currentX)) / 3;
const control1Y = currentY + (2 * (reflectedY - currentY)) / 3;
const control2X = x + (2 * (reflectedX - x)) / 3;
const control2Y = y + (2 * (reflectedY - y)) / 3;
output.push({
key: 'C',
data: [control1X, control1Y, control2X, control2Y, x, y],
});
lastControlX = reflectedX;
lastControlY = reflectedY;
currentX = x;
currentY = y;
break;
}
case 'Q': {
const [controlX, controlY, x, y] = data;
const control1X = currentX + (2 * (controlX - currentX)) / 3;
const control1Y = currentY + (2 * (controlY - currentY)) / 3;
const control2X = x + (2 * (controlX - x)) / 3;
const control2Y = y + (2 * (controlY - y)) / 3;
output.push({
key: 'C',
data: [control1X, control1Y, control2X, control2Y, x, y],
});
lastControlX = controlX;
lastControlY = controlY;
currentX = x;
currentY = y;
break;
}
case 'A': {
const radiusX = Math.abs(data[0]);
const radiusY = Math.abs(data[1]);
const angle = data[2];
const largeArcFlag = data[3];
const sweepFlag = data[4];
const x = data[5];
const y = data[6];
if (radiusX === 0 || radiusY === 0) {
output.push({ key: 'C', data: [currentX, currentY, x, y, x, y] });
currentX = x;
currentY = y;
break;
}
if (currentX !== x || currentY !== y) {
const curves = arcToCubicCurves(
currentX,
currentY,
x,
y,
radiusX,
radiusY,
angle,
largeArcFlag,
sweepFlag
);
for (const curve of curves) {
output.push({ key: 'C', data: curve });
}
currentX = x;
currentY = y;
}
break;
}
case 'Z':
output.push({ key: 'Z', data: [] });
currentX = subpathX;
currentY = subpathY;
break;
}
lastType = key;
}
return output;
}
function tokenize(d) {
const tokens = [];
while (d !== '') {
if (d.match(/^([ \t\r\n,]+)/)) {
d = d.slice(RegExp.$1.length);
} else if (d.match(/^([aAcChHlLmMqQsStTvVzZ])/)) {
tokens.push({ type: COMMAND, text: RegExp.$1 });
d = d.slice(RegExp.$1.length);
} else if (d.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/)) {
tokens.push({ type: NUMBER, text: `${Number.parseFloat(RegExp.$1)}` });
d = d.slice(RegExp.$1.length);
} else {
return [];
}
}
tokens.push({ type: EOD, text: '' });
return tokens;
}
function isType(token, type) {
return token.type === type;
}
function degToRad(degrees) {
return (Math.PI * degrees) / 180;
}
function rotate(x, y, angleRad) {
return [
x * Math.cos(angleRad) - y * Math.sin(angleRad),
x * Math.sin(angleRad) + y * Math.cos(angleRad),
];
}
function arcToCubicCurves(
startX,
startY,
endX,
endY,
radiusX,
radiusY,
angle,
largeArcFlag,
sweepFlag,
recursive
) {
const angleRad = degToRad(angle);
let params = [];
let startAngle = 0;
let endAngle = 0;
let centerX = 0;
let centerY = 0;
if (recursive) {
[startAngle, endAngle, centerX, centerY] = recursive;
} else {
[startX, startY] = rotate(startX, startY, -angleRad);
[endX, endY] = rotate(endX, endY, -angleRad);
const deltaX = (startX - endX) / 2;
const deltaY = (startY - endY) / 2;
let distance =
(deltaX * deltaX) / (radiusX * radiusX) + (deltaY * deltaY) / (radiusY * radiusY);
if (distance > 1) {
distance = Math.sqrt(distance);
radiusX *= distance;
radiusY *= distance;
}
const sign = largeArcFlag === sweepFlag ? -1 : 1;
const radiusXPow = radiusX * radiusX;
const radiusYPow = radiusY * radiusY;
const left =
radiusXPow * radiusYPow - radiusXPow * deltaY * deltaY - radiusYPow * deltaX * deltaX;
const right = radiusXPow * deltaY * deltaY + radiusYPow * deltaX * deltaX;
const factor = sign * Math.sqrt(Math.abs(left / right));
centerX = (factor * radiusX * deltaY) / radiusY + (startX + endX) / 2;
centerY = (-factor * radiusY * deltaX) / radiusX + (startY + endY) / 2;
startAngle = Math.asin(Number.parseFloat(((startY - centerY) / radiusY).toFixed(9)));
endAngle = Math.asin(Number.parseFloat(((endY - centerY) / radiusY).toFixed(9)));
if (startX < centerX) {
startAngle = Math.PI - startAngle;
}
if (endX < centerX) {
endAngle = Math.PI - endAngle;
}
if (startAngle < 0) {
startAngle = Math.PI * 2 + startAngle;
}
if (endAngle < 0) {
endAngle = Math.PI * 2 + endAngle;
}
if (sweepFlag && startAngle > endAngle) {
startAngle -= Math.PI * 2;
}
if (!sweepFlag && endAngle > startAngle) {
endAngle -= Math.PI * 2;
}
}
let angleDelta = endAngle - startAngle;
if (Math.abs(angleDelta) > (Math.PI * 120) / 180) {
const previousEndAngle = endAngle;
const previousEndX = endX;
const previousEndY = endY;
if (sweepFlag && endAngle > startAngle) {
endAngle = startAngle + ((Math.PI * 120) / 180) * 1;
} else {
endAngle = startAngle + ((Math.PI * 120) / 180) * -1;
}
endX = centerX + radiusX * Math.cos(endAngle);
endY = centerY + radiusY * Math.sin(endAngle);
params = arcToCubicCurves(
endX,
endY,
previousEndX,
previousEndY,
radiusX,
radiusY,
angle,
0,
sweepFlag,
[endAngle, previousEndAngle, centerX, centerY]
);
}
angleDelta = endAngle - startAngle;
const cosineStart = Math.cos(startAngle);
const sineStart = Math.sin(startAngle);
const cosineEnd = Math.cos(endAngle);
const sineEnd = Math.sin(endAngle);
const tangent = Math.tan(angleDelta / 4);
const controlX = (4 / 3) * radiusX * tangent;
const controlY = (4 / 3) * radiusY * tangent;
const point1 = [startX, startY];
const point2 = [startX + controlX * sineStart, startY - controlY * cosineStart];
const point3 = [endX + controlX * sineEnd, endY - controlY * cosineEnd];
const point4 = [endX, endY];
point2[0] = 2 * point1[0] - point2[0];
point2[1] = 2 * point1[1] - point2[1];
if (recursive) {
return [point2, point3, point4].concat(params);
}
params = [point2, point3, point4].concat(params);
const curves = [];
for (let index = 0; index < params.length; index += 3) {
const rotated1 = rotate(params[index][0], params[index][1], angleRad);
const rotated2 = rotate(params[index + 1][0], params[index + 1][1], angleRad);
const rotated3 = rotate(params[index + 2][0], params[index + 2][1], angleRad);
curves.push([rotated1[0], rotated1[1], rotated2[0], rotated2[1], rotated3[0], rotated3[1]]);
}
return curves;
}
+26 -4
View File
@@ -4,6 +4,7 @@ import * as React from 'react';
import { getIconAssetURL, useIcons } from './IconsProvider';
import { getIconStyle } from './getIconStyle';
import { getIconMetrics } from './iconMetrics';
import type { IconName, IconStyle } from './types';
/**
@@ -46,19 +47,25 @@ export const Icon = React.forwardRef(function Icon(
iconStyle: propIconStyle = context.iconStyle,
className = '',
size,
viewBox: propViewBox,
...rest
} = props;
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
const url = getIconAssetURL(context, iconStyle, icon);
const metrics = getIconMetrics(iconStyle, icon);
const maskId = React.useId();
const originalViewBox = metrics?.originalViewBox;
const safeViewBox = metrics?.safeViewBox;
return (
<svg
ref={ref}
{...rest}
viewBox={propViewBox ?? (originalViewBox ? originalViewBox.join(' ') : undefined)}
style={{
...(size ? { width: size, height: size } : {}),
...(metrics ? { overflow: 'visible' } : {}),
...rest.style,
}}
className={`gb-icon ${className}`}
@@ -67,6 +74,12 @@ export const Icon = React.forwardRef(function Icon(
<defs>
<mask
id={maskId}
maskUnits={metrics ? 'userSpaceOnUse' : undefined}
maskContentUnits={metrics ? 'userSpaceOnUse' : undefined}
x={safeViewBox?.[0]}
y={safeViewBox?.[1]}
width={safeViewBox?.[2]}
height={safeViewBox?.[3]}
style={{
maskType: 'alpha',
}}
@@ -74,13 +87,22 @@ export const Icon = React.forwardRef(function Icon(
<image
data-testid="mask-image"
href={url}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
x={safeViewBox?.[0] ?? 0}
y={safeViewBox?.[1] ?? 0}
width={safeViewBox?.[2] ?? '100%'}
height={safeViewBox?.[3] ?? '100%'}
preserveAspectRatio={metrics ? 'none' : 'xMidYMid meet'}
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="currentColor" mask={`url(#${maskId})`} />
<rect
x={safeViewBox?.[0] ?? 0}
y={safeViewBox?.[1] ?? 0}
width={safeViewBox?.[2] ?? '100%'}
height={safeViewBox?.[3] ?? '100%'}
fill="currentColor"
mask={`url(#${maskId})`}
/>
</svg>
);
});
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import { collectNormalizedIconAssets, createMetricsManifest } from '../bin/icon-assets.js';
import { getKitPath } from '../bin/kit.js';
const regularAssetsPromise = collectNormalizedIconAssets(getKitPath(), ['regular']);
async function getRegularAsset(icon: string) {
const assets = await regularAssetsPromise;
const asset = assets.find((candidate) => candidate.icon === icon);
if (!asset) {
throw new Error(`Missing regular asset for "${icon}"`);
}
return asset;
}
describe('icon asset normalization', () => {
it('keeps the original viewBox while expanding jar to include the overshoot', async () => {
const jar = await getRegularAsset('jar');
expect(jar.originalViewBox).toEqual([0, 0, 320, 512]);
expect(jar.safeViewBox).toEqual([0, -32, 320, 544]);
expect(jar.svg).toContain('viewBox="0 -32 320 544"');
});
it('captures diagonal overflow for arrow-archery', async () => {
const arrowArchery = await getRegularAsset('arrow-archery');
expect(arrowArchery.originalViewBox).toEqual([0, 0, 576, 512]);
expect(arrowArchery.safeViewBox).toEqual([0, -39.9928, 584.5055, 583.8523]);
});
it('only emits metrics for icons that need adjusted bounds', async () => {
const manifest = createMetricsManifest(await regularAssetsPromise);
expect(manifest['regular/jar']).toEqual({
originalViewBox: [0, 0, 320, 512],
safeViewBox: [0, -32, 320, 544],
});
expect(manifest['regular/circle-info']).toBeUndefined();
});
});
+17
View File
@@ -0,0 +1,17 @@
import rawMetrics from './data/metrics.json' with { type: 'json' };
type IconViewBox = [number, number, number, number];
type IconMetrics = {
originalViewBox: IconViewBox;
safeViewBox: IconViewBox;
};
const iconMetrics = rawMetrics as unknown as Record<string, IconMetrics>;
/**
* Lookup the safe rendering metrics for a given icon asset.
*/
export function getIconMetrics(style: string, icon: string): IconMetrics | null {
return iconMetrics[`${style}/${icon}`] ?? null;
}