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.
- package/.mcp.json +9 -0
- package/CLI.md +593 -0
- package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +406 -13
- package/bun.lock +45 -6
- package/dist/cnotes +0 -0
- 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 +14 -4
- package/public/web-demo/index.html +813 -0
- package/scripts/examples/01-collection-setup.ts +46 -0
- package/scripts/examples/02-querying.ts +67 -0
- package/scripts/examples/03-sections.ts +36 -0
- package/scripts/examples/04-relationships.ts +54 -0
- package/scripts/examples/05-document-api.ts +54 -0
- package/scripts/examples/06-extract-sections.ts +55 -0
- package/scripts/examples/07-validation.ts +46 -0
- package/scripts/examples/08-serialization.ts +51 -0
- package/scripts/examples/lib/format.ts +87 -0
- package/scripts/examples/lib/setup.ts +21 -0
- package/scripts/examples/run-all.ts +43 -0
- package/showcases/node_modules/.cache/.repl_history +3 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
- package/src/__tests__/semantic-search.integration.test.ts +284 -0
- 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 +455 -6
- package/src/define-model.ts +104 -7
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +222 -0
- package/src/index.ts +20 -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 +24 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/test/collection.test.ts +118 -2
- package/test/extract-sections.test.ts +356 -0
- package/test/fixtures/sdlc/MODELS.md +106 -0
- package/test/fixtures/sdlc/SKILL.md +404 -0
- package/test/fixtures/sdlc/index.ts +9 -0
- package/test/fixtures/sdlc/models.ts +8 -6
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
- package/test/fixtures/sdlc/templates/epic.md +23 -0
- package/test/fixtures/sdlc/templates/story.md +19 -0
- package/test/pattern.test.ts +191 -0
- package/test/query-dsl.test.ts +431 -0
- package/test/query.test.ts +29 -0
- package/test/relationships.test.ts +130 -0
- package/test/section.test.ts +65 -4
- package/test/table-of-contents.test.ts +135 -0
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);
|
|
@@ -348,20 +546,271 @@ export class Collection {
|
|
|
348
546
|
return relativePath.replace(/\.[a-z]+$/i, "");
|
|
349
547
|
}
|
|
350
548
|
|
|
549
|
+
// ─── Table of Contents ───
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Generate a markdown table of contents with links to each document.
|
|
553
|
+
* Links use relative paths that work for GitHub navigation.
|
|
554
|
+
*
|
|
555
|
+
* If models are registered, items are grouped under model name headings.
|
|
556
|
+
* Items that don't match any model appear under an "Other" group.
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* ```ts
|
|
560
|
+
* const toc = collection.tableOfContents({ title: "Project Docs" });
|
|
561
|
+
* // # Project Docs
|
|
562
|
+
* //
|
|
563
|
+
* // ## Epics
|
|
564
|
+
* //
|
|
565
|
+
* // - [Authentication](./epics/authentication.mdx)
|
|
566
|
+
* // - [Searching And Browsing](./epics/searching-and-browsing.mdx)
|
|
567
|
+
* //
|
|
568
|
+
* // ## Stories
|
|
569
|
+
* //
|
|
570
|
+
* // - [A User should be able to register.](./stories/authentication/a-user-should-be-able-to-register.mdx)
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
tableOfContents(
|
|
574
|
+
options: { title?: string; basePath?: string } = {}
|
|
575
|
+
): string {
|
|
576
|
+
if (!this.#loaded) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
"Collection has not been loaded. Call load() first."
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const basePath = options.basePath ?? ".";
|
|
583
|
+
const lines: string[] = [];
|
|
584
|
+
|
|
585
|
+
if (options.title) {
|
|
586
|
+
lines.push(`# ${options.title}`, "");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const sorted = [...this.available].sort();
|
|
590
|
+
|
|
591
|
+
if (this.#models.size > 0) {
|
|
592
|
+
const grouped = new Map<string, string[]>();
|
|
593
|
+
const ungrouped: string[] = [];
|
|
594
|
+
|
|
595
|
+
for (const pathId of sorted) {
|
|
596
|
+
const def = this.findModelDefinition(pathId);
|
|
597
|
+
if (def) {
|
|
598
|
+
let group = grouped.get(def.name);
|
|
599
|
+
if (!group) {
|
|
600
|
+
group = [];
|
|
601
|
+
grouped.set(def.name, group);
|
|
602
|
+
}
|
|
603
|
+
group.push(pathId);
|
|
604
|
+
} else {
|
|
605
|
+
ungrouped.push(pathId);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for (const def of this.modelDefinitions) {
|
|
610
|
+
const ids = grouped.get(def.name);
|
|
611
|
+
if (!ids || ids.length === 0) continue;
|
|
612
|
+
|
|
613
|
+
lines.push(`## ${pluralize(def.name)}`, "");
|
|
614
|
+
for (const id of ids) {
|
|
615
|
+
lines.push(this.#tocEntry(id, basePath));
|
|
616
|
+
}
|
|
617
|
+
lines.push("");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (ungrouped.length > 0) {
|
|
621
|
+
lines.push(`## Other`, "");
|
|
622
|
+
for (const id of ungrouped) {
|
|
623
|
+
lines.push(this.#tocEntry(id, basePath));
|
|
624
|
+
}
|
|
625
|
+
lines.push("");
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
for (const id of sorted) {
|
|
629
|
+
lines.push(this.#tocEntry(id, basePath));
|
|
630
|
+
}
|
|
631
|
+
lines.push("");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
635
|
+
}
|
|
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
|
+
|
|
700
|
+
#tocEntry(pathId: string, basePath: string): string {
|
|
701
|
+
const item = this.#items.get(pathId)!;
|
|
702
|
+
const ext = path.extname(item.path);
|
|
703
|
+
const relativePath = `${basePath}/${pathId}${ext}`;
|
|
704
|
+
const doc = this.document(pathId);
|
|
705
|
+
return `- [${doc.title}](${relativePath})`;
|
|
706
|
+
}
|
|
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
|
+
|
|
351
800
|
// ─── Serialization ───
|
|
352
801
|
|
|
353
802
|
toJSON(options: { content?: boolean } = {}): Record<string, unknown> {
|
|
354
803
|
const models = this.modelDefinitions.map((def) => ({
|
|
355
804
|
name: def.name,
|
|
356
805
|
prefix: def.prefix,
|
|
357
|
-
matchingPaths: this.available.filter(
|
|
358
|
-
id.
|
|
806
|
+
matchingPaths: this.available.filter(
|
|
807
|
+
(id) => this.findModelDefinition(id)?.name === def.name
|
|
359
808
|
),
|
|
360
809
|
}));
|
|
361
810
|
|
|
362
811
|
const result: Record<string, unknown> = {
|
|
363
812
|
models,
|
|
364
|
-
itemIds:
|
|
813
|
+
itemIds: this.available,
|
|
365
814
|
};
|
|
366
815
|
|
|
367
816
|
if (options.content) {
|