@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.
- package/astro.config.mjs +4 -0
- package/package.json +3 -2
- package/src/cli/build.js +8 -0
- package/src/cli/write-mcp-files.js +96 -0
- package/src/lib/agent-markdown.js +142 -0
- package/src/lib/config-file.js +1 -1
- package/src/lib/config-schema.js +5 -0
- package/src/lib/config-schema.ts +5 -0
- package/src/lib/json-schema-descriptions.js +1 -0
- package/src/lib/llms-index.ts +88 -0
- package/src/lib/llms.js +200 -0
- package/src/lib/mcp-dev-integration.js +60 -0
- package/src/lib/mcp-index.ts +101 -0
- package/src/lib/pages.js +20 -14
- package/src/mcp/server.js +262 -0
- package/src/pages/[...slug].md.ts +15 -6
- package/src/pages/[mcpIndex].json.ts +16 -0
- package/src/pages/llms/[...path].md.ts +23 -0
- package/src/pages/llms-full.txt.ts +30 -9
- package/src/pages/llms.txt.ts +21 -129
- package/writedocs.schema.json +6 -0
package/src/pages/llms.txt.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import type { APIRoute } from 'astro';
|
|
5
|
-
import {
|
|
6
|
-
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
4
|
+
import { llmsIndexFiles } from '../lib/llms-index';
|
|
7
5
|
|
|
8
6
|
// A lightweight, LLM-facing index of every page on the site - the
|
|
9
7
|
// llms.txt convention (see https://llmstxt.org), also adopted by Mintlify
|
|
@@ -13,136 +11,30 @@ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
|
13
11
|
// of the pair; llms-full.txt (llms-full.txt.ts, right next to this file)
|
|
14
12
|
// is the "everything, concatenated" half.
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// - see [...slug].md.ts for the same fixed-route-vs-catchall distinction
|
|
21
|
-
// applied to per-page raw Markdown.
|
|
14
|
+
// Pages are listed under headings that follow the navigation. A site too
|
|
15
|
+
// big for one file keeps this as a directory whose largest sections link to
|
|
16
|
+
// child files - llms/[...path].md.ts serves those - so no page is left out;
|
|
17
|
+
// see lib/llms.js.
|
|
22
18
|
//
|
|
23
|
-
//
|
|
24
|
-
// this
|
|
19
|
+
// Fixed, non-dynamic route - Astro prerenders this once at the literal path
|
|
20
|
+
// /llms.txt (this project's astro.config.mjs sets `output: 'static'`), and
|
|
21
|
+
// re-evaluates it per request in `writedocs dev`.
|
|
22
|
+
//
|
|
23
|
+
// Unconditional - unlike the per-page .md routes in [...slug].md.ts, this
|
|
24
|
+
// doesn't depend on writedocs.json's `contextMenu` field. There's no UI
|
|
25
25
|
// footprint to gate (it's an extra static file, not a visible menu), so
|
|
26
26
|
// it's always generated. `contextMenu` only affects which URL each entry
|
|
27
|
-
// links to
|
|
28
|
-
//
|
|
29
|
-
type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
30
|
-
|
|
31
|
-
// Mirrors Mintlify's own llms.txt behavior: truncate a page's frontmatter
|
|
32
|
-
// `description` at the first line break (a multi-paragraph description
|
|
33
|
-
// would blow out a one-line list entry) and at 300 characters (an
|
|
34
|
-
// arbitrary but reasonable cap - keeps every entry scannable regardless
|
|
35
|
-
// of how verbose an individual page's description happens to be).
|
|
36
|
-
const DESCRIPTION_MAX_CHARS = 300;
|
|
37
|
-
function truncateDescription(description: string | undefined): string | undefined {
|
|
38
|
-
if (!description) return undefined;
|
|
39
|
-
const firstLine = description.split('\n')[0].trim();
|
|
40
|
-
if (!firstLine) return undefined;
|
|
41
|
-
return firstLine.length > DESCRIPTION_MAX_CHARS
|
|
42
|
-
? firstLine.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() + '…'
|
|
43
|
-
: firstLine;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Same cap Mintlify documents for its own auto-generated llms.txt - this
|
|
47
|
-
// file is meant to stay a lightweight index (one line per page), not
|
|
48
|
-
// balloon into something llms-full.txt-sized. Realistically unlikely to
|
|
49
|
-
// matter for most Writedocs sites (a site would need on the order of a
|
|
50
|
-
// thousand-plus pages with full-length descriptions to hit this), but
|
|
51
|
-
// cheap to guard against regardless.
|
|
52
|
-
const MAX_CHARS = 100_000;
|
|
53
|
-
|
|
27
|
+
// links to (a real fetchable .md URL if the per-page raw-Markdown routes
|
|
28
|
+
// exist, the ordinary HTML page URL otherwise).
|
|
54
29
|
export const GET: APIRoute = async () => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const customPath = path.join(contentDir, 'llms.txt');
|
|
64
|
-
if (fs.existsSync(customPath)) {
|
|
65
|
-
return new Response(fs.readFileSync(customPath, 'utf-8'), {
|
|
66
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const config = loadDocsConfig(contentDir);
|
|
71
|
-
const siteUrl = resolveSiteUrl(config); // absolute origin if `domain` is set, else null
|
|
72
|
-
|
|
73
|
-
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
74
|
-
const hasPages = findAllPages(contentDir).length > 0;
|
|
75
|
-
const [pagesEntries, generatedDocsEntries] = await Promise.all([
|
|
76
|
-
hasPages ? getCollection('pages') : Promise.resolve([]),
|
|
77
|
-
hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
|
|
78
|
-
]);
|
|
79
|
-
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
80
|
-
|
|
81
|
-
const items = entries
|
|
82
|
-
// A page that opted out of search-engine indexing (frontmatter
|
|
83
|
-
// `seo.noindex: true`, already excluded from sitemap.xml - see
|
|
84
|
-
// astro.config.mjs's collectNoindexIds()) is excluded here for the
|
|
85
|
-
// same reason: llms.txt exists to help external tools discover
|
|
86
|
-
// pages, exactly what `noindex` asked not to happen.
|
|
87
|
-
.filter((entry) => !entry.data.seo?.noindex)
|
|
88
|
-
// A frontmatter `url` page (Mintlify's external link) has no content -
|
|
89
|
-
// its own URL only redirects to the link.
|
|
90
|
-
.filter((entry) => !entry.data.url)
|
|
91
|
-
.map((entry: DocsEntry) => {
|
|
92
|
-
const slug = normalizeEntryId(entry.id);
|
|
93
|
-
// Same URL Astro's own router resolves this entry to - see
|
|
94
|
-
// hrefForSlug() in [...slug].astro for the HTML form, and
|
|
95
|
-
// [...slug].md.ts for the .md form (no trailing slash, "index.md"
|
|
96
|
-
// for the home page rather than the HTML convention's bare "/").
|
|
97
|
-
// An OpenAPI operation page has no .md form ([...slug].md.ts leaves
|
|
98
|
-
// it out - it renders from the spec, not prose), so it keeps its HTML
|
|
99
|
-
// URL even when the .md routes exist.
|
|
100
|
-
const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
|
|
101
|
-
const path_ = hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
|
|
102
|
-
const href = (siteUrl ?? '') + path_;
|
|
103
|
-
let description = truncateDescription(entry.data.description);
|
|
104
|
-
// Mirrors Mintlify's own behavior: an OpenAPI operation page's
|
|
105
|
-
// description gets its "METHOD /path" appended, since the page
|
|
106
|
-
// itself renders almost entirely from the spec at request time
|
|
107
|
-
// (see ApiPlayground.astro) rather than from frontmatter prose.
|
|
108
|
-
if (entry.data.openapi) {
|
|
109
|
-
description = description ? `${description} (${entry.data.openapi})` : entry.data.openapi;
|
|
110
|
-
}
|
|
111
|
-
return { title: entry.data.title, href, description, slug };
|
|
112
|
-
})
|
|
113
|
-
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
114
|
-
|
|
115
|
-
const lines: string[] = [`# ${config.name}`, ''];
|
|
116
|
-
if (config.description) lines.push(`> ${config.description}`, '');
|
|
117
|
-
lines.push('## Docs', '');
|
|
118
|
-
for (const item of items) {
|
|
119
|
-
const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
|
|
120
|
-
lines.push(entryLine);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
let content = lines.join('\n') + '\n';
|
|
124
|
-
if (content.length > MAX_CHARS) {
|
|
125
|
-
// Truncate at the last full line inside the budget, then note how
|
|
126
|
-
// many entries got cut - rather than silently producing a
|
|
127
|
-
// mid-sentence-cutoff file, or (worse) one JSON/Markdown-breaking
|
|
128
|
-
// half-written link.
|
|
129
|
-
const truncatedLines = lines.slice(0, 4 + (config.description ? 2 : 0)); // "# name" / "" / ["> desc" / ""] / "## Docs" / ""
|
|
130
|
-
let runningLength = truncatedLines.join('\n').length + 1;
|
|
131
|
-
let omitted = 0;
|
|
132
|
-
for (const item of items) {
|
|
133
|
-
const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
|
|
134
|
-
if (runningLength + entryLine.length + 1 > MAX_CHARS - 200) {
|
|
135
|
-
omitted++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
truncatedLines.push(entryLine);
|
|
139
|
-
runningLength += entryLine.length + 1;
|
|
140
|
-
}
|
|
141
|
-
truncatedLines.push('', `_Truncated — ${omitted} more page(s) omitted. See llms-full.txt or the site's own navigation for the complete list._`);
|
|
142
|
-
content = truncatedLines.join('\n') + '\n';
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return new Response(content, {
|
|
30
|
+
const files = await llmsIndexFiles();
|
|
31
|
+
// null: the project has its own llms.txt at its root, next to
|
|
32
|
+
// writedocs.json - it replaces the generated one (and its child files)
|
|
33
|
+
// outright, and a site can fall back by just deleting it.
|
|
34
|
+
const text = files
|
|
35
|
+
? files.get('llms.txt')
|
|
36
|
+
: fs.readFileSync(path.join(process.env.WRITEDOCS_CONTENT_DIR || process.cwd(), 'llms.txt'), 'utf-8');
|
|
37
|
+
return new Response(text, {
|
|
146
38
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
147
39
|
});
|
|
148
40
|
};
|
package/writedocs.schema.json
CHANGED
|
@@ -772,6 +772,12 @@
|
|
|
772
772
|
"description": "Adds a \"Copy page\" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + \".md\".",
|
|
773
773
|
"markdownDescription": "Adds a \"Copy page\" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + \".md\"."
|
|
774
774
|
},
|
|
775
|
+
"mcp": {
|
|
776
|
+
"default": true,
|
|
777
|
+
"type": "boolean",
|
|
778
|
+
"description": "An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out.",
|
|
779
|
+
"markdownDescription": "An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out."
|
|
780
|
+
},
|
|
775
781
|
"redirects": {
|
|
776
782
|
"default": [],
|
|
777
783
|
"type": "array",
|