diff --git a/src/components/DocumentView/BlockContentRef.tsx b/src/components/DocumentView/BlockContentRef.tsx index a2fe63754..34f86af68 100644 --- a/src/components/DocumentView/BlockContentRef.tsx +++ b/src/components/DocumentView/BlockContentRef.tsx @@ -8,7 +8,9 @@ export async function BlockContentRef(props: BlockProps const { block, context, style } = props; const kind = block?.data?.ref?.kind; - const resolved = await context.resolveContentRef(block.data.ref); + const resolved = await context.resolveContentRef(block.data.ref, { + resolveAnchorText: true, + }); if (!resolved) { return null; } diff --git a/src/components/DocumentView/DocumentView.tsx b/src/components/DocumentView/DocumentView.tsx index 6712370f6..8ed3247f9 100644 --- a/src/components/DocumentView/DocumentView.tsx +++ b/src/components/DocumentView/DocumentView.tsx @@ -1,7 +1,7 @@ import { ContentRef, JSONDocument } from '@gitbook/api'; import { ContentTarget } from '@/lib/api'; -import { ResolvedContentRef } from '@/lib/references'; +import { ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references'; import { ClassValue } from '@/lib/tailwind'; import { Blocks } from './Blocks'; @@ -21,7 +21,10 @@ export interface DocumentContext { /** * Resolve a content reference. */ - resolveContentRef: (ref: ContentRef) => Promise; + resolveContentRef: ( + ref: ContentRef, + options?: ResolveContentRefOptions, + ) => Promise; /** * Transform an ID to be added to the DOM. diff --git a/src/components/DocumentView/Mention.tsx b/src/components/DocumentView/Mention.tsx index 90f3277a3..3cc0f8fcc 100644 --- a/src/components/DocumentView/Mention.tsx +++ b/src/components/DocumentView/Mention.tsx @@ -7,7 +7,9 @@ import { InlineProps } from './Inline'; export async function Mention(props: InlineProps) { const { inline, context } = props; - const resolved = await context.resolveContentRef(inline.data.ref); + const resolved = await context.resolveContentRef(inline.data.ref, { + resolveAnchorText: true, + }); if (!resolved) { return null; diff --git a/src/components/DocumentView/Table/RecordColumnValue.tsx b/src/components/DocumentView/Table/RecordColumnValue.tsx index dc21e410c..7e625695e 100644 --- a/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -133,7 +133,11 @@ export async function RecordColumnValue( ); case 'content-ref': { - const resolved = value ? await context.resolveContentRef(value as ContentRef) : null; + const resolved = value + ? await context.resolveContentRef(value as ContentRef, { + resolveAnchorText: true, + }) + : null; return ( {resolved && resolved.emoji ? ( diff --git a/src/components/PageBody/PageBody.tsx b/src/components/PageBody/PageBody.tsx index 12485a5fd..0fabd5352 100644 --- a/src/components/PageBody/PageBody.tsx +++ b/src/components/PageBody/PageBody.tsx @@ -70,7 +70,8 @@ export function PageBody(props: { context={{ mode: 'default', content: contentTarget, - resolveContentRef: (ref) => resolveContentRef(ref, context), + resolveContentRef: (ref, options) => + resolveContentRef(ref, context, options), }} /> ) : ( diff --git a/src/lib/document.ts b/src/lib/document.ts index 05c36da1d..16dbd995d 100644 --- a/src/lib/document.ts +++ b/src/lib/document.ts @@ -147,3 +147,84 @@ export function isNodeEmpty( const text = getNodeText(node); return text.trim().length === 0; } + +/** + * Get the title for a node. + */ +export function getBlockTitle(block: DocumentBlock): string { + switch (block.type) { + case 'expandable': { + const titleFragment = getNodeFragmentByType(block, 'title'); + if (titleFragment) { + return getNodeText(titleFragment); + } + return ''; + } + + case 'tabs-item': { + return block.data.title ?? ''; + } + + case 'swagger': { + return `${block.data.method?.toUpperCase()} ${block.data.path}`; + } + + case 'heading-1': + case 'heading-2': + case 'heading-3': + default: + return getNodeText(block); + } +} + +/** + * Get a block by its ID in the document. + */ +export function getBlockById(document: JSONDocument, id: string): DocumentBlock | null { + return findBlock(document, (block) => { + if ('meta' in block && block.meta && 'id' in block.meta) { + return block.meta.id === id; + } + return false; + }); +} + +/** + * Find a block by a predicate in the document. + */ +function findBlock( + container: JSONDocument | DocumentBlock | DocumentFragment, + test: (block: DocumentBlock) => boolean, +): DocumentBlock | null { + if (!('nodes' in container)) { + return null; + } + + for (const block of container.nodes) { + if (block.object !== 'block') { + return null; + } + + if (test(block)) { + return block; + } + + if (block.object === 'block' && 'nodes' in block) { + const result = findBlock(block, test); + if (result) { + return result; + } + } + + if (block.object === 'block' && 'fragments' in block) { + for (const fragment of block.fragments) { + const result = findBlock(fragment, test); + if (result) { + return result; + } + } + } + } + + return null; +} diff --git a/src/lib/references.ts b/src/lib/references.ts index b30012012..8c5a8bdaf 100644 --- a/src/lib/references.ts +++ b/src/lib/references.ts @@ -4,12 +4,14 @@ import assertNever from 'assert-never'; import { ContentPointer, getCollection, + getDocument, getRevisionFile, getSpace, getSpaceContentData, getUserById, ignoreAPIError, } from './api'; +import { getBlockById, getBlockTitle } from './document'; import { gitbookAppHref, pageHref, PageHrefContext } from './links'; import { getPagePath, resolvePageId } from './pages'; @@ -53,13 +55,23 @@ export interface ContentRefContext extends PageHrefContext { page?: RevisionPageDocument; } +export interface ResolveContentRefOptions { + /** + * Should the content ref be rendered as text. + * @default false + */ + resolveAnchorText?: boolean; +} + /** * Resolve a content reference to be rendered. */ export async function resolveContentRef( contentRef: ContentRef, context: ContentRefContext, + options: ResolveContentRefOptions = {}, ): Promise { + const { resolveAnchorText = false } = options; const { space, revisionId, pages, page: activePage, ...linksContext } = context; switch (contentRef.kind) { @@ -100,9 +112,33 @@ export async function resolveContentRef( } let anchor = contentRef.kind === 'page' ? undefined : contentRef.anchor; - const isCurrentPage = page.id === activePage?.id; + let href = ''; + let text = ''; + let emoji: string | undefined = undefined; + + // Compute the text to display for the link + if (anchor) { + text = '#' + anchor; + + if (resolveAnchorText) { + const document = page.documentId + ? await getDocument(space.id, page.documentId) + : null; + if (document) { + const block = getBlockById(document, anchor); + if (block) { + text = getBlockTitle(block); + } + } + } + } else { + text = page.title; + emoji = isCurrentPage ? undefined : page.emoji; + } + + // Compute the href for the link if (context.baseUrl) { // Page in another content href = new URL(getPagePath(pages, page), context.baseUrl).toString(); @@ -117,8 +153,8 @@ export async function resolveContentRef( return { href, - text: anchor ? (isCurrentPage ? '' : page.title) + '#' + anchor : page.title, - emoji: isCurrentPage ? undefined : page.emoji, + text, + emoji, active: !anchor && page.id === activePage?.id, }; }