contentbase 0.0.1 → 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 (97) 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 +406 -13
  7. package/bun.lock +45 -6
  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 +14 -4
  14. package/public/web-demo/index.html +813 -0
  15. package/scripts/examples/01-collection-setup.ts +46 -0
  16. package/scripts/examples/02-querying.ts +67 -0
  17. package/scripts/examples/03-sections.ts +36 -0
  18. package/scripts/examples/04-relationships.ts +54 -0
  19. package/scripts/examples/05-document-api.ts +54 -0
  20. package/scripts/examples/06-extract-sections.ts +55 -0
  21. package/scripts/examples/07-validation.ts +46 -0
  22. package/scripts/examples/08-serialization.ts +51 -0
  23. package/scripts/examples/lib/format.ts +87 -0
  24. package/scripts/examples/lib/setup.ts +21 -0
  25. package/scripts/examples/run-all.ts +43 -0
  26. package/showcases/node_modules/.cache/.repl_history +3 -0
  27. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  28. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  29. package/src/api/endpoints/actions.ts +34 -0
  30. package/src/api/endpoints/doc.ts +187 -0
  31. package/src/api/endpoints/docs-index.ts +26 -0
  32. package/src/api/endpoints/document.ts +171 -0
  33. package/src/api/endpoints/documents.ts +71 -0
  34. package/src/api/endpoints/inspect.ts +16 -0
  35. package/src/api/endpoints/models.ts +10 -0
  36. package/src/api/endpoints/query.ts +88 -0
  37. package/src/api/endpoints/root.ts +7 -0
  38. package/src/api/endpoints/search-reindex.ts +65 -0
  39. package/src/api/endpoints/search-status.ts +55 -0
  40. package/src/api/endpoints/search.ts +104 -0
  41. package/src/api/endpoints/semantic-search.ts +120 -0
  42. package/src/api/endpoints/text-search.ts +63 -0
  43. package/src/api/endpoints/validate.ts +34 -0
  44. package/src/api/helpers.ts +97 -0
  45. package/src/base-model.ts +12 -0
  46. package/src/cli/commands/action.ts +82 -44
  47. package/src/cli/commands/console.ts +124 -0
  48. package/src/cli/commands/create.ts +179 -53
  49. package/src/cli/commands/embed.ts +323 -0
  50. package/src/cli/commands/export.ts +58 -24
  51. package/src/cli/commands/extract.ts +174 -0
  52. package/src/cli/commands/help.ts +81 -0
  53. package/src/cli/commands/index.ts +17 -0
  54. package/src/cli/commands/init.ts +72 -48
  55. package/src/cli/commands/inspect.ts +56 -46
  56. package/src/cli/commands/mcp.ts +1219 -0
  57. package/src/cli/commands/search.ts +285 -0
  58. package/src/cli/commands/serve.ts +348 -0
  59. package/src/cli/commands/summary.ts +60 -0
  60. package/src/cli/commands/teach.ts +86 -0
  61. package/src/cli/commands/text-search.ts +134 -0
  62. package/src/cli/commands/validate.ts +126 -64
  63. package/src/cli/index.ts +88 -19
  64. package/src/cli/load-collection.ts +144 -17
  65. package/src/cli/registry.ts +28 -0
  66. package/src/collection.ts +455 -6
  67. package/src/define-model.ts +104 -7
  68. package/src/document.ts +47 -1
  69. package/src/extract-sections.ts +222 -0
  70. package/src/index.ts +20 -2
  71. package/src/model-instance.ts +35 -5
  72. package/src/node-shortcuts.ts +1 -1
  73. package/src/query/collection-query.ts +96 -9
  74. package/src/query/index.ts +7 -0
  75. package/src/query/query-dsl.ts +259 -0
  76. package/src/relationships/has-many.ts +39 -0
  77. package/src/relationships/index.ts +7 -2
  78. package/src/section.ts +2 -0
  79. package/src/types.ts +24 -2
  80. package/src/utils/index.ts +1 -0
  81. package/src/utils/match-pattern.ts +65 -0
  82. package/src/validator.ts +18 -1
  83. package/test/collection.test.ts +118 -2
  84. package/test/extract-sections.test.ts +356 -0
  85. package/test/fixtures/sdlc/MODELS.md +106 -0
  86. package/test/fixtures/sdlc/SKILL.md +404 -0
  87. package/test/fixtures/sdlc/index.ts +9 -0
  88. package/test/fixtures/sdlc/models.ts +8 -6
  89. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  90. package/test/fixtures/sdlc/templates/epic.md +23 -0
  91. package/test/fixtures/sdlc/templates/story.md +19 -0
  92. package/test/pattern.test.ts +191 -0
  93. package/test/query-dsl.test.ts +431 -0
  94. package/test/query.test.ts +29 -0
  95. package/test/relationships.test.ts +130 -0
  96. package/test/section.test.ts +65 -4
  97. package/test/table-of-contents.test.ts +135 -0
package/README.md CHANGED
@@ -5,8 +5,7 @@
5
5
  Contentbase treats a folder of Markdown and MDX files as a typed, queryable database. Define models with Zod schemas, extract structured data from headings and lists, traverse parent/child relationships across documents, validate everything, and query it all with a fluent API.
6
6
 
7
7
  ```ts
8
- import { Collection, defineModel, section, hasMany, z } from "contentbase";
9
- import { toString } from "mdast-util-to-string";
8
+ import { Collection, defineModel, section, hasMany, z, toString } from "contentbase";
10
9
 
11
10
  const Story = defineModel("Story", {
12
11
  meta: z.object({
@@ -79,14 +78,18 @@ content/
79
78
 
80
79
  A model is a config object that describes one type of document. It declares:
81
80
 
81
+ - **description** -- human-readable summary (auto-generated from schema if omitted)
82
82
  - **meta** -- a Zod schema for frontmatter
83
83
  - **sections** -- named extractions from heading-based sections
84
84
  - **relationships** -- `hasMany` / `belongsTo` links between models
85
85
  - **computed** -- derived values calculated from instance data
86
+ - **defaults** -- static default values for frontmatter fields
87
+ - **pattern** -- Express-style path patterns for inferring meta from file paths
86
88
 
87
89
  ```ts
88
90
  const Epic = defineModel("Epic", {
89
91
  prefix: "epics",
92
+ description: "A project epic that groups related user stories.",
90
93
  meta: z.object({
91
94
  priority: z.enum(["low", "medium", "high"]).optional(),
92
95
  status: z.enum(["created", "in-progress", "complete"]).default("created"),
@@ -103,14 +106,71 @@ const Epic = defineModel("Epic", {
103
106
  });
104
107
  ```
105
108
 
109
+ If `description` is omitted, one is generated on first access from the model's schema: *"An Epic has metadata (priority, status), relationship (stories → Story), and computed property (isComplete)."*
110
+
106
111
  The `prefix` determines which files match this model. Files whose path starts with `"epics"` are Epics. If omitted, the prefix is auto-pluralized from the name (`"Epic"` -> `"epics"`).
107
112
 
113
+ #### Explicit Model Assignment with `_model`
114
+
115
+ Documents at the root of a collection (not in a subfolder) can't be matched by prefix. You can explicitly assign a model by adding `_model` to the frontmatter:
116
+
117
+ ```yaml
118
+ ---
119
+ _model: Epic
120
+ title: Platform Migration
121
+ status: created
122
+ ---
123
+ ```
124
+
125
+ The `_model` key takes priority over prefix matching, so this works even if the file lives outside the model's prefix folder.
126
+
127
+ #### The Base Model
128
+
129
+ Every collection automatically registers a built-in `Base` model as a catch-all. Documents that don't match any other model (by `_model` or prefix) are assigned to Base. You can query these unmatched documents:
130
+
131
+ ```ts
132
+ import { Base } from "contentbase";
133
+
134
+ const misc = await collection.query(Base).fetchAll();
135
+ ```
136
+
137
+ If you want to customize the Base model (e.g. add a meta schema), register your own before calling `load()`:
138
+
139
+ ```ts
140
+ const MyBase = defineModel("Base", {
141
+ meta: z.object({ tags: z.array(z.string()).optional() }),
142
+ });
143
+ collection.register(MyBase);
144
+ await collection.load(); // won't auto-register the built-in Base
145
+ ```
146
+
147
+ #### Path Patterns
148
+
149
+ Models can declare Express-style path patterns to automatically infer meta values from the document's file path:
150
+
151
+ ```ts
152
+ const Story = defineModel("Story", {
153
+ prefix: "stories",
154
+ pattern: "stories/:epic/:slug",
155
+ meta: z.object({
156
+ epic: z.string(),
157
+ slug: z.string(),
158
+ }),
159
+ });
160
+ ```
161
+
162
+ A file at `stories/authentication/user-can-register.mdx` will automatically have `{ epic: "authentication", slug: "user-can-register" }` inferred into its meta. Explicit frontmatter values always take precedence over pattern-inferred values. You can also supply an array of patterns -- the first match wins.
163
+
108
164
  ### Collections
109
165
 
110
166
  A `Collection` loads a directory tree and gives you access to documents and typed model instances.
111
167
 
112
168
  ```ts
113
- const collection = new Collection({ rootPath: "./content" });
169
+ const collection = new Collection({
170
+ rootPath: "./content",
171
+ extensions: ["mdx", "md"], // default
172
+ autoDiscover: true, // auto-load models.ts if no models registered
173
+ });
114
174
  await collection.load();
115
175
 
116
176
  // Register models for prefix-based matching
@@ -141,8 +201,7 @@ Given this Markdown:
141
201
  Define a section to extract the list items:
142
202
 
143
203
  ```ts
144
- import { section } from "contentbase";
145
- import { toString } from "mdast-util-to-string";
204
+ import { section, toString } from "contentbase";
146
205
 
147
206
  const Story = defineModel("Story", {
148
207
  sections: {
@@ -150,12 +209,13 @@ const Story = defineModel("Story", {
150
209
  extract: (query) =>
151
210
  query.selectAll("listItem").map((node) => toString(node)),
152
211
  schema: z.array(z.string()),
212
+ alternatives: ["Requirements"], // fallback heading names
153
213
  }),
154
214
  },
155
215
  });
156
216
  ```
157
217
 
158
- The `extract` function receives an `AstQuery` scoped to the content under that heading. The `schema` is optional and used during validation.
218
+ The `extract` function receives an `AstQuery` scoped to the content under that heading. The `schema` is optional and used during validation. The `alternatives` array provides fallback heading names -- if "Acceptance Criteria" isn't found, it tries "Requirements" next.
159
219
 
160
220
  Section data is **lazily computed and cached** -- the extract function only runs the first time you access the property.
161
221
 
@@ -272,6 +332,9 @@ const results = await collection
272
332
  .query(Story)
273
333
  .whereIn("meta.status", ["created", "in-progress"])
274
334
  .whereExists("meta.epic")
335
+ .sort("meta.status", "asc")
336
+ .limit(10)
337
+ .offset(0)
275
338
  .fetchAll();
276
339
 
277
340
  // Convenience accessors
@@ -283,6 +346,60 @@ Available operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `cont
283
346
 
284
347
  Queries filter by model type **before** creating instances, so you only pay the parsing cost for matching documents.
285
348
 
349
+ ### JSON Query DSL
350
+
351
+ For querying over the wire (REST API, MCP server) without executing arbitrary code, Contentbase provides a MongoDB-style JSON query DSL:
352
+
353
+ ```json
354
+ {
355
+ "model": "Epic",
356
+ "where": {
357
+ "meta.status": "created",
358
+ "meta.priority": { "$gt": 3 }
359
+ },
360
+ "sort": { "meta.priority": "desc" },
361
+ "select": ["id", "title", "meta.status"],
362
+ "limit": 10,
363
+ "offset": 0
364
+ }
365
+ ```
366
+
367
+ **Where value shortcuts:**
368
+
369
+ | Value type | Interpretation | Example |
370
+ |------------|---------------|---------|
371
+ | Literal (string, number, boolean, null) | Implicit `$eq` | `"meta.status": "active"` |
372
+ | Array | Implicit `$in` | `"meta.tags": ["a", "b"]` |
373
+ | Operator object | Explicit operator | `"meta.priority": { "$gt": 5 }` |
374
+ | Multiple operators | AND on same field | `"meta.priority": { "$gte": 3, "$lte": 8 }` |
375
+
376
+ **Available operators:** `$eq`, `$neq`, `$in`, `$notIn`, `$gt`, `$lt`, `$gte`, `$lte`, `$contains`, `$startsWith`, `$endsWith`, `$regex`, `$exists`
377
+
378
+ **Options:**
379
+
380
+ | Field | Type | Default | Description |
381
+ |-------|------|---------|-------------|
382
+ | `model` | string | *(required)* | Model name or prefix |
383
+ | `where` | object | -- | MongoDB-style filter conditions |
384
+ | `sort` | object or array | -- | `{ "field": "asc"\|"desc" }` or `[{ "path": "...", "direction": "asc" }]` |
385
+ | `select` | string[] | -- | Fields to include in output |
386
+ | `limit` | number | -- | Maximum results to return |
387
+ | `offset` | number | -- | Number of results to skip |
388
+ | `method` | string | `"fetchAll"` | `"fetchAll"`, `"first"`, `"last"`, or `"count"` |
389
+
390
+ Use via `POST /api/query` with a JSON body, or through the MCP `query` tool. The DSL parser is also available programmatically:
391
+
392
+ ```ts
393
+ import { executeQueryDSL, queryDSLSchema } from "contentbase";
394
+
395
+ const result = await executeQueryDSL(collection, {
396
+ model: "Epic",
397
+ where: { "meta.status": "created" },
398
+ sort: { title: "asc" },
399
+ limit: 5,
400
+ });
401
+ ```
402
+
286
403
  ---
287
404
 
288
405
  ## Validation
@@ -376,6 +493,147 @@ await doc.reload();
376
493
 
377
494
  ---
378
495
 
496
+ ## Standalone Parsing
497
+
498
+ The `parse()` function gives you a queryable document from a file path or raw markdown string, without needing a Collection:
499
+
500
+ ```ts
501
+ import { parse } from "contentbase";
502
+
503
+ const doc = await parse("./content/my-post.mdx");
504
+ doc.title; // first heading text
505
+ doc.meta; // frontmatter
506
+ doc.astQuery.selectAll("heading"); // AST querying
507
+ doc.nodes.links; // node shortcuts
508
+ doc.querySection("Introduction").selectAll("paragraph");
509
+
510
+ // Also works with raw markdown
511
+ const doc2 = await parse("# Hello\n\nWorld");
512
+ ```
513
+
514
+ ---
515
+
516
+ ## Extracting Sections Across Documents
517
+
518
+ `extractSections()` pulls named sections from multiple documents into a single combined document, with heading depths adjusted automatically:
519
+
520
+ ```ts
521
+ import { extractSections } from "contentbase";
522
+
523
+ const combined = extractSections([
524
+ { source: doc1, sections: "Acceptance Criteria" },
525
+ { source: doc2, sections: ["Acceptance Criteria", "Mockups"] },
526
+ ], {
527
+ title: "All Acceptance Criteria",
528
+ });
529
+ ```
530
+
531
+ This produces:
532
+
533
+ ```md
534
+ # All Acceptance Criteria
535
+
536
+ ## Authentication
537
+ ### Acceptance Criteria
538
+ - Users can sign up with email and password
539
+ - ...
540
+
541
+ ## Searching And Browsing
542
+ ### Acceptance Criteria
543
+ - Users can search by category
544
+ - ...
545
+ ```
546
+
547
+ ### Modes
548
+
549
+ **Grouped** (default) -- each source document gets a heading (its title), with extracted sections nested underneath:
550
+
551
+ ```ts
552
+ extractSections(entries, { mode: "grouped" });
553
+ ```
554
+
555
+ **Flat** -- sections are placed sequentially with no source grouping:
556
+
557
+ ```ts
558
+ extractSections(entries, { mode: "flat" });
559
+ // ## Acceptance Criteria <- from doc1
560
+ // - ...
561
+ // ## Acceptance Criteria <- from doc2
562
+ // - ...
563
+ ```
564
+
565
+ ### Options
566
+
567
+ | Option | Default | Description |
568
+ | --- | --- | --- |
569
+ | `title` | -- | Optional h1 title for the combined document |
570
+ | `mode` | `"grouped"` | `"grouped"` nests under source titles, `"flat"` places sections sequentially |
571
+ | `onMissing` | `"skip"` | `"skip"` silently omits missing sections, `"throw"` raises an error |
572
+
573
+ The return value is a `ParsedDocument` -- fully queryable with `astQuery`, `nodes`, `extractSection()`, `querySection()`, and `stringify()`.
574
+
575
+ Sources can be any mix of `Document` and `ParsedDocument` instances.
576
+
577
+ ---
578
+
579
+ ## Table of Contents Generation
580
+
581
+ Generate a markdown table of contents for a collection with links that work on GitHub:
582
+
583
+ ```ts
584
+ const toc = collection.tableOfContents({ title: "Project Docs" });
585
+ ```
586
+
587
+ Output:
588
+
589
+ ```md
590
+ # Project Docs
591
+
592
+ ## Epic
593
+
594
+ - [Authentication](./epics/authentication.mdx)
595
+ - [Searching And Browsing](./epics/searching-and-browsing.mdx)
596
+
597
+ ## Story
598
+
599
+ - [A User should be able to register.](./stories/authentication/a-user-should-be-able-to-register.mdx)
600
+ ```
601
+
602
+ If models are registered, documents are grouped by model. Without models, a flat list is produced. Use `basePath` to control the link prefix:
603
+
604
+ ```ts
605
+ collection.tableOfContents({ basePath: "./content" });
606
+ // links become: ./content/epics/authentication.mdx
607
+ ```
608
+
609
+ ### File Tree
610
+
611
+ Render an ASCII file tree of all documents in the collection:
612
+
613
+ ```ts
614
+ const tree = collection.renderFileTree();
615
+ ```
616
+
617
+ ```
618
+ epics/
619
+ ├── authentication.mdx
620
+ └── searching-and-browsing.mdx
621
+ stories/
622
+ └── authentication/
623
+ └── a-user-should-be-able-to-register.mdx
624
+ ```
625
+
626
+ ### Model Summary
627
+
628
+ Generate comprehensive documentation of all registered models, including schema fields, sections, relationships, and defaults:
629
+
630
+ ```ts
631
+ const summary = await collection.generateModelSummary();
632
+ // Returns markdown documenting each model's schema, sections, relationships
633
+ ```
634
+
635
+ ---
636
+
379
637
  ## Computed Properties
380
638
 
381
639
  Derived values that are lazily evaluated from instance data:
@@ -422,16 +680,141 @@ collection.use(timestampPlugin, { format: "iso" });
422
680
 
423
681
  ## CLI
424
682
 
425
- Contentbase ships with a CLI for common operations:
683
+ Contentbase ships with a CLI available as both `cnotes` and `contentbase`. See [CLI.md](./CLI.md) for the full reference with examples for every command.
684
+
685
+ ```bash
686
+ bun add contentbase
687
+
688
+ # Then use it via bunx, or in package.json scripts
689
+ bunx cnotes inspect
690
+ ```
691
+
692
+ ### Commands
693
+
694
+ ```bash
695
+ cnotes init [name] # scaffold a new project
696
+ cnotes create <Model> --title "..." # scaffold a new document (uses templates if available)
697
+ cnotes inspect # show models, sections, relationships, doc counts
698
+ cnotes validate [target] # validate documents ('all', a model name, or a path ID)
699
+ cnotes export # export collection as JSON
700
+ cnotes extract <glob> --sections "A, B" # extract specific sections from matching documents
701
+ cnotes summary # generate MODELS.md and TABLE-OF-CONTENTS.md
702
+ cnotes teach # output combined documentation for LLM context
703
+ cnotes action <name> # run a named action
704
+ cnotes text-search <pattern> # search file contents with pattern matching
705
+ cnotes serve # start HTTP server with REST API and doc serving
706
+ cnotes mcp # start MCP server for AI agent integration
707
+ cnotes console # interactive REPL with collection in scope
708
+ cnotes help # list available commands
709
+ ```
710
+
711
+ All commands accept `--contentFolder` to specify which folder contains your content. Defaults to `./docs`. You can also set it in `package.json`:
712
+
713
+ ```json
714
+ {
715
+ "contentbase": {
716
+ "contentFolder": "content"
717
+ }
718
+ }
719
+ ```
720
+
721
+ ### serve
722
+
723
+ Start an HTTP server that exposes a full REST API for the collection. Documents are available as JSON, rendered HTML, or raw markdown.
724
+
725
+ ```bash
726
+ # Start on default port 8000
727
+ cnotes serve
728
+
729
+ # Custom port, specific content folder
730
+ cnotes serve --port 9000 --contentFolder ./sdlc
731
+
732
+ # Read-only mode: disables all write endpoints (POST/PUT/PATCH/DELETE)
733
+ cnotes serve --read-only
734
+ ```
735
+
736
+ **Built-in endpoints:**
737
+
738
+ | Path | Description |
739
+ |------|-------------|
740
+ | `GET /api/inspect` | Collection overview |
741
+ | `GET /api/models` | All model definitions |
742
+ | `GET /api/documents` | List documents (filter with `?model=`) |
743
+ | `GET/POST/PUT/PATCH/DELETE /api/documents/:pathId` | Document CRUD |
744
+ | `GET /api/query?model=&where=&select=` | Query model instances (flat condition format) |
745
+ | `POST /api/query` | Query with JSON DSL body (MongoDB-style) |
746
+ | `GET /api/search?pattern=` | Full-text regex search across documents |
747
+ | `GET /api/text-search?pattern=` | File-level text search (`expanded=true` for line detail) |
748
+ | `GET /api/validate?pathId=` | Validate against schema |
749
+ | `GET/POST /api/actions` | List or execute actions |
750
+ | `GET /docs/:path.json\|.md\|.html` | Content-negotiated doc serving |
751
+ | `GET /openapi.json` | Auto-generated OpenAPI 3.1 spec |
752
+
753
+ When `--read-only` is passed, all mutating endpoints return `403 Forbidden`. This includes `POST /api/documents`, `PUT/PATCH/DELETE /api/documents/:pathId`, and `POST /api/actions`. Read endpoints are unaffected.
754
+
755
+ You can also add your own endpoints by placing files in an `endpoints/` directory. See [CLI.md](./CLI.md#user-defined-endpoints) for details.
756
+
757
+ ### mcp
758
+
759
+ Start a Model Context Protocol server for AI agent integration. Exposes tools, resources, and prompts for the collection.
426
760
 
427
761
  ```bash
428
- contentbase inspect # show collection info
429
- contentbase validate # validate all documents
430
- contentbase export # export collection as JSON
431
- contentbase create Story # scaffold a new document
432
- contentbase action publish # run a named action
762
+ cnotes mcp # stdio transport (for Claude Desktop, etc.)
763
+ cnotes mcp --transport http --port 3003 # HTTP transport
433
764
  ```
434
765
 
766
+ ### extract
767
+
768
+ The `extract` command outputs document titles, leading content, and only the requested sections -- combined into a single document suitable for creating new content:
769
+
770
+ ```bash
771
+ # Extract Acceptance Criteria from all stories
772
+ cnotes extract "stories/**/*" --sections "Acceptance Criteria"
773
+
774
+ # Combine epics into a single document with a title
775
+ cnotes extract "epics/*" -s "Stories" --title "All Stories"
776
+
777
+ # Multiple sections, include frontmatter, raw heading depths
778
+ cnotes extract "epics/*" -s "Stories, Notes" --frontmatter --no-normalize-headings
779
+ ```
780
+
781
+ Glob patterns are matched against document path IDs using [picomatch](https://github.com/micromatch/picomatch). Sections that don't exist in a document are silently skipped.
782
+
783
+ By default, heading depths are normalized so each document's content nests properly in the combined output. When `--title` is provided, it becomes the h1 and document titles shift to h2. Use `--no-normalize-headings` to preserve original heading depths.
784
+
785
+ ### create
786
+
787
+ The `create` command scaffolds new documents with smart defaults:
788
+
789
+ ```bash
790
+ cnotes create Story --title "User can logout"
791
+ cnotes create Epic --title "Payments" --meta.priority high
792
+ ```
793
+
794
+ If a template exists at `templates/<model>.md` (or `.mdx`) in your content directory, it's used as the base. Meta values are merged with this priority: Zod defaults < model defaults < template frontmatter < CLI `--meta.*` flags.
795
+
796
+ ### Model Discovery
797
+
798
+ The CLI uses a 3-tier system to find your models:
799
+
800
+ **Tier 1 — `index.ts`** (recommended): If your content directory has an `index.ts` that exports a `Collection` with models registered, the CLI uses it directly. This is what `contentbase init` scaffolds.
801
+
802
+ ```ts
803
+ // docs/index.ts
804
+ import { Collection, defineModel, z } from "contentbase";
805
+
806
+ const Post = defineModel("Post", {
807
+ meta: z.object({ draft: z.boolean().default(false) }),
808
+ });
809
+
810
+ export const collection = new Collection({ rootPath: import.meta.dir });
811
+ collection.register(Post);
812
+ ```
813
+
814
+ **Tier 2 — `models.ts`**: If no `index.ts` exists but a `models.ts` is found, the CLI imports it, detects model definitions from exports, and auto-registers them on a new Collection.
815
+
816
+ **Tier 3 — Auto-discovery**: If neither file exists, the CLI scans top-level subdirectories for markdown files and generates bare models from folder names (`epics/` → `Epic`). These models have no schema validation — useful for quick inspection, but you'll want a `models.ts` or `index.ts` for real use.
817
+
435
818
  ---
436
819
 
437
820
  ## API Reference
@@ -442,16 +825,26 @@ contentbase action publish # run a named action
442
825
  | --- | --- |
443
826
  | `Collection` | Loads and manages a directory of documents |
444
827
  | `Document` | A single Markdown/MDX file with AST operations |
445
- | `defineModel()` | Create a typed model definition |
828
+ | `defineModel()` | Create a typed model definition (accepts optional `description`, auto-generated if omitted) |
829
+ | `generateDescription()` | Generate a human-readable model description from its schema |
446
830
  | `section()` | Declare a section extraction |
447
831
  | `hasMany()` | Declare a one-to-many relationship |
448
832
  | `belongsTo()` | Declare a many-to-one relationship |
833
+ | `parse()` | Parse a file path or markdown string into a queryable `ParsedDocument` |
834
+ | `extractSections()` | Combine sections from multiple documents into one |
449
835
  | `CollectionQuery` | Fluent query builder for model instances |
836
+ | `queryDSLSchema` | Zod schema for validating JSON query DSL input |
837
+ | `parseWhereClause()` | Parse MongoDB-style where object into internal conditions |
838
+ | `executeQueryDSL()` | Execute a JSON query DSL against a collection |
450
839
  | `AstQuery` | MDAST query wrapper (select, visit, find) |
451
840
  | `NodeShortcuts` | Convenience getters for common AST nodes |
452
841
  | `createModelInstance()` | Low-level factory for model instances |
453
842
  | `validateDocument()` | Standalone validation function |
843
+ | `matchPattern()` | Express-style path pattern matching (`:param` syntax) |
844
+ | `matchPatterns()` | Try multiple patterns against a path, first match wins |
845
+ | `introspectMetaSchema()` | Extract field info (name, type, required, default) from a Zod schema |
454
846
  | `z` | Re-exported from Zod (no extra dependency needed) |
847
+ | `toString` | Re-exported from `mdast-util-to-string` |
455
848
 
456
849
  ---
457
850