@mandujs/core 0.24.0 → 0.25.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.
@@ -0,0 +1,196 @@
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
+ basePath?: string;
77
+ /**
78
+ * When true, include each entry's body verbatim under its heading.
79
+ * This produces the `llms-full.txt` variant — significantly larger
80
+ * output, but lets consumers avoid a second fetch per entry.
81
+ */
82
+ full?: boolean;
83
+ /**
84
+ * Include entries with `data.draft === true` (default: false).
85
+ * Production sites should leave this off so unpublished content
86
+ * doesn't leak to external crawlers.
87
+ */
88
+ includeDrafts?: boolean;
89
+ /**
90
+ * Override the per-entry summary line. Defaults to
91
+ * `entry.data.description ?? ""`. Returning an empty string
92
+ * omits the trailing `: {summary}` tail.
93
+ */
94
+ getSummary?: (entry: CollectionEntry<unknown>) => string;
95
+ }
96
+
97
+ /**
98
+ * Generate an llms.txt document from one or more collections.
99
+ *
100
+ * Accepts both `Collection` instances (loaded internally) and
101
+ * pre-loaded entry arrays so callers can pipe in filtered/transformed
102
+ * data without paying for a second scan.
103
+ */
104
+ export async function generateLLMSTxt(
105
+ inputs: LLMSTxtInput[],
106
+ options: GenerateLLMSTxtOptions = {}
107
+ ): Promise<string> {
108
+ const {
109
+ siteName,
110
+ description,
111
+ basePath = "/",
112
+ full = false,
113
+ includeDrafts = false,
114
+ getSummary,
115
+ } = options;
116
+
117
+ const lines: string[] = [];
118
+ if (siteName) {
119
+ lines.push(`# ${siteName}`);
120
+ if (description) {
121
+ lines.push("");
122
+ lines.push(`> ${description}`);
123
+ }
124
+ lines.push("");
125
+ }
126
+
127
+ for (const input of inputs) {
128
+ const entries = await loadInput(input);
129
+ const visible = includeDrafts
130
+ ? entries
131
+ : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
132
+ if (visible.length === 0) continue;
133
+
134
+ // Sort within each collection by slug for deterministic output —
135
+ // llms.txt is often diffed by agents/tools, and a stable order
136
+ // keeps those diffs meaningful.
137
+ const sorted = [...visible].sort((a, b) =>
138
+ a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
139
+ );
140
+
141
+ lines.push(`## ${input.name}`);
142
+ lines.push("");
143
+ for (const entry of sorted) {
144
+ const title =
145
+ typeof (entry.data as { title?: unknown })?.title === "string"
146
+ ? String((entry.data as { title: string }).title)
147
+ : entry.slug || "index";
148
+ const href = joinHref(basePath, input.name, entry.slug);
149
+ const summary = getSummary
150
+ ? getSummary(entry)
151
+ : typeof (entry.data as { description?: unknown })?.description === "string"
152
+ ? String((entry.data as { description: string }).description)
153
+ : "";
154
+ const tail = summary ? `: ${summary}` : "";
155
+ lines.push(`- [${title}](${href})${tail}`);
156
+ if (full) {
157
+ lines.push("");
158
+ lines.push(entry.content);
159
+ lines.push("");
160
+ }
161
+ }
162
+ lines.push("");
163
+ }
164
+
165
+ // Trim the trailing blank line — the "ends in newline" convention
166
+ // is handled by the single final `\n` below so we don't accumulate
167
+ // extra blank tail lines across collections.
168
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
169
+ return lines.join("\n") + "\n";
170
+ }
171
+
172
+ async function loadInput(input: LLMSTxtInput): Promise<CollectionEntry<unknown>[]> {
173
+ if ("entries" in input) return input.entries;
174
+ // CollectionReader.all() yields the same shape as
175
+ // CollectionEntry<unknown>[] — the cast here is a trivial widening.
176
+ const entries = await input.collection.all();
177
+ return entries as CollectionEntry<unknown>[];
178
+ }
179
+
180
+ function joinHref(base: string, collectionName: string, slug: string): string {
181
+ const parts = [collectionName, slug].filter((x) => x !== "" && x !== "/");
182
+ const tail = parts.join("/").replace(/\/+/g, "/");
183
+ if (base.startsWith("http")) {
184
+ // Preserve the `//` after the protocol — we only collapse slashes
185
+ // in the path portion, so `https://example.com/docs/foo` survives
186
+ // intact instead of becoming `https:/example.com/docs/foo`.
187
+ const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
188
+ return tail ? `${trimmedBase}/${tail}` : trimmedBase;
189
+ }
190
+ if (base === "" || base === "/") {
191
+ return `/${tail}`.replace(/\/+/g, "/");
192
+ }
193
+ const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
194
+ const leading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
195
+ return `${leading}/${tail}`.replace(/\/+/g, "/");
196
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for `content/prebuild.ts` (Issue #196).
3
+ *
4
+ * We exercise:
5
+ * - `discoverPrebuildScripts` globbing + sort determinism
6
+ * - `shouldAutoPrebuild` activation policy (content/ OR scripts/prebuild-*.ts)
7
+ * - `runPrebuildScripts` with an injected spawn hook (no actual subprocess)
8
+ * covering the success path, per-script fail-fast, timeout surface, and
9
+ * the empty-discovery no-op fast path.
10
+ *
11
+ * Spawning a real `bun` subprocess is intentionally avoided — those paths
12
+ * belong in the CLI-level integration test. At this layer the Bun runtime
13
+ * is mocked so the test doubles as a correctness guarantee for any Node
14
+ * environment that later injects its own spawn hook.
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
22
+ import {
23
+ discoverPrebuildScripts,
24
+ shouldAutoPrebuild,
25
+ runPrebuildScripts,
26
+ PrebuildError,
27
+ type SpawnHook,
28
+ } from "./prebuild";
29
+
30
+ const PREFIX = path.join(os.tmpdir(), "mandu-prebuild-test-");
31
+
32
+ function mktmp(prefix = ""): string {
33
+ return fs.mkdtempSync(PREFIX + prefix);
34
+ }
35
+
36
+ function writeFile(dir: string, rel: string, content = "// stub\n"): string {
37
+ const abs = path.join(dir, rel);
38
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
39
+ fs.writeFileSync(abs, content);
40
+ return abs;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // discoverPrebuildScripts
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("discoverPrebuildScripts", () => {
48
+ let dir = "";
49
+ beforeEach(() => { dir = mktmp("discover-"); });
50
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
51
+
52
+ it("returns empty when scripts/ does not exist", () => {
53
+ expect(discoverPrebuildScripts(dir)).toEqual([]);
54
+ });
55
+
56
+ it("returns empty when scripts/ exists but has no prebuild files", () => {
57
+ writeFile(dir, "scripts/build.ts");
58
+ writeFile(dir, "scripts/README.md");
59
+ expect(discoverPrebuildScripts(dir)).toEqual([]);
60
+ });
61
+
62
+ it("finds prebuild-<name>.ts files", () => {
63
+ writeFile(dir, "scripts/prebuild-docs.ts");
64
+ writeFile(dir, "scripts/prebuild-seo.ts");
65
+ const found = discoverPrebuildScripts(dir);
66
+ expect(found).toHaveLength(2);
67
+ expect(found[0]).toContain("prebuild-docs.ts");
68
+ expect(found[1]).toContain("prebuild-seo.ts");
69
+ });
70
+
71
+ it("sorts lexicographically so numeric prefix controls ordering", () => {
72
+ writeFile(dir, "scripts/prebuild-20-second.ts");
73
+ writeFile(dir, "scripts/prebuild-10-first.ts");
74
+ writeFile(dir, "scripts/prebuild-99-third.ts");
75
+ const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
76
+ expect(found).toEqual([
77
+ "prebuild-10-first.ts",
78
+ "prebuild-20-second.ts",
79
+ "prebuild-99-third.ts",
80
+ ]);
81
+ });
82
+
83
+ it("accepts .tsx, .js, .mjs extensions", () => {
84
+ writeFile(dir, "scripts/prebuild-a.tsx");
85
+ writeFile(dir, "scripts/prebuild-b.js");
86
+ writeFile(dir, "scripts/prebuild-c.mjs");
87
+ const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
88
+ expect(found).toEqual(["prebuild-a.tsx", "prebuild-b.js", "prebuild-c.mjs"]);
89
+ });
90
+
91
+ it("rejects non-prebuild filenames even if in scripts/", () => {
92
+ writeFile(dir, "scripts/my-prebuild.ts"); // doesn't start with prebuild
93
+ writeFile(dir, "scripts/prebuild.py"); // wrong extension
94
+ writeFile(dir, "scripts/prebuild-valid.ts");
95
+ const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
96
+ expect(found).toEqual(["prebuild-valid.ts"]);
97
+ });
98
+
99
+ it("honors custom scriptsDir", () => {
100
+ writeFile(dir, "custom/prebuild-1.ts");
101
+ expect(discoverPrebuildScripts(dir, "custom")).toHaveLength(1);
102
+ });
103
+ });
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // shouldAutoPrebuild
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe("shouldAutoPrebuild", () => {
110
+ let dir = "";
111
+ beforeEach(() => { dir = mktmp("policy-"); });
112
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
113
+
114
+ it("false when neither content/ nor prebuild scripts exist", () => {
115
+ expect(shouldAutoPrebuild(dir)).toBe(false);
116
+ });
117
+
118
+ it("true when content/ directory exists (even if empty)", () => {
119
+ fs.mkdirSync(path.join(dir, "content"));
120
+ expect(shouldAutoPrebuild(dir)).toBe(true);
121
+ });
122
+
123
+ it("true when a prebuild script exists but content/ does not", () => {
124
+ writeFile(dir, "scripts/prebuild-docs.ts");
125
+ expect(shouldAutoPrebuild(dir)).toBe(true);
126
+ });
127
+
128
+ it("false when content/ is a file, not a directory", () => {
129
+ fs.writeFileSync(path.join(dir, "content"), "not a dir");
130
+ expect(shouldAutoPrebuild(dir)).toBe(false);
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // runPrebuildScripts with mock spawn
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function makeMockSpawn(
139
+ policy: (scriptPath: string) => { exitCode: number | null; durationMs: number } | Promise<{ exitCode: number | null; durationMs: number }>,
140
+ ): SpawnHook & { calls: Array<{ scriptPath: string; cwd: string; timeoutMs: number }> } {
141
+ const calls: Array<{ scriptPath: string; cwd: string; timeoutMs: number }> = [];
142
+ const hook: SpawnHook = async (args) => {
143
+ calls.push(args);
144
+ return await policy(args.scriptPath);
145
+ };
146
+ return Object.assign(hook, { calls });
147
+ }
148
+
149
+ describe("runPrebuildScripts", () => {
150
+ let dir = "";
151
+ beforeEach(() => { dir = mktmp("run-"); });
152
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
153
+
154
+ it("returns { ran: 0 } when no scripts exist — silent no-op", async () => {
155
+ const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 5 }));
156
+ const result = await runPrebuildScripts({ rootDir: dir, spawn });
157
+ expect(result.ran).toBe(0);
158
+ expect(spawn.calls).toHaveLength(0);
159
+ });
160
+
161
+ it("runs discovered scripts in order", async () => {
162
+ writeFile(dir, "scripts/prebuild-b.ts");
163
+ writeFile(dir, "scripts/prebuild-a.ts");
164
+ const order: string[] = [];
165
+ const spawn = makeMockSpawn(async (p) => {
166
+ order.push(path.basename(p));
167
+ return { exitCode: 0, durationMs: 1 };
168
+ });
169
+ const result = await runPrebuildScripts({ rootDir: dir, spawn });
170
+ expect(result.ran).toBe(2);
171
+ expect(order).toEqual(["prebuild-a.ts", "prebuild-b.ts"]);
172
+ });
173
+
174
+ it("throws PrebuildError with exitCode on first non-zero exit", async () => {
175
+ writeFile(dir, "scripts/prebuild-1.ts");
176
+ writeFile(dir, "scripts/prebuild-2.ts");
177
+ const spawn = makeMockSpawn((p) => ({
178
+ exitCode: path.basename(p) === "prebuild-1.ts" ? 3 : 0,
179
+ durationMs: 1,
180
+ }));
181
+ let caught: unknown;
182
+ try {
183
+ await runPrebuildScripts({ rootDir: dir, spawn });
184
+ } catch (e) {
185
+ caught = e;
186
+ }
187
+ expect(caught).toBeInstanceOf(PrebuildError);
188
+ const err = caught as PrebuildError;
189
+ expect(err.exitCode).toBe(3);
190
+ expect(err.scriptPath).toContain("prebuild-1.ts");
191
+ // prebuild-2.ts must NOT have been invoked (fail-fast chain semantics).
192
+ expect(spawn.calls).toHaveLength(1);
193
+ });
194
+
195
+ it("wraps non-PrebuildError spawn rejections", async () => {
196
+ writeFile(dir, "scripts/prebuild.ts");
197
+ const spawn: SpawnHook = async () => {
198
+ throw new Error("spawn ENOENT");
199
+ };
200
+ let caught: unknown;
201
+ try {
202
+ await runPrebuildScripts({ rootDir: dir, spawn });
203
+ } catch (e) {
204
+ caught = e;
205
+ }
206
+ expect(caught).toBeInstanceOf(PrebuildError);
207
+ expect((caught as PrebuildError).message).toContain("spawn ENOENT");
208
+ });
209
+
210
+ it("invokes onStart / onFinish callbacks for each script", async () => {
211
+ writeFile(dir, "scripts/prebuild-a.ts");
212
+ writeFile(dir, "scripts/prebuild-b.ts");
213
+ const starts: string[] = [];
214
+ const finishes: Array<{ name: string; code: number | null }> = [];
215
+ await runPrebuildScripts({
216
+ rootDir: dir,
217
+ spawn: async () => ({ exitCode: 0, durationMs: 1 }),
218
+ onStart: (p, i, total) => {
219
+ starts.push(`${i + 1}/${total}:${path.basename(p)}`);
220
+ },
221
+ onFinish: (r) => {
222
+ finishes.push({ name: path.basename(r.scriptPath), code: r.exitCode });
223
+ },
224
+ });
225
+ expect(starts).toEqual(["1/2:prebuild-a.ts", "2/2:prebuild-b.ts"]);
226
+ expect(finishes).toEqual([
227
+ { name: "prebuild-a.ts", code: 0 },
228
+ { name: "prebuild-b.ts", code: 0 },
229
+ ]);
230
+ });
231
+
232
+ it("threads timeoutMs through to the spawn hook", async () => {
233
+ writeFile(dir, "scripts/prebuild-1.ts");
234
+ const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
235
+ await runPrebuildScripts({
236
+ rootDir: dir,
237
+ spawn,
238
+ timeoutMs: 5_000,
239
+ });
240
+ expect(spawn.calls[0].timeoutMs).toBe(5_000);
241
+ });
242
+
243
+ it("uses default 2-minute timeout when not overridden", async () => {
244
+ writeFile(dir, "scripts/prebuild-1.ts");
245
+ const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
246
+ await runPrebuildScripts({ rootDir: dir, spawn });
247
+ expect(spawn.calls[0].timeoutMs).toBe(2 * 60 * 1000);
248
+ });
249
+ });