Skip to content

Commit e5bac69

Browse files
authored
Fix markdown page generation for groups (#3402)
1 parent 8d65983 commit e5bac69

File tree

5 files changed

+379
-133
lines changed

5 files changed

+379
-133
lines changed

.changeset/rotten-crews-tie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': patch
3+
---
4+
5+
Fix markdown page generation for groups

packages/gitbook/src/lib/pages.test.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'bun:test';
22
import type { RevisionPage } from '@gitbook/api';
33

4-
import { resolveFirstDocument } from './pages';
4+
import { resolveFirstDocument, resolvePagePath, resolvePagePathDocumentOrGroup } from './pages';
55

66
describe('resolveFirstDocument', () => {
77
it('should go into the first group', () => {
@@ -91,3 +91,151 @@ describe('resolveFirstDocument', () => {
9191
});
9292
});
9393
});
94+
95+
describe('resolvePagePath', () => {
96+
it('should resolve a page path', () => {
97+
const pages: RevisionPage[] = [
98+
{
99+
id: 'p1',
100+
title: 'Empty group',
101+
kind: 'sheet',
102+
type: 'document',
103+
document: {
104+
object: 'document',
105+
data: {
106+
schemaVersion: 1,
107+
},
108+
nodes: [],
109+
},
110+
urls: {
111+
app: 'https://app.gitbook.com/s/fvBF1lEt2CVd4RTffSOk/sales',
112+
},
113+
path: 'sales',
114+
slug: 'sales',
115+
pages: [],
116+
layout: {
117+
cover: true,
118+
title: true,
119+
description: true,
120+
tableOfContents: true,
121+
outline: true,
122+
pagination: true,
123+
},
124+
},
125+
];
126+
127+
const page = resolvePagePath(pages, 'sales');
128+
expect(page).toMatchObject({
129+
page: {
130+
id: 'p1',
131+
kind: 'sheet',
132+
type: 'document',
133+
document: {
134+
object: 'document',
135+
data: {
136+
schemaVersion: 1,
137+
},
138+
nodes: [],
139+
},
140+
urls: {
141+
app: 'https://app.gitbook.com/s/fvBF1lEt2CVd4RTffSOk/sales',
142+
},
143+
path: 'sales',
144+
slug: 'sales',
145+
pages: [],
146+
layout: {
147+
cover: true,
148+
title: true,
149+
description: true,
150+
tableOfContents: true,
151+
outline: true,
152+
pagination: true,
153+
},
154+
},
155+
});
156+
});
157+
158+
it('should resolve a page path with a group', () => {
159+
const pages: RevisionPage[] = [
160+
{
161+
id: 'p1',
162+
title: 'Empty group',
163+
kind: 'group',
164+
type: 'group',
165+
path: 'sales',
166+
slug: 'sales',
167+
pages: [
168+
{
169+
id: 'p2',
170+
title: 'Product Knowledge',
171+
kind: 'sheet',
172+
type: 'document',
173+
document: {
174+
object: 'document',
175+
data: {
176+
schemaVersion: 1,
177+
},
178+
nodes: [],
179+
},
180+
urls: {
181+
app: 'https://app.gitbook.com/s/fvBF1lEt2CVd4RTffSOk/product-knowledge',
182+
},
183+
path: 'product-knowledge',
184+
slug: 'product-knowledge',
185+
pages: [],
186+
layout: {
187+
cover: true,
188+
title: true,
189+
description: true,
190+
tableOfContents: true,
191+
outline: true,
192+
pagination: true,
193+
},
194+
},
195+
],
196+
},
197+
];
198+
199+
const page = resolvePagePathDocumentOrGroup(pages, 'sales');
200+
expect(page).toMatchObject({
201+
ancestors: [],
202+
page: {
203+
id: 'p1',
204+
kind: 'group',
205+
pages: [
206+
{
207+
document: {
208+
data: {
209+
schemaVersion: 1,
210+
},
211+
nodes: [],
212+
object: 'document',
213+
},
214+
id: 'p2',
215+
kind: 'sheet',
216+
layout: {
217+
cover: true,
218+
description: true,
219+
outline: true,
220+
pagination: true,
221+
tableOfContents: true,
222+
title: true,
223+
},
224+
pages: [],
225+
path: 'product-knowledge',
226+
slug: 'product-knowledge',
227+
title: 'Product Knowledge',
228+
type: 'document',
229+
urls: {
230+
app: 'https://app.gitbook.com/s/fvBF1lEt2CVd4RTffSOk/product-knowledge',
231+
},
232+
},
233+
],
234+
path: 'sales',
235+
slug: 'sales',
236+
title: 'Empty group',
237+
type: 'group',
238+
},
239+
});
240+
});
241+
});

packages/gitbook/src/lib/pages.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,63 @@ import {
88

99
export type AncestorRevisionPage = RevisionPageDocument | RevisionPageGroup;
1010

11+
type ResolvedPagePath<Page extends RevisionPageDocument | RevisionPageGroup> = {
12+
page: Page;
13+
ancestors: AncestorRevisionPage[];
14+
};
15+
1116
/**
1217
* Resolve a page path to a page document.
1318
*/
1419
export function resolvePagePath(
1520
rootPages: Revision['pages'],
1621
pagePath: string
17-
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined {
22+
): ResolvedPagePath<RevisionPageDocument> | undefined {
23+
const result = findPageByPath(rootPages, pagePath);
24+
25+
if (!result) {
26+
return undefined;
27+
}
28+
29+
return resolvePageDocument(result.page, result.ancestors);
30+
}
31+
32+
/**
33+
* Resolve a page path to a page document or group.
34+
* Similar to resolvePagePath but returns both documents and groups.
35+
*/
36+
export function resolvePagePathDocumentOrGroup(
37+
rootPages: Revision['pages'],
38+
pagePath: string
39+
): ResolvedPagePath<RevisionPageDocument | RevisionPageGroup> | undefined {
40+
const result = findPageByPath(rootPages, pagePath);
41+
42+
if (!result) {
43+
return undefined;
44+
}
45+
46+
return { page: result.page, ancestors: result.ancestors };
47+
}
48+
49+
/**
50+
* Helper function to find a page by path, handling empty paths and page iteration.
51+
*/
52+
function findPageByPath(
53+
rootPages: Revision['pages'],
54+
pagePath: string
55+
): ResolvedPagePath<RevisionPageDocument | RevisionPageGroup> | undefined {
56+
if (!pagePath) {
57+
const firstPage = resolveFirstDocument(rootPages, []);
58+
if (!firstPage) {
59+
return undefined;
60+
}
61+
return { page: firstPage.page, ancestors: firstPage.ancestors };
62+
}
63+
1864
const iteratePages = (
1965
pages: RevisionPage[],
2066
ancestors: AncestorRevisionPage[]
21-
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
67+
): ResolvedPagePath<RevisionPageDocument | RevisionPageGroup> | undefined => {
2268
for (const page of pages) {
2369
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
2470
continue;
@@ -34,19 +80,10 @@ export function resolvePagePath(
3480
continue;
3581
}
3682

37-
return resolvePageDocument(page, ancestors);
83+
return { page, ancestors };
3884
}
3985
};
4086

41-
if (!pagePath) {
42-
const firstPage = resolveFirstDocument(rootPages, []);
43-
if (!firstPage) {
44-
return undefined;
45-
}
46-
47-
return firstPage;
48-
}
49-
5087
return iteratePages(rootPages, []);
5188
}
5289

packages/gitbook/src/routes/llms.ts

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { type GitBookSiteContext, checkIsRootSiteContext } from '@/lib/context';
22
import { throwIfDataError } from '@/lib/data';
3+
import type { GitBookLinker } from '@/lib/links';
34
import { joinPath } from '@/lib/paths';
4-
import { getIndexablePages } from '@/lib/sitemap';
5+
import { type FlatPageEntry, getIndexablePages } from '@/lib/sitemap';
56
import { getSiteStructureSections } from '@/lib/sites';
67
import type { SiteSection, SiteSpace } from '@gitbook/api';
78
import assertNever from 'assert-never';
@@ -140,46 +141,76 @@ async function getNodesFromSiteSpaces(
140141
})
141142
);
142143
const pages = getIndexablePages(revision.pages);
143-
const listChildren = await Promise.all(
144-
pages.map(async ({ page }): Promise<ListItem> => {
145-
const pageURL = new URL(siteSpaceUrl);
146-
pageURL.pathname = joinPath(pageURL.pathname, page.path);
147-
if (options.withMarkdownPages) {
148-
pageURL.pathname = `${pageURL.pathname}.md`;
149-
}
150-
151-
const url = linker.toLinkForContent(pageURL.toString());
152-
const children: Paragraph['children'] = [
153-
{
154-
type: 'link',
155-
url,
156-
children: [{ type: 'text', value: page.title }],
157-
},
158-
];
159-
if (page.description) {
160-
children.push({ type: 'text', value: `: ${page.description}` });
161-
}
162-
return {
163-
type: 'listItem',
164-
children: [{ type: 'paragraph', children }],
165-
};
166-
})
167-
);
144+
168145
const nodes: RootContent[] = [];
146+
147+
// Add the space title as a heading
169148
if (options.heading) {
170149
nodes.push({
171150
type: 'heading',
172151
depth: 2,
173152
children: [{ type: 'text', value: siteSpace.title }],
174153
});
175154
}
176-
nodes.push({
177-
type: 'list',
178-
spread: false,
179-
children: listChildren,
180-
});
155+
156+
// Add the pages as a list
157+
nodes.push(
158+
...(await getMarkdownForPagesTree(pages, {
159+
siteSpaceUrl,
160+
linker,
161+
withMarkdownPages: options.withMarkdownPages,
162+
}))
163+
);
164+
181165
return nodes;
182166
})
183167
);
184168
return all.flat();
185169
}
170+
171+
/**
172+
* Returns a list of markdown nodes for a pages tree.
173+
*/
174+
export async function getMarkdownForPagesTree(
175+
pages: FlatPageEntry[],
176+
options: {
177+
siteSpaceUrl: string;
178+
linker: GitBookLinker;
179+
withMarkdownPages?: boolean;
180+
}
181+
): Promise<RootContent[]> {
182+
const { siteSpaceUrl, linker } = options;
183+
184+
const listChildren = await Promise.all(
185+
pages.map(async ({ page }): Promise<ListItem> => {
186+
const pageURL = new URL(siteSpaceUrl);
187+
pageURL.pathname = joinPath(pageURL.pathname, page.path);
188+
if (options.withMarkdownPages) {
189+
pageURL.pathname = `${pageURL.pathname}.md`;
190+
}
191+
192+
const url = linker.toLinkForContent(pageURL.toString());
193+
const children: Paragraph['children'] = [
194+
{
195+
type: 'link',
196+
url,
197+
children: [{ type: 'text', value: page.title }],
198+
},
199+
];
200+
if (page.description) {
201+
children.push({ type: 'text', value: `: ${page.description}` });
202+
}
203+
return {
204+
type: 'listItem',
205+
children: [{ type: 'paragraph', children }],
206+
};
207+
})
208+
);
209+
const nodes: RootContent[] = [];
210+
nodes.push({
211+
type: 'list',
212+
spread: false,
213+
children: listChildren,
214+
});
215+
return nodes;
216+
}

0 commit comments

Comments
 (0)