Compare commits

...

18 Commits

Author SHA1 Message Date
Zeno Kapitein 36877d92a6 Format 2025-04-03 13:57:22 +02:00
Zeno Kapitein 23433d1287 Change type 2025-04-03 13:56:16 +02:00
Zeno Kapitein 5d9183e1b4 Format 2025-04-03 13:53:41 +02:00
Zeno Kapitein a1d5326ffa Remove adaptive pane (for now) 2025-04-03 13:52:23 +02:00
Zeno Kapitein cc26fb34aa Remove other AI experiments (for now) 2025-04-03 13:52:23 +02:00
Zeno Kapitein b1abbf603b Use new customisation option 2025-04-03 13:52:23 +02:00
Zeno Kapitein a70e8cd7dc Refactor to split components into Link and LinkTooltip 2025-04-03 13:51:53 +02:00
Zeno Kapitein b38eed850f Improve prompt & layout 2025-04-03 13:51:53 +02:00
Zeno Kapitein f4c50c7e3c Add page paths 2025-04-03 13:51:53 +02:00
Zeno Kapitein 5d308c764e Update AIPageLinkSummary.tsx 2025-04-03 13:51:53 +02:00
Zeno Kapitein 08e780496b Tweak link summaries 2025-04-03 13:51:53 +02:00
Zeno Kapitein e4cd67ebe2 Tweak prompt 2025-04-03 13:51:53 +02:00
Zeno Kapitein 939b160bfa Make link summaries work 2025-04-03 13:51:53 +02:00
Zeno Kapitein 689e78d8e7 Fix typing 2025-04-03 13:51:53 +02:00
Zeno Kapitein 37c1d4b8ea Initial UI for tooltip experiment 2025-04-03 13:51:53 +02:00
Samy Pessé 75855585f5 Start panel 2025-04-03 13:51:53 +02:00
Samy Pessé dcf0f9aac5 New experiment 2025-04-03 13:51:53 +02:00
Samy Pessé 1d7253307a Show list of recommended questions at the bottom 2025-04-03 13:51:53 +02:00
24 changed files with 1648 additions and 1533 deletions
+876 -1316
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -12,7 +12,7 @@
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.106.0"
"@gitbook/api": "0.107.0"
},
"private": true,
"scripts": {
@@ -38,5 +38,8 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
},
"dependencies": {
"@radix-ui/react-tooltip": "^1.1.8"
}
}
+7 -4
View File
@@ -32,9 +32,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
@@ -68,7 +66,12 @@
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"event-iterator": "^2.0.0",
"partial-json": "^0.1.7",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^4.3.0",
@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/primitives/Button';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
export default function ErrorPage(props: {
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
}}
variant="secondary"
size="small"
>
{t(language, 'unexpected_error_retry')}
</Button>
label={tString(language, 'unexpected_error_retry')}
/>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
'use client';
import { Icon } from '@gitbook/icons';
import { useEffect, useState } from 'react';
import { Loading } from '../primitives';
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
/**
* Summarise a page's content for use in a link preview
*/
export function AIPageLinkSummary(props: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
showTrademark: boolean;
}) {
const {
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
showTrademark = true,
} = props;
const [highlight, setHighlight] = useState('');
useEffect(() => {
let canceled = false;
setHighlight('');
(async () => {
const stream = await streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
previousPageIds: [],
});
for await (const highlight of stream) {
if (canceled) return;
setHighlight(highlight ?? '');
}
})();
return () => {
canceled = true;
};
}, [currentSpaceId, currentPageId, targetSpaceId, targetPageId, linkPreview, linkTitle]);
return (
<div className="flex flex-col gap-1">
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{showTrademark ? (
<Loading className="size-4" busy={!highlight || highlight.length === 0} />
) : (
<Icon icon="sparkle" className="size-3" />
)}
<h6 className="text-tint">Page highlight</h6>
</div>
{highlight.length > 0 ? <p>{highlight}</p> : null}
{highlight.length > 0 ? (
<div className="text-tint-subtle text-xs">
Based on your context. May contain mistakes.
</div>
) : null}
</div>
);
}
@@ -0,0 +1 @@
export * from './AIPageLinkSummary';
@@ -0,0 +1,124 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
import * as partialJson from 'partial-json';
import type { DeepPartial } from 'ts-essentials';
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
/**
* Get the latest value from a stream and the response id.
*/
export async function generate<T>(
promise: MaybePromise<{
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
}>
) {
const input = await promise;
let value: T | undefined;
for await (const event of input.stream) {
value = event;
}
const { responseId } = await input.response;
return {
responseId,
value,
};
}
/**
* Stream the generation of an object using the AI.
*/
export async function streamGenerateObject<T>(
context: GitBookBaseContext,
{
organizationId,
siteId,
}: {
organizationId: string;
siteId: string;
},
{
schema,
messages,
model = AIModel.Fast,
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
}
) {
const apiClient = await context.dataFetcher.api();
const rawStream = apiClient.orgs.streamAiResponseInSite(organizationId, siteId, {
input: messages,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
return parsed;
}
});
}
/**
* Parse a stream from the API to extract the responseId.
*/
function parseResponse<T>(
responseStream: EventIterator<AIStreamResponse>,
parse: (response: AIStreamResponse) => T | undefined
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve;
});
const stream = new EventIterator<T>((queue) => {
(async () => {
let foundResponse = false;
for await (const event of responseStream) {
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
} else {
const parsed = parse(event);
if (parsed !== undefined) {
queue.push(parsed);
}
}
}
if (!foundResponse) {
throw new Error('No response found');
}
})().then(
() => {
queue.stop();
},
(error) => {
queue.fail(error);
}
);
});
return { stream, response };
}
@@ -0,0 +1 @@
export * from './streamLinkPageSummary';
@@ -0,0 +1,127 @@
'use server';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
}: {
currentSpaceId: string;
currentPageId: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
previousPageIds?: string[];
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
highlight: z.string().describe('The most important content of the target page'),
// questions: z.array(z.string().describe('The questions to sea')).max(3),
}),
messages: [
{
role: AIMessageRole.Developer,
content: `# Role
You are a documentation navigator. Your job is to help the user read documentation more efficiently. Your aim is to prevent the user from having to read the target page by giving them all the information they need to know.
# Task
Using both the current page context and the target page content, produce a page highlight that:
- Highlights the key facts from the target page.
- Relates strongly to the topic the user is currently reading about.
- Is very succinct and direct, using only one or two short sentences (each sentence using no more than one comma).
- Remains strictly factual, without referring to “the page”.
# Instructions
1. Identify the key paragraph surrounding the link's text (e.g., “change request”) from the current page.
2. Extract and combine relevant information from the target page to address the link's context.
3. Combine in one or two short sentences that are direct and brief.
# Examples
## Example 1
- Link context: “This feature is only available on the Ultimate plan.”
- Link preview: “Pricing: Learn about our different pricing tiers.”
- Response: “The Ultimate plan costs $25 per month. A Pro plan is available too.”
## Example 2
- Link context: “You can use keyboard shortcuts to get to the Search menu faster.”
- Link preview: “Keyboard shortcuts: A quick reference guide to all the keyboard shortcuts available.”
- Response: “To open the Search menu, use the keyboard shortcut ⌘K or Ctrl+K.”
## Example 3
- Link context: “This feature can only be enabled by an admin.”
- Link preview: “Roles: An overview of the different roles on the platform.”
- Response: “The admin role is reserved for the creator of the organisation.”`,
},
{
role: AIMessageRole.Developer,
content: `# Context
## Current page
The content of the current page is:`,
attachments: [
{
type: 'page',
spaceId: currentSpaceId,
pageId: currentPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `## Target page
The content of the target page is:`,
attachments: [
{
type: 'page',
spaceId: targetSpaceId,
pageId: targetPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `## Link preview
The content of the link preview is:
> ${linkPreview}`,
},
{
role: AIMessageRole.User,
content: `I'm considering reading the link titled "${linkTitle}" to page ID ${targetPageId}. Give the most relevant information from this page. Relate it to my current page and in particular the paragraph I'm currently reading. Be very concise.`,
},
],
}
),
fetchServerActionSiteContext(baseContext),
]);
for await (const value of stream) {
const highlight = value.highlight;
if (!highlight) {
continue;
}
yield highlight;
}
}
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(true);
}}
>
{t(language, 'cookies_accept')}
</Button>
label={tString(language, 'cookies_accept')}
/>
<Button
variant="secondary"
size="small"
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(false);
}}
>
{t(language, 'cookies_reject')}
</Button>
label={tString(language, 'cookies_reject')}
/>
</div>
</div>
);
@@ -1,19 +1,22 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineLinkTooltip } from './InlineLinkTooltip';
import { Inlines } from './Inlines';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!resolved) {
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
@@ -25,24 +28,38 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
<InlineLinkTooltip
inline={inline}
document={document}
context={context}
ancestorInlines={ancestorInlines}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</StyledLink>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</InlineLinkTooltip>
);
}
@@ -0,0 +1,178 @@
import type { DocumentInlineLink } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as Tooltip from '@radix-ui/react-tooltip';
import { Fragment } from 'react';
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
import { Button, StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { Inlines } from './Inlines';
export async function InlineLinkTooltip(
props: InlineProps<DocumentInlineLink> & { children: React.ReactNode }
) {
const { inline, document, context, ancestorInlines, children } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
let breadcrumbs = resolved.ancestors;
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
breadcrumbs = [
{
label: 'External link to',
},
];
}
if (isSamePage) {
breadcrumbs = [
{
label: 'Jump to section',
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
resolved.subText = undefined;
}
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
<div className="bg-tint-base p-4">
<div className={tcls('flex gap-4')}>
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0
? null
: 'place-self-start'
)}
variant="blank"
href={resolved.href}
target="_blank"
label="Open in new tab"
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
) : null}
</div>
{'customization' in context.contentContext &&
context.contentContext.customization.ai?.pageLinkSummaries.enabled &&
!isExternal &&
'page' in context.contentContext &&
inline.data.ref.kind === 'page' ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<AIPageLinkSummary
currentPageId={context.contentContext.page.id}
currentSpaceId={context.contentContext.space.id}
currentPageTitle={context.contentContext.page.title}
targetPageId={
inline.data.ref.page ?? context.contentContext.page.id
}
targetSpaceId={
inline.data.ref.space ?? context.contentContext.space.id
}
linkTitle={inline.nodes
.map((node) => {
if (node.object === 'text') {
return node.leaves
.map((leaf) => leaf.text)
.join('');
}
return '';
})
.join('')}
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
showTrademark={
context.contentContext.customization.trademark.enabled
}
/>
</div>
) : null}
</div>
<Tooltip.Arrow className="fill-tint-1" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
@@ -168,7 +168,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
className="flex flex-row items-center gap-2"
insights={
ref.file
? {
@@ -142,10 +142,9 @@ function HeaderItemButton(
position: SiteInsightsLinkPosition.Header,
},
}}
label={title}
{...rest}
>
{title}
</Button>
/>
);
}
@@ -5,7 +5,7 @@ import React from 'react';
import { useScrollActiveId } from '@/components/hooks';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type PDFSearchParams, getPDFURLSearchParams } from './urls';
@@ -58,9 +58,8 @@ export function PageControlButtons(props: {
only: true,
}).toString()}`}
variant="secondary"
>
{t(language, 'pdf_mode_only_page')}
</Button>
label={tString(language, 'pdf_mode_only_page')}
/>
)}
<Button
href={`?${getPDFURLSearchParams({
@@ -69,9 +68,8 @@ export function PageControlButtons(props: {
only: false,
}).toString()}`}
variant="secondary"
>
{t(language, 'pdf_mode_all')}
</Button>
label={tString(language, 'pdf_mode_all')}
/>
{trademark ? <div className={tcls('mt-5')}>{trademark}</div> : null}
</div>
@@ -13,7 +13,6 @@ import urlJoin from 'url-join';
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Ad } from '../Ads';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
@@ -42,12 +41,14 @@ export function PageAside(props: {
limit: 100,
}).toString()}`
);
return (
<aside
className={tcls(
'group/aside',
'hidden',
'xl:flex',
'text-sm',
// 'page-no-toc:lg:flex',
'flex-col',
'basis-56',
@@ -92,139 +93,109 @@ export function PageAside(props: {
)}
>
{page.layout.outline ? (
<>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-2',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<div
className={tcls(
'hidden',
'page-api-block:xl:max-2xl:flex',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'page-api-block:xl:max-2xl:group-hover/aside:hidden'
)}
/>
</div>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-6',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{document ? (
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</>
</div>
) : null}
<div
className={tcls(
@@ -7,7 +7,6 @@ import { t } from '@/intl/translate';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { TrackPageViewEvent } from '../Insights';
import { PageFeedbackForm } from '../PageFeedback';
@@ -35,7 +35,7 @@ export async function PageHeader(props: {
<li key={breadcrumb.id}>
<StyledLink
href={href}
style={tcls(
className={tcls(
'no-underline',
'hover:underline',
'text-xs',
@@ -106,9 +106,8 @@ export function PageFeedbackForm(props: {
<Button
size="small"
onClick={() => onSubmitComment(rating, comment)}
>
{t(languages, 'submit')}
</Button>
label={tString(languages, 'submit')}
/>
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
<span
className={
@@ -1,62 +1,80 @@
'use client';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributeAnchorTarget, HTMLAttributes } from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from './Link';
type ButtonProps = {
href?: string;
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'blank';
icon?: IconName;
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
const variantClasses = {
primary: [
'bg-primary-solid',
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'ring-0',
'contrast-more:ring-1',
],
blank: [
'bg-transparent',
'text-tint',
'ring-0',
'shadow-none',
'hover:bg-primary-hover',
'hover:text-primary',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
],
secondary: [
'bg-tint',
'text-tint',
'hover:bg-tint-hover',
'hover:text-primary',
'contrast-more:bg-tint-subtle',
],
};
export function Button({
href,
children,
variant = 'primary',
size = 'default',
className,
insights,
target,
label,
icon,
iconOnly = false,
...rest
}: ButtonProps) {
const variantClasses =
variant === 'primary'
? //PRIMARY
[
'bg-primary-solid',
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'ring-0',
'contrast-more:ring-1',
]
: // SECONDARY
[
'bg-tint',
'text-tint',
'hover:bg-tint-hover',
'hover:text-primary',
'contrast-more:bg-tint-subtle',
];
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
default: ['text-base', 'px-4', 'py-2'],
medium: ['text-sm', 'px-3', 'py-1.5'],
small: ['text-xs', 'px-3 py-2'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
};
const sizeClasses = sizes[size] || sizes.default;
const domClassName = tcls(
'button',
'inline-block',
'inline-flex',
'items-center',
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'place-self-start',
// 'place-self-start',
'ring-1',
'ring-tint',
@@ -79,22 +97,31 @@ export function Button({
'grow-0',
'shrink-0',
'truncate',
variantClasses,
variantClasses[variant],
sizeClasses,
className
);
if (href) {
return (
<Link href={href} className={domClassName} insights={insights} {...rest}>
{children}
<Link
href={href}
className={domClassName}
insights={insights}
aria-label={label}
target={target}
{...rest}
>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</Link>
);
}
return (
<button type="button" className={domClassName} {...rest}>
{children}
<button type="button" className={domClassName} aria-label={label} {...rest}>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</button>
);
}
@@ -2,7 +2,10 @@ import type { SVGProps } from 'react';
import { tcls } from '@/lib/tailwind';
export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
export const Loading = ({
busy = true,
...props
}: { busy?: boolean } & SVGProps<SVGSVGElement>) => {
return (
<svg
width="100%"
@@ -14,7 +17,11 @@ export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
{...props}
>
<path
className={tcls('animate-[pathLoading_2s_ease_infinite_forwards]')}
className={tcls(
busy
? 'animate-[pathLoading_2s_ease_infinite_forwards]'
: 'animate-[pathLoading_2s_ease_forwards]'
)}
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
@@ -27,7 +34,8 @@ export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
strokeOpacity="0.24"
strokeOpacity={busy ? 0.24 : 1}
className="transition-opacity duration-1000"
fill="none"
strokeWidth="11"
strokeLinecap="round"
@@ -25,11 +25,11 @@ export const linkStyles = [
/**
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
export function StyledLink(props: Omit<LinkProps, 'style'> & { className?: ClassValue }) {
const { className, ...rest } = props;
return (
<Link {...rest} className={tcls(linkStyles, style)}>
<Link {...rest} className={tcls(linkStyles, className)}>
{props.children}
</Link>
);
+29 -3
View File
@@ -19,6 +19,7 @@ import { getGitbookAppHref } from './links';
import { resolvePageId } from './pages';
import { findSiteSpaceById } from './sites';
import type { ClassValue } from './tailwind';
import { filterOutNullable } from './typescript';
export interface ResolvedContentRef {
/** Text to render in the content ref */
@@ -27,8 +28,12 @@ export interface ResolvedContentRef {
subText?: string;
/** Icon associated with it */
icon?: React.ReactNode;
/** ID of the content ref */
id?: string;
/** Emoji associated with the reference */
emoji?: string;
/** The content ref's ancestors */
ancestors?: { icon?: React.ReactNode; label: string; href?: string }[];
/** URL to open for the content ref */
href: string;
/** True if the content ref is active */
@@ -115,6 +120,14 @@ export async function resolveContentRef(
: resolvePageId(pages, contentRef.page);
const page = resolvePageResult?.page;
const ancestors =
resolvePageResult?.ancestors.map((ancestor) => ({
label: ancestor.title,
icon: <PageIcon page={ancestor} style={iconStyle} />,
href: resolveAsAbsoluteURL
? linker.toAbsoluteURL(linker.toPathForPage({ page: ancestor, pages }))
: linker.toPathForPage({ page: ancestor, pages }),
})) ?? [];
if (!page) {
return null;
}
@@ -125,10 +138,16 @@ export async function resolveContentRef(
let text = '';
let icon: React.ReactNode | undefined = undefined;
let emoji: string | undefined = undefined;
const href = linker.toPathForPage({ page, pages, anchor });
// Compute the text to display for the link
if (anchor) {
text = `#${anchor}`;
ancestors.push({
label: page.title,
icon: <PageIcon page={page} style={iconStyle} />,
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
});
if (resolveAnchorText) {
const document = await getPageDocument(dataFetcher, space, page);
@@ -151,13 +170,14 @@ export async function resolveContentRef(
icon = <PageIcon page={page} style={iconStyle} />;
}
const href = linker.toPathForPage({ page, pages, anchor });
return {
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
text,
subText: page.description,
ancestors: ancestors,
emoji,
icon,
id: page.id,
active: !anchor && page.id === activePage?.id,
};
}
@@ -346,6 +366,12 @@ async function resolveContentRefInSpace(
return {
...resolved,
subText: space.title,
ancestors: [
{
label: space.title,
href: baseURL.toString(),
},
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
}
+2 -2
View File
@@ -295,7 +295,7 @@ const config: Config = {
),
},
animation: {
present: 'present .5s ease-out both',
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease',
scaleOut: 'scaleOut 200ms ease',
fadeIn: 'fadeIn 200ms ease forwards',
@@ -330,7 +330,7 @@ const config: Config = {
present: {
from: {
opacity: '0',
transform: 'translateY(2rem) scale(0.9)',
transform: 'translateY(1rem) scale(90%)',
},
to: {
opacity: '1',