@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.
@@ -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 { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl } from '../lib/config';
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
- // Fixed, non-dynamic route (no getStaticPaths/params needed) - Astro
17
- // prerenders this once at the literal path /llms.txt, same as any other
18
- // static-output route (this project's astro.config.mjs sets
19
- // `output: 'static'`), and re-evaluates it per request in `writedocs dev`
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
- // Unconditional - unlike the per-page .md routes in [...slug].md.ts,
24
- // this doesn't depend on writedocs.json's `contextMenu` field. There's no UI
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 below (a real fetchable .md URL if the per-page raw-Markdown
28
- // routes exist, the ordinary HTML page URL otherwise).
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 contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
56
-
57
- // A hand-authored llms.txt at the project root (next to writedocs.json)
58
- // always wins outright over the generated one below - same override
59
- // convention Mintlify documents for its own llms.txt. Lets a site
60
- // author hand-curate this file (different ordering, editorial
61
- // descriptions, extra sections) without losing the ability to fall
62
- // back to the auto-generated version by just deleting the override.
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
  };
@@ -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",