mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-05 12:54:22 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b307b09acd |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Stepper heading alignment and overflow-menu separator on mobile
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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} 提问',
|
||||
|
||||
@@ -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('')}}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user