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
@@ -0,0 +1,468 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkGfm from "remark-gfm";
4
+ import yaml from "js-yaml";
5
+ import { toString } from "mdast-util-to-string";
6
+ import { kebabCase } from "./utils/inflect";
7
+ import { AstQuery } from "./ast-query";
8
+ import { NodeShortcuts } from "./node-shortcuts";
9
+ import { stringifyAst } from "./utils/stringify-ast";
10
+ import { normalizeHeadings } from "./utils/normalize-headings";
11
+ import { parseTable } from "./utils/parse-table";
12
+ import type { Root, Content, RootContent, Heading } from "mdast";
13
+ import type { Collection } from "./collection";
14
+
15
+ export interface DocumentOptions {
16
+ id: string;
17
+ content: string;
18
+ meta?: Record<string, unknown>;
19
+ collection: Collection;
20
+ ast?: Root;
21
+ }
22
+
23
+ export class Document {
24
+ readonly id: string;
25
+ readonly collection: Collection;
26
+
27
+ #content: string;
28
+ #meta: Record<string, unknown>;
29
+ #ast: Root | null;
30
+
31
+ constructor(options: DocumentOptions) {
32
+ this.id = options.id;
33
+ this.collection = options.collection;
34
+ this.#content = options.content;
35
+ this.#meta = options.meta ?? {};
36
+ this.#ast = options.ast ?? null;
37
+ }
38
+
39
+ // ─── Core getters ───
40
+
41
+ get meta(): Record<string, unknown> {
42
+ return this.#meta;
43
+ }
44
+
45
+ get content(): string {
46
+ return this.#content;
47
+ }
48
+
49
+ get ast(): Root {
50
+ if (!this.#ast) {
51
+ this.#ast = this.processor.parse(this.#content);
52
+ }
53
+ return this.#ast;
54
+ }
55
+
56
+ get title(): string {
57
+ const heading = this.astQuery.select("heading");
58
+ return heading ? toString(heading) : this.id;
59
+ }
60
+
61
+ get slug(): string {
62
+ return kebabCase(this.title.toLowerCase());
63
+ }
64
+
65
+ get rawContent(): string {
66
+ if (Object.keys(this.#meta).length === 0) {
67
+ return this.content;
68
+ }
69
+ const frontmatter = yaml.dump(this.#meta).trim();
70
+ return `---\n${frontmatter}\n---\n\n${this.content}`;
71
+ }
72
+
73
+ get path(): string {
74
+ return this.collection.resolve(this.id) + ".mdx";
75
+ }
76
+
77
+ // ─── Processor ───
78
+
79
+ /**
80
+ * Returns a unified processor configured for parsing markdown with GFM.
81
+ * This is intentionally NOT the MDX processor -- MDX compilation is a
82
+ * separate concern handled by plugins.
83
+ */
84
+ get processor() {
85
+ return unified().use(remarkParse).use(remarkGfm);
86
+ }
87
+
88
+ // ─── AST access ───
89
+
90
+ get astQuery(): AstQuery {
91
+ return new AstQuery(this.ast);
92
+ }
93
+
94
+ get nodes(): NodeShortcuts {
95
+ return new NodeShortcuts(this.astQuery);
96
+ }
97
+
98
+ query(ast: Root = this.ast): AstQuery {
99
+ return new AstQuery(ast);
100
+ }
101
+
102
+ // ─── Section operations ───
103
+
104
+ /**
105
+ * Extract a section of the document, starting with a heading.
106
+ * Returns all nodes underneath the heading until another heading
107
+ * of the same depth is encountered, or the end of the document.
108
+ */
109
+ extractSection(startHeading: string | Content): Content[] {
110
+ let heading: Content | undefined;
111
+ if (typeof startHeading === "string") {
112
+ heading = this.astQuery.findHeadingByText(startHeading) as
113
+ | Content
114
+ | undefined;
115
+ } else {
116
+ heading = startHeading;
117
+ }
118
+ if (!heading) {
119
+ throw new Error(
120
+ `Heading not found: ${typeof startHeading === "string" ? startHeading : toString(startHeading)}`
121
+ );
122
+ }
123
+
124
+ const endHeading = this.astQuery.findNextSiblingHeadingTo(heading as any);
125
+ const sectionNodes = endHeading
126
+ ? this.astQuery.findBetween(heading, endHeading)
127
+ : this.astQuery.findAllAfter(heading);
128
+ return [heading, ...sectionNodes];
129
+ }
130
+
131
+ /**
132
+ * Returns an AstQuery scoped to the nodes underneath a particular heading,
133
+ * excluding the heading itself.
134
+ */
135
+ querySection(startHeading: string | Content): AstQuery {
136
+ let children: Content[] = [];
137
+ try {
138
+ children = this.extractSection(startHeading).slice(1);
139
+ } catch {
140
+ // Section not found: return empty query
141
+ }
142
+ return new AstQuery({
143
+ type: "root",
144
+ children: children as RootContent[],
145
+ });
146
+ }
147
+
148
+ // ─── Section mutations ───
149
+
150
+ /**
151
+ * Removes the nodes under the given heading from the AST.
152
+ * Returns a new Document by default. Pass { mutate: true } to modify in place.
153
+ */
154
+ removeSection(
155
+ heading: string | Content,
156
+ opts: { mutate?: boolean } = {}
157
+ ): Document {
158
+ const headingNode =
159
+ typeof heading === "string"
160
+ ? (this.astQuery.findHeadingByText(heading) as Content | undefined)
161
+ : heading;
162
+ if (!headingNode) throw new Error(`Heading not found: ${heading}`);
163
+
164
+ const sectionNodes = this.extractSection(headingNode);
165
+ const newChildren = this.ast.children.filter(
166
+ (n) => !sectionNodes.includes(n as Content)
167
+ );
168
+
169
+ if (opts.mutate) {
170
+ (this.ast as any).children = newChildren;
171
+ this.#content = stringifyAst(this.ast);
172
+ return this;
173
+ }
174
+
175
+ const newAst: Root = { type: "root", children: [...newChildren] };
176
+ return new Document({
177
+ id: this.id,
178
+ content: stringifyAst(newAst),
179
+ meta: { ...this.#meta },
180
+ collection: this.collection,
181
+ ast: newAst,
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Replaces the content underneath a heading with new content.
187
+ */
188
+ replaceSectionContent(
189
+ heading: string | Content,
190
+ nodesOrMarkdown: string | Content[],
191
+ opts: { mutate?: boolean } = {}
192
+ ): Document {
193
+ const headingNode =
194
+ typeof heading === "string"
195
+ ? (this.astQuery.findHeadingByText(heading) as Content | undefined)
196
+ : heading;
197
+ if (!headingNode) throw new Error(`Heading not found: ${heading}`);
198
+
199
+ let newNodes: RootContent[];
200
+ if (typeof nodesOrMarkdown === "string") {
201
+ newNodes = this.processor.parse(nodesOrMarkdown)
202
+ .children as RootContent[];
203
+ } else {
204
+ newNodes = nodesOrMarkdown as RootContent[];
205
+ }
206
+
207
+ const sectionNodes = this.extractSection(headingNode).slice(1);
208
+ const headingIndex = this.ast.children.indexOf(headingNode as RootContent);
209
+
210
+ if (opts.mutate) {
211
+ this.ast.children.splice(
212
+ headingIndex + 1,
213
+ sectionNodes.length,
214
+ ...newNodes
215
+ );
216
+ this.#content = stringifyAst(this.ast);
217
+ return this;
218
+ }
219
+
220
+ const children = [...this.ast.children];
221
+ children.splice(headingIndex + 1, sectionNodes.length, ...newNodes);
222
+ const newAst: Root = { type: "root", children };
223
+ return new Document({
224
+ id: this.id,
225
+ content: stringifyAst(newAst),
226
+ meta: { ...this.#meta },
227
+ collection: this.collection,
228
+ ast: newAst,
229
+ });
230
+ }
231
+
232
+ /** Insert new content before a given node. */
233
+ insertBefore(
234
+ node: Content,
235
+ nodesOrMarkdown: string | Content[],
236
+ opts: { mutate?: boolean } = {}
237
+ ): Document {
238
+ let newNodes: RootContent[];
239
+ if (typeof nodesOrMarkdown === "string") {
240
+ newNodes = this.processor.parse(nodesOrMarkdown)
241
+ .children as RootContent[];
242
+ } else {
243
+ newNodes = nodesOrMarkdown as RootContent[];
244
+ }
245
+ const index = this.ast.children.indexOf(node as RootContent);
246
+
247
+ if (opts.mutate) {
248
+ this.ast.children.splice(index, 0, ...newNodes);
249
+ this.#content = stringifyAst(this.ast);
250
+ return this;
251
+ }
252
+
253
+ const children = [...this.ast.children];
254
+ children.splice(index, 0, ...newNodes);
255
+ const newAst: Root = { type: "root", children };
256
+ return new Document({
257
+ id: this.id,
258
+ content: stringifyAst(newAst),
259
+ meta: { ...this.#meta },
260
+ collection: this.collection,
261
+ ast: newAst,
262
+ });
263
+ }
264
+
265
+ /** Insert new content after a given node. */
266
+ insertAfter(
267
+ node: Content,
268
+ nodesOrMarkdown: string | Content[],
269
+ opts: { mutate?: boolean } = {}
270
+ ): Document {
271
+ let newNodes: RootContent[];
272
+ if (typeof nodesOrMarkdown === "string") {
273
+ newNodes = this.processor.parse(nodesOrMarkdown)
274
+ .children as RootContent[];
275
+ } else {
276
+ newNodes = nodesOrMarkdown as RootContent[];
277
+ }
278
+ const index = this.ast.children.indexOf(node as RootContent);
279
+
280
+ if (opts.mutate) {
281
+ this.ast.children.splice(index + 1, 0, ...newNodes);
282
+ this.#content = stringifyAst(this.ast);
283
+ return this;
284
+ }
285
+
286
+ const children = [...this.ast.children];
287
+ children.splice(index + 1, 0, ...newNodes);
288
+ const newAst: Root = { type: "root", children };
289
+ return new Document({
290
+ id: this.id,
291
+ content: stringifyAst(newAst),
292
+ meta: { ...this.#meta },
293
+ collection: this.collection,
294
+ ast: newAst,
295
+ });
296
+ }
297
+
298
+ /** Append new content at the end of a section. */
299
+ appendToSection(
300
+ heading: string | Content,
301
+ nodesOrMarkdown: string | Content[],
302
+ opts: { mutate?: boolean } = {}
303
+ ): Document {
304
+ const headingNode =
305
+ typeof heading === "string"
306
+ ? (this.astQuery.findHeadingByText(heading) as Content | undefined)
307
+ : heading;
308
+ if (!headingNode) throw new Error(`Heading not found: ${heading}`);
309
+
310
+ let newNodes: RootContent[];
311
+ if (typeof nodesOrMarkdown === "string") {
312
+ newNodes = this.processor.parse(nodesOrMarkdown)
313
+ .children as RootContent[];
314
+ } else {
315
+ newNodes = nodesOrMarkdown as RootContent[];
316
+ }
317
+
318
+ const sectionNodes = this.extractSection(headingNode);
319
+ const lastNode = sectionNodes[sectionNodes.length - 1];
320
+ const lastIndex = this.ast.children.indexOf(lastNode as RootContent);
321
+
322
+ if (opts.mutate) {
323
+ this.ast.children.splice(lastIndex + 1, 0, ...newNodes);
324
+ this.#content = stringifyAst(this.ast);
325
+ return this;
326
+ }
327
+
328
+ const children = [...this.ast.children];
329
+ children.splice(lastIndex + 1, 0, ...newNodes);
330
+ const newAst: Root = { type: "root", children };
331
+ return new Document({
332
+ id: this.id,
333
+ content: stringifyAst(newAst),
334
+ meta: { ...this.#meta },
335
+ collection: this.collection,
336
+ ast: newAst,
337
+ });
338
+ }
339
+
340
+ // ─── Content manipulation ───
341
+
342
+ replaceContent(content: string): Document {
343
+ return new Document({
344
+ id: this.id,
345
+ content,
346
+ meta: { ...this.#meta },
347
+ collection: this.collection,
348
+ });
349
+ }
350
+
351
+ appendContent(content: string): Document {
352
+ return new Document({
353
+ id: this.id,
354
+ content: this.#content + content,
355
+ meta: { ...this.#meta },
356
+ collection: this.collection,
357
+ });
358
+ }
359
+
360
+ /** Re-parse the AST from the current content. Mutable. */
361
+ rerenderAST(newContent: string = this.content): this {
362
+ this.#content = newContent;
363
+ this.#ast = this.processor.parse(newContent);
364
+ return this;
365
+ }
366
+
367
+ /** Update content from the current AST state. Mutable. */
368
+ reloadFromAST(newAst: Root = this.ast): this {
369
+ this.#ast = newAst;
370
+ this.#content = stringifyAst(newAst);
371
+ return this;
372
+ }
373
+
374
+ stringify(ast: Root = this.ast): string {
375
+ return stringifyAst(ast);
376
+ }
377
+
378
+ normalizeHeadings(): this {
379
+ normalizeHeadings(this.ast);
380
+ this.#content = stringifyAst(this.ast);
381
+ return this;
382
+ }
383
+
384
+ // ─── Serialization ───
385
+
386
+ toJSON(): {
387
+ id: string;
388
+ meta: Record<string, unknown>;
389
+ content: string;
390
+ ast: Root;
391
+ } {
392
+ return {
393
+ id: this.id,
394
+ meta: this.meta,
395
+ content: this.content,
396
+ ast: this.ast,
397
+ };
398
+ }
399
+
400
+ toText(
401
+ filterFn: (node: Content) => boolean = () => true
402
+ ): string {
403
+ return (this.ast.children as Content[])
404
+ .filter(filterFn)
405
+ .map((n) => toString(n))
406
+ .join("\n");
407
+ }
408
+
409
+ /**
410
+ * Returns an indented text outline of the document's headings.
411
+ * Each heading is indented based on its depth relative to the
412
+ * minimum heading depth found in the document.
413
+ */
414
+ toOutline(): string {
415
+ const headings = (this.ast.children as Content[]).filter(
416
+ (n): n is Heading => n.type === "heading"
417
+ );
418
+ if (headings.length === 0) return "";
419
+
420
+ const minDepth = Math.min(...headings.map((h) => h.depth));
421
+
422
+ return headings
423
+ .map((h) => {
424
+ const indent = " ".repeat(h.depth - minDepth);
425
+ return `${indent}- ${toString(h)}`;
426
+ })
427
+ .join("\n");
428
+ }
429
+
430
+ // ─── Utility access ───
431
+
432
+ get utils() {
433
+ return {
434
+ kebabCase,
435
+ toString,
436
+ stringifyAst,
437
+ parseTable,
438
+ normalizeHeadings,
439
+ extractSection: (heading: string | Content) =>
440
+ this.extractSection(heading),
441
+ createNewAst: (children: RootContent[] = []) =>
442
+ ({ type: "root" as const, children }) as Root,
443
+ };
444
+ }
445
+
446
+ // ─── Persistence ───
447
+
448
+ async save(
449
+ options: { normalize?: boolean; extension?: string } = {}
450
+ ): Promise<this> {
451
+ if (options.normalize !== false) {
452
+ this.normalizeHeadings();
453
+ }
454
+ await this.collection.saveItem(this.id, {
455
+ content: this.rawContent,
456
+ extension: options.extension,
457
+ });
458
+ return this;
459
+ }
460
+
461
+ async reload(): Promise<this> {
462
+ const item = await this.collection.readItem(this.id);
463
+ this.#content = item.content;
464
+ this.#meta = item.meta;
465
+ this.#ast = null; // Will be lazily re-parsed
466
+ return this;
467
+ }
468
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Core classes
2
+ export { Collection } from "./collection";
3
+ export { Document } from "./document";
4
+ export { AstQuery } from "./ast-query";
5
+ export { NodeShortcuts } from "./node-shortcuts";
6
+ export { parse } from "./parse";
7
+ export type { ParsedDocument } from "./parse";
8
+
9
+ // defineModel and helpers
10
+ export { defineModel } from "./define-model";
11
+ export { section } from "./section";
12
+ export { hasMany, belongsTo } from "./relationships/index";
13
+
14
+ // Query
15
+ export { CollectionQuery } from "./query/collection-query";
16
+ export { QueryBuilder } from "./query/query-builder";
17
+
18
+ // Model instance factory (advanced use)
19
+ export { createModelInstance } from "./model-instance";
20
+
21
+ // Validation
22
+ export { validateDocument } from "./validator";
23
+
24
+ import { toString } from "mdast-util-to-string";
25
+
26
+ // Types
27
+ export type {
28
+ ModelDefinition,
29
+ InferModelInstance,
30
+ SectionDefinition,
31
+ HasManyDefinition,
32
+ BelongsToDefinition,
33
+ RelationshipDefinition,
34
+ CollectionItem,
35
+ CollectionOptions,
36
+ HasManyAccessor,
37
+ BelongsToAccessor,
38
+ ValidationResult,
39
+ SerializeOptions,
40
+ SaveOptions,
41
+ DocumentRef,
42
+ } from "./types";
43
+
44
+ // Re-export zod for convenience
45
+ export { z } from "zod";
46
+
47
+ export { toString };