contentbase 0.0.1

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 (89) hide show
  1. package/README.md +460 -0
  2. package/bun.lock +473 -0
  3. package/examples/sdlc-queries.ts +161 -0
  4. package/package.json +41 -0
  5. package/showcases/national-parks/models.ts +74 -0
  6. package/showcases/national-parks/parks/acadia.mdx +40 -0
  7. package/showcases/national-parks/parks/yosemite.mdx +44 -0
  8. package/showcases/national-parks/parks/zion.mdx +44 -0
  9. package/showcases/national-parks/queries.ts +103 -0
  10. package/showcases/national-parks/trails/angels-landing.mdx +19 -0
  11. package/showcases/national-parks/trails/cathedral-lakes.mdx +19 -0
  12. package/showcases/national-parks/trails/half-dome.mdx +19 -0
  13. package/showcases/national-parks/trails/jordan-pond-path.mdx +19 -0
  14. package/showcases/national-parks/trails/mist-trail.mdx +19 -0
  15. package/showcases/national-parks/trails/observation-point.mdx +19 -0
  16. package/showcases/national-parks/trails/precipice-trail.mdx +19 -0
  17. package/showcases/national-parks/trails/the-narrows.mdx +19 -0
  18. package/showcases/recipes/cuisines/chinese.mdx +28 -0
  19. package/showcases/recipes/cuisines/italian.mdx +32 -0
  20. package/showcases/recipes/cuisines/mexican.mdx +28 -0
  21. package/showcases/recipes/models.ts +77 -0
  22. package/showcases/recipes/queries.ts +89 -0
  23. package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +43 -0
  24. package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +47 -0
  25. package/showcases/recipes/recipes/italian/bruschetta.mdx +38 -0
  26. package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +39 -0
  27. package/showcases/recipes/recipes/italian/tiramisu.mdx +43 -0
  28. package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +44 -0
  29. package/showcases/recipes/recipes/mexican/guacamole.mdx +39 -0
  30. package/showcases/vinyl-collection/albums/bitches-brew.mdx +36 -0
  31. package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +35 -0
  32. package/showcases/vinyl-collection/albums/in-rainbows.mdx +35 -0
  33. package/showcases/vinyl-collection/albums/kind-of-blue.mdx +32 -0
  34. package/showcases/vinyl-collection/albums/ok-computer.mdx +37 -0
  35. package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +35 -0
  36. package/showcases/vinyl-collection/artists/miles-davis.mdx +27 -0
  37. package/showcases/vinyl-collection/artists/nina-simone.mdx +26 -0
  38. package/showcases/vinyl-collection/artists/radiohead.mdx +27 -0
  39. package/showcases/vinyl-collection/models.ts +73 -0
  40. package/showcases/vinyl-collection/queries.ts +87 -0
  41. package/src/ast-query.ts +132 -0
  42. package/src/cli/commands/action.ts +44 -0
  43. package/src/cli/commands/create.ts +59 -0
  44. package/src/cli/commands/export.ts +24 -0
  45. package/src/cli/commands/init.ts +75 -0
  46. package/src/cli/commands/inspect.ts +46 -0
  47. package/src/cli/commands/validate.ts +75 -0
  48. package/src/cli/index.ts +20 -0
  49. package/src/cli/load-collection.ts +53 -0
  50. package/src/collection.ts +399 -0
  51. package/src/define-model.ts +80 -0
  52. package/src/document.ts +468 -0
  53. package/src/index.ts +47 -0
  54. package/src/model-instance.ts +227 -0
  55. package/src/node-shortcuts.ts +87 -0
  56. package/src/parse.ts +123 -0
  57. package/src/query/collection-query.ts +149 -0
  58. package/src/query/index.ts +5 -0
  59. package/src/query/operators.ts +37 -0
  60. package/src/query/query-builder.ts +109 -0
  61. package/src/relationships/belongs-to.ts +50 -0
  62. package/src/relationships/has-many.ts +136 -0
  63. package/src/relationships/index.ts +57 -0
  64. package/src/relationships/types.ts +7 -0
  65. package/src/section.ts +29 -0
  66. package/src/types.ts +221 -0
  67. package/src/utils/index.ts +11 -0
  68. package/src/utils/inflect.ts +82 -0
  69. package/src/utils/normalize-headings.ts +31 -0
  70. package/src/utils/parse-table.ts +30 -0
  71. package/src/utils/read-directory.ts +35 -0
  72. package/src/utils/stringify-ast.ts +9 -0
  73. package/src/validator.ts +52 -0
  74. package/test/ast-query.test.ts +128 -0
  75. package/test/collection.test.ts +99 -0
  76. package/test/define-model.test.ts +78 -0
  77. package/test/document.test.ts +225 -0
  78. package/test/fixtures/sdlc/epics/authentication.mdx +42 -0
  79. package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +21 -0
  80. package/test/fixtures/sdlc/models.ts +89 -0
  81. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +20 -0
  82. package/test/helpers.ts +21 -0
  83. package/test/model-instance.test.ts +197 -0
  84. package/test/query.test.ts +167 -0
  85. package/test/relationships.test.ts +84 -0
  86. package/test/section.test.ts +99 -0
  87. package/test/validator.test.ts +62 -0
  88. package/tsconfig.json +18 -0
  89. package/vitest.config.ts +11 -0
package/README.md ADDED
@@ -0,0 +1,460 @@
1
+ # Contentbase
2
+
3
+ **An ORM for your Markdown.**
4
+
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
+
7
+ ```ts
8
+ import { Collection, defineModel, section, hasMany, z } from "contentbase";
9
+ import { toString } from "mdast-util-to-string";
10
+
11
+ const Story = defineModel("Story", {
12
+ meta: z.object({
13
+ status: z.enum(["draft", "ready", "shipped"]).default("draft"),
14
+ points: z.number().optional(),
15
+ }),
16
+ sections: {
17
+ acceptanceCriteria: section("Acceptance Criteria", {
18
+ extract: (q) => q.selectAll("listItem").map((n) => toString(n)),
19
+ schema: z.array(z.string()).min(1),
20
+ }),
21
+ },
22
+ });
23
+
24
+ const collection = new Collection({ rootPath: "./content" });
25
+ await collection.load();
26
+
27
+ const stories = await collection
28
+ .query(Story)
29
+ .where("meta.status", "ready")
30
+ .fetchAll();
31
+
32
+ stories[0].meta.status; // "ready" (typed!)
33
+ stories[0].sections.acceptanceCriteria; // string[] (typed!)
34
+ ```
35
+
36
+ No database. No build step. Your content is the source of truth.
37
+
38
+ ---
39
+
40
+ ## Why
41
+
42
+ You already organize knowledge in Markdown: specs, stories, docs, runbooks, design decisions. But the moment you need to query across files, validate frontmatter, or extract structured data from a heading, you're writing brittle scripts.
43
+
44
+ Contentbase gives you the primitives to treat that content like a real data layer:
45
+
46
+ - **Schema-validated frontmatter** via Zod. Typos in your `status` field get caught, not shipped.
47
+ - **Sections as typed data.** A heading called "Acceptance Criteria" containing a bullet list becomes `string[]` on the model instance, validated and cached.
48
+ - **Relationships derived from document structure.** An Epic's `## Stories` heading with `### Story Name` sub-headings automatically yields a `hasMany` relationship. No join tables. No IDs to manage.
49
+ - **Full TypeScript inference.** `defineModel()` infers all five generic parameters from your config object. You never write a type annotation.
50
+
51
+ ---
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ bun add contentbase
57
+ ```
58
+
59
+ Contentbase is ESM-only and requires Node 18+ or Bun.
60
+
61
+ ---
62
+
63
+ ## Core Concepts
64
+
65
+ ### Documents
66
+
67
+ Every `.md` or `.mdx` file in your content directory becomes a `Document`. Documents have an `id` (the file path without the extension), lazily-parsed AST, frontmatter metadata, and a rich set of section operations.
68
+
69
+ ```
70
+ content/
71
+ epics/
72
+ authentication.mdx -> id: "epics/authentication"
73
+ stories/
74
+ authentication/
75
+ user-can-register.mdx -> id: "stories/authentication/user-can-register"
76
+ ```
77
+
78
+ ### Models
79
+
80
+ A model is a config object that describes one type of document. It declares:
81
+
82
+ - **meta** -- a Zod schema for frontmatter
83
+ - **sections** -- named extractions from heading-based sections
84
+ - **relationships** -- `hasMany` / `belongsTo` links between models
85
+ - **computed** -- derived values calculated from instance data
86
+
87
+ ```ts
88
+ const Epic = defineModel("Epic", {
89
+ prefix: "epics",
90
+ meta: z.object({
91
+ priority: z.enum(["low", "medium", "high"]).optional(),
92
+ status: z.enum(["created", "in-progress", "complete"]).default("created"),
93
+ }),
94
+ relationships: {
95
+ stories: hasMany(() => Story, { heading: "Stories" }),
96
+ },
97
+ computed: {
98
+ isComplete: (self) => self.meta.status === "complete",
99
+ },
100
+ defaults: {
101
+ status: "created",
102
+ },
103
+ });
104
+ ```
105
+
106
+ 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
+
108
+ ### Collections
109
+
110
+ A `Collection` loads a directory tree and gives you access to documents and typed model instances.
111
+
112
+ ```ts
113
+ const collection = new Collection({ rootPath: "./content" });
114
+ await collection.load();
115
+
116
+ // Register models for prefix-based matching
117
+ collection.register(Epic);
118
+ collection.register(Story);
119
+
120
+ // Get a typed instance
121
+ const epic = collection.getModel("epics/authentication", Epic);
122
+ epic.meta.priority; // "high" | "medium" | "low" | undefined
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Sections
128
+
129
+ Sections let you extract typed, structured data from the content beneath a heading.
130
+
131
+ Given this Markdown:
132
+
133
+ ```md
134
+ ## Acceptance Criteria
135
+
136
+ - Users can sign up with email and password
137
+ - Validation errors are shown inline
138
+ - Confirmation email is sent
139
+ ```
140
+
141
+ Define a section to extract the list items:
142
+
143
+ ```ts
144
+ import { section } from "contentbase";
145
+ import { toString } from "mdast-util-to-string";
146
+
147
+ const Story = defineModel("Story", {
148
+ sections: {
149
+ acceptanceCriteria: section("Acceptance Criteria", {
150
+ extract: (query) =>
151
+ query.selectAll("listItem").map((node) => toString(node)),
152
+ schema: z.array(z.string()),
153
+ }),
154
+ },
155
+ });
156
+ ```
157
+
158
+ The `extract` function receives an `AstQuery` scoped to the content under that heading. The `schema` is optional and used during validation.
159
+
160
+ Section data is **lazily computed and cached** -- the extract function only runs the first time you access the property.
161
+
162
+ ```ts
163
+ instance.sections.acceptanceCriteria;
164
+ // ["Users can sign up with email and password", "Validation errors are shown inline", ...]
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Relationships
170
+
171
+ ### hasMany
172
+
173
+ A `hasMany` relationship extracts child models from sub-headings. Given an Epic document:
174
+
175
+ ```md
176
+ # Authentication
177
+
178
+ ## Stories
179
+
180
+ ### User can register
181
+ As a user I want to register...
182
+
183
+ ### User can login
184
+ As a user I want to login...
185
+ ```
186
+
187
+ Defining the relationship:
188
+
189
+ ```ts
190
+ const Epic = defineModel("Epic", {
191
+ relationships: {
192
+ stories: hasMany(() => Story, { heading: "Stories" }),
193
+ },
194
+ });
195
+ ```
196
+
197
+ Contentbase finds the `## Stories` heading, extracts each `###` sub-heading as a child document, and creates typed model instances:
198
+
199
+ ```ts
200
+ const epic = collection.getModel("epics/authentication", Epic);
201
+
202
+ const stories = epic.relationships.stories.fetchAll();
203
+ stories.length; // 2
204
+ stories[0].title; // "User can register"
205
+
206
+ const first = epic.relationships.stories.first();
207
+ const last = epic.relationships.stories.last();
208
+ ```
209
+
210
+ ### belongsTo
211
+
212
+ A `belongsTo` relationship resolves a parent via a foreign key in frontmatter.
213
+
214
+ ```yaml
215
+ # stories/authentication/user-can-register.mdx
216
+ ---
217
+ status: created
218
+ epic: authentication
219
+ ---
220
+ ```
221
+
222
+ ```ts
223
+ const Story = defineModel("Story", {
224
+ meta: z.object({
225
+ status: z.enum(["created", "in-progress", "complete"]).default("created"),
226
+ epic: z.string().optional(),
227
+ }),
228
+ relationships: {
229
+ epic: belongsTo(() => Epic, {
230
+ foreignKey: (doc) => doc.meta.epic as string,
231
+ }),
232
+ },
233
+ });
234
+
235
+ const story = collection.getModel(
236
+ "stories/authentication/user-can-register",
237
+ Story
238
+ );
239
+ const epic = story.relationships.epic.fetch();
240
+ epic.title; // "Authentication"
241
+ ```
242
+
243
+ Relationship targets use thunks (`() => Epic`) so you can define circular references without import ordering issues.
244
+
245
+ ---
246
+
247
+ ## Querying
248
+
249
+ The query API filters typed model instances with a fluent builder:
250
+
251
+ ```ts
252
+ // Simple equality
253
+ const epics = await collection
254
+ .query(Epic)
255
+ .where("meta.priority", "high")
256
+ .fetchAll();
257
+
258
+ // Object shorthand
259
+ const drafts = await collection
260
+ .query(Story)
261
+ .where({ "meta.status": "created" })
262
+ .fetchAll();
263
+
264
+ // Comparison operators
265
+ const urgent = await collection
266
+ .query(Story)
267
+ .where("meta.points", "gte", 5)
268
+ .fetchAll();
269
+
270
+ // Chainable methods
271
+ const results = await collection
272
+ .query(Story)
273
+ .whereIn("meta.status", ["created", "in-progress"])
274
+ .whereExists("meta.epic")
275
+ .fetchAll();
276
+
277
+ // Convenience accessors
278
+ const first = await collection.query(Epic).first();
279
+ const count = await collection.query(Epic).count();
280
+ ```
281
+
282
+ Available operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `contains`, `startsWith`, `endsWith`, `regex`, `exists`.
283
+
284
+ Queries filter by model type **before** creating instances, so you only pay the parsing cost for matching documents.
285
+
286
+ ---
287
+
288
+ ## Validation
289
+
290
+ Every model instance can be validated against its Zod schemas:
291
+
292
+ ```ts
293
+ const instance = collection.getModel("epics/authentication", Epic);
294
+ const result = await instance.validate();
295
+
296
+ result.valid; // true
297
+ result.errors; // ZodIssue[]
298
+ ```
299
+
300
+ Validation checks:
301
+ 1. **Meta** against the model's Zod schema (with defaults applied)
302
+ 2. **Sections** against any section-level schemas
303
+
304
+ ```ts
305
+ if (instance.hasErrors) {
306
+ for (const [path, issue] of instance.errors) {
307
+ console.log(`${path}: ${issue.message}`);
308
+ }
309
+ }
310
+ ```
311
+
312
+ The standalone `validateDocument` function is also available for lower-level use.
313
+
314
+ ---
315
+
316
+ ## Serialization
317
+
318
+ ```ts
319
+ const json = instance.toJSON();
320
+ // { id, title, meta }
321
+
322
+ const full = instance.toJSON({
323
+ sections: ["acceptanceCriteria"],
324
+ computed: ["isComplete"],
325
+ related: ["stories"],
326
+ });
327
+ // { id, title, meta, acceptanceCriteria: [...], isComplete: false, stories: [...] }
328
+ ```
329
+
330
+ Export an entire collection:
331
+
332
+ ```ts
333
+ const data = await collection.export();
334
+ ```
335
+
336
+ ---
337
+
338
+ ## Document API
339
+
340
+ Documents expose a powerful AST manipulation layer built on the unified/remark ecosystem.
341
+
342
+ ```ts
343
+ const doc = collection.document("epics/authentication");
344
+
345
+ // Read
346
+ doc.title; // "Authentication"
347
+ doc.slug; // "authentication"
348
+ doc.meta; // { priority: "high", status: "created" }
349
+ doc.content; // raw markdown (without frontmatter)
350
+ doc.rawContent; // full file content with frontmatter
351
+
352
+ // AST querying
353
+ const headings = doc.astQuery.selectAll("heading");
354
+ const h2s = doc.astQuery.headingsAtDepth(2);
355
+ const storiesHeading = doc.astQuery.findHeadingByText("Stories");
356
+
357
+ // Node shortcuts
358
+ doc.nodes.headings; // all headings
359
+ doc.nodes.links; // all links
360
+ doc.nodes.tables; // all table nodes
361
+ doc.nodes.tablesAsData; // tables as { headers, rows } objects
362
+ doc.nodes.codeBlocks; // all code blocks
363
+
364
+ // Section operations (immutable by default)
365
+ const trimmed = doc.removeSection("Stories"); // new Document
366
+ const updated = doc.replaceSectionContent("Stories", newMarkdown);
367
+ const expanded = doc.appendToSection("Stories", "### New Story\n\nDetails...");
368
+
369
+ // Mutable when you need it
370
+ doc.removeSection("Stories", { mutate: true });
371
+
372
+ // Persistence
373
+ await doc.save();
374
+ await doc.reload();
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Computed Properties
380
+
381
+ Derived values that are lazily evaluated from instance data:
382
+
383
+ ```ts
384
+ const Epic = defineModel("Epic", {
385
+ meta: z.object({
386
+ status: z.enum(["created", "in-progress", "complete"]).default("created"),
387
+ }),
388
+ computed: {
389
+ isComplete: (self) => self.meta.status === "complete",
390
+ storyCount: (self) => self.relationships.stories.fetchAll().length,
391
+ },
392
+ });
393
+
394
+ const epic = collection.getModel("epics/authentication", Epic);
395
+ epic.computed.isComplete; // false
396
+ epic.computed.storyCount; // 2
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Plugins and Actions
402
+
403
+ ```ts
404
+ // Register named actions on the collection
405
+ collection.action("publish", async (coll, instance, opts) => {
406
+ // your publish logic
407
+ });
408
+
409
+ await instance.runAction("publish", { target: "production" });
410
+
411
+ // Plugin system
412
+ function timestampPlugin(collection, options) {
413
+ collection.action("touch", async (coll, instance) => {
414
+ // update timestamps
415
+ });
416
+ }
417
+
418
+ collection.use(timestampPlugin, { format: "iso" });
419
+ ```
420
+
421
+ ---
422
+
423
+ ## CLI
424
+
425
+ Contentbase ships with a CLI for common operations:
426
+
427
+ ```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
433
+ ```
434
+
435
+ ---
436
+
437
+ ## API Reference
438
+
439
+ ### Top-level exports
440
+
441
+ | Export | Description |
442
+ | --- | --- |
443
+ | `Collection` | Loads and manages a directory of documents |
444
+ | `Document` | A single Markdown/MDX file with AST operations |
445
+ | `defineModel()` | Create a typed model definition |
446
+ | `section()` | Declare a section extraction |
447
+ | `hasMany()` | Declare a one-to-many relationship |
448
+ | `belongsTo()` | Declare a many-to-one relationship |
449
+ | `CollectionQuery` | Fluent query builder for model instances |
450
+ | `AstQuery` | MDAST query wrapper (select, visit, find) |
451
+ | `NodeShortcuts` | Convenience getters for common AST nodes |
452
+ | `createModelInstance()` | Low-level factory for model instances |
453
+ | `validateDocument()` | Standalone validation function |
454
+ | `z` | Re-exported from Zod (no extra dependency needed) |
455
+
456
+ ---
457
+
458
+ ## License
459
+
460
+ MIT