@writedocs/generator 0.7.3 → 0.7.4
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/package.json +1 -1
- package/src/lib/agent-markdown.js +137 -0
- package/src/lib/config-file.js +1 -1
- package/src/lib/llms-index.ts +88 -0
- package/src/lib/llms.js +200 -0
- package/src/lib/pages.js +20 -14
- package/src/pages/[...slug].md.ts +15 -6
- 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/package.json
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The Markdown an AI agent reads for a page - llms-full.txt and the per-page
|
|
2
|
+
// .md route. It starts from the page's raw MDX, so everything the build does
|
|
3
|
+
// to that source on the way to HTML has to be redone here, or the agent reads
|
|
4
|
+
// something other than the page: `<Visibility>` blocks, imported .mdx
|
|
5
|
+
// snippets (inlined, as the page shows them) and writedocs.json `variables`.
|
|
6
|
+
//
|
|
7
|
+
// Code is left exactly as written - fenced blocks and inline `code` - the same
|
|
8
|
+
// way the build never substitutes inside code: a page documenting the
|
|
9
|
+
// `[[key]]` syntax, or showing `<Snippet />` in an example, must keep it.
|
|
10
|
+
// Plain JavaScript, so it can be tested with plain Node.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import matter from 'gray-matter';
|
|
14
|
+
import { applyVisibilityForAgents } from './visibility.js';
|
|
15
|
+
|
|
16
|
+
/** `text` with `transform` applied to everything outside fenced code blocks
|
|
17
|
+
* and inline code spans; code comes back untouched. */
|
|
18
|
+
export function mapOutsideCode(text, transform) {
|
|
19
|
+
const out = [];
|
|
20
|
+
let prose = [];
|
|
21
|
+
let fence = null; // the opening marker (``` or ~~~, maybe longer) while inside a block
|
|
22
|
+
const flush = () => {
|
|
23
|
+
if (prose.length) out.push(mapOutsideInlineCode(prose.join('\n'), transform));
|
|
24
|
+
prose = [];
|
|
25
|
+
};
|
|
26
|
+
for (const line of text.split('\n')) {
|
|
27
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
28
|
+
if (fence) {
|
|
29
|
+
out.push(line);
|
|
30
|
+
if (marker && marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) fence = null;
|
|
31
|
+
} else if (marker) {
|
|
32
|
+
flush();
|
|
33
|
+
fence = marker;
|
|
34
|
+
out.push(line);
|
|
35
|
+
} else {
|
|
36
|
+
prose.push(line);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
flush();
|
|
40
|
+
return out.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mapOutsideInlineCode(text, transform) {
|
|
44
|
+
// Backtick runs of equal length delimit a code span (CommonMark).
|
|
45
|
+
return text
|
|
46
|
+
.split(/(`+)([\s\S]*?)(\1)(?!`)/)
|
|
47
|
+
.reduce((acc, part, i, parts) => {
|
|
48
|
+
const k = i % 4;
|
|
49
|
+
if (k === 0) acc.push(transform(part));
|
|
50
|
+
else if (k === 1) acc.push(part + parts[i + 1] + parts[i + 2]);
|
|
51
|
+
return acc;
|
|
52
|
+
}, [])
|
|
53
|
+
.join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PLACEHOLDER = /\[\[\s*([\w.-]+)\s*\]\]/g;
|
|
57
|
+
// A standalone `{key}` expression - not an attribute value (`prop={key}`),
|
|
58
|
+
// which the build leaves alone too.
|
|
59
|
+
const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
60
|
+
|
|
61
|
+
/** writedocs.json `variables` - `[[key]]`, and Mintlify's `{key}` - replaced
|
|
62
|
+
* the way the build replaces them (lib/mdx-substitute-variables.js): outside
|
|
63
|
+
* code, and only for keys that exist. */
|
|
64
|
+
export function substituteVariables(text, variables) {
|
|
65
|
+
if (!variables || Object.keys(variables).length === 0) return text;
|
|
66
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(variables, key);
|
|
67
|
+
return mapOutsideCode(text, (prose) =>
|
|
68
|
+
prose
|
|
69
|
+
.replace(PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
|
|
70
|
+
.replace(MINTLIFY_PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMPORT_LINE = /^[ \t]*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.mdx?)['"];?[ \t]*$/gm;
|
|
75
|
+
|
|
76
|
+
function resolveSnippet(spec, fromFile, contentDir) {
|
|
77
|
+
if (spec.startsWith('/snippets/')) return path.join(contentDir, 'snippets', spec.slice('/snippets/'.length));
|
|
78
|
+
if (spec.startsWith('./') || spec.startsWith('../')) return path.resolve(path.dirname(fromFile), spec);
|
|
79
|
+
if (spec.startsWith('/')) return path.join(contentDir, spec.slice(1));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function attributesOf(source) {
|
|
84
|
+
const props = {};
|
|
85
|
+
for (const m of source.matchAll(/([A-Za-z_$][\w$-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*["']([^"']*)["']\s*\})/g)) {
|
|
86
|
+
props[m[1]] = m[2] ?? m[3] ?? m[4];
|
|
87
|
+
}
|
|
88
|
+
return props;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fillProps(snippet, props, children) {
|
|
92
|
+
return snippet
|
|
93
|
+
.replace(/\{\s*props\.children\s*\}/g, children ?? '')
|
|
94
|
+
.replace(/\{\s*props\.([A-Za-z_$][\w$]*)\s*\}/g, (match, key) => (key in props ? props[key] : match));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Imported .mdx snippets inlined where the page uses them - the snippet's
|
|
98
|
+
* own text, with `{props.x}` filled from the tag's string attributes, and
|
|
99
|
+
* its own imports inlined in turn. The import line goes too. A snippet that
|
|
100
|
+
* can't be read, or a .jsx/.tsx component, stays as written. */
|
|
101
|
+
export function inlineSnippets(text, { file, contentDir, depth = 0 }) {
|
|
102
|
+
if (depth > 5) return text;
|
|
103
|
+
const snippets = new Map();
|
|
104
|
+
let result = mapOutsideCode(text, (prose) =>
|
|
105
|
+
prose.replace(IMPORT_LINE, (line, name, spec) => {
|
|
106
|
+
const target = resolveSnippet(spec, file, contentDir);
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = target && fs.readFileSync(target, 'utf-8');
|
|
110
|
+
} catch {
|
|
111
|
+
raw = null;
|
|
112
|
+
}
|
|
113
|
+
if (!raw) return line;
|
|
114
|
+
const body = inlineSnippets(matter(raw).content.trim(), { file: target, contentDir, depth: depth + 1 });
|
|
115
|
+
snippets.set(name, body);
|
|
116
|
+
return '';
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
for (const [name, body] of snippets) {
|
|
120
|
+
result = mapOutsideCode(result, (prose) =>
|
|
121
|
+
prose
|
|
122
|
+
.replace(new RegExp(`<${name}\\b([^>]*?)\\/>`, 'g'), (_, attrs) => fillProps(body, attributesOf(attrs)))
|
|
123
|
+
.replace(new RegExp(`<${name}\\b([^>]*)>([\\s\\S]*?)<\\/${name}>`, 'g'), (_, attrs, children) =>
|
|
124
|
+
fillProps(body, attributesOf(attrs), children.trim())
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return result.replace(/\n{3,}/g, '\n\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A page's body as an agent should read it. `file` is the page's absolute
|
|
132
|
+
* path (to resolve relative snippet imports). */
|
|
133
|
+
export function markdownForAgents(body, { file, contentDir, variables }) {
|
|
134
|
+
let text = applyVisibilityForAgents(body ?? '');
|
|
135
|
+
if (file) text = inlineSnippets(text, { file, contentDir });
|
|
136
|
+
return substituteVariables(text, variables);
|
|
137
|
+
}
|
package/src/lib/config-file.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Windows editors - Notepad, PowerShell 5.1's `Out-File`/`Set-Content
|
|
6
6
|
// -Encoding utf8` - save UTF-8 with a byte order mark. JSON.parse rejects
|
|
7
|
-
// it ("Unexpected token
|
|
7
|
+
// it ("Unexpected token U+FEFF"), and the error then blames commas and
|
|
8
8
|
// brackets the file doesn't have. Every reader strips it here instead.
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// llms.txt and its child files (/llms/*.md) - the pages an AI tool can
|
|
2
|
+
// discover, in the site's navigation structure. Shared by llms.txt.ts (the
|
|
3
|
+
// root file) and llms/[...path].md.ts (the child files a big site's llms.txt
|
|
4
|
+
// links to), so both render from the same tree in the same run. The
|
|
5
|
+
// structure and the splitting live in lib/llms.js.
|
|
6
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from './config';
|
|
10
|
+
import { buildLlmsTree, renderLlmsFiles } from './llms.js';
|
|
11
|
+
import { navigationPageOrder } from './pages.js';
|
|
12
|
+
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
13
|
+
|
|
14
|
+
type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
15
|
+
|
|
16
|
+
// Mirrors Mintlify's own llms.txt behavior: a page's frontmatter
|
|
17
|
+
// `description` is cut at the first line break (a multi-paragraph
|
|
18
|
+
// description would blow out a one-line list entry) and at 300 characters,
|
|
19
|
+
// so every entry stays scannable.
|
|
20
|
+
const DESCRIPTION_MAX_CHARS = 300;
|
|
21
|
+
function truncateDescription(description: string | undefined): string | undefined {
|
|
22
|
+
if (!description) return undefined;
|
|
23
|
+
const firstLine = description.split('\n')[0].trim();
|
|
24
|
+
if (!firstLine) return undefined;
|
|
25
|
+
return firstLine.length > DESCRIPTION_MAX_CHARS ? firstLine.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() + '…' : firstLine;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Most characters one file holds - llms.txt or a child file. The llms.txt
|
|
29
|
+
// spec sets no limit ("small enough to fit in context"); this is Mintlify's,
|
|
30
|
+
// about 25,000 tokens. Past it, sections move into child files instead of
|
|
31
|
+
// pages being dropped.
|
|
32
|
+
export const LLMS_MAX_CHARS = 100_000;
|
|
33
|
+
|
|
34
|
+
/** The generated files as Map<path, text> - 'llms.txt', then 'llms/<...>.md'
|
|
35
|
+
* when the site needs them - or null when the project has its own
|
|
36
|
+
* llms.txt, which replaces all of it. */
|
|
37
|
+
export async function llmsIndexFiles(): Promise<Map<string, string> | null> {
|
|
38
|
+
const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
39
|
+
const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
|
|
40
|
+
// A hand-authored llms.txt at the project root (next to writedocs.json)
|
|
41
|
+
// wins outright - same override convention Mintlify documents.
|
|
42
|
+
if (fs.existsSync(path.join(contentDir, 'llms.txt'))) return null;
|
|
43
|
+
|
|
44
|
+
const config = loadDocsConfig(contentDir);
|
|
45
|
+
const siteUrl = resolveSiteUrl(config); // absolute origin if `domain` is set, else null
|
|
46
|
+
|
|
47
|
+
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
48
|
+
const hasPages = findAllPages(contentDir).length > 0;
|
|
49
|
+
const [pagesEntries, generatedDocsEntries] = await Promise.all([
|
|
50
|
+
hasPages ? getCollection('pages') : Promise.resolve([]),
|
|
51
|
+
hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
|
|
52
|
+
]);
|
|
53
|
+
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
54
|
+
|
|
55
|
+
const pages = new Map<string, { title: string; href: string; description?: string; slug: string }>();
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
// A page that opted out of search-engine indexing (`seo.noindex`, also
|
|
58
|
+
// left out of sitemap.xml) is left out here too, and a frontmatter `url`
|
|
59
|
+
// page (Mintlify's external link) has no content of its own.
|
|
60
|
+
if (entry.data.seo?.noindex || entry.data.url) continue;
|
|
61
|
+
const slug = normalizeEntryId(entry.id);
|
|
62
|
+
// The .md route when it exists ([...slug].md.ts - with `contextMenu`,
|
|
63
|
+
// and never for an OpenAPI page, which renders from the spec), else the
|
|
64
|
+
// HTML page.
|
|
65
|
+
const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
|
|
66
|
+
const href = (siteUrl ?? '') + (hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`);
|
|
67
|
+
let description = truncateDescription(entry.data.description);
|
|
68
|
+
// Mirrors Mintlify: an OpenAPI operation page's description gets its
|
|
69
|
+
// "METHOD /path" appended - the page itself renders from the spec.
|
|
70
|
+
if (entry.data.openapi) description = description ? `${description} (${entry.data.openapi})` : entry.data.openapi;
|
|
71
|
+
pages.set(fileIdForEntry(contentDir, packageRoot, entry), { title: entry.data.title, href, description, slug });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const listed = new Set(navigationPageOrder(config.navigation));
|
|
75
|
+
const unlisted = [...pages.entries()]
|
|
76
|
+
.filter(([id]) => !listed.has(id))
|
|
77
|
+
.sort((a, b) => a[1].slug.localeCompare(b[1].slug))
|
|
78
|
+
.map(([id]) => id);
|
|
79
|
+
const tree = buildLlmsTree(config.navigation, (id: string) => pages.get(id) ?? null, unlisted);
|
|
80
|
+
|
|
81
|
+
return renderLlmsFiles({
|
|
82
|
+
name: config.name,
|
|
83
|
+
description: config.description,
|
|
84
|
+
tree,
|
|
85
|
+
maxChars: LLMS_MAX_CHARS,
|
|
86
|
+
urlFor: (file: string) => `${siteUrl ?? ''}/${file}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/lib/llms.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Shared by the llms routes (lib/llms-index.ts, llms-full.txt.ts): the order
|
|
2
|
+
// pages appear in, and llms.txt's structure. Plain JavaScript, so it can be
|
|
3
|
+
// tested with plain Node.
|
|
4
|
+
import { navigationPageOrder } from './pages.js';
|
|
5
|
+
|
|
6
|
+
/** `items` ({ fileId, slug }) in navigation order - the order a reader meets
|
|
7
|
+
* the pages in the sidebar - then every page the navigation doesn't list,
|
|
8
|
+
* by URL. */
|
|
9
|
+
export function orderByNavigation(items, navigation) {
|
|
10
|
+
const rank = new Map(navigationPageOrder(navigation).map((id, i) => [id, i]));
|
|
11
|
+
const at = (item) => rank.get(item.fileId) ?? Infinity;
|
|
12
|
+
return [...items].sort((a, b) => at(a) - at(b) || a.slug.localeCompare(b.slug));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- llms.txt as a tree -------------------------------------------------
|
|
16
|
+
//
|
|
17
|
+
// llms.txt lists every page under headings that follow the navigation -
|
|
18
|
+
// products, versions, languages, tabs, dropdowns, groups. A site too big for
|
|
19
|
+
// one file (MAX_CHARS) keeps llms.txt as a directory: its largest sections
|
|
20
|
+
// move into child files under /llms/, linked from where they were, and a
|
|
21
|
+
// child too big is split the same way. No page is ever left out - the scheme
|
|
22
|
+
// Mintlify moved to from cutting the list off
|
|
23
|
+
// (https://www.mintlify.com/blog/scaling-llms-txt).
|
|
24
|
+
|
|
25
|
+
const CONTAINERS = [
|
|
26
|
+
['tabs', 'tab'],
|
|
27
|
+
['versions', 'version'],
|
|
28
|
+
['languages', 'language'],
|
|
29
|
+
['dropdowns', 'dropdown'],
|
|
30
|
+
['products', 'product'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** A Section: { label, items: [page], sections: [Section] }. `pageFor(id)`
|
|
34
|
+
* gives a listed page ({ title, href, description }) or null for one that
|
|
35
|
+
* isn't listed (noindex, an external `url`, missing). A page shows up once,
|
|
36
|
+
* where the navigation first lists it; `unlisted` pages (not in the
|
|
37
|
+
* navigation at all) go in a trailing "Other pages" section. */
|
|
38
|
+
export function buildLlmsTree(navigation, pageFor, unlisted = []) {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const page = (id) => {
|
|
41
|
+
if (seen.has(id)) return null;
|
|
42
|
+
const found = pageFor(id);
|
|
43
|
+
if (!found) return null;
|
|
44
|
+
seen.add(id);
|
|
45
|
+
return found;
|
|
46
|
+
};
|
|
47
|
+
const prune = (section) => section.items.length > 0 || section.sections.length > 0;
|
|
48
|
+
|
|
49
|
+
function fromList(list) {
|
|
50
|
+
const node = { items: [], sections: [] };
|
|
51
|
+
for (const item of list ?? []) {
|
|
52
|
+
if (typeof item === 'string') {
|
|
53
|
+
const p = page(item);
|
|
54
|
+
if (p) node.items.push(p);
|
|
55
|
+
} else if (item && typeof item === 'object' && typeof item.group === 'string' && Array.isArray(item.pages)) {
|
|
56
|
+
const own = typeof item.page === 'string' ? page(item.page) : null;
|
|
57
|
+
const inner = fromList(item.pages);
|
|
58
|
+
const section = { label: item.group, items: own ? [own, ...inner.items] : inner.items, sections: inner.sections };
|
|
59
|
+
if (prune(section)) node.sections.push(section);
|
|
60
|
+
}
|
|
61
|
+
// A link leaf ({ href }) isn't a page of this site.
|
|
62
|
+
}
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fromContainer(container) {
|
|
67
|
+
if (Array.isArray(container)) return fromList(container);
|
|
68
|
+
if (!container || typeof container !== 'object') return { items: [], sections: [] };
|
|
69
|
+
if (Array.isArray(container.pages)) return fromList(container.pages);
|
|
70
|
+
for (const [key, labelKey] of CONTAINERS) {
|
|
71
|
+
if (!Array.isArray(container[key])) continue;
|
|
72
|
+
const sections = container[key]
|
|
73
|
+
.filter((child) => child && typeof child === 'object' && !('href' in child))
|
|
74
|
+
.map((child) => {
|
|
75
|
+
const label = labelKey === 'version' || labelKey === 'language' ? child.label ?? child[labelKey] : child[labelKey];
|
|
76
|
+
return { label: String(label), ...fromContainer(child) };
|
|
77
|
+
})
|
|
78
|
+
.filter(prune);
|
|
79
|
+
return { items: [], sections };
|
|
80
|
+
}
|
|
81
|
+
return { items: [], sections: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const root = fromContainer(navigation);
|
|
85
|
+
for (const dropdown of (!Array.isArray(navigation) && navigation?.global?.dropdowns) || []) {
|
|
86
|
+
if ('href' in dropdown) continue;
|
|
87
|
+
const section = { label: String(dropdown.dropdown), ...fromContainer(dropdown) };
|
|
88
|
+
if (prune(section)) root.sections.push(section);
|
|
89
|
+
}
|
|
90
|
+
const rest = unlisted.map((id) => page(id)).filter(Boolean);
|
|
91
|
+
if (rest.length) root.sections.push({ label: 'Other pages', items: rest, sections: [] });
|
|
92
|
+
return root;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pageLine = (p) => `- [${p.title}](${p.href})${p.description ? `: ${p.description}` : ''}`;
|
|
96
|
+
const countPages = (s) => s.items.length + s.sections.reduce((n, c) => n + countPages(c), 0);
|
|
97
|
+
const heading = (depth, label) => `${'#'.repeat(Math.min(depth, 6))} ${label}`;
|
|
98
|
+
|
|
99
|
+
function slugify(label) {
|
|
100
|
+
return String(label).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The body of `node` as Markdown lines: its pages, then each sub-section -
|
|
104
|
+
* under its own heading, or, when it's in `split`, as a link to its file. */
|
|
105
|
+
function renderBody(node, depth, split) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
if (node.items.length) lines.push(...node.items.map(pageLine), '');
|
|
108
|
+
for (const section of node.sections) {
|
|
109
|
+
lines.push(heading(depth, section.label), '');
|
|
110
|
+
const file = split.get(section);
|
|
111
|
+
if (file) {
|
|
112
|
+
const n = countPages(section);
|
|
113
|
+
lines.push(`- [${section.label}](${file.url}): ${n} page${n === 1 ? '' : 's'}, listed in their own file`, '');
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(...renderBody(section, depth + 1, split));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const render = (header, node, depth, split) => [...header, ...renderBody(node, depth, split)].join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
122
|
+
|
|
123
|
+
/** Every section inside `node` still rendered inline (not split, and not
|
|
124
|
+
* inside a split one), with the characters it takes. */
|
|
125
|
+
function inlineSections(node, depth, split, out = []) {
|
|
126
|
+
for (const section of node.sections) {
|
|
127
|
+
if (split.has(section)) continue;
|
|
128
|
+
out.push({ section, size: render([], { items: [], sections: [section] }, depth, split).length });
|
|
129
|
+
inlineSections(section, depth + 1, split, out);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A section whose own page list alone is too big becomes parts, each a
|
|
135
|
+
* sub-section small enough for a file of its own. */
|
|
136
|
+
function chunkItems(node, maxChars, label) {
|
|
137
|
+
const budget = Math.max(1000, maxChars - 2000);
|
|
138
|
+
const parts = [];
|
|
139
|
+
let current = [];
|
|
140
|
+
let size = 0;
|
|
141
|
+
for (const p of node.items) {
|
|
142
|
+
const n = pageLine(p).length + 1;
|
|
143
|
+
if (current.length && size + n > budget) {
|
|
144
|
+
parts.push(current);
|
|
145
|
+
current = [];
|
|
146
|
+
size = 0;
|
|
147
|
+
}
|
|
148
|
+
current.push(p);
|
|
149
|
+
size += n;
|
|
150
|
+
}
|
|
151
|
+
if (current.length) parts.push(current);
|
|
152
|
+
// One part is the list as it was - nothing smaller to split into (a single
|
|
153
|
+
// page line longer than the budget). Leave it, rather than loop.
|
|
154
|
+
if (parts.length < 2) return [];
|
|
155
|
+
const sections = parts.map((items, i) => ({ label: `${label} (part ${i + 1} of ${parts.length})`, items, sections: [] }));
|
|
156
|
+
node.sections = [...sections, ...node.sections];
|
|
157
|
+
node.items = [];
|
|
158
|
+
return sections;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** llms.txt, and any child files it needs, as Map<path, text>: 'llms.txt',
|
|
162
|
+
* then e.g. 'llms/core-platform.md'. `urlFor(path)` gives a file's URL. */
|
|
163
|
+
export function renderLlmsFiles({ name, description, tree, maxChars, urlFor }) {
|
|
164
|
+
const files = new Map();
|
|
165
|
+
const taken = new Set(['llms.txt']);
|
|
166
|
+
const rootUrl = urlFor('llms.txt');
|
|
167
|
+
|
|
168
|
+
function emit(filePath, header, node, depth, trail) {
|
|
169
|
+
const split = new Map();
|
|
170
|
+
const splitOut = (section) => {
|
|
171
|
+
const dir = filePath === 'llms.txt' ? 'llms' : filePath.replace(/\.md$/, '');
|
|
172
|
+
let childPath = `${dir}/${slugify(section.label)}.md`;
|
|
173
|
+
for (let n = 2; taken.has(childPath); n++) childPath = `${dir}/${slugify(section.label)}-${n}.md`;
|
|
174
|
+
taken.add(childPath);
|
|
175
|
+
split.set(section, { path: childPath, url: urlFor(childPath) });
|
|
176
|
+
};
|
|
177
|
+
// A page list too long for this file on its own becomes parts - all of
|
|
178
|
+
// them separate files, so this one is a plain list of the parts.
|
|
179
|
+
if (render(header, { items: node.items, sections: [] }, depth, split).length > maxChars) {
|
|
180
|
+
chunkItems(node, maxChars, trail.length ? trail[trail.length - 1] : 'Pages').forEach(splitOut);
|
|
181
|
+
}
|
|
182
|
+
// Then the biggest sections move out, one at a time, until this fits.
|
|
183
|
+
while (render(header, node, depth, split).length > maxChars) {
|
|
184
|
+
const candidates = inlineSections(node, depth, split);
|
|
185
|
+
if (!candidates.length) break;
|
|
186
|
+
splitOut(candidates.reduce((a, b) => (b.size > a.size ? b : a)).section);
|
|
187
|
+
}
|
|
188
|
+
files.set(filePath, render(header, node, depth, split));
|
|
189
|
+
for (const [section, file] of split) {
|
|
190
|
+
const childTrail = [...trail, section.label];
|
|
191
|
+
const childHeader = [`# ${name}: ${childTrail.join(' > ')}`, '', `> Part of the ${name} docs index: ${rootUrl}`, ''];
|
|
192
|
+
emit(file.path, childHeader, section, 2, childTrail);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const header = [`# ${name}`, ''];
|
|
197
|
+
if (description) header.push(`> ${description}`, '');
|
|
198
|
+
emit('llms.txt', header, tree, 2, []);
|
|
199
|
+
return files;
|
|
200
|
+
}
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
77
|
-
else if (key !== 'openapi' && key !== 'href') walk(value);
|
|
74
|
+
if (key !== 'page' && key !== 'openapi' && key !== 'href') walk(value);
|
|
78
75
|
}
|
|
79
76
|
}
|
|
80
|
-
})(
|
|
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
|
-
* -
|
|
202
|
-
*
|
|
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)
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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,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 {
|
|
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
|
|
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
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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';
|
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
|
};
|