diff --git a/.changeset/forty-dolls-decide.md b/.changeset/forty-dolls-decide.md new file mode 100644 index 0000000000..85a5932e71 --- /dev/null +++ b/.changeset/forty-dolls-decide.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Implement AI actions dropdown diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx new file mode 100644 index 0000000000..1e6f0ac9fe --- /dev/null +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -0,0 +1,268 @@ +'use client'; + +import { useAIChatController } from '@/components/AI/useAIChat'; +import { useAIChatState } from '@/components/AI/useAIChat'; +import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon'; +import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon'; +import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; +import AIChatIcon from '@/components/AIChat/AIChatIcon'; +import { Button } from '@/components/primitives/Button'; +import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; +import { tString, useLanguage } from '@/intl/client'; +import type { TranslationLanguage } from '@/intl/translations'; +import { Icon, type IconName, IconStyle } from '@gitbook/icons'; +import assertNever from 'assert-never'; +import type React from 'react'; +import { useEffect, useRef } from 'react'; +import { create } from 'zustand'; + +type AIActionType = 'button' | 'dropdown-menu-item'; + +/** + * Opens our AI Docs Assistant. + */ +export function OpenDocsAssistant(props: { type: AIActionType }) { + const { type } = props; + const chatController = useAIChatController(); + const chat = useAIChatState(); + const language = useLanguage(); + + return ( + } + label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))} + shortLabel={tString(language, 'ask')} + description={tString( + language, + 'ai_chat_ask_about_page', + tString(language, 'ai_chat_assistant_name') + )} + disabled={chat.loading} + onClick={() => { + // Open the chat if it's not already open + if (!chat.opened) { + chatController.open(); + } + + // Send the "What is this page about?" message + chatController.postMessage({ + message: tString(language, 'ai_chat_suggested_questions_about_this_page'), + }); + }} + /> + ); +} + +// We need to store the copied state in a store to share the state between the +// copy button and the dropdown menu item. +const useCopiedStore = create<{ + copied: boolean; + setCopied: (copied: boolean) => void; +}>((set) => ({ + copied: false, + setCopied: (copied: boolean) => set({ copied }), +})); + +/** + * Copies the markdown version of the page to the clipboard. + */ +export function CopyMarkdown(props: { + markdown: string; + type: AIActionType; + isDefaultAction?: boolean; +}) { + const { markdown, type, isDefaultAction } = props; + const language = useLanguage(); + const { copied, setCopied } = useCopiedStore(); + const timeoutRef = useRef | null>(null); + + // Close the dropdown menu manually after the copy button is clicked + const closeDropdownMenu = () => { + const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); + + // Cancel if no dropdown menu is open + if (!dropdownMenu) return; + + // Dispatch on `document` so that the event is captured by Radix's + // dismissable-layer listener regardless of focus location. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const onClick = (e: React.MouseEvent) => { + // Prevent default behavior for non-default actions to avoid closing the dropdown. + // This allows showing transient UI (e.g., a "copied" state) inside the menu item. + // Default action buttons are excluded from this behavior. + if (!isDefaultAction) { + e.preventDefault(); + } + + // Cancel any pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + navigator.clipboard.writeText(markdown); + + setCopied(true); + + // Reset the copied state after 2 seconds + timeoutRef.current = setTimeout(() => { + // Close the dropdown menu if it's a dropdown menu item and not the default action + if (type === 'dropdown-menu-item' && !isDefaultAction) { + closeDropdownMenu(); + } + + setCopied(false); + }, 2000); + }; + + return ( + + ); +} + +/** + * Redirects to the markdown version of the page. + */ +export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) { + const { markdownPageUrl, type } = props; + const language = useLanguage(); + + return ( + } + label={tString(language, 'view_page_markdown')} + description={tString(language, 'view_page_plaintext')} + href={`${markdownPageUrl}.md`} + /> + ); +} + +/** + * Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude. + */ +export function OpenInLLM(props: { + provider: 'chatgpt' | 'claude'; + url: string; + type: AIActionType; +}) { + const { provider, url, type } = props; + const language = useLanguage(); + + const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude'; + + return ( + + ) : ( + + ) + } + label={tString(language, 'open_in', providerLabel)} + shortLabel={providerLabel} + description={tString(language, 'ai_chat_ask_about_page', providerLabel)} + href={getLLMURL(provider, url, language)} + /> + ); +} + +/** + * Wraps an action in a button (for the default action) or dropdown menu item. + */ +function AIActionWrapper(props: { + type: AIActionType; + icon: IconName | React.ReactNode; + label: string; + /** + * The label to display in the button. If not provided, the `label` will be used. + */ + shortLabel?: string; + onClick?: (e: React.MouseEvent) => void; + description?: string; + href?: string; + disabled?: boolean; +}) { + const { type, icon, label, shortLabel, onClick, href, description, disabled } = props; + + if (type === 'button') { + return ( + ); - return iconOnly ? {button} : button; + return iconOnly && label ? {button} : button; } diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index be4a12a4c3..20bb7d29b1 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -27,8 +27,25 @@ export function DropdownMenu(props: { className?: ClassValue; /** Open the dropdown on hover */ openOnHover?: boolean; + /** + * Side of the dropdown + * @default "bottom" + */ + side?: RadixDropdownMenu.DropdownMenuContentProps['side']; + /** + * Alignment of the dropdown + * @default "start" + */ + align?: RadixDropdownMenu.DropdownMenuContentProps['align']; }) { - const { button, children, className, openOnHover = false } = props; + const { + button, + children, + className, + openOnHover = false, + side = 'bottom', + align = 'start', + } = props; const [hovered, setHovered] = useState(false); const [clicked, setClicked] = useState(false); @@ -55,12 +72,13 @@ export function DropdownMenu(props: { collisionPadding={8} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} - align="start" - className="z-40 animate-scaleIn pt-2" + align={align} + side={side} + className="z-40 animate-scaleIn border-tint pt-2" >
@@ -116,16 +134,17 @@ export function DropdownButton(props: { export function DropdownMenuItem( props: { href?: string; + target?: React.HTMLAttributeAnchorTarget; active?: boolean; className?: ClassValue; children: React.ReactNode; } & LinkInsightsProps & RadixDropdownMenu.DropdownMenuItemProps ) { - const { children, active = false, href, className, insights, ...rest } = props; + const { children, active = false, href, className, insights, target, ...rest } = props; const itemClassName = tcls( - 'rounded straight-corners:rounded-sm px-3 py-1 text-sm flex gap-2 items-center', + 'rounded straight-corners:rounded-sm circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center', active ? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover' : 'data-[highlighted]:bg-tint-hover', @@ -137,7 +156,7 @@ export function DropdownMenuItem( if (href) { return ( - + {children} diff --git a/packages/gitbook/src/components/primitives/styles.ts b/packages/gitbook/src/components/primitives/styles.ts index 9e8b438a64..fcd5f48bdf 100644 --- a/packages/gitbook/src/components/primitives/styles.ts +++ b/packages/gitbook/src/components/primitives/styles.ts @@ -10,9 +10,9 @@ export const ButtonStyles = [ 'circular-corners:rounded-full', // 'place-self-start', - 'ring-1', - 'ring-tint', - 'hover:ring-tint-hover', + 'border', + 'border-tint', + 'hover:border-tint-hover', 'shadow-sm', 'shadow-tint', @@ -21,9 +21,9 @@ export const ButtonStyles = [ 'active:shadow-none', 'depth-flat:shadow-none', - 'contrast-more:ring-tint-12', - 'contrast-more:hover:ring-2', - 'contrast-more:hover:ring-tint-12', + 'contrast-more:border-tint-12', + 'contrast-more:hover:border-2', + 'contrast-more:hover:border-tint-12', 'hover:scale-104', 'depth-flat:hover:scale-100', diff --git a/packages/gitbook/src/components/utils/isAIChatEnabled.ts b/packages/gitbook/src/components/utils/isAIChatEnabled.ts new file mode 100644 index 0000000000..d7e65a275e --- /dev/null +++ b/packages/gitbook/src/components/utils/isAIChatEnabled.ts @@ -0,0 +1,7 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { CustomizationAIMode } from '@gitbook/api'; + +// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025) +export const isAIChatEnabled = (context: GitBookSiteContext) => + context.customization.ai?.mode === CustomizationAIMode.Assistant && + (context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv'); diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 98a0c36503..2fe65b9baf 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -92,4 +92,14 @@ export const de = { ai_chat_tools_listed_pages: 'Docs durchsucht', ai_chat_tools_read_page: 'Seite ${1} gelesen', ai_chat_tools_mcp_tool: 'Aufgerufen ${1}', + ai_chat_ask: 'Frage ${1}', + ai_chat_ask_about_page: 'Frage ${1} zu dieser Seite', + copy_for_llms: 'Für LLMs kopieren', + copy_page_markdown: 'Seite als Markdown für LLMs kopieren', + copy_page: 'Seite kopieren', + view_page_markdown: 'Als Markdown anzeigen', + view_page_plaintext: 'Diese Seite als Klartext anzeigen', + open_in: 'Öffnen in ${1}', + open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.', + ask: 'Fragen', }; diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index 8685358b17..653f48f027 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -14,6 +14,7 @@ export const en = { search_results_count: '${1} results', search_scope_space: '${1}', search_scope_all: 'All content', + ask: 'Ask', search_ask: 'Ask "${1}"', search_ask_description: 'Find the answer with AI', search_ask_sources: 'Sources', @@ -90,4 +91,13 @@ export const en = { ai_chat_tools_listed_pages: 'Browsed the docs', ai_chat_tools_read_page: 'Read page ${1}', ai_chat_tools_mcp_tool: 'Called ${1}', + ai_chat_ask: 'Ask ${1}', + ai_chat_ask_about_page: 'Ask ${1} about this page', + copy_for_llms: 'Copy for LLMs', + copy_page_markdown: 'Copy page as Markdown for LLMs', + copy_page: 'Copy page', + view_page_markdown: 'View as Markdown', + view_page_plaintext: 'View this page as plain text', + open_in: 'Open in ${1}', + open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.', }; diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 8bfb8ba602..251c833015 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -94,4 +94,14 @@ export const es: TranslationLanguage = { ai_chat_tools_listed_pages: 'Exploró los docs', ai_chat_tools_read_page: 'Leyó la página ${1}', ai_chat_tools_mcp_tool: 'Llamó a ${1}', + ai_chat_ask: 'Preguntar ${1}', + ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página', + copy_for_llms: 'Copiar para LLMs', + copy_page_markdown: 'Copiar página como Markdown para LLMs', + copy_page: 'Copiar página', + view_page_markdown: 'Ver como Markdown', + view_page_plaintext: 'Ver esta página como texto plano', + open_in: 'Abrir en ${1}', + open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.', + ask: 'Preguntar', }; diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 6d865cb176..cc3370d9d5 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -92,4 +92,14 @@ export const fr: TranslationLanguage = { ai_chat_tools_listed_pages: 'Parcouru les docs', ai_chat_tools_read_page: 'Lu la page ${1}', ai_chat_tools_mcp_tool: 'Appelé ${1}', + ai_chat_ask: 'Demander à ${1}', + ai_chat_ask_about_page: 'Demander à ${1} au sujet de cette page', + copy_for_llms: 'Copier pour les LLMs', + copy_page_markdown: 'Copier la page en Markdown pour les LLMs', + copy_page: 'Copier la page', + view_page_markdown: 'Voir en Markdown', + view_page_plaintext: 'Voir cette page en texte brut', + open_in: 'Ouvrir dans ${1}', + open_in_llms_pre_prompt: 'Lis ${1} et réponds aux questions sur le contenu.', + ask: 'Demander', }; diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index eaae9defc4..cf42848497 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -92,4 +92,14 @@ export const ja: TranslationLanguage = { ai_chat_tools_listed_pages: 'ドキュメントを閲覧', ai_chat_tools_read_page: 'ページ ${1} を読みました', ai_chat_tools_mcp_tool: '${1} を呼び出しました', + ai_chat_ask: '${1} に質問する', + ai_chat_ask_about_page: 'このページについて ${1} に質問する', + copy_for_llms: 'LLM 用にコピー', + copy_page_markdown: 'ページを Markdown として LLM 用にコピー', + copy_page: 'ページをコピー', + view_page_markdown: 'Markdown として表示', + view_page_plaintext: 'このページをプレーンテキストで表示', + open_in: '${1} で開く', + open_in_llms_pre_prompt: '${1} を読んで内容に関する質問に答えてください。', + ask: '質問する', }; diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index dec0598349..2fe991e8d5 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -92,4 +92,14 @@ export const nl: TranslationLanguage = { ai_chat_tools_listed_pages: 'Docs doorzocht', ai_chat_tools_read_page: 'Pagina ${1} gelezen', ai_chat_tools_mcp_tool: '${1} aangeroepen', + ai_chat_ask: 'Vraag ${1}', + ai_chat_ask_about_page: 'Vraag ${1} over deze pagina', + copy_for_llms: 'Kopiëren voor LLMs', + copy_page_markdown: 'Pagina kopiëren als Markdown voor LLMs', + copy_page: 'Pagina kopiëren', + view_page_markdown: 'Bekijk als Markdown', + view_page_plaintext: 'Bekijk deze pagina als platte tekst', + open_in: 'Open in ${1}', + open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.', + ask: 'Vraag', }; diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 62ebdd46f9..f635ff4e43 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -92,4 +92,14 @@ export const no: TranslationLanguage = { ai_chat_tools_listed_pages: 'Bladde gjennom docs', ai_chat_tools_read_page: 'Leste side ${1}', ai_chat_tools_mcp_tool: 'Kallte ${1}', + ai_chat_ask: 'Spør ${1}', + ai_chat_ask_about_page: 'Spør ${1} om denne siden', + copy_for_llms: 'Kopier for LLMs', + copy_page_markdown: 'Kopier siden som Markdown for LLMs', + copy_page: 'Kopier side', + view_page_markdown: 'Vis som Markdown', + view_page_plaintext: 'Vis denne siden som ren tekst', + open_in: 'Åpne i ${1}', + open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.', + ask: 'Spør', }; diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 00e1fef884..9684a15e0c 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -92,4 +92,14 @@ export const pt_br = { ai_chat_tools_listed_pages: 'Navegou pelos docs', ai_chat_tools_read_page: 'Leu a página ${1}', ai_chat_tools_mcp_tool: 'Chamou ${1}', + ai_chat_ask: 'Perguntar a ${1}', + ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página', + copy_for_llms: 'Copiar para LLMs', + copy_page_markdown: 'Copiar página como Markdown para LLMs', + copy_page: 'Copiar página', + view_page_markdown: 'Ver como Markdown', + view_page_plaintext: 'Ver esta página em texto simples', + open_in: 'Abrir no ${1}', + open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.', + ask: 'Perguntar', }; diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 0cd81e2582..a5ebafe565 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -89,4 +89,14 @@ export const zh: TranslationLanguage = { ai_chat_tools_listed_pages: '浏览了文档', ai_chat_tools_read_page: '已读取页面 ${1}', ai_chat_tools_mcp_tool: '调用了 ${1}', + ai_chat_ask: '向 ${1} 提问', + ai_chat_ask_about_page: '向 ${1} 提问有关此页面', + copy_for_llms: '为 LLMs 复制', + copy_page_markdown: '将页面以 Markdown 格式复制供 LLMs 使用', + copy_page: '复制页面', + view_page_markdown: '以 Markdown 查看', + view_page_plaintext: '以纯文本查看此页面', + open_in: '在 ${1} 中打开', + open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。', + ask: '提问', }; diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts new file mode 100644 index 0000000000..d3cccb27cf --- /dev/null +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -0,0 +1,168 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import type { DataFetcherResponse } from '@/lib/data'; +import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; +import { getIndexablePages } from '@/lib/sitemap'; +import { getMarkdownForPagesTree } from '@/routes/llms'; +import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api'; +import type { Root } from 'mdast'; +import { toMarkdown } from 'mdast-util-to-markdown'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +type MarkdownResult = DataFetcherResponse; + +/** + * Generate a markdown version of a page. + * Handles both regular document pages and group pages (pages with child pages). + */ +export async function getMarkdownForPage( + context: GitBookSiteContext, + pagePath: string +): Promise { + const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath); + + if (!pageLookup) { + return { + error: { + message: `Page "${pagePath}" not found`, + code: 404, + }, + }; + } + + const { page } = pageLookup; + + // Only handle documents and groups + if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) { + return { + error: { + message: `Page "${pagePath}" is not a document or group`, + code: 400, + }, + }; + } + + // Handle group pages + if (page.type === RevisionPageType.Group) { + return servePageGroup(context, page); + } + + const { data: markdown, error } = await context.dataFetcher.getRevisionPageMarkdown({ + spaceId: context.space.id, + revisionId: context.revisionId, + pageId: page.id, + }); + + if (error) { + return { + error: { + message: 'An error occurred while fetching the markdown for this page', + code: 500, + }, + }; + } + + // Handle empty document pages which have children + if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) { + return servePageGroup(context, page); + } + + return { + data: markdown, + }; +} + +/** + * Determine if a page is empty. + * A page is empty if it has no content or only a title. + */ +function isEmptyMarkdownPage(markdown: string): boolean { + // Remove frontmatter + const stripped = markdown + .trim() + .replace(/^---\n[\s\S]*?\n---\n?/g, '') + .trim(); + + // Fast path: try to quickly detect obvious matches + if (/^[ \t]*# .+$/m.test(stripped)) { + // If there's a single heading line or empty lines, and no other content + return ( + /^#{1,6} .+\s*$/.test(stripped) && + !/\n\S+/g.test(stripped.split('\n').slice(1).join('\n')) + ); + } + + // Fallback: parse with remark for safety + const tree = unified().use(remarkParse).parse(stripped) as Root; + + let seenHeading = false; + + for (const node of tree.children) { + if (node.type === 'heading') { + if (seenHeading) { + return false; + } + seenHeading = true; + continue; + } + + // Allow empty whitespace-only text nodes (e.g., extra newlines) + if ( + node.type === 'paragraph' && + node.children.length === 1 && + node.children[0].type === 'text' && + !node.children[0].value.trim() + ) { + continue; + } + + // Anything else is disallowed + return false; + } + + return seenHeading; +} + +/** + * Generate markdown for a group page by creating a page listing. + * Creates a markdown document with the page title as heading and a list of child pages. + */ +async function servePageGroup( + context: GitBookSiteContext, + page: RevisionPageDocument | RevisionPageGroup +): Promise { + const siteSpaceUrl = context.space.urls.published; + if (!siteSpaceUrl) { + return { + error: { + message: `Page "${page.title}" is not published`, + code: 404, + }, + }; + } + + const indexablePages = getIndexablePages(page.pages); + + // Create a markdown tree with the page title as heading and a list of child pages + const markdownTree: Root = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: page.title }], + }, + ...(await getMarkdownForPagesTree(indexablePages, { + siteSpaceUrl, + linker: context.linker, + withMarkdownPages: true, + })), + ], + }; + + return { + data: toMarkdown(markdownTree, { + bullet: '-', + }), + }; +} diff --git a/packages/gitbook/src/routes/markdownPage.ts b/packages/gitbook/src/routes/markdownPage.ts index 83c004a66e..fbc031c989 100644 --- a/packages/gitbook/src/routes/markdownPage.ts +++ b/packages/gitbook/src/routes/markdownPage.ts @@ -1,148 +1,17 @@ import type { GitBookSiteContext } from '@/lib/context'; import { throwIfDataError } from '@/lib/data'; -import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; -import { getIndexablePages } from '@/lib/sitemap'; -import { getMarkdownForPagesTree } from '@/routes/llms'; -import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api'; -import type { Root } from 'mdast'; -import { toMarkdown } from 'mdast-util-to-markdown'; -import remarkParse from 'remark-parse'; -import { unified } from 'unified'; +import { getMarkdownForPage } from '@/lib/markdownPage'; /** - * Generate a markdown version of a page. - * Handles both regular document pages and group pages (pages with child pages). + * Serve a markdown version of a page. + * Returns a 404 if the page is not found. */ export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) { - const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath); + const result = await throwIfDataError(getMarkdownForPage(context, pagePath)); - if (!pageLookup) { - return new Response(`Page "${pagePath}" not found`, { status: 404 }); - } - - const { page } = pageLookup; - - // Only handle documents and groups - if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) { - return new Response(`Page "${pagePath}" is not a document or group`, { status: 404 }); - } - - // Handle group pages - if (page.type === RevisionPageType.Group) { - return servePageGroup(context, page); - } - - const markdown = await throwIfDataError( - context.dataFetcher.getRevisionPageMarkdown({ - spaceId: context.space.id, - revisionId: context.revisionId, - pageId: page.id, - }) - ); - - // Handle empty document pages which have children - if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) { - return servePageGroup(context, page); - } - - return new Response(markdown, { + return new Response(result, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', }, }); } - -/** - * Determine if a page is empty. - * A page is empty if it has no content or only a title. - */ -function isEmptyMarkdownPage(markdown: string): boolean { - // Remove frontmatter - const stripped = markdown - .trim() - .replace(/^---\n[\s\S]*?\n---\n?/g, '') - .trim(); - - // Fast path: try to quickly detect obvious matches - if (/^[ \t]*# .+$/m.test(stripped)) { - // If there's a single heading line or empty lines, and no other content - return ( - /^#{1,6} .+\s*$/.test(stripped) && - !/\n\S+/g.test(stripped.split('\n').slice(1).join('\n')) - ); - } - - // Fallback: parse with remark for safety - const tree = unified().use(remarkParse).parse(stripped) as Root; - - let seenHeading = false; - - for (const node of tree.children) { - if (node.type === 'heading') { - if (seenHeading) { - return false; - } - seenHeading = true; - continue; - } - - // Allow empty whitespace-only text nodes (e.g., extra newlines) - if ( - node.type === 'paragraph' && - node.children.length === 1 && - node.children[0].type === 'text' && - !node.children[0].value.trim() - ) { - continue; - } - - // Anything else is disallowed - return false; - } - - return seenHeading; -} - -/** - * Generate markdown for a group page by creating a page listing. - * Creates a markdown document with the page title as heading and a list of child pages. - */ -async function servePageGroup( - context: GitBookSiteContext, - page: RevisionPageDocument | RevisionPageGroup -) { - const siteSpaceUrl = context.space.urls.published; - if (!siteSpaceUrl) { - return new Response(`Page "${page.title}" is not published`, { status: 404 }); - } - - const indexablePages = getIndexablePages(page.pages); - - // Create a markdown tree with the page title as heading and a list of child pages - const markdownTree: Root = { - type: 'root', - children: [ - { - type: 'heading', - depth: 1, - children: [{ type: 'text', value: page.title }], - }, - ...(await getMarkdownForPagesTree(indexablePages, { - siteSpaceUrl, - linker: context.linker, - withMarkdownPages: true, - })), - ], - }; - - return new Response( - toMarkdown(markdownTree, { - bullet: '-', - }), - { - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - }, - } - ); -}