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
@@ -2,7 +2,7 @@ import { QueryBuilder } from "./query-builder";
2
2
  import { operators } from "./operators";
3
3
  import { createModelInstance } from "../model-instance";
4
4
  import type { Collection } from "../collection";
5
- import type { ModelDefinition, InferModelInstance } from "../types";
5
+ import type { ModelDefinition, InferModelInstance, SerializeOptions } from "../types";
6
6
 
7
7
  /**
8
8
  * CollectionQuery is a typed query builder for a specific model type.
@@ -12,14 +12,24 @@ import type { ModelDefinition, InferModelInstance } from "../types";
12
12
  * const results = await collection
13
13
  * .query(Epic)
14
14
  * .where("meta.priority", "high")
15
+ * .include("plans")
15
16
  * .fetchAll();
16
17
  */
18
+ interface SortSpec {
19
+ path: string;
20
+ direction: "asc" | "desc";
21
+ }
22
+
17
23
  export class CollectionQuery<
18
24
  TDef extends ModelDefinition<any, any, any, any, any>,
19
25
  > {
20
26
  #collection: Collection;
21
27
  #definition: TDef;
22
28
  #queryBuilder: QueryBuilder;
29
+ #sorts: SortSpec[] = [];
30
+ #limit: number | undefined;
31
+ #offset: number | undefined;
32
+ #include: string[] = [];
23
33
 
24
34
  constructor(collection: Collection, definition: TDef) {
25
35
  this.#collection = collection;
@@ -27,6 +37,26 @@ export class CollectionQuery<
27
37
  this.#queryBuilder = new QueryBuilder();
28
38
  }
29
39
 
40
+ /**
41
+ * Include related models in query results.
42
+ * Named relationships will be eagerly resolved and included in toJSON() output.
43
+ *
44
+ * @example
45
+ * await collection.query(Project).include("plans").fetchAll()
46
+ * await collection.query(Project).include("plans", "goal").fetchAll()
47
+ */
48
+ include(...names: string[]): this {
49
+ this.#include.push(...names);
50
+ return this;
51
+ }
52
+
53
+ /** Returns the serialize options based on include() calls */
54
+ get serializeOptions(): SerializeOptions {
55
+ const opts: SerializeOptions = {};
56
+ if (this.#include.length > 0) opts.related = this.#include;
57
+ return opts;
58
+ }
59
+
30
60
  where(
31
61
  pathOrObject: string | Record<string, unknown>,
32
62
  operatorOrValue?: any,
@@ -96,22 +126,41 @@ export class CollectionQuery<
96
126
  return this;
97
127
  }
98
128
 
129
+ scope(name: string): this {
130
+ const scopeFn = (this.#definition as any).scopes?.[name];
131
+ if (!scopeFn) {
132
+ throw new Error(`Unknown scope "${name}" on model "${this.#definition.name}"`);
133
+ }
134
+ return scopeFn(this) as this;
135
+ }
136
+
137
+ sort(path: string, direction: "asc" | "desc" = "asc"): this {
138
+ this.#sorts.push({ path, direction });
139
+ return this;
140
+ }
141
+
142
+ limit(n: number): this {
143
+ this.#limit = n;
144
+ return this;
145
+ }
146
+
147
+ offset(n: number): this {
148
+ this.#offset = n;
149
+ return this;
150
+ }
151
+
99
152
  async fetchAll(): Promise<InferModelInstance<TDef>[]> {
100
153
  const collection = this.#collection;
101
154
  if (!collection.loaded) await collection.load();
102
155
 
103
156
  const definition = this.#definition;
104
157
  const conditions = this.#queryBuilder.conditions;
105
- const results: InferModelInstance<TDef>[] = [];
158
+ let results: InferModelInstance<TDef>[] = [];
106
159
 
107
160
  for (const pathId of collection.available) {
108
- // Filter by model type BEFORE creating instances (fixes original perf bug)
109
- const item = collection.items.get(pathId)!;
110
- const matchesModel = definition.match
111
- ? definition.match({ id: pathId, meta: item.meta })
112
- : pathId.startsWith(definition.prefix);
113
-
114
- if (!matchesModel) continue;
161
+ // Delegate all matching logic to collection (handles _model meta, prefix, Base fallback)
162
+ const matchedDef = collection.findModelDefinition(pathId);
163
+ if (matchedDef?.name !== definition.name) continue;
115
164
 
116
165
  const doc = collection.document(pathId);
117
166
  const instance = createModelInstance(doc, definition, collection);
@@ -127,6 +176,44 @@ export class CollectionQuery<
127
176
  }
128
177
  }
129
178
 
179
+ if (this.#sorts.length > 0) {
180
+ results.sort((a, b) => {
181
+ for (const { path, direction } of this.#sorts) {
182
+ const aVal = getNestedValue(a, path);
183
+ const bVal = getNestedValue(b, path);
184
+ if (aVal === bVal) continue;
185
+ if (aVal == null) return direction === "asc" ? 1 : -1;
186
+ if (bVal == null) return direction === "asc" ? -1 : 1;
187
+ const cmp = aVal < bVal ? -1 : 1;
188
+ return direction === "asc" ? cmp : -cmp;
189
+ }
190
+ return 0;
191
+ });
192
+ }
193
+
194
+ // Apply pagination
195
+ if (this.#offset && this.#offset > 0) {
196
+ results = results.slice(this.#offset);
197
+ }
198
+ if (this.#limit !== undefined && this.#limit >= 0) {
199
+ results = results.slice(0, this.#limit);
200
+ }
201
+
202
+ // Bind serialize options from include() so toJSON() includes relationships
203
+ if (this.#include.length > 0) {
204
+ const opts = this.serializeOptions;
205
+ for (const instance of results) {
206
+ const original = (instance as any).toJSON;
207
+ (instance as any).toJSON = (overrides?: SerializeOptions) => {
208
+ const merged = { ...opts, ...overrides };
209
+ if (opts.related && overrides?.related) {
210
+ merged.related = [...new Set([...opts.related, ...overrides.related])];
211
+ }
212
+ return original(merged);
213
+ };
214
+ }
215
+ }
216
+
130
217
  return results;
131
218
  }
132
219
 
@@ -3,3 +3,10 @@ export { QueryBuilder } from "./query-builder";
3
3
  export { operators } from "./operators";
4
4
  export type { Operator } from "./operators";
5
5
  export type { Condition } from "./query-builder";
6
+ export {
7
+ queryDSLSchema,
8
+ parseWhereClause,
9
+ parseSortClause,
10
+ executeQueryDSL,
11
+ } from "./query-dsl";
12
+ export type { QueryDSL } from "./query-dsl";
@@ -0,0 +1,259 @@
1
+ import { z } from "zod";
2
+ import type { Collection } from "../collection";
3
+ import type { Condition } from "./query-builder";
4
+ import type { Operator } from "./operators";
5
+ import { resolveModelDef } from "../api/helpers.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Operator mapping: $dsl → internal
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const OPERATOR_MAP: Record<string, Operator> = {
12
+ $eq: "eq",
13
+ $neq: "neq",
14
+ $in: "in",
15
+ $notIn: "notIn",
16
+ $gt: "gt",
17
+ $lt: "lt",
18
+ $gte: "gte",
19
+ $lte: "lte",
20
+ $contains: "contains",
21
+ $startsWith: "startsWith",
22
+ $endsWith: "endsWith",
23
+ $regex: "regex",
24
+ $exists: "exists",
25
+ };
26
+
27
+ const VALID_OPERATORS = new Set(Object.keys(OPERATOR_MAP));
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Security helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const FORBIDDEN_PATH_SEGMENTS = new Set([
34
+ "__proto__",
35
+ "constructor",
36
+ "prototype",
37
+ ]);
38
+
39
+ const MAX_REGEX_LENGTH = 200;
40
+
41
+ function validatePath(path: string): void {
42
+ const segments = path.split(".");
43
+ for (const seg of segments) {
44
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {
45
+ throw new Error(
46
+ `Forbidden path segment "${seg}" in "${path}"`,
47
+ );
48
+ }
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Zod schema
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const whereValueSchema: z.ZodType = z.union([
57
+ z.string(),
58
+ z.number(),
59
+ z.boolean(),
60
+ z.null(),
61
+ z.array(z.unknown()),
62
+ z.record(z.string(), z.unknown()),
63
+ ]);
64
+
65
+ const sortSchema = z.union([
66
+ z.record(z.string(), z.enum(["asc", "desc"])),
67
+ z.array(
68
+ z.object({
69
+ path: z.string(),
70
+ direction: z.enum(["asc", "desc"]).default("asc"),
71
+ }),
72
+ ),
73
+ ]);
74
+
75
+ export const queryDSLSchema = z.object({
76
+ model: z.string(),
77
+ where: z.record(z.string(), whereValueSchema).optional(),
78
+ sort: sortSchema.optional(),
79
+ select: z.array(z.string()).optional(),
80
+ related: z.array(z.string()).optional(),
81
+ scopes: z.array(z.string()).optional(),
82
+ limit: z.number().int().min(0).optional(),
83
+ offset: z.number().int().min(0).optional(),
84
+ method: z
85
+ .enum(["fetchAll", "first", "last", "count"])
86
+ .default("fetchAll"),
87
+ });
88
+
89
+ export type QueryDSL = z.infer<typeof queryDSLSchema>;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Parser: where object → Condition[]
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export function parseWhereClause(
96
+ where: Record<string, unknown>,
97
+ ): Condition[] {
98
+ const conditions: Condition[] = [];
99
+
100
+ for (const [path, value] of Object.entries(where)) {
101
+ validatePath(path);
102
+
103
+ if (
104
+ value === null ||
105
+ typeof value === "string" ||
106
+ typeof value === "number" ||
107
+ typeof value === "boolean"
108
+ ) {
109
+ // Literal → implicit $eq
110
+ conditions.push({ path, operator: "eq", value });
111
+ } else if (Array.isArray(value)) {
112
+ // Array → implicit $in
113
+ conditions.push({ path, operator: "in", value });
114
+ } else if (typeof value === "object" && value !== null) {
115
+ // Operator object: { "$gt": 5, "$lte": 10 }
116
+ for (const [opKey, opValue] of Object.entries(
117
+ value as Record<string, unknown>,
118
+ )) {
119
+ if (!VALID_OPERATORS.has(opKey)) {
120
+ throw new Error(
121
+ `Unknown operator: ${opKey}. Valid: ${[...VALID_OPERATORS].join(", ")}`,
122
+ );
123
+ }
124
+
125
+ const operator = OPERATOR_MAP[opKey];
126
+
127
+ // Regex length guard
128
+ if (
129
+ operator === "regex" &&
130
+ typeof opValue === "string" &&
131
+ opValue.length > MAX_REGEX_LENGTH
132
+ ) {
133
+ throw new Error(
134
+ `Regex pattern exceeds maximum length of ${MAX_REGEX_LENGTH} characters`,
135
+ );
136
+ }
137
+
138
+ conditions.push({ path, operator, value: opValue });
139
+ }
140
+ }
141
+ }
142
+
143
+ return conditions;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Parser: sort clause → SortSpec[]
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export function parseSortClause(
151
+ sort: unknown,
152
+ ): Array<{ path: string; direction: "asc" | "desc" }> {
153
+ if (Array.isArray(sort)) {
154
+ return sort;
155
+ }
156
+ if (typeof sort === "object" && sort !== null) {
157
+ return Object.entries(sort as Record<string, string>).map(
158
+ ([path, direction]) => ({
159
+ path,
160
+ direction: direction as "asc" | "desc",
161
+ }),
162
+ );
163
+ }
164
+ return [];
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Select helper
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function applySelect(
172
+ instance: any,
173
+ select?: string[],
174
+ related?: string[],
175
+ ): Record<string, unknown> {
176
+ const json = instance.toJSON({ related });
177
+ if (!select || select.length === 0) return json;
178
+
179
+ const filtered: Record<string, unknown> = {};
180
+ for (const key of select) {
181
+ if (key in json) {
182
+ filtered[key] = json[key];
183
+ } else if (key.startsWith("meta.") && json.meta) {
184
+ filtered[key] = json.meta[key.slice(5)];
185
+ }
186
+ }
187
+ return filtered;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Executor
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export async function executeQueryDSL(
195
+ collection: Collection,
196
+ dsl: QueryDSL,
197
+ ) {
198
+ const def = resolveModelDef(collection, dsl.model);
199
+ if (!def) {
200
+ throw new Error(`Unknown model: ${dsl.model}`);
201
+ }
202
+
203
+ let q = collection.query(def);
204
+
205
+ // Apply scopes first
206
+ if (dsl.scopes) {
207
+ for (const name of dsl.scopes) {
208
+ q = q.scope(name);
209
+ }
210
+ }
211
+
212
+ // Apply where conditions
213
+ if (dsl.where) {
214
+ const conditions = parseWhereClause(dsl.where);
215
+ for (const cond of conditions) {
216
+ q = q.where(cond.path, cond.operator, cond.value);
217
+ }
218
+ }
219
+
220
+ // Apply sorts
221
+ if (dsl.sort) {
222
+ const sorts = parseSortClause(dsl.sort);
223
+ for (const { path, direction } of sorts) {
224
+ q = q.sort(path, direction);
225
+ }
226
+ }
227
+
228
+ // Apply pagination
229
+ if (dsl.limit !== undefined) {
230
+ q = q.limit(dsl.limit);
231
+ }
232
+ if (dsl.offset !== undefined) {
233
+ q = q.offset(dsl.offset);
234
+ }
235
+
236
+ // Execute based on method
237
+ switch (dsl.method) {
238
+ case "count":
239
+ return { count: await q.count() };
240
+
241
+ case "first": {
242
+ const result = await q.first();
243
+ return result ? applySelect(result, dsl.select, dsl.related) : null;
244
+ }
245
+
246
+ case "last": {
247
+ const result = await q.last();
248
+ return result ? applySelect(result, dsl.select, dsl.related) : null;
249
+ }
250
+
251
+ case "fetchAll":
252
+ default: {
253
+ const results = await q.fetchAll();
254
+ return results.map((instance: any) =>
255
+ applySelect(instance, dsl.select, dsl.related),
256
+ );
257
+ }
258
+ }
259
+ }
@@ -51,6 +51,7 @@ export class HasManyRelationship<
51
51
  */
52
52
  private extractChildNodes(): ChildNode[] {
53
53
  const { astQuery } = this.#document;
54
+ if (!this.#definition.heading) return [];
54
55
  const parentHeading = astQuery.findHeadingByText(this.#definition.heading);
55
56
  if (!parentHeading) return [];
56
57
 
@@ -100,6 +101,14 @@ export class HasManyRelationship<
100
101
 
101
102
  fetchAll(): InferModelInstance<TTarget>[] {
102
103
  const targetDef = this.#definition.target();
104
+
105
+ // Foreign key mode: query for target documents where meta[foreignKey] matches this document's slug
106
+ // Used when foreignKey is explicitly set, or when no heading is specified (convention-based)
107
+ if (this.#definition.foreignKey || !this.#definition.heading) {
108
+ return this.#fetchByForeignKey(targetDef);
109
+ }
110
+
111
+ // Heading mode: extract child nodes from parent document AST
103
112
  const childNodes = this.extractChildNodes();
104
113
 
105
114
  return childNodes.map(({ id, ast }) => {
@@ -119,6 +128,36 @@ export class HasManyRelationship<
119
128
  });
120
129
  }
121
130
 
131
+ #fetchByForeignKey(targetDef: TTarget): InferModelInstance<TTarget>[] {
132
+ // If foreignKey is explicitly set, use it. Otherwise infer from the parent document's model.
133
+ // Convention: look for meta[lowercase(parentModelName)] on target documents.
134
+ // e.g. Project hasMany Plans → looks for meta.project on Plan documents.
135
+ const fk = this.#definition.foreignKey || this.#inferForeignKey();
136
+ const slug = this.#document.slug;
137
+ const prefix = targetDef.prefix;
138
+ const results: InferModelInstance<TTarget>[] = [];
139
+
140
+ for (const pathId of this.#collection.available) {
141
+ if (!pathId.startsWith(prefix + "/")) continue;
142
+ const doc = this.#collection.document(pathId);
143
+ if (doc.meta[fk] === slug) {
144
+ results.push(this.#factory(doc, targetDef, this.#collection));
145
+ }
146
+ }
147
+
148
+ return results;
149
+ }
150
+
151
+ /**
152
+ * Infer the foreign key name from the parent document's prefix.
153
+ * e.g. if the parent is in "projects/", the FK is "project" (singularized, lowercased).
154
+ */
155
+ #inferForeignKey(): string {
156
+ const parentPrefix = this.#document.id.split("/")[0];
157
+ // Simple singularize: strip trailing "s"
158
+ return parentPrefix.replace(/s$/, "");
159
+ }
160
+
122
161
  first(): InferModelInstance<TTarget> | undefined {
123
162
  return this.fetchAll()[0];
124
163
  }
@@ -7,7 +7,10 @@ import type {
7
7
 
8
8
  /**
9
9
  * Declare a hasMany relationship.
10
- * Child models are extracted from sub-headings under a parent heading.
10
+ *
11
+ * Two modes:
12
+ * - `heading`: Children are extracted from sub-headings under a parent heading in the document.
13
+ * - `foreignKey`: Children are found by querying target documents where meta[foreignKey] matches this document's slug.
11
14
  *
12
15
  * The target parameter is a thunk (() => ModelDef) to allow circular references.
13
16
  */
@@ -16,7 +19,8 @@ export function hasMany<
16
19
  >(
17
20
  target: () => TTarget,
18
21
  options: {
19
- heading: string;
22
+ heading?: string;
23
+ foreignKey?: string;
20
24
  meta?: (self: any) => Record<string, unknown>;
21
25
  id?: (slug: string) => string;
22
26
  }
@@ -25,6 +29,7 @@ export function hasMany<
25
29
  type: "hasMany",
26
30
  target,
27
31
  heading: options.heading,
32
+ foreignKey: options.foreignKey,
28
33
  meta: options.meta,
29
34
  id: options.id,
30
35
  };
package/src/section.ts CHANGED
@@ -19,10 +19,12 @@ export function section<T>(
19
19
  options: {
20
20
  extract: (query: AstQuery) => T;
21
21
  schema?: z.ZodType<T>;
22
+ alternatives?: string[];
22
23
  }
23
24
  ): SectionDefinition<T> {
24
25
  return {
25
26
  heading,
27
+ alternatives: options.alternatives,
26
28
  extract: options.extract,
27
29
  schema: options.schema,
28
30
  };
package/src/types.ts CHANGED
@@ -12,6 +12,7 @@ export interface CollectionItem {
12
12
  path: string;
13
13
  createdAt: Date;
14
14
  updatedAt: Date;
15
+ size: number;
15
16
  }
16
17
 
17
18
  /** Options when constructing a Collection */
@@ -19,6 +20,8 @@ export interface CollectionOptions {
19
20
  rootPath: string;
20
21
  extensions?: string[];
21
22
  name?: string;
23
+ /** When true (default), load() looks for a models.{ts,js,mjs} in rootPath and auto-registers exported model definitions. */
24
+ autoDiscover?: boolean;
22
25
  }
23
26
 
24
27
  // ─── Section system ───
@@ -30,6 +33,8 @@ export interface CollectionOptions {
30
33
  export interface SectionDefinition<T = unknown> {
31
34
  /** The heading text to find in the document */
32
35
  heading: string;
36
+ /** Alternative heading texts to try if the primary heading is not found */
37
+ alternatives?: string[];
33
38
  /** Extract structured data from the section's AST query */
34
39
  extract: (query: AstQuery) => T;
35
40
  /** Optional Zod schema to validate the extracted value */
@@ -43,7 +48,10 @@ export interface HasManyDefinition<
43
48
  > {
44
49
  type: "hasMany";
45
50
  target: () => TTarget;
46
- heading: string;
51
+ /** Extract children from sub-headings under this heading in the parent document */
52
+ heading?: string;
53
+ /** Find children by querying for target documents where meta[foreignKey] matches this document's slug */
54
+ foreignKey?: string;
47
55
  meta?: (self: any) => Record<string, unknown>;
48
56
  id?: (slug: string) => string;
49
57
  }
@@ -80,7 +88,7 @@ export interface DocumentRef {
80
88
  */
81
89
  export interface ModelDefinition<
82
90
  TName extends string = string,
83
- TMeta extends z.ZodTypeAny = z.ZodTypeAny,
91
+ TMeta extends z.ZodType = z.ZodType,
84
92
  TSections extends Record<string, SectionDefinition<any>> = Record<
85
93
  string,
86
94
  never
@@ -100,8 +108,17 @@ export interface ModelDefinition<
100
108
  sections: TSections;
101
109
  relationships: TRelationships;
102
110
  computed: TComputed;
111
+ /** Human-readable description of this model, auto-generated if not provided */
112
+ description: string;
113
+ /** Named scopes — reusable query presets */
114
+ scopes: Record<string, (query: any) => any>;
103
115
  match?: (doc: DocumentRef) => boolean;
104
116
  defaults?: Partial<z.input<TMeta>>;
117
+ pattern?: string | string[];
118
+ /** Glob patterns or RegExp to exclude matching pathIds from queries, listings, and TOC */
119
+ exclude?: (string | RegExp)[];
120
+ /** When true, documents are not required to have an H1 title */
121
+ titleOptional?: boolean;
105
122
 
106
123
  /** The inferred Zod schema for convenience (same as meta) */
107
124
  schema: TMeta;
@@ -131,6 +148,11 @@ export type InferModelInstance<
131
148
  readonly document: import("./document.js").Document;
132
149
  readonly collection: import("./collection.js").Collection;
133
150
 
151
+ // File metadata
152
+ readonly createdAt: Date | undefined;
153
+ readonly updatedAt: Date | undefined;
154
+ readonly size: number | undefined;
155
+
134
156
  // Typed meta from Zod schema
135
157
  readonly meta: z.infer<TMeta>;
136
158
 
@@ -9,3 +9,4 @@ export { stringifyAst } from "./stringify-ast";
9
9
  export { parseTable } from "./parse-table";
10
10
  export { normalizeHeadings } from "./normalize-headings";
11
11
  export { readDirectory } from "./read-directory";
12
+ export { matchPattern, matchPatterns } from "./match-pattern";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Express-style path pattern matching for extracting named parameters
3
+ * from document pathIds.
4
+ *
5
+ * Patterns use `:param` syntax for named segments and literal segments
6
+ * for exact matching. Segment counts must match exactly.
7
+ *
8
+ * Examples:
9
+ * matchPattern("plans/:project/:slug", "plans/acme/launch")
10
+ * // => { project: "acme", slug: "launch" }
11
+ *
12
+ * matchPattern("plans/:project/:slug", "stories/acme/launch")
13
+ * // => null (literal mismatch)
14
+ */
15
+
16
+ /**
17
+ * Match a single pattern against a pathId.
18
+ * Returns extracted params on match, null on mismatch.
19
+ */
20
+ export function matchPattern(
21
+ pattern: string,
22
+ pathId: string
23
+ ): Record<string, string> | null {
24
+ const patternParts = pattern.split("/").filter(Boolean);
25
+ const pathParts = pathId.split("/").filter(Boolean);
26
+
27
+ if (patternParts.length !== pathParts.length) {
28
+ return null;
29
+ }
30
+
31
+ const params: Record<string, string> = {};
32
+
33
+ for (let i = 0; i < patternParts.length; i++) {
34
+ const pat = patternParts[i];
35
+ const val = pathParts[i];
36
+
37
+ if (pat.startsWith(":")) {
38
+ params[pat.slice(1)] = val;
39
+ } else if (pat !== val) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return params;
45
+ }
46
+
47
+ /**
48
+ * Try multiple patterns against a pathId, returning the first match.
49
+ * Accepts a single pattern string or an array of patterns.
50
+ */
51
+ export function matchPatterns(
52
+ patterns: string | string[],
53
+ pathId: string
54
+ ): Record<string, string> | null {
55
+ const list = Array.isArray(patterns) ? patterns : [patterns];
56
+
57
+ for (const pattern of list) {
58
+ const result = matchPattern(pattern, pathId);
59
+ if (result !== null) {
60
+ return result;
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }