diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx
index 0627202169..5069db59d7 100644
--- a/packages/gitbook/src/components/primitives/Button.tsx
+++ b/packages/gitbook/src/components/primitives/Button.tsx
@@ -26,13 +26,13 @@ export const variantClasses = {
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
- 'ring-0',
- 'contrast-more:ring-1',
+ 'border-0',
+ 'contrast-more:border-1',
],
blank: [
'bg-transparent',
'text-tint',
- 'ring-0',
+ 'border-0',
'shadow-none',
'hover:bg-primary-hover',
'hover:text-primary',
@@ -74,6 +74,19 @@ export function Button({
const domClassName = tcls(variantClasses[variant], sizeClasses, className);
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
+ const content = (
+ <>
+ {icon ? (
+ typeof icon === 'string' ? (
+
+ ) : (
+ icon
+ )
+ ) : null}
+ {iconOnly ? null : label}
+ >
+ );
+
if (href) {
return (
- {icon ? (
- typeof icon === 'string' ? (
-
- ) : (
- icon
- )
- ) : null}
- {iconOnly ? null : label}
+ {content}
);
}
@@ -104,16 +110,9 @@ export function Button({
aria-label={label}
{...rest}
>
- {icon ? (
- typeof icon === 'string' ? (
-
- ) : (
- icon
- )
- ) : null}
- {iconOnly ? null : label}
+ {content}
);
- 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',
- },
- }
- );
-}