contentbase 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
package/PRIMER.md ADDED
@@ -0,0 +1,540 @@
1
+ # Contentbase API Primer
2
+
3
+ This document teaches you how to use contentbase by example, using theoretical models (`BlogPost`, `Author`, `Tag`). Pair this with your project's generated `MODELS.md` (run `cnotes summary`) to understand the specific models, attributes, sections, relationships, and computed properties available.
4
+
5
+ All examples below assume you already have a loaded collection:
6
+
7
+ ```ts
8
+ import { Collection, defineModel, section, hasMany, belongsTo, z } from "contentbase";
9
+
10
+ const collection = new Collection({ rootPath: "./content" });
11
+ collection.register(BlogPost);
12
+ collection.register(Author);
13
+ await collection.load();
14
+ ```
15
+
16
+ ---
17
+
18
+ ## 1. Querying
19
+
20
+ Queries start with `collection.query(ModelDefinition)` and return typed model instances.
21
+
22
+ ### Fetch all instances
23
+
24
+ ```ts
25
+ const posts = await collection.query(BlogPost).fetchAll();
26
+ // posts: BlogPost instance[]
27
+ ```
28
+
29
+ ### First and last
30
+
31
+ ```ts
32
+ const newest = await collection.query(BlogPost).first();
33
+ const oldest = await collection.query(BlogPost).last();
34
+ ```
35
+
36
+ ### Count
37
+
38
+ ```ts
39
+ const total = await collection.query(BlogPost).count();
40
+ ```
41
+
42
+ ### Filtering with `.where()`
43
+
44
+ The `where` method filters on any dot-path into the model instance (`meta.field`, `title`, `id`, `computed.field`, etc.).
45
+
46
+ ```ts
47
+ // Simple equality
48
+ const drafts = await collection.query(BlogPost)
49
+ .where("meta.status", "draft")
50
+ .fetchAll();
51
+
52
+ // Object form (multiple conditions)
53
+ const results = await collection.query(BlogPost)
54
+ .where({ "meta.status": "published", "meta.category": "engineering" })
55
+ .fetchAll();
56
+ ```
57
+
58
+ ### Operator shortcuts
59
+
60
+ Each shortcut method applies a specific comparison operator:
61
+
62
+ ```ts
63
+ // Greater than / less than
64
+ await collection.query(BlogPost).whereGt("meta.wordCount", 1000).fetchAll();
65
+ await collection.query(BlogPost).whereLt("meta.wordCount", 500).fetchAll();
66
+
67
+ // Greater/less than or equal
68
+ await collection.query(BlogPost).whereGte("meta.rating", 4).fetchAll();
69
+ await collection.query(BlogPost).whereLte("meta.rating", 3).fetchAll();
70
+
71
+ // Membership
72
+ await collection.query(BlogPost).whereIn("meta.status", ["draft", "review"]).fetchAll();
73
+ await collection.query(BlogPost).whereNotIn("meta.category", ["archived"]).fetchAll();
74
+
75
+ // String matching
76
+ await collection.query(BlogPost).whereContains("title", "TypeScript").fetchAll();
77
+ await collection.query(BlogPost).whereStartsWith("meta.slug", "intro-").fetchAll();
78
+ await collection.query(BlogPost).whereEndsWith("meta.slug", "-part-2").fetchAll();
79
+
80
+ // Regex
81
+ await collection.query(BlogPost).whereRegex("title", /react|vue/i).fetchAll();
82
+
83
+ // Existence
84
+ await collection.query(BlogPost).whereExists("meta.featuredImage").fetchAll();
85
+ await collection.query(BlogPost).whereNotExists("meta.deletedAt").fetchAll();
86
+ ```
87
+
88
+ ### Chaining (AND logic)
89
+
90
+ All conditions are combined with AND logic:
91
+
92
+ ```ts
93
+ const featured = await collection.query(BlogPost)
94
+ .where("meta.status", "published")
95
+ .whereGt("meta.wordCount", 500)
96
+ .whereExists("meta.featuredImage")
97
+ .fetchAll();
98
+ ```
99
+
100
+ ---
101
+
102
+ ## 2. Working with Model Instances
103
+
104
+ Model instances are created by the query system or directly via `collection.getModel()`.
105
+
106
+ ### Core properties
107
+
108
+ ```ts
109
+ const post = await collection.query(BlogPost).first();
110
+
111
+ post.id; // "posts/my-first-post" — the path-based ID
112
+ post.title; // "My First Post" — extracted from the first heading
113
+ post.slug; // "my-first-post" — kebab-cased title
114
+ post.meta; // { status: "published", category: "engineering", ... } — typed frontmatter
115
+ ```
116
+
117
+ ### Computed properties
118
+
119
+ Computed properties are lazy getters defined in the model:
120
+
121
+ ```ts
122
+ post.computed.readingTime; // 5 (minutes)
123
+ post.computed.isPublished; // true
124
+ ```
125
+
126
+ ### Validation
127
+
128
+ ```ts
129
+ const result = await post.validate();
130
+ // { valid: true, errors: [] }
131
+ // or { valid: false, errors: [ZodIssue, ...] }
132
+
133
+ post.hasErrors; // boolean — true after validate() finds issues
134
+ post.errors; // Map<string, ZodIssue>
135
+ ```
136
+
137
+ ### Serialization
138
+
139
+ ```ts
140
+ // Basic: includes id, title, and meta
141
+ post.toJSON();
142
+
143
+ // With specific sections, computed, and relationships
144
+ post.toJSON({
145
+ sections: ["summary", "tags"],
146
+ computed: ["readingTime"],
147
+ related: ["author"],
148
+ });
149
+ ```
150
+
151
+ ---
152
+
153
+ ## 3. Sections
154
+
155
+ Sections extract structured data from specific headings in the markdown document.
156
+
157
+ ### Defining sections
158
+
159
+ ```ts
160
+ import { section } from "contentbase";
161
+ import { toString } from "contentbase"; // re-exported from mdast-util-to-string
162
+
163
+ const BlogPost = defineModel("BlogPost", {
164
+ prefix: "posts",
165
+ meta: z.object({
166
+ status: z.enum(["draft", "published"]).default("draft"),
167
+ }),
168
+ sections: {
169
+ // Extract list items as strings
170
+ tags: section("Tags", {
171
+ extract: (query) =>
172
+ query.selectAll("listItem").map((node) => toString(node)),
173
+ schema: z.array(z.string()),
174
+ }),
175
+
176
+ // Extract the full text of a section
177
+ summary: section("Summary", {
178
+ extract: (query) =>
179
+ query.selectAll("paragraph").map((node) => toString(node)).join("\n"),
180
+ }),
181
+
182
+ // With alternative heading names
183
+ tldr: section("TL;DR", {
184
+ extract: (query) =>
185
+ query.selectAll("paragraph").map((node) => toString(node)).join("\n"),
186
+ alternatives: ["TLDR", "Summary"],
187
+ }),
188
+ },
189
+ });
190
+ ```
191
+
192
+ ### Accessing sections
193
+
194
+ Sections are lazy — they are only extracted when first accessed:
195
+
196
+ ```ts
197
+ const post = await collection.query(BlogPost).first();
198
+
199
+ post.sections.tags; // ["typescript", "react", "testing"]
200
+ post.sections.summary; // "This post covers..."
201
+ post.sections.tldr; // Falls back to "TLDR" or "Summary" heading if "TL;DR" not found
202
+ ```
203
+
204
+ ### The `extract` function
205
+
206
+ The `extract` function receives an `AstQuery` scoped to the nodes under the section heading (excluding the heading itself). Use it to query the markdown AST:
207
+
208
+ ```ts
209
+ // Get all list items
210
+ query.selectAll("listItem").map((node) => toString(node));
211
+
212
+ // Get paragraphs
213
+ query.selectAll("paragraph");
214
+
215
+ // Get code blocks
216
+ query.selectAll("code");
217
+
218
+ // Get links
219
+ query.selectAll("link");
220
+
221
+ // Get tables as structured data
222
+ // (use the document's nodes.tablesAsData for convenience)
223
+
224
+ // Find a specific element
225
+ query.select("paragraph"); // first paragraph only
226
+ ```
227
+
228
+ ---
229
+
230
+ ## 4. Relationships
231
+
232
+ Contentbase supports `hasMany` and `belongsTo` relationships between models.
233
+
234
+ ### Defining relationships
235
+
236
+ ```ts
237
+ import { hasMany, belongsTo } from "contentbase";
238
+
239
+ const Author = defineModel("Author", {
240
+ prefix: "authors",
241
+ meta: z.object({ name: z.string() }),
242
+ relationships: {
243
+ // Children are extracted from sub-headings under "Posts"
244
+ posts: hasMany(() => BlogPost, {
245
+ heading: "Posts",
246
+ }),
247
+ },
248
+ });
249
+
250
+ const BlogPost = defineModel("BlogPost", {
251
+ prefix: "posts",
252
+ meta: z.object({
253
+ status: z.enum(["draft", "published"]).default("draft"),
254
+ author: z.string().optional(),
255
+ }),
256
+ relationships: {
257
+ // Looks up the parent by extracting a foreign key from the child's meta
258
+ author: belongsTo(() => Author, {
259
+ foreignKey: (doc) => doc.meta.author as string,
260
+ }),
261
+ },
262
+ });
263
+ ```
264
+
265
+ **Note:** Relationship targets use thunks (`() => Model`) to allow circular references without import order issues.
266
+
267
+ ### hasMany
268
+
269
+ The `hasMany` relationship extracts child models from sub-headings within a parent heading in the document's markdown. For example, an Author document with a "## Posts" heading containing "### My First Post" and "### My Second Post" would yield two BlogPost instances.
270
+
271
+ ```ts
272
+ const author = await collection.query(Author).first();
273
+
274
+ // Get all related posts
275
+ const posts = author.relationships.posts.fetchAll();
276
+
277
+ // Get first / last
278
+ const firstPost = author.relationships.posts.first();
279
+ const lastPost = author.relationships.posts.last();
280
+ ```
281
+
282
+ ### belongsTo
283
+
284
+ The `belongsTo` relationship resolves a parent model by computing its path ID from the child's metadata.
285
+
286
+ ```ts
287
+ const post = await collection.query(BlogPost).first();
288
+
289
+ // Get the parent author
290
+ const author = post.relationships.author.fetch();
291
+ author.meta.name; // "Jane Doe"
292
+ ```
293
+
294
+ ### Custom ID generation
295
+
296
+ By default, hasMany generates child IDs as `{targetPrefix}/{parentSlug}/{childSlug}`. Override with the `id` option:
297
+
298
+ ```ts
299
+ posts: hasMany(() => BlogPost, {
300
+ heading: "Posts",
301
+ id: (slug) => `posts/${slug}`,
302
+ });
303
+ ```
304
+
305
+ ---
306
+
307
+ ## 5. Document API
308
+
309
+ Every model instance has a `document` property (non-enumerable) providing access to the underlying markdown document.
310
+
311
+ ### Accessing the document
312
+
313
+ ```ts
314
+ const post = await collection.query(BlogPost).first();
315
+ const doc = post.document;
316
+ ```
317
+
318
+ ### Document properties
319
+
320
+ ```ts
321
+ doc.id; // "posts/my-first-post"
322
+ doc.title; // "My First Post" — from first heading
323
+ doc.slug; // "my-first-post"
324
+ doc.meta; // frontmatter as object
325
+ doc.content; // raw markdown string (without frontmatter)
326
+ ```
327
+
328
+ ### Node shortcuts
329
+
330
+ The `doc.nodes` helper provides convenient access to common AST elements:
331
+
332
+ ```ts
333
+ doc.nodes.headings; // all Heading nodes
334
+ doc.nodes.firstHeading; // first heading
335
+ doc.nodes.lastHeading; // last heading
336
+ doc.nodes.headingsByDepth; // { 1: [...], 2: [...], 3: [...] }
337
+ doc.nodes.paragraphs; // all paragraphs
338
+ doc.nodes.links; // all links
339
+ doc.nodes.lists; // all lists
340
+ doc.nodes.codeBlocks; // all code blocks
341
+ doc.nodes.tables; // all table nodes
342
+ doc.nodes.tablesAsData; // tables parsed as Record<string, string>[][]
343
+ doc.nodes.leadingElementsAfterTitle; // elements between first and second heading
344
+ doc.nodes.images; // all images
345
+ ```
346
+
347
+ ### AST querying
348
+
349
+ ```ts
350
+ // Get a scoped AstQuery for the full document
351
+ const q = doc.astQuery;
352
+
353
+ q.select("heading"); // first heading
354
+ q.selectAll("heading"); // all headings
355
+ q.headingsAtDepth(2); // all ## headings
356
+ q.findHeadingByText("Introduction"); // find by text (case-insensitive)
357
+ q.findAllHeadingsByText("Note", false); // substring match
358
+ q.findBetween(nodeA, nodeB); // nodes between two nodes
359
+ q.findAllAfter(node); // all nodes after a node
360
+ q.findAllBefore(node); // all nodes before a node
361
+ q.atLine(10); // node at a specific line
362
+ ```
363
+
364
+ ### Section extraction and querying
365
+
366
+ ```ts
367
+ // Extract a section (heading + all content until next same-depth heading)
368
+ const sectionNodes = doc.extractSection("Introduction");
369
+
370
+ // Get an AstQuery scoped to a section's content (excludes the heading itself)
371
+ const sectionQuery = doc.querySection("Introduction");
372
+ sectionQuery.selectAll("listItem"); // list items within the "Introduction" section
373
+ ```
374
+
375
+ ### Section mutations
376
+
377
+ All mutation methods return a **new Document** by default (immutable). Pass `{ mutate: true }` to modify in place.
378
+
379
+ ```ts
380
+ // Remove a section
381
+ const updated = doc.removeSection("Draft Notes");
382
+
383
+ // Replace section content with markdown string
384
+ const updated = doc.replaceSectionContent("Summary", "New summary paragraph here.");
385
+
386
+ // Replace with AST nodes
387
+ const updated = doc.replaceSectionContent("Summary", [paragraphNode]);
388
+
389
+ // Insert before / after a node
390
+ const heading = doc.astQuery.findHeadingByText("Conclusion");
391
+ const updated = doc.insertBefore(heading, "## New Section\n\nContent here.");
392
+ const updated = doc.insertAfter(heading, "Follow-up content.");
393
+
394
+ // Append to end of a section
395
+ const updated = doc.appendToSection("Notes", "- Another note");
396
+
397
+ // Mutate in place instead of creating a new document
398
+ doc.removeSection("Draft Notes", { mutate: true });
399
+ ```
400
+
401
+ ### Serialization and output
402
+
403
+ ```ts
404
+ doc.stringify(); // current AST as markdown string
405
+ doc.toText(); // plain text of all nodes
406
+ doc.toOutline(); // indented heading outline
407
+ doc.rawContent; // frontmatter + content as a full markdown string
408
+ doc.toJSON(); // { id, meta, content, ast }
409
+
410
+ // Filter toText output
411
+ doc.toText((node) => node.type === "paragraph");
412
+ ```
413
+
414
+ ### Persistence
415
+
416
+ ```ts
417
+ // Save document to disk (normalizes headings by default)
418
+ await doc.save();
419
+ await doc.save({ normalize: false });
420
+
421
+ // Reload from disk
422
+ await doc.reload();
423
+ ```
424
+
425
+ ---
426
+
427
+ ## 6. Defining Models — Quick Reference
428
+
429
+ ```ts
430
+ import { defineModel, section, hasMany, belongsTo, z } from "contentbase";
431
+
432
+ const BlogPost = defineModel("BlogPost", {
433
+ // File path prefix — documents matching "posts/**" belong to this model
434
+ prefix: "posts",
435
+
436
+ // Frontmatter schema (Zod)
437
+ meta: z.object({
438
+ status: z.enum(["draft", "published"]).default("draft"),
439
+ category: z.string().optional(),
440
+ author: z.string().optional(),
441
+ wordCount: z.number().optional(),
442
+ }),
443
+
444
+ // Default values merged before validation
445
+ defaults: {
446
+ status: "draft",
447
+ },
448
+
449
+ // Structured data extracted from markdown headings
450
+ sections: {
451
+ tags: section("Tags", {
452
+ extract: (query) =>
453
+ query.selectAll("listItem").map((n) => toString(n)),
454
+ schema: z.array(z.string()),
455
+ }),
456
+ },
457
+
458
+ // Relationships to other models
459
+ relationships: {
460
+ author: belongsTo(() => Author, {
461
+ foreignKey: (doc) => doc.meta.author as string,
462
+ }),
463
+ },
464
+
465
+ // Derived properties computed from the instance
466
+ computed: {
467
+ isPublished: (self) => self.meta.status === "published",
468
+ readingTime: (self) => Math.ceil((self.meta.wordCount ?? 0) / 200),
469
+ },
470
+ });
471
+ ```
472
+
473
+ ### Custom matching
474
+
475
+ By default, documents are matched to models by their `prefix` (path starts with `"posts/"`). Override with a `match` function for custom logic:
476
+
477
+ ```ts
478
+ const SpecialPost = defineModel("SpecialPost", {
479
+ prefix: "posts",
480
+ match: (doc) => doc.meta.type === "special",
481
+ meta: z.object({ type: z.literal("special") }),
482
+ });
483
+ ```
484
+
485
+ ---
486
+
487
+ ## 7. Collection API
488
+
489
+ ```ts
490
+ const collection = new Collection({
491
+ rootPath: "./content",
492
+ name: "my-project", // optional display name
493
+ extensions: ["mdx", "md"], // file extensions to scan (default)
494
+ });
495
+
496
+ // Register models before loading
497
+ collection.register(BlogPost);
498
+ collection.register(Author);
499
+
500
+ // Load all files
501
+ await collection.load();
502
+
503
+ // Reload files from disk
504
+ await collection.load({ refresh: true });
505
+
506
+ // Access loaded data
507
+ collection.available; // all path IDs: ["posts/hello", "authors/jane", ...]
508
+ collection.items; // Map<string, CollectionItem>
509
+ collection.documents; // Map<string, Document> (lazily populated)
510
+ collection.modelDefinitions; // all registered model definitions
511
+ collection.loaded; // boolean
512
+
513
+ // Get a specific document
514
+ const doc = collection.document("posts/hello");
515
+
516
+ // Get a typed model instance directly
517
+ const post = collection.getModel("posts/hello", BlogPost);
518
+
519
+ // Determine which model matches a document
520
+ const def = collection.findModelDefinition("posts/hello"); // BlogPost definition
521
+
522
+ // Generate documentation
523
+ await collection.generateModelSummary(); // writes MODELS.md to rootPath
524
+
525
+ // Table of contents
526
+ const toc = collection.tableOfContents({ title: "Content Index" });
527
+
528
+ // Serialization
529
+ collection.toJSON(); // { models, itemIds }
530
+ await collection.export(); // includes full model data
531
+
532
+ // Actions
533
+ collection.action("rebuild-index", async (coll) => { /* ... */ });
534
+ await collection.runAction("rebuild-index");
535
+ collection.availableActions; // ["rebuild-index"]
536
+
537
+ // Persistence
538
+ await collection.saveItem("posts/new-post", { content: "---\ntitle: New\n---\n\n# New Post" });
539
+ await collection.deleteItem("posts/old-post");
540
+ ```