Compare commits

..

1 Commits

Author SHA1 Message Date
Nicolas Dorseuil b307b09acd Add urlLookup parameter to restrict URL lookup alternatives 2026-07-28 13:00:10 +02:00
95 changed files with 291 additions and 1781 deletions
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add an assistant tool to rate its own previous response when the user reacts to it.
-7
View File
@@ -1,7 +0,0 @@
---
"gitbook": patch
---
Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Persist content selection (tabs and other `select` blocks) in localStorage only, dropping the `?select=` query parameter from the URL. A tab click still writes the tab's hash, so a copied URL lands on that tab and reactivates it on load.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve cookie handling in the OpenAPI "Test it" request proxy.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Stepper heading alignment and overflow-menu separator on mobile
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Make the table search "no results" empty state more prominent with vertical spacing so it no longer blends into the content below.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add the `select` action to InlineButton. Clicking the button activates its slug, so any block containing that slug switches to it.
+2 -2
View File
@@ -360,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.192.0",
"@gitbook/api": "0.191.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -756,7 +756,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.192.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-S9ceF3XspgSqXb7JH5feSHSytVkq1jRDwOKeEK4nnZs9pSiKFxHZMj/LnHi8k8anaue/FeLwWYo+Vk08Pdl0+g=="],
"@gitbook/api": ["@gitbook/api@0.191.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Bizv/lGBeUqUCajS4AOu9uAtc9mDJBUh6BjEmgoCzolxVAa68rW/T4GugyiRl0JrAKIimLVmkYCPevPzXa6lLg=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.192.0",
"@gitbook/api": "0.191.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
-238
View File
@@ -1,238 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
// that the store pulls in — Playwright's loader doesn't resolve it.
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
/**
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
* group must show exactly the most-recently-activated of its options (its "first ranking"
* selection), falling back to its default when none are active. These run in a real browser against
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
*/
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
async function renderGroup(page: Page, slugs: string[]) {
const css = generateSelectCSS(slugs);
const scope = selectSetClassName(slugs);
const panes = slugs
.map(
(slug, index) =>
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
await page.setContent(
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
);
}
/**
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
*/
async function applySelection(page: Page, active: string[]) {
for (const [rank, value] of active.entries()) {
await page.evaluate(
({ attr, value }) => document.documentElement.setAttribute(attr, value),
{ attr: selectRankAttribute(rank), value }
);
}
}
/** Assert exactly one pane is visible, and it is the expected slug. */
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
for (const slug of slugs) {
const pane = page.getByTestId(`pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
async function setup(page: Page, slugs: string[], active: string[]) {
await renderGroup(page, slugs);
await applySelection(page, active);
}
test.describe('select CSS visibility', () => {
const slugs = ['python', 'go', 'java'];
test('shows the default when nothing is selected', async ({ page }) => {
await setup(page, slugs, []);
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
});
test('shows the selected option and hides the rest', async ({ page }) => {
await setup(page, slugs, ['go']);
await expectOnlyVisible(page, slugs, 'go');
});
test('shows the most-recently-activated option of the group', async ({ page }) => {
// Recency list is most-recent-first: `go` is more recent than `python`.
await setup(page, slugs, ['go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
await setup(page, slugs, ['python', 'go']);
await expectOnlyVisible(page, slugs, 'python');
});
test('ignores more-recent selections that are not in the group', async ({ page }) => {
// `dark` is more recent but not one of this group's options, so `go` still wins.
await setup(page, slugs, ['dark', 'go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
});
test('falls back to the default when no active slug is in the group', async ({ page }) => {
await setup(page, slugs, ['dark', 'light']);
await expectOnlyVisible(page, slugs, 'python');
});
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
page,
}) => {
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
// selectors without collapsing together.
const symbols = ['c', 'c++', 'c#'];
await setup(page, symbols, ['c++']);
await expectOnlyVisible(page, symbols, 'c++');
});
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
page,
}) => {
// Two panes share the slug `js`; activating it must reveal only the first, never both.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-first')).toBeVisible();
await expect(page.getByTestId('js-second')).toBeHidden();
await expect(page.getByTestId('ts')).toBeHidden();
});
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
page,
}) => {
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
// the pinned one must win over the first-match default.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-second')).toBeVisible();
await expect(page.getByTestId('js-first')).toBeHidden();
});
});
interface GroupSpec {
id: string;
slugs: string[];
}
/**
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
* sees — a real click switching every group that offers that option — driven by real browser CSS.
*/
async function renderGroups(page: Page, groups: GroupSpec[]) {
const styles = [
...new Map(
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
).values(),
]
.map((css) => `<style>${css}</style>`)
.join('');
const markup = groups
.map((group) => {
const scope = selectSetClassName(group.slugs);
const buttons = group.slugs
.map(
(slug) =>
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
)
.join('');
const panes = group.slugs
.map(
(slug, index) =>
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
})
.join('');
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
await page.setContent(
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
);
}
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
async function expectGroupShows(
page: Page,
groupId: string,
slugs: string[],
expectedSlug: string
) {
for (const slug of slugs) {
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
test.describe('select syncing across groups (click-driven)', () => {
test('clicking a tab syncs every group offering that option', async ({ page }) => {
const slugs = ['python', 'go'];
await renderGroups(page, [
{ id: 'a', slugs },
{ id: 'b', slugs },
]);
// Both groups start on their default (first) pane.
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
// Clicking a tab in group A switches group B too.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', slugs, 'go');
await expectGroupShows(page, 'b', slugs, 'go');
// And the sync works from either group.
await page.getByTestId('b-btn-python').click();
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
});
test('only groups that share the clicked option follow along', async ({ page }) => {
const shared = ['python', 'go'];
const other = ['go', 'rust'];
await renderGroups(page, [
{ id: 'a', slugs: shared },
{ id: 'b', slugs: other },
]);
// `rust` exists only in group B, so clicking it leaves group A on its default.
await page.getByTestId('b-btn-rust').click();
await expectGroupShows(page, 'b', other, 'rust');
await expectGroupShows(page, 'a', shared, 'python');
// `go` is shared, so clicking it in A moves both groups.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', shared, 'go');
await expectGroupShows(page, 'b', other, 'go');
});
});
+1 -1
View File
@@ -132,7 +132,7 @@
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
@@ -25,10 +25,6 @@ import { type RenderAIMessageOptions, streamAIChatResponse } from './server-acti
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import {
type ResponseToRate,
useSubmitAssistantFeedbackTool,
} from './useSubmitAssistantFeedbackTool';
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
export type AIChatMessage = {
@@ -238,35 +234,23 @@ export function AIChatProvider(props: {
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
const displayContext = renderMessageOptions?.asEmbeddable
? SiteInsightsDisplayContext.Embed
: SiteInsightsDisplayContext.Site;
// The assistant response the user is reacting to. Snapshotted when a new user turn begins
// (before it overwrites the store's responseId/query), so the self-feedback tool rates that
// previous response rather than the one this reaction turn produces.
const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null });
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
// 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.
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page
// feedback). Each tool has a stable identity, so it can be referenced directly from the
// streaming callback.
const navigateToPageTool = useNavigateToPageTool();
const submitPageFeedbackTool = useSubmitPageFeedbackTool({ displayContext });
const submitAssistantFeedbackTool = useSubmitAssistantFeedbackTool({
displayContext,
getResponseToRate,
const submitPageFeedbackTool = useSubmitPageFeedbackTool({
displayContext: renderMessageOptions?.asEmbeddable
? SiteInsightsDisplayContext.Embed
: SiteInsightsDisplayContext.Site,
});
// 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.
const builtInTools = React.useMemo(() => {
const tools = [navigateToPageTool, submitAssistantFeedbackTool];
if (withPageFeedback) {
tools.push(submitPageFeedbackTool);
}
return tools;
}, [navigateToPageTool, submitAssistantFeedbackTool, submitPageFeedbackTool, withPageFeedback]);
// Only expose the submit-feedback tool when the site has page feedback enabled, mirroring the
// "Was this helpful?" widget's visibility.
const builtInTools = React.useMemo(
() =>
withPageFeedback ? [navigateToPageTool, submitPageFeedbackTool] : [navigateToPageTool],
[navigateToPageTool, submitPageFeedbackTool, withPageFeedback]
);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
@@ -652,14 +636,6 @@ export function AIChatProvider(props: {
return;
}
// Snapshot the response the user is reacting to before this turn overwrites the store's
// query/responseId, so the self-feedback tool rates the previous answer. `query` and
// `responseId` here still describe the last completed turn.
const { responseId: previousResponseId } = globalState.getState();
if (previousResponseId) {
responseToRateRef.current = { responseId: previousResponseId, query };
}
trackEvent({ type: 'ask_question', query: input.message });
// Add user message and placeholder for AI response
@@ -706,7 +682,6 @@ export function AIChatProvider(props: {
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
responseToRateRef.current = { responseId: null, query: null };
globalState.setState((state) => ({
opened: state.opened,
responding: false,
@@ -1,125 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import type { AIToolDefinition, SiteInsightsDisplayContext } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
import { type PagePointer, useCurrentPage } from '../hooks';
/** The assistant response the user is reacting to, which the tool rates. */
export type ResponseToRate = {
responseId: string | null;
query: string | null;
};
const SubmitAssistantFeedbackInputSchema = z.object({
rating: z.enum(['good', 'bad']).describe(
`How the user rated your previous response:
- 'good' when they express it helped or that they are satisfied.
- 'bad' when they indicate it was wrong, unhelpful, incomplete, or otherwise problematic.`
),
});
/**
* Build the built-in `submitAssistantFeedback` tool exposed to the assistant.
*
* The tool records the user's rating of the assistant's own previous response, reusing the same
* `ask_rate_response` insights signal as the thumbs up/down control shown under each answer. It
* rates the response the user is reacting to — read from a snapshot taken before the current
* (reaction) turn started, since by tool-execution time the store already holds this turn's
* response and query. Because it acts on the user's behalf, it asks for confirmation first.
*/
export function useSubmitAssistantFeedbackTool(options: {
/** Display context recorded with the rating event (e.g. `site` vs. `embed`). */
displayContext: SiteInsightsDisplayContext;
/** Read the previous response to rate, resolved at tool-execution time. */
getResponseToRate: () => ResponseToRate;
}): GitBookIntegrationTool {
const { displayContext, getResponseToRate } = options;
const trackEvent = useTrackEvent();
const language = useLanguage();
const currentPage = useCurrentPage();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef<{
trackEvent: typeof trackEvent;
language: typeof language;
currentPage: PagePointer | null;
displayContext: SiteInsightsDisplayContext;
getResponseToRate: () => ResponseToRate;
}>({ trackEvent, language, currentPage, displayContext, getResponseToRate });
React.useEffect(() => {
ref.current = { trackEvent, language, currentPage, displayContext, getResponseToRate };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'submitAssistantFeedback',
description:
"Record the user's rating of your own previous response, reusing the same signal as the thumbs up/down control shown under each answer. Use this when the user reacts to how you answered — telling you it was wrong, unhelpful, or incomplete (rate 'bad'), or that it helped (rate 'good'). This is about your response, not the page's content — use submitPageFeedback for feedback about the documentation page itself. Only use it when the user clearly expresses such a sentiment, not for every follow-up or minor correction. The user will be asked to confirm before the rating is recorded.",
confirmation: (input) => {
const parsed = SubmitAssistantFeedbackInputSchema.safeParse(input);
const rating = parsed.success ? parsed.data.rating : undefined;
return {
icon: rating === 'good' ? 'thumbs-up' : 'thumbs-down',
label: tString(language, 'ai_chat_tools_submit_feedback'),
context: tString(
language,
'ai_chat_tools_submit_assistant_feedback',
(rating === 'good'
? tString(language, 'was_this_helpful_positive_label')
: tString(language, 'was_this_helpful_negative_label')
).toLocaleLowerCase()
),
};
},
inputSchema: zodToJsonSchema(
SubmitAssistantFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { trackEvent, language, currentPage, displayContext, getResponseToRate } =
ref.current;
const { rating } = SubmitAssistantFeedbackInputSchema.parse(input);
const { responseId, query } = getResponseToRate();
if (!responseId || !query) {
throw new Error('There is no previous response to rate.');
}
// Attribute to the current page with an explicit context so the event still flushes
// on pathnames whose ambient insights context has no page (e.g. the embed's
// assistant tab), matching the submit-page-feedback tool.
const pageContext: InsightsEventPageContext = {
pageId: currentPage?.pageId ?? null,
displayContext,
};
trackEvent(
{
type: 'ask_rate_response',
query,
responseId,
rating: rating === 'good' ? 1 : -1,
},
pageContext,
{ immediate: true }
);
return {
output: { submitted: true, rating, responseId },
summary: {
icon: rating === 'good' ? 'thumbs-up' : 'thumbs-down',
text: tString(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]
);
}
@@ -36,7 +36,7 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
return (
<div
className={tcls(
'grid w-full @2xl:grid-flow-col @2xl:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] grid-cols-1 @2xl:grid-rows-1 gap-x-8 gap-y-4',
'grid w-full grid-cols-1 gap-x-8 gap-y-4 md:grid-flow-col md:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] md:grid-rows-1',
style
)}
style={{ '--grid-slices': COLUMN_DIVISIONS } as React.CSSProperties}
@@ -102,7 +102,7 @@ export function transformLengthToCSS(length: Length) {
}
if (length.unit === '%') {
return {
className: '@2xl:flex-shrink-0 @2xl:[grid-column:var(--grid-col)]',
className: 'md:flex-shrink-0 md:[grid-column:var(--grid-col)]',
style: {
'--grid-col': `auto / span ${Math.round(length.value * 0.01 * COLUMN_DIVISIONS)}`,
} as React.CSSProperties,
@@ -8,8 +8,6 @@ import { Button, type ButtonProps } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
import { SelectActionButton } from './SelectActionButton';
import { getSelectAction } from './selectAction';
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
const BUTTON_SIZE_MAP: Record<
@@ -32,13 +30,6 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
};
const ButtonImplementation = () => {
// Skip the select action in print/PDF: the client store isn't mounted, so it falls through
// to the plain disabled button below.
const selectAction = context.mode !== 'print' ? getSelectAction(inline.data) : null;
if (selectAction) {
return <SelectActionButton value={selectAction.value} buttonProps={buttonProps} />;
}
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
return (
@@ -1,22 +0,0 @@
'use client';
import { useSelect } from '@/components/Select';
import { slugifySelectValue } from '@/lib/select';
import { Button, type ButtonProps } from '../primitives';
/**
* Renders a "Select" button action: clicking it activates the slug derived from `value`, so any
* block containing that slug (tabs, and future consumers) switches to it. This is what turns cards
* into content switchers.
*/
export function SelectActionButton(props: { value: string; buttonProps: ButtonProps }) {
const { value, buttonProps } = props;
const { activate } = useSelect();
const slug = slugifySelectValue(value);
const label = `Select "${slug}"`;
return (
<Button {...buttonProps} disabled={!slug} onClick={() => activate(slug)} label={label}>
{label !== buttonProps.label ? buttonProps.label : null}
</Button>
);
}
@@ -21,11 +21,11 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
}
switch (firstChild.type) {
case 'heading-1':
return '-mt-[18px] @xs:-mt-[24px] @lg:-mt-9';
return '-mt-9';
case 'heading-2':
return '-mt-[11px] @xs:-mt-[14px] @lg:-mt-[calc(1.25rem+1px)]';
return '-mt-[calc(1.25rem+1px)]';
case 'heading-3':
return '-mt-[5px] @xs:-mt-[7px] @lg:-mt-[calc(0.50rem+1px)]';
return '-mt-[calc(0.50rem+1px)]';
default:
return '';
}
@@ -201,7 +201,7 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
const trimmed = query.trim();
return (
<div className={tcls('mx-auto py-8 text-center text-sm text-tint', props.className)}>
<div className={tcls('mx-auto text-center text-sm text-tint', props.className)}>
{trimmed
? tString(language, 'search_no_results_for', trimmed)
: tString(language, 'search_no_results')}
@@ -31,7 +31,8 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
'inline-grid',
'gap-4',
'grid-cols-1',
view.cardSize === 'large' ? '@xl:grid-cols-2' : '@2xl:grid-cols-3 @sm:grid-cols-2', // Large cards break earlier to avoid becoming *too* big.
'@sm:grid-cols-2',
view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3',
block.data.fullWidth ? 'large:flex-column' : null
)}
>
@@ -1,110 +1,213 @@
'use client';
import type React from 'react';
import { type ComponentPropsWithRef, memo, useCallback, useMemo, useState } from 'react';
import React, {
memo,
useCallback,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
} from 'react';
import { useResolvedSlug, useSelect } from '@/components/Select';
import { useListOverflow } from '@/components/hooks';
import { NavigationStatusContext, useListOverflow } from '@/components/hooks';
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
SELECT_DEFAULT_ATTR,
SELECT_GROUP_ATTR,
SELECT_OPTION_ATTR,
SELECT_PINNED_ATTR,
SELECT_UNPINNED_ATTR,
} from '@/lib/select';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import { tcls } from '@/lib/tailwind';
import { resolveAnchorURL } from '@/lib/urls';
import { Icon, type IconName } from '@gitbook/icons';
import { useRouter } from 'next/navigation';
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
const defaultTabsState: TabsState = {
activeIds: {},
activeTitles: [],
};
let globalTabsState = getLocalStorageItem('@gitbook/tabsState', defaultTabsState);
const listeners = new Set<() => void>();
function useTabsState() {
const subscribe = useCallback((callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
}, []);
const getSnapshot = useCallback(() => globalTabsState, []);
const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => {
globalTabsState = updater(globalTabsState);
setLocalStorageItem('@gitbook/tabsState', globalTabsState);
listeners.forEach((listener) => listener());
}, []);
const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [state, setTabsState] as const;
}
// How many titles are remembered:
const TITLES_MAX = 5;
export interface TabsItem {
id: string;
title: string;
/** The `select` slug for this tab, derived from its title (see Tabs.tsx). */
slug: string;
icon?: IconName;
body: React.ReactNode;
}
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
/**
* Client side component for the tabs, taking care of interactions.
*
* Pane visibility is driven entirely by CSS (see generateSelectCSS): each pane carries its slug as
* `data-select-option`, and the generated stylesheet shows the most-recently-activated one based on
* the `data-sel-*` attributes on `<html>`. That means the correct pane is visible before hydration
* (no flash) and with JS disabled.
*
* The one thing CSS can't decide is *which* of several same-named tabs in one group the visitor
* clicked — by slug they're identical, so the stylesheet falls back to the first. After an explicit
* click we pin the exact pane via `data-select-pinned`/`-unpinned` (a client-only override that
* reverts to first-match on reload). The tablist highlight follows the same resolved tab.
*/
export function DynamicTabs(props: { tabs: TabsItem[]; setClassName: string; className?: string }) {
const { tabs, setClassName, className } = props;
const { activate } = useSelect();
// The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS).
const [manualId, setManualId] = useState<string | null>(null);
export function DynamicTabs(props: {
id: string;
tabs: TabsItem[];
className?: string;
}) {
const { id, tabs, className } = props;
const router = useRouter();
const candidateSlugs = useMemo(() => tabs.map((tab) => tab.slug), [tabs]);
const activeSlug = useResolvedSlug(candidateSlugs, tabs[0]?.slug ?? null);
const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
const [initialized, setInitialized] = useState(false);
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
return (
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
);
}, [id, tabs, tabsState]);
// Resolve which tab is shown. Default: the first tab of the active slug — matching the CSS
// first-match. A manual click pins a specific tab, but only while its slug is the active one; if
// the active slug is a duplicate and the pinned tab isn't the first of it, `override` carries the
// pin so the panes below can steer CSS past first-match.
const { activeTabId, override } = useMemo(() => {
const firstMatch = tabs.find((tab) => tab.slug === activeSlug) ?? tabs[0];
const manual = manualId ? tabs.find((tab) => tab.id === manualId) : undefined;
const pinned =
manual && manual.slug === activeSlug && manual.id !== firstMatch?.id ? manual : null;
return { activeTabId: pinned?.id ?? firstMatch?.id ?? null, override: pinned };
}, [tabs, activeSlug, manualId]);
// Track if the tab has been touched by the user.
const touchedRef = useRef(false);
// To avoid issue with hydration, we only use the state from localStorage
// once the component has been initialized (=mounted).
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
const active = initialized ? activeState : tabs[0];
// When clicking to select a tab, we:
// - update the URL hash
// - mark this specific ID as selected
// - store the ID to auto-select other tabs with the same title
const selectTab = useCallback(
(tabId: string) => {
const tab = tabs.find((item) => item.id === tabId);
if (!tab?.slug) {
(tabId: string, manual = true) => {
const tab = tabs.find((tab) => tab.id === tabId);
if (!tab) {
return;
}
activate(tab.slug);
setManualId(tabId);
// The hash is the only URL handle for a selection, so a copied URL lands on this tab and
// `useSelectAnchor` re-activates its slug on load. We deliberately bypass the navigation
// context: it would report a hash change and scroll the tab the visitor is already
// looking at.
window.history.replaceState(null, '', resolveAnchorURL(`#${tab.id}`, window.location));
if (manual) {
touchedRef.current = true;
const href = `#${tab.id}`;
if (window.location.hash !== href) {
onNavigationClick(href);
router.replace(href, { scroll: false });
}
}
setTabsState((prev) => {
if (prev.activeIds[id] === tab.id) {
return prev;
}
return {
activeIds: {
...prev.activeIds,
[id]: tab.id,
},
activeTitles: tab.title
? prev.activeTitles
.filter((t) => t !== tab.title)
.concat([tab.title])
.slice(-TITLES_MAX)
: prev.activeTitles,
};
});
},
[tabs, activate]
[router, setTabsState, tabs, id]
);
// When the hash changes, we try to select the tab containing the targetted element.
React.useLayoutEffect(() => {
setInitialized(true);
if (hash) {
// First check if the hash matches a tab ID.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
selectTab(hash, false);
return;
}
// Then check if the hash matches an element inside a tab.
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
const tabPanel = activeElement.closest('[role="tabpanel"]');
if (!tabPanel) {
return;
}
selectTab(tabPanel.id, false);
}
}, [selectTab, tabs, hash]);
// Scroll to active element in the tab.
React.useLayoutEffect(() => {
// If there is no hash or active tab, nothing to scroll.
if (!hash || hash !== '' || !active) {
return;
}
// If the tab is touched, we don't want to scroll.
if (touchedRef.current) {
return;
}
// If the hash matches a tab, then the scroll is already done.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
return;
}
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
activeElement.scrollIntoView({
block: 'start',
behavior: 'instant',
});
}, [active, tabs, hash]);
return (
<div
{...{ [SELECT_GROUP_ATTR]: '' }}
className={tcls(
'rounded-lg',
'straight-corners:rounded-xs',
'ring-1 ring-tint-subtle ring-inset',
'flex min-w-0 flex-col',
setClassName,
className
)}
>
<TabItemList tabs={tabs} activeTabId={activeTabId} onSelect={selectTab} />
{tabs.map((tab, index) => (
<TabPanel
key={tab.id}
tab={tab}
isDefault={index === 0}
pin={
override && tab.slug === override.slug
? tab.id === override.id
? 'pinned'
: 'unpinned'
: undefined
}
/>
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
{tabs.map((tab) => (
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
))}
</div>
);
@@ -112,24 +215,19 @@ export function DynamicTabs(props: { tabs: TabsItem[]; setClassName: string; cla
const TabPanel = memo(function TabPanel(props: {
tab: TabsItem;
isDefault: boolean;
pin?: 'pinned' | 'unpinned';
isActive: boolean;
}) {
const { tab, isDefault, pin } = props;
const { tab, isActive } = props;
return (
<div
{...{
[SELECT_OPTION_ATTR]: tab.slug,
...(isDefault ? { [SELECT_DEFAULT_ATTR]: '' } : {}),
...(pin === 'pinned' ? { [SELECT_PINNED_ATTR]: '' } : {}),
...(pin === 'unpinned' ? { [SELECT_UNPINNED_ATTR]: '' } : {}),
}}
role="tabpanel"
id={tab.id}
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4">{tab.body}</div>
<div className="p-4" hidden={!isActive}>
{tab.body}
</div>
</div>
);
});
@@ -340,3 +438,42 @@ function getTabIdFromButtonId(buttonId: string) {
}
return buttonId;
}
/**
* Get explicitly selected tab in a set of tabs.
*/
function getTabBySelection(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
const activeId = state.activeIds[input.id];
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
}
/**
* Get the best selected tab in a set of tabs by taking only title into account.
*/
function getTabByTitle(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
return (
input.tabs
.map((item) => {
return {
item,
score: state.activeTitles.indexOf(item.title),
};
})
.filter(({ score }) => score >= 0)
// .sortBy(({ score }) => -score)
.sort(({ score: a }, { score: b }) => b - a)
.map(({ item }) => item)[0] ?? null
);
}
@@ -2,12 +2,11 @@ import type { DocumentBlockTabs } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { validateIconName } from '@gitbook/icons/icons';
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { DynamicTabs } from './DynamicTabs';
import { DynamicTabs, type TabsItem } from './DynamicTabs';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const { block, ancestorBlocks, document, style, context } = props;
@@ -16,7 +15,9 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
throw new Error('Tabs block is missing a key');
}
const items = block.nodes.map((tab) => {
const id = block.key;
const tabs: TabsItem[] = block.nodes.map((tab) => {
if (!tab.key) {
throw new Error('Tab block is missing a key');
}
@@ -42,72 +43,12 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
};
});
const tabs = withSelectSlugs(items);
// When printing, we display the tabs one after the other, each as its own single-tab group so
// every variant is visible (no selection to hide them).
// When printing we show every tab, one after another, so there's no selection to resolve — skip
// the generated stylesheet entirely (each single-tab group's pane is its own default and stays
// visible on its own).
// When printing, we display the tab, one after the other
if (context.mode === 'print') {
return tabs.map((tab) => (
<DynamicTabs
key={tab.id}
tabs={[tab]}
setClassName={selectSetClassName([tab.slug])}
className={tcls(style)}
/>
));
return tabs.map((tab) => {
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
});
}
const slugs = tabs.map((tab) => tab.slug);
return (
<>
<SelectGroupStyle slugs={slugs} />
<DynamicTabs
tabs={tabs}
setClassName={selectSetClassName(slugs)}
className={tcls(style)}
/>
</>
);
}
/**
* Stylesheet that resolves which pane a tab group shows, purely in CSS (see generateSelectCSS).
* Byte-identical for every visitor, so it has no cache impact.
*
* `href` + `precedence` opt into React's stylesheet hoisting: the tag is moved to `<head>` (out of
* the content flow, so sibling/child selectors like Tailwind's `space-y-*` never count it as a
* phantom node) and deduped by `href`, so identical option-sets across the page share one sheet.
*/
function SelectGroupStyle({ slugs }: { slugs: string[] }) {
const css = generateSelectCSS(slugs);
if (!css) {
return null;
}
return (
<style href={selectSetClassName(slugs)} precedence="high">
{css}
</style>
);
}
/**
* Derive a `select` slug for each tab 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.
*/
function withSelectSlugs<T extends { id: string; title: string }>(
items: T[]
): Array<T & { slug: string }> {
return items.map((item) => ({
...item,
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
}));
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
}
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'bun:test';
import type * as api from '@gitbook/api';
import { getSelectAction } from './selectAction';
// The action shapes are cast because the installed @gitbook/api doesn't type the select variant yet.
const data = (value: unknown) => value as api.DocumentInlineButton['data'];
describe('getSelectAction', () => {
it('returns the action for a select button', () => {
expect(getSelectAction(data({ action: { action: 'select', slug: 'Python' } }))).toEqual({
action: 'select',
value: 'Python',
});
});
it('returns null for ask/search actions', () => {
expect(getSelectAction(data({ action: { action: 'ask', query: 'hi' } }))).toBeNull();
expect(getSelectAction(data({ action: { action: 'search' } }))).toBeNull();
});
it('returns null for a link (ref) button', () => {
expect(getSelectAction(data({ ref: { kind: 'url', url: 'https://x.dev' } }))).toBeNull();
});
it('returns null when the value is missing or not a string', () => {
expect(getSelectAction(data({ action: { action: 'select' } }))).toBeNull();
expect(getSelectAction(data({ action: { action: 'select', slug: 42 } }))).toBeNull();
});
});
@@ -1,15 +0,0 @@
import type * as api from '@gitbook/api';
/**
* Detect a "Select" action on a button's data returns the action (with its `value`) or `null` if the button is not a select action.
*/
export function getSelectAction(data: api.DocumentInlineButton['data']) {
if (!('action' in data)) {
return null;
}
const action = data.action;
if (action.action === 'select' && typeof action.slug === 'string') {
return { action: action.action, value: action.slug };
}
return null;
}
@@ -45,7 +45,7 @@ export function HeaderLinkMore(props: {
{links.map((link, index) => (
<MoreMenuLink key={index} link={link} context={context} />
))}
{links.length > 0 && socialAccounts.length > 0 && <DropdownMenuSeparator />}
{socialAccounts.length > 0 && <DropdownMenuSeparator />}
{socialAccounts.map((account) => (
<SocialAccountLink
key={`${account.platform}-${account.handle}`}
@@ -40,7 +40,6 @@ import {
} from '@/lib/icons/inline';
import { defaultCustomization } from '@/lib/utils';
import { AnnouncementDismissedScript } from '../Announcement';
import { SelectStateScript } from '../Select';
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
function preloadFont(fontData: FontData) {
@@ -148,9 +147,6 @@ export async function CustomizationRootLayout(props: {
<OperatingSystemClassScript />
{/* Apply the visitor's content selection to <html> before first paint (no flash) */}
<SelectStateScript />
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{monospaceFontData.type === 'custom' ? (
@@ -1,28 +0,0 @@
'use client';
import { selectStore } from '@/lib/select';
import type React from 'react';
import { useEffect, useLayoutEffect } from 'react';
import { useSelectAnchor } from './useSelectAnchor';
// `useLayoutEffect` runs before paint but warns during SSR (effects don't run on the server anyway),
// so fall back to `useEffect` there.
const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
/**
* Hydrates the `select` store from localStorage. Mounted once at the site layout level. Provides no
* React context — the store is a module singleton — so it simply renders its children.
*/
export function SelectProvider(props: { children: React.ReactNode }) {
// Adopt what the pre-paint script already applied, before paint, so the store (and the tab
// highlight it drives) agrees with the `<html data-sel-*>` on the page.
// Must stay registered before `useSelectAnchor`, whose effect can activate slugs: a write before
// hydration would persist over the visitor's stored list instead of merging into it.
useIsomorphicLayoutEffect(() => {
selectStore.init();
}, []);
useSelectAnchor();
return props.children;
}
@@ -1,19 +0,0 @@
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY } from '@/lib/select';
import { applySelectStateScript } from './script';
/**
* Inline `<head>` script that applies the visitor's `select` state to `<html>` before first paint,
* so the right content variant renders with no flash. Mounted once in the root layout head.
*/
export function SelectStateScript() {
const scriptArgs = JSON.stringify([SELECT_STORAGE_KEY, SELECT_LIST_CAP]).slice(1, -1);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${applySelectStateScript.toString()})(${scriptArgs})`,
}}
/>
);
}
@@ -1,3 +0,0 @@
export { SelectStateScript } from './SelectStateScript';
export { SelectProvider } from './SelectProvider';
export { useSelect, useResolvedSlug } from './useSelect';
@@ -1,54 +0,0 @@
/**
* Read the `select` state from localStorage and apply it to `<html>` as `data-sel-N` attributes as
* early as possible, so the correct content variant is visible before hydration — no flash, and it
* works on cached/static HTML.
*
* NOTE: this runs in `<head>` before `<body>` exists, and is stringified and injected — so it must be
* self-contained (no imports/closures) and touch only `document.documentElement`. The attribute name
* and dedupe rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
* sync.
*/
export function applySelectStateScript(storageKey: string, cap: number) {
try {
const slugs: string[] = [];
// A Set (not a plain object) so slugs like "constructor"/"toString" aren't treated as
// already-seen via Object.prototype — matching the runtime store's dedupe.
const seen = new Set<string>();
const push = (value: string | null | undefined) => {
if (!value) {
return;
}
const slug = String(value).trim();
if (!slug || seen.has(slug) || slugs.length >= cap) {
return;
}
seen.add(slug);
slugs.push(slug);
};
const storedStr = window.localStorage.getItem(storageKey);
if (storedStr) {
const stored = JSON.parse(storedStr);
// Only trust a real array — corrupted storage (a string, or an object with `length`)
// would otherwise iterate per character/index. Matches the runtime store's handling.
if (Array.isArray(stored)) {
for (let i = 0; i < stored.length; i++) {
push(stored[i]);
}
}
}
const el = document.documentElement;
for (let rank = 0; rank < cap; rank++) {
const attribute = `data-sel-${rank}`;
const slug = slugs[rank];
if (slug) {
el.setAttribute(attribute, slug);
} else {
el.removeAttribute(attribute);
}
}
} catch {
// localStorage blocked (private mode) or malformed state — fall through to block defaults.
}
}
@@ -1,36 +0,0 @@
'use client';
import { selectStore } from '@/lib/select';
import { useCallback, useSyncExternalStore } from 'react';
/**
* Subscribe to the site-wide `select` state. Returns the current recency list plus the setters.
* Consumers that only need "which of my options is active" should prefer {@link useResolvedSlug}.
*/
export function useSelect() {
const slugs = useSyncExternalStore(
selectStore.subscribe,
selectStore.getState,
selectStore.getState
).slugs;
return {
slugs,
activate: selectStore.activate,
deactivate: selectStore.deactivate,
};
}
/**
* Resolve which of a block's candidate slugs is active, falling back to `defaultSlug`. Recomputes
* whenever the selection changes.
*/
export function useResolvedSlug(candidateSlugs: string[], defaultSlug: string | null = null) {
// `candidateSlugs` is a fresh array each render; key on its contents to keep the snapshot stable.
const key = candidateSlugs.join(',');
const getResolved = useCallback(() => {
const candidates = key ? key.split(',') : [];
return selectStore.resolveActiveSlug(candidates) ?? defaultSlug;
}, [key, defaultSlug]);
return useSyncExternalStore(selectStore.subscribe, getResolved, getResolved);
}
@@ -1,53 +0,0 @@
'use client';
import { useHash } from '@/components/hooks';
import { SELECT_OPTION_ATTR, selectStore } from '@/lib/select';
import { useLayoutEffect } from 'react';
/**
* Make anchors work across `select` variants: when the URL points at an element inside an inactive
* pane (e.g. a heading in a non-selected tab), activate the slugs of its `data-select-option`
* ancestors so the pane becomes visible, then scroll to it after the panes reflow.
*
* Runs once globally (mounted by SelectProvider) so nested groups resolve in a single pass — the
* outermost pane is activated first, leaving the innermost (the actual target) most-recent.
*/
export function useSelectAnchor() {
const hash = useHash();
useLayoutEffect(() => {
if (!hash) {
return;
}
const target = document.getElementById(hash);
if (!target) {
return;
}
const slugs: string[] = [];
let node: Element | null = target.closest(`[${SELECT_OPTION_ATTR}]`);
while (node) {
const slug = node.getAttribute(SELECT_OPTION_ATTR);
if (slug) {
slugs.push(slug);
}
node = node.parentElement?.closest(`[${SELECT_OPTION_ATTR}]`) ?? null;
}
if (slugs.length === 0) {
return;
}
// Outermost first, so the innermost target ends up most-recent and wins in its group.
for (let i = slugs.length - 1; i >= 0; i--) {
const slug = slugs[i];
if (slug) {
selectStore.activate(slug);
}
}
requestAnimationFrame(() => {
target.scrollIntoView({ block: 'start', behavior: 'instant' });
});
}, [hash]);
}
@@ -7,7 +7,6 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type React from 'react';
import { useMemo } from 'react';
import { SearchContextProvider } from '../Search';
import { SelectProvider } from '../Select';
import { useClearRouterCache } from '../hooks/useClearRouterCache';
import { LinkContext, type LinkContextType } from '../primitives';
import { isExternalLink } from '../utils/link';
@@ -63,13 +62,11 @@ export function SiteLayoutClientContexts(props: {
storageKey={themeStorageKey}
>
<NuqsAdapter>
<SelectProvider>
<LinkContext.Provider value={linkContext}>
<SearchContextProvider>
<ReducedMotionProvider>{children}</ReducedMotionProvider>
</SearchContextProvider>
</LinkContext.Provider>
</SelectProvider>
<LinkContext.Provider value={linkContext}>
<SearchContextProvider>
<ReducedMotionProvider>{children}</ReducedMotionProvider>
</SearchContextProvider>
</LinkContext.Provider>
</NuqsAdapter>
</ThemeProvider>
);
@@ -80,9 +80,8 @@ export function useScrollToHash() {
/**
* Scroll to a hash, if scroll didn't work, return false.
*/
export function scrollToHash(hash: string) {
// Decode so non-ASCII / spaced heading ids (percent-encoded in the URL) resolve.
const element = document.getElementById(decodeHash(hash));
function scrollToHash(hash: string) {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({
block: 'start',
@@ -94,14 +93,3 @@ export function scrollToHash(hash: string) {
}
return false;
}
/**
* Decode a URL hash, falling back to the raw value if it is malformed.
*/
function decodeHash(hash: string): string {
try {
return decodeURIComponent(hash);
} catch {
return hash;
}
}
@@ -4,9 +4,9 @@ import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { getSamePageAnchor, resolveAnchorURL } from '@/lib/urls';
import { checkIsAnchor, resolveAnchorURL } from '@/lib/urls';
import { type TrackEventInput, useTrackEvent } from '../Insights';
import { NavigationStatusContext, scrollToHash } from '../hooks';
import { NavigationStatusContext } from '../hooks';
import { isExternalLink, toNonEmbedLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
@@ -93,19 +93,17 @@ export function Link(props: LinkProps) {
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalServer(href);
const isAnchor = checkIsAnchor(href);
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
// Only trigger navigation context for internal links in the same window without modifier keys (i.e. open in new tab).
if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
const samePageAnchor = getSamePageAnchor(href, window.location);
if (samePageAnchor) {
if (isAnchor) {
event.preventDefault();
const resolvedHref = resolveAnchorURL(`#${samePageAnchor}`, window.location);
const resolvedHref = resolveAnchorURL(href, window.location);
window.history.pushState(null, '', resolvedHref);
onNavigationClick(resolvedHref);
// Repeated anchor clicks don't change hash state, so the navigation effect won't rerun.
scrollToHash(samePageAnchor);
} else {
onNavigationClick(href);
}
@@ -146,7 +146,6 @@ export const ar: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'تم فتح الصفحة',
ai_chat_tools_navigate_failed: 'تعذّر فتح الصفحة',
ai_chat_tools_submit_feedback: 'إرسال الملاحظات',
ai_chat_tools_submit_assistant_feedback: 'قيّم رسالة المساعد السابقة بأنها ${1}',
ai_chat_tools_submitted_feedback: 'تم إرسال ملاحظاتك',
ai_chat_tools_mcp_tool: 'تم استدعاء ${1}',
ai_chat_ask: 'اسأل ${1}',
@@ -150,7 +150,6 @@ export const bg: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Страницата е отворена',
ai_chat_tools_navigate_failed: 'Страницата не може да бъде отворена',
ai_chat_tools_submit_feedback: 'Изпращане на обратна връзка',
ai_chat_tools_submit_assistant_feedback: 'Оценете предишното съобщение на асистента като ${1}',
ai_chat_tools_submitted_feedback: 'Обратната връзка е изпратена',
ai_chat_tools_mcp_tool: 'Извика ${1}',
ai_chat_ask: 'Попитайте ${1}',
@@ -148,7 +148,6 @@ export const cs: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Stránka otevřena',
ai_chat_tools_navigate_failed: 'Stránku se nepodařilo otevřít',
ai_chat_tools_submit_feedback: 'Odeslat zpětnou vazbu',
ai_chat_tools_submit_assistant_feedback: 'Ohodnotit předchozí zprávu asistenta jako ${1}',
ai_chat_tools_submitted_feedback: 'Zpětná vazba odeslána',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Zeptat se ${1}',
@@ -147,7 +147,6 @@ export const da: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Åbnede siden',
ai_chat_tools_navigate_failed: 'Kunne ikke åbne siden',
ai_chat_tools_submit_feedback: 'Send feedback',
ai_chat_tools_submit_assistant_feedback: 'Bedøm assistentens forrige besked som ${1}',
ai_chat_tools_submitted_feedback: 'Feedback sendt',
ai_chat_tools_mcp_tool: 'Kaldte ${1}',
ai_chat_ask: 'Spørg ${1}',
@@ -154,7 +154,6 @@ export const de: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Seite geöffnet',
ai_chat_tools_navigate_failed: 'Seite konnte nicht geöffnet werden',
ai_chat_tools_submit_feedback: 'Feedback senden',
ai_chat_tools_submit_assistant_feedback: 'Vorherige Assistenten-Nachricht als ${1} bewerten',
ai_chat_tools_submitted_feedback: 'Feedback gesendet',
ai_chat_tools_mcp_tool: '${1} aufgerufen',
ai_chat_ask: '${1} fragen',
@@ -152,8 +152,6 @@ export const el: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Άνοιξε η σελίδα',
ai_chat_tools_navigate_failed: 'Αποτυχία ανοίγματος της σελίδας',
ai_chat_tools_submit_feedback: 'Υποβολή σχολίων',
ai_chat_tools_submit_assistant_feedback:
'Αξιολόγηση του προηγούμενου μηνύματος του Βοηθού ως ${1}',
ai_chat_tools_submitted_feedback: 'Τα σχόλια υποβλήθηκαν',
ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}',
ai_chat_ask: 'Ρωτήστε ${1}',
@@ -145,7 +145,6 @@ export const en = {
ai_chat_tools_navigated_to_page: 'Opened the page',
ai_chat_tools_navigate_failed: 'Failed to open the page',
ai_chat_tools_submit_feedback: 'Submit feedback',
ai_chat_tools_submit_assistant_feedback: "Rate the Assistant's previous message as ${1}",
ai_chat_tools_submitted_feedback: 'Submitted your feedback',
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
@@ -152,7 +152,6 @@ export const es: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Página abierta',
ai_chat_tools_navigate_failed: 'No se pudo abrir la página',
ai_chat_tools_submit_feedback: 'Enviar comentarios',
ai_chat_tools_submit_assistant_feedback: 'Valorar el mensaje anterior del asistente como ${1}',
ai_chat_tools_submitted_feedback: 'Comentarios enviados',
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
ai_chat_ask: 'Preguntar a ${1}',
@@ -147,7 +147,6 @@ export const et: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Leht avatud',
ai_chat_tools_navigate_failed: 'Lehe avamine ebaõnnestus',
ai_chat_tools_submit_feedback: 'Saada tagasiside',
ai_chat_tools_submit_assistant_feedback: 'Hinda assistendi eelmist sõnumit kui ${1}',
ai_chat_tools_submitted_feedback: 'Tagasiside saadetud',
ai_chat_tools_mcp_tool: 'Kutsus ${1}',
ai_chat_ask: 'Küsi ${1}',
@@ -149,7 +149,6 @@ export const fi: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Sivu avattu',
ai_chat_tools_navigate_failed: 'Sivun avaaminen epäonnistui',
ai_chat_tools_submit_feedback: 'Lähetä palaute',
ai_chat_tools_submit_assistant_feedback: 'Arvioi avustajan edellinen viesti: ${1}',
ai_chat_tools_submitted_feedback: 'Palaute lähetetty',
ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}',
ai_chat_ask: 'Kysy ${1}',
@@ -148,8 +148,6 @@ export const fr: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Page ouverte',
ai_chat_tools_navigate_failed: "Échec de l'ouverture de la page",
ai_chat_tools_submit_feedback: 'Envoyer',
ai_chat_tools_submit_assistant_feedback:
"Évaluer le message précédent de l'assistant comme ${1}",
ai_chat_tools_submitted_feedback: 'Merci pour votre retour',
ai_chat_tools_mcp_tool: 'A appelé ${1}',
ai_chat_ask: 'Demander à ${1}',
@@ -145,7 +145,6 @@ export const he: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'הדף נפתח',
ai_chat_tools_navigate_failed: 'פתיחת הדף נכשלה',
ai_chat_tools_submit_feedback: 'שליחת משוב',
ai_chat_tools_submit_assistant_feedback: 'דרג את ההודעה הקודמת של העוזר בתור ${1}',
ai_chat_tools_submitted_feedback: 'המשוב נשלח',
ai_chat_tools_mcp_tool: 'קרא ל-${1}',
ai_chat_ask: 'שאל את ${1}',
@@ -146,7 +146,6 @@ export const hi: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'पेज खोला गया',
ai_chat_tools_navigate_failed: 'पेज खोलने में विफल',
ai_chat_tools_submit_feedback: 'प्रतिक्रिया सबमिट करें',
ai_chat_tools_submit_assistant_feedback: 'सहायक के पिछले संदेश को ${1} के रूप में रेट करें',
ai_chat_tools_submitted_feedback: 'आपकी प्रतिक्रिया सबमिट कर दी गई',
ai_chat_tools_mcp_tool: '${1} को कॉल किया',
ai_chat_ask: '${1} से पूछें',
@@ -148,7 +148,6 @@ export const hr: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Stranica otvorena',
ai_chat_tools_navigate_failed: 'Stranicu nije moguće otvoriti',
ai_chat_tools_submit_feedback: 'Pošalji povratne informacije',
ai_chat_tools_submit_assistant_feedback: 'Ocijenite prethodnu poruku asistenta kao ${1}',
ai_chat_tools_submitted_feedback: 'Povratne informacije poslane',
ai_chat_tools_mcp_tool: 'Pozvao ${1}',
ai_chat_ask: 'Pitaj ${1}',
@@ -149,7 +149,6 @@ export const hu: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Oldal megnyitva',
ai_chat_tools_navigate_failed: 'Az oldal megnyitása sikertelen',
ai_chat_tools_submit_feedback: 'Visszajelzés küldése',
ai_chat_tools_submit_assistant_feedback: 'Az asszisztens előző üzenetének értékelése: ${1}',
ai_chat_tools_submitted_feedback: 'Visszajelzés elküldve',
ai_chat_tools_mcp_tool: '${1} meghívva',
ai_chat_ask: '${1} kérdezése',
@@ -147,7 +147,6 @@ export const id: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Halaman dibuka',
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_submit_feedback: 'Kirim masukan',
ai_chat_tools_submit_assistant_feedback: 'Nilai pesan sebelumnya dari Asisten sebagai ${1}',
ai_chat_tools_submitted_feedback: 'Masukan terkirim',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
@@ -151,8 +151,6 @@ export const it: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Pagina aperta',
ai_chat_tools_navigate_failed: 'Impossibile aprire la pagina',
ai_chat_tools_submit_feedback: 'Invia feedback',
ai_chat_tools_submit_assistant_feedback:
"Valuta il messaggio precedente dell'assistente come ${1}",
ai_chat_tools_submitted_feedback: 'Feedback inviato',
ai_chat_tools_mcp_tool: 'Ha chiamato ${1}',
ai_chat_ask: 'Chiedi a ${1}',
@@ -148,7 +148,6 @@ export const ja: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'ページを開きました',
ai_chat_tools_navigate_failed: 'ページを開けませんでした',
ai_chat_tools_submit_feedback: 'フィードバックを送信',
ai_chat_tools_submit_assistant_feedback: 'アシスタントの前のメッセージを${1}と評価する',
ai_chat_tools_submitted_feedback: 'フィードバックを送信しました',
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
ai_chat_ask: '${1} に質問する',
@@ -147,7 +147,6 @@ export const ko: TranslationLanguage = {
ai_chat_tools_navigated_to_page: '페이지를 열었습니다',
ai_chat_tools_navigate_failed: '페이지를 열지 못했습니다',
ai_chat_tools_submit_feedback: '피드백 제출',
ai_chat_tools_submit_assistant_feedback: '어시스턴트의 이전 메시지를 ${1}(으)로 평가하기',
ai_chat_tools_submitted_feedback: '피드백을 제출했습니다',
ai_chat_tools_mcp_tool: '${1} 호출함',
ai_chat_ask: '${1}에게 질문',
@@ -148,7 +148,6 @@ export const lt: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Puslapis atidarytas',
ai_chat_tools_navigate_failed: 'Nepavyko atidaryti puslapio',
ai_chat_tools_submit_feedback: 'Siųsti atsiliepimą',
ai_chat_tools_submit_assistant_feedback: 'Įvertinkite ankstesnę asistento žinutę kaip ${1}',
ai_chat_tools_submitted_feedback: 'Atsiliepimas išsiųstas',
ai_chat_tools_mcp_tool: 'Iškviesta ${1}',
ai_chat_ask: 'Klausti ${1}',
@@ -146,7 +146,6 @@ export const lv: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Lapa atvērta',
ai_chat_tools_navigate_failed: 'Neizdevās atvērt lapu',
ai_chat_tools_submit_feedback: 'Nosūtīt atsauksmi',
ai_chat_tools_submit_assistant_feedback: 'Novērtējiet asistenta iepriekšējo ziņojumu kā ${1}',
ai_chat_tools_submitted_feedback: 'Atsauksme nosūtīta',
ai_chat_tools_mcp_tool: 'Izsauca ${1}',
ai_chat_ask: 'Jautāt ${1}',
@@ -147,8 +147,6 @@ export const ms: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Halaman dibuka',
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_submit_feedback: 'Hantar maklum balas',
ai_chat_tools_submit_assistant_feedback:
'Nilai mesej sebelumnya daripada Pembantu sebagai ${1}',
ai_chat_tools_submitted_feedback: 'Maklum balas dihantar',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
@@ -150,7 +150,6 @@ export const nl: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Pagina geopend',
ai_chat_tools_navigate_failed: 'Kan de pagina niet openen',
ai_chat_tools_submit_feedback: 'Feedback verzenden',
ai_chat_tools_submit_assistant_feedback: 'Vorig bericht van de assistent beoordelen als ${1}',
ai_chat_tools_submitted_feedback: 'Feedback verzonden',
ai_chat_tools_mcp_tool: '${1} aangeroepen',
ai_chat_ask: 'Vraag het aan ${1}',
@@ -149,7 +149,6 @@ export const no: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Åpnet siden',
ai_chat_tools_navigate_failed: 'Kunne ikke åpne siden',
ai_chat_tools_submit_feedback: 'Send tilbakemelding',
ai_chat_tools_submit_assistant_feedback: 'Vurder assistentens forrige melding som ${1}',
ai_chat_tools_submitted_feedback: 'Tilbakemelding sendt',
ai_chat_tools_mcp_tool: 'Kalte ${1}',
ai_chat_ask: 'Spør ${1}',
@@ -148,7 +148,6 @@ export const pl: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Otwarto stronę',
ai_chat_tools_navigate_failed: 'Nie udało się otworzyć strony',
ai_chat_tools_submit_feedback: 'Wyślij opinię',
ai_chat_tools_submit_assistant_feedback: 'Oceń poprzednią wiadomość asystenta jako ${1}',
ai_chat_tools_submitted_feedback: 'Przesłano opinię',
ai_chat_tools_mcp_tool: 'Wywołano ${1}',
ai_chat_ask: 'Zapytaj ${1}',
@@ -152,7 +152,6 @@ export const pt_br: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Página aberta',
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_submit_feedback: 'Enviar feedback',
ai_chat_tools_submit_assistant_feedback: 'Avalie a mensagem anterior do assistente como ${1}',
ai_chat_tools_submitted_feedback: 'Feedback enviado',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
@@ -149,7 +149,6 @@ export const pt: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Página aberta',
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_submit_feedback: 'Enviar feedback',
ai_chat_tools_submit_assistant_feedback: 'Avaliar a mensagem anterior do assistente como ${1}',
ai_chat_tools_submitted_feedback: 'Feedback enviado',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
@@ -151,7 +151,6 @@ export const ro: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Pagina a fost deschisă',
ai_chat_tools_navigate_failed: 'Deschiderea paginii a eșuat',
ai_chat_tools_submit_feedback: 'Trimite feedback',
ai_chat_tools_submit_assistant_feedback: 'Evaluează mesajul anterior al Asistentului ca ${1}',
ai_chat_tools_submitted_feedback: 'Feedback trimis',
ai_chat_tools_mcp_tool: 'A apelat ${1}',
ai_chat_ask: 'Întreabă ${1}',
@@ -151,7 +151,6 @@ export const ru: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Страница открыта',
ai_chat_tools_navigate_failed: 'Не удалось открыть страницу',
ai_chat_tools_submit_feedback: 'Отправить отзыв',
ai_chat_tools_submit_assistant_feedback: 'Оцените предыдущее сообщение ассистента как ${1}',
ai_chat_tools_submitted_feedback: 'Отзыв отправлен',
ai_chat_tools_mcp_tool: 'Вызван ${1}',
ai_chat_ask: 'Спросить у ${1}',
@@ -150,7 +150,6 @@ export const sk: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Stránka otvorená',
ai_chat_tools_navigate_failed: 'Stránku sa nepodarilo otvoriť',
ai_chat_tools_submit_feedback: 'Odoslať spätnú väzbu',
ai_chat_tools_submit_assistant_feedback: 'Ohodnotiť predchádzajúcu správu asistenta ako ${1}',
ai_chat_tools_submitted_feedback: 'Spätná väzba odoslaná',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Opýtať sa ${1}',
@@ -148,7 +148,6 @@ export const sl: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Stran odprta',
ai_chat_tools_navigate_failed: 'Strani ni bilo mogoče odpreti',
ai_chat_tools_submit_feedback: 'Pošlji povratne informacije',
ai_chat_tools_submit_assistant_feedback: 'Ocenite prejšnje sporočilo pomočnika kot ${1}',
ai_chat_tools_submitted_feedback: 'Povratne informacije poslane',
ai_chat_tools_mcp_tool: 'Poklicano ${1}',
ai_chat_ask: 'Vprašaj ${1}',
@@ -148,8 +148,6 @@ export const sv: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Öppnade sidan',
ai_chat_tools_navigate_failed: 'Det gick inte att öppna sidan',
ai_chat_tools_submit_feedback: 'Skicka feedback',
ai_chat_tools_submit_assistant_feedback:
'Betygsätt assistentens föregående meddelande som ${1}',
ai_chat_tools_submitted_feedback: 'Feedback skickad',
ai_chat_tools_mcp_tool: 'Anropade ${1}',
ai_chat_ask: 'Fråga ${1}',
@@ -144,7 +144,6 @@ export const th: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'เปิดหน้าแล้ว',
ai_chat_tools_navigate_failed: 'ไม่สามารถเปิดหน้าได้',
ai_chat_tools_submit_feedback: 'ส่งความคิดเห็น',
ai_chat_tools_submit_assistant_feedback: 'ให้คะแนนข้อความก่อนหน้าของผู้ช่วยเป็น ${1}',
ai_chat_tools_submitted_feedback: 'ส่งความคิดเห็นแล้ว',
ai_chat_tools_mcp_tool: 'เรียกใช้ ${1}',
ai_chat_ask: 'ถาม ${1}',
@@ -146,7 +146,6 @@ export const tr: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Sayfa açıldı',
ai_chat_tools_navigate_failed: 'Sayfa açılamadı',
ai_chat_tools_submit_feedback: 'Geri bildirim gönder',
ai_chat_tools_submit_assistant_feedback: "Asistan'ın önceki mesajını ${1} olarak değerlendir",
ai_chat_tools_submitted_feedback: 'Geri bildiriminiz gönderildi',
ai_chat_tools_mcp_tool: '${1} çağrıldı',
ai_chat_ask: '${1} sor',
@@ -146,7 +146,6 @@ export const uk: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Сторінку відкрито',
ai_chat_tools_navigate_failed: 'Не вдалося відкрити сторінку',
ai_chat_tools_submit_feedback: 'Надіслати відгук',
ai_chat_tools_submit_assistant_feedback: 'Оцініть попереднє повідомлення асистента як ${1}',
ai_chat_tools_submitted_feedback: 'Відгук надіслано',
ai_chat_tools_mcp_tool: 'Викликано ${1}',
ai_chat_ask: 'Запитати ${1}',
@@ -146,7 +146,6 @@ export const vi: TranslationLanguage = {
ai_chat_tools_navigated_to_page: 'Đã mở trang',
ai_chat_tools_navigate_failed: 'Không thể mở trang',
ai_chat_tools_submit_feedback: 'Gửi phản hồi',
ai_chat_tools_submit_assistant_feedback: 'Đánh giá tin nhắn trước đó của Trợ lý là ${1}',
ai_chat_tools_submitted_feedback: 'Đã gửi phản hồi',
ai_chat_tools_mcp_tool: 'Đã gọi ${1}',
ai_chat_ask: 'Hỏi ${1}',
@@ -143,7 +143,6 @@ export const yue: TranslationLanguage = {
ai_chat_tools_navigated_to_page: '已開啟頁面',
ai_chat_tools_navigate_failed: '無法開啟頁面',
ai_chat_tools_submit_feedback: '提交反饋',
ai_chat_tools_submit_assistant_feedback: '將助手嘅上一則訊息評為 ${1}',
ai_chat_tools_submitted_feedback: '已提交反饋',
ai_chat_tools_mcp_tool: '已呼叫 ${1}',
ai_chat_ask: '問 ${1}',
@@ -143,7 +143,6 @@ export const zh_tw: TranslationLanguage = {
ai_chat_tools_navigated_to_page: '已開啟頁面',
ai_chat_tools_navigate_failed: '無法開啟頁面',
ai_chat_tools_submit_feedback: '提交意見回饋',
ai_chat_tools_submit_assistant_feedback: '將助理的上一則訊息評為 ${1}',
ai_chat_tools_submitted_feedback: '已提交意見回饋',
ai_chat_tools_mcp_tool: '已呼叫 ${1}',
ai_chat_ask: '詢問 ${1}',
@@ -144,7 +144,6 @@ export const zh: TranslationLanguage = {
ai_chat_tools_navigated_to_page: '已打开页面',
ai_chat_tools_navigate_failed: '无法打开页面',
ai_chat_tools_submit_feedback: '提交反馈',
ai_chat_tools_submit_assistant_feedback: '将助手的上一条消息评为 ${1}',
ai_chat_tools_submitted_feedback: '已提交反馈',
ai_chat_tools_mcp_tool: '调用了 ${1}',
ai_chat_ask: '向 ${1} 提问',
+15
View File
@@ -12,6 +12,11 @@ interface LookupPublishedContentByUrlInput {
redirectOnError: boolean;
apiToken: string | null;
visitorPayload: SiteVisitorPayload;
/**
* When provided and matching one of the URL lookup alternatives, restrict the lookup
* to that single alternative instead of racing all of them.
*/
urlLookup?: string;
}
/**
@@ -25,6 +30,16 @@ export async function lookupPublishedContentByUrl(
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
if (input.urlLookup) {
// We verify first that it matches one of the alternatives, otherwise we ignore it and race all alternatives.
const matched = lookup.urls.find((alternative) => alternative.url === input.urlLookup);
if (matched) {
// Restrict to the requested alternative, forced as primary so errors and
// incomplete results still surface instead of being swallowed as null.
lookup.urls = [{ ...matched, primary: true }];
}
}
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace(
@@ -1,47 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { isAITrainingOrIndexingRequest } from './indexing-crawlers';
describe('isAITrainingOrIndexingRequest', () => {
it('detects the configured AI training and indexing crawlers on ask and search endpoints', () => {
for (const [userAgent, parameter] of [
['Meta-ExternalAgent/1.1', 'ask'],
['meta-webindexer/1.0', 'q'],
['Amazonbot/0.1', 'ask'],
] as const) {
expect(
isAITrainingOrIndexingRequest(
new Request(`https://docs.example.com/page?${parameter}=query`, {
headers: { 'User-Agent': userAgent },
})
)
).toBe(true);
}
});
it('does not apply to regular pages or unlisted crawlers', () => {
expect(
isAITrainingOrIndexingRequest(
new Request('https://docs.example.com/page', {
headers: { 'User-Agent': 'Meta-ExternalAgent/1.1' },
})
)
).toBe(false);
expect(
isAITrainingOrIndexingRequest(
new Request('https://docs.example.com/page?ask=Question', {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
},
})
)
).toBe(false);
expect(
isAITrainingOrIndexingRequest(
new Request('https://docs.example.com/page?ask=Question&q=query', {
headers: { 'User-Agent': 'GPTBot/1.2' },
})
)
).toBe(false);
});
});
@@ -1,25 +0,0 @@
const AI_TRAINING_OR_INDEXING_USER_AGENT_PATTERNS = [
// We try to be conservative here, and only act on bot causing excessive load
'meta-externalagent',
'meta-webindexer',
'amazonbot',
] as const;
function isAITrainingOrIndexingCrawler(request: Request): boolean {
const userAgent = request.headers.get('user-agent')?.toLowerCase() ?? '';
return AI_TRAINING_OR_INDEXING_USER_AGENT_PATTERNS.some((pattern) =>
userAgent.includes(pattern)
);
}
/**
* Detect AI training and indexing crawlers accessing an internal search endpoint.
*/
export function isAITrainingOrIndexingRequest(request: Request): boolean {
if (!isAITrainingOrIndexingCrawler(request)) {
return false;
}
const searchParams = new URL(request.url).searchParams;
return searchParams.has('ask') || searchParams.has('q');
}
@@ -1,44 +0,0 @@
/**
* localStorage key holding the visitor's selection: a JSON array of slugs, most-recent-first.
* Not namespaced per site — a slug is just a key, so a selection ("python") is meant to follow the
* visitor across pages and spaces, exactly like the tabs store it generalizes.
*/
export const SELECT_STORAGE_KEY = '@gitbook/select';
/**
* How many slugs are remembered, most-recent-first. This is also the depth of the CSS "rank ladder"
* (see generateSelectCSS): since pane visibility is CSS-only, the ladder must cover every stored
* rank, so the two are one knob. The generated CSS is linear in this value, so it's cheap to tune;
* 8 comfortably covers realistic stacking of distinct preferences.
*/
export const SELECT_LIST_CAP = 8;
/**
* Attribute written on `<html>` for the slug at a given recency rank, e.g. `data-sel-0="python"`.
* The pre-paint script and the store both write these; the generated CSS reads them.
*/
export function selectRankAttribute(rank: number): string {
return `data-sel-${rank}`;
}
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
export const SELECT_GROUP_ATTR = 'data-select-group';
/** Carries a pane's slug, e.g. `data-select-option="python"`. */
export const SELECT_OPTION_ATTR = 'data-select-option';
/** Marks the pane shown when none of the group's slugs are active. */
export const SELECT_DEFAULT_ATTR = 'data-select-default';
/**
* Set by the client on an explicitly-clicked pane to pin it (with `data-select-unpinned` on its
* same-slug siblings), overriding the first-match default so the visitor sees exactly the duplicate
* they picked. Only applied after a real click — the pre-paint/reload path stays purely CSS-driven.
*/
export const SELECT_PINNED_ATTR = 'data-select-pinned';
export const SELECT_UNPINNED_ATTR = 'data-select-unpinned';
/**
* Class prefix (followed by a set hash) identifying a distinct candidate-set so identical sets share
* one stylesheet. Uses the `gb-` namespace like GitBook's other own classes (`gb-page-cover`, …) to
* avoid colliding with author or Tailwind classes; `sel` matches the `data-sel-*` rank attributes.
*/
export const SELECT_SET_CLASS_PREFIX = 'gb-sel-';
@@ -1,50 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { generateSelectCSS, selectSetClassName } from './generateSelectCSS';
// The actual show/hide behaviour of this CSS (most-recent option wins, others hidden, default
// fallback) is verified in a real browser in e2e/select.spec.ts. These unit tests only cover the
// pure contract of the helpers, independent of how the selectors are constructed.
describe('selectSetClassName', () => {
it('is independent of candidate order', () => {
expect(selectSetClassName(['python', 'go'])).toBe(selectSetClassName(['go', 'python']));
});
it('ignores duplicates and empty slugs', () => {
expect(selectSetClassName(['python', '', 'python', 'go'])).toBe(
selectSetClassName(['go', 'python'])
);
});
it('differs for different sets', () => {
expect(selectSetClassName(['python', 'go'])).not.toBe(selectSetClassName(['python', 'js']));
});
});
describe('generateSelectCSS', () => {
it('returns nothing for a degenerate set', () => {
expect(generateSelectCSS([])).toBe('');
expect(generateSelectCSS(['', ''])).toBe('');
});
it('scopes the generated rules to the set class', () => {
const css = generateSelectCSS(['python', 'go']);
expect(css).toContain(selectSetClassName(['python', 'go']));
});
it('keeps the safelisted symbols usable in attribute selectors', () => {
// `+` and `#` are valid inside a quoted attribute value; they must appear verbatim.
const css = generateSelectCSS(['c++', 'c#']);
expect(css).toContain('[data-select-option="c++"]');
expect(css).toContain('[data-select-option="c#"]');
});
it('escapes CSS-string metacharacters so a widened charset stays well-formed', () => {
// slugifySelectValue can't produce these today; this guards future widening.
const css = generateSelectCSS(['a"b', 'c\\d']);
expect(css).toContain('[data-select-option="a\\"b"]');
expect(css).toContain('[data-select-option="c\\\\d"]');
// The raw, unescaped quote must never leak into the stylesheet.
expect(css).not.toContain('="a"b"');
});
});
@@ -1,125 +0,0 @@
import {
SELECT_DEFAULT_ATTR,
SELECT_LIST_CAP,
SELECT_OPTION_ATTR,
SELECT_PINNED_ATTR,
SELECT_SET_CLASS_PREFIX,
SELECT_UNPINNED_ATTR,
selectRankAttribute,
} from './constants';
// FNV-1a (32-bit) constants — see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
const FNV_PRIME_32 = 0x01000193;
/**
* Stable, order-independent hash of a candidate set, so two groups offering the same options
* (e.g. `npm`/`yarn`/`pnpm` repeated across a docs site) share one generated stylesheet.
*/
function hashSlugSet(slugs: string[]): string {
const key = [...slugs].sort().join(' ');
// FNV-1a: deterministic and dependency-free. Collision risk is irrelevant here since a clash
// only means two identical-looking sets share CSS, which is exactly what we want anyway.
let hash = FNV_OFFSET_BASIS_32;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME_32);
}
return (hash >>> 0).toString(36);
}
/**
* The class a consumer puts on a group element to scope the generated CSS to it. Keyed on the
* candidate set (not its order), matching {@link generateSelectCSS}.
*/
export function selectSetClassName(candidateSlugs: string[]): string {
return `${SELECT_SET_CLASS_PREFIX}${hashSlugSet(uniqueSlugs(candidateSlugs))}`;
}
function uniqueSlugs(slugs: string[]): string[] {
return [...new Set(slugs.filter(Boolean))];
}
/**
* Escape a slug for interpolation into a CSS string literal (a quoted attribute-selector value).
* `slugifySelectValue` can't currently produce `"` or `\`, so this is defensive — it keeps the
* generated CSS well-formed if the slug charset is ever widened.
*/
function escapeCssString(value: string): string {
return value.replace(/["\\]/g, '\\$&');
}
/**
* Generate the CSS that makes a group show the most-recently-activated of its options — the same
* rule as `resolveActiveSlug`, expressed purely in CSS so it works before hydration and with
* JavaScript disabled.
*
* CSS can't compare two dynamic attributes, but it can compare a dynamic `<html>` rank attribute
* (`data-sel-i`, written by the pre-paint script/store) against the set's slugs, which the server
* knows as literals. We encode the "most recent wins" priority in **source order** rather than in
* selector specificity: rank rules are emitted worst-first (highest rank down to rank 0), so the
* most-recent match appears last and wins the cascade among these equal-specificity rules. At each
* rank one rule hides the group's panes and the next reveals whichever option sits there, so a lower
* (more recent) rank cleanly overrides a higher one. When no option of the set is active anywhere,
* the default pane shows.
*
* A single hide isn't possible: a group can have several of its own options active at once, so each
* rank must re-hide to let the most recent win. But the output stays compact via two modern-CSS
* levers (both within our Tailwind v4 browser baseline): the whole thing nests under the set's scope
* class (`&`) so it isn't repeated in every selector, and the per-rank hide-all — which is
* uncorrelated ("hide the group if *any* of its slugs is at this rank") — folds into one `:is()`
* selector. The per-rank show can't fold that way (each entry correlates rank-value → option-value,
* which `:is()` can't express), so it stays a comma list. Result: `2·depth + 2` rules, no `:not()`
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
*
* Returns `''` for an empty/degenerate set.
*/
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
const slugs = uniqueSlugs(candidateSlugs);
if (slugs.length === 0) {
return '';
}
const option = `[${SELECT_OPTION_ATTR}]`;
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
const rules: string[] = [
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
`${option}{display:none}`,
`[${SELECT_DEFAULT_ATTR}]{display:block}`,
];
for (let rank = depth - 1; rank >= 0; rank--) {
const attr = selectRankAttribute(rank);
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
// When any of the set's options sits at this rank, hide the group's panes...
rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
// ...then reveal whichever one matches (correlated, so a per-option list).
const show = slugs
.map((slug) => {
const value = escapeCssString(slug);
return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
})
.join(',');
rules.push(`${show}{display:block}`);
}
// Duplicate tab names in one group would otherwise reveal two panes at once. Keep only the first:
// hide any option pane preceded by a same-slug sibling. Emitted last and prefixed with `html` so
// it beats the show rules above (equal specificity, later source order). The slug stays shared, so
// syncing is unaffected — only the second pane's visibility changes.
for (const slug of slugs) {
const value = escapeCssString(slug);
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
rules.push(`html & ${pane} ~ ${pane}{display:none}`);
}
// A client click can override that first-match default: it pins the picked pane and unpins its
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
}
-5
View File
@@ -1,5 +0,0 @@
export * from './constants';
export * from './slug';
export * from './generateSelectCSS';
export * as selectStore from './store';
export type { SelectState } from './store';
@@ -1,71 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { SLUG_MAX_CODE_POINTS, slugifySelectValue } from './slug';
describe('slugifySelectValue', () => {
// This table IS the slug contract — see SLUG_ALGO_VERSION. Changing any expectation here orphans
// selections already persisted in visitors' localStorage.
const cases: Array<[input: string, expected: string]> = [
['Python', 'python'],
['JavaScript', 'javascript'],
['JS', 'js'], // note: does NOT equal "javascript" — the near-duplicate lint case
// Technical symbols in the safelist keep otherwise-colliding names distinct.
['C', 'c'],
['C++', 'c++'],
['C#', 'c#'],
['.NET', '.net'],
['Node.js', 'node.js'],
['on_prem', 'on_prem'],
// Letters/numbers/marks from every script survive.
['café', 'café'],
['naïve', 'naïve'],
['安装', '安装'],
['日本語', '日本語'],
['Ελληνικά', 'ελληνικά'],
// Whitespace and other symbols collapse to single dashes and trim.
[' npm ', 'npm'],
['Two Words', 'two-words'],
['on-prem', 'on-prem'],
['On-Prem', 'on-prem'],
['🚀 Launch', 'launch'],
['', ''],
['---', ''],
['🚀', ''],
];
for (const [input, expected] of cases) {
it(`${JSON.stringify(input)} → ${JSON.stringify(expected)}`, () => {
expect(slugifySelectValue(input)).toBe(expected);
});
}
it('drops control/format characters instead of turning them into dashes', () => {
const zeroWidthSpace = String.fromCodePoint(0x200b);
const nul = String.fromCodePoint(0);
expect(slugifySelectValue(`a${zeroWidthSpace}b`)).toBe('ab');
expect(slugifySelectValue(`a${nul}b`)).toBe('ab');
});
it('never produces the reserved comma delimiter', () => {
expect(slugifySelectValue('a, b, c')).not.toContain(',');
});
describe('length cap', () => {
it('caps to SLUG_MAX_CODE_POINTS code points', () => {
expect(slugifySelectValue('a'.repeat(200))).toHaveLength(SLUG_MAX_CODE_POINTS);
});
it('counts code points, not UTF-16 units, so astral chars are not cut in half', () => {
const astral = '𠀀'; // U+20000, a CJK Extension B ideograph = one surrogate pair
const result = slugifySelectValue(astral.repeat(200));
expect([...result]).toHaveLength(SLUG_MAX_CODE_POINTS);
expect(result).toBe(astral.repeat(SLUG_MAX_CODE_POINTS));
});
});
it('is idempotent', () => {
for (const [input] of cases) {
const once = slugifySelectValue(input);
expect(slugifySelectValue(once)).toBe(once);
}
});
});
-51
View File
@@ -1,51 +0,0 @@
/**
* Version of the slugification algorithm below.
*
* The slugs it produces are the keys that sync content across the site, baked into server-rendered
* markup and CSS and persisted in the visitor's localStorage. Changing the algorithm in place would
* orphan every stored selection and desync it from the markup, so any future change must bump this
* version and be gated behind it.
*/
export const SLUG_ALGO_VERSION = 1;
/**
* Maximum slug length, counted in code points (not UTF-16 units, so we never cleave a surrogate
* pair). Guards against pathological titles bloating the generated CSS and stored state. Part of the
* contract (see {@link SLUG_ALGO_VERSION}).
*/
export const SLUG_MAX_CODE_POINTS = 64;
/**
* Turn an author-typed name (a tab title, button label, picker option…) into a `select` slug.
*
* DO NOT CHANGE in place — see {@link SLUG_ALGO_VERSION}. The output must be byte-identical on the
* server (baking slugs into markup/CSS) and the client (reading storage), so it relies only on
* locale-independent primitives: Unicode NFKC
* normalization + `String.prototype.toLowerCase` (Unicode default case folding, not locale-sensitive).
*
* It keeps letters, numbers and marks from every script (so `café`, `安装`, `日本語` survive) plus a
* small safelist of symbols — `+ # . _` — that distinguish technical names that would otherwise
* collide (`c` vs `c++` vs `c#`, `node.js`, `on_prem`). Every other run of characters collapses to a
* single `-`, and leading/trailing `-` are trimmed. A slug can never contain a `,`, which callers rely
* on to join slug lists into a single key (see `useResolvedSlug`) — but the safelist widens the set
* beyond bare word characters, so consumers that interpolate a slug into another syntax must still
* escape for it (see the CSS escaping in generateSelectCSS).
*
* Control, format, bidi and lone-surrogate characters (`\p{C}`) are dropped outright rather than
* turned into a `-`, and the string is re-normalized after `toLowerCase` (case mapping can leave it
* un-normalized), so the same visible name always yields the same bytes on server and client. The
* result is capped to {@link SLUG_MAX_CODE_POINTS} code points. Names that reduce to nothing (e.g. an
* emoji-only title) return `''`; callers treat an empty slug as "no slug" and fall back to default.
*/
export function slugifySelectValue(name: string): string {
const slug = name
.normalize('NFKC')
.replace(/\p{C}+/gu, '')
.toLowerCase()
.normalize('NFKC')
.replace(/[^\p{L}\p{N}\p{M}+#._]+/gu, '-')
.replace(/^-+|-+$/gu, '');
// Slice by code point so a surrogate pair (e.g. astral CJK) is never cut in half, then re-trim a
// trailing `-` the cut may have exposed.
return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, '');
}
@@ -1,56 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { SELECT_STORAGE_KEY } from './constants';
// Own file, own module instance: the store hydrates once per page load, so the "mutation before
// init()" path can only be exercised on a store nothing has touched yet.
const storage = new Map<string, string>();
const globals = globalThis as unknown as { localStorage?: unknown };
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
beforeEach(() => {
storage.clear();
globals.localStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
};
});
// Bun shares globals across test files, so leave `localStorage` exactly as we found it (absent) —
// every other suite relies on the `typeof localStorage` guard in `lib/browser` short-circuiting.
afterEach(() => {
if (originalLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', originalLocalStorage);
} else {
delete globals.localStorage;
}
storage.clear();
});
describe('select store hydration', () => {
it('merges a mutation that lands before init() into the stored list', async () => {
storage.set(SELECT_STORAGE_KEY, JSON.stringify(['go', 'rust']));
// Query string busts the module cache so this store is untouched by the sibling suite; the
// specifier is held in a variable because TS won't resolve it as a literal.
const freshStore = './store?hydration';
const { activate, getState, init } = (await import(freshStore)) as typeof import('./store');
// A deep-linked pane activating during hydration, ahead of the provider's init effect.
activate('python');
expect(getState().slugs).toEqual(['python', 'go', 'rust']);
expect(JSON.parse(storage.get(SELECT_STORAGE_KEY) as string)).toEqual([
'python',
'go',
'rust',
]);
// The later init() must not resurrect the pre-mutation list.
init();
expect(getState().slugs).toEqual(['python', 'go', 'rust']);
});
});
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it } from 'bun:test';
import { SELECT_LIST_CAP } from './constants';
import { activate, deactivate, getState, resolveActiveSlug, setSlugs, subscribe } from './store';
beforeEach(() => {
setSlugs([]);
});
describe('select store', () => {
it('activates slugs most-recent-first', () => {
activate('python');
activate('cloud');
expect(getState().slugs).toEqual(['cloud', 'python']);
});
it('moves an already-active slug back to the front', () => {
setSlugs(['go', 'python', 'cloud']);
activate('cloud');
expect(getState().slugs).toEqual(['cloud', 'go', 'python']);
});
it('dedupes and drops empty slugs', () => {
setSlugs(['python', '', 'python', 'go']);
expect(getState().slugs).toEqual(['python', 'go']);
});
it('caps the list and evicts the oldest', () => {
const many = Array.from({ length: SELECT_LIST_CAP + 5 }, (_, i) => `s${i}`);
setSlugs(many);
expect(getState().slugs).toHaveLength(SELECT_LIST_CAP);
expect(getState().slugs).toEqual(many.slice(0, SELECT_LIST_CAP));
});
it('deactivates a slug', () => {
setSlugs(['python', 'cloud']);
deactivate('python');
expect(getState().slugs).toEqual(['cloud']);
});
describe('resolveActiveSlug', () => {
it('returns the most recently active candidate', () => {
setSlugs(['cloud', 'python', 'go']);
expect(resolveActiveSlug(['go', 'python'])).toBe('python');
});
it('returns null when no candidate is active', () => {
setSlugs(['cloud']);
expect(resolveActiveSlug(['python', 'go'])).toBeNull();
});
});
describe('change notifications', () => {
it('notifies subscribers on a real change', () => {
let calls = 0;
const unsubscribe = subscribe(() => {
calls++;
});
activate('python');
expect(calls).toBe(1);
unsubscribe();
});
it('does not notify when the list is unchanged (avoids redundant re-renders)', () => {
setSlugs(['python', 'go']);
let calls = 0;
const unsubscribe = subscribe(() => {
calls++;
});
setSlugs(['python', 'go']);
activate('python'); // already at front → no change
expect(calls).toBe(0);
unsubscribe();
});
});
});
-153
View File
@@ -1,153 +0,0 @@
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, selectRankAttribute } from './constants';
/**
* The one piece of `select` state: a site-wide, recency-ordered list of active slugs
* (most-recent-first, deduped, capped). Setters `activate`/`deactivate` slugs; consumers read the
* list through `resolveActiveSlug`. Everything is client-side — the store is never rendered into
* SSR HTML, so pages stay byte-identical for every visitor.
*/
export interface SelectState {
slugs: string[];
}
let state: SelectState = { slugs: [] };
const listeners = new Set<() => void>();
let initialized = false;
/** Current selection. Server-side this is always the empty list. */
export function getState(): SelectState {
return state;
}
/** Subscribe to selection changes. Returns an unsubscribe function. */
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Dedupe, drop empties, and cap to the most-recent {@link SELECT_LIST_CAP} slugs. */
function normalize(slugs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const slug of slugs) {
if (!slug || seen.has(slug)) {
continue;
}
seen.add(slug);
out.push(slug);
if (out.length >= SELECT_LIST_CAP) {
break;
}
}
return out;
}
function sameList(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((slug, index) => slug === b[index]);
}
function commit(nextSlugs: string[]) {
// Latch hydration even for callers that replace the list wholesale, so a later `init()` can't
// overwrite this write with what was in storage beforehand.
hydrate();
const slugs = normalize(nextSlugs);
// No-op when nothing changed, so we don't rewrite storage or re-render every consumer.
if (sameList(slugs, state.slugs)) {
return;
}
state = { slugs };
setLocalStorageItem(SELECT_STORAGE_KEY, slugs);
mirrorToHtml(slugs);
for (const listener of listeners) {
listener();
}
}
/** Move a slug to the front (most-recent). No-op for an empty slug. */
export function activate(slug: string) {
if (!slug) {
return;
}
// Hydrate before reading `state`, not just before writing: a mutation that lands ahead of `init()`
// (a deep-linked pane activating during hydration) must merge into the stored list, not replace it.
hydrate();
commit([slug, ...state.slugs]);
}
/** Remove a slug from the list (used by filters). */
export function deactivate(slug: string) {
if (!slug) {
return;
}
hydrate();
commit(state.slugs.filter((s) => s !== slug));
}
/** Replace the whole list. */
export function setSlugs(slugs: string[]) {
commit(slugs);
}
/**
* The single shared resolution rule every consumer uses: given the slugs a block contains, return
* the one that is most recently active (smallest index), or `null` so the caller can fall back to
* its own default.
*/
export function resolveActiveSlug(candidates: string[]): string | null {
let best: string | null = null;
let bestIndex = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const index = state.slugs.indexOf(candidate);
if (index >= 0 && index < bestIndex) {
bestIndex = index;
best = candidate;
}
}
return best;
}
/**
* Hydrate the in-memory store from localStorage (once per full page load). The pre-paint script has
* already read the same storage and written `<html>` before this runs, so we just adopt it; we
* re-mirror to `<html>` too, to stay correct after a client-side navigation.
*/
export function init() {
hydrate();
mirrorToHtml(state.slugs);
for (const listener of listeners) {
listener();
}
}
/** Read the persisted list into memory, once per page load. Silent — callers notify if they need to. */
function hydrate() {
if (initialized) {
return;
}
initialized = true;
const stored = getLocalStorageItem<string[]>(SELECT_STORAGE_KEY, []);
state = { slugs: normalize(Array.isArray(stored) ? stored : []) };
}
/**
* Write the recency list onto `<html>` as `data-sel-0…N` attributes — the same attributes the
* pre-paint script writes — so runtime updates re-drive the exact CSS that handled the first paint.
*/
function mirrorToHtml(slugs: string[]) {
if (typeof document === 'undefined') {
return;
}
const el = document.documentElement;
for (let rank = 0; rank < SELECT_LIST_CAP; rank++) {
const attribute = selectRankAttribute(rank);
const slug = slugs[rank];
if (slug) {
el.setAttribute(attribute, slug);
} else {
el.removeAttribute(attribute);
}
}
}
+1 -33
View File
@@ -1,38 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { getSamePageAnchor, resolveAnchorURL } from './urls';
describe('getSamePageAnchor', () => {
const location = {
href: 'https://gitbook.com/docs/docs-site/site-settings?mode=preview#current',
};
it('resolves a hash-only link', () => {
expect(getSamePageAnchor('#target', location)).toBe('target');
});
it('resolves a same-page path with a hash', () => {
expect(
getSamePageAnchor(
'/docs/docs-site/site-settings?mode=preview#gitbook-subdirectory',
location
)
).toBe('gitbook-subdirectory');
});
it('does not resolve a different page, query, origin, or missing hash', () => {
expect(getSamePageAnchor('/docs/other#target', location)).toBeNull();
expect(
getSamePageAnchor('/docs/docs-site/site-settings?mode=other#target', location)
).toBeNull();
expect(
getSamePageAnchor('https://example.com/docs/docs-site/site-settings#target', location)
).toBeNull();
expect(
getSamePageAnchor('/docs/docs-site/site-settings?mode=preview', location)
).toBeNull();
});
});
import { resolveAnchorURL } from './urls';
describe('resolveAnchorURL', () => {
it('replaces the current location hash with the new anchor', () => {
-19
View File
@@ -23,25 +23,6 @@ export function checkIsAnchor(input: string): boolean {
return input.startsWith('#');
}
/**
* Return the encoded anchor when a URL points to the current page.
*/
export function getSamePageAnchor(input: string, location: Pick<Location, 'href'>): string | null {
const currentURL = new URL(location.href);
const targetURL = new URL(input, currentURL);
if (
targetURL.origin !== currentURL.origin ||
targetURL.pathname !== currentURL.pathname ||
targetURL.search !== currentURL.search ||
!targetURL.hash
) {
return null;
}
return targetURL.hash.slice(1);
}
/**
* Resolve a hash-only anchor against a location while replacing any existing hash.
*/
-12
View File
@@ -21,18 +21,6 @@ describe('getVisitorAuthToken', () => {
).toEqual({ source: 'url', token: '123' });
});
it('should return a revalidation token for requests from the revalidation worker', () => {
expect(
getVisitorToken({
cookies: [],
headers: new Headers({
'User-Agent': 'GitBook-Open-Revalidation-Worker',
}),
url: new URL('https://example.com?jwt_token=123'),
})
).toEqual({ source: 'revalidation', token: '123' });
});
it('should return the token from the cookie root basepath', () => {
const visitorAuth = getVisitorToken({
cookies: [
-9
View File
@@ -79,11 +79,6 @@ export type VisitorTokenLookup =
source: 'visitor-oauth-protected';
token: string;
}
| {
/** A visitor token used for revalidation purposes. This is coming from our backend and we don't want to redirect in this case */
source: 'revalidation';
token: string;
}
/** Not visitor token was found */
| undefined;
@@ -135,10 +130,6 @@ export function getVisitorToken({
// Allow the empty string to come through
if (fromUrl !== null && fromUrl !== undefined) {
if (headers.get('user-agent')?.toLowerCase() === 'gitbook-open-revalidation-worker') {
return { source: 'revalidation', token: fromUrl };
}
return { source: 'url', token: fromUrl };
}
+5 -14
View File
@@ -28,7 +28,6 @@ import {
} from '@/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
import { getImageResizingContextId } from '@/lib/images';
import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers';
import { MiddlewareHeaders } from '@/lib/middleware';
import {
createOAuthProtectedResourceMetadataResponse,
@@ -152,13 +151,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const { url: siteRequestURL, mode } = match;
if (isAITrainingOrIndexingRequest(request)) {
return new Response('This endpoint is not intended for AI training or indexing.', {
status: 403,
headers: { 'content-type': 'text/plain; charset=utf-8' },
});
}
// Normalize URL after extracting the URL from the request to make sure the client is redirected to the proper one
const normalizationResponse = normalizeRequestURL(siteRequestURL);
if (normalizationResponse) {
@@ -212,10 +204,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
//
request.headers.delete('x-gitbook-disable-tracking');
// header that allow to bypass the race in lookupPublishedContentByUrl when we know in advance the correct lookup to use
const urlLookup = request.headers.get('x-gitbook-lookup-url') ?? undefined;
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
urlLookup,
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
@@ -335,16 +331,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// Make sure the URL is clean of any va token after a successful lookup,
// and of any visitor.* params that may have been passed to the URL.
//
// We only redirect if the visitor token is not coming from a revalidation request, as we don't want to redirect in that case.
//
// The token and the visitor.* params value are stored in cookies that are set
// on the redirect response.
//
const normalizedVisitorURL = normalizeVisitorURL(incomingURL);
if (
normalizedVisitorURL.toString() !== incomingURL.toString() &&
visitorToken?.source !== 'revalidation'
) {
if (normalizedVisitorURL.toString() !== incomingURL.toString()) {
return writeResponseCookies(
NextResponse.redirect(normalizedVisitorURL.toString()),
cookies
@@ -179,7 +179,6 @@ describe('handleOpenAPIProxyRequest', () => {
referer: 'http://localhost:3000/docs',
'x-forwarded-for': '127.0.0.1',
accept: 'application/json',
cookie: 'gitbook_visitor=secret',
'x-scalar-cookie': 'session=abc123',
'x-scalar-user-agent': 'ScalarClient/1.0',
},
@@ -193,7 +192,7 @@ describe('handleOpenAPIProxyRequest', () => {
expect(headers.get('x-forwarded-for')).toBeNull();
// Kept
expect(headers.get('accept')).toBe('application/json');
// The browser's own cookie must not leak to the target; only the scalar cookie is sent.
// Remapped
expect(headers.get('cookie')).toBe('session=abc123');
expect(headers.get('user-agent')).toBe('ScalarClient/1.0');
// Host set to target
@@ -209,7 +208,6 @@ describe('handleOpenAPIProxyRequest', () => {
'content-encoding': 'gzip',
'transfer-encoding': 'chunked',
'content-type': 'application/json',
'set-cookie': 'evil=1; Domain=.gitbook.com; Path=/',
},
})
)
@@ -224,8 +222,6 @@ describe('handleOpenAPIProxyRequest', () => {
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('content-encoding')).toBeNull();
expect(res.headers.get('transfer-encoding')).toBeNull();
// A target must not be able to set cookies on GitBook's own origin.
expect(res.headers.get('set-cookie')).toBeNull();
});
it('returns 502 when upstream fetch fails', async () => {
+5 -3
View File
@@ -18,7 +18,6 @@ const REQUEST_HEADERS_TO_STRIP = new Set([
'origin',
'referer',
'connection',
'cookie',
'x-scalar-cookie',
'x-scalar-user-agent',
'x-forwarded-for',
@@ -37,7 +36,6 @@ const RESPONSE_HEADERS_TO_STRIP = new Set([
'transfer-encoding',
'connection',
'keep-alive',
'set-cookie',
]);
const CORS_HEADERS = {
@@ -238,7 +236,11 @@ export async function handleOpenAPIProxyRequest(request: NextRequest): Promise<R
for (const [key, value] of response.headers.entries()) {
const lower = key.toLowerCase();
if (!RESPONSE_HEADERS_TO_STRIP.has(lower) && !lower.startsWith('access-control-')) {
responseHeaders.set(key, value);
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
responseHeaders.set(key, value);
}
}
}
-31
View File
@@ -50,37 +50,6 @@ describe('markdown serving based on user agent', () => {
});
});
describe('search parameters for indexing crawlers', () => {
const ASK_QUESTION = 'This question must not reach Ask AI';
const SEARCH_QUERY = 'This query must not reach search';
it('should reject Ask AI requests from Meta external agents', async () => {
const response = await fetch(
getContentTestURL(
`${TEST_PAGE_URL}?ask=${encodeURIComponent(ASK_QUESTION)}&goal=Read%20the%20docs`
),
{ headers: { 'User-Agent': 'meta-externalagent/1.1' } }
);
expect(response.status).toBe(403);
expect(response.headers.get('content-type')).toContain('text/plain');
expect(await response.text()).toBe(
'This endpoint is not intended for AI training or indexing.'
);
});
it('should reject search requests from Amazonbot', async () => {
const response = await fetch(
getContentTestURL(`${TEST_PAGE_URL}?q=${encodeURIComponent(SEARCH_QUERY)}`),
{ headers: { 'User-Agent': 'Amazonbot/0.1' } }
);
expect(response.status).toBe(403);
expect(response.headers.get('content-type')).toContain('text/plain');
expect(await response.text()).toBe(
'This endpoint is not intended for AI training or indexing.'
);
});
});
describe('markdown pages', () => {
it('should expose a markdown page with the .md extension', async () => {
const response = await fetch(