diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 4b7d822e6..dc1138639 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -25,7 +25,7 @@ import { categorizeVariants } from '../SpaceLayout/categorizeVariants'; import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown'; import { PageTags } from './PageTags'; import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context'; -import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages'; +import { getPageDescription, type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages'; import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; import { getPageRSSURL } from '@/routes/rss'; @@ -132,6 +132,7 @@ export async function PageHeader(props: { } const hasContextCrumbs = contextCrumbs.length > 0; const showBreadcrumbs = hasAncestors || hasContextCrumbs; + const description = getPageDescription(page); const pageActionsEnabled = page.layout.actions !== false; @@ -148,7 +149,7 @@ export async function PageHeader(props: { ].some((type) => isPageActionEnabled(context.customization, type)); const hasPageActions = pageActionsEnabled && (hasConfiguredPageActions || withRSSFeed); - if (!page.layout.title && !page.layout.description && !hasPageActions) { + if (!page.layout.title && !description && !hasPageActions) { return null; } @@ -265,7 +266,7 @@ export async function PageHeader(props: { {page.title} ) : null} - {page.description && page.layout.description ? ( + {description ? (

- {page.description} + {description}

) : null} diff --git a/packages/gitbook/src/lib/markdownPage.test.ts b/packages/gitbook/src/lib/markdownPage.test.ts index f7eb9d955..c0a5e8253 100644 --- a/packages/gitbook/src/lib/markdownPage.test.ts +++ b/packages/gitbook/src/lib/markdownPage.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'bun:test'; -import type { GitBookAnyContext } from './context'; +import type { RevisionPageDocument } from '@gitbook/api'; + +import type { GitBookAnyContext, GitBookSiteContext } from './context'; import { createLinker, linkerWithDirectPagePaths, linkerWithMarkdownPages } from './links'; -import { fromPageMarkdown, toPageMarkdown } from './markdownPage'; +import { fromPageMarkdown, getMarkdownForPage, toPageMarkdown } from './markdownPage'; const page = { id: 'designer', @@ -32,6 +34,84 @@ async function rewrite(markdown: string) { return toPageMarkdown(await fromPageMarkdown(context, { markdown, pagePath: 'workflows' })); } +async function renderPage( + markdown: string, + pageOverrides: Omit, 'layout'> & { + layout?: Partial; + } +) { + const page = { + ...pageBase, + ...pageOverrides, + layout: { + ...pageBase.layout, + ...pageOverrides.layout, + }, + } as RevisionPageDocument; + + return getMarkdownForPage( + { + ...context, + revisionId: 'revision', + dataFetcher: { + getRevisionPageMarkdown: async () => ({ data: markdown }), + }, + } as unknown as GitBookSiteContext, + { page, ancestors: [] } + ); +} + +const pageBase = { + ...page, + layout: { + description: true, + }, +} as RevisionPageDocument; + +describe('page descriptions in markdown', () => { + it('renders visible descriptions immediately after the H1', async () => { + expect( + await renderPage('# About the Workflow Designer\n\nSome content.', { + description: 'Design workflows visually.', + }) + ).toBe('# About the Workflow Designer\n\nDesign workflows visually.\n\nSome content.\n'); + }); + + it('omits hidden and missing descriptions', async () => { + const pageMarkdown = '# About the Workflow Designer\n\nSome content.'; + const markdown = await renderPage(pageMarkdown, { + description: 'Design workflows visually.', + layout: { description: false }, + }); + + expect(markdown).toBe('# About the Workflow Designer\n\nSome content.\n'); + expect(await renderPage(pageMarkdown, {})).toBe( + '# About the Workflow Designer\n\nSome content.\n' + ); + }); + + it('keeps child links for an otherwise empty parent page', async () => { + const markdown = await renderPage('# Workflows\n', { + title: 'Workflows', + description: 'Build and manage workflows.', + pages: [ + { + ...pageBase, + id: 'child', + title: 'First workflow', + path: 'first-workflow', + slug: 'first-workflow', + pages: [], + }, + ], + }); + + expect(markdown).toContain('# Workflows'); + expect(markdown).toContain('Build and manage workflows.'); + expect(markdown).toContain('First workflow'); + }); +}); + describe('HTML links in page markdown', () => { it.each([ ['/pages/designer', linker.toPathForPagePath({ path: page.path })], diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts index 75590995e..92929cb7a 100644 --- a/packages/gitbook/src/lib/markdownPage.ts +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -26,6 +26,7 @@ import { } from '@/lib/context'; import { DataFetcherError, throwIfDataError } from '@/lib/data'; import type { ResolvedPagePath } from '@/lib/pages'; +import { getPageDescription } from '@/lib/pages'; import { getIndexablePages } from '@/lib/sitemap'; import { getMarkdownForPagesTree } from '@/routes/llms'; @@ -70,13 +71,14 @@ export async function getMarkdownForPage( markdown: rawMarkdown, pagePath: page.path, }); - insertDescriptionAfterHeading(tree, page.description); // Handle empty document pages which have children if (isEmptyMarkdownPage(tree) && page.pages.length > 0) { return servePageGroup(context, page); } + insertDescriptionAfterHeading(tree, getPageDescription(page)); + return toPageMarkdown(tree); } @@ -107,13 +109,14 @@ export async function getMarkdownForPageInSpace( markdown: rawMarkdown, pagePath: page.path, }); - insertDescriptionAfterHeading(tree, page.description); // Handle empty document pages which have children (same as getMarkdownForPage) if (isEmptyMarkdownPage(tree) && page.pages.length > 0) { return renderGroupPageMarkdown({ linker: siteSpaceContext.linker, page }); } + insertDescriptionAfterHeading(tree, getPageDescription(page)); + return toPageMarkdown(tree); } @@ -225,7 +228,8 @@ async function renderGroupPageMarkdown(args: { }): Promise { const { linker, page } = args; const indexablePages = getIndexablePages(page.pages); - const description = page.type === RevisionPageType.Document ? page.description : undefined; + const description = + page.type === RevisionPageType.Document ? getPageDescription(page) : undefined; const markdownTree: Root = { type: 'root', diff --git a/packages/gitbook/src/lib/pages.test.ts b/packages/gitbook/src/lib/pages.test.ts index f74da9131..d4cdfba7c 100644 --- a/packages/gitbook/src/lib/pages.test.ts +++ b/packages/gitbook/src/lib/pages.test.ts @@ -9,12 +9,29 @@ import { import { extractPagePath, + getPageDescription, getSimilarPages, resolveFirstDocument, resolvePagePath, resolvePagePathDocumentOrGroup, } from './pages'; +describe('getPageDescription', () => { + const page = { + description: 'A useful description', + layout: { description: true }, + }; + + it('returns the description when it is visible', () => { + expect(getPageDescription(page)).toBe(page.description); + }); + + it('omits hidden and missing descriptions', () => { + expect(getPageDescription({ ...page, layout: { description: false } })).toBeUndefined(); + expect(getPageDescription({ layout: { description: true } })).toBeUndefined(); + }); +}); + describe('extractPagePath', () => { const baseURL = 'https://docs.example.com/api/'; diff --git a/packages/gitbook/src/lib/pages.ts b/packages/gitbook/src/lib/pages.ts index 8bcfc60bc..277c68fcf 100644 --- a/packages/gitbook/src/lib/pages.ts +++ b/packages/gitbook/src/lib/pages.ts @@ -17,6 +17,15 @@ export type ResolvedPagePath & { + layout: Pick; + } +): string | undefined { + return page.description && page.layout.description ? page.description : undefined; +} + /** * Resolve a page path to a page document. */ diff --git a/packages/gitbook/src/routes/llms-full.ts b/packages/gitbook/src/routes/llms-full.ts index 64fbfbf08..5b6e17de9 100644 --- a/packages/gitbook/src/routes/llms-full.ts +++ b/packages/gitbook/src/routes/llms-full.ts @@ -12,6 +12,7 @@ import { import { throwIfDataError } from '@/lib/data'; import { getMarkdownContentType } from '@/lib/markdown-content-type'; import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage'; +import { getPageDescription } from '@/lib/pages'; import { getIndexablePages } from '@/lib/sitemap'; import { filterSiteSpacesByLocale, getSiteStructureSections } from '@/lib/sites'; @@ -199,12 +200,13 @@ async function getMarkdownForPage( pagePath: page.path, }); - if (page.description) { + const description = getPageDescription(page); + if (description) { // The first node is the page title as a H1, we insert the description as a paragraph // after it. const descriptionNode: Paragraph = { type: 'paragraph', - children: [{ type: 'text', value: page.description }], + children: [{ type: 'text', value: description }], }; tree.children.splice(1, 0, descriptionNode); }