mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
Support rendering upcoming Update blocks (#3794)
This commit is contained in:
+1
-1
@@ -42,7 +42,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.147.0",
|
||||
"@gitbook/api": "0.149.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -160,20 +160,20 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Button',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Ask - AI Mode: Assistant - Button',
|
||||
// url: getCustomizationURL({
|
||||
// ai: {
|
||||
// mode: CustomizationAIMode.Assistant,
|
||||
// },
|
||||
// }),
|
||||
// screenshot: false,
|
||||
// run: async (page) => {
|
||||
// await page.getByTestId('ai-chat-button').click();
|
||||
// await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
// await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
|
||||
@@ -34,6 +35,8 @@ import { Stepper } from './Stepper';
|
||||
import { StepperStep } from './StepperStep';
|
||||
import { Table } from './Table';
|
||||
import { Tabs } from './Tabs';
|
||||
import { Update } from './Update';
|
||||
import { Updates } from './Updates';
|
||||
|
||||
export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps {
|
||||
block: Block;
|
||||
@@ -105,6 +108,10 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <Stepper {...props} block={block} />;
|
||||
case 'stepper-step':
|
||||
return <StepperStep {...props} block={block} />;
|
||||
case 'updates':
|
||||
return <Updates {...props} block={block} />;
|
||||
case 'update':
|
||||
return <Update {...props} block={block} />;
|
||||
case 'if':
|
||||
// If block should be processed by the API.
|
||||
return null;
|
||||
@@ -165,10 +172,13 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'images':
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
case 'column':
|
||||
case 'update':
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
default:
|
||||
return nullIfNever(block);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { formatDateFull, formatNumericDate } from '@/components/utils/dates';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockUpdate, DocumentBlockUpdates } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
import type { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function Update(props: BlockProps<DocumentBlockUpdate>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
// Get the parent Updates block to retrieve properties from
|
||||
const parentUpdates = ancestorBlocks.find(
|
||||
(ancestor): ancestor is DocumentBlockUpdates => ancestor.type === 'updates'
|
||||
);
|
||||
|
||||
if (!parentUpdates) {
|
||||
assert(parentUpdates, 'Parent updates block should exist');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the date for this Update block and parse it to a Date object
|
||||
const date = block.data.date;
|
||||
const parsedDate = parseISODate(date);
|
||||
|
||||
if (!parsedDate) {
|
||||
assert(date, 'Date should exist on Update block');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Then get the format from the parent Updates block and use that format
|
||||
const dateFormat = parentUpdates.data?.format ?? 'full';
|
||||
const displayDate =
|
||||
dateFormat === 'numeric' ? formatNumericDate(parsedDate) : formatDateFull(parsedDate);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20',
|
||||
style
|
||||
)}
|
||||
>
|
||||
<div className="h-fit w-40 min-w-40 shrink-0">
|
||||
<time
|
||||
// Adding a dateTime attribute for accessibility (and SEO)
|
||||
dateTime={date}
|
||||
className="inline-flex items-center font-medium text-neutral-10 text-xs uppercase tracking-wide"
|
||||
>
|
||||
{displayDate}
|
||||
</time>
|
||||
</div>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
// Remove padding-top from headings when they're the first child (similar to column-first-of-type pattern)
|
||||
style="[&>*:first-child]:!pt-0 flex-1 space-y-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ISO date string into a Date object.
|
||||
*/
|
||||
function parseISODate(value: string): Date | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DocumentBlockUpdates } from '@gitbook/api';
|
||||
import type { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function Updates(props: BlockProps<DocumentBlockUpdates>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
return (
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
style={[style, 'flex flex-col gap-20']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -70,3 +70,21 @@ export function SkeletonSmall(props: { id?: string; style?: ClassValue }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder when loading an Update block
|
||||
*/
|
||||
export function SkeletonUpdate(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="status"
|
||||
aria-busy
|
||||
className={tcls('flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20', style)}
|
||||
>
|
||||
<SkeletonSmall id={id} style={['w-48', style]} />
|
||||
<LoadingPane tile={96} delay={0} style={['rounded-md', 'w-full']} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Format date as MM/DD/YYYY (e.g., "05/11/2025")
|
||||
*/
|
||||
export function formatNumericDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date as "Month Day, Year" (e.g., "November 5, 2025")
|
||||
*/
|
||||
export function formatDateFull(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Image';
|
||||
export * from './dates';
|
||||
|
||||
Reference in New Issue
Block a user