contentbase 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -376,6 +376,121 @@ await doc.reload();
376
376
 
377
377
  ---
378
378
 
379
+ ## Standalone Parsing
380
+
381
+ The `parse()` function gives you a queryable document from a file path or raw markdown string, without needing a Collection:
382
+
383
+ ```ts
384
+ import { parse } from "contentbase";
385
+
386
+ const doc = await parse("./content/my-post.mdx");
387
+ doc.title; // first heading text
388
+ doc.meta; // frontmatter
389
+ doc.astQuery.selectAll("heading"); // AST querying
390
+ doc.nodes.links; // node shortcuts
391
+ doc.querySection("Introduction").selectAll("paragraph");
392
+
393
+ // Also works with raw markdown
394
+ const doc2 = await parse("# Hello\n\nWorld");
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Extracting Sections Across Documents
400
+
401
+ `extractSections()` pulls named sections from multiple documents into a single combined document, with heading depths adjusted automatically:
402
+
403
+ ```ts
404
+ import { extractSections } from "contentbase";
405
+
406
+ const combined = extractSections([
407
+ { source: doc1, sections: "Acceptance Criteria" },
408
+ { source: doc2, sections: ["Acceptance Criteria", "Mockups"] },
409
+ ], {
410
+ title: "All Acceptance Criteria",
411
+ });
412
+ ```
413
+
414
+ This produces:
415
+
416
+ ```md
417
+ # All Acceptance Criteria
418
+
419
+ ## Authentication
420
+ ### Acceptance Criteria
421
+ - Users can sign up with email and password
422
+ - ...
423
+
424
+ ## Searching And Browsing
425
+ ### Acceptance Criteria
426
+ - Users can search by category
427
+ - ...
428
+ ```
429
+
430
+ ### Modes
431
+
432
+ **Grouped** (default) -- each source document gets a heading (its title), with extracted sections nested underneath:
433
+
434
+ ```ts
435
+ extractSections(entries, { mode: "grouped" });
436
+ ```
437
+
438
+ **Flat** -- sections are placed sequentially with no source grouping:
439
+
440
+ ```ts
441
+ extractSections(entries, { mode: "flat" });
442
+ // ## Acceptance Criteria <- from doc1
443
+ // - ...
444
+ // ## Acceptance Criteria <- from doc2
445
+ // - ...
446
+ ```
447
+
448
+ ### Options
449
+
450
+ | Option | Default | Description |
451
+ | --- | --- | --- |
452
+ | `title` | -- | Optional h1 title for the combined document |
453
+ | `mode` | `"grouped"` | `"grouped"` nests under source titles, `"flat"` places sections sequentially |
454
+ | `onMissing` | `"skip"` | `"skip"` silently omits missing sections, `"throw"` raises an error |
455
+
456
+ The return value is a `ParsedDocument` -- fully queryable with `astQuery`, `nodes`, `extractSection()`, `querySection()`, and `stringify()`.
457
+
458
+ Sources can be any mix of `Document` and `ParsedDocument` instances.
459
+
460
+ ---
461
+
462
+ ## Table of Contents Generation
463
+
464
+ Generate a markdown table of contents for a collection with links that work on GitHub:
465
+
466
+ ```ts
467
+ const toc = collection.tableOfContents({ title: "Project Docs" });
468
+ ```
469
+
470
+ Output:
471
+
472
+ ```md
473
+ # Project Docs
474
+
475
+ ## Epic
476
+
477
+ - [Authentication](./epics/authentication.mdx)
478
+ - [Searching And Browsing](./epics/searching-and-browsing.mdx)
479
+
480
+ ## Story
481
+
482
+ - [A User should be able to register.](./stories/authentication/a-user-should-be-able-to-register.mdx)
483
+ ```
484
+
485
+ If models are registered, documents are grouped by model. Without models, a flat list is produced. Use `basePath` to control the link prefix:
486
+
487
+ ```ts
488
+ collection.tableOfContents({ basePath: "./content" });
489
+ // links become: ./content/epics/authentication.mdx
490
+ ```
491
+
492
+ ---
493
+
379
494
  ## Computed Properties
380
495
 
381
496
  Derived values that are lazily evaluated from instance data:
@@ -446,6 +561,8 @@ contentbase action publish # run a named action
446
561
  | `section()` | Declare a section extraction |
447
562
  | `hasMany()` | Declare a one-to-many relationship |
448
563
  | `belongsTo()` | Declare a many-to-one relationship |
564
+ | `parse()` | Parse a file path or markdown string into a queryable `ParsedDocument` |
565
+ | `extractSections()` | Combine sections from multiple documents into one |
449
566
  | `CollectionQuery` | Fluent query builder for model instances |
450
567
  | `AstQuery` | MDAST query wrapper (select, visit, find) |
451
568
  | `NodeShortcuts` | Convenience getters for common AST nodes |
package/bun.lock CHANGED
@@ -20,7 +20,7 @@
20
20
  "unist-util-find-before": "^4.0.0",
21
21
  "unist-util-select": "^5.1.0",
22
22
  "unist-util-visit": "^5.0.0",
23
- "zod": "^3.23.0",
23
+ "zod": "^4.3.6",
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/js-yaml": "^4.0.9",
@@ -456,7 +456,7 @@
456
456
 
457
457
  "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
458
458
 
459
- "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
459
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
460
460
 
461
461
  "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
462
462
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contentbase",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,7 +10,8 @@
10
10
  "scripts": {
11
11
  "test": "vitest run",
12
12
  "test:watch": "vitest",
13
- "typecheck": "tsc --noEmit"
13
+ "typecheck": "tsc --noEmit",
14
+ "examples": "bun run scripts/examples/run-all.ts"
14
15
  },
15
16
  "dependencies": {
16
17
  "citty": "^0.1.6",
@@ -29,7 +30,7 @@
29
30
  "unist-util-find-before": "^4.0.0",
30
31
  "unist-util-select": "^5.1.0",
31
32
  "unist-util-visit": "^5.0.0",
32
- "zod": "^3.23.0"
33
+ "zod": "^4.3.6"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/js-yaml": "^4.0.9",
@@ -0,0 +1,46 @@
1
+ import { scriptTitle, demo, kv, list } from "./lib/format";
2
+ import { createDemoCollection, Epic, Story } from "./lib/setup";
3
+
4
+ export async function main() {
5
+ scriptTitle("01", "Collection Setup");
6
+ const collection = await createDemoCollection();
7
+
8
+ await demo({
9
+ title: "Create and load a collection",
10
+ description: "Register models, load markdown files from disk.",
11
+ code: `const collection = new Collection({ rootPath: "./content" });
12
+ collection.register(Epic);
13
+ collection.register(Story);
14
+ await collection.load();`,
15
+ run: () => {
16
+ return [
17
+ kv("Documents loaded", collection.available.length),
18
+ kv("Models registered", collection.modelDefinitions.length),
19
+ ].join("\n");
20
+ },
21
+ });
22
+
23
+ await demo({
24
+ title: "List available documents",
25
+ code: `collection.available`,
26
+ run: () => {
27
+ return list(collection.available);
28
+ },
29
+ });
30
+
31
+ await demo({
32
+ title: "Get a typed model instance",
33
+ code: `const epic = collection.getModel("epics/authentication", Epic);`,
34
+ run: () => {
35
+ const epic = collection.getModel("epics/authentication", Epic);
36
+ return [
37
+ kv("Title", epic.title),
38
+ kv("Slug", epic.slug),
39
+ kv("Status", epic.meta.status),
40
+ kv("Priority", epic.meta.priority),
41
+ ].join("\n");
42
+ },
43
+ });
44
+ }
45
+
46
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,67 @@
1
+ import { scriptTitle, demo, kv, list } from "./lib/format";
2
+ import { createDemoCollection, Epic, Story } from "./lib/setup";
3
+
4
+ export async function main() {
5
+ scriptTitle("02", "Querying");
6
+ const collection = await createDemoCollection();
7
+
8
+ await demo({
9
+ title: "Fetch all instances of a model",
10
+ code: `const epics = await collection.query(Epic).fetchAll();`,
11
+ run: async () => {
12
+ const epics = await collection.query(Epic).fetchAll();
13
+ return list(epics.map((e) => `${e.title} (${e.meta.status})`));
14
+ },
15
+ });
16
+
17
+ await demo({
18
+ title: "Filter with where()",
19
+ code: `const high = await collection.query(Epic)
20
+ .where("meta.priority", "high")
21
+ .fetchAll();`,
22
+ run: async () => {
23
+ const high = await collection
24
+ .query(Epic)
25
+ .where("meta.priority", "high")
26
+ .fetchAll();
27
+ return list(high.map((e) => `${e.title} — priority: ${e.meta.priority}`));
28
+ },
29
+ });
30
+
31
+ await demo({
32
+ title: "Chained where + whereExists",
33
+ code: `const filtered = await collection.query(Epic)
34
+ .where("meta.status", "created")
35
+ .whereExists("meta.priority")
36
+ .fetchAll();`,
37
+ run: async () => {
38
+ const filtered = await collection
39
+ .query(Epic)
40
+ .where("meta.status", "created")
41
+ .whereExists("meta.priority")
42
+ .fetchAll();
43
+ return list(
44
+ filtered.map((e) => `${e.title} — ${e.meta.status}, ${e.meta.priority}`)
45
+ );
46
+ },
47
+ });
48
+
49
+ await demo({
50
+ title: "Query helpers: first, last, count",
51
+ code: `const first = await collection.query(Epic).first();
52
+ const last = await collection.query(Epic).last();
53
+ const count = await collection.query(Epic).count();`,
54
+ run: async () => {
55
+ const first = await collection.query(Epic).first();
56
+ const last = await collection.query(Epic).last();
57
+ const count = await collection.query(Epic).count();
58
+ return [
59
+ kv("First", first?.title),
60
+ kv("Last", last?.title),
61
+ kv("Count", count),
62
+ ].join("\n");
63
+ },
64
+ });
65
+ }
66
+
67
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,36 @@
1
+ import { scriptTitle, demo, kv, list } from "./lib/format";
2
+ import { createDemoCollection, Story } from "./lib/setup";
3
+
4
+ export async function main() {
5
+ scriptTitle("03", "Sections");
6
+ const collection = await createDemoCollection();
7
+ const story = collection.getModel(
8
+ "stories/authentication/a-user-should-be-able-to-register",
9
+ Story
10
+ );
11
+
12
+ await demo({
13
+ title: "Typed section access: acceptanceCriteria",
14
+ description: "Sections are defined in the model with section() helpers and extracted from headings.",
15
+ code: `const story = collection.getModel(
16
+ "stories/authentication/a-user-should-be-able-to-register",
17
+ Story
18
+ );
19
+ story.sections.acceptanceCriteria;`,
20
+ run: () => {
21
+ return list(story.sections.acceptanceCriteria);
22
+ },
23
+ });
24
+
25
+ await demo({
26
+ title: "Typed section access: mockups",
27
+ description: "The mockups section extracts links as a key-value record.",
28
+ code: `story.sections.mockups;`,
29
+ run: () => {
30
+ const entries = Object.entries(story.sections.mockups);
31
+ return entries.map(([label, url]) => kv(label, url)).join("\n");
32
+ },
33
+ });
34
+ }
35
+
36
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,54 @@
1
+ import { scriptTitle, demo, kv, list } from "./lib/format";
2
+ import { createDemoCollection, Epic, Story } from "./lib/setup";
3
+
4
+ export async function main() {
5
+ scriptTitle("04", "Relationships");
6
+ const collection = await createDemoCollection();
7
+ const epic = collection.getModel("epics/authentication", Epic);
8
+
9
+ await demo({
10
+ title: "hasMany: Epic → Stories",
11
+ description: "Navigate from an epic to its child stories.",
12
+ code: `const epic = collection.getModel("epics/authentication", Epic);
13
+ const stories = epic.relationships.stories.fetchAll();`,
14
+ run: () => {
15
+ const stories = epic.relationships.stories.fetchAll();
16
+ return list(stories.map((s: any) => s.title));
17
+ },
18
+ });
19
+
20
+ await demo({
21
+ title: "hasMany helpers: first() and last()",
22
+ code: `epic.relationships.stories.first()?.title;
23
+ epic.relationships.stories.last()?.title;`,
24
+ run: () => {
25
+ return [
26
+ kv("First story", epic.relationships.stories.first()?.title),
27
+ kv("Last story", epic.relationships.stories.last()?.title),
28
+ ].join("\n");
29
+ },
30
+ });
31
+
32
+ await demo({
33
+ title: "belongsTo: Story → Epic",
34
+ description: "Navigate from a story back to its parent epic.",
35
+ code: `const story = collection.getModel(
36
+ "stories/authentication/a-user-should-be-able-to-register",
37
+ Story
38
+ );
39
+ const parent = story.relationships.epic.fetch();`,
40
+ run: () => {
41
+ const story = collection.getModel(
42
+ "stories/authentication/a-user-should-be-able-to-register",
43
+ Story
44
+ );
45
+ const parent = story.relationships.epic.fetch();
46
+ return [
47
+ kv("Story", story.title),
48
+ kv("Parent epic", parent.title),
49
+ ].join("\n");
50
+ },
51
+ });
52
+ }
53
+
54
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,54 @@
1
+ import path from "path";
2
+ import { scriptTitle, demo, kv } from "./lib/format";
3
+ import { createDemoCollection, FIXTURES_PATH } from "./lib/setup";
4
+ import { parse } from "../../src/index";
5
+
6
+ export async function main() {
7
+ scriptTitle("05", "Document API");
8
+ const collection = await createDemoCollection();
9
+ const filePath = path.join(FIXTURES_PATH, "epics/authentication.mdx");
10
+
11
+ await demo({
12
+ title: "parse() a standalone file",
13
+ description: "Parse any markdown file without needing a collection.",
14
+ code: `import { parse } from "contentbase";
15
+ const doc = await parse("./epics/authentication.mdx");`,
16
+ run: async () => {
17
+ const doc = await parse(filePath);
18
+ return [
19
+ kv("Title", doc.title),
20
+ kv("Headings", doc.nodes.headings.length),
21
+ kv("Links", doc.nodes.links.length),
22
+ kv("Lists", doc.nodes.lists.length),
23
+ ].join("\n");
24
+ },
25
+ });
26
+
27
+ await demo({
28
+ title: "Document outline",
29
+ description: "Generate an indented heading outline of any document.",
30
+ code: `const doc = collection.document("epics/authentication");
31
+ doc.toOutline();`,
32
+ run: () => {
33
+ const doc = collection.document("epics/authentication");
34
+ return doc.toOutline();
35
+ },
36
+ });
37
+
38
+ await demo({
39
+ title: "Immutable section removal",
40
+ description: "removeSection() returns a new document — the original is unchanged.",
41
+ code: `const original = collection.document("epics/authentication");
42
+ const trimmed = original.removeSection("Stories");`,
43
+ run: () => {
44
+ const original = collection.document("epics/authentication");
45
+ const trimmed = original.removeSection("Stories");
46
+ return [
47
+ kv("Original headings", original.nodes.headings.length),
48
+ kv("After removeSection", trimmed.nodes.headings.length),
49
+ ].join("\n");
50
+ },
51
+ });
52
+ }
53
+
54
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,55 @@
1
+ import { scriptTitle, demo } from "./lib/format";
2
+ import { createDemoCollection, Epic } from "./lib/setup";
3
+ import { extractSections } from "../../src/index";
4
+
5
+ export async function main() {
6
+ scriptTitle("06", "Extract Sections");
7
+ const collection = await createDemoCollection();
8
+ const allEpics = await collection.query(Epic).fetchAll();
9
+
10
+ await demo({
11
+ title: "Grouped mode (default)",
12
+ description: "Combine sections from multiple documents, grouped by source.",
13
+ code: `const combined = extractSections(
14
+ allEpics.map((e) => ({
15
+ source: e.document,
16
+ sections: "Stories",
17
+ })),
18
+ { title: "All Stories", mode: "grouped" }
19
+ );`,
20
+ run: () => {
21
+ const combined = extractSections(
22
+ allEpics.map((e: any) => ({
23
+ source: e.document,
24
+ sections: "Stories",
25
+ })),
26
+ { title: "All Stories", mode: "grouped" }
27
+ );
28
+ return combined.content;
29
+ },
30
+ });
31
+
32
+ await demo({
33
+ title: "Flat mode",
34
+ description: "Combine sections without source grouping.",
35
+ code: `const flat = extractSections(
36
+ allEpics.map((e) => ({
37
+ source: e.document,
38
+ sections: "Stories",
39
+ })),
40
+ { title: "All Stories", mode: "flat" }
41
+ );`,
42
+ run: () => {
43
+ const flat = extractSections(
44
+ allEpics.map((e: any) => ({
45
+ source: e.document,
46
+ sections: "Stories",
47
+ })),
48
+ { title: "All Stories", mode: "flat" }
49
+ );
50
+ return flat.content;
51
+ },
52
+ });
53
+ }
54
+
55
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,46 @@
1
+ import { scriptTitle, demo, kv } from "./lib/format";
2
+ import { createDemoCollection, Story } from "./lib/setup";
3
+ import { validateDocument } from "../../src/index";
4
+
5
+ export async function main() {
6
+ scriptTitle("07", "Validation");
7
+ const collection = await createDemoCollection();
8
+ const story = collection.getModel(
9
+ "stories/authentication/a-user-should-be-able-to-register",
10
+ Story
11
+ );
12
+
13
+ await demo({
14
+ title: "Instance validation",
15
+ description: "Validate a model instance against its Zod schemas.",
16
+ code: `const story = collection.getModel("stories/.../register", Story);
17
+ const result = await story.validate();`,
18
+ run: async () => {
19
+ const result = await story.validate();
20
+ return [
21
+ kv("Valid", result.valid),
22
+ kv("Errors", result.errors.length),
23
+ ].join("\n");
24
+ },
25
+ });
26
+
27
+ await demo({
28
+ title: "Standalone validateDocument()",
29
+ description: "Validate any document against a model definition without creating an instance.",
30
+ code: `import { validateDocument } from "contentbase";
31
+ const doc = collection.document("stories/.../register");
32
+ const result = validateDocument(doc, Story);`,
33
+ run: () => {
34
+ const doc = collection.document(
35
+ "stories/authentication/a-user-should-be-able-to-register"
36
+ );
37
+ const result = validateDocument(doc, Story);
38
+ return [
39
+ kv("Valid", result.valid),
40
+ kv("Errors", result.errors.length),
41
+ ].join("\n");
42
+ },
43
+ });
44
+ }
45
+
46
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,51 @@
1
+ import { scriptTitle, demo, kv } from "./lib/format";
2
+ import { createDemoCollection, Epic } from "./lib/setup";
3
+
4
+ export async function main() {
5
+ scriptTitle("08", "Serialization");
6
+ const collection = await createDemoCollection();
7
+ const epic = collection.getModel("epics/authentication", Epic);
8
+
9
+ await demo({
10
+ title: "toJSON() — basic",
11
+ code: `epic.toJSON();`,
12
+ run: () => epic.toJSON(),
13
+ });
14
+
15
+ await demo({
16
+ title: "toJSON() — with computed and relationships",
17
+ code: `epic.toJSON({
18
+ computed: ["isComplete"],
19
+ related: ["stories"],
20
+ });`,
21
+ run: () =>
22
+ epic.toJSON({
23
+ computed: ["isComplete"],
24
+ related: ["stories"],
25
+ }),
26
+ });
27
+
28
+ await demo({
29
+ title: "Table of contents",
30
+ description: "Generate a markdown TOC with links for the whole collection.",
31
+ code: `collection.tableOfContents({ title: "SDLC Docs" });`,
32
+ run: () => collection.tableOfContents({ title: "SDLC Docs" }),
33
+ });
34
+
35
+ await demo({
36
+ title: "Collection export",
37
+ description: "Export all model data as a JSON snapshot.",
38
+ code: `const data = await collection.export();
39
+ Object.keys(data);`,
40
+ run: async () => {
41
+ const data = await collection.export();
42
+ return [
43
+ kv("Keys", Object.keys(data).join(", ")),
44
+ kv("Models", Object.keys(data.modelData as object).join(", ")),
45
+ kv("Item count", (data.itemIds as string[]).length),
46
+ ].join("\n");
47
+ },
48
+ });
49
+ }
50
+
51
+ if (import.meta.main) main().catch(console.error);
@@ -0,0 +1,87 @@
1
+ // ANSI escape codes
2
+ const c = {
3
+ reset: "\x1b[0m",
4
+ bold: "\x1b[1m",
5
+ dim: "\x1b[2m",
6
+ cyan: "\x1b[36m",
7
+ yellow: "\x1b[33m",
8
+ green: "\x1b[32m",
9
+ gray: "\x1b[90m",
10
+ magenta: "\x1b[35m",
11
+ white: "\x1b[37m",
12
+ };
13
+
14
+ export function scriptTitle(num: string, title: string) {
15
+ const text = ` ${num}. ${title} `;
16
+ const line = "═".repeat(text.length + 2);
17
+ console.log();
18
+ console.log(`${c.cyan}${c.bold}╔${line}╗${c.reset}`);
19
+ console.log(`${c.cyan}${c.bold}║ ${text} ║${c.reset}`);
20
+ console.log(`${c.cyan}${c.bold}╚${line}╝${c.reset}`);
21
+ console.log();
22
+ }
23
+
24
+ interface DemoBlock {
25
+ title: string;
26
+ description?: string;
27
+ code: string;
28
+ run: () => unknown | Promise<unknown>;
29
+ }
30
+
31
+ export async function demo(block: DemoBlock) {
32
+ // Title
33
+ console.log(` ${c.yellow}${c.bold}▸ ${block.title}${c.reset}`);
34
+
35
+ // Description
36
+ if (block.description) {
37
+ console.log(` ${c.dim}${block.description}${c.reset}`);
38
+ }
39
+
40
+ // Code box
41
+ const codeLines = block.code.split("\n");
42
+ const maxLen = Math.max(...codeLines.map((l) => l.length));
43
+ const border = "─".repeat(maxLen + 2);
44
+
45
+ console.log(` ${c.dim}┌${border}┐${c.reset}`);
46
+ for (const line of codeLines) {
47
+ console.log(` ${c.dim}│${c.reset} ${line.padEnd(maxLen)} ${c.dim}│${c.reset}`);
48
+ }
49
+ console.log(` ${c.dim}└${border}┘${c.reset}`);
50
+
51
+ // Run and display output
52
+ const result = await block.run();
53
+
54
+ if (result != null) {
55
+ let output: string;
56
+ if (typeof result === "string") {
57
+ output = result;
58
+ } else {
59
+ output = JSON.stringify(result, null, 2);
60
+ }
61
+
62
+ const outLines = output.split("\n");
63
+ const outMaxLen = Math.max(...outLines.map((l) => l.length));
64
+ const outBorder = "─".repeat(outMaxLen + 2);
65
+
66
+ console.log(` ${c.green}┌${outBorder}┐${c.reset}`);
67
+ for (const line of outLines) {
68
+ console.log(` ${c.green}│${c.reset} ${c.green}${line.padEnd(outMaxLen)}${c.reset} ${c.green}│${c.reset}`);
69
+ }
70
+ console.log(` ${c.green}└${outBorder}┘${c.reset}`);
71
+ }
72
+
73
+ console.log();
74
+ }
75
+
76
+ export function kv(key: string, value: unknown): string {
77
+ return `${c.magenta}${key}:${c.reset} ${value}`;
78
+ }
79
+
80
+ export function list(items: string[]): string {
81
+ return items.map((item) => ` • ${item}`).join("\n");
82
+ }
83
+
84
+ export function heading(text: string) {
85
+ console.log(` ${c.cyan}${c.bold}${text}${c.reset}`);
86
+ console.log();
87
+ }