@writedocs/generator 0.7.4 → 0.8.1

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.
@@ -33,7 +33,7 @@ import { pathToFileURL } from 'node:url';
33
33
  import matter from 'gray-matter';
34
34
  import { visit } from 'unist-util-visit';
35
35
  import { findAllPages } from './pages.js';
36
- import { createJsonLocator } from './config-schema.js';
36
+ import { createJsonLocator, contextMenuOptions } from './config-schema.js';
37
37
  import { writedocsTempDir } from './writedocs-temp-dir.js';
38
38
 
39
39
  // The same github-slugger Astro builds page URLs and heading ids with -
@@ -423,7 +423,7 @@ export async function checkLinks(contentDir, configText) {
423
423
  // A link to a page's source file (docs/setup.mdx) instead of its URL.
424
424
  if (extension === '.mdx' || extension === '.md') {
425
425
  const bare = pathname.replace(/\.mdx?$/i, '').replace(/\/?$/, '/');
426
- if (extension === '.md' && config.contextMenu && pages.has(bare)) return; // the page's Markdown copy
426
+ if (extension === '.md' && contextMenuOptions(config.contextMenu).length && pages.has(bare)) return; // the page's Markdown copy (on unless "contextMenu": false)
427
427
  // Written as a file path, so look it up as one: from the file's own
428
428
  // folder when relative.
429
429
  const filePath = isRelative && fileAbs ? path.resolve(path.dirname(fileAbs), decodeURI(value.split(/[?#]/)[0])) : path.join(contentDir, pathname);
@@ -6,7 +6,7 @@
6
6
  import { getCollection, type CollectionEntry } from 'astro:content';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from './config';
9
+ import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry, contextMenuOptions } from './config';
10
10
  import { buildLlmsTree, renderLlmsFiles } from './llms.js';
11
11
  import { navigationPageOrder } from './pages.js';
12
12
  import { writedocsTempDir } from './writedocs-temp-dir.js';
@@ -59,10 +59,10 @@ export async function llmsIndexFiles(): Promise<Map<string, string> | null> {
59
59
  // page (Mintlify's external link) has no content of its own.
60
60
  if (entry.data.seo?.noindex || entry.data.url) continue;
61
61
  const slug = normalizeEntryId(entry.id);
62
- // The .md route when it exists ([...slug].md.ts - with `contextMenu`,
62
+ // The .md route when it exists ([...slug].md.ts - unless `contextMenu` is off,
63
63
  // and never for an OpenAPI page, which renders from the spec), else the
64
64
  // HTML page.
65
- const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
65
+ const hasMarkdownRoute = contextMenuOptions(config.contextMenu).length > 0 && !entry.data.openapi;
66
66
  const href = (siteUrl ?? '') + (hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`);
67
67
  let description = truncateDescription(entry.data.description);
68
68
  // Mirrors Mintlify: an OpenAPI operation page's description gets its
@@ -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
+ }
@@ -530,13 +530,16 @@ export function convertMintlifyConfig(docs) {
530
530
  if (docs.seo?.indexing === 'all') notes.add('seo.indexing', ['seo', 'indexing'], '`seo.indexing: "all"` has no equivalent - writedocs indexes every page that isn\'t marked noindex.');
531
531
 
532
532
  // context menu
533
+ // Mintlify's option names are writedocs' own for the five both have, so the
534
+ // list carries over as is. No `contextual` leaves the field out - the menu
535
+ // is on by default, with every option.
533
536
  const options = docs.contextual?.options;
534
537
  if (Array.isArray(options) && options.length) {
535
- const openIn = options.filter((o) => ['chatgpt', 'claude', 'perplexity'].includes(o));
536
- out.contextMenu = { openIn };
537
- const other = options.filter((o) => typeof o !== 'string' || !['copy', 'view', 'chatgpt', 'claude', 'perplexity'].includes(o));
538
+ const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'];
539
+ out.contextMenu = known.filter((o) => options.includes(o));
540
+ const other = options.filter((o) => typeof o !== 'string' || !known.includes(o));
538
541
  if (other.length) {
539
- notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu always has copy and view-as-Markdown, plus open in ChatGPT, Claude and Perplexity.');
542
+ notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu offers copy, view as Markdown, and open in ChatGPT, Claude and Perplexity.');
540
543
  }
541
544
  }
542
545
 
@@ -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
+ }
@@ -16,6 +16,7 @@ import {
16
16
  flattenNav,
17
17
  firstSlugOfNavigation,
18
18
  mergeSeo,
19
+ contextMenuOptions,
19
20
  resolveSiteUrl,
20
21
  findAllPages,
21
22
  } from "../lib/config";
@@ -414,8 +415,9 @@ const showToc = pageMode === "default";
414
415
  // branch on separately.
415
416
  const isCanvasMode = pageMode === "custom" || pageMode === "blank";
416
417
 
417
- // The "Copy page" dropdown (CopyPageMenu.astro) - opt-in via writedocs.json's
418
- // `contextMenu` field (see contextMenuSchema in lib/config.ts), and only
418
+ // The "Copy page" menu (CopyPageMenu.astro) - on by default, with the
419
+ // options writedocs.json's `contextMenu` leaves on (contextMenuOptions() in
420
+ // lib/config-schema.ts; `false` turns it off), and only
419
421
  // shown where there's a real auto-rendered <h1> to sit next to: canvas
420
422
  // mode pages (custom/blank) have no such header (a hand-built landing
421
423
  // page controls its own layout, there's nothing standard to anchor the
@@ -423,7 +425,8 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
423
425
  // spec rather than prose - see [...slug].md.ts's own comment on why
424
426
  // those are excluded from the .md route this menu links to in the first
425
427
  // place, which this mirrors on the UI side.
426
- const showCopyPageMenu = Boolean(config.contextMenu) && !isCanvasMode && !entry.data.openapi;
428
+ const contextMenuItems = contextMenuOptions(config.contextMenu);
429
+ const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
427
430
  const siteUrl = resolveSiteUrl(config);
428
431
 
429
432
  const components = {
@@ -513,7 +516,7 @@ const components = {
513
516
  )
514
517
  }
515
518
  {showCopyPageMenu && (
516
- <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} contextMenu={config.contextMenu!} />
519
+ <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} options={contextMenuItems} />
517
520
  )}
518
521
  </div>
519
522
  <Content components={components} />
@@ -672,6 +675,19 @@ const components = {
672
675
  initCopyPageMenu(document);
673
676
  document.addEventListener("astro:page-load", () => initCopyPageMenu(document));
674
677
 
678
+ // "Open in ChatGPT/Claude/Perplexity" on a site with no writedocs.json
679
+ // `domain`: the page's absolute .md URL isn't known at build time, so the
680
+ // link is completed here from the address the page is served from (see
681
+ // CopyPageMenu.astro) - same prompt the build writes when it does know.
682
+ function initAskLinks(root: ParentNode) {
683
+ root.querySelectorAll<HTMLAnchorElement>("a[data-ask-base][data-md-path]").forEach((link) => {
684
+ const url = new URL(link.dataset.mdPath ?? "/", window.location.origin).href;
685
+ link.href = link.dataset.askBase + encodeURIComponent(`Read ${url} so you can answer questions about it.`);
686
+ });
687
+ }
688
+ initAskLinks(document);
689
+ document.addEventListener("astro:page-load", () => initAskLinks(document));
690
+
675
691
  // Show more/less toggle for ```js expandable code blocks - same
676
692
  // build-time-emitted-button + client-wired-click pattern as the copy
677
693
  // button above; codeBlockTransformer only adds this button when the
@@ -845,7 +861,7 @@ const components = {
845
861
  width: 100%;
846
862
  }
847
863
  /* Wraps the auto-rendered <h1> together with CopyPageMenu (only
848
- rendered when writedocs.json's `contextMenu` is set - see
864
+ rendered when writedocs.json's `contextMenu` isn't off - see
849
865
  showCopyPageMenu above) so the two sit on one row, menu pinned to
850
866
  the right. Always present (even with no menu) rather than only
851
867
  wrapping the <h1> conditionally, so the <h1>'s own top/bottom
@@ -2,7 +2,7 @@ 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, contextMenuOptions } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
7
  import { markdownForAgents } from '../lib/agent-markdown.js';
8
8
 
@@ -11,10 +11,10 @@ import { markdownForAgents } from '../lib/agent-markdown.js';
11
11
  // /guides/foo/ -> /guides/foo.md), serving its untouched MDX/Markdown
12
12
  // source instead of rendered HTML - what the "Copy page"/"View as
13
13
  // Markdown" menu (CopyPageMenu.astro, wired in from [...slug].astro)
14
- // links to, and what an LLM fetching that URL directly gets. Gated
15
- // entirely behind writedocs.json's `contextMenu` field being present (see
16
- // contextMenuSchema in lib/config.ts) - a site that hasn't opted in gets
17
- // no .md routes at all, not just a hidden menu.
14
+ // links to, and what an LLM fetching that URL directly gets. On by
15
+ // default, like the menu; writedocs.json `"contextMenu": false` (see
16
+ // contextMenuOptions() in lib/config-schema.ts) leaves out the .md routes
17
+ // too, not just the menu.
18
18
  //
19
19
  // A literal ".md" filename suffix rather than a `[...slug]` capture
20
20
  // covering it: naming the file `[...slug].md.ts` makes Astro treat ".md"
@@ -33,9 +33,9 @@ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
33
33
  export async function getStaticPaths() {
34
34
  const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
35
35
  const config = loadDocsConfig(contentDir);
36
- // No writedocs.json `contextMenu` - feature is off entirely, including these
37
- // routes (not just the menu UI - see the file-level comment above).
38
- if (!config.contextMenu) return [];
36
+ // `"contextMenu": false` - the feature is off entirely, these routes
37
+ // included (not just the menu UI - see the file-level comment above).
38
+ if (contextMenuOptions(config.contextMenu).length === 0) return [];
39
39
 
40
40
  const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
41
41
  const hasPages = findAllPages(contentDir).length > 0;
@@ -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
+ });