Merge branch 'main' into viktor/admin-toolbar-magnification-polish

This commit is contained in:
Viktor Renkema
2026-10-20 13:11:05 +02:00
committed by GitHub
293 changed files with 8577 additions and 1695 deletions
+30
View File
@@ -54,6 +54,7 @@ GitBook('configure', {
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -107,6 +108,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
defaultPage: '/getting-started',
actions: [
{
icon: 'circle-question',
@@ -142,6 +144,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'search', 'docs']}
defaultPage="/getting-started"
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -249,6 +252,33 @@ Override which tabs are displayed. Defaults to your site's configuration.
tabs: ['assistant', 'search', 'docs']
```
### `defaultTab`
Available in: Standalone script, NPM package, React components
The tab the embed opens on. Without it, the embed opens on `defaultPage` if set, otherwise on the assistant (or the docs when the assistant isn't available). The tab must be one of the enabled `tabs`.
- **Type**: `'assistant' | 'search' | 'docs'`
```javascript
tabs: ['assistant', 'search', 'docs'],
defaultTab: 'search'
```
`defaultTab` and `defaultPage` apply when the embed first loads: the standalone widget keeps its place when it is closed and reopened. To move it later, use `navigateToPage` or `navigateToAssistant`.
### `defaultPage`
Available in: Standalone script, NPM package, React components
The page the docs tab opens on, in place of your site's home page. Accepts the same references as `navigateToPage`: the page's path within the site, an absolute path, or its full published URL. Unless `defaultTab` says otherwise, the embed opens on this page.
- **Type**: `string`
```javascript
defaultPage: '/getting-started/quickstart'
```
### `closeButton`
Available in: Standalone script, NPM package, React components
+6
View File
@@ -62,6 +62,12 @@ export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs' | 'search')[];
/** Tab to open the embed on. */
defaultTab?: 'assistant' | 'docs' | 'search';
/** Page to open the docs tab on, instead of the site's home page. */
defaultPage?: string;
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
@@ -27,6 +27,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
defaultTab,
defaultPage,
trademark = true,
closeButton = false,
assistantName,
@@ -50,6 +52,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
defaultTab,
defaultPage,
actions,
greeting,
suggestions,
@@ -65,6 +69,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions,
tools,
tabs,
defaultTab,
defaultPage,
closeButton,
trademark,
assistantName,
+158 -10
View File
@@ -10,6 +10,7 @@ import {
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationPageActionType,
CustomizationSidebarListStyle,
SiteSocialAccountPlatform,
} from '@gitbook/api';
@@ -158,7 +159,7 @@ const searchTestCases: Test[] = [
},
},
{
name: 'Search - Keyboard focus stays within search',
name: 'Search - Keyboard focus exits to the next page control',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
@@ -179,13 +180,16 @@ const searchTestCases: Test[] = [
)
.filter({ visible: true })
.last();
const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first();
await expect(finalPopupControl).toBeVisible();
await expect(nextPageControl).toBeVisible();
await finalPopupControl.focus();
await page.keyboard.press('Tab');
await expect(searchInput).toBeFocused();
await expect(searchPopup).toBeHidden();
await expect(nextPageControl).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(finalPopupControl).toBeFocused();
await expect(searchInput).toBeFocused();
},
},
{
@@ -245,6 +249,43 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
// RND-12844: the popover's focus manager re-focused the closing popup and
// scrolled the page back to the top right after landing on the section.
name: 'Search - Section result scrolls to the section',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
// Type like a visitor: `fill()` doesn't trigger the remote search.
await searchInput.pressSequentially('tasks');
const sectionResult = page.locator(
'[data-testid="search-page-result"][href$="/blocks/lists#tasks"]'
);
// Section results come from the remote index, which can be slow to answer.
await expect(sectionResult).toBeVisible({ timeout: 30_000 });
await sectionResult.click();
await page.waitForURL(/\/blocks\/lists#tasks$/);
// The regression scrolled back to the top shortly after landing, so let
// that happen before asserting.
await page.waitForTimeout(1000);
// The heading is parked under the header, within its scroll margin.
const top = await page
.locator('#tasks')
.evaluate((heading) => heading.getBoundingClientRect().top);
expect(top).toBeGreaterThanOrEqual(0);
expect(top).toBeLessThanOrEqual(150);
},
},
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
@@ -513,6 +554,24 @@ const testCases: TestsCase[] = [
name: 'GitBook Site (Navigation when switching variant)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
tests: [
{
name: 'Strip fallback after loading a page without adding history',
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const previousURL = page.url();
const targetURL = new URL(previousURL);
targetURL.searchParams.set('fallback', 'true');
targetURL.searchParams.set('ref', 'variant');
targetURL.hash = 'pets';
await page.goto(targetURL.toString());
targetURL.searchParams.delete('fallback');
await expect(page).toHaveURL(targetURL.toString());
await page.goBack();
await expect(page).toHaveURL(previousURL);
},
},
{
name: 'Keep navigation path/route when switching variant (Public)',
url: 'api-multi-versions/reference/api-reference/pets',
@@ -535,8 +594,11 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL((url) =>
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
await page.waitForURL(
(url) =>
url.pathname.includes(
'api-multi-versions/2.0/reference/api-reference/pets'
) && !url.searchParams.has('fallback')
);
},
},
@@ -1063,6 +1125,30 @@ const testCases: TestsCase[] = [
).toBeVisible();
},
},
{
name: 'Markdown page with theme cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
searchParams.set('theme', 'dark');
return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
// The app opens previews with `?theme=`, which the middleware persists in a cookie.
const markdownURL = new URL(page.url());
markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`;
markdownURL.search = '';
const response = await page.request.get(markdownURL.toString());
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/markdown');
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
@@ -1572,6 +1658,70 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Edit on Git page actions',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
tests: [
{
name: 'With Edit on Git as the default action',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Git],
},
}),
run: async (page) => {
await waitForHydration(page);
await expect(
page.getByRole('link', { name: 'Edit', exact: true })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'With Edit on Git in the dropdown',
url: getCustomizationURL({
pageActions: {
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.Git,
],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveAttribute(
'href',
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
);
},
screenshot: false,
},
{
name: 'Without Edit on Git',
url: getCustomizationURL({
pageActions: {
items: [CustomizationPageActionType.Markdown],
},
}),
run: async (page) => {
await waitForHydration(page);
await page.getByRole('button', { name: 'More' }).click();
await expect(page.getByRole('menu')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Edit on GitHub' })
).toHaveCount(0);
},
screenshot: false,
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1624,7 +1774,6 @@ const testCases: TestsCase[] = [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
],
},
@@ -1833,9 +1982,6 @@ const testCases: TestsCase[] = [
{
name: 'Visitor Auth - Space (oversized token)',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
// Our Cloudflare stack still folds multiple Set-Cookie headers into one,
// breaking chunked cookies (variant of opennextjs-cloudflare#501).
skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare',
tests: [
{
name: 'Oversized token is chunked into cookies and survives navigation',
@@ -1886,7 +2032,9 @@ const testCases: TestsCase[] = [
.replace(/\/$/, '');
secondURL.pathname = `${basePathname}/second`;
secondURL.search = '';
await page.goto(secondURL.toString());
// Same reason as the harness navigation: third-party subresources on
// this site can hang and never fire `load`.
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('heading', { level: 1, name: 'second' })
).toBeVisible();
+5 -1
View File
@@ -465,7 +465,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
socialAccounts: [],
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
const encoded = rison.encode_object(
deepMerge(DEFAULT_CUSTOMIZATION, partial, {
arrayMerge: (_target, source) => source,
})
);
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
+3
View File
@@ -94,6 +94,9 @@ const nextConfig = {
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE,
GITBOOK_DISABLE_INSIGHTS: process.env.GITBOOK_DISABLE_INSIGHTS,
GITBOOK_VISITOR_COUNTRY_HOSTNAMES: process.env.GITBOOK_VISITOR_COUNTRY_HOSTNAMES,
VERCEL_TARGET_ENV: process.env.VERCEL_TARGET_ENV,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
+5 -5
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -46,16 +46,16 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.14.0",
"mermaid": "11.16.1",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.3",
"next": "^16.3.8",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"object-identity": "^0.2.1",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
@@ -1,5 +1,6 @@
import { build } from 'bun';
import { mkdir, rename, rm } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -12,14 +13,20 @@ const temporaryDir = join(outputDir, '.build');
const require = createRequire(import.meta.url);
const mermaidPackage = require('mermaid/package.json') as { version: string };
const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string };
const mermaidPatch = await readFile(
join(scriptDir, `../../../patches/mermaid@${mermaidPackage.version}.patch`)
).catch(() => null);
const mermaidPatchSuffix = mermaidPatch
? `-patch.${createHash('sha256').update(mermaidPatch).digest('hex').slice(0, 8)}`
: '';
// The runtime URL is served as immutable, it has to change whenever the bundled versions change.
// The runtime URL is served as immutable, it has to change whenever the bundled versions or patch change.
if (
MERMAID_RUNTIME_PATH !==
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
`mermaid/mermaid@${mermaidPackage.version}${mermaidPatchSuffix}-zenuml@${zenumlPackage.version}.mjs`
) {
throw new Error(
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions and the mermaid patch'
);
}
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteAiCatalog } from '@/lib/aiCatalog';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteAiCatalog(context, request);
}
export { handler as GET, handler as OPTIONS };
@@ -1,30 +1,12 @@
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
/**
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
*/
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
waitUntil(
trackServerInsightsEvents(args).catch((error) => {
console.error('Failed to track MCP insights event:', error);
})
);
}
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
import { buildMcpServerInfo } from '@/lib/mcp/serverCard';
import { createSiteMcpTools, registerSiteMcpTools, trackMcpEvent } from '@/lib/mcp/tools';
export async function handleMcpRequest(
rawRequest: NextRequest,
@@ -32,11 +14,8 @@ export async function handleMcpRequest(
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
if (!isMcpEnabled) {
if (!isSiteMcpEnabled(context)) {
return new Response('Not Found', { status: 404 });
}
@@ -61,418 +40,12 @@ export async function handleMcpRequest(
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
{
title: 'Search documentation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
// The search API returns sections ordered highest-score-first, so
// the first section with a body is the best-scoring preview.
const body = (pageResult.sections ?? []).find(
(section) => section.body
)?.body;
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
const siteUrl = context.siteSpace.urls.published;
server.tool(
'getPage',
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
{
url: z
.string()
.describe('The URL of the page to fetch')
.transform((value, ctx) => {
if (URL.canParse(value)) {
return value;
}
if (URL.canParse(`https://${value}`)) {
return `https://${value}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}),
},
{
title: 'Get page content',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const markdown = await getMarkdownForPageInSpace(
context,
match.siteSpace,
resolved.page
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
},
],
request,
});
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
// Only expose the answer tool when the site has AI enabled, since it relies on
// the same AI search backend that powers the site's "ask a question" experience.
if (isAIEnabled(context.customization.ai.mode)) {
server.tool(
'askQuestion',
`Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`,
{
question: z
.string()
.describe(
`The natural-language question to answer about ${site.title}.`
),
goal: z
.string()
.optional()
.describe(
'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.'
),
},
{
title: 'Ask a question',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async ({ question, goal }) => {
try {
const trimmedQuestion = question.trim();
if (!trimmedQuestion) {
return {
content: [
{
type: 'text',
text: 'Please provide a question to answer.',
},
],
isError: true,
};
}
const trimmedGoal = goal?.trim() || undefined;
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
goal: trimmedGoal,
});
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'ask_question',
query: trimmedQuestion,
...(trimmedGoal ? { goal: trimmedGoal } : {}),
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
if (!answer || !answer.answer || !('markdown' in answer.answer)) {
return {
content: [
{
type: 'text',
text: "We couldn't answer this question.",
},
],
};
}
const answerMarkdown = toPageMarkdown(
await fromPageMarkdown(context, {
markdown: answer.answer.markdown,
pagePath: '',
})
);
const sourcesMarkdown = await renderAskSourcesMarkdown(
context,
answer.sources ?? []
);
let text = answerMarkdown.trim();
if (sourcesMarkdown) {
text += `\n\n# Sources\n\n${sourcesMarkdown}`;
}
return { content: [{ type: 'text', text }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
}
server.tool(
'sendFeedback',
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
{
content: z
.string()
.min(1)
.max(2048)
.describe(
'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.'
),
pageUrl: z
.string()
.describe(
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started), so the finding is linked to the exact page.`
)
.transform((value, ctx) => {
const candidate = URL.canParse(value)
? new URL(value)
: URL.canParse(value, siteUrl)
? new URL(value, siteUrl)
: null;
if (
!candidate ||
(candidate.protocol !== 'https:' && candidate.protocol !== 'http:')
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}
return candidate.toString();
}),
goal: z
.string()
.optional()
.describe(
'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.'
),
},
{
title: 'Send feedback',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async ({ content, pageUrl, goal }) => {
try {
const match = findSiteSpaceByUrl(context.structure, pageUrl);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolveSiteSpacePagePath(
match.siteSpace,
revision.pages,
match.pagePath
);
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
const trimmedGoal = goal?.trim() || undefined;
//!! DISABLED FOR NOW: We'll add this back in when we have a way to track agent feedback.
// trackMcpEvent({
// organizationId: context.organizationId,
// siteId: site.id,
// events: [
// {
// type: 'agent_feedback',
// feedback: { content, category },
// location: {
// displayContext: SiteInsightsDisplayContext.Mcp,
// page: resolved.page.id,
// space: match.siteSpace.space.id,
// revision: match.siteSpace.space.revision,
// },
// },
// ],
// request,
// });
const apiClient = await dataFetcher.api();
await apiClient.orgs.submitSiteAgentFeedback(
context.organizationId,
site.id,
{
feedback: content,
url: pageUrl,
spaceId: match.siteSpace.space.id,
pageId: resolved.page.id,
...(trimmedGoal ? { goal: trimmedGoal } : {}),
}
);
return {
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
};
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
registerSiteMcpTools(server, createSiteMcpTools(context, { request }));
},
{
// The same identity the server card republishes, so the card and the handshake agree.
serverInfo: buildMcpServerInfo(context),
},
{},
{
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteMcpServerCard } from '@/lib/mcp/serverCard';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteMcpServerCard(context, request);
}
export { handler as GET, handler as OPTIONS };
+4
View File
@@ -6,6 +6,8 @@ import {
GITBOOK_API_URL,
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
GITBOOK_DISABLE_TRACKING,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -35,6 +37,8 @@ export async function GET(_req: NextRequest) {
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
// Secret envs
GITBOOK_SECRET: !!GITBOOK_SECRET,
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { setIntercomLauncherHidden } from './intercom';
const originalWindow = globalThis.window;
afterEach(() => {
if (originalWindow === undefined) {
delete (globalThis as { window?: Window }).window;
} else {
globalThis.window = originalWindow;
}
});
function installIntercom(intercom: Window['Intercom']): void {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { Intercom: intercom },
writable: true,
});
}
describe('setIntercomLauncherHidden', () => {
it('hides the Intercom launcher', () => {
const calls: unknown[][] = [];
installIntercom((...args) => calls.push(args));
setIntercomLauncherHidden(true);
expect(calls).toEqual([['update', { hide_default_launcher: true }]]);
});
it('shows the Intercom launcher', () => {
const calls: unknown[][] = [];
installIntercom((...args) => calls.push(args));
setIntercomLauncherHidden(false);
expect(calls).toEqual([['update', { hide_default_launcher: false }]]);
});
it('does nothing when Intercom is unavailable', () => {
expect(() => setIntercomLauncherHidden(true)).not.toThrow();
});
});
@@ -0,0 +1,16 @@
declare global {
interface Window {
Intercom?: (command: string, options?: Record<string, unknown>) => void;
}
}
/**
* Hide or show Intercom's default launcher when it is available on the host page.
*/
export function setIntercomLauncherHidden(hidden: boolean): void {
if (typeof window === 'undefined' || typeof window.Intercom !== 'function') {
return;
}
window.Intercom('update', { hide_default_launcher: hidden });
}
@@ -0,0 +1,33 @@
'use server';
import { AgentFeedbackSource } from '@gitbook/api';
import type { AgentFeedbackResult } from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
/**
* Record a finding about the site's content, submitted by the assistant while helping a reader.
*/
export async function submitAssistantFeedbackToTeam(input: {
feedback: string;
goal?: string;
/** Page the finding is about, as a URL on the site or a `/spaces/…/pages/…` ref. */
page: string;
asEmbeddable?: boolean;
}): Promise<AgentFeedbackResult> {
return traceErrorOnly('AI.submitAssistantFeedbackToTeam', async () => {
const baseContext = await getServerActionBaseContext({
isEmbeddable: input.asEmbeddable,
});
const context = await fetchServerActionSiteContext(baseContext);
return submitAgentFeedback(context, {
feedback: input.feedback,
goal: input.goal,
page: input.page,
source: AgentFeedbackSource.Assistant,
});
});
}
@@ -1,3 +1,4 @@
export * from './types';
export * from './chat';
export * from './feedback';
export * from './navigate';
@@ -1,7 +1,7 @@
'use server';
import { toInSiteHref } from '../navigation';
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
import { resolveContentRef, resolveStringContentRef, toContentRefPath } from '@/lib/references';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -21,15 +21,7 @@ export async function resolveAINavigationLink(
const baseContext = await getServerActionBaseContext();
const context = await fetchServerActionSiteContext(baseContext);
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
let path = url;
if (URL.canParse(url)) {
const parsed = new URL(url);
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
const contentRef = resolveStringContentRef(path);
const contentRef = resolveStringContentRef(toContentRefPath(url));
if (contentRef) {
const resolved = await resolveContentRef(contentRef, context);
if (!resolved) {
+12 -7
View File
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '.';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,7 +26,8 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -102,8 +103,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string) => {
chatController.open();
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
if (query) {
chatController.postMessage({ message: query });
}
@@ -119,9 +120,13 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
}
},
pageAction: false,
@@ -11,6 +11,7 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
SiteInsightsDisplayContext,
type SiteInsightsEventAskView,
} from '@gitbook/api';
import { getInsightsSession, useTrackEvent } from '../Insights';
@@ -18,10 +19,12 @@ import { type UpdateSearchState, useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { setIntercomLauncherHidden } from './intercom';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import {
type ResponseToRate,
@@ -58,6 +61,8 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = NonNullable<SiteInsightsEventAskView['trigger']>;
export type AIChatState = {
/**
* If true, the chat is open.
@@ -158,7 +163,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
open: (trigger?: AskAITrigger) => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -240,7 +245,6 @@ export function AIChatProvider(props: {
const setSearchStateInURL = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
const displayContext = renderMessageOptions?.asEmbeddable
? SiteInsightsDisplayContext.Embed
: SiteInsightsDisplayContext.Site;
@@ -256,6 +260,10 @@ export function AIChatProvider(props: {
const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null });
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
// Findings reported to the site's team are capped at one per conversation, so the assistant
// cannot flood the inbox over a long chat.
const reportedAgentFeedbackRef = React.useRef(false);
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or
// assistant feedback). Each tool has a stable identity, so it can be referenced directly from
// the streaming callback.
@@ -265,38 +273,65 @@ export function AIChatProvider(props: {
displayContext,
getResponseToRate,
});
const leaveAgentFeedbackTool = useLeaveAgentFeedbackTool({
asEmbeddable: renderMessageOptions?.asEmbeddable,
displayContext,
reportedRef: reportedAgentFeedbackRef,
});
const leaveUserFeedbackTool = useLeaveUserFeedbackTool({
asEmbeddable: renderMessageOptions?.asEmbeddable,
displayContext,
});
// The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down
// rating), while the page-feedback tool is gated on the site's "Was this helpful?" setting.
// rating), while the tools carrying the reader's own feedback are gated on the site's
// "Was this helpful?" setting.
const builtInTools = React.useMemo(() => {
const tools = [navigateToPageTool, submitAssistantFeedbackTool];
const tools = [navigateToPageTool, submitAssistantFeedbackTool, leaveAgentFeedbackTool];
if (withPageFeedback) {
tools.push(submitPageFeedbackTool);
tools.push(submitPageFeedbackTool, leaveUserFeedbackTool);
}
return tools;
}, [navigateToPageTool, submitAssistantFeedbackTool, submitPageFeedbackTool, withPageFeedback]);
}, [
navigateToPageTool,
submitAssistantFeedbackTool,
leaveAgentFeedbackTool,
leaveUserFeedbackTool,
submitPageFeedbackTool,
withPageFeedback,
]);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackEvent({ type: 'ask_view', trigger });
}
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
setIntercomLauncherHidden(false);
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
@@ -711,6 +746,7 @@ export function AIChatProvider(props: {
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
responseToRateRef.current = { responseId: null, query: null };
reportedAgentFeedbackRef.current = false;
globalState.setState((state) => ({
opened: state.opened,
responding: false,
@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { AIToolDefinition, SiteInsightsDisplayContext } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { type PagePointer, useCurrentPage } from '../hooks';
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
import { submitAssistantFeedbackToTeam } from './server-actions';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
AGENT_FEEDBACK_MAX_LENGTH,
agentFeedbackDescriptions,
formatAgentFeedbackPageRef,
} from '@/lib/agentFeedback';
const feedbackInput = z.string().min(1).max(AGENT_FEEDBACK_MAX_LENGTH);
const pageInput = z.string().optional().describe(agentFeedbackDescriptions.pageReference);
const LeaveAgentFeedbackInputSchema = z.object({
feedback: feedbackInput.describe(agentFeedbackDescriptions.finding),
goal: z
.string()
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
.optional()
.describe(agentFeedbackDescriptions.goal),
page: pageInput,
});
const LeaveUserFeedbackInputSchema = z.object({
feedback: feedbackInput.describe(
"The user's feedback about the content, copied verbatim. Never rephrase, summarise, correct, translate or add to their words. Quote only what they said about the documentation, and leave out anything personal or confidential."
),
page: pageInput,
});
type LeaveFeedbackToolOptions = {
/** Whether the assistant runs in the embeddable, which resolves its own site context. */
asEmbeddable?: boolean;
/** Display context recorded with the feedback event (e.g. `site` vs. `embed`). */
displayContext: SiteInsightsDisplayContext;
};
/** The assistant's own finding, so it submits without confirmation, capped at one per chat. */
export function useLeaveAgentFeedbackTool(
options: LeaveFeedbackToolOptions & {
/** Whether a finding was already reported in this conversation, reset when it is cleared. */
reportedRef: React.RefObject<boolean>;
}
): GitBookIntegrationTool {
const { asEmbeddable, displayContext, reportedRef } = options;
const language = useLanguage();
const currentPage = useCurrentPage();
const trackEvent = useTrackEvent();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({
language,
currentPage,
asEmbeddable,
displayContext,
trackEvent,
reportedRef,
});
React.useEffect(() => {
ref.current = {
language,
currentPage,
asEmbeddable,
displayContext,
trackEvent,
reportedRef,
};
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'leaveAgentFeedback',
description:
'Leave feedback as you, the GitBook Assistant, on ways this question could have been answered more easily through content changes or reorganization — a missing explanation, an outdated or contradictory passage, or an answer you had to piece together from several pages. Use it when answering was hard, or when you could not answer at all, so the team can close the gap. Write it for a documentation maintainer who never saw this conversation, and send at most one per conversation. This carries your own observation: use `leaveUserFeedback` to relay what the user said, and `submitPageFeedback` to record their rating of a page. Do not use it to confirm that a page is accurate.',
inputSchema: zodToJsonSchema(
LeaveAgentFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { reportedRef, ...tracking } = ref.current;
const { feedback, goal, page } = LeaveAgentFeedbackInputSchema.parse(input);
if (reportedRef.current) {
throw new Error(
'A finding was already reported in this conversation. Do not report another one.'
);
}
await submitFeedback({ feedback, goal, page, ...tracking });
reportedRef.current = true;
return {
output: { submitted: true },
summary: {
icon: 'comment-check',
text: tString(tracking.language, 'ai_chat_tools_shared_feedback_with_team'),
},
};
},
}),
// Rebuild when the locale changes so the summary (read at memo time, not from the ref)
// stays translated.
[language]
);
}
/** Speaks for the reader, so it confirms first, showing the exact text about to be sent. */
export function useLeaveUserFeedbackTool(
options: LeaveFeedbackToolOptions
): GitBookIntegrationTool {
const { asEmbeddable, displayContext } = options;
const language = useLanguage();
const currentPage = useCurrentPage();
const trackEvent = useTrackEvent();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({ language, currentPage, asEmbeddable, displayContext, trackEvent });
React.useEffect(() => {
ref.current = { language, currentPage, asEmbeddable, displayContext, trackEvent };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'leaveUserFeedback',
description:
"Leave feedback on behalf of the user about the documentation, so the team can act on it. Use it when the user reports that something in the content is wrong, missing, outdated or confusing, or asks you to pass a message to the documentation team. Do not modify their feedback in any way: copy their own words. The user will be asked to confirm before it is sent. This carries the user's words: use `leaveAgentFeedback` for your own findings about the content, and `submitPageFeedback` to record their rating of a page.",
confirmation: (input) => {
const parsed = LeaveUserFeedbackInputSchema.safeParse(input);
const feedback = parsed.success ? parsed.data.feedback.trim() : undefined;
return {
icon: 'paper-plane',
label: tString(language, 'ai_chat_tools_submit_feedback'),
context: feedback ? `"${feedback}"` : undefined,
};
},
inputSchema: zodToJsonSchema(
LeaveUserFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const tracking = ref.current;
const { feedback, page } = LeaveUserFeedbackInputSchema.parse(input);
await submitFeedback({ feedback, page, ...tracking });
return {
output: { submitted: true },
summary: {
icon: 'comment-check',
text: tString(tracking.language, 'ai_chat_tools_submitted_feedback'),
},
};
},
}),
// Rebuild when the locale changes so the confirmation label (read at memo time, not from
// the ref) stays translated.
[language]
);
}
async function submitFeedback(input: {
feedback: string;
goal?: string;
page?: string;
currentPage: PagePointer | null;
asEmbeddable?: boolean;
displayContext: SiteInsightsDisplayContext;
trackEvent: ReturnType<typeof useTrackEvent>;
}): Promise<void> {
const page =
input.page?.trim() ||
(input.currentPage ? formatAgentFeedbackPageRef(input.currentPage) : null);
if (!page) {
throw new Error('No documentation page is currently open to leave feedback about.');
}
const result = await submitAssistantFeedbackToTeam({
feedback: input.feedback,
goal: input.goal,
page,
asEmbeddable: input.asEmbeddable,
});
if (!result.submitted) {
throw new Error(result.error);
}
// Attribute to the page the reader is on, since the insights location only lets us override
// the page — its space and revision come from the ambient context.
const pageContext: InsightsEventPageContext = {
pageId: input.currentPage?.pageId ?? null,
displayContext: input.displayContext,
};
input.trackEvent({ type: 'agent_feedback' }, pageContext, { immediate: true });
}
@@ -80,7 +80,7 @@ export function useSubmitPageFeedbackTool(options: {
() => ({
name: 'submitPageFeedback',
description:
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words.",
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words. This records their rating of the page — use `leaveUserFeedback` to pass a report of their own about the content on to the documentation team.",
confirmation: (input) => {
const parsed = SubmitPageFeedbackInputSchema.safeParse(input);
const comment = parsed.success ? parsed.data.comment?.trim() : undefined;
@@ -24,7 +24,6 @@ import {
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useIsMounted, useNow } from '../hooks';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
@@ -68,16 +67,6 @@ export function AIChat() {
}
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
onClick={() => assistant.open(undefined, 'site')}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -1,29 +1,58 @@
'use client';
import fnv1a from '@sindresorhus/fnv1a';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { AIChatIcon } from '../AIChatIcon';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { t, tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { tcls } from '@/lib/tailwind';
/**
* A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on
* devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens
* the AI chat — a more discoverable variant of the text-selection "Ask" affordance.
* A small icon button revealed in the left margin of a paragraph when it is hovered (on devices
* with a fine pointer). Clicking it stages the paragraph's text as a reference and opens the AI
* chat — a more discoverable variant of the text-selection "Ask" affordance.
*
* The button is absolutely positioned so it never affects the document flow, and its visibility is
* driven purely by the `group/ask-ai` hover state of its paragraph wrapper.
* A single instance is portaled into the last hovered paragraph wrapper, rather than rendering one
* per paragraph, so pages with many paragraphs don't pay for markup that is hidden until hovered.
* Its visibility is still driven purely by the `group/ask-ai` hover state of that wrapper.
*/
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
const { content, className } = props;
export function AskAIParagraphButton() {
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const [target, setTarget] = React.useState<HTMLElement | null>(null);
const enabled = isAIChatEnabled(config.aiMode);
React.useEffect(() => {
if (!enabled) {
return;
}
const onPointerOver = (event: PointerEvent) => {
if (!(event.target instanceof Element)) {
return;
}
const wrapper = event.target.closest<HTMLElement>('[data-ask-ai-paragraph]');
if (wrapper) {
setTarget(wrapper);
}
};
document.addEventListener('pointerover', onPointerOver);
return () => document.removeEventListener('pointerover', onPointerOver);
}, [enabled]);
if (!enabled || !target) {
return null;
}
const onClick = () => {
const text = content.trim();
const text = getParagraphText(target).trim();
if (!text) {
return;
}
@@ -33,12 +62,12 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.open('hover');
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
return (
return createPortal(
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
@@ -52,13 +81,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'invisible opacity-0 transition-opacity duration-150',
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden',
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
// tables, record cards).
'in-[[role=table]]:hidden',
'in-[table]:hidden',
'in-[[data-card]]:hidden',
className
'not-pointer-fine:hidden'
)}
>
<Button
@@ -75,6 +98,37 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
)}
/>
</div>
</div>,
target
);
}
/**
* Read the paragraph's text, skipping rendered math like `getNodeText` does.
*/
function getParagraphText(wrapper: HTMLElement): string {
const paragraph = wrapper.querySelector(':scope > p');
if (!paragraph) {
return '';
}
const walker = document.createTreeWalker(
paragraph,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
node instanceof Element &&
node.matches('.katex, mjx-container, [aria-hidden="true"]')
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
}
);
let text = '';
while (walker.nextNode()) {
if (walker.currentNode.nodeType === Node.TEXT_NODE) {
text += walker.currentNode.textContent ?? '';
}
}
return text;
}
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.open('selection');
chatController.focus();
clear();
};
@@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map<
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
const settled = (async () => {
try {
result = await promise;
} catch {
result = null;
}
return result;
})();
return {
read() {
if (result === undefined) {
throw suspender;
throw settled;
}
return result;
},
load() {
return settled;
},
};
}
@@ -65,6 +69,12 @@ export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
export type AdaptiveVisitorAsyncContextValue = () => Promise<AdaptiveVisitorClaims | null>;
const AdaptiveVisitorAsyncContext = createContext<AdaptiveVisitorAsyncContextValue>(
async () => null
);
/**
* Provide context to adapt site based on visitor claims.
*/
@@ -83,11 +93,22 @@ export function AdaptiveVisitorContextProvider(
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
const loadAdaptiveVisitorClaims = React.useCallback(async () => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).load();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
<AdaptiveVisitorAsyncContext.Provider value={loadAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider
getPrefillInputContextData={getAdaptiveVisitorClaims}
>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorAsyncContext.Provider>
</AdaptiveVisitorContext.Provider>
);
}
@@ -98,3 +119,10 @@ export function AdaptiveVisitorContextProvider(
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
/**
* Hook that returns an async getter for adaptive visitor claims data, for use outside of render.
*/
export function useAdaptiveVisitorAsync(): AdaptiveVisitorAsyncContextValue {
return useContext(AdaptiveVisitorAsyncContext);
}
@@ -323,7 +323,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
href: `${site.urls.app}/analytics`,
siteId: site.id,
buttonId: 'insights',
})}
@@ -247,11 +247,15 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -1 +1 @@
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs';
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.16.1-patch.78ecb855-zenuml@0.2.2.mjs';
@@ -37,7 +37,10 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
}}
data-visual-test="blackout"
/>
@@ -73,6 +76,23 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { shouldShowHashLinks } from '../HashLinkButton';
import { Inlines } from '../Inlines';
import { Details } from './Details';
import { ToggleChevron } from '@/components/primitives';
@@ -31,6 +32,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<Details
id={id}
@@ -41,7 +44,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
className={tcls(
'cursor-pointer',
'px-4',
'pr-10',
// Reserve room for the anchor icon pinned to the right edge
showHashLink && 'pr-10',
'py-4',
'relative',
'list-none',
@@ -71,32 +75,34 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
context={context}
ancestorInlines={[]}
/>
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
>
<Icon
icon="hashtag"
{showHashLink ? (
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
/>
</a>
>
<Icon
icon="hashtag"
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
)}
/>
</a>
) : null}
</summary>
<Blocks
nodes={body.nodes}
@@ -2,9 +2,22 @@ import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import type { DocumentContext } from './DocumentView';
import { getBlockTextStyle } from './spacing';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
* Whether blocks should render a visible anchor link icon.
* Only the page body carries a page; search answers, AI chat and PDF export keep anchors.
*/
export function shouldShowHashLinks(context: DocumentContext): boolean {
const contentContext = context.contentContext;
if (!contentContext || !('page' in contentContext)) {
return true;
}
return contentContext.page.layout.anchors !== false;
}
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
@@ -1,7 +1,7 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { HashLinkButton, hashLinkButtonWrapperStyles, shouldShowHashLinks } from './HashLinkButton';
import { HeadingRevealWrapper } from './HeadingRevealWrapper';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -24,6 +24,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<HeadingRevealWrapper
as={Tag}
@@ -34,7 +36,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
'font-heading',
'pdf-heading',
'block',
'pr-6',
// Reserve room for the absolutely positioned anchor icon on coarse pointers
showHashLink && 'pr-6',
'pointer-fine:flex',
'pointer-fine:items-baseline',
'pointer-fine:pr-0',
@@ -63,20 +66,22 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
</span>
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
{showHashLink ? (
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
) : null}
</HeadingRevealWrapper>
);
}
@@ -135,10 +135,10 @@ const HINT_STYLES: {
header: 'bg-warning-active',
body: [
'text-neutral-strong',
'links-default:[&_a]:text-warning',
'links-default:[&_a:hover]:text-warning-strong',
'links-default:[&_a]:decoration-warning/6',
'links-accent:[&_a]:decoration-warning',
'links-default:[&_a:not(.button)]:text-warning',
'links-default:[&_a:not(.button):hover]:text-warning-strong',
'links-default:[&_a:not(.button)]:decoration-warning/6',
'links-accent:[&_a:not(.button)]:decoration-warning',
'decoration-warning/6',
'[&_.can-override-bg]:bg-warning-active',
'[&_.can-override-text]:text-warning-strong',
@@ -152,10 +152,10 @@ const HINT_STYLES: {
header: 'bg-danger-active',
body: [
'text-neutral-strong',
'links-default:[&_a]:text-danger',
'links-default:[&_a:hover]:text-danger-strong',
'links-default:[&_a]:decoration-danger/6',
'links-accent:[&_a]:decoration-danger',
'links-default:[&_a:not(.button)]:text-danger',
'links-default:[&_a:not(.button):hover]:text-danger-strong',
'links-default:[&_a:not(.button)]:decoration-danger/6',
'links-accent:[&_a:not(.button)]:decoration-danger',
'decoration-danger/6',
'[&_.can-override-bg]:bg-danger-active',
'[&_.can-override-text]:text-danger-strong',
@@ -169,10 +169,10 @@ const HINT_STYLES: {
header: 'bg-success-active',
body: [
'text-neutral-strong',
'links-default:[&_a]:text-success',
'links-default:[&_a:hover]:text-success-strong',
'links-default:[&_a]:decoration-success/6',
'links-accent:[&_a]:decoration-success',
'links-default:[&_a:not(.button)]:text-success',
'links-default:[&_a:not(.button):hover]:text-success-strong',
'links-default:[&_a:not(.button)]:decoration-success/6',
'links-accent:[&_a:not(.button)]:decoration-success',
'decoration-success/6',
'[&_.can-override-bg]:bg-success-active',
'[&_.can-override-text]:text-success-strong',
@@ -13,9 +13,14 @@ const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium
};
export function InlineActionButton(
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
props: {
action: 'ask' | 'search';
query?: string;
/** Where a search runs: the whole site, or only the space the visitor is reading. */
scope?: 'site' | 'space';
} & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
) {
const { action, query, buttonProps } = props;
const { action, query, scope, buttonProps } = props;
const { assistants } = useAI();
const chatState = useAIChatState();
@@ -29,7 +34,7 @@ export function InlineActionButton(
setSearchState((prev) => ({
...prev,
ask: null,
scope: 'default',
scope: scope === 'space' ? 'current' : 'default',
query: value ?? query,
open: true,
}));
@@ -47,6 +47,11 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
<InlineActionButton
action={inline.data.action.action}
query={inline.data.action.query ?? ''}
scope={
inline.data.action.action === 'search'
? inline.data.action.scope
: undefined
}
buttonProps={buttonProps}
/>
);
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = inline.data.ref.kind === 'url';
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -6,7 +6,7 @@ import React from 'react';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import type { WebframePageContext } from './adaptive';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useAdaptiveVisitorAsync } from '@/components/Adaptive';
import { NavigationStatusContext } from '@/components/hooks';
import { type GitBookLinker, createLinker } from '@/lib/links';
@@ -40,7 +40,12 @@ export function ContentKitWithClientContext<RenderContext>(
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const loadAdaptiveVisitorClaims = useAdaptiveVisitorAsync();
const getVisitorContext = React.useCallback(async () => {
const visitorClaims = await loadAdaptiveVisitorClaims();
return { visitor: visitorClaims?.visitor ?? null };
}, [loadAdaptiveVisitorClaims]);
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
// of the app, instead of duplicating the join logic here.
@@ -55,15 +60,9 @@ export function ContentKitWithClientContext<RenderContext>(
},
[onNavigationClick, router]
);
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
const clientContext = React.useMemo<ContentKitClientContextData>(
() => ({
getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
getVisitorContext: canAccessVisitorClaims ? getVisitorContext : undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor, query }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
@@ -78,7 +77,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + search + hash);
},
}),
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
[canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -1,8 +1,8 @@
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import type { BlockProps } from '../Block';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import { contentKitServerContext } from './contentkit';
import './contentkit.css';
import {
@@ -74,16 +74,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
const page = getWebframePageContext(context.contentContext);
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
// exposes the current page, plus visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
renderContext: {
integrationName: block.data.integration,
@@ -105,20 +100,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
) : (
<ContentKit {...contentKitProps}>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
)}
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
</div>
);
}
@@ -1,47 +1,9 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
const webframe: ContentKitWebFrame = {
type: 'webframe',
source: { url: 'https://integrations.gitbook.com/frame' },
};
function elementOutput(element: unknown): ContentKitRenderOutput {
return {
type: 'element',
element,
state: {},
props: {},
} as ContentKitRenderOutput;
}
describe('integrationBlockContainsWebframe', () => {
it('returns false for a completed output', () => {
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
});
it('returns false when there is no webframe in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'text', text: 'hello' }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(false);
});
it('returns true when a webframe is nested in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'vstack', children: [webframe] }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(true);
});
});
describe('getWebframePageContext', () => {
it('returns null when the context has no page', () => {
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
@@ -1,14 +1,5 @@
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
ContentKitRootElement,
ContentKitStepper,
} from '@gitbook/api';
import type { GitBookAnyContext } from '@/lib/context';
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Current page exposed to a webframe through the client-only webframe state.
*/
@@ -19,18 +10,6 @@ export type WebframePageContext = {
title: string;
};
/**
* Whether an integration block's output contains a webframe that can consume client-only context
* (navigation, visitor claims and/or the current page).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
return false;
}
return doesContentKitElementContainWebframe(output.element);
}
/**
* Extract the current page to expose to a webframe, or `null` when it is unknown
* (e.g. a non-page context, or reusable content resolved from another source).
@@ -55,46 +34,3 @@ export function getWebframePageContext(
title,
};
}
/**
* Check whether a ContentKit element tree contains a webframe element.
*/
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
switch (element.type) {
case 'webframe':
return true;
case 'block':
case 'box':
case 'hstack':
case 'vstack':
case 'step':
case 'modal':
case 'configuration':
case 'stepper':
case 'card':
return doesContentKitElementArrayContainWebframe(element.children);
case 'codeblock':
return (
doesContentKitElementArrayContainWebframe(element.header) ||
doesContentKitElementArrayContainWebframe(element.footer)
);
default:
return false;
}
}
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
if (!Array.isArray(elements)) {
return doesContentKitElementContainWebframeValue(elements);
}
return elements.some(doesContentKitElementContainWebframeValue);
}
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
if (typeof value !== 'object' || value === null || !('type' in value)) {
return false;
}
return doesContentKitElementContainWebframe(value as ContentKitElement);
}
@@ -52,6 +52,14 @@
@apply relative;
}
.contentkit-webframe-container {
@apply relative;
}
.contentkit-webframe-loading {
@apply absolute inset-0 m-auto size-5 animate-spin;
}
/** Card */
.contentkit-card {
@apply flex flex-col border rounded border-tint-subtle;
@@ -95,7 +103,7 @@
/** Buttons */
.contentkit-button {
@apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none place-self-start ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all grow-0 shrink-0 truncate w-full;
@apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all truncate;
}
.contentkit-button-style-primary {
@@ -130,15 +138,38 @@
.contentkit-modal-header {
@apply flex flex-col gap-2 px-4 py-2;
}
.contentkit-modal-header-with-close {
@apply flex-row items-start gap-4 px-4 pb-0 pt-4;
}
.contentkit-modal-header-content {
@apply min-w-0 flex-1;
}
.contentkit-modal-title {
@apply text-2xl font-medium text-tint-strong;
}
.contentkit-modal-header-with-close .contentkit-modal-subtitle {
@apply break-words text-sm text-tint;
}
.contentkit-modal-header-with-close .contentkit-modal-title {
@apply break-words;
}
.contentkit-modal-body {
@apply px-4 py-4;
}
.contentkit-modal-footer {
@apply flex justify-end gap-2 px-4 pb-4;
}
.contentkit-modal-close {
@apply flex size-8 shrink-0 items-center justify-center rounded-full text-tint transition-colors hover:bg-tint-hover hover:text-tint-strong focus-visible:outline-2 focus-visible:outline-primary;
}
.contentkit-modal-close-icon {
@apply size-4;
}
.contentkit-modal-header + .contentkit-modal-body {
@apply pt-0;
}
@@ -208,9 +239,6 @@
.contentkit-modal-confirm .contentkit-modal-footer {
@apply flex gap-2 justify-end;
}
.contentkit-button-confirm {
@apply w-auto flex-grow-0 flex-shrink-0;
}
.contentkit-button-style-danger {
@apply bg-danger text-danger hover:bg-danger-hover hover:text-danger-strong contrast-more:bg-tint-subtle;
}
@@ -4,12 +4,11 @@ import { CustomizationAIMode } from '@gitbook/api';
import type { BlockProps } from './Block';
import { Inlines } from './Inlines';
import { getTextAlignment } from './utils';
import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton';
import { getNodeText } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { block, style, ancestorBlocks, ...contextProps } = props;
const { context } = contextProps;
const paragraph = (
@@ -28,6 +27,8 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const contentContext = context.contentContext;
const aiAssistantEnabled =
context.mode !== 'print' &&
// Table cells would clip the margin button, and big tables hold thousands of paragraphs.
!ancestorBlocks.some((ancestor) => ancestor.type === 'table') &&
contentContext != null &&
'customization' in contentContext &&
contentContext.customization.ai.mode === CustomizationAIMode.Assistant;
@@ -38,6 +39,8 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
// `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages.
return (
<div
// The shared `AskAIParagraphButton` portals itself into the hovered wrapper.
data-ask-ai-paragraph=""
className={tcls(
'group/ask-ai relative',
style,
@@ -45,7 +48,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
)}
>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}
@@ -0,0 +1,156 @@
'use client';
import React from 'react';
import { Icon } from '@gitbook/icons';
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuItem,
ToggleChevron,
} from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { AI_AGENTS, getAIAgent } from '@/lib/ai-agents';
import { tcls } from '@/lib/tailwind';
/** How long the copy button shows its confirmation. */
const COPIED_MESSAGE_DURATION = 1000;
/**
* Actions of a prompt block: copying the prompt, or handing it to a coding agent. The visitor's
* last pick becomes the main button, here and in every other prompt block they come across.
*/
export function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
const { prompt, openInAIProviders } = props;
const language = useLanguage();
const selectedAction = usePromptAction();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const copyPrompt = () => {
navigator.clipboard.writeText(prompt);
setCopied(true);
};
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
// actions are turned off — and with nothing to hand over, an agent link would open an agent on
// an empty prompt.
const action: PromptActionId = openInAIProviders && prompt ? selectedAction : 'copy';
const agent = action === 'copy' ? null : getAIAgent(action);
const mainButton = agent ? (
<Button
variant="secondary"
size="xsmall"
icon={agent.icon}
label={tString(language, 'open_in', agent.label)}
href={agent.getURL(prompt)}
// The OS picks the deep link up and the page stays put, where `_blank` would strand the
// visitor on a tab that never loads anything.
target="_self"
/>
) : (
<Button
variant="secondary"
size="xsmall"
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
disabled={!prompt}
onClick={copyPrompt}
/>
);
return (
// Lifted above the header's overlay button, which otherwise swallows the clicks.
<div className="relative z-20 flex shrink-0 items-center gap-2">
{openInAIProviders ? (
<ButtonGroup>
{mainButton}
<DropdownMenu
align="end"
className="!min-w-48 max-w-max"
button={
<Button
label={tString(language, 'more')}
size="xsmall"
variant="secondary"
truncate={false}
disabled={!prompt}
>
<span className="flex items-center gap-1.5">
<AgentIconStack />
<ToggleChevron className="size-text-sm" />
</span>
</Button>
}
>
<DropdownMenuItem
active={action === 'copy'}
leadingIcon="copy"
onClick={() => {
setPromptAction('copy');
copyPrompt();
}}
>
{tString(language, 'prompt_copy')}
</DropdownMenuItem>
{AI_AGENTS.map((item) => (
<DropdownMenuItem
key={item.id}
active={action === item.id}
leadingIcon={item.icon}
href={item.getURL(prompt)}
target="_self"
onClick={() => setPromptAction(item.id)}
>
{tString(language, 'open_in', item.label)}
</DropdownMenuItem>
))}
</DropdownMenu>
</ButtonGroup>
) : (
mainButton
)}
</div>
);
}
/**
* The agents on offer, overlapped into a stack, so the menu advertises what it holds without
* spelling out three names next to a button that already has one.
*/
function AgentIconStack() {
return (
<span className="flex items-center">
{AI_AGENTS.map((agent, index) => (
<span
key={agent.id}
className={tcls(
'flex size-4 shrink-0 items-center justify-center rounded-full border border-tint bg-tint-base',
// Each chip's border cuts into the one behind it, so the marks stay legible
// however tightly they are stacked.
index > 0 && '-ms-1.5'
)}
>
<Icon icon={agent.icon} className="size-2.5" />
</span>
))}
</span>
);
}
@@ -5,14 +5,11 @@ import React from 'react';
import type { DocumentBlockPrompt } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives';
import { getURLForLLM } from '@/components/utils';
import { PromptActions } from './PromptActions';
import { ToggleChevron } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
type PromptClientProps = DocumentBlockPrompt['data'] & {
contentIcon: IconName | null;
prompt: string;
@@ -88,106 +85,3 @@ export function PromptClient(props: PromptClientProps) {
</div>
);
}
function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
const { prompt, openInAIProviders } = props;
return (
<div className="relative z-20 flex shrink-0 items-center gap-2">
<CopyPromptButton prompt={prompt} />
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
</div>
);
}
// time in milliseconds to show the "Copied" message after copying a prompt
const COPIED_MESSAGE_DURATION = 1000;
function CopyPromptButton(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
return (
<Button
variant="primary"
size="xsmall"
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
disabled={!prompt}
onClick={() => {
navigator.clipboard.writeText(prompt);
setCopied(true);
}}
/>
);
}
function OpenPromptDropdown(props: { prompt: string }) {
const { prompt } = props;
const language = useLanguage();
return (
<DropdownMenu
align="end"
className="!min-w-48 max-w-max"
button={
<Button
label={tString(language, 'open_in_ai')}
trailing={<ToggleChevron className="size-text-sm" />}
size="xsmall"
variant="secondary"
className="max-sm:hidden"
disabled={!prompt}
/>
}
>
{OPEN_IN_AI_PROVIDERS.map((provider) => {
const definition = getPromptOpenActionDefinition(provider);
return (
<DropdownMenuItem
key={provider}
href={getURLForLLM(provider, prompt)}
target="_blank"
leadingIcon={definition.icon}
>
{tString(language, 'open_in', definition.label)}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
function getPromptOpenActionDefinition(action: AIProviders): { icon: IconName; label: string } {
switch (action) {
case 'cursor':
return {
icon: 'cursor',
label: 'Cursor',
};
case 'claude':
return {
icon: 'claude',
label: 'Claude',
};
case 'chatgpt':
return {
icon: 'chatgpt',
label: 'ChatGPT',
};
}
}
@@ -0,0 +1,95 @@
'use client';
import React from 'react';
import { type AIAgentId, isAIAgentId } from '@/lib/ai-agents';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
/**
* What a prompt block does when its main button is pressed: copy the prompt, or hand it to one of
* the coding agents.
*/
export type PromptActionId = 'copy' | AIAgentId;
/**
* Not namespaced per site: a visitor who works in Cursor works in Cursor everywhere, so the pick
* follows them across sites the same way the `select` store's slugs do.
*/
const STORAGE_KEY = '@gitbook/prompt-action';
const DEFAULT_ACTION: PromptActionId = 'copy';
let state: PromptActionId = DEFAULT_ACTION;
let loaded = false;
const listeners = new Set<() => void>();
function isPromptActionId(value: unknown): value is PromptActionId {
return value === 'copy' || isAIAgentId(value);
}
function read(): PromptActionId {
const stored = getLocalStorageItem<unknown>(STORAGE_KEY, DEFAULT_ACTION);
return isPromptActionId(stored) ? stored : DEFAULT_ACTION;
}
function emitChange() {
for (const listener of listeners) {
listener();
}
}
/**
* Adopt what's in storage, once per page load. Deferred to the first subscription rather than run at
* module scope so the first client render still matches the server's, and the stored pick only
* lands once React is listening.
*/
function load() {
if (loaded || typeof window === 'undefined') {
return;
}
loaded = true;
state = read();
// Another tab picking an action updates this one too.
window.addEventListener('storage', (event) => {
if (event.key !== null && event.key !== STORAGE_KEY) {
return;
}
state = read();
emitChange();
});
}
function subscribe(listener: () => void) {
load();
listeners.add(listener);
// Storage was only read just now, so nudge the subscriber to re-read the snapshot.
listener();
return () => {
listeners.delete(listener);
};
}
/**
* Remember the action the visitor picked, for every prompt block on the page and the next one.
*/
export function setPromptAction(action: PromptActionId) {
load();
if (state === action) {
return;
}
state = action;
setLocalStorageItem(STORAGE_KEY, action);
emitChange();
}
/**
* The action the visitor last picked, defaulting to copying the prompt.
*/
export function usePromptAction(): PromptActionId {
return React.useSyncExternalStore(
subscribe,
() => state,
() => DEFAULT_ACTION
);
}
@@ -50,6 +50,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
tag: Tag = 'div',
ariaLabelledBy,
block,
ancestorBlocks,
document,
record,
column,
@@ -151,7 +152,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<Blocks
tag={Tag}
document={document}
ancestorBlocks={[]}
ancestorBlocks={[...ancestorBlocks, block]}
nodes={fragment.nodes}
style={[
'blocks',
@@ -15,7 +15,12 @@ import {
} from './search';
import { shouldShowTableSearch } from './shouldShowSearch';
import { StickyViewGrid } from './StickyViewGrid';
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
import {
TableSearchEmpty,
TableSearchInput,
TableSearchProvider,
TableSelectionFilter,
} from './TableSearch';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { tcls } from '@/lib/tailwind';
@@ -48,9 +53,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
searchOverride: block.data.search,
isPrint: context.mode === 'print',
});
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const selectColumns = getTableSelectColumns(block);
// Also needed when the search bar is hidden: a reader's content selection can narrow a select
// column from a tab or picker elsewhere on the page, and without records there is nothing to
// match against. A table with no select column can never be narrowed that way, so it skips.
const searchRecords =
showSearch || selectColumns.length > 0
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const cellMergeLayout = createTableCellMergeLayout(
block,
records.map(([recordId]) => recordId)
@@ -59,6 +69,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
return (
<TableSearchProvider
records={searchRecords}
selectColumns={selectColumns}
recordGroups={
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
}
@@ -66,10 +77,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
selectColumns={getTableSelectColumns(block)}
selectColumns={selectColumns}
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
{/* Sits under the filter control it relates to, and tight against the search bar,
so it reads as part of the filter controls rather than a caption on the table.
Standalone — cards, short grids — there is no control to sit under. */}
<TableSelectionFilter className={showSearch ? '-mt-1.5 justify-end' : undefined} />
<TableView
{...props}
isOffscreen={isOffscreen}
@@ -10,8 +10,16 @@ import {
type TableSearchRecordData,
getVisibleTableRecordIds,
} from './searchMatch';
import {
type SlugFilterEntry,
getAppliedSlugFilter,
reconcileSelectedOptions,
resolveSlugFilter,
slugFilterKey,
} from './slugFilter';
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { selectStore } from '@/lib/select';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
@@ -37,6 +45,13 @@ type TableSearchContextValue = {
visibleIds: ReadonlySet<string> | null;
/** True when there are records but the active filters match none of them. */
isEmpty: boolean;
/**
* Select columns the reader's content selection narrowed and the reader has left alone.
* A column they have since filtered themselves drops out, since the table no longer shows it.
*/
slugFilter: SlugFilterEntry[];
/** Drop the selection driving {@link slugFilter}, site-wide. The only thing that clears one. */
clearSlugFilter: () => void;
};
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
@@ -47,16 +62,32 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
export function TableSearchProvider(props: {
records?: TableSearchRecordData[];
recordGroups?: readonly (readonly string[])[];
/** Select columns of this table, so the reader's content selection can narrow them. */
selectColumns?: TableSelectColumn[];
children: React.ReactNode;
}) {
const { records = [], recordGroups = [] } = props;
const { records = [], recordGroups = [], selectColumns = [] } = props;
const [query, setQuery] = React.useState('');
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
() => new Set()
);
// The selection that drives this lives outside the table — a tab, a select button or a picker
// elsewhere on the page — so this synchronises with it rather than deriving from it.
const slugFilter = useSlugFilter(selectColumns);
// Columns the selection narrowed last time round. Kept so a new selection — or clearing it —
// undoes the previous one, rather than leaving a filter the reader can no longer account for.
const narrowedColumns = React.useRef<string[]>([]);
const toggleOption = React.useCallback((column: string, value: string) => {
// The reader is taking this column over, so the selection no longer owns it: a later clear
// must leave their choice of options alone. Their change stays local — the filter is this
// table's, for this visit, while the selection is site-wide and persists, so only the
// clear beside the notice touches it.
narrowedColumns.current = narrowedColumns.current.filter((narrowed) => narrowed !== column);
setSelectedOptions((previous) => {
const values = new Set(previous[column]);
if (values.has(value)) {
@@ -87,6 +118,33 @@ export function TableSearchProvider(props: {
});
}, []);
React.useEffect(() => {
const previouslyNarrowed = narrowedColumns.current;
narrowedColumns.current = slugFilter.map((entry) => entry.column);
setSelectedOptions((previous) =>
reconcileSelectedOptions(previous, previouslyNarrowed, slugFilter)
);
}, [slugFilter]);
// What the notice may speak for: the columns the selection narrowed and the reader has left
// alone. A column they have since filtered themselves still has an active slug, but the table
// is no longer showing it, so the notice must not claim it.
const appliedSlugFilter = React.useMemo(
() => getAppliedSlugFilter(slugFilter, selectedOptions),
[slugFilter, selectedOptions]
);
// Clearing goes through the store rather than local state: the selection is what persists, so
// only dropping it there stops the filter coming back on the next load. It is site-wide, so a
// tab elsewhere on the page reverts to its default too. This is the only thing that clears a
// selection — changing the filter never does.
const clearSlugFilter = React.useCallback(() => {
for (const entry of appliedSlugFilter) {
selectStore.deactivate(entry.slug);
}
}, [appliedSlugFilter]);
// Match every record once, here, rather than in each row — rows just look themselves up by id.
const visibleIds = React.useMemo(
() =>
@@ -112,8 +170,20 @@ export function TableSearchProvider(props: {
toggleCheckbox,
visibleIds,
isEmpty,
slugFilter: appliedSlugFilter,
clearSlugFilter,
}),
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
[
query,
selectedOptions,
toggleOption,
checkedColumns,
toggleCheckbox,
visibleIds,
isEmpty,
appliedSlugFilter,
clearSlugFilter,
]
);
return (
@@ -121,6 +191,40 @@ export function TableSearchProvider(props: {
);
}
/**
* The reader's selection, reduced to the columns of *this* table.
*
* Subscribes once and returns a string rather than an object: `useSyncExternalStore` compares
* snapshots by identity, so a fresh object each call would loop. It also means a selection that
* changes nothing for this table re-renders nothing — the reason `useSelect` stopped exposing the
* recency list in the first place.
*/
function useSlugFilter(selectColumns: TableSelectColumn[]): SlugFilterEntry[] {
const columnsKey = selectColumns
.map(
(column) =>
`${column.id}:${column.options.map((option) => `${option.value}=${option.label}`).join('|')}`
)
.join(';');
const getKey = React.useCallback(
() => slugFilterKey(resolveSlugFilter(selectColumns, selectStore.getState().slugs)),
// `selectColumns` is a fresh array each render; its contents are what matter.
// oxlint-disable-next-line react-hooks/exhaustive-deps
[columnsKey]
);
const filterKey = React.useSyncExternalStore(selectStore.subscribe, getKey, getKey);
// The key is only an identity: rebuild the filter itself when it moves, rather than parsing the
// key back apart, since an option value can be any string an import gave it.
return React.useMemo(
() => resolveSlugFilter(selectColumns, selectStore.getState().slugs),
// oxlint-disable-next-line react-hooks/exhaustive-deps
[filterKey]
);
}
function useTableSearch(): TableSearchContextValue {
const context = React.useContext(TableSearchContext);
if (!context) {
@@ -193,6 +297,49 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
);
}
/**
* Names the selection narrowing this table, and lets the reader drop it.
*
* Deliberately worded around the *selection* rather than the filter: the column dropdown beside it
* shows the same column as active, but clearing there only resets local state and the filter returns
* on the next load. This is the control that actually undoes it.
*
* Rendered independently of the search bar. `shouldShowTableSearch` leaves the filter controls off
* cards, off grids below the row threshold, and off any table whose author turned search off — and
* in every one of those a narrowed table would otherwise just read as missing rows.
*/
export function TableSelectionFilter(props: { className?: ClassValue }) {
const language = useLanguage();
const { slugFilter, clearSlugFilter } = useTableSearch();
if (slugFilter.length === 0) {
return null;
}
return (
<div
className={tcls('flex flex-wrap items-center gap-2 text-sm text-tint', props.className)}
>
<Icon icon="filter" className="size-3 shrink-0" />
<span>
{tString(
language,
'table_filtered_by_selection',
slugFilter.map((entry) => entry.label).join(', ')
)}
</span>
<Button
variant="blank"
size="xsmall"
icon="xmark"
iconOnly
label={tString(language, 'table_clear_selection')}
onClick={clearSlugFilter}
/>
</div>
);
}
/**
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
* of its options are selected.
@@ -226,11 +373,10 @@ function SelectFilterDropdown(props: { column: TableSelectColumn }) {
key={option.value}
active={selected}
leadingIcon={selected ? 'check' : undefined}
onSelect={(event) => {
// Keep the menu open so several options can be toggled at once.
event.preventDefault();
toggleOption(column.id, option.value);
}}
// `closeOnClick={false}` keeps the menu open so several options can be
// toggled at once.
closeOnClick={false}
onClick={() => toggleOption(column.id, option.value)}
>
{option.label || option.value}
</DropdownMenuItem>
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'bun:test';
import type { TableSelectColumn } from './search';
import {
getAppliedSlugFilter,
getOptionSlug,
reconcileSelectedOptions,
resolveSlugFilter,
slugFilterKey,
} from './slugFilter';
/**
* A select column as the editor writes one: each option carries an opaque generated `value` and the
* author's wording as its `label`. Tests must not conflate the two — matching on `value` looks
* right against fixtures that reuse the label and matches nothing against real content.
*/
function column(id: string, labels: string[]): TableSelectColumn {
return {
id,
label: id,
options: labels.map((label, index) => ({
value: `${id}-key-${index}`,
label,
color: 'blue',
})),
};
}
/** The opaque value of the option an author labelled `label`. */
function valueOf(column: TableSelectColumn, label: string): string {
const option = column.options.find((option) => option.label === label);
if (!option) {
throw new Error(`no option labelled ${label}`);
}
return option.value;
}
describe('resolveSlugFilter', () => {
const platform = column('platform', ['macOS', 'Windows', 'Linux']);
const status = column('status', ['Done', 'To do']);
it('matches the option label, and resolves to its opaque value', () => {
expect(resolveSlugFilter([platform], ['macos'])).toEqual([
{
column: 'platform',
value: valueOf(platform, 'macOS'),
label: 'macOS',
slug: 'macos',
},
]);
});
it('carries the label and slug needed to show and clear the filter', () => {
// The reader is told what narrowed the table, and the slug is what clearing deactivates.
const [entry] = resolveSlugFilter([platform], ['windows']);
expect(entry?.label).toBe('Windows');
expect(entry?.slug).toBe('windows');
});
it('never matches the opaque value itself', () => {
expect(resolveSlugFilter([platform], [valueOf(platform, 'macOS')])).toEqual([]);
});
it('slugifies the label the way every other select surface does', () => {
const languages = column('language', ['Node.js', 'C++', 'Windows 10']);
expect(resolveSlugFilter([languages], ['node.js'])[0]?.label).toBe('Node.js');
expect(resolveSlugFilter([languages], ['c++'])[0]?.label).toBe('C++');
expect(resolveSlugFilter([languages], ['windows-10'])[0]?.label).toBe('Windows 10');
});
it('falls back to the value when an option has no label', () => {
const legacy: TableSelectColumn = {
id: 'legacy',
label: 'legacy',
options: [{ value: 'macos', label: '', color: 'blue' }],
};
expect(resolveSlugFilter([legacy], ['macos'])).toEqual([
{ column: 'legacy', value: 'macos', label: 'macos', slug: 'macos' },
]);
});
it('leaves a column the selection says nothing about', () => {
const entries = resolveSlugFilter([platform, status], ['macos']);
expect(entries.map((entry) => entry.column)).toEqual(['platform']);
});
it('narrows several columns at once when the selection covers both', () => {
const entries = resolveSlugFilter([platform, status], ['macos', 'done']);
expect(entries.map((entry) => entry.value)).toEqual([
valueOf(platform, 'macOS'),
valueOf(status, 'Done'),
]);
});
it('takes the most recently activated when a column offers several active options', () => {
// Most-recent-first, so `windows` wins over `macos` — the rule tabs resolve with.
expect(resolveSlugFilter([platform], ['windows', 'macos'])[0]?.value).toBe(
valueOf(platform, 'Windows')
);
expect(resolveSlugFilter([platform], ['macos', 'windows'])[0]?.value).toBe(
valueOf(platform, 'macOS')
);
});
it('filters nothing when the selection matches no column', () => {
expect(resolveSlugFilter([platform, status], ['python'])).toEqual([]);
expect(resolveSlugFilter([platform], [])).toEqual([]);
expect(resolveSlugFilter([], ['macos'])).toEqual([]);
});
});
describe('getOptionSlug', () => {
// Shared by the matcher and by the write-back that moves the selection when a reader changes a
// governed column, so the two can never disagree about what an option answers to.
it('slugifies the label, and falls back to the value', () => {
expect(getOptionSlug({ value: 'key-0', label: 'macOS' })).toBe('macos');
expect(getOptionSlug({ value: 'key-1', label: 'Windows 10' })).toBe('windows-10');
expect(getOptionSlug({ value: 'macos', label: '' })).toBe('macos');
});
});
describe('reconcileSelectedOptions', () => {
const entry = (column: string, value: string) => ({
column,
value,
label: value,
slug: value,
});
it('drops the column when the selection is cleared', () => {
const previous = { platform: new Set(['macos-value']) };
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({});
});
it('leaves filters the reader set themselves', () => {
const previous = {
platform: new Set(['macos-value']),
status: new Set(['done-value']),
};
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({
status: new Set(['done-value']),
});
});
it('replaces the previous selection rather than adding to it', () => {
const previous = { platform: new Set(['macos-value']) };
expect(
reconcileSelectedOptions(previous, ['platform'], [entry('platform', 'windows-value')])
).toEqual({ platform: new Set(['windows-value']) });
});
it('gives the same answer however many times it is applied', () => {
// React may invoke a state updater more than once with the same input. An earlier version
// tracked the narrowed columns inside the updater, so the second pass saw them already
// cleared, took the early return and handed back the *unchanged* state — silently undoing
// a clear while leaving an apply working, which is exactly how it presented.
const previous = { platform: new Set(['macos-value']) };
const once = reconcileSelectedOptions(previous, ['platform'], []);
const twice = reconcileSelectedOptions(previous, ['platform'], []);
expect(twice).toEqual(once);
});
it('is a no-op when there is nothing to narrow and nothing to undo', () => {
const previous = { status: new Set(['done-value']) };
expect(reconcileSelectedOptions(previous, [], [])).toBe(previous);
});
});
describe('getAppliedSlugFilter', () => {
const macos = { column: 'platform', value: 'macos-value', label: 'macOS', slug: 'macos' };
it('speaks for a column the reader has left alone', () => {
const selected = { platform: new Set(['macos-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([macos]);
});
it('drops a column the reader has filtered to something else', () => {
// Changing the filter deliberately leaves the selection active, so the slug is still there;
// the notice just must not claim a match the table is no longer showing.
const selected = { platform: new Set(['windows-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
});
it('drops a column the reader has widened to several options', () => {
const selected = { platform: new Set(['macos-value', 'windows-value']) };
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
});
it('drops a column the reader has cleared', () => {
expect(getAppliedSlugFilter([macos], {})).toEqual([]);
});
});
describe('slugFilterKey', () => {
const entry = (column: string, value: string) => ({
column,
value,
label: value,
slug: value,
});
it('is stable whatever order the columns resolve in', () => {
expect(slugFilterKey([entry('platform', 'macos'), entry('status', 'done')])).toBe(
slugFilterKey([entry('status', 'done'), entry('platform', 'macos')])
);
});
it('changes when the selection moves', () => {
expect(slugFilterKey([entry('platform', 'macos')])).not.toBe(
slugFilterKey([entry('platform', 'linux')])
);
});
it('is empty when nothing is filtered', () => {
expect(slugFilterKey([])).toBe('');
});
});
@@ -0,0 +1,127 @@
import type { TableSelectColumn } from './search';
import type { SelectedOptions } from './searchMatch';
import { slugifySelectValue } from '@/lib/select';
/** One select column narrowed by the reader's content selection. */
export interface SlugFilterEntry {
/** Id of the select column being narrowed. */
column: string;
/** The option's opaque value, which the record matcher compares against. */
value: string;
/** The option's author-typed label, shown to the reader. */
label: string;
/** The active slug that narrowed the column, so the reader can clear it again. */
slug: string;
}
/**
* The `select` slug an option answers to.
*
* A table option's `value` is an opaque generated key, so it is the author-typed `label` that names
* it — the same wording a tab title or select button would carry, put through the same slugifier so
* a "macOS" column option and a "macOS" tab resolve to the one slug. Options with no label fall back
* to the raw value, mirroring how a cell renders one.
*/
export function getOptionSlug(option: { value: string; label: string }): string {
return slugifySelectValue(option.label || option.value);
}
/**
* Work out which option of each select column the reader's current selection points at.
*
* A table's select column declares its own option set, so a slug only filters a column that
* actually offers it: a reader who picked `macos` narrows a Platform column to macOS and leaves a
* Status column alone. Where several of a column's options are active at once, the most recently
* activated wins — the same rule tabs resolve with, so a table and the tabs beside it agree.
*/
export function resolveSlugFilter(
columns: TableSelectColumn[],
slugs: string[]
): SlugFilterEntry[] {
const entries: SlugFilterEntry[] = [];
for (const column of columns) {
let best: SlugFilterEntry | undefined;
let bestRank = Number.POSITIVE_INFINITY;
for (const option of column.options) {
const slug = getOptionSlug(option);
if (!slug) {
continue;
}
const rank = slugs.indexOf(slug);
if (rank >= 0 && rank < bestRank) {
bestRank = rank;
best = {
column: column.id,
value: option.value,
label: option.label || option.value,
slug,
};
}
}
if (best) {
entries.push(best);
}
}
return entries;
}
/** Stable identity for a filter, so it is only rebuilt when the selection actually moves. */
export function slugFilterKey(entries: SlugFilterEntry[]): string {
return entries
.map((entry) => `${entry.column}=${entry.value}`)
.sort()
.join(',');
}
/**
* Fold the columns the selection narrows into the reader's own filters.
*
* Kept pure, and given the previously narrowed columns rather than reading them from a ref, so it
* can be applied more than once without changing the answer — React may invoke a state updater
* twice, and an earlier version tracked those columns inside the updater itself, which silently
* undid a clear on the second pass.
*
* Only columns the selection narrowed last time are dropped; anything the reader filtered by hand
* is left exactly as it was.
*/
export function reconcileSelectedOptions(
previous: SelectedOptions,
previouslyNarrowed: readonly string[],
slugFilter: readonly SlugFilterEntry[]
): SelectedOptions {
if (previouslyNarrowed.length === 0 && slugFilter.length === 0) {
return previous;
}
const next = { ...previous };
for (const column of previouslyNarrowed) {
delete next[column];
}
for (const entry of slugFilter) {
next[entry.column] = new Set([entry.value]);
}
return next;
}
/**
* Narrow the filter to the columns the table is *still* showing.
*
* A reader can change any of these columns by hand, and that deliberately leaves the selection
* alone — the filter is this table's, for this visit, while the selection is site-wide and
* persists. So the notice has to stop speaking for a column that no longer matches, rather than
* describing a view the reader has since changed.
*/
export function getAppliedSlugFilter(
slugFilter: readonly SlugFilterEntry[],
selectedOptions: SelectedOptions
): SlugFilterEntry[] {
return slugFilter.filter((entry) => {
const values = selectedOptions[entry.column];
return values?.size === 1 && values.has(entry.value);
});
}
@@ -5,7 +5,12 @@ import { validateIconName } from '@gitbook/icons/icons';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { DynamicTabs } from './DynamicTabs';
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
import {
generateSelectCSS,
resolveSelectSlug,
selectSetClassName,
slugifySelectValue,
} from '@/lib/select';
import { tcls } from '@/lib/tailwind';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
@@ -26,6 +31,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
return {
id: tab.meta?.id ?? tab.key,
title: tab.data.title ?? '',
slug: tab.data.slug,
icon,
body: (
<Blocks
@@ -94,19 +100,20 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) {
}
/**
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
* so they stay selectable.
* Resolve a `select` slug for each tab: its explicit slug when set, else one derived from its title.
* Untitled tabs fall back to their (stable) id so they stay selectable.
*
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
* with a positional suffix: that would desync the duplicate and make a stored selection retarget
* whenever tabs are renamed or reordered.
* whenever tabs are renamed or reordered. An explicit slug is the way out for a tab that needs to
* keep its identity across a rename.
*/
function withSelectSlugs<T extends { id: string; title: string }>(
function withSelectSlugs<T extends { id: string; title: string; slug?: string }>(
items: T[]
): (T & { slug: string })[] {
return items.map((item) => ({
...item,
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
slug: resolveSelectSlug(item) || slugifySelectValue(item.id) || item.id,
}));
}
@@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
return (
<span
className={tcls([
textColorToStyle[mark.data.text],
backgroundColorToStyle[mark.data.background],
textColorToStyle[mark.data.text ?? 'default'],
backgroundColorToStyle[mark.data.background ?? 'default'],
])}
>
{children}
@@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons';
import { TagIcon } from '../Tag';
import { Button } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_ATTR,
UPDATES_TAG_CHIP_SELECTED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR,
UPDATES_TAG_CLEAR_ATTR,
UPDATES_TAG_FILTER_ATTR,
normalizeUpdatesFilterTags,
updatesFilterStyleKey,
} from '@/lib/updates';
type UpdatesFilterContextValue = {
selectedTags: string[];
@@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
const sanitizeTags = React.useCallback(
(tags: string[]) => {
const next: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
if (!availableTags.has(tag) || seen.has(tag)) {
continue;
}
next.push(tag);
seen.add(tag);
}
return next;
},
(tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags),
[availableTags]
);
@@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
() => sanitizeTags(rawSelectedTags),
[sanitizeTags, rawSelectedTags]
);
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const selectedTagsRef = React.useRef(selectedTags);
const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]);
// Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale
// stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR).
React.useLayoutEffect(() => {
document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey);
return () => {
document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR);
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
};
}, [styleKey]);
const replaceTags = React.useCallback(
(nextTags: string[]) => {
@@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
}
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
React.useEffect(() => {
// Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI.
React.useLayoutEffect(() => {
applyTagFilterAttribute(urlSelectedTags);
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
return;
}
@@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
selectedTagsRef.current = nextTags;
setSelectedTags(nextTags);
applyTagFilterAttribute(nextTags);
replaceTags(nextTags);
},
[replaceTags, sanitizeTags]
@@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: {
clearLabel: string;
}) {
const { tags, tagsLabel, clearLabel } = props;
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
const isFiltering = selectedTags.length > 0;
const { toggleTag, clearTags } = useUpdatesFilter();
if (tags.length === 0) {
return null;
@@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: {
<Icon icon="tags" className="size-3" />
{tagsLabel}
</div>
{/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */}
<Button
variant="blank"
size="xsmall"
icon="xmark"
label={clearLabel}
onClick={isFiltering ? clearTags : undefined}
aria-hidden={!isFiltering}
tabIndex={isFiltering ? undefined : -1}
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
onClick={clearTags}
{...{ [UPDATES_TAG_CLEAR_ATTR]: '' }}
className="pointer-events-none invisible text-xs"
/>
</div>
<div className="flex flex-wrap gap-1.5 px-3">
{tags.map((tag) => {
const selected = selectedTagSet.has(tag.slug);
return (
<button
key={tag.slug}
type="button"
aria-pressed={selected}
onClick={() => toggleTag(tag.slug)}
className={tcls(
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
'circular-corners:rounded-2xl straight-corners:rounded-xs',
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
selected
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
isFiltering && !selected && 'opacity-8 hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</button>
);
})}
{tags.map((tag) => (
<TagChip key={tag.slug} tag={tag} onToggle={toggleTag} />
))}
</div>
</div>
);
}
const CHIP_CLASS =
'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary';
const CHIP_VARIANT_CLASS =
'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs';
function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) {
const { tag, onToggle } = props;
const { selectedTagSet } = useUpdatesFilter();
const onClick = () => onToggle(tag.slug);
return (
<button
type="button"
aria-pressed={selectedTagSet.has(tag.slug)}
onClick={onClick}
className={CHIP_CLASS}
>
<span
{...{ [UPDATES_TAG_CHIP_SELECTED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-tint-5 text-tint-strong opacity-8 hover:bg-tint-hover hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'flex bg-tint-5 text-tint-strong hover:bg-tint-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
</button>
);
}
function areTagsEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index]);
}
/** Mirrors the active filter onto `<html>`, matching what the pre-paint script does on first load. */
function applyTagFilterAttribute(tags: string[]) {
if (typeof document === 'undefined') {
return;
}
if (tags.length > 0) {
document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' '));
} else {
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
}
}
/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */
export function FilteredUpdate(props: {
tagSlugs: string[];
className?: string;
children: React.ReactNode;
}) {
const { tagSlugs, className, children } = props;
const { selectedTagSet } = useUpdatesFilter();
const isVisible =
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
return (
<div className={className} hidden={!isVisible}>
<div className={className} {...{ [UPDATES_TAG_ATTR]: tagSlugs.join(' ') }}>
{children}
</div>
);
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'bun:test';
import { serializeUpdatesFilterScriptArgs } from './UpdatesFilterScript';
describe('serializeUpdatesFilterScriptArgs', () => {
it('cannot terminate the surrounding script element', () => {
const serialized = serializeUpdatesFilterScriptArgs([
['</script><script>globalThis.injected = true</script>'],
]);
expect(serialized).not.toContain('</script>');
expect(serialized).toContain('\\u003c/script>');
});
});
@@ -0,0 +1,95 @@
'use client';
import { useServerInsertedHTML } from 'next/navigation';
import { useRef } from 'react';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
updatesFilterStyleKey,
} from '@/lib/updates';
/**
* Mirrors the `?tag=` search params onto `<html>` as `data-updates-tag-filter`, before first paint,
* so the generated CSS can filter with no flash. Also stamps the filter style key.
*
* NOTE: stringified and injected as an inline script — must be self-contained, touching only
* `document.documentElement` and `window.location`.
*/
export function applyUpdatesFilterScript(
searchParam: string,
attribute: string,
cap: number,
availableTags: string[],
keyAttribute: string,
styleKey: string
) {
const el = document.documentElement;
el.setAttribute(keyAttribute, styleKey);
try {
const validTags = new Set(availableTags);
const params = new URLSearchParams(window.location.search);
const seen = new Set<string>();
const slugs: string[] = [];
for (const value of params.getAll(searchParam)) {
const slug = value.trim();
if (!slug || seen.has(slug) || slugs.length >= cap || !validTags.has(slug)) {
continue;
}
seen.add(slug);
slugs.push(slug);
}
if (slugs.length > 0) {
el.setAttribute(attribute, slugs.join(' '));
} else {
el.removeAttribute(attribute);
}
} catch {
// Malformed URL — fall through, so the generated CSS's default (show everything) applies.
}
}
/**
* Inline script that applies the URL's `?tag=` filter to `<html>` before first paint. Rendered only
* on pages with filterable updates (see SitePage), not globally.
*/
export function UpdatesFilterScript(props: { tagSlugs: string[] }) {
const { tagSlugs } = props;
const inserted = useRef(false);
const scriptArgs = serializeUpdatesFilterScriptArgs([
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
tagSlugs,
UPDATES_FILTER_KEY_ATTR,
updatesFilterStyleKey(tagSlugs),
]);
// Only needed for the initial document — client navs are handled by UpdatesFilterProvider.
useServerInsertedHTML(() => {
if (inserted.current) {
return null;
}
inserted.current = true;
return (
<script
dangerouslySetInnerHTML={{
__html: `(${applyUpdatesFilterScript.toString()})(${scriptArgs})`,
}}
/>
);
});
return null;
}
/** Serialize arguments without allowing revision data to terminate the surrounding script tag. */
export function serializeUpdatesFilterScriptArgs(args: unknown[]): string {
return JSON.stringify(args).replaceAll('<', '\\u003c').slice(1, -1);
}
@@ -1,6 +1,6 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockParagraph, JSONDocument } from '@gitbook/api';
import type { DocumentBlockParagraph, DocumentBlockTable, JSONDocument } from '@gitbook/api';
import { isBlockOffscreen } from './isBlockOffscreen';
@@ -42,3 +42,31 @@ it('should return true if block is offscreen', () => {
)
).toBe(true);
});
it('should treat content in table cells as offscreen', () => {
const cell: DocumentBlockParagraph = {
object: 'block',
type: 'paragraph',
nodes: [],
};
const table: DocumentBlockTable = {
object: 'block',
type: 'table',
isVoid: true,
data: {
view: { type: 'cards', cardSize: 'medium', columns: ['column'] },
records: {},
definition: {},
},
fragments: [{ object: 'fragment', fragment: 'cell', nodes: [cell] }],
};
const document: JSONDocument = {
object: 'document',
nodes: [table],
data: {},
};
expect(isBlockOffscreen({ document, block: cell, ancestorBlocks: [table] })).toBe(true);
});
@@ -18,6 +18,11 @@ export function isBlockOffscreen(
},
screenHeight = 500
): boolean {
// Cell content lives in fragments, not table nodes, so its row position can't be estimated.
if (ancestorBlocks.some((ancestor) => ancestor.type === 'table')) {
return true;
}
let current = block;
const allAncestors = [document, ...ancestorBlocks];
@@ -61,9 +61,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}
trackEvent(
{
type: 'ask_view',
},
{ type: 'ask_view', trigger: 'embed' },
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
@@ -97,6 +97,7 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="my-1.5 ml-2 self-start"
/>
) : null}
@@ -125,6 +126,7 @@ export async function EmbeddableDocsPage(
) ?? context.siteSpace
}
siteSpaces={variants.translations}
asEmbeddable
className="max-md:[&_.button-content]:block"
/>
) : null
@@ -137,6 +139,7 @@ export async function EmbeddableDocsPage(
context={context}
siteSpace={context.siteSpace}
siteSpaces={variants.generic}
asEmbeddable
className="w-full px-3"
/>
) : null}
@@ -9,6 +9,7 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import { integrationsAssistantTools } from '../Integrations';
import { Button, LinkContext, type LinkContextType } from '../primitives';
import { getChannel } from './channel';
import { DEFAULT_EMBEDDABLE_TABS, getEmbeddableLandingTab } from './getEmbeddableLandingTab';
import { resolveEmbedPageLink } from './server-actions';
import { useAI, useAIChatController } from '@/components/AI';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
@@ -23,6 +24,17 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
trademark: true,
}));
const docsHome = createStore<{ reference?: string; href?: string }>(() => ({}));
// Module-level so it survives the layout remounting on a cross-space navigation.
let landed = false;
function resolvePageHref(pagePath: string, baseURL: string): Promise<string> {
return resolveEmbedPageLink(pagePath)
.then((resolved) => ('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`))
.catch(() => `${baseURL}/page/${pagePath}`);
}
// oxlint-disable-next-line typescript/no-explicit-any
function log(...data: any[]) {
// oxlint-disable-next-line no-console
@@ -66,6 +78,15 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
const { baseURL, router, chatController } = refs.current;
const message = payload as ParentToFrameMessage;
const navigate = (href: Promise<string>) => {
const token = ++navToken.current;
href.then((href) => {
if (navToken.current === token) {
refs.current.router.push(href);
}
});
};
log('[gitbook] received message', message);
switch (message.type) {
@@ -83,29 +104,43 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
break;
}
case 'configure': {
embeddableConfiguration.setState(message.settings);
const { settings } = message;
embeddableConfiguration.setState(settings);
integrationsAssistantTools.setState({
tools: message.settings.tools,
tools: settings.tools,
});
const defaultPage =
typeof settings.defaultPage === 'string' && settings.defaultPage
? settings.defaultPage
: undefined;
let docsHomeHref: Promise<string> | undefined;
if (defaultPage !== docsHome.getState().reference) {
docsHome.setState({ reference: defaultPage, href: undefined });
if (defaultPage) {
docsHomeHref = resolvePageHref(defaultPage, baseURL);
docsHomeHref.then((href) => {
if (docsHome.getState().reference === defaultPage) {
docsHome.setState({ href });
}
});
}
}
if (!landed) {
landed = true;
const tab = getEmbeddableLandingTab(settings);
if (tab === 'docs') {
navigate(docsHomeHref ?? Promise.resolve(`${baseURL}/page/`));
} else if (tab) {
navToken.current++;
router.push(`${baseURL}/${tab}`);
}
}
break;
}
case 'navigateToPage': {
// Resolve the target server-side: on a multi-space site the page may
// live in another space/section, whose base must go before
// `~gitbook/embed/page`. Fall back to a same-space push on failure.
const { pagePath } = message;
const token = ++navToken.current;
// Ignore the result if a later navigation has since superseded this one.
const push = (href: string) => {
if (navToken.current === token) {
router.push(href);
}
};
resolveEmbedPageLink(pagePath)
.then((resolved) =>
push('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`)
)
.catch(() => push(`${baseURL}/page/${pagePath}`));
navigate(resolvePageHref(message.pagePath, baseURL));
break;
}
case 'navigateToAssistant': {
@@ -132,7 +167,7 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
export function useEmbeddableTabs() {
const configuredTabs = useEmbeddableConfiguration((state) => state.tabs);
return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs'];
return configuredTabs.length > 0 ? configuredTabs : DEFAULT_EMBEDDABLE_TABS;
}
export function useEmbeddableLinkContext() {
@@ -200,6 +235,7 @@ export function EmbeddableIframeTabs(props: {
const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props;
const actions = useEmbeddableConfiguration((state) => state.actions);
const tabs = useEmbeddableTabs();
const docsHomeHref = useStore(docsHome, (state) => state.href);
const { assistants, config } = useAI();
const language = useLanguage();
@@ -228,7 +264,7 @@ export function EmbeddableIframeTabs(props: {
key: 'docs',
label: siteTitle,
icon: 'book-open',
href: `${baseURL}/page/`,
href: docsHomeHref ?? `${baseURL}/page/`,
}
: null,
].filter((tab) => tab !== null);
@@ -1,6 +1,8 @@
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { AskAIParagraphButton } from '../AIChat';
import { LoadIntegrations } from '../Integrations';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -76,6 +78,7 @@ export async function EmbeddableRootLayout({
}}
>
<NavigationLoader />
<AskAIParagraphButton />
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
@@ -93,6 +96,8 @@ export async function EmbeddableRootLayout({
/>
</SpaceLayoutServerContext>
</EmbeddableAIContextProvider>
{/* Embeds load no integrations; this marks them loaded so search stops waiting. */}
<LoadIntegrations />
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'bun:test';
import { getEmbeddableLandingTab } from './getEmbeddableLandingTab';
describe('getEmbeddableLandingTab', () => {
it('keeps the embed default when nothing is configured', () => {
expect(getEmbeddableLandingTab({})).toBeNull();
expect(getEmbeddableLandingTab({ tabs: ['docs', 'search'] })).toBeNull();
});
it('opens on the configured tab', () => {
expect(getEmbeddableLandingTab({ defaultTab: 'search' })).toBe('search');
expect(
getEmbeddableLandingTab({ tabs: ['assistant', 'search', 'docs'], defaultTab: 'search' })
).toBe('search');
});
it('opens on the docs when only a default page is set', () => {
expect(getEmbeddableLandingTab({ defaultPage: 'getting-started' })).toBe('docs');
});
it('prefers the configured tab over the default page', () => {
expect(
getEmbeddableLandingTab({ defaultTab: 'assistant', defaultPage: 'getting-started' })
).toBe('assistant');
});
it('ignores a tab that is not enabled', () => {
expect(getEmbeddableLandingTab({ tabs: ['assistant'], defaultTab: 'search' })).toBeNull();
expect(
getEmbeddableLandingTab({ tabs: ['assistant', 'search'], defaultPage: 'quickstart' })
).toBeNull();
});
it('ignores an unknown tab', () => {
// @ts-expect-error - the parent window is plain JS and can send anything.
expect(getEmbeddableLandingTab({ defaultTab: 'settings' })).toBeNull();
});
});
@@ -0,0 +1,17 @@
import type { GitBookEmbeddableConfiguration } from '@gitbook/embed';
export type EmbeddableTab = GitBookEmbeddableConfiguration['tabs'][number];
export const DEFAULT_EMBEDDABLE_TABS: EmbeddableTab[] = ['assistant', 'search', 'docs'];
export function getEmbeddableLandingTab(
settings: Partial<Pick<GitBookEmbeddableConfiguration, 'tabs' | 'defaultTab' | 'defaultPage'>>
): EmbeddableTab | null {
const tab = settings.defaultTab ?? (settings.defaultPage ? 'docs' : undefined);
if (!tab || !DEFAULT_EMBEDDABLE_TABS.includes(tab)) {
return null;
}
const tabs = settings.tabs?.length ? settings.tabs : DEFAULT_EMBEDDABLE_TABS;
return tabs.includes(tab) ? tab : null;
}
@@ -2,9 +2,10 @@ import type { SiteSpace } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ButtonProps } from '../primitives';
import { getTargetURLForSiteSpace } from '../SiteSections/encodeClientSiteSections';
import { SpacesDropdownClient } from './SpacesDropdownClient';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
// Memoized regex for checking if a string starts with an emoji
@@ -20,9 +21,18 @@ export function SpacesDropdown(
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
/** Keep the links inside the embed rather than sending readers to the full site. */
asEmbeddable?: boolean;
} & ButtonProps
) {
const { context, siteSpace, siteSpaces, className, ...buttonProps } = props;
const {
context,
siteSpace,
siteSpaces,
className,
asEmbeddable = false,
...buttonProps
} = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
@@ -33,7 +43,7 @@ export function SpacesDropdown(
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
url: getTargetURLForSiteSpace(context, siteSp, asEmbeddable),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
@@ -57,9 +67,10 @@ export function TranslationsDropdown(
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
className?: string;
asEmbeddable?: boolean;
} & ButtonProps
) {
const { context, siteSpace, siteSpaces, className, ...buttonProps } = props;
const { context, siteSpace, siteSpaces, className, asEmbeddable, ...buttonProps } = props;
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
@@ -70,6 +81,7 @@ export function TranslationsDropdown(
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
asEmbeddable={asEmbeddable}
variant="blank"
className={tcls(
'bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
@@ -75,12 +76,15 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -154,6 +159,9 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -161,6 +169,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -214,6 +223,18 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
}
/**
* Post the events to the server.
*/
@@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -258,6 +280,11 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -1,5 +1,6 @@
'use client';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import type { SiteInsightsDisplayContext } from '@gitbook/api';
@@ -7,6 +8,7 @@ import type { SiteInsightsDisplayContext } from '@gitbook/api';
import { useCurrentPage } from '../hooks';
import { useIsVisible } from '../VisibilityContext';
import { useTrackEvent } from './InsightsProvider';
import { isGitBookInternalPath } from '@/lib/paths';
/**
* Track a page view event.
@@ -15,11 +17,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
const { displayContext } = props;
const page = useCurrentPage();
const trackEvent = useTrackEvent();
const pathname = usePathname();
// Internal routes that 404 (e.g. `~gitbook/pdf` under an unpublished variant) aren't broken URLs.
const isInternalNotFound = !page && isGitBookInternalPath(pathname ?? '');
// Always true outside of the embed, whose frame can be loaded while hidden.
const isVisible = useIsVisible();
React.useEffect(() => {
if (!isVisible) {
if (!isVisible || isInternalNotFound) {
return;
}
@@ -32,7 +37,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
displayContext,
}
);
}, [page, trackEvent, displayContext, isVisible]);
}, [page, trackEvent, displayContext, isVisible, isInternalNotFound]);
return null;
}
@@ -147,6 +147,10 @@ export function useCustomCookieBanner(): CustomCookieBannerStore {
export function LoadIntegrations() {
React.useEffect(() => {
return whenIntegrationsReady(() => {
// Integrations must only receive `load` once, even if this is mounted twice.
if (integrationsStore.getState().loaded) {
return;
}
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
});
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open();
assistant.open(undefined, 'page');
}}
/>
);
@@ -9,6 +9,7 @@ import { useScrollActiveId } from '@/components/hooks';
import { useBodyLoaded } from '@/components/primitives';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { UPDATES_TAG_SECTION_ATTR } from '@/lib/updates';
/**
* The threshold at which we consider a section as intersecting the viewport.
@@ -22,20 +23,18 @@ const ACTIVE_ITEM_OFFSET = 100;
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
const { selectedTagSet } = useUpdatesFilter();
const visibleSections = React.useMemo(() => {
if (selectedTagSet.size === 0) {
return sections;
}
return sections.filter((section) => {
if (section.tags === undefined) {
return true;
}
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
});
}, [sections, selectedTagSet]);
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
const ids = React.useMemo(
() =>
sections
.filter(
(section) =>
selectedTagSet.size === 0 ||
section.tags === undefined ||
section.tags.some((tagSlug) => selectedTagSet.has(tagSlug))
)
.map(({ id }) => id),
[sections, selectedTagSet]
);
const enabled = useBodyLoaded();
@@ -62,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
className="relative flex flex-col border-tint-subtle pb-5 sidebar-list-line:border-l"
ref={scrollContainerRef}
>
{visibleSections.map((section) => (
{sections.map((section) => (
<li
key={section.id}
className={tcls(
@@ -76,6 +75,9 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
ref={activeId === section.id ? activeItemRef : null}
{...(section.tags !== undefined
? { [UPDATES_TAG_SECTION_ATTR]: section.tags.join(' ') }
: {})}
>
<a
href={`#${section.id}`}
@@ -1,4 +1,8 @@
import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api';
import {
type JSONDocument,
type RevisionPageDocument,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
@@ -25,6 +29,7 @@ import {
import { getLLMsTxtURL, getPageMarkdownURL } from '@/lib/llms-directive';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
const LINK_PREVIEW_MAX_COUNT = 500;
@@ -86,6 +91,14 @@ export async function PageBody(props: {
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
{contentHasUpdates ? (
<link
rel="alternate"
type="application/rss+xml"
href={getPageRSSURL(context, page)}
title="RSS Feed"
/>
) : null}
<main
className={tcls(
'relative min-w-0 flex-1',
@@ -118,6 +131,7 @@ export async function PageBody(props: {
ancestors={ancestors}
withRSSFeed={contentHasUpdates}
hasAPIBlocks={hasAPIBlocks}
asEmbeddable={insightsDisplayContext === SiteInsightsDisplayContext.Embed}
/>
{document && !isNodeEmpty(document) ? (
<OptionalSuspense
@@ -29,18 +29,21 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -52,7 +55,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -73,7 +76,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -7,7 +7,6 @@ import {
type RevisionPageDocument,
type SiteSection,
type SiteSectionGroup,
SiteVisibility,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -20,13 +19,17 @@ import {
import { PageAsideToggleButton } from '../PageAside/PageAsideButton';
import { PageIcon } from '../PageIcon';
import { getPDFURLSearchParams } from '../PDF';
import { findBestTargetURL } from '../SiteSections/encodeClientSiteSections';
import {
findBestTargetURL,
getTargetURLForSiteSpace,
} from '../SiteSections/encodeClientSiteSections';
import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown';
import { PageTags } from './PageTags';
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
import { hasAdaptiveMcpEndpoint } from '@/lib/mcp/endpoints';
import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
@@ -41,8 +44,10 @@ export async function PageHeader(props: {
* exactly as-is.
*/
hasAPIBlocks: boolean;
/** Keep the context crumbs' links inside the embed rather than sending readers to the full site. */
asEmbeddable: boolean;
}) {
const { context, page, ancestors, withRSSFeed, hasAPIBlocks } = props;
const { context, page, ancestors, withRSSFeed, hasAPIBlocks, asEmbeddable } = props;
const { revision, linker } = context;
const hasAncestors = ancestors.length > 0;
@@ -82,7 +87,10 @@ export async function PageHeader(props: {
// Section groups have no URL of their own; only sections are navigable. Resolve the
// section to the site space matching the current variant so switching sections keeps
// the reader on their current variant (same logic as the section tabs).
href: node.object === 'site-section' ? findBestTargetURL(context, node) : undefined,
href:
node.object === 'site-section'
? findBestTargetURL(context, node, asEmbeddable)
: undefined,
label: getLocalizedTitle(node, context.locale),
icon: node.icon,
siblings: siblings
@@ -92,7 +100,7 @@ export async function PageHeader(props: {
// Don't offer hidden sections/groups as switch targets.
.filter((sibling) => !visibleSectionIds || visibleSectionIds.has(sibling.id))
.map((sibling) => {
const href = getSectionNodeURL(context, sibling);
const href = getSectionNodeURL(context, sibling, asEmbeddable);
// Keep siblings whose URL is "" (the site's first page); only drop the ones
// with no resolvable URL at all.
return href !== undefined
@@ -112,7 +120,7 @@ export async function PageHeader(props: {
if (currentSiteSpace) {
contextCrumbs.push({
key: `variant-${currentSiteSpace.id}`,
href: getSiteSpaceURL(context, currentSiteSpace),
href: getTargetURLForSiteSpace(context, currentSiteSpace, asEmbeddable),
label: getLocalizedTitle(currentSiteSpace, context.locale),
siblings: [],
// Reuse the header's variant switcher rather than plain per-variant links, so each entry
@@ -122,7 +130,7 @@ export async function PageHeader(props: {
slimSpaces: variantSpaces.map((siteSpace) => ({
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
url: getSiteSpaceURL(context, siteSpace),
url: getTargetURLForSiteSpace(context, siteSpace, asEmbeddable),
isActive: siteSpace.id === currentSiteSpace.id,
spaceId: siteSpace.space.id,
})),
@@ -214,9 +222,14 @@ export async function PageHeader(props: {
))}
{ancestors.map((breadcrumb, index) => {
const parentPages = ancestors[index - 1]?.pages ?? revision.pages;
// A group's own URL redirects to its first document, so link straight to it.
const target =
breadcrumb.type === 'group'
? (resolveFirstDocument([breadcrumb], [])?.page ?? breadcrumb)
: breadcrumb;
const href = linker.toPathForPage({
pages: revision.pages,
page: breadcrumb,
page: target,
});
return (
<li key={breadcrumb.id} className="inline">
@@ -448,9 +461,13 @@ function collectSectionNodeIds(list: SiteStructureNode[]): Set<string> {
* The URL resolves to the site space matching the current variant (same logic as the section tabs),
* so switching sections keeps the reader on their current variant.
*/
function getSectionNodeURL(context: GitBookSiteContext, node: SectionNode): string | undefined {
function getSectionNodeURL(
context: GitBookSiteContext,
node: SectionNode,
asEmbeddable: boolean
): string | undefined {
const section = findFirstSection(node);
return section ? findBestTargetURL(context, section) : undefined;
return section ? findBestTargetURL(context, section, asEmbeddable) : undefined;
}
/**
@@ -471,15 +488,16 @@ function getPageSiblings(
if (page.hidden && page.id !== activeId) {
continue;
}
// A group whose subtree has no document resolves to no page — the sidebar renders it as a
// non-clickable label, so don't offer it as a navigable sibling (its path would 404).
if (page.type === 'group' && page.id !== activeId && !resolveFirstDocument([page], [])) {
// A group's own URL redirects to its first document, so link straight to it. A group whose
// subtree has no document resolves to no page, so don't offer it (its path would 404).
const target = page.type === 'group' ? resolveFirstDocument([page], [])?.page : page;
if (!target) {
continue;
}
result.push({
id: page.id,
title: page.linkTitle || page.title,
href: context.linker.toPathForPage({ pages: context.revision.pages, page }),
href: context.linker.toPathForPage({ pages: context.revision.pages, page: target }),
emoji: page.emoji,
icon: page.icon,
isActive: page.id === activeId,
@@ -559,12 +577,7 @@ function isPageActionEnabled(
* Return the MCP URL to be used in the page actions dropdown.
*/
function getPageActionsMCPURL(context: GitBookSiteContext) {
const useAuthenticatedEndpoint = Boolean(
context.site.visibility !== SiteVisibility.VisitorAuth &&
context.site.adaptiveContent?.enabled &&
context.site.urls.login &&
context.isLoggedInVisitor
);
const useAuthenticatedEndpoint = hasAdaptiveMcpEndpoint(context) && context.isLoggedInVisitor;
const endpoint = useAuthenticatedEndpoint ? '~gitbook/mcp/auth' : '~gitbook/mcp';
return context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint));
@@ -1,6 +1,5 @@
'use client';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
import { useLayoutEffect, useState } from 'react';
interface ImageSize {
width: number;
@@ -22,19 +21,18 @@ interface Images {
}
/**
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
*/
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) {
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
containerRef,
objectPositionY,
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -16,18 +16,10 @@ import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearchController } from './useSearchController';
import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const SEARCH_POPUP_FOCUSABLE_SELECTOR = [
'a[href]:not([aria-disabled="true"])',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
ssr: false,
});
@@ -144,54 +136,7 @@ export function SearchContainer({
? Boolean(state?.open || state?.query || wasSearchOpened)
: Boolean(state?.query || withAI);
React.useEffect(() => {
if (
usesSideSheet ||
!isSearchOpen ||
!shouldShowSearchFrame ||
!searchInputRef.current ||
!searchPopup
) {
return;
}
const searchInput = searchInputRef.current.querySelector<HTMLElement>(
'[data-testid="search-input"]'
);
if (!searchInput) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}
const popupControls = Array.from(
searchPopup.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR)
).filter((element) => element.getClientRects().length > 0);
const focusableElements = [searchInput, ...popupControls];
const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement);
const nextIndex =
currentIndex === -1
? event.shiftKey
? focusableElements.length - 1
: 0
: event.shiftKey
? currentIndex - 1
: currentIndex + 1;
const wrappedIndex = (nextIndex + focusableElements.length) % focusableElements.length;
event.preventDefault();
focusableElements[wrappedIndex]?.focus();
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isSearchOpen, searchPopup, shouldShowSearchFrame, usesSideSheet]);
useSearchPopupFocusTrap({ close, searchInputRef, searchPopup, usesSideSheet });
const scopeControlNode =
searchProps.withVariants || searchProps.withSections ? (
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
import React from 'react';
import { Button, SkeletonParagraph, SkeletonSmall } from '../primitives';
import { isExternalLink } from '../utils/link';
import { addRecentSearchQuery } from './recent-queries';
import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
@@ -218,6 +219,16 @@ export const SearchResults = React.forwardRef(function SearchResults(
addRecentSearchQuery(siteSpaceId, query, 'search');
}
// The popover's focus manager re-focuses the popup when the focused
// result is torn down during close (base-ui `restoreFocus`), and that
// focus() scrolls the popup — anchored at the top of the page — into
// view, undoing the scroll to the section the result linked to.
// A click that opens elsewhere leaves the search open, and keyboard
// users should keep their place in it.
if (navigatesCurrentWindow(event)) {
event.currentTarget.blur();
}
onResultSelect?.();
};
const resultItemProps = {
@@ -385,3 +396,19 @@ const SearchResultsSkeleton = (props: { items: number }) => {
</>
);
};
/**
* Whether clicking a result navigates the current window, and so closes the search — as opposed
* to opening a new tab or window (modifier keys, `target="_blank"`, or an external destination,
* which an embed opens in a new tab).
*/
function navigatesCurrentWindow(event: React.MouseEvent<HTMLAnchorElement>) {
const link = event.currentTarget;
return (
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
link.target !== '_blank' &&
!isExternalLink(link.href, window.location.origin)
);
}
@@ -5,6 +5,7 @@ import React from 'react';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useIntegrationsLoaded } from '../Integrations';
import { useBodyLoaded } from '../primitives';
import {
clearLastSearchQuery,
@@ -140,6 +141,7 @@ export function useSearchController(
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const integrationsLoaded = useIntegrationsLoaded();
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -169,6 +171,8 @@ export function useSearchController(
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Integration assistants register on window load, so wait for it before treating `ask` as a search query.
const askIsSearchQuery = withSearchAI || (!withAI && integrationsLoaded);
// Handle initial ask state on page load, once assistants are ready.
// `ask=` should still bootstrap the assistant on the docs site, so we must
@@ -203,7 +207,7 @@ export function useSearchController(
const query =
prev?.query ??
getLastSearchQuery(siteSpace.id) ??
(withSearchAI || !withAI ? prev?.ask : null) ??
(askIsSearchQuery ? prev?.ask : null) ??
'';
return {
@@ -217,7 +221,7 @@ export function useSearchController(
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]);
const setQuery = React.useCallback(
(value: string) => {
@@ -261,7 +265,7 @@ export function useSearchController(
});
const searchValue =
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
const searchResultsId = `search-results-${React.useId()}`;
// Only clears the remembered last query and stops any in-flight fetch — it must
@@ -0,0 +1,115 @@
import React from 'react';
const SEARCH_POPUP_FOCUSABLE_SELECTOR = [
'a[href]:not([aria-disabled="true"])',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
/**
* Traps Tab/Shift+Tab within the search input and its popup while it's open, and hands focus
* off to the adjacent page element once the trap's boundary is reached.
*/
export function useSearchPopupFocusTrap({
close,
searchInputRef,
searchPopup,
usesSideSheet,
}: {
close: () => void;
searchInputRef: React.RefObject<HTMLDivElement | null>;
searchPopup: HTMLDivElement | null;
usesSideSheet: boolean;
}) {
React.useEffect(() => {
if (usesSideSheet || !searchInputRef.current || !searchPopup) {
return;
}
const searchInput = searchInputRef.current.querySelector<HTMLElement>(
'[data-testid="search-input"]'
);
if (!searchInput) {
return;
}
// Finds the next (or previous) focusable element in real document order,
// skipping the popup's own content as well as Base UI's internal focus-guard
// sentinels — both live in a portal, so their DOM position doesn't match their
// visual position after the search input, and would otherwise re-trap focus.
const findAdjacentPageElement = (direction: 'next' | 'previous') => {
const step = direction === 'next' ? 1 : -1;
const pageFocusable = Array.from(
document.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR)
).filter(
(element) =>
element.getClientRects().length > 0 &&
!element.closest('[data-base-ui-portal], [data-base-ui-focus-guard]')
);
return pageFocusable[pageFocusable.indexOf(searchInput) + step];
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}
const activeElement = document.activeElement as HTMLElement | null;
// Whether or not the popover ever visibly opened, Base UI still keeps a pair
// of hidden, tabbable focus-guard nodes mounted in its portal — reachable by
// Tab regardless of open state, and otherwise a dead end.
const isOnOwnFocusGuard = Boolean(
activeElement?.hasAttribute('data-base-ui-focus-guard') &&
searchPopup.closest('[data-base-ui-portal]')?.contains(activeElement)
);
const popupControls = Array.from(
searchPopup.querySelectorAll<HTMLElement>(SEARCH_POPUP_FOCUSABLE_SELECTOR)
).filter((element) => element.getClientRects().length > 0);
const focusableElements = [searchInput, ...popupControls];
const currentIndex = focusableElements.indexOf(activeElement as HTMLElement);
if (currentIndex === -1 && !isOnOwnFocusGuard) {
// Focus isn't within the search widget — leave native Tab handling alone.
return;
}
if (event.shiftKey && currentIndex === 0) {
// Search input is a regular DOM node, so the browser's native Shift+Tab
// already moves focus to whatever precedes it once we close the popover.
close();
return;
}
if (event.shiftKey && (isOnOwnFocusGuard || currentIndex === -1)) {
event.preventDefault();
close();
findAdjacentPageElement('previous')?.focus();
return;
}
if (
!event.shiftKey &&
(isOnOwnFocusGuard || currentIndex === focusableElements.length - 1)
) {
event.preventDefault();
close();
findAdjacentPageElement('next')?.focus();
return;
}
const nextIndex = event.shiftKey ? currentIndex - 1 : currentIndex + 1;
event.preventDefault();
focusableElements[nextIndex]?.focus();
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [close, searchPopup, usesSideSheet]);
}
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { useIsMounted } from '../hooks/useIsMounted';
import { Button, type ButtonProps } from '../primitives/Button';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
@@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
const pathname = rawPathname ?? '/';
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
// the site API token), so the location is only added once mounted.
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
const isMounted = useIsMounted();
const pathname = isMounted ? (rawPathname ?? '/') : null;
return useMemo(() => {
if (pathname === null) {
return href;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
@@ -3,7 +3,7 @@ import Script from 'next/script';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { CustomizationDefaultThemeMode } from '@gitbook/api';
import { CustomizationDefaultThemeMode, CustomizationPageActionType } from '@gitbook/api';
import { AIContextProvider } from '../AI';
import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -12,6 +12,7 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import { WebMCP } from '@/components/WebMCP/WebMCP';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import type { GitBookSiteContext } from '@/lib/context';
@@ -117,6 +118,9 @@ export async function SiteLayout(props: {
</AIContextProvider>
<LoadIntegrations />
{customization.pageActions.items.includes(CustomizationPageActionType.Mcp) ? (
<WebMCP mcpURL={context.linker.toPathInSite('~gitbook/mcp')} />
) : null}
{scripts.map(({ script }) =>
isDeferrableScript(script) ? (
<Script key={script} src={script} strategy="lazyOnload" />
@@ -1,6 +1,6 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import React from 'react';
import type { PageMetaLinks } from './SitePage';
@@ -33,7 +33,6 @@ export function PageClientLayout({ pageMetaLinks }: { pageMetaLinks: PageMetaLin
* so we need to remove the fallback parameter.
*/
function useStripFallbackQueryParam() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -41,9 +40,14 @@ function useStripFallbackQueryParam() {
if (searchParams?.has('fallback')) {
const params = new URLSearchParams(searchParams.toString());
params.delete('fallback');
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
const query = params.toString();
window.history.replaceState(
null,
'',
`${pathname}${query ? `?${query}` : ''}${window.location.hash}`
);
}
}, [router, pathname, searchParams]);
}, [pathname, searchParams]);
}
/**
@@ -78,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useEffect(() => {
React.useLayoutEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -146,7 +150,7 @@ function useMarkTextOverCover() {
});
};
scheduleUpdate();
update();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import { getPageFullTitle } from './title';
import type { GitBookSiteContext } from '@/lib/context';
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
return {
site: { title: siteTitle },
visibleSections: sectionTitle
? {
current: { title: sectionTitle, default: false },
list: [{ object: 'site-section' }, { object: 'site-section' }],
}
: undefined,
visibleSiteSpaces: [],
} as unknown as GitBookSiteContext;
}
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
return { title, tagTitle } as unknown as RevisionPageDocument;
}
describe('getPageFullTitle', () => {
it('uses tagTitle as the first segment', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
'SEO title | GitBook'
);
});
it('falls back to the page title when tagTitle is absent', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
'Page title | GitBook'
);
});
it('deduplicates the section title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
).toBe('Section | GitBook');
});
it('deduplicates the site title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
).toBe('SEO title');
});
});
@@ -4,16 +4,22 @@ import { notFound, redirect } from 'next/navigation';
import {
CustomizationDefaultThemeMode,
CustomizationHeaderPreset,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
import { IconsProvider } from '@gitbook/icons';
import { PageContextProvider } from '../PageContext';
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
import {
type PagePathParams,
fetchPageData,
getLowercasePathnameRedirect,
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { getPageFullTitle } from './title';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
@@ -27,14 +33,15 @@ import { getResizedImageURL } from '@/lib/images';
import { getPagePath } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import {
getSiteSpacePagePaths,
getSiteStructureTitle,
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getDocumentFilterableTags } from '@/lib/updates';
import { getPageRSSURL } from '@/routes/rss';
import {
generateUpdatesFilterCSS,
getDocumentFilterableTags,
updatesFilterStyleHref,
} from '@/lib/updates';
export { getPageFullTitle } from './title';
export type SitePageProps = {
context: GitBookSiteContext;
@@ -81,6 +88,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
} = await getSitePageData(props);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
const filterableTags = document ? getDocumentFilterableTags(document, context.revision) : [];
const filterableTagSlugs = filterableTags.map((tag) => tag.slug);
const content = (
<>
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
@@ -134,8 +142,10 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
return (
<IconsProvider iconSources={iconSources}>
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{filterableTags.length > 0 ? (
<UpdatesFilterProvider tagSlugs={filterableTags.map((tag) => tag.slug)}>
{filterableTagSlugs.length > 0 ? (
<UpdatesFilterProvider tagSlugs={filterableTagSlugs}>
<UpdatesFilterScript tagSlugs={filterableTagSlugs} />
<UpdatesFilterStyle tagSlugs={filterableTagSlugs} />
{content}
</UpdatesFilterProvider>
) : (
@@ -146,6 +156,22 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
);
}
/**
* Stylesheet that resolves which `updates` entries the active `?tag=` filter shows, purely in CSS
* (see generateUpdatesFilterCSS). Byte-identical for every visitor, so it has no cache impact.
*/
function UpdatesFilterStyle({ tagSlugs }: { tagSlugs: string[] }) {
const css = generateUpdatesFilterCSS(tagSlugs);
if (!css) {
return null;
}
return (
<style href={updatesFilterStyleHref(tagSlugs)} precedence="high">
{css}
</style>
);
}
export async function generateSitePageViewport(context: GitBookSiteContext): Promise<Viewport> {
const { customization } = context;
@@ -220,9 +246,6 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
languages: alternates?.languages,
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
// We always reference the RSS feed even if the page doesn't have updates blocks,
// It might result in 404, but we can't know here if the page has updates blocks.
'application/rss+xml': [{ url: getPageRSSURL(context, page), title: 'RSS Feed' }],
// Currently it will output with an empty "type" like <link rel="alternate" href="..." type />
// Team at Vercel is aware of this and will ensure it will be omitted when the value is empty in future versions of Next.js
// https://gitbook.slack.com/archives/C04K6MV5W1K/p1763034072958419?thread_ts=1762937203.511629&cid=C04K6MV5W1K
@@ -257,15 +280,15 @@ export async function getSitePageData(props: SitePageProps) {
const rawPathname = getPathnameParam(props.pageParams);
if (!pageTarget) {
const pathname = rawPathname.toLowerCase();
if (pathname !== rawPathname) {
const pathname = getLowercasePathnameRedirect(rawPathname);
if (pathname !== null) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(context.linker.toPathInSpace(pathname));
redirect(context.linker.toPathForPagePath({ path: pathname }));
} else {
// If the page is not found and we are in fallback mode, return a redirect to the basepath
if (context.isFallback) {
redirect(context.linker.toPathInSpace('/'));
redirect(context.linker.toPathForPagePath({ path: '/' }));
}
notFound();
}
@@ -396,20 +419,3 @@ async function resolvePageMetaLinks(
alternates: [],
};
}
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}
@@ -16,6 +16,7 @@ import { useSetSearchState } from '../Search';
import { fetchSiteIndex } from '../Search/site-index';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import {
useIsLoggedInVisitor,
useSiteAdaptiveAuthLoginHref,
useSiteIndexURL,
useSpaceBasePath,
@@ -44,6 +45,9 @@ export function SitePageNotFound() {
const setSearchState = useSetSearchState();
const { assistants } = useAI();
const isAuthenticated = useIsLoggedInVisitor();
const copy = getNotFoundCopy({ hasAdaptiveLogin: !!adaptiveAuthLoginHref, isAuthenticated });
// Show the assistant input when a non-search assistant is available (i.e. not just ask-AI).
const assistant = assistants.find((candidate) => candidate.mode !== 'search') ?? null;
const inputLabel = tString(language, assistant ? 'search_or_ask' : 'search');
@@ -124,21 +128,20 @@ export function SitePageNotFound() {
'text-tint-strong'
)}
>
{t(
language,
adaptiveAuthLoginHref
? 'notfound_adaptive_title'
: 'notfound_title'
)}
{t(language, copy.title)}
</h1>
<p className={tcls('text-base', 'text-tint')}>
{t(
language,
adaptiveAuthLoginHref ? 'notfound_adaptive' : 'notfound'
)}
<p className={tcls('text-base', 'text-tint', 'text-center')}>
{t(language, copy.message)}
</p>
</div>
{adaptiveAuthLoginHref ? (
{adaptiveAuthLoginHref && isAuthenticated ? (
// Re-running login refreshes the visitor's claims, e.g. after a role change.
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
variant="secondary"
label={t(language, 'notfound_adaptive_login_again')}
/>
) : adaptiveAuthLoginHref ? (
<div className="flex flex-col items-center gap-4">
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
@@ -190,6 +193,19 @@ export function SitePageNotFound() {
);
}
function getNotFoundCopy(args: { hasAdaptiveLogin: boolean; isAuthenticated: boolean }) {
if (!args.hasAdaptiveLogin) {
return { title: 'notfound_title', message: 'notfound' } as const;
}
if (args.isAuthenticated) {
return {
title: 'notfound_adaptive_no_access_title',
message: 'notfound_adaptive_no_access',
} as const;
}
return { title: 'notfound_adaptive_title', message: 'notfound_adaptive' } as const;
}
/**
* List of related pages, with skeleton rows while they load. Renders nothing once we know
* there are no suggestions.
@@ -0,0 +1,231 @@
import { describe, expect, it, mock } from 'bun:test';
import { CustomizationPageActionType, type RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
mock.module('server-only', () => ({}));
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
await import('./fetch');
const { normalizeURL } = await import('@/lib/data/urls');
const page = {
id: 'page-1',
title: 'Introduction',
kind: 'sheet',
type: 'document',
path: 'readme',
slug: 'readme',
pages: [],
} as RevisionPageDocument;
const git = {
oid: 'abc123',
path: 'README.md',
};
function createContext(options: { gitEnabled: boolean; gitSync?: boolean }) {
const getRevisionPageByPath = mock(async () => ({
data: {
...page,
git,
},
}));
const context = {
revision: { pages: [page] },
revisionId: 'revision-1',
customization: {
pageActions: {
items: options.gitEnabled ? [CustomizationPageActionType.Git] : [],
},
},
space: {
id: 'space-1',
gitSync:
options.gitSync === false
? undefined
: {
url: 'https://github.com/gitbook/example/tree/main',
},
},
dataFetcher: { getRevisionPageByPath },
} as unknown as GitBookSiteContext;
return { context, getRevisionPageByPath };
}
describe('fetchPageData', () => {
it('fetches the Git metadata for an enabled Edit on Git action', async () => {
const { context, getRevisionPageByPath } = createContext({ gitEnabled: true });
const result = await fetchPageData(context, { pageId: page.id });
expect(getRevisionPageByPath).toHaveBeenCalledWith({
spaceId: 'space-1',
revisionId: 'revision-1',
path: 'readme',
metadata: true,
cachedMetadata: true,
});
expect(result.pageTarget?.page.git).toEqual(git);
expect(result.context.page?.git).toEqual(git);
});
it('does not fetch Git metadata when the action is disabled', async () => {
const { context, getRevisionPageByPath } = createContext({ gitEnabled: false });
const result = await fetchPageData(context, { pageId: page.id });
expect(getRevisionPageByPath).not.toHaveBeenCalled();
expect(result.pageTarget?.page.git).toBeUndefined();
});
it('does not fetch Git metadata without Git Sync', async () => {
const { context, getRevisionPageByPath } = createContext({
gitEnabled: true,
gitSync: false,
});
const result = await fetchPageData(context, { pageId: page.id });
expect(getRevisionPageByPath).not.toHaveBeenCalled();
expect(result.pageTarget?.page.git).toBeUndefined();
});
});
describe('resolveMissingPagePath', () => {
function createRedirectContext(options: {
siteRedirect?: { source?: string; target: string; permanent?: boolean };
spaceRedirectPageId?: string;
}) {
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
options.siteRedirect && source === (options.siteRedirect.source ?? '/old')
? {
data: {
target: options.siteRedirect.target,
redirect: { permanent: options.siteRedirect.permanent ?? false },
},
}
: { error: { code: 404, message: 'Not found' } }
);
const getRevisionPageByPath = mock(async () =>
options.spaceRedirectPageId
? { data: { id: options.spaceRedirectPageId } }
: { error: { code: 404, message: 'Not found' } }
);
return {
organizationId: 'org-1',
site: { id: 'site-1' },
space: { id: 'space-1', revision: 'revision-1' },
revisionId: 'revision-1',
revision: { pages: [page] },
linker: {
toPathInSpace: (path: string) => path,
toRelativePathInSite: (path: string) => path,
toLinkForContent: (url: string) => new URL(url).pathname,
},
dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath },
} as unknown as GitBookSiteContext;
}
it('resolves a site redirect', async () => {
const context = createRedirectContext({
siteRedirect: { target: 'https://docs.example.com/new', permanent: true },
});
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'redirect',
destination: '/new',
permanent: true,
});
});
it('resolves a site redirect whose source has non-ASCII characters', async () => {
const context = createRedirectContext({
siteRedirect: {
source: '/de/kategorien-hinzuf%C3%BCgen',
target: 'https://docs.example.com/new',
},
});
const pathname = normalizeURL(
new URL('https://docs.example.com/de/kategorien-hinzufügen')
).pathname.slice(1);
expect(await resolveMissingPagePath(context, pathname)).toEqual({
type: 'redirect',
destination: '/new',
permanent: false,
});
});
it('resolves a space redirect to a page', async () => {
const context = createRedirectContext({ spaceRedirectPageId: page.id });
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'page',
page: { page, ancestors: [] },
});
});
it('returns undefined when nothing matches', async () => {
const context = createRedirectContext({});
expect(await resolveMissingPagePath(context, 'old')).toBeUndefined();
});
});
describe('getLowercasePathnameRedirect', () => {
it('redirects ASCII paths with uppercase letters', () => {
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
});
it('does not redirect lowercase paths', () => {
expect(getLowercasePathnameRedirect('foo/cafe')).toBeNull();
});
it('does not redirect when only percent-encoded hex digits are uppercase', () => {
expect(getLowercasePathnameRedirect('foo/caf%C3%A9')).toBeNull();
expect(getLowercasePathnameRedirect('foo/caf%c3%a9')).toBeNull();
});
it('lowercases encoded non-ASCII letters', () => {
// É -> é
expect(getLowercasePathnameRedirect('foo/%C3%89')).toBe('foo/%C3%A9');
});
it('does not redirect paths that fail to decode', () => {
expect(getLowercasePathnameRedirect('Foo/%E0%A4%A')).toBeNull();
});
it('redirects to a pathname the middleware leaves unchanged', () => {
const normalize = (pathname: string) =>
normalizeURL(new URL(`https://example.com/${pathname}`)).pathname.slice(1);
const paths = [
'Foo/Bar',
'Foo/caf%C3%A9',
'foo/%C3%89',
'Video/porte%C3%91o-x.html',
'%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82', // Привет
'%CE%95%CE%BB%CE%BB%CE%AC%CE%B4%CE%B1', // Ελλάδα
'%C3%96sterreich/Stra%C3%9FE', // Österreich/StraßE
'%C4%B0stanbul', // İstanbul, lowercases to two code points
'Foo:Bar',
'Foo@Bar+Baz',
'Brack[et]',
'Q%3FX',
'Hash%23Y',
'Sp%20Ace',
];
for (const path of paths) {
const target = getLowercasePathnameRedirect(path);
expect(target).not.toBeNull();
expect(normalize(target!)).toBe(target!);
expect(getLowercasePathnameRedirect(target!)).toBeNull();
}
});
});
+168 -62
View File
@@ -1,13 +1,15 @@
import { permanentRedirect, redirect } from 'next/navigation';
import {
CustomizationPageActionType,
type RevisionPageDocument,
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
SITE_REDIRECT_SOURCE_PATH_PATTERN,
} from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull } from '@/lib/data';
import { resolvePageId } from '@/lib/pages';
import { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
import { resolveSiteSpacePagePath } from '@/lib/sites';
@@ -26,7 +28,34 @@ export type PageParams = PagePathParams | PageIdParams;
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(context: GitBookSiteContext, params: PageParams) {
const pageTarget = await resolvePage(context, params);
let pageTarget = await resolvePage(context, params);
// Revision trees omit metadata for cache efficiency, so load it only when this action needs the Git path.
if (
pageTarget &&
!pageTarget.page.git &&
context.space.gitSync?.url &&
context.customization.pageActions.items.includes(CustomizationPageActionType.Git)
) {
const response = await context.dataFetcher.getRevisionPageByPath({
spaceId: context.space.id,
revisionId: context.revisionId,
path: pageTarget.page.path,
metadata: true,
cachedMetadata: true,
});
const pageWithMetadata = response.data;
if (pageWithMetadata?.type === 'document' && pageWithMetadata.git) {
pageTarget = {
...pageTarget,
page: {
...pageTarget.page,
git: pageWithMetadata.git,
},
};
}
}
return {
context: {
@@ -42,7 +71,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
const { revision } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -57,77 +86,108 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
return page;
}
const fallback = await resolveMissingPagePath(context, rawPathname);
if (fallback?.type === 'redirect') {
return fallback.permanent
? permanentRedirect(fallback.destination)
: redirect(fallback.destination);
}
return fallback?.page;
}
export type MissingPagePathResolution =
| {
type: 'redirect';
/** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */
destination: string;
permanent: boolean;
}
| {
type: 'page';
page: ResolvedPagePath<RevisionPageDocument>;
};
/**
* Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects.
*/
export async function resolveMissingPagePath(
context: GitBookSiteContext,
rawPathname: string
): Promise<MissingPagePathResolution | undefined> {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
return undefined;
}
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
if (
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
return {
type: 'redirect',
destination: linker.toLinkForContent(resolvedSiteRedirect.target),
permanent: Boolean(
resolvedSiteRedirect.redirect?.permanent &&
!resolvedSiteRedirect.redirect.draft &&
isPublicLiveContext
) {
return permanentRedirect(destination);
}
return redirect(destination);
}
}
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
),
};
}
}
return undefined;
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined;
return page ? { type: 'page', page } : undefined;
}
/**
* Transform a pathname into a candidate source for site redirect matching.
* We also encode each segment to handle special characters in redirects.
* The pathname is already percent-encoded, so each segment is decoded before being encoded
* again: redirect sources store special characters percent-encoded exactly once.
*/
function toSiteRedirectSourceCandidate(pathname: string): string {
const normalized = withLeadingSlash(pathname);
@@ -135,11 +195,19 @@ function toSiteRedirectSourceCandidate(pathname: string): string {
normalized
.slice(1)
.split('/')
.map((segment) => encodeURIComponent(segment))
.map((segment) => encodeURIComponent(safeDecodeURIComponent(segment)))
.join('/')
);
}
function safeDecodeURIComponent(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
/**
* Get the page path from the params.
*/
@@ -156,3 +224,41 @@ export function getPathnameParam(params: PagePathParams): string {
return pathname.map((part) => decodeURIComponent(part)).join('/');
}
/**
* Get the lowercased pathname to redirect a missing page to, or `null` if there is none.
* The pathname is percent-encoded, so lowercase its decoded form and re-encode it canonically:
* any other encoding would make the middleware redirect again, or loop.
*/
export function getLowercasePathnameRedirect(rawPathname: string): string | null {
let changed = false;
const segments: string[] = [];
for (const segment of rawPathname.split('/')) {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return null;
}
const lowercased = decoded.toLowerCase();
changed ||= lowercased !== decoded;
segments.push(lowercased);
}
if (!changed) {
return null;
}
return encodeURLPathname(segments.join('/')).slice(1);
}
/**
* Percent-encode a decoded pathname the way the URL parser does, the canonical form `normalizeURL` produces.
*/
function encodeURLPathname(pathname: string): string {
const url = new URL('https://gitbook.invalid');
url.pathname = pathname;
return url.pathname;
}
@@ -0,0 +1,23 @@
import type { RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteStructureTitle } from '@/lib/sites';
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const tagTitle = page.tagTitle || page.title;
return [
tagTitle,
// The first page of a section is often the same as the section title, so we don't need to show it.
tagTitle !== siteStructureTitle ? siteStructureTitle : null,
// The site title can also be the same as the page title on the site's landing page.
tagTitle !== site.title ? site.title : null,
]
.filter(Boolean)
.join(' | ');
}
@@ -201,7 +201,7 @@ function getTargetURLForSection(
return getSectionURL(context, section);
}
function getTargetURLForSiteSpace(
export function getTargetURLForSiteSpace(
context: GitBookSiteContext,
siteSpace: SiteSpace,
asEmbeddable: boolean
@@ -5,7 +5,7 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AIChat, AskAIParagraphButton, AskAITextSelection } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { CurrentContentProvider } from '../hooks';
@@ -58,20 +58,21 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
? context.linker.toPathInSite('~gitbook/auth/login')
: null;
const eventUrl = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('/~gitbook/__evt'))
);
eventUrl.searchParams.set('o', context.organizationId);
eventUrl.searchParams.set('s', context.site.id);
// Kept relative: a prerendered page has no request to read the host from, so an absolute URL
// pins the configured host and turns these fetches cross-origin when it differs (apex vs www).
const eventParams = new URLSearchParams({
o: context.organizationId,
s: context.site.id,
});
const eventUrl = `${context.linker.toPathInSite('/~gitbook/__evt')}?${eventParams}`;
const getVisitorClaimsUrl = context.linker.toAbsoluteURL(
context.linker.toPathInSite('/~gitbook/visitor')
);
const getVisitorClaimsUrl = context.linker.toPathInSite('/~gitbook/visitor');
return (
<SpaceLayoutContextProvider
basePath={context.linker.toPathInSpace('')}
siteAdaptiveAuthLoginHref={siteAdaptiveAuthLoginHref}
isLoggedInVisitor={context.isLoggedInVisitor}
siteIndexURL={context.linker.toPathInSite('~gitbook/site-index')}
>
<AdaptiveVisitorContextProvider
@@ -92,7 +93,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
appURL={GITBOOK_APP_URL}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl}>
<AIChatProvider
renderMessageOptions={aiChatRenderMessageOptions}
withPageFeedback={customization.feedback.enabled}
@@ -137,6 +138,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<>
<AIChat />
<AskAITextSelection />
<AskAIParagraphButton />
</>
) : null}
@@ -5,6 +5,7 @@ import React from 'react';
const SpaceLayoutContext = React.createContext({
basePath: '',
siteAdaptiveAuthLoginHref: null as string | null,
isLoggedInVisitor: false,
siteIndexURL: '',
});
@@ -15,14 +16,21 @@ export function SpaceLayoutContextProvider(
props: React.PropsWithChildren<{
basePath: string;
siteAdaptiveAuthLoginHref?: string | null;
isLoggedInVisitor?: boolean;
siteIndexURL: string;
}>
) {
const { basePath, siteAdaptiveAuthLoginHref = null, siteIndexURL, children } = props;
const {
basePath,
siteAdaptiveAuthLoginHref = null,
isLoggedInVisitor = false,
siteIndexURL,
children,
} = props;
const value = React.useMemo(
() => ({ basePath, siteAdaptiveAuthLoginHref, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, siteIndexURL]
() => ({ basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL]
);
return <SpaceLayoutContext.Provider value={value}>{children}</SpaceLayoutContext.Provider>;
@@ -50,6 +58,17 @@ export function useSiteAdaptiveAuthLoginHref() {
return context.siteAdaptiveAuthLoginHref;
}
/**
* Return whether the visitor has a visitor auth token, with or without claims.
*/
export function useIsLoggedInVisitor() {
const context = React.useContext(SpaceLayoutContext);
if (!context) {
throw new Error('SpaceLayoutContext not found');
}
return context.isLoggedInVisitor;
}
/**
* Return the URL of the site search index (`~gitbook/site-index`).
*/
@@ -29,7 +29,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
'bg-tint-base',
@@ -71,7 +72,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
{hasDescendants ? (
<span
className={tcls(
'toc-group-chevron ml-auto flex shrink-0 transition-opacity duration-150',
'toc-group-chevron ml-auto mr-1 flex shrink-0 transition-opacity duration-150',
isOpen
? 'pointer-events-none opacity-0 delay-75'
: 'opacity-6 delay-0'
@@ -134,6 +134,7 @@ export async function TableOfContents(props: {
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
followActive
leading={{
fade: true,
button: {
@@ -165,7 +165,7 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () =
iconOnly
variant="blank"
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
className="-my-0.5 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action.
/>
);
@@ -19,7 +19,7 @@ export async function Trademark(
const { space } = context;
const language = await getSpaceLanguage(context);
const url = new URL('https://www.gitbook.com');
const url = new URL('https://www.gitbook.com/powered-by');
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'trademark');
url.searchParams.set('utm_campaign', space.id);
@@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
// Subset of the WebMCP `ModelContext` interface (https://webmachinelearning.github.io/webmcp/).
type ModelContext = {
registerTool: (
tool: {
name: string;
description: string;
inputSchema?: object;
execute: (input: object, options?: { signal?: AbortSignal }) => Promise<unknown>;
},
options?: { signal?: AbortSignal }
) => Promise<void>;
};
/**
* Expose the site's MCP tools (`~gitbook/mcp`) to browser agents through WebMCP, so anything
* added to the server is automatically available to them. Renders nothing.
*/
export function WebMCP(props: { mcpURL: string }) {
const { mcpURL } = props;
React.useEffect(() => {
const modelContext = (document as { modelContext?: ModelContext }).modelContext;
if (!modelContext) {
return;
}
// Aborting unregisters the tools and discards a load still in flight.
const controller = new AbortController();
const { signal } = controller;
(async () => {
// The MCP SDK is imported lazily: only agentic browsers pay for it.
const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js'),
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
]);
// Tagged so WebMCP calls are distinguishable in insights (the request URL is tracked).
const url = new URL(mcpURL, window.location.href);
url.searchParams.set('client', 'webmcp');
const client = new Client({ name: 'gitbook-webmcp', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(url));
const { tools } = await client.listTools();
if (signal.aborted) {
return;
}
for (const tool of tools) {
// Answer synthesis takes 20-30s and browser agents abort tool calls around 30s.
if (tool.name === 'askQuestion') {
continue;
}
await modelContext.registerTool(
{
name: tool.name,
description: tool.description ?? tool.name,
inputSchema: tool.inputSchema,
// The MCP result (`content` blocks, plus `isError` on failure) is passed through.
execute: (input, options) =>
client.callTool(
{ name: tool.name, arguments: input as Record<string, unknown> },
undefined,
{ signal: options?.signal }
),
},
{ signal }
);
}
})().catch((error) => {
// oxlint-disable-next-line no-console
console.warn('WebMCP: could not expose the site MCP tools', error);
});
return () => controller.abort();
}, [mcpURL]);
return null;
}
@@ -15,11 +15,12 @@ export function useScrollActiveId(
enabled: boolean;
} = { enabled: true }
): string | undefined {
const [activeId, setActiveId] = React.useState<string | undefined>(ids[0]);
const [activeId, setActiveId] = React.useState<string | undefined>(undefined);
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
React.useEffect(() => {
const defaultActiveId = ids[0];
const defaultActiveId =
ids.find((id) => document.getElementById(id)?.offsetParent !== null) ?? ids[0];
sectionsIntersectingMap.current.clear();
setActiveId((activeId) =>
activeId !== undefined && ids.includes(activeId) ? activeId : defaultActiveId
@@ -1,4 +1,5 @@
'use client';
import { useTrackEvent } from '../Insights';
import { Button, type ButtonProps } from './Button';
/**
@@ -7,13 +8,17 @@ import { Button, type ButtonProps } from './Button';
export function DownloadButton(
props: Omit<ButtonProps, 'onClick' | 'href'> & { downloadUrl: string; filename: string }
) {
const { downloadUrl, filename, ...buttonProps } = props;
const { downloadUrl, filename, insights, ...buttonProps } = props;
const trackEvent = useTrackEvent();
return (
<Button
{...buttonProps}
onClick={(e) => {
e.preventDefault();
if (insights) {
trackEvent(insights);
}
void forceDownload(downloadUrl, filename);
}}
/>
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item when it's initially active.
* - Auto-scroll to the active item on mount and when it changes.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
@@ -42,6 +42,12 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/**
* Only scroll to the active item when it is not fully visible, and keep following it
* when it changes later (requires `active` to be a selector).
*/
followActive?: boolean;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -53,6 +59,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
followActive = false,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
@@ -80,8 +87,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
if (!activeItem || !container.contains(activeItem)) {
return;
}
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
return;
}
scrollToElementInContainer(activeItem, container);
}, [active]);
}, [active, followActive]);
React.useEffect(() => {
const container = containerRef.current;
if (!followActive || !container || typeof active !== 'string') {
return;
}
let frame = 0;
let previousActiveItems = new Set(container.querySelectorAll(active));
// Active items can mount only after a collapsed group expands.
const observer = new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const activeItems = new Set(container.querySelectorAll(active));
const newActiveItems = [...activeItems].filter(
(item) => !previousActiveItems.has(item)
);
previousActiveItems = activeItems;
// Only follow newly active items so unrelated changes (e.g. expanding another
// group) don't pull the user back to the current page.
for (const activeItem of newActiveItems) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
}
}
});
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-active'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [active, followActive]);
const scrollFurther = () => {
const container = containerRef.current;
@@ -335,7 +386,11 @@ function scrollByViewport(
/**
* Scroll to an element in a container.
*/
function scrollToElementInContainer(element: Element, container: HTMLElement) {
export function scrollToElementInContainer(
element: Element,
container: HTMLElement,
behavior: ScrollBehavior = 'auto'
) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -350,8 +405,26 @@ function scrollToElementInContainer(element: Element, container: HTMLElement) {
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
behavior,
});
}
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
if (
!element.getClientRects().length ||
container.clientHeight === 0 ||
container.clientWidth === 0
) {
return true;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
);
}
@@ -3,15 +3,13 @@ import assertNever from 'assert-never';
/**
* Returns the URL to open the page in a LLM with a pre-filled prompt.
*/
export function getURLForLLM(provider: 'chatgpt' | 'claude' | 'cursor', prompt: string) {
export function getURLForLLM(provider: 'chatgpt' | 'claude', prompt: string) {
const encodedPrompt = encodeURIComponent(prompt);
switch (provider) {
case 'chatgpt':
return `https://chat.openai.com/?q=${encodedPrompt}`;
case 'claude':
return `https://claude.ai/new?q=${encodedPrompt}`;
case 'cursor':
return `https://cursor.com/link/prompt?text=${encodedPrompt}`;
default:
assertNever(provider);
}
+9 -2
View File
@@ -11,6 +11,8 @@ export const ar: TranslationLanguage = {
switch_to_system_theme: 'التبديل إلى سمة النظام',
search: 'بحث',
clear: 'مسح',
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
table_clear_selection: 'مسح الاختيار',
tags: 'الوسوم',
search_back: 'العودة إلى نتائج البحث',
search_or_ask: 'اسأل أو ابحث',
@@ -75,10 +77,14 @@ export const ar: TranslationLanguage = {
notfound_title: 'الصفحة غير موجودة',
notfound_link: 'يشير هذا الرابط إلى صفحة تمت إزالتها أو لم تعد موجودة.',
notfound: 'الصفحة التي تبحث عنها غير موجودة.',
notfound_adaptive_title: 'الصفحة غير متاحة',
notfound_adaptive: 'قد تكون هذه الصفحة موجودة، لكن قد تحتاج إلى تسجيل الدخول للوصول إليها.',
notfound_adaptive_title: 'سجّل الدخول لعرض هذه الصفحة',
notfound_adaptive: 'قد تتطلب هذه الصفحة تسجيل الدخول. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_login: 'تسجيل الدخول',
notfound_adaptive_registration_hint: 'قد يكون التسجيل متاحًا حسب المؤسسة.',
notfound_adaptive_no_access_title: 'قد لا تملك صلاحية الوصول إلى هذه الصفحة',
notfound_adaptive_no_access:
'قد لا تكون هذه الصفحة موجودة، أو قد لا يملك حسابك صلاحية الوصول إليها. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_login_again: 'تسجيل الدخول مجددًا',
notfound_goto_home: 'الانتقال إلى الصفحة الرئيسية',
notfound_suggestions_title: 'ربما تبحث عن',
unexpected_error_title: 'حدث خطأ',
@@ -148,6 +154,7 @@ export const ar: TranslationLanguage = {
ai_chat_tools_submit_feedback: 'إرسال الملاحظات',
ai_chat_tools_submit_assistant_feedback: 'قيّم رسالة المساعد السابقة بأنها ${1}',
ai_chat_tools_submitted_feedback: 'تم إرسال ملاحظاتك',
ai_chat_tools_shared_feedback_with_team: 'تم إرسال الملاحظات إلى فريق التوثيق',
ai_chat_tools_mcp_tool: 'تم استدعاء ${1}',
ai_chat_ask: 'اسأل ${1}',
ai_chat_ask_about: 'اسأل ${1} عن ${2}',

Some files were not shown because too many files have changed in this diff Show More