@writedocs/generator 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ // `writedocs dev`: the site's MCP server at /mcp, as the build serves it - the
2
+ // same handler (src/mcp/server.js), answering from the dev server's own
3
+ // /mcp-index.json, so an edit shows up on the next request. Lets an author
4
+ // point an MCP client at http://localhost:4321/mcp before deploying.
5
+ import { handleMcpHttp } from '../mcp/server.js';
6
+
7
+ function readBody(req) {
8
+ return new Promise((resolve, reject) => {
9
+ const chunks = [];
10
+ req.on('data', (chunk) => chunks.push(chunk));
11
+ req.on('end', () => resolve(Buffer.concat(chunks)));
12
+ req.on('error', reject);
13
+ });
14
+ }
15
+
16
+ export function mcpDevServer({ version }) {
17
+ return {
18
+ name: 'writedocs-mcp-dev',
19
+ hooks: {
20
+ 'astro:server:setup': ({ server }) => {
21
+ server.middlewares.use(async (req, res, next) => {
22
+ const pathname = (req.url ?? '').split('?')[0];
23
+ if (pathname !== '/mcp' && pathname !== '/mcp/') return next();
24
+ try {
25
+ const origin = `http://${req.headers.host}`;
26
+ const headers = new Headers();
27
+ for (const [key, value] of Object.entries(req.headers)) {
28
+ if (typeof value === 'string') headers.set(key, value);
29
+ }
30
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
31
+ const request = new Request(origin + req.url, { method: req.method, headers, body });
32
+ let missing = false;
33
+ const response = await handleMcpHttp(request, {
34
+ version,
35
+ loadIndex: async () => {
36
+ const index = await fetch(`${origin}/mcp-index.json`);
37
+ if (!index.ok) {
38
+ missing = true;
39
+ return { meta: {}, docs: [] };
40
+ }
41
+ return index.json();
42
+ },
43
+ });
44
+ if (missing) {
45
+ res.statusCode = 404;
46
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
47
+ res.end('No MCP server: writedocs.json has "mcp": false.');
48
+ return;
49
+ }
50
+ res.statusCode = response.status;
51
+ response.headers.forEach((value, key) => res.setHeader(key, value));
52
+ res.end(Buffer.from(await response.arrayBuffer()));
53
+ } catch (err) {
54
+ next(err);
55
+ }
56
+ });
57
+ },
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,101 @@
1
+ // mcp-index.json - everything the site's MCP server (src/mcp/server.js)
2
+ // answers from: every page an agent may read, as it should read it. Built
3
+ // from the same pipeline as llms-full.txt (markdownForAgents(): snippets
4
+ // inlined, `variables` filled in, <Visibility> for agents), in navigation
5
+ // order, with each page's real URL and the version/language it sits under in
6
+ // the navigation - what search_docs filters on.
7
+ import { getCollection, type CollectionEntry } from 'astro:content';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {
11
+ loadDocsConfig,
12
+ normalizeEntryId,
13
+ findAllPages,
14
+ resolveSiteUrl,
15
+ fileIdForEntry,
16
+ resolveSections,
17
+ flattenNav,
18
+ } from './config';
19
+ import { markdownForAgents } from './agent-markdown.js';
20
+ import { orderByNavigation } from './llms.js';
21
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
22
+
23
+ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
24
+
25
+ export interface McpDoc {
26
+ path: string;
27
+ url: string;
28
+ title: string;
29
+ description: string;
30
+ content: string;
31
+ version: string;
32
+ language: string;
33
+ }
34
+
35
+ export interface McpIndex {
36
+ meta: { name: string; description: string; siteUrl: string; generator: 'writedocs' };
37
+ docs: McpDoc[];
38
+ }
39
+
40
+ export async function mcpIndex(): Promise<McpIndex> {
41
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
42
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
43
+ const config = loadDocsConfig(contentDir);
44
+ const siteUrl = resolveSiteUrl(config) ?? '';
45
+
46
+ const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
47
+ const hasPages = findAllPages(contentDir).length > 0;
48
+ const [pagesEntries, generatedDocsEntries] = await Promise.all([
49
+ hasPages ? getCollection('pages') : Promise.resolve([]),
50
+ hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
51
+ ]);
52
+ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
53
+
54
+ // The version and language each page sits under, from its section's path
55
+ // through the navigation (tabs > versions > languages > ...).
56
+ const sections = resolveSections(config.navigation);
57
+ const placeOf = (fileId: string) => {
58
+ const section = sections.find((s) => flattenNav(s.pages).some((e) => e.slug === fileId));
59
+ const place = { version: '', language: '' };
60
+ for (const segment of section?.path ?? []) {
61
+ const item = segment.items[segment.index] as { version?: string; language?: string };
62
+ if (segment.kind === 'version' && item.version) place.version = item.version;
63
+ if (segment.kind === 'language' && item.language) place.language = item.language;
64
+ }
65
+ return place;
66
+ };
67
+
68
+ const docs = entries
69
+ // Same pages llms.txt lists: not `noindex`, not a frontmatter `url`
70
+ // (an external link, with no content of its own).
71
+ .filter((entry) => !entry.data.seo?.noindex && !entry.data.url)
72
+ .map((entry) => {
73
+ const slug = normalizeEntryId(entry.id);
74
+ const fileId = fileIdForEntry(contentDir, packageRoot, entry);
75
+ let content = markdownForAgents(entry.body ?? '', {
76
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
77
+ contentDir,
78
+ variables: config.variables,
79
+ }).trim();
80
+ // An OpenAPI operation page renders from the spec, so its source body
81
+ // is near-empty - name the operation, at least.
82
+ if (entry.data.openapi) content = [`Operation: ${entry.data.openapi}`, content].filter(Boolean).join('\n\n');
83
+ return {
84
+ slug,
85
+ fileId,
86
+ doc: {
87
+ path: fileId,
88
+ url: `${siteUrl}${slug === 'index' ? '/' : `/${slug}/`}`,
89
+ title: entry.data.title,
90
+ description: entry.data.description ?? '',
91
+ content,
92
+ ...placeOf(fileId),
93
+ },
94
+ };
95
+ });
96
+
97
+ return {
98
+ meta: { name: config.name, description: config.description ?? '', siteUrl, generator: 'writedocs' },
99
+ docs: orderByNavigation(docs, config.navigation).map((d) => d.doc),
100
+ };
101
+ }
package/src/lib/pages.js CHANGED
@@ -61,24 +61,31 @@ function readIgnoreFile(contentDir) {
61
61
  * groups' `page`) - read straight from the file, since this runs before
62
62
  * (and independently of) config validation. Empty when there's no
63
63
  * readable writedocs.json. */
64
- function navigationPageIds(contentDir) {
65
- let config;
66
- try {
67
- config = JSON.parse(readConfigText(contentDir));
68
- } catch {
69
- return new Set();
70
- }
64
+ /** Every page id a `navigation` lists, in the order a reader meets them -
65
+ * top to bottom, each group's own `page` before its `pages` - without
66
+ * repeats. */
67
+ export function navigationPageOrder(navigation) {
71
68
  const ids = new Set();
72
69
  (function walk(node) {
73
70
  if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(item) : walk(item)));
74
71
  else if (node && typeof node === 'object') {
72
+ if (typeof node.page === 'string') ids.add(node.page);
75
73
  for (const [key, value] of Object.entries(node)) {
76
- if (key === 'page' && typeof value === 'string') ids.add(value);
77
- else if (key !== 'openapi' && key !== 'href') walk(value);
74
+ if (key !== 'page' && key !== 'openapi' && key !== 'href') walk(value);
78
75
  }
79
76
  }
80
- })(config.navigation);
81
- return ids;
77
+ })(navigation);
78
+ return [...ids];
79
+ }
80
+
81
+ function navigationPageIds(contentDir) {
82
+ let config;
83
+ try {
84
+ config = JSON.parse(readConfigText(contentDir));
85
+ } catch {
86
+ return new Set();
87
+ }
88
+ return new Set(navigationPageOrder(config.navigation));
82
89
  }
83
90
 
84
91
  /** Recursively finds every .md/.mdx file under `contentDir` that is a page:
@@ -198,9 +205,8 @@ export function fileIdForPath(relativePath) {
198
205
  *
199
206
  * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
200
207
  * by their respective path for deterministic load order across rebuilds
201
- * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
202
- * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
203
- * across OSes or directory-walk order otherwise.
208
+ * - filesystem readdir order isn't guaranteed portable across OSes or
209
+ * directory-walk order otherwise.
204
210
  *
205
211
  * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
206
212
  * absolute paths or file contents - BaseLayout.astro (the sole caller)
@@ -0,0 +1,262 @@
1
+ // The site's MCP server - lets an AI tool (Claude, Cursor, ...) list, search
2
+ // and read the docs over the Model Context Protocol, at /mcp.
3
+ //
4
+ // Dependency-free and Web-standard (Request in, Response out), so the same
5
+ // code runs wherever the site does: the Cloudflare `_worker.js` that
6
+ // `writedocs build` writes into dist/ (src/cli/write-mcp-files.js - this file
7
+ // is inlined into it), and any other host or serving Worker that imports
8
+ // `@writedocs/generator/mcp` and calls handleMcpHttp() for /mcp. The docs
9
+ // themselves come from the site's mcp-index.json (src/lib/mcp-index.ts),
10
+ // which the build writes next to the pages.
11
+ //
12
+ // Streamable HTTP transport, JSON responses only (no server-sent events),
13
+ // which every protocol version below allows. Keep this file free of imports:
14
+ // the Cloudflare worker inlines its source as-is.
15
+
16
+ // MCP protocol versions this server speaks, newest first.
17
+ export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
18
+
19
+ export const TOOLS = [
20
+ {
21
+ name: 'list_docs',
22
+ description: 'List every documentation page with its title, path, URL and description.',
23
+ inputSchema: { type: 'object', properties: {} },
24
+ },
25
+ {
26
+ name: 'get_doc',
27
+ description:
28
+ 'Get the full content of one or more documentation pages, as Markdown. Pass a path or URL from list_docs or search_docs (the page URL works with or without its site address), or an array of them for a batch read.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ path: {
33
+ oneOf: [
34
+ { type: 'string', description: 'A page path or URL, e.g. "docs/quickstart" or "/docs/quickstart/"' },
35
+ { type: 'array', items: { type: 'string' }, description: 'Several page paths or URLs' },
36
+ ],
37
+ },
38
+ },
39
+ required: ['path'],
40
+ },
41
+ },
42
+ {
43
+ name: 'search_docs',
44
+ description:
45
+ 'Search the documentation by keywords, returning the best-matching pages with a snippet each. Follow up with get_doc to read a page in full.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ query: { type: 'string', description: 'Search terms' },
50
+ limit: { type: 'number', description: 'Max results (default 5, max 20)' },
51
+ version: { type: 'string', description: 'Only pages of this docs version' },
52
+ language: { type: 'string', description: 'Only pages in this language' },
53
+ },
54
+ required: ['query'],
55
+ },
56
+ },
57
+ ];
58
+
59
+ const CORS = {
60
+ 'Access-Control-Allow-Origin': '*',
61
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
62
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Protocol-Version, Mcp-Session-Id',
63
+ };
64
+
65
+ const json = (body, status = 200, headers = {}) =>
66
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...CORS, ...headers } });
67
+
68
+ /**
69
+ * Handles one HTTP request to /mcp. `loadIndex()` returns (or resolves to) the
70
+ * site's mcp-index.json, parsed; `version` is reported as the server's.
71
+ *
72
+ * POST JSON-RPC - one message or a batch
73
+ * GET an MCP client asking for an event stream (Accept:
74
+ * text/event-stream) gets 405, as the spec says; anyone else (a
75
+ * browser) gets a description of the server
76
+ * OPTIONS CORS preflight
77
+ */
78
+ export async function handleMcpHttp(request, { loadIndex, version = '' } = {}) {
79
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
80
+ if (request.method === 'GET') {
81
+ if ((request.headers.get('accept') ?? '').includes('text/event-stream')) {
82
+ return new Response(null, { status: 405, headers: { Allow: 'POST, OPTIONS', ...CORS } });
83
+ }
84
+ const index = await loadIndex();
85
+ return json({
86
+ name: index.meta?.name ?? 'docs',
87
+ description: 'An MCP server for these docs. Connect an MCP client to this URL (Streamable HTTP).',
88
+ server: { name: 'writedocs', version },
89
+ protocolVersions: PROTOCOL_VERSIONS,
90
+ pages: index.docs?.length ?? 0,
91
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description })),
92
+ });
93
+ }
94
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: { Allow: 'GET, POST, OPTIONS', ...CORS } });
95
+
96
+ let body;
97
+ try {
98
+ body = await request.json();
99
+ } catch {
100
+ return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 400);
101
+ }
102
+ const index = await loadIndex();
103
+ if (Array.isArray(body)) {
104
+ const responses = body.map((message) => handleMcpMessage(message, index, { version })).filter(Boolean);
105
+ return responses.length ? json(responses) : new Response(null, { status: 202, headers: CORS });
106
+ }
107
+ const response = handleMcpMessage(body, index, { version });
108
+ return response ? json(response) : new Response(null, { status: 202, headers: CORS });
109
+ }
110
+
111
+ /** One JSON-RPC message -> its response, or null for a notification. */
112
+ export function handleMcpMessage(message, index, { version = '' } = {}) {
113
+ if (!message || typeof message !== 'object') {
114
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
115
+ }
116
+ const id = message.id ?? null;
117
+ if (message.id === undefined && String(message.method ?? '').startsWith('notifications/')) return null;
118
+
119
+ switch (message.method) {
120
+ case 'initialize': {
121
+ // The client's version when this server speaks it, else the newest one
122
+ // it does - the client then decides whether it can go on.
123
+ const requested = message.params?.protocolVersion;
124
+ return {
125
+ jsonrpc: '2.0',
126
+ id,
127
+ result: {
128
+ protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
129
+ capabilities: { tools: {} },
130
+ serverInfo: { name: 'writedocs', title: index.meta?.name, version },
131
+ instructions: index.meta?.name
132
+ ? `Documentation for ${index.meta.name}. Use search_docs to find pages, get_doc to read them.`
133
+ : undefined,
134
+ },
135
+ };
136
+ }
137
+ case 'ping':
138
+ return { jsonrpc: '2.0', id, result: {} };
139
+ case 'tools/list':
140
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
141
+ case 'tools/call': {
142
+ const { name, arguments: args = {} } = message.params ?? {};
143
+ return { jsonrpc: '2.0', id, result: callTool(name, args ?? {}, index) };
144
+ }
145
+ default:
146
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${message.method}` } };
147
+ }
148
+ }
149
+
150
+ const text = (value, isError = false) => ({ content: [{ type: 'text', text: value }], ...(isError ? { isError: true } : {}) });
151
+
152
+ function callTool(name, args, index) {
153
+ const docs = index.docs ?? [];
154
+ switch (name) {
155
+ case 'list_docs': {
156
+ if (!docs.length) return text('This site has no pages.');
157
+ return text(
158
+ docs
159
+ .map((d) => {
160
+ let line = `${d.path} - ${d.title} (${d.url})`;
161
+ if (d.description) line += `\n ${d.description}`;
162
+ const meta = [d.version, d.language].filter(Boolean).join(', ');
163
+ if (meta) line += `\n [${meta}]`;
164
+ return line;
165
+ })
166
+ .join('\n')
167
+ );
168
+ }
169
+
170
+ case 'get_doc': {
171
+ const paths = Array.isArray(args.path) ? args.path : [args.path];
172
+ const found = [];
173
+ const missing = [];
174
+ for (const p of paths) {
175
+ const doc = findDoc(docs, p);
176
+ if (!doc) {
177
+ missing.push(String(p ?? ''));
178
+ continue;
179
+ }
180
+ const parts = [`# ${doc.title}`, '', `URL: ${doc.url}`];
181
+ if (doc.description) parts.push('', `> ${doc.description}`);
182
+ parts.push('', doc.content);
183
+ found.push(parts.join('\n'));
184
+ }
185
+ const quoted = missing.map((p) => `"${p}"`).join(', ');
186
+ if (!found.length) return text(`No page found for ${quoted}. Use list_docs or search_docs to find page paths.`, true);
187
+ if (missing.length) found.push(`> No page found for ${quoted}.`);
188
+ return text(found.join('\n\n---\n\n'));
189
+ }
190
+
191
+ case 'search_docs': {
192
+ const query = String(args.query ?? '').trim();
193
+ if (!query) return text('The query is empty.', true);
194
+ // A limit that isn't a positive number (a model sent "abc", 0) falls
195
+ // back to the default instead of returning nothing.
196
+ const requested = Number(args.limit);
197
+ const limit = Number.isFinite(requested) && requested >= 1 ? Math.min(Math.floor(requested), 20) : 5;
198
+ let pool = docs;
199
+ if (args.version) pool = pool.filter((d) => d.version === String(args.version));
200
+ if (args.language) pool = pool.filter((d) => d.language === String(args.language));
201
+ const results = search(pool, query).slice(0, limit);
202
+ if (!results.length) return text(`No pages match "${query}".`);
203
+ return text(
204
+ results
205
+ .map((d) => {
206
+ const meta = [d.version, d.language].filter(Boolean).join(', ');
207
+ return `**${d.title}** (\`${d.path}\`) ${d.url}${meta ? ` [${meta}]` : ''}\n${d.description || snippet(d.content, query)}`;
208
+ })
209
+ .join('\n\n')
210
+ );
211
+ }
212
+
213
+ default:
214
+ return text(`Unknown tool: ${name}`, true);
215
+ }
216
+ }
217
+
218
+ /** The page a path points at, however it's written: its path ("docs/setup"),
219
+ * its URL with or without slashes, the full site URL, a ".md" address, or
220
+ * "/" for the home page. */
221
+ export function findDoc(docs, value) {
222
+ const raw = String(value ?? '').trim();
223
+ if (!raw) return undefined;
224
+ const exact = docs.find((d) => d.path === raw || d.url === raw);
225
+ if (exact) return exact;
226
+ const key = (s) => {
227
+ const p = String(s)
228
+ .replace(/^https?:\/\/[^/]+/i, '')
229
+ .replace(/[?#].*$/, '')
230
+ .replace(/\.mdx?$/i, '')
231
+ .replace(/^\/+|\/+$/g, '')
232
+ .replace(/(^|\/)index$/, '');
233
+ return p || 'index';
234
+ };
235
+ const wanted = key(raw);
236
+ return docs.find((d) => key(d.url) === wanted || key(d.path) === wanted);
237
+ }
238
+
239
+ /** Pages scored by how often the query's words occur - title matches count
240
+ * most - best first. */
241
+ function search(docs, query) {
242
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
243
+ const count = (haystack, word) => haystack.split(word).length - 1;
244
+ return docs
245
+ .map((doc) => {
246
+ const title = doc.title.toLowerCase();
247
+ const body = `${doc.description ?? ''} ${doc.content}`.toLowerCase();
248
+ const score = words.reduce((s, w) => s + count(title, w) * 5 + count(body, w), 0);
249
+ return { doc, score };
250
+ })
251
+ .filter((r) => r.score > 0)
252
+ .sort((a, b) => b.score - a.score)
253
+ .map((r) => r.doc);
254
+ }
255
+
256
+ function snippet(content, query) {
257
+ const word = query.toLowerCase().split(/\s+/)[0];
258
+ const at = content.toLowerCase().indexOf(word);
259
+ const start = Math.max(0, at - 60);
260
+ const end = Math.min(content.length, (at < 0 ? 0 : at) + 180);
261
+ return (start > 0 ? '...' : '') + content.slice(start, end).replace(/\s+/g, ' ').trim() + (end < content.length ? '...' : '');
262
+ }
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
5
  import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
- import { applyVisibilityForAgents } from '../lib/visibility.js';
7
+ import { markdownForAgents } from '../lib/agent-markdown.js';
8
8
 
9
9
  // The raw-Markdown twin of [...slug].astro: every content page is also
10
10
  // reachable at the exact same slug with a literal ".md" suffix (e.g.
@@ -58,25 +58,34 @@ export async function getStaticPaths() {
58
58
  .filter((entry) => !entry.data.url)
59
59
  .map((entry) => ({
60
60
  params: { slug: normalizeEntryId(entry.id) },
61
- props: { entry },
61
+ // Passed along rather than re-read per page - GET only renders one entry.
62
+ props: { entry, variables: config.variables },
62
63
  }));
63
64
  }
64
65
 
65
66
  interface Props {
66
67
  entry: DocsEntry;
68
+ variables: Record<string, string>;
67
69
  }
68
70
 
69
71
  export const GET: APIRoute = ({ props }) => {
70
- const { entry } = props as Props;
72
+ const { entry, variables } = props as Props;
73
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
74
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
71
75
  // entry.body is the raw, frontmatter-stripped MDX/Markdown source text
72
76
  // Astro's glob() loader already read off disk and stashed on every
73
77
  // entry by default (see astro/dist/content/loaders/glob.js) - reusing
74
78
  // it here means this route needs no separate file read/parse of its
75
79
  // own, and always matches exactly what [...slug].astro rendered from
76
80
  // (same entry, same collection query).
77
- // Mintlify's <Visibility>: this is the agents' view of the page - see
78
- // lib/visibility.js.
79
- const body = applyVisibilityForAgents(entry.body ?? '');
81
+ // What the page shows rather than its raw source: Mintlify's <Visibility>
82
+ // for agents, imported snippets inlined, `variables` filled in - see
83
+ // lib/agent-markdown.js (llms-full.txt uses the same).
84
+ const body = markdownForAgents(entry.body ?? '', {
85
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
86
+ contentDir,
87
+ variables,
88
+ });
80
89
  const markdown = `# ${entry.data.title}\n\n${body}`;
81
90
  return new Response(markdown, {
82
91
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { loadDocsConfig } from '../lib/config';
3
+ import { mcpIndex } from '../lib/mcp-index';
4
+
5
+ // /mcp-index.json - the pages the site's MCP server answers from (see
6
+ // lib/mcp-index.ts and src/mcp/server.js). A dynamic route only so it can be
7
+ // left out: writedocs.json `"mcp": false` gives no paths, so no file.
8
+ export async function getStaticPaths() {
9
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
10
+ return loadDocsConfig(contentDir).mcp ? [{ params: { mcpIndex: 'mcp-index' } }] : [];
11
+ }
12
+
13
+ export const GET: APIRoute = async () =>
14
+ new Response(JSON.stringify(await mcpIndex()), {
15
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
16
+ });
@@ -0,0 +1,23 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { llmsIndexFiles } from '../../lib/llms-index';
3
+
4
+ // The child files a big site's llms.txt links to - /llms/<section>.md, one
5
+ // per navigation section that didn't fit in llms.txt itself (see
6
+ // lib/llms.js). Most sites have none: llms.txt holds every page, and this
7
+ // route produces nothing. Nor with a hand-written llms.txt, which replaces
8
+ // the generated index as a whole.
9
+ export async function getStaticPaths() {
10
+ const files = await llmsIndexFiles();
11
+ if (!files) return [];
12
+ return [...files.entries()]
13
+ .filter(([file]) => file.startsWith('llms/'))
14
+ .map(([file, text]) => ({
15
+ params: { path: file.replace(/^llms\//, '').replace(/\.md$/, '') },
16
+ props: { text },
17
+ }));
18
+ }
19
+
20
+ export const GET: APIRoute = ({ props }) =>
21
+ new Response((props as { text: string }).text, {
22
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
23
+ });
@@ -2,9 +2,10 @@ import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
- import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
- import { applyVisibilityForAgents } from '../lib/visibility.js';
7
+ import { markdownForAgents } from '../lib/agent-markdown.js';
8
+ import { orderByNavigation } from '../lib/llms.js';
8
9
 
9
10
  // The "everything, concatenated" half of the llms.txt pair - see
10
11
  // llms.txt.ts (right next to this file) for the lightweight index half,
@@ -29,7 +30,9 @@ export const GET: APIRoute = async () => {
29
30
  });
30
31
  }
31
32
 
33
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
32
34
  const config = loadDocsConfig(contentDir);
35
+ const siteUrl = resolveSiteUrl(config);
33
36
 
34
37
  const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
35
38
  const hasPages = findAllPages(contentDir).length > 0;
@@ -39,7 +42,7 @@ export const GET: APIRoute = async () => {
39
42
  ]);
40
43
  const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
41
44
 
42
- const sections = entries
45
+ const unordered = entries
43
46
  // Same exclusion [...slug].md.ts applies to its own per-page raw
44
47
  // Markdown route, for the same reason: an OpenAPI operation page
45
48
  // (generated stub, or a hand-written page that opts into rendering
@@ -54,17 +57,35 @@ export const GET: APIRoute = async () => {
54
57
  .filter((entry) => !entry.data.url)
55
58
  .map((entry: DocsEntry) => {
56
59
  const slug = normalizeEntryId(entry.id);
57
- // Agents' view of <Visibility> blocks, same as the .md route.
58
- const body = applyVisibilityForAgents(entry.body ?? '');
59
- return { slug, title: entry.data.title, body };
60
- })
61
- .sort((a, b) => a.slug.localeCompare(b.slug));
60
+ // What the page shows, not its raw source: <Visibility> for agents,
61
+ // imported snippets inlined, `variables` filled in - same as the .md
62
+ // route (lib/agent-markdown.js).
63
+ const body = markdownForAgents(entry.body ?? '', {
64
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
65
+ contentDir,
66
+ variables: config.variables,
67
+ });
68
+ // The page's own URL, so an agent can cite it.
69
+ const url = `${siteUrl ?? ''}${slug === 'index' ? '/' : `/${slug}/`}`;
70
+ return {
71
+ slug,
72
+ fileId: fileIdForEntry(contentDir, packageRoot, entry),
73
+ title: entry.data.title,
74
+ description: entry.data.description,
75
+ url,
76
+ body,
77
+ };
78
+ });
79
+ // Navigation order, same as llms.txt.
80
+ const sections = orderByNavigation(unordered, config.navigation);
62
81
 
63
82
  const lines: string[] = [`# ${config.name}`, ''];
64
83
  if (config.description) lines.push(`> ${config.description}`, '');
65
84
 
66
85
  for (const section of sections) {
67
- lines.push('---', '', `# ${section.title}`, '', section.body.trim(), '');
86
+ lines.push('---', '', `# ${section.title}`, '', `URL: ${section.url}`, '');
87
+ if (section.description) lines.push(`> ${section.description}`, '');
88
+ lines.push(section.body.trim(), '');
68
89
  }
69
90
 
70
91
  const content = lines.join('\n') + '\n';