contentbase 0.0.2 → 0.0.5
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.
- package/CLI.md +593 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +289 -13
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +12 -3
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +361 -10
- package/src/define-model.ts +101 -4
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +1 -1
- package/src/index.ts +14 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +23 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/bun.lock +0 -473
- package/examples/sdlc-queries.ts +0 -161
- package/scripts/examples/01-collection-setup.ts +0 -46
- package/scripts/examples/02-querying.ts +0 -67
- package/scripts/examples/03-sections.ts +0 -36
- package/scripts/examples/04-relationships.ts +0 -54
- package/scripts/examples/05-document-api.ts +0 -54
- package/scripts/examples/06-extract-sections.ts +0 -55
- package/scripts/examples/07-validation.ts +0 -46
- package/scripts/examples/08-serialization.ts +0 -51
- package/scripts/examples/lib/format.ts +0 -87
- package/scripts/examples/lib/setup.ts +0 -21
- package/scripts/examples/run-all.ts +0 -43
- package/showcases/national-parks/models.ts +0 -74
- package/showcases/national-parks/parks/acadia.mdx +0 -40
- package/showcases/national-parks/parks/yosemite.mdx +0 -44
- package/showcases/national-parks/parks/zion.mdx +0 -44
- package/showcases/national-parks/queries.ts +0 -103
- package/showcases/national-parks/trails/angels-landing.mdx +0 -19
- package/showcases/national-parks/trails/cathedral-lakes.mdx +0 -19
- package/showcases/national-parks/trails/half-dome.mdx +0 -19
- package/showcases/national-parks/trails/jordan-pond-path.mdx +0 -19
- package/showcases/national-parks/trails/mist-trail.mdx +0 -19
- package/showcases/national-parks/trails/observation-point.mdx +0 -19
- package/showcases/national-parks/trails/precipice-trail.mdx +0 -19
- package/showcases/national-parks/trails/the-narrows.mdx +0 -19
- package/showcases/recipes/cuisines/chinese.mdx +0 -28
- package/showcases/recipes/cuisines/italian.mdx +0 -32
- package/showcases/recipes/cuisines/mexican.mdx +0 -28
- package/showcases/recipes/models.ts +0 -77
- package/showcases/recipes/queries.ts +0 -89
- package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +0 -43
- package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +0 -47
- package/showcases/recipes/recipes/italian/bruschetta.mdx +0 -38
- package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +0 -39
- package/showcases/recipes/recipes/italian/tiramisu.mdx +0 -43
- package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +0 -44
- package/showcases/recipes/recipes/mexican/guacamole.mdx +0 -39
- package/showcases/vinyl-collection/albums/bitches-brew.mdx +0 -36
- package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +0 -35
- package/showcases/vinyl-collection/albums/in-rainbows.mdx +0 -35
- package/showcases/vinyl-collection/albums/kind-of-blue.mdx +0 -32
- package/showcases/vinyl-collection/albums/ok-computer.mdx +0 -37
- package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +0 -35
- package/showcases/vinyl-collection/artists/miles-davis.mdx +0 -27
- package/showcases/vinyl-collection/artists/nina-simone.mdx +0 -26
- package/showcases/vinyl-collection/artists/radiohead.mdx +0 -27
- package/showcases/vinyl-collection/models.ts +0 -73
- package/showcases/vinyl-collection/queries.ts +0 -87
- package/test/ast-query.test.ts +0 -128
- package/test/collection.test.ts +0 -99
- package/test/define-model.test.ts +0 -78
- package/test/document.test.ts +0 -225
- package/test/extract-sections.test.ts +0 -356
- package/test/fixtures/sdlc/epics/authentication.mdx +0 -42
- package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +0 -21
- package/test/fixtures/sdlc/models.ts +0 -89
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +0 -20
- package/test/helpers.ts +0 -21
- package/test/model-instance.test.ts +0 -197
- package/test/query.test.ts +0 -167
- package/test/relationships.test.ts +0 -84
- package/test/section.test.ts +0 -99
- package/test/table-of-contents.test.ts +0 -91
- package/test/validator.test.ts +0 -62
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -11
package/src/collection.ts
CHANGED
|
@@ -1,17 +1,110 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
|
+
import picomatch from "picomatch";
|
|
4
5
|
import { Document } from "./document";
|
|
5
6
|
import { CollectionQuery } from "./query/collection-query";
|
|
6
7
|
import { createModelInstance } from "./model-instance";
|
|
7
8
|
import { readDirectory } from "./utils/read-directory";
|
|
9
|
+
import { pluralize } from "./utils/inflect";
|
|
10
|
+
import { Base } from "./base-model";
|
|
8
11
|
import type {
|
|
9
12
|
ModelDefinition,
|
|
10
13
|
CollectionItem,
|
|
11
14
|
CollectionOptions,
|
|
12
15
|
InferModelInstance,
|
|
16
|
+
HasManyDefinition,
|
|
17
|
+
RelationshipDefinition,
|
|
13
18
|
} from "./types";
|
|
14
19
|
|
|
20
|
+
// ─── Zod schema introspection ───
|
|
21
|
+
|
|
22
|
+
export interface FieldInfo {
|
|
23
|
+
name: string;
|
|
24
|
+
type: string;
|
|
25
|
+
required: boolean;
|
|
26
|
+
defaultValue?: unknown;
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function describeZodType(schema: any): { type: string; defaultValue?: unknown; optional: boolean; description?: string } {
|
|
31
|
+
const def = schema?._zod?.def;
|
|
32
|
+
if (!def) return { type: "unknown", optional: false };
|
|
33
|
+
|
|
34
|
+
const description = schema.description ?? def.description;
|
|
35
|
+
|
|
36
|
+
let optional = schema._zod.optout === "optional";
|
|
37
|
+
let defaultValue: unknown = undefined;
|
|
38
|
+
|
|
39
|
+
if (def.type === "default") {
|
|
40
|
+
defaultValue = def.defaultValue;
|
|
41
|
+
const inner = describeZodType(def.innerType);
|
|
42
|
+
return { type: inner.type, defaultValue, optional: true, description: description ?? inner.description };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (def.type === "optional") {
|
|
46
|
+
const inner = describeZodType(def.innerType);
|
|
47
|
+
return { ...inner, optional: true, description: description ?? inner.description };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (def.type === "nullable") {
|
|
51
|
+
const inner = describeZodType(def.innerType);
|
|
52
|
+
return { type: `${inner.type} | null`, optional: inner.optional, defaultValue: inner.defaultValue, description: description ?? inner.description };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (def.type === "enum") {
|
|
56
|
+
const values = Object.keys(def.entries);
|
|
57
|
+
return { type: `enum(\`${values.join("`, `")}\`)`, optional, description };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (def.type === "array") {
|
|
61
|
+
const element = describeZodType(def.element);
|
|
62
|
+
return { type: `${element.type}[]`, optional, description };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (def.type === "record") {
|
|
66
|
+
const valType = describeZodType(def.valueType);
|
|
67
|
+
return { type: `record<string, ${valType.type}>`, optional, description };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (def.type === "literal") {
|
|
71
|
+
return { type: `literal(${JSON.stringify(def.value)})`, optional, description };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (def.type === "union") {
|
|
75
|
+
const options = (def.options as any[]).map((o: any) => describeZodType(o).type);
|
|
76
|
+
return { type: options.join(" | "), optional, description };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { type: def.type ?? "unknown", optional, description };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function introspectMetaSchema(schema: any): FieldInfo[] {
|
|
83
|
+
const shape = schema?._zod?.def?.shape;
|
|
84
|
+
if (!shape) return [];
|
|
85
|
+
|
|
86
|
+
return Object.entries(shape).map(([name, fieldSchema]) => {
|
|
87
|
+
const info = describeZodType(fieldSchema);
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
type: info.type,
|
|
91
|
+
required: !info.optional,
|
|
92
|
+
...(info.defaultValue !== undefined && { defaultValue: info.defaultValue }),
|
|
93
|
+
...(info.description && { description: info.description }),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isModelDefinition(value: unknown): boolean {
|
|
99
|
+
return (
|
|
100
|
+
typeof value === "object" &&
|
|
101
|
+
value !== null &&
|
|
102
|
+
typeof (value as any).name === "string" &&
|
|
103
|
+
typeof (value as any).prefix === "string" &&
|
|
104
|
+
"meta" in (value as any)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
15
108
|
export class Collection {
|
|
16
109
|
readonly rootPath: string;
|
|
17
110
|
readonly name: string;
|
|
@@ -23,11 +116,13 @@ export class Collection {
|
|
|
23
116
|
#actions: Map<string, (collection: Collection, ...args: any[]) => any> =
|
|
24
117
|
new Map();
|
|
25
118
|
#loaded = false;
|
|
119
|
+
#autoDiscover: boolean;
|
|
26
120
|
|
|
27
121
|
constructor(options: CollectionOptions) {
|
|
28
122
|
this.rootPath = path.resolve(options.rootPath);
|
|
29
123
|
this.name = options.name ?? options.rootPath;
|
|
30
124
|
this.extensions = options.extensions ?? ["mdx", "md"];
|
|
125
|
+
this.#autoDiscover = options.autoDiscover ?? true;
|
|
31
126
|
}
|
|
32
127
|
|
|
33
128
|
// ─── Model registration ───
|
|
@@ -54,6 +149,29 @@ export class Collection {
|
|
|
54
149
|
return Array.from(this.#models.values());
|
|
55
150
|
}
|
|
56
151
|
|
|
152
|
+
// ─── Model auto-discovery ───
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Discover and register model definitions from a
|
|
156
|
+
* models.{ts,js,mjs} file in the collection's root path.
|
|
157
|
+
*/
|
|
158
|
+
async #discoverModels(): Promise<void> {
|
|
159
|
+
for (const ext of ["ts", "js", "mjs"]) {
|
|
160
|
+
const candidate = path.resolve(this.rootPath, `models.${ext}`);
|
|
161
|
+
try {
|
|
162
|
+
const mod = await import(candidate);
|
|
163
|
+
for (const value of Object.values(mod)) {
|
|
164
|
+
if (isModelDefinition(value) && !this.#models.has((value as any).name)) {
|
|
165
|
+
this.register(value as any);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
57
175
|
// ─── Loading ───
|
|
58
176
|
|
|
59
177
|
get loaded(): boolean {
|
|
@@ -69,7 +187,28 @@ export class Collection {
|
|
|
69
187
|
}
|
|
70
188
|
|
|
71
189
|
get available(): string[] {
|
|
72
|
-
return Array.from(this.#items.keys())
|
|
190
|
+
return Array.from(this.#items.keys()).filter(
|
|
191
|
+
(id) => !this.#isExcludedByModel(id)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if a pathId is excluded by any model's exclude patterns.
|
|
197
|
+
* Patterns are matched against the full pathId using picomatch (globs) or RegExp.
|
|
198
|
+
*/
|
|
199
|
+
#isExcludedByModel(pathId: string): boolean {
|
|
200
|
+
for (const def of this.modelDefinitions) {
|
|
201
|
+
if (!def.exclude || def.exclude.length === 0) continue;
|
|
202
|
+
if (!pathId.startsWith(def.prefix)) continue;
|
|
203
|
+
for (const pattern of def.exclude) {
|
|
204
|
+
if (pattern instanceof RegExp) {
|
|
205
|
+
if (pattern.test(pathId)) return true;
|
|
206
|
+
} else {
|
|
207
|
+
if (picomatch.isMatch(pathId, pattern)) return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
73
212
|
}
|
|
74
213
|
|
|
75
214
|
/**
|
|
@@ -83,6 +222,16 @@ export class Collection {
|
|
|
83
222
|
return this;
|
|
84
223
|
}
|
|
85
224
|
|
|
225
|
+
// Auto-discover models if none have been manually registered
|
|
226
|
+
if (this.#models.size === 0 && this.#autoDiscover) {
|
|
227
|
+
await this.#discoverModels();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Auto-register Base model as catch-all if not already registered
|
|
231
|
+
if (!this.#models.has("Base")) {
|
|
232
|
+
this.register(Base);
|
|
233
|
+
}
|
|
234
|
+
|
|
86
235
|
if (this.#loaded && refresh) {
|
|
87
236
|
this.#items.clear();
|
|
88
237
|
}
|
|
@@ -96,6 +245,11 @@ export class Collection {
|
|
|
96
245
|
await Promise.all(
|
|
97
246
|
paths.map(async (filePath) => {
|
|
98
247
|
const pathId = this.getPathId(filePath);
|
|
248
|
+
|
|
249
|
+
// Globally exclude templates directory — templates are only used
|
|
250
|
+
// for scaffolding and should never appear in queries or listings
|
|
251
|
+
if (pathId.startsWith("templates/") || pathId === "templates") return;
|
|
252
|
+
|
|
99
253
|
const raw = await fs.readFile(filePath, "utf8");
|
|
100
254
|
const stat = await fs.stat(filePath);
|
|
101
255
|
const { data, content } = matter(raw);
|
|
@@ -107,12 +261,19 @@ export class Collection {
|
|
|
107
261
|
path: filePath,
|
|
108
262
|
createdAt: stat.ctime,
|
|
109
263
|
updatedAt: stat.mtime,
|
|
264
|
+
size: stat.size,
|
|
110
265
|
});
|
|
111
266
|
})
|
|
112
267
|
);
|
|
113
268
|
|
|
114
269
|
// Refresh any already-created documents
|
|
115
270
|
if (this.#loaded && refresh) {
|
|
271
|
+
// Evict documents that no longer exist on disk
|
|
272
|
+
for (const [pathId] of this.#documents) {
|
|
273
|
+
if (!this.#items.has(pathId)) {
|
|
274
|
+
this.#documents.delete(pathId);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
116
277
|
await Promise.all(
|
|
117
278
|
Array.from(this.#documents.values()).map((doc) => doc.reload())
|
|
118
279
|
);
|
|
@@ -135,6 +296,13 @@ export class Collection {
|
|
|
135
296
|
);
|
|
136
297
|
}
|
|
137
298
|
|
|
299
|
+
// Strip known extensions if provided (e.g. "some-doc.md" -> "some-doc")
|
|
300
|
+
const extensionPattern = new RegExp(
|
|
301
|
+
`\\.(${this.extensions.join("|")})$`,
|
|
302
|
+
"i"
|
|
303
|
+
);
|
|
304
|
+
pathId = pathId.replace(extensionPattern, "");
|
|
305
|
+
|
|
138
306
|
let doc = this.#documents.get(pathId);
|
|
139
307
|
if (doc) return doc;
|
|
140
308
|
|
|
@@ -198,13 +366,31 @@ export class Collection {
|
|
|
198
366
|
const item = this.#items.get(pathId);
|
|
199
367
|
if (!item) return undefined;
|
|
200
368
|
|
|
369
|
+
// 1. Explicit _model meta key takes priority
|
|
370
|
+
if (item.meta._model && typeof item.meta._model === "string") {
|
|
371
|
+
const explicit = this.#models.get(item.meta._model);
|
|
372
|
+
if (explicit) return explicit;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 2. Check non-Base models by match/prefix
|
|
376
|
+
let baseModel: ModelDefinition<any, any, any, any, any> | undefined;
|
|
201
377
|
for (const def of this.#models.values()) {
|
|
378
|
+
if (def.name === "Base") {
|
|
379
|
+
baseModel = def;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
202
382
|
if (def.match) {
|
|
203
383
|
if (def.match({ id: pathId, meta: item.meta })) return def;
|
|
204
384
|
} else {
|
|
205
385
|
if (pathId.startsWith(def.prefix)) return def;
|
|
206
386
|
}
|
|
207
387
|
}
|
|
388
|
+
|
|
389
|
+
// 3. Fall back to Base model only for root-level documents (no subfolder)
|
|
390
|
+
if (baseModel && !pathId.includes("/")) {
|
|
391
|
+
return baseModel;
|
|
392
|
+
}
|
|
393
|
+
|
|
208
394
|
return undefined;
|
|
209
395
|
}
|
|
210
396
|
|
|
@@ -225,7 +411,13 @@ export class Collection {
|
|
|
225
411
|
pathId: string,
|
|
226
412
|
options: { content: string; extension?: string }
|
|
227
413
|
): Promise<CollectionItem> {
|
|
228
|
-
const extension = options.extension ?? ".
|
|
414
|
+
const extension = options.extension ?? ".md";
|
|
415
|
+
if (options.content == null) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`saveItem("${pathId}"): content must be a string, got ${typeof options.content}. ` +
|
|
418
|
+
`Use doc.save() or pass the full raw content including frontmatter.`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
229
421
|
const { data, content } = matter(options.content);
|
|
230
422
|
|
|
231
423
|
if (!this.#items.has(pathId)) {
|
|
@@ -237,6 +429,7 @@ export class Collection {
|
|
|
237
429
|
path: filePath,
|
|
238
430
|
createdAt: new Date(),
|
|
239
431
|
updatedAt: new Date(),
|
|
432
|
+
size: Buffer.byteLength(options.content, "utf8"),
|
|
240
433
|
});
|
|
241
434
|
}
|
|
242
435
|
|
|
@@ -251,6 +444,10 @@ export class Collection {
|
|
|
251
444
|
item.content = content;
|
|
252
445
|
item.meta = data;
|
|
253
446
|
item.updatedAt = new Date();
|
|
447
|
+
item.size = Buffer.byteLength(options.content, "utf8");
|
|
448
|
+
|
|
449
|
+
// Invalidate cached Document so the next query rebuilds from fresh data
|
|
450
|
+
this.#documents.delete(pathId);
|
|
254
451
|
|
|
255
452
|
return item;
|
|
256
453
|
}
|
|
@@ -271,7 +468,7 @@ export class Collection {
|
|
|
271
468
|
|
|
272
469
|
async readItem(
|
|
273
470
|
pathId: string,
|
|
274
|
-
extension: string = "
|
|
471
|
+
extension: string = "md"
|
|
275
472
|
): Promise<CollectionItem> {
|
|
276
473
|
let filePath: string;
|
|
277
474
|
|
|
@@ -292,6 +489,7 @@ export class Collection {
|
|
|
292
489
|
path: filePath,
|
|
293
490
|
createdAt: stat.ctime,
|
|
294
491
|
updatedAt: stat.mtime,
|
|
492
|
+
size: stat.size,
|
|
295
493
|
};
|
|
296
494
|
|
|
297
495
|
this.#items.set(pathId, item);
|
|
@@ -412,8 +610,7 @@ export class Collection {
|
|
|
412
610
|
const ids = grouped.get(def.name);
|
|
413
611
|
if (!ids || ids.length === 0) continue;
|
|
414
612
|
|
|
415
|
-
|
|
416
|
-
lines.push(`${depth} ${def.name}`, "");
|
|
613
|
+
lines.push(`## ${pluralize(def.name)}`, "");
|
|
417
614
|
for (const id of ids) {
|
|
418
615
|
lines.push(this.#tocEntry(id, basePath));
|
|
419
616
|
}
|
|
@@ -421,8 +618,7 @@ export class Collection {
|
|
|
421
618
|
}
|
|
422
619
|
|
|
423
620
|
if (ungrouped.length > 0) {
|
|
424
|
-
|
|
425
|
-
lines.push(`${depth} Other`, "");
|
|
621
|
+
lines.push(`## Other`, "");
|
|
426
622
|
for (const id of ungrouped) {
|
|
427
623
|
lines.push(this.#tocEntry(id, basePath));
|
|
428
624
|
}
|
|
@@ -438,6 +634,69 @@ export class Collection {
|
|
|
438
634
|
return lines.join("\n").trimEnd() + "\n";
|
|
439
635
|
}
|
|
440
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Renders an ASCII file tree of all documents in the collection.
|
|
639
|
+
*
|
|
640
|
+
* @example
|
|
641
|
+
* ```
|
|
642
|
+
* const tree = collection.renderFileTree();
|
|
643
|
+
* // epics/
|
|
644
|
+
* // authentication.mdx
|
|
645
|
+
* // searching-and-browsing.mdx
|
|
646
|
+
* // stories/
|
|
647
|
+
* // authentication/
|
|
648
|
+
* // a-user-should-be-able-to-register.mdx
|
|
649
|
+
* ```
|
|
650
|
+
*/
|
|
651
|
+
renderFileTree(): string {
|
|
652
|
+
if (!this.#loaded) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
"Collection has not been loaded. Call load() first."
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const sorted = [...this.available].sort();
|
|
659
|
+
|
|
660
|
+
// Build a nested tree structure from pathIds
|
|
661
|
+
interface TreeNode {
|
|
662
|
+
children: Map<string, TreeNode>;
|
|
663
|
+
}
|
|
664
|
+
const root: TreeNode = { children: new Map() };
|
|
665
|
+
|
|
666
|
+
for (const pathId of sorted) {
|
|
667
|
+
const item = this.#items.get(pathId)!;
|
|
668
|
+
const ext = path.extname(item.path);
|
|
669
|
+
const fullPath = `${pathId}${ext}`;
|
|
670
|
+
const parts = fullPath.split("/");
|
|
671
|
+
|
|
672
|
+
let node = root;
|
|
673
|
+
for (const part of parts) {
|
|
674
|
+
if (!node.children.has(part)) {
|
|
675
|
+
node.children.set(part, { children: new Map() });
|
|
676
|
+
}
|
|
677
|
+
node = node.children.get(part)!;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Render the tree with indentation and connector lines
|
|
682
|
+
const lines: string[] = [];
|
|
683
|
+
|
|
684
|
+
const render = (node: TreeNode, prefix: string) => {
|
|
685
|
+
const entries = [...node.children.entries()];
|
|
686
|
+
entries.forEach(([name, child], i) => {
|
|
687
|
+
const isLast = i === entries.length - 1;
|
|
688
|
+
const connector = isLast ? "└── " : "├── ";
|
|
689
|
+
const isDir = child.children.size > 0;
|
|
690
|
+
lines.push(`${prefix}${connector}${name}${isDir ? "/" : ""}`);
|
|
691
|
+
const nextPrefix = prefix + (isLast ? " " : "│ ");
|
|
692
|
+
render(child, nextPrefix);
|
|
693
|
+
});
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
render(root, "");
|
|
697
|
+
return lines.join("\n") + "\n";
|
|
698
|
+
}
|
|
699
|
+
|
|
441
700
|
#tocEntry(pathId: string, basePath: string): string {
|
|
442
701
|
const item = this.#items.get(pathId)!;
|
|
443
702
|
const ext = path.extname(item.path);
|
|
@@ -446,20 +705,112 @@ export class Collection {
|
|
|
446
705
|
return `- [${doc.title}](${relativePath})`;
|
|
447
706
|
}
|
|
448
707
|
|
|
708
|
+
// ─── Model Summary ───
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Generate a plain-text summary of the collection and its models.
|
|
712
|
+
* Returns the same output as `cnotes inspect`.
|
|
713
|
+
*/
|
|
714
|
+
generateModelSummary(): string {
|
|
715
|
+
if (!this.#loaded) {
|
|
716
|
+
throw new Error("Collection has not been loaded. Call load() first.");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const lines: string[] = [];
|
|
720
|
+
|
|
721
|
+
lines.push(`Collection: ${this.name}`);
|
|
722
|
+
lines.push(`Root: ${this.rootPath}`);
|
|
723
|
+
lines.push(`Items: ${this.available.length}`);
|
|
724
|
+
lines.push("");
|
|
725
|
+
|
|
726
|
+
for (const def of this.modelDefinitions) {
|
|
727
|
+
const matchingItems = this.available.filter(
|
|
728
|
+
(id) => this.findModelDefinition(id)?.name === def.name
|
|
729
|
+
);
|
|
730
|
+
lines.push(` Model: ${def.name}`);
|
|
731
|
+
lines.push(` Prefix: ${def.prefix}`);
|
|
732
|
+
const fields = introspectMetaSchema(def.meta);
|
|
733
|
+
lines.push(
|
|
734
|
+
` Meta: ${fields.length > 0 ? fields.map((f) => `${f.name}(${f.type})`).join(", ") : "(none)"}`
|
|
735
|
+
);
|
|
736
|
+
lines.push(
|
|
737
|
+
` Sections: ${Object.keys(def.sections).join(", ") || "(none)"}`
|
|
738
|
+
);
|
|
739
|
+
lines.push(
|
|
740
|
+
` Relationships: ${Object.keys(def.relationships).join(", ") || "(none)"}`
|
|
741
|
+
);
|
|
742
|
+
lines.push(` Documents: ${matchingItems.length}`);
|
|
743
|
+
if (matchingItems.length > 0) {
|
|
744
|
+
lines.push(` IDs: ${matchingItems.join(", ")}`);
|
|
745
|
+
}
|
|
746
|
+
lines.push("");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (this.availableActions.length > 0) {
|
|
750
|
+
lines.push(`Actions: ${this.availableActions.join(", ")}`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return lines.join("\n").trimEnd();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Write README.md to the collection root.
|
|
758
|
+
* Preserves the `## Overview` section if it already exists.
|
|
759
|
+
* The generated summary is placed in the `## Summary` section.
|
|
760
|
+
*/
|
|
761
|
+
async saveModelSummary(): Promise<string> {
|
|
762
|
+
const summary = this.generateModelSummary();
|
|
763
|
+
const modelsPath = path.join(this.rootPath, "README.md");
|
|
764
|
+
|
|
765
|
+
// Preserve existing Overview section content
|
|
766
|
+
let overview = "";
|
|
767
|
+
try {
|
|
768
|
+
const existing = await fs.readFile(modelsPath, "utf8");
|
|
769
|
+
const overviewStart = existing.indexOf("## Overview");
|
|
770
|
+
if (overviewStart !== -1) {
|
|
771
|
+
const contentStart = existing.indexOf("\n", overviewStart) + 1;
|
|
772
|
+
const nextHeading = existing.indexOf("\n## ", contentStart);
|
|
773
|
+
const contentEnd = nextHeading !== -1 ? nextHeading : existing.length;
|
|
774
|
+
overview = existing.slice(contentStart, contentEnd).trim();
|
|
775
|
+
}
|
|
776
|
+
} catch {
|
|
777
|
+
// No existing file
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const lines: string[] = [
|
|
781
|
+
"# Models",
|
|
782
|
+
"",
|
|
783
|
+
"## Overview",
|
|
784
|
+
"",
|
|
785
|
+
overview || "",
|
|
786
|
+
"",
|
|
787
|
+
"## Summary",
|
|
788
|
+
"",
|
|
789
|
+
"```",
|
|
790
|
+
summary,
|
|
791
|
+
"```",
|
|
792
|
+
"",
|
|
793
|
+
];
|
|
794
|
+
|
|
795
|
+
const markdown = lines.join("\n");
|
|
796
|
+
await fs.writeFile(modelsPath, markdown, "utf8");
|
|
797
|
+
return summary;
|
|
798
|
+
}
|
|
799
|
+
|
|
449
800
|
// ─── Serialization ───
|
|
450
801
|
|
|
451
802
|
toJSON(options: { content?: boolean } = {}): Record<string, unknown> {
|
|
452
803
|
const models = this.modelDefinitions.map((def) => ({
|
|
453
804
|
name: def.name,
|
|
454
805
|
prefix: def.prefix,
|
|
455
|
-
matchingPaths: this.available.filter(
|
|
456
|
-
id.
|
|
806
|
+
matchingPaths: this.available.filter(
|
|
807
|
+
(id) => this.findModelDefinition(id)?.name === def.name
|
|
457
808
|
),
|
|
458
809
|
}));
|
|
459
810
|
|
|
460
811
|
const result: Record<string, unknown> = {
|
|
461
812
|
models,
|
|
462
|
-
itemIds:
|
|
813
|
+
itemIds: this.available,
|
|
463
814
|
};
|
|
464
815
|
|
|
465
816
|
if (options.content) {
|
package/src/define-model.ts
CHANGED
|
@@ -17,12 +17,21 @@ export interface DefineModelConfig<
|
|
|
17
17
|
TComputed extends Record<string, (self: any) => any>,
|
|
18
18
|
> {
|
|
19
19
|
prefix?: string;
|
|
20
|
+
/** Human-readable description of this model. Auto-generated from schema if not provided. */
|
|
21
|
+
description?: string;
|
|
20
22
|
meta?: TMeta;
|
|
21
23
|
sections?: TSections;
|
|
22
24
|
relationships?: TRelationships;
|
|
23
25
|
computed?: TComputed;
|
|
26
|
+
/** Named scopes — reusable query presets */
|
|
27
|
+
scopes?: Record<string, (query: any) => any>;
|
|
24
28
|
match?: (doc: DocumentRef) => boolean;
|
|
25
29
|
defaults?: Partial<z.input<TMeta>>;
|
|
30
|
+
pattern?: string | string[];
|
|
31
|
+
/** Glob patterns or RegExp to exclude matching pathIds from queries, listings, and TOC */
|
|
32
|
+
exclude?: (string | RegExp)[];
|
|
33
|
+
/** When true, documents are not required to have an H1 title */
|
|
34
|
+
titleOptional?: boolean;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
@@ -65,16 +74,104 @@ export function defineModel<
|
|
|
65
74
|
> = {} as any
|
|
66
75
|
): ModelDefinition<TName, TMeta, TSections, TRelationships, TComputed> {
|
|
67
76
|
const meta = (config.meta ?? z.looseObject({})) as TMeta;
|
|
77
|
+
const sections = (config.sections ?? ({} as any)) as TSections;
|
|
78
|
+
const relationships = (config.relationships ?? ({} as any)) as TRelationships;
|
|
79
|
+
const computed = (config.computed ?? ({} as any)) as TComputed;
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
const def: any = {
|
|
70
82
|
name,
|
|
71
83
|
prefix: config.prefix ?? pluralize(name.toLowerCase()),
|
|
72
84
|
meta,
|
|
73
85
|
schema: meta,
|
|
74
|
-
sections
|
|
75
|
-
relationships
|
|
76
|
-
computed
|
|
86
|
+
sections,
|
|
87
|
+
relationships,
|
|
88
|
+
computed,
|
|
89
|
+
scopes: config.scopes ?? {},
|
|
77
90
|
match: config.match,
|
|
78
91
|
defaults: config.defaults,
|
|
92
|
+
pattern: config.pattern,
|
|
93
|
+
exclude: config.exclude,
|
|
94
|
+
titleOptional: config.titleOptional,
|
|
79
95
|
};
|
|
96
|
+
|
|
97
|
+
// description is lazy — computed on first access if not provided by the user.
|
|
98
|
+
// This avoids calling relationship target thunks during defineModel() which
|
|
99
|
+
// would break circular references (e.g. Epic ↔ Story).
|
|
100
|
+
if (config.description) {
|
|
101
|
+
def.description = config.description;
|
|
102
|
+
} else {
|
|
103
|
+
let cached: string | undefined;
|
|
104
|
+
Object.defineProperty(def, "description", {
|
|
105
|
+
get() {
|
|
106
|
+
if (cached === undefined) {
|
|
107
|
+
cached = generateDescription(name, meta, sections, relationships, computed);
|
|
108
|
+
}
|
|
109
|
+
return cached;
|
|
110
|
+
},
|
|
111
|
+
enumerable: true,
|
|
112
|
+
configurable: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return def;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Auto-generates a human-readable description from the model's schema.
|
|
121
|
+
* Safe to call after all models are defined (relationship thunks are resolved lazily).
|
|
122
|
+
*/
|
|
123
|
+
export function generateDescription(
|
|
124
|
+
name: string,
|
|
125
|
+
meta: z.ZodType,
|
|
126
|
+
sections: Record<string, SectionDefinition<any>>,
|
|
127
|
+
relationships: Record<string, RelationshipDefinition<any>>,
|
|
128
|
+
computed: Record<string, (self: any) => any>
|
|
129
|
+
): string {
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
|
|
132
|
+
// Extract meta field names from Zod schema shape
|
|
133
|
+
const shape = (meta as any)?._zod?.def?.shape;
|
|
134
|
+
const metaKeys = shape ? Object.keys(shape) : [];
|
|
135
|
+
if (metaKeys.length > 0) {
|
|
136
|
+
parts.push(`metadata (${metaKeys.join(", ")})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Section headings
|
|
140
|
+
const sectionEntries = Object.values(sections ?? {});
|
|
141
|
+
if (sectionEntries.length > 0) {
|
|
142
|
+
const headings = sectionEntries.map((s: any) => s.heading);
|
|
143
|
+
parts.push(`section${headings.length === 1 ? "" : "s"} (${headings.join(", ")})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Relationships — resolve target thunks now (safe at access time, not at define time)
|
|
147
|
+
const relEntries = Object.entries(relationships ?? {});
|
|
148
|
+
if (relEntries.length > 0) {
|
|
149
|
+
const relDescs = relEntries.map(([key, rel]: [string, any]) => {
|
|
150
|
+
const targetName = typeof rel.target === "function" ? rel.target()?.name : undefined;
|
|
151
|
+
return targetName ? `${key} → ${targetName}` : key;
|
|
152
|
+
});
|
|
153
|
+
parts.push(`relationship${relDescs.length === 1 ? "" : "s"} (${relDescs.join(", ")})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Computed
|
|
157
|
+
const computedKeys = Object.keys(computed ?? {});
|
|
158
|
+
if (computedKeys.length > 0) {
|
|
159
|
+
parts.push(`computed ${computedKeys.length === 1 ? "property" : "properties"} (${computedKeys.join(", ")})`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Scopes — accessed from the definition at runtime, not passed as parameter
|
|
163
|
+
// to keep backward compat with existing callers
|
|
164
|
+
|
|
165
|
+
if (parts.length === 0) {
|
|
166
|
+
return `A ${name} document.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const article = /^[aeiou]/i.test(name) ? "An" : "A";
|
|
170
|
+
return `${article} ${name} has ${joinNatural(parts)}.`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function joinNatural(items: string[]): string {
|
|
174
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
175
|
+
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
|
176
|
+
return items.slice(0, -1).join(", ") + ", and " + items[items.length - 1];
|
|
80
177
|
}
|