@mandujs/core 0.41.2 → 0.43.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/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/content/llms-txt.ts
CHANGED
|
@@ -1,277 +1,277 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* llms.txt Generator (Issue #199)
|
|
3
|
-
*
|
|
4
|
-
* Emits a single plain-text index of collection entries in the
|
|
5
|
-
* `llms.txt` convention (https://llmstxt.org). The output is a
|
|
6
|
-
* deterministic Markdown-ish digest that LLM ingestion pipelines can
|
|
7
|
-
* crawl without having to understand the project's routing or MDX
|
|
8
|
-
* compilation.
|
|
9
|
-
*
|
|
10
|
-
* # Default format
|
|
11
|
-
*
|
|
12
|
-
* ```text
|
|
13
|
-
* # {site.name}
|
|
14
|
-
*
|
|
15
|
-
* ## docs
|
|
16
|
-
* - [Introduction](/docs/intro): Getting started
|
|
17
|
-
* - [CLI](/docs/cli): Command reference
|
|
18
|
-
*
|
|
19
|
-
* ## blog
|
|
20
|
-
* - [Hello world](/blog/hello): First post
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* # `full: true` variant
|
|
24
|
-
*
|
|
25
|
-
* When `full: true` is passed, every entry's body is inlined after
|
|
26
|
-
* its heading — useful for tools that want an offline snapshot of
|
|
27
|
-
* all content. This produces `llms-full.txt` in most conventions;
|
|
28
|
-
* the filename is the caller's responsibility.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import type { Collection, CollectionEntry } from "./collection";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Collection shape accepted by llms.txt — we use a structural subset
|
|
35
|
-
* (just the `all()` method) so callers can pass typed collections
|
|
36
|
-
* (`Collection<{ title: string }>`) without fighting TypeScript's
|
|
37
|
-
* invariance on generic classes. The generator never writes back into
|
|
38
|
-
* the collection, so the type-erasure to `CollectionEntry<unknown>`
|
|
39
|
-
* is safe at runtime — llms.txt reads `data.title`/`data.description`
|
|
40
|
-
* via `unknown` narrowing.
|
|
41
|
-
*/
|
|
42
|
-
// Covariant read-only view of a Collection. We deliberately re-declare
|
|
43
|
-
// the method signature with `unknown` so Collection<{...}> assigns
|
|
44
|
-
// structurally. `bivarianceHack` lets the assignability flow.
|
|
45
|
-
interface CollectionReader {
|
|
46
|
-
all(): Promise<Array<{ slug: string; filePath: string; data: unknown; content: string }>>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Entry in the input array — either a Collection or a pre-loaded triple. */
|
|
50
|
-
export type LLMSTxtInput =
|
|
51
|
-
| { name: string; collection: CollectionReader }
|
|
52
|
-
| { name: string; entries: CollectionEntry<unknown>[] };
|
|
53
|
-
|
|
54
|
-
// Re-export the full Collection type so consumers importing the llms
|
|
55
|
-
// types still see the concrete class alongside the reader alias.
|
|
56
|
-
export type { Collection };
|
|
57
|
-
|
|
58
|
-
/** Options controlling llms.txt rendering. */
|
|
59
|
-
export interface GenerateLLMSTxtOptions {
|
|
60
|
-
/**
|
|
61
|
-
* Top-level site title. Rendered as the `#` heading. When omitted,
|
|
62
|
-
* the heading line is skipped so callers can prepend their own.
|
|
63
|
-
*/
|
|
64
|
-
siteName?: string;
|
|
65
|
-
/**
|
|
66
|
-
* Short description rendered below the site heading. Ignored
|
|
67
|
-
* when `siteName` is omitted — the heading anchors the block.
|
|
68
|
-
*/
|
|
69
|
-
description?: string;
|
|
70
|
-
/**
|
|
71
|
-
* Base URL prefix applied to every entry href. Default `/`; pass
|
|
72
|
-
* an absolute origin (e.g. `https://example.com`) to produce an
|
|
73
|
-
* outward-facing llms.txt that third-party crawlers can consume
|
|
74
|
-
* without resolving against the host.
|
|
75
|
-
*
|
|
76
|
-
* Alias for `baseUrl` — accepts either name so the API reads
|
|
77
|
-
* naturally whether the caller thinks in "site base path" or
|
|
78
|
-
* "absolute URL".
|
|
79
|
-
*/
|
|
80
|
-
basePath?: string;
|
|
81
|
-
/**
|
|
82
|
-
* Alias for `basePath`. When both are provided, `baseUrl` wins
|
|
83
|
-
* (callers explicitly typing an absolute URL typically mean
|
|
84
|
-
* "use this verbatim"). Useful in docs-site configs that already
|
|
85
|
-
* expose `baseUrl` for their router / canonical URL helpers.
|
|
86
|
-
*/
|
|
87
|
-
baseUrl?: string;
|
|
88
|
-
/**
|
|
89
|
-
* When true, include each entry's body verbatim under its heading.
|
|
90
|
-
* This produces the `llms-full.txt` variant — significantly larger
|
|
91
|
-
* output, but lets consumers avoid a second fetch per entry.
|
|
92
|
-
*/
|
|
93
|
-
full?: boolean;
|
|
94
|
-
/**
|
|
95
|
-
* Include entries with `data.draft === true` (default: false).
|
|
96
|
-
* Production sites should leave this off so unpublished content
|
|
97
|
-
* doesn't leak to external crawlers.
|
|
98
|
-
*/
|
|
99
|
-
includeDrafts?: boolean;
|
|
100
|
-
/**
|
|
101
|
-
* Override the per-entry summary line. Defaults to
|
|
102
|
-
* `entry.data.description ?? ""`. Returning an empty string
|
|
103
|
-
* omits the trailing `: {summary}` tail.
|
|
104
|
-
*/
|
|
105
|
-
getSummary?: (entry: CollectionEntry<unknown>) => string;
|
|
106
|
-
/**
|
|
107
|
-
* When true, emit a nested heading structure that groups entries
|
|
108
|
-
* by their first slug segment (the "category"). With `full: true`
|
|
109
|
-
* this produces an `llms-full.txt` that mirrors the docs sidebar
|
|
110
|
-
* layout — useful for LLM crawlers that consume the
|
|
111
|
-
* category-section convention.
|
|
112
|
-
*
|
|
113
|
-
* The category headings use `###` so they nest cleanly under the
|
|
114
|
-
* `##` collection heading. Entries without a slash in their slug
|
|
115
|
-
* are placed under an implicit "root" section.
|
|
116
|
-
*/
|
|
117
|
-
groupByCategory?: boolean;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Generate an llms.txt document from one or more collections.
|
|
122
|
-
*
|
|
123
|
-
* Accepts both `Collection` instances (loaded internally) and
|
|
124
|
-
* pre-loaded entry arrays so callers can pipe in filtered/transformed
|
|
125
|
-
* data without paying for a second scan.
|
|
126
|
-
*/
|
|
127
|
-
export async function generateLLMSTxt(
|
|
128
|
-
inputs: LLMSTxtInput[],
|
|
129
|
-
options: GenerateLLMSTxtOptions = {}
|
|
130
|
-
): Promise<string> {
|
|
131
|
-
const {
|
|
132
|
-
siteName,
|
|
133
|
-
description,
|
|
134
|
-
full = false,
|
|
135
|
-
includeDrafts = false,
|
|
136
|
-
getSummary,
|
|
137
|
-
groupByCategory = false,
|
|
138
|
-
} = options;
|
|
139
|
-
// `baseUrl` wins when both are provided — see the option JSDoc.
|
|
140
|
-
const basePath = options.baseUrl ?? options.basePath ?? "/";
|
|
141
|
-
|
|
142
|
-
const lines: string[] = [];
|
|
143
|
-
if (siteName) {
|
|
144
|
-
lines.push(`# ${siteName}`);
|
|
145
|
-
if (description) {
|
|
146
|
-
lines.push("");
|
|
147
|
-
lines.push(`> ${description}`);
|
|
148
|
-
}
|
|
149
|
-
lines.push("");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
for (const input of inputs) {
|
|
153
|
-
const entries = await loadInput(input);
|
|
154
|
-
const visible = includeDrafts
|
|
155
|
-
? entries
|
|
156
|
-
: entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
|
|
157
|
-
if (visible.length === 0) continue;
|
|
158
|
-
|
|
159
|
-
// Sort within each collection by slug for deterministic output —
|
|
160
|
-
// llms.txt is often diffed by agents/tools, and a stable order
|
|
161
|
-
// keeps those diffs meaningful.
|
|
162
|
-
const sorted = [...visible].sort((a, b) =>
|
|
163
|
-
a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
lines.push(`## ${input.name}`);
|
|
167
|
-
lines.push("");
|
|
168
|
-
|
|
169
|
-
if (groupByCategory) {
|
|
170
|
-
// Bucket entries by their first slug segment. Entries without
|
|
171
|
-
// a slash go under the "__root__" sentinel so we can render
|
|
172
|
-
// them above the categorized groups.
|
|
173
|
-
const groups = new Map<string, typeof sorted>();
|
|
174
|
-
for (const entry of sorted) {
|
|
175
|
-
const [head, ...rest] = entry.slug.split("/");
|
|
176
|
-
const key = rest.length > 0 ? head : "__root__";
|
|
177
|
-
const bucket = groups.get(key);
|
|
178
|
-
if (bucket) bucket.push(entry);
|
|
179
|
-
else groups.set(key, [entry]);
|
|
180
|
-
}
|
|
181
|
-
// Emit root-level entries first (if any), then categorized
|
|
182
|
-
// groups in alphabetical category order for determinism.
|
|
183
|
-
const rootEntries = groups.get("__root__") ?? [];
|
|
184
|
-
for (const entry of rootEntries) {
|
|
185
|
-
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
186
|
-
if (full) {
|
|
187
|
-
lines.push("");
|
|
188
|
-
lines.push(entry.content);
|
|
189
|
-
lines.push("");
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
const categoryKeys = Array.from(groups.keys())
|
|
193
|
-
.filter((k) => k !== "__root__")
|
|
194
|
-
.sort();
|
|
195
|
-
for (const catKey of categoryKeys) {
|
|
196
|
-
if (rootEntries.length > 0) lines.push("");
|
|
197
|
-
lines.push(`### ${catKey}`);
|
|
198
|
-
lines.push("");
|
|
199
|
-
for (const entry of groups.get(catKey) ?? []) {
|
|
200
|
-
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
201
|
-
if (full) {
|
|
202
|
-
lines.push("");
|
|
203
|
-
lines.push(entry.content);
|
|
204
|
-
lines.push("");
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
for (const entry of sorted) {
|
|
210
|
-
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
211
|
-
if (full) {
|
|
212
|
-
lines.push("");
|
|
213
|
-
lines.push(entry.content);
|
|
214
|
-
lines.push("");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
lines.push("");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Trim the trailing blank line — the "ends in newline" convention
|
|
222
|
-
// is handled by the single final `\n` below so we don't accumulate
|
|
223
|
-
// extra blank tail lines across collections.
|
|
224
|
-
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
225
|
-
return lines.join("\n") + "\n";
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function loadInput(input: LLMSTxtInput): Promise<CollectionEntry<unknown>[]> {
|
|
229
|
-
if ("entries" in input) return input.entries;
|
|
230
|
-
// CollectionReader.all() yields the same shape as
|
|
231
|
-
// CollectionEntry<unknown>[] — the cast here is a trivial widening.
|
|
232
|
-
const entries = await input.collection.all();
|
|
233
|
-
return entries as CollectionEntry<unknown>[];
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Format a single entry as a `- [Title](href): summary` line. Split
|
|
238
|
-
* out from the main loop so both the flat and the categorized
|
|
239
|
-
* rendering paths share the same output shape.
|
|
240
|
-
*/
|
|
241
|
-
function renderEntryLine(
|
|
242
|
-
entry: CollectionEntry<unknown>,
|
|
243
|
-
collectionName: string,
|
|
244
|
-
basePath: string,
|
|
245
|
-
getSummary: ((entry: CollectionEntry<unknown>) => string) | undefined
|
|
246
|
-
): string {
|
|
247
|
-
const title =
|
|
248
|
-
typeof (entry.data as { title?: unknown })?.title === "string"
|
|
249
|
-
? String((entry.data as { title: string }).title)
|
|
250
|
-
: entry.slug || "index";
|
|
251
|
-
const href = joinHref(basePath, collectionName, entry.slug);
|
|
252
|
-
const summary = getSummary
|
|
253
|
-
? getSummary(entry)
|
|
254
|
-
: typeof (entry.data as { description?: unknown })?.description === "string"
|
|
255
|
-
? String((entry.data as { description: string }).description)
|
|
256
|
-
: "";
|
|
257
|
-
const tail = summary ? `: ${summary}` : "";
|
|
258
|
-
return `- [${title}](${href})${tail}`;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function joinHref(base: string, collectionName: string, slug: string): string {
|
|
262
|
-
const parts = [collectionName, slug].filter((x) => x !== "" && x !== "/");
|
|
263
|
-
const tail = parts.join("/").replace(/\/+/g, "/");
|
|
264
|
-
if (base.startsWith("http")) {
|
|
265
|
-
// Preserve the `//` after the protocol — we only collapse slashes
|
|
266
|
-
// in the path portion, so `https://example.com/docs/foo` survives
|
|
267
|
-
// intact instead of becoming `https:/example.com/docs/foo`.
|
|
268
|
-
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
269
|
-
return tail ? `${trimmedBase}/${tail}` : trimmedBase;
|
|
270
|
-
}
|
|
271
|
-
if (base === "" || base === "/") {
|
|
272
|
-
return `/${tail}`.replace(/\/+/g, "/");
|
|
273
|
-
}
|
|
274
|
-
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
275
|
-
const leading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
276
|
-
return `${leading}/${tail}`.replace(/\/+/g, "/");
|
|
277
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt Generator (Issue #199)
|
|
3
|
+
*
|
|
4
|
+
* Emits a single plain-text index of collection entries in the
|
|
5
|
+
* `llms.txt` convention (https://llmstxt.org). The output is a
|
|
6
|
+
* deterministic Markdown-ish digest that LLM ingestion pipelines can
|
|
7
|
+
* crawl without having to understand the project's routing or MDX
|
|
8
|
+
* compilation.
|
|
9
|
+
*
|
|
10
|
+
* # Default format
|
|
11
|
+
*
|
|
12
|
+
* ```text
|
|
13
|
+
* # {site.name}
|
|
14
|
+
*
|
|
15
|
+
* ## docs
|
|
16
|
+
* - [Introduction](/docs/intro): Getting started
|
|
17
|
+
* - [CLI](/docs/cli): Command reference
|
|
18
|
+
*
|
|
19
|
+
* ## blog
|
|
20
|
+
* - [Hello world](/blog/hello): First post
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* # `full: true` variant
|
|
24
|
+
*
|
|
25
|
+
* When `full: true` is passed, every entry's body is inlined after
|
|
26
|
+
* its heading — useful for tools that want an offline snapshot of
|
|
27
|
+
* all content. This produces `llms-full.txt` in most conventions;
|
|
28
|
+
* the filename is the caller's responsibility.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Collection, CollectionEntry } from "./collection";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collection shape accepted by llms.txt — we use a structural subset
|
|
35
|
+
* (just the `all()` method) so callers can pass typed collections
|
|
36
|
+
* (`Collection<{ title: string }>`) without fighting TypeScript's
|
|
37
|
+
* invariance on generic classes. The generator never writes back into
|
|
38
|
+
* the collection, so the type-erasure to `CollectionEntry<unknown>`
|
|
39
|
+
* is safe at runtime — llms.txt reads `data.title`/`data.description`
|
|
40
|
+
* via `unknown` narrowing.
|
|
41
|
+
*/
|
|
42
|
+
// Covariant read-only view of a Collection. We deliberately re-declare
|
|
43
|
+
// the method signature with `unknown` so Collection<{...}> assigns
|
|
44
|
+
// structurally. `bivarianceHack` lets the assignability flow.
|
|
45
|
+
interface CollectionReader {
|
|
46
|
+
all(): Promise<Array<{ slug: string; filePath: string; data: unknown; content: string }>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Entry in the input array — either a Collection or a pre-loaded triple. */
|
|
50
|
+
export type LLMSTxtInput =
|
|
51
|
+
| { name: string; collection: CollectionReader }
|
|
52
|
+
| { name: string; entries: CollectionEntry<unknown>[] };
|
|
53
|
+
|
|
54
|
+
// Re-export the full Collection type so consumers importing the llms
|
|
55
|
+
// types still see the concrete class alongside the reader alias.
|
|
56
|
+
export type { Collection };
|
|
57
|
+
|
|
58
|
+
/** Options controlling llms.txt rendering. */
|
|
59
|
+
export interface GenerateLLMSTxtOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Top-level site title. Rendered as the `#` heading. When omitted,
|
|
62
|
+
* the heading line is skipped so callers can prepend their own.
|
|
63
|
+
*/
|
|
64
|
+
siteName?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Short description rendered below the site heading. Ignored
|
|
67
|
+
* when `siteName` is omitted — the heading anchors the block.
|
|
68
|
+
*/
|
|
69
|
+
description?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Base URL prefix applied to every entry href. Default `/`; pass
|
|
72
|
+
* an absolute origin (e.g. `https://example.com`) to produce an
|
|
73
|
+
* outward-facing llms.txt that third-party crawlers can consume
|
|
74
|
+
* without resolving against the host.
|
|
75
|
+
*
|
|
76
|
+
* Alias for `baseUrl` — accepts either name so the API reads
|
|
77
|
+
* naturally whether the caller thinks in "site base path" or
|
|
78
|
+
* "absolute URL".
|
|
79
|
+
*/
|
|
80
|
+
basePath?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Alias for `basePath`. When both are provided, `baseUrl` wins
|
|
83
|
+
* (callers explicitly typing an absolute URL typically mean
|
|
84
|
+
* "use this verbatim"). Useful in docs-site configs that already
|
|
85
|
+
* expose `baseUrl` for their router / canonical URL helpers.
|
|
86
|
+
*/
|
|
87
|
+
baseUrl?: string;
|
|
88
|
+
/**
|
|
89
|
+
* When true, include each entry's body verbatim under its heading.
|
|
90
|
+
* This produces the `llms-full.txt` variant — significantly larger
|
|
91
|
+
* output, but lets consumers avoid a second fetch per entry.
|
|
92
|
+
*/
|
|
93
|
+
full?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Include entries with `data.draft === true` (default: false).
|
|
96
|
+
* Production sites should leave this off so unpublished content
|
|
97
|
+
* doesn't leak to external crawlers.
|
|
98
|
+
*/
|
|
99
|
+
includeDrafts?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Override the per-entry summary line. Defaults to
|
|
102
|
+
* `entry.data.description ?? ""`. Returning an empty string
|
|
103
|
+
* omits the trailing `: {summary}` tail.
|
|
104
|
+
*/
|
|
105
|
+
getSummary?: (entry: CollectionEntry<unknown>) => string;
|
|
106
|
+
/**
|
|
107
|
+
* When true, emit a nested heading structure that groups entries
|
|
108
|
+
* by their first slug segment (the "category"). With `full: true`
|
|
109
|
+
* this produces an `llms-full.txt` that mirrors the docs sidebar
|
|
110
|
+
* layout — useful for LLM crawlers that consume the
|
|
111
|
+
* category-section convention.
|
|
112
|
+
*
|
|
113
|
+
* The category headings use `###` so they nest cleanly under the
|
|
114
|
+
* `##` collection heading. Entries without a slash in their slug
|
|
115
|
+
* are placed under an implicit "root" section.
|
|
116
|
+
*/
|
|
117
|
+
groupByCategory?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Generate an llms.txt document from one or more collections.
|
|
122
|
+
*
|
|
123
|
+
* Accepts both `Collection` instances (loaded internally) and
|
|
124
|
+
* pre-loaded entry arrays so callers can pipe in filtered/transformed
|
|
125
|
+
* data without paying for a second scan.
|
|
126
|
+
*/
|
|
127
|
+
export async function generateLLMSTxt(
|
|
128
|
+
inputs: LLMSTxtInput[],
|
|
129
|
+
options: GenerateLLMSTxtOptions = {}
|
|
130
|
+
): Promise<string> {
|
|
131
|
+
const {
|
|
132
|
+
siteName,
|
|
133
|
+
description,
|
|
134
|
+
full = false,
|
|
135
|
+
includeDrafts = false,
|
|
136
|
+
getSummary,
|
|
137
|
+
groupByCategory = false,
|
|
138
|
+
} = options;
|
|
139
|
+
// `baseUrl` wins when both are provided — see the option JSDoc.
|
|
140
|
+
const basePath = options.baseUrl ?? options.basePath ?? "/";
|
|
141
|
+
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
if (siteName) {
|
|
144
|
+
lines.push(`# ${siteName}`);
|
|
145
|
+
if (description) {
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push(`> ${description}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const input of inputs) {
|
|
153
|
+
const entries = await loadInput(input);
|
|
154
|
+
const visible = includeDrafts
|
|
155
|
+
? entries
|
|
156
|
+
: entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
|
|
157
|
+
if (visible.length === 0) continue;
|
|
158
|
+
|
|
159
|
+
// Sort within each collection by slug for deterministic output —
|
|
160
|
+
// llms.txt is often diffed by agents/tools, and a stable order
|
|
161
|
+
// keeps those diffs meaningful.
|
|
162
|
+
const sorted = [...visible].sort((a, b) =>
|
|
163
|
+
a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
lines.push(`## ${input.name}`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
|
|
169
|
+
if (groupByCategory) {
|
|
170
|
+
// Bucket entries by their first slug segment. Entries without
|
|
171
|
+
// a slash go under the "__root__" sentinel so we can render
|
|
172
|
+
// them above the categorized groups.
|
|
173
|
+
const groups = new Map<string, typeof sorted>();
|
|
174
|
+
for (const entry of sorted) {
|
|
175
|
+
const [head, ...rest] = entry.slug.split("/");
|
|
176
|
+
const key = rest.length > 0 ? head : "__root__";
|
|
177
|
+
const bucket = groups.get(key);
|
|
178
|
+
if (bucket) bucket.push(entry);
|
|
179
|
+
else groups.set(key, [entry]);
|
|
180
|
+
}
|
|
181
|
+
// Emit root-level entries first (if any), then categorized
|
|
182
|
+
// groups in alphabetical category order for determinism.
|
|
183
|
+
const rootEntries = groups.get("__root__") ?? [];
|
|
184
|
+
for (const entry of rootEntries) {
|
|
185
|
+
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
186
|
+
if (full) {
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(entry.content);
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const categoryKeys = Array.from(groups.keys())
|
|
193
|
+
.filter((k) => k !== "__root__")
|
|
194
|
+
.sort();
|
|
195
|
+
for (const catKey of categoryKeys) {
|
|
196
|
+
if (rootEntries.length > 0) lines.push("");
|
|
197
|
+
lines.push(`### ${catKey}`);
|
|
198
|
+
lines.push("");
|
|
199
|
+
for (const entry of groups.get(catKey) ?? []) {
|
|
200
|
+
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
201
|
+
if (full) {
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push(entry.content);
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
for (const entry of sorted) {
|
|
210
|
+
lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
|
|
211
|
+
if (full) {
|
|
212
|
+
lines.push("");
|
|
213
|
+
lines.push(entry.content);
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
lines.push("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Trim the trailing blank line — the "ends in newline" convention
|
|
222
|
+
// is handled by the single final `\n` below so we don't accumulate
|
|
223
|
+
// extra blank tail lines across collections.
|
|
224
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
225
|
+
return lines.join("\n") + "\n";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadInput(input: LLMSTxtInput): Promise<CollectionEntry<unknown>[]> {
|
|
229
|
+
if ("entries" in input) return input.entries;
|
|
230
|
+
// CollectionReader.all() yields the same shape as
|
|
231
|
+
// CollectionEntry<unknown>[] — the cast here is a trivial widening.
|
|
232
|
+
const entries = await input.collection.all();
|
|
233
|
+
return entries as CollectionEntry<unknown>[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Format a single entry as a `- [Title](href): summary` line. Split
|
|
238
|
+
* out from the main loop so both the flat and the categorized
|
|
239
|
+
* rendering paths share the same output shape.
|
|
240
|
+
*/
|
|
241
|
+
function renderEntryLine(
|
|
242
|
+
entry: CollectionEntry<unknown>,
|
|
243
|
+
collectionName: string,
|
|
244
|
+
basePath: string,
|
|
245
|
+
getSummary: ((entry: CollectionEntry<unknown>) => string) | undefined
|
|
246
|
+
): string {
|
|
247
|
+
const title =
|
|
248
|
+
typeof (entry.data as { title?: unknown })?.title === "string"
|
|
249
|
+
? String((entry.data as { title: string }).title)
|
|
250
|
+
: entry.slug || "index";
|
|
251
|
+
const href = joinHref(basePath, collectionName, entry.slug);
|
|
252
|
+
const summary = getSummary
|
|
253
|
+
? getSummary(entry)
|
|
254
|
+
: typeof (entry.data as { description?: unknown })?.description === "string"
|
|
255
|
+
? String((entry.data as { description: string }).description)
|
|
256
|
+
: "";
|
|
257
|
+
const tail = summary ? `: ${summary}` : "";
|
|
258
|
+
return `- [${title}](${href})${tail}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function joinHref(base: string, collectionName: string, slug: string): string {
|
|
262
|
+
const parts = [collectionName, slug].filter((x) => x !== "" && x !== "/");
|
|
263
|
+
const tail = parts.join("/").replace(/\/+/g, "/");
|
|
264
|
+
if (base.startsWith("http")) {
|
|
265
|
+
// Preserve the `//` after the protocol — we only collapse slashes
|
|
266
|
+
// in the path portion, so `https://example.com/docs/foo` survives
|
|
267
|
+
// intact instead of becoming `https:/example.com/docs/foo`.
|
|
268
|
+
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
269
|
+
return tail ? `${trimmedBase}/${tail}` : trimmedBase;
|
|
270
|
+
}
|
|
271
|
+
if (base === "" || base === "/") {
|
|
272
|
+
return `/${tail}`.replace(/\/+/g, "/");
|
|
273
|
+
}
|
|
274
|
+
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
275
|
+
const leading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
276
|
+
return `${leading}/${tail}`.replace(/\/+/g, "/");
|
|
277
|
+
}
|