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.
@@ -0,0 +1,21 @@
1
+ import path from "path";
2
+ import { Collection } from "../../../src/collection";
3
+ import { Epic, Story } from "../../../test/fixtures/sdlc/models";
4
+
5
+ const dir = import.meta.dirname ?? new URL(".", import.meta.url).pathname;
6
+
7
+ const FIXTURES_PATH = path.resolve(dir, "../../../test/fixtures/sdlc");
8
+
9
+ export async function createDemoCollection(): Promise<Collection> {
10
+ const collection = new Collection({
11
+ rootPath: FIXTURES_PATH,
12
+ name: "sdlc",
13
+ });
14
+ collection.register(Epic);
15
+ collection.register(Story);
16
+ await collection.load();
17
+ return collection;
18
+ }
19
+
20
+ export { Epic, Story };
21
+ export { FIXTURES_PATH };
@@ -0,0 +1,43 @@
1
+ const c = {
2
+ reset: "\x1b[0m",
3
+ bold: "\x1b[1m",
4
+ cyan: "\x1b[36m",
5
+ dim: "\x1b[2m",
6
+ green: "\x1b[32m",
7
+ };
8
+
9
+ const scripts = [
10
+ "01-collection-setup",
11
+ "02-querying",
12
+ "03-sections",
13
+ "04-relationships",
14
+ "05-document-api",
15
+ "06-extract-sections",
16
+ "07-validation",
17
+ "08-serialization",
18
+ ];
19
+
20
+ async function runAll() {
21
+ console.log();
22
+ console.log(
23
+ `${c.cyan}${c.bold} ╔══════════════════════════════════════╗${c.reset}`
24
+ );
25
+ console.log(
26
+ `${c.cyan}${c.bold} ║ Contentbase — Interactive Examples ║${c.reset}`
27
+ );
28
+ console.log(
29
+ `${c.cyan}${c.bold} ╚══════════════════════════════════════╝${c.reset}`
30
+ );
31
+
32
+ for (const script of scripts) {
33
+ const mod = await import(`./${script}`);
34
+ await mod.main();
35
+ }
36
+
37
+ console.log(
38
+ `${c.green}${c.bold} ✓ All ${scripts.length} example scripts completed.${c.reset}`
39
+ );
40
+ console.log();
41
+ }
42
+
43
+ runAll().catch(console.error);
package/src/collection.ts CHANGED
@@ -348,6 +348,104 @@ export class Collection {
348
348
  return relativePath.replace(/\.[a-z]+$/i, "");
349
349
  }
350
350
 
351
+ // ─── Table of Contents ───
352
+
353
+ /**
354
+ * Generate a markdown table of contents with links to each document.
355
+ * Links use relative paths that work for GitHub navigation.
356
+ *
357
+ * If models are registered, items are grouped under model name headings.
358
+ * Items that don't match any model appear under an "Other" group.
359
+ *
360
+ * @example
361
+ * ```ts
362
+ * const toc = collection.tableOfContents({ title: "Project Docs" });
363
+ * // # Project Docs
364
+ * //
365
+ * // ## Epics
366
+ * //
367
+ * // - [Authentication](./epics/authentication.mdx)
368
+ * // - [Searching And Browsing](./epics/searching-and-browsing.mdx)
369
+ * //
370
+ * // ## Stories
371
+ * //
372
+ * // - [A User should be able to register.](./stories/authentication/a-user-should-be-able-to-register.mdx)
373
+ * ```
374
+ */
375
+ tableOfContents(
376
+ options: { title?: string; basePath?: string } = {}
377
+ ): string {
378
+ if (!this.#loaded) {
379
+ throw new Error(
380
+ "Collection has not been loaded. Call load() first."
381
+ );
382
+ }
383
+
384
+ const basePath = options.basePath ?? ".";
385
+ const lines: string[] = [];
386
+
387
+ if (options.title) {
388
+ lines.push(`# ${options.title}`, "");
389
+ }
390
+
391
+ const sorted = [...this.available].sort();
392
+
393
+ if (this.#models.size > 0) {
394
+ const grouped = new Map<string, string[]>();
395
+ const ungrouped: string[] = [];
396
+
397
+ for (const pathId of sorted) {
398
+ const def = this.findModelDefinition(pathId);
399
+ if (def) {
400
+ let group = grouped.get(def.name);
401
+ if (!group) {
402
+ group = [];
403
+ grouped.set(def.name, group);
404
+ }
405
+ group.push(pathId);
406
+ } else {
407
+ ungrouped.push(pathId);
408
+ }
409
+ }
410
+
411
+ for (const def of this.modelDefinitions) {
412
+ const ids = grouped.get(def.name);
413
+ if (!ids || ids.length === 0) continue;
414
+
415
+ const depth = options.title ? "##" : "#";
416
+ lines.push(`${depth} ${def.name}`, "");
417
+ for (const id of ids) {
418
+ lines.push(this.#tocEntry(id, basePath));
419
+ }
420
+ lines.push("");
421
+ }
422
+
423
+ if (ungrouped.length > 0) {
424
+ const depth = options.title ? "##" : "#";
425
+ lines.push(`${depth} Other`, "");
426
+ for (const id of ungrouped) {
427
+ lines.push(this.#tocEntry(id, basePath));
428
+ }
429
+ lines.push("");
430
+ }
431
+ } else {
432
+ for (const id of sorted) {
433
+ lines.push(this.#tocEntry(id, basePath));
434
+ }
435
+ lines.push("");
436
+ }
437
+
438
+ return lines.join("\n").trimEnd() + "\n";
439
+ }
440
+
441
+ #tocEntry(pathId: string, basePath: string): string {
442
+ const item = this.#items.get(pathId)!;
443
+ const ext = path.extname(item.path);
444
+ const relativePath = `${basePath}/${pathId}${ext}`;
445
+ const doc = this.document(pathId);
446
+ return `- [${doc.title}](${relativePath})`;
447
+ }
448
+
351
449
  // ─── Serialization ───
352
450
 
353
451
  toJSON(options: { content?: boolean } = {}): Record<string, unknown> {
@@ -11,7 +11,7 @@ import type {
11
11
  * Configuration input for defineModel. This is what the user writes.
12
12
  */
13
13
  export interface DefineModelConfig<
14
- TMeta extends z.ZodTypeAny,
14
+ TMeta extends z.ZodType,
15
15
  TSections extends Record<string, SectionDefinition<any>>,
16
16
  TRelationships extends Record<string, RelationshipDefinition<any>>,
17
17
  TComputed extends Record<string, (self: any) => any>,
@@ -42,7 +42,7 @@ export interface DefineModelConfig<
42
42
  */
43
43
  export function defineModel<
44
44
  TName extends string,
45
- TMeta extends z.ZodTypeAny = z.ZodObject<{}, "passthrough">,
45
+ TMeta extends z.ZodType = z.ZodObject<{}, z.core.$loose>,
46
46
  TSections extends Record<string, SectionDefinition<any>> = Record<
47
47
  string,
48
48
  never
@@ -64,7 +64,7 @@ export function defineModel<
64
64
  TComputed
65
65
  > = {} as any
66
66
  ): ModelDefinition<TName, TMeta, TSections, TRelationships, TComputed> {
67
- const meta = (config.meta ?? z.object({}).passthrough()) as TMeta;
67
+ const meta = (config.meta ?? z.looseObject({})) as TMeta;
68
68
 
69
69
  return {
70
70
  name,
@@ -0,0 +1,222 @@
1
+ import { toString } from "mdast-util-to-string";
2
+ import { AstQuery } from "./ast-query";
3
+ import { NodeShortcuts } from "./node-shortcuts";
4
+ import { stringifyAst } from "./utils/stringify-ast";
5
+ import type { Root, Content, RootContent, Heading } from "mdast";
6
+ import type { ParsedDocument } from "./parse";
7
+
8
+ /** Any object that exposes a title and section extraction — satisfied by both Document and ParsedDocument. */
9
+ export type SectionSource = {
10
+ title: string;
11
+ extractSection(heading: string | Content): Content[];
12
+ };
13
+
14
+ export interface ExtractionEntry {
15
+ /** The source document or parsed document */
16
+ source: SectionSource;
17
+ /** Section heading name(s) to extract. A string extracts one section; an array extracts multiple. */
18
+ sections: string | string[];
19
+ }
20
+
21
+ export interface ExtractSectionsOptions {
22
+ /** Optional title for the combined document (becomes an h1). */
23
+ title?: string;
24
+ /**
25
+ * How to organize extracted sections.
26
+ * - `"grouped"` (default): Each source document gets a heading (its title),
27
+ * with extracted sections nested underneath.
28
+ * - `"flat"`: All extracted sections are placed sequentially with no source grouping.
29
+ */
30
+ mode?: "grouped" | "flat";
31
+ /**
32
+ * What to do when a requested section is not found in a source document.
33
+ * - `"skip"` (default): silently omit the missing section
34
+ * - `"throw"`: throw an error
35
+ */
36
+ onMissing?: "skip" | "throw";
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function createHeadingNode(
44
+ text: string,
45
+ depth: 1 | 2 | 3 | 4 | 5 | 6,
46
+ ): Heading {
47
+ return {
48
+ type: "heading",
49
+ depth,
50
+ children: [{ type: "text", value: text }],
51
+ };
52
+ }
53
+
54
+ function clampDepth(d: number): 1 | 2 | 3 | 4 | 5 | 6 {
55
+ return Math.max(1, Math.min(6, d)) as 1 | 2 | 3 | 4 | 5 | 6;
56
+ }
57
+
58
+ /** Deep-clone section nodes and shift all heading depths to the target depth. */
59
+ function cloneAndShiftHeadings(
60
+ nodes: Content[],
61
+ targetDepth: number,
62
+ ): RootContent[] {
63
+ if (nodes.length === 0) return [];
64
+
65
+ const firstNode = nodes[0];
66
+ const originalDepth =
67
+ firstNode.type === "heading" ? (firstNode as Heading).depth : 1;
68
+ const depthShift = targetDepth - originalDepth;
69
+
70
+ const cloned = structuredClone(nodes) as RootContent[];
71
+
72
+ if (depthShift !== 0) {
73
+ for (const node of cloned) {
74
+ if (node.type === "heading") {
75
+ (node as Heading).depth = clampDepth(
76
+ (node as Heading).depth + depthShift,
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ return cloned;
83
+ }
84
+
85
+ function tryExtractSection(
86
+ source: SectionSource,
87
+ sectionName: string,
88
+ onMissing: "skip" | "throw",
89
+ ): Content[] | null {
90
+ try {
91
+ return source.extractSection(sectionName);
92
+ } catch (err) {
93
+ if (onMissing === "throw") throw err;
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function normalizeSections(sections: string | string[]): string[] {
99
+ return Array.isArray(sections) ? sections : [sections];
100
+ }
101
+
102
+ /** Build a fully queryable ParsedDocument from an AST root (mirrors parse.ts). */
103
+ function buildParsedDocument(ast: Root): ParsedDocument {
104
+ const astQuery = new AstQuery(ast);
105
+ const nodes = new NodeShortcuts(astQuery);
106
+ const firstHeading = astQuery.select("heading");
107
+ const title = firstHeading ? toString(firstHeading) : "";
108
+ const content = stringifyAst(ast);
109
+
110
+ function extractSection(startHeading: string | Content): Content[] {
111
+ let heading: Content | undefined;
112
+ if (typeof startHeading === "string") {
113
+ heading = astQuery.findHeadingByText(startHeading) as
114
+ | Content
115
+ | undefined;
116
+ } else {
117
+ heading = startHeading;
118
+ }
119
+ if (!heading) {
120
+ throw new Error(
121
+ `Heading not found: ${typeof startHeading === "string" ? startHeading : toString(startHeading)}`,
122
+ );
123
+ }
124
+
125
+ const endHeading = astQuery.findNextSiblingHeadingTo(heading as any);
126
+ const sectionNodes = endHeading
127
+ ? astQuery.findBetween(heading, endHeading)
128
+ : astQuery.findAllAfter(heading);
129
+ return [heading, ...sectionNodes];
130
+ }
131
+
132
+ function querySection(startHeading: string | Content): AstQuery {
133
+ let children: Content[] = [];
134
+ try {
135
+ children = extractSection(startHeading).slice(1);
136
+ } catch {
137
+ // Section not found: return empty query
138
+ }
139
+ return new AstQuery({
140
+ type: "root",
141
+ children: children as RootContent[],
142
+ });
143
+ }
144
+
145
+ return {
146
+ meta: {},
147
+ content,
148
+ ast,
149
+ astQuery,
150
+ nodes,
151
+ title,
152
+ stringify: (tree: Root = ast) => stringifyAst(tree),
153
+ extractSection,
154
+ querySection,
155
+ };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Main
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Extract named sections from multiple documents into a single combined document.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * const combined = extractSections([
168
+ * { source: doc1, sections: "Acceptance Criteria" },
169
+ * { source: doc2, sections: ["Acceptance Criteria", "Mockups"] },
170
+ * ], {
171
+ * title: "All Acceptance Criteria",
172
+ * mode: "grouped",
173
+ * });
174
+ * ```
175
+ */
176
+ export function extractSections(
177
+ entries: ExtractionEntry[],
178
+ options: ExtractSectionsOptions = {},
179
+ ): ParsedDocument {
180
+ const { title, mode = "grouped", onMissing = "skip" } = options;
181
+ const combinedChildren: RootContent[] = [];
182
+
183
+ let baseDepth = 1;
184
+ if (title) {
185
+ combinedChildren.push(createHeadingNode(title, 1));
186
+ baseDepth = 2;
187
+ }
188
+
189
+ for (const entry of entries) {
190
+ const { source } = entry;
191
+ const sectionNames = normalizeSections(entry.sections);
192
+
193
+ if (mode === "grouped") {
194
+ const sourceTitle = source.title || "(Untitled)";
195
+ combinedChildren.push(
196
+ createHeadingNode(sourceTitle, clampDepth(baseDepth)),
197
+ );
198
+ const sectionTargetDepth = baseDepth + 1;
199
+
200
+ for (const sectionName of sectionNames) {
201
+ const sectionNodes = tryExtractSection(source, sectionName, onMissing);
202
+ if (!sectionNodes) continue;
203
+ const shifted = cloneAndShiftHeadings(
204
+ sectionNodes,
205
+ sectionTargetDepth,
206
+ );
207
+ combinedChildren.push(...shifted);
208
+ }
209
+ } else {
210
+ // flat mode
211
+ for (const sectionName of sectionNames) {
212
+ const sectionNodes = tryExtractSection(source, sectionName, onMissing);
213
+ if (!sectionNodes) continue;
214
+ const shifted = cloneAndShiftHeadings(sectionNodes, baseDepth);
215
+ combinedChildren.push(...shifted);
216
+ }
217
+ }
218
+ }
219
+
220
+ const ast: Root = { type: "root", children: combinedChildren };
221
+ return buildParsedDocument(ast);
222
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,12 @@ export { AstQuery } from "./ast-query";
5
5
  export { NodeShortcuts } from "./node-shortcuts";
6
6
  export { parse } from "./parse";
7
7
  export type { ParsedDocument } from "./parse";
8
+ export { extractSections } from "./extract-sections";
9
+ export type {
10
+ ExtractionEntry,
11
+ ExtractSectionsOptions,
12
+ SectionSource,
13
+ } from "./extract-sections";
8
14
 
9
15
  // defineModel and helpers
10
16
  export { defineModel } from "./define-model";
package/src/types.ts CHANGED
@@ -80,7 +80,7 @@ export interface DocumentRef {
80
80
  */
81
81
  export interface ModelDefinition<
82
82
  TName extends string = string,
83
- TMeta extends z.ZodTypeAny = z.ZodTypeAny,
83
+ TMeta extends z.ZodType = z.ZodType,
84
84
  TSections extends Record<string, SectionDefinition<any>> = Record<
85
85
  string,
86
86
  never