@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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -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
+ }