Skip to content

Commit 7595706

Browse files
authored
Allow AI agents to ask questions using ?ask= (#4205)
1 parent be08024 commit 7595706

12 files changed

Lines changed: 821 additions & 167 deletions

File tree

.changeset/nice-news-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": minor
3+
---
4+
5+
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
2+
import { serveAskMarkdown } from '@/routes/markdownAsk';
3+
import type { NextRequest } from 'next/server';
4+
5+
export const dynamic = 'force-static';
6+
7+
export async function GET(
8+
_request: NextRequest,
9+
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
10+
) {
11+
const { question: encodedQuestion } = await params;
12+
const { context } = await getStaticSiteContext(await params);
13+
const question = decodeURIComponent(encodedQuestion);
14+
15+
return serveAskMarkdown(context, question);
16+
}

packages/gitbook/src/lib/links.ts

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import path from 'node:path';
21
import { getPagePath } from '@/lib/pages';
32
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
43
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
5-
import type { Link, Root } from 'mdast';
6-
import { visit } from 'unist-util-visit';
74
import warnOnce from 'warn-once';
8-
import { checkIsAnchor, checkIsExternalURL } from './urls';
95

106
/**
117
* Generic interface to generate links based on a given context.
@@ -228,6 +224,18 @@ export function linkerWithAbsoluteURLs(linker: GitBookLinker): GitBookLinker {
228224
};
229225
}
230226

227+
/**
228+
* Create a new linker that resolves pages to their markdown version.
229+
*/
230+
export function linkerWithMarkdownPages(linker: GitBookLinker): GitBookLinker {
231+
return {
232+
...linker,
233+
toPathForPage: (input) => {
234+
return `${linker.toPathInSpace(input.page.path)}.md${input.anchor ? `#${input.anchor}` : ''}`;
235+
},
236+
};
237+
}
238+
231239
function joinPaths(prefix: string, path: string): string {
232240
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
233241
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
@@ -238,34 +246,3 @@ function joinPaths(prefix: string, path: string): string {
238246
function removeTrailingSlash(path: string): string {
239247
return path.endsWith('/') ? path.slice(0, -1) : path;
240248
}
241-
242-
/**
243-
* Re-writes the URL of every relative <a> link so it is expressed from the site-root.
244-
*/
245-
export function relativeToAbsoluteLinks(
246-
linker: GitBookLinker,
247-
tree: Root,
248-
currentPagePath: string
249-
): Root {
250-
const currentDir = path.posix.dirname(currentPagePath);
251-
252-
visit(tree, 'link', (node: Link) => {
253-
const original = node.url;
254-
255-
// Skip anchors, mailto:, http(s):, protocol-like, or already-rooted paths
256-
if (checkIsExternalURL(original) || checkIsAnchor(original) || original.startsWith('/')) {
257-
return;
258-
}
259-
260-
// Resolve against the current page’s directory and strip any leading “/” or "../"
261-
// Sometimes the path can be "../" if we are on the default section
262-
// but it means we are just at the root of the site.
263-
const pathInPage = path.posix
264-
.normalize(path.posix.join(currentDir, original))
265-
.replace(/^[\/\.]+/, '');
266-
267-
node.url = linker.toAbsoluteURL(linker.toPathInSpace(pathInPage));
268-
});
269-
270-
return tree;
271-
}

packages/gitbook/src/lib/markdownPage.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { GitBookSiteContext } from '@/lib/context';
1+
import path from 'node:path';
2+
import type { GitBookAnyContext, GitBookSiteContext } from '@/lib/context';
23
import { DataFetcherError, throwIfDataError } from '@/lib/data';
34
import type { ResolvedPagePath } from '@/lib/pages';
45
import { getIndexablePages } from '@/lib/sitemap';
@@ -10,15 +11,18 @@ import {
1011
RevisionPageType,
1112
type SiteSpace,
1213
} from '@gitbook/api';
13-
import type { Root } from 'mdast';
14+
import type { Link, Root } from 'mdast';
1415
import { fromMarkdown } from 'mdast-util-from-markdown';
1516
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
1617
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';
1718
import { toMarkdown } from 'mdast-util-to-markdown';
1819
import { frontmatter } from 'micromark-extension-frontmatter';
1920
import { gfm } from 'micromark-extension-gfm';
2021
import { remove } from 'unist-util-remove';
21-
import { type GitBookLinker, relativeToAbsoluteLinks } from './links';
22+
import { visit } from 'unist-util-visit';
23+
import type { GitBookLinker } from './links';
24+
import { resolveContentRef, resolveStringContentRef } from './references';
25+
import { checkIsAnchor, checkIsExternalURL } from './urls';
2226

2327
/**
2428
* Generate a markdown version of a page.
@@ -53,8 +57,7 @@ export async function getMarkdownForPage(
5357
throw error;
5458
}
5559

56-
const tree = fromPageMarkdown({
57-
linker: context.linker,
60+
const tree = await fromPageMarkdown(context, {
5861
markdown: rawMarkdown,
5962
pagePath: page.path,
6063
});
@@ -98,8 +101,7 @@ export async function getMarkdownForPageInSpace(
98101
})
99102
);
100103

101-
const tree = fromPageMarkdown({
102-
linker,
104+
const tree = await fromPageMarkdown(context, {
103105
markdown: rawMarkdown,
104106
pagePath: page.path,
105107
});
@@ -120,11 +122,13 @@ export async function getMarkdownForPageInSpace(
120122
* Parse markdown from a page, removing frontmatter and rewriting relative links to absolute links.
121123
* Returns the markdown AST that can be further processed or converted back to markdown using `toPageMarkdown`.
122124
*/
123-
export function fromPageMarkdown(args: {
124-
linker: GitBookLinker;
125-
markdown: string;
126-
pagePath: string;
127-
}): Root {
125+
export async function fromPageMarkdown(
126+
context: GitBookAnyContext,
127+
args: {
128+
markdown: string;
129+
pagePath: string;
130+
}
131+
): Promise<Root> {
128132
const tree = fromMarkdown(args.markdown, {
129133
extensions: [frontmatter(['yaml']), gfm()],
130134
mdastExtensions: [frontmatterFromMarkdown(['yaml']), gfmFromMarkdown()],
@@ -133,7 +137,7 @@ export function fromPageMarkdown(args: {
133137
// Remove frontmatter
134138
remove(tree, 'yaml');
135139

136-
relativeToAbsoluteLinks(args.linker, tree, args.pagePath);
140+
await rewriteMarkdownLinks(context, tree, args.pagePath);
137141

138142
return tree;
139143
}
@@ -230,3 +234,55 @@ async function renderGroupPageMarkdown(args: {
230234
bullet: '-',
231235
});
232236
}
237+
238+
/**
239+
* Re-writes URLs in a markdown content:
240+
* -
241+
* - the URL of every relative <a> link so it is expressed from the site-root.
242+
*/
243+
async function rewriteMarkdownLinks(
244+
context: GitBookAnyContext,
245+
tree: Root,
246+
currentPagePath: string
247+
): Promise<Root> {
248+
const currentDir = path.posix.dirname(currentPagePath);
249+
250+
const pending: Array<Promise<void>> = [];
251+
252+
visit(tree, 'link', (node: Link) => {
253+
const original = node.url;
254+
255+
// Skip anchors, mailto:, http(s):, protocol-like
256+
if (checkIsExternalURL(original) || checkIsAnchor(original)) {
257+
return;
258+
}
259+
260+
const contentRef = resolveStringContentRef(original);
261+
262+
if (contentRef) {
263+
pending.push(
264+
(async () => {
265+
const resolved = await resolveContentRef(contentRef, context);
266+
if (resolved?.href) {
267+
node.url = resolved.href;
268+
}
269+
})()
270+
);
271+
} else {
272+
// Resolve against the current page’s directory and strip any leading “/” or "../"
273+
// Sometimes the path can be "../" if we are on the default section
274+
// but it means we are just at the root of the site.
275+
const pathInPage = path.posix
276+
.normalize(path.posix.join(currentDir, original))
277+
.replace(/^[\/\.]+/, '');
278+
279+
node.url = context.linker.toAbsoluteURL(context.linker.toPathInSpace(pathInPage));
280+
}
281+
});
282+
283+
if (pending.length > 0) {
284+
await Promise.all(pending);
285+
}
286+
287+
return tree;
288+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { resolveStringContentRef } from './references';
4+
5+
describe('resolveStringContentRef', () => {
6+
it.each([
7+
{
8+
label: 'an external URL',
9+
input: 'https://docs.gitbook.com/product-tour',
10+
expected: {
11+
kind: 'url',
12+
url: 'https://docs.gitbook.com/product-tour',
13+
},
14+
},
15+
{
16+
label: 'a page ref in the current space',
17+
input: '/pages/page_123',
18+
expected: {
19+
kind: 'page',
20+
page: 'page_123',
21+
},
22+
},
23+
{
24+
label: 'a page ref in another space',
25+
input: '/spaces/space-123/pages/page_123',
26+
expected: {
27+
kind: 'page',
28+
space: 'space-123',
29+
page: 'page_123',
30+
},
31+
},
32+
{
33+
label: 'an anchor in the current page',
34+
input: '#heading-1',
35+
expected: {
36+
kind: 'anchor',
37+
anchor: 'heading-1',
38+
},
39+
},
40+
{
41+
label: 'an anchor on a page in another space',
42+
input: '/spaces/space-123/pages/page-123#heading_1',
43+
expected: {
44+
kind: 'anchor',
45+
space: 'space-123',
46+
page: 'page-123',
47+
anchor: 'heading_1',
48+
},
49+
},
50+
{
51+
label: 'a file ref',
52+
input: '/spaces/space-123/files/file_123',
53+
expected: {
54+
kind: 'file',
55+
space: 'space-123',
56+
file: 'file_123',
57+
},
58+
},
59+
{
60+
label: 'a space ref',
61+
input: '/spaces/space-123',
62+
expected: {
63+
kind: 'space',
64+
space: 'space-123',
65+
},
66+
},
67+
{
68+
label: 'a collection ref',
69+
input: '/collections/collection-123',
70+
expected: {
71+
kind: 'collection',
72+
collection: 'collection-123',
73+
},
74+
},
75+
{
76+
label: 'a user ref',
77+
input: '/users/user_123',
78+
expected: {
79+
kind: 'user',
80+
user: 'user_123',
81+
},
82+
},
83+
{
84+
label: 'a reusable content ref',
85+
input: '/spaces/space-123/reusable-content/reusable_123',
86+
expected: {
87+
kind: 'reusable-content',
88+
space: 'space-123',
89+
reusableContent: 'reusable_123',
90+
},
91+
},
92+
{
93+
label: 'a tag ref',
94+
input: '/spaces/space-123/tags/tag_123',
95+
expected: {
96+
kind: 'tag',
97+
space: 'space-123',
98+
tag: 'tag_123',
99+
},
100+
},
101+
{
102+
label: 'an OpenAPI ref',
103+
input: '/openapi/spec-v1',
104+
expected: {
105+
kind: 'openapi',
106+
spec: 'spec-v1',
107+
},
108+
},
109+
])('parses $label', ({ input, expected }) => {
110+
// @ts-expect-error
111+
expect(resolveStringContentRef(input)).toEqual(expected);
112+
});
113+
114+
it.each([
115+
{
116+
label: 'a relative page path',
117+
input: 'getting-started',
118+
},
119+
{
120+
label: 'a page path with an extra segment',
121+
input: '/pages/page-123/child',
122+
},
123+
{
124+
label: 'a missing page identifier',
125+
input: '/pages/',
126+
},
127+
{
128+
label: 'a space path with a trailing slash',
129+
input: '/spaces/space-123/',
130+
},
131+
{
132+
label: 'an OpenAPI path with nested segments',
133+
input: '/openapi/spec/v1',
134+
},
135+
])('returns null for $label', ({ input }) => {
136+
expect(resolveStringContentRef(input)).toBeNull();
137+
});
138+
});

0 commit comments

Comments
 (0)