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/define-model.ts
CHANGED
|
@@ -11,18 +11,27 @@ 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.
|
|
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>,
|
|
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
|
/**
|
|
@@ -42,7 +51,7 @@ export interface DefineModelConfig<
|
|
|
42
51
|
*/
|
|
43
52
|
export function defineModel<
|
|
44
53
|
TName extends string,
|
|
45
|
-
TMeta extends z.
|
|
54
|
+
TMeta extends z.ZodType = z.ZodObject<{}, z.core.$loose>,
|
|
46
55
|
TSections extends Record<string, SectionDefinition<any>> = Record<
|
|
47
56
|
string,
|
|
48
57
|
never
|
|
@@ -64,17 +73,105 @@ export function defineModel<
|
|
|
64
73
|
TComputed
|
|
65
74
|
> = {} as any
|
|
66
75
|
): ModelDefinition<TName, TMeta, TSections, TRelationships, TComputed> {
|
|
67
|
-
const meta = (config.meta ?? z.
|
|
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
|
}
|
package/src/document.ts
CHANGED
|
@@ -54,6 +54,7 @@ export class Document {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
get title(): string {
|
|
57
|
+
if (this.#meta.title) return String(this.#meta.title);
|
|
57
58
|
const heading = this.astQuery.select("heading");
|
|
58
59
|
return heading ? toString(heading) : this.id;
|
|
59
60
|
}
|
|
@@ -62,6 +63,18 @@ export class Document {
|
|
|
62
63
|
return kebabCase(this.title.toLowerCase());
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
get createdAt(): Date | undefined {
|
|
67
|
+
return this.collection.items.get(this.id)?.createdAt;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get updatedAt(): Date | undefined {
|
|
71
|
+
return this.collection.items.get(this.id)?.updatedAt;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get size(): number | undefined {
|
|
75
|
+
return this.collection.items.get(this.id)?.size;
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
get rawContent(): string {
|
|
66
79
|
if (Object.keys(this.#meta).length === 0) {
|
|
67
80
|
return this.content;
|
|
@@ -71,7 +84,7 @@ export class Document {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
get path(): string {
|
|
74
|
-
return this.collection.resolve(this.id) + ".
|
|
87
|
+
return this.collection.resolve(this.id) + ".md";
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
// ─── Processor ───
|
|
@@ -112,6 +125,11 @@ export class Document {
|
|
|
112
125
|
heading = this.astQuery.findHeadingByText(startHeading) as
|
|
113
126
|
| Content
|
|
114
127
|
| undefined;
|
|
128
|
+
|
|
129
|
+
// If not found, try alternatives from the model definition's sections
|
|
130
|
+
if (!heading) {
|
|
131
|
+
heading = this.#resolveAlternativeHeading(startHeading);
|
|
132
|
+
}
|
|
115
133
|
} else {
|
|
116
134
|
heading = startHeading;
|
|
117
135
|
}
|
|
@@ -128,6 +146,28 @@ export class Document {
|
|
|
128
146
|
return [heading, ...sectionNodes];
|
|
129
147
|
}
|
|
130
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Look up section alternatives from the model definition.
|
|
151
|
+
* If the requested heading matches a section's heading or is one of its alternatives,
|
|
152
|
+
* try all variants (primary + alternatives) until one is found in the AST.
|
|
153
|
+
*/
|
|
154
|
+
#resolveAlternativeHeading(requestedHeading: string): Content | undefined {
|
|
155
|
+
const def = this.collection?.findModelDefinition?.(this.id);
|
|
156
|
+
if (!def?.sections) return undefined;
|
|
157
|
+
|
|
158
|
+
for (const sectionDef of Object.values(def.sections) as any[]) {
|
|
159
|
+
const allNames = [sectionDef.heading, ...(sectionDef.alternatives ?? [])];
|
|
160
|
+
if (!allNames.some((n: string) => n === requestedHeading)) continue;
|
|
161
|
+
|
|
162
|
+
for (const name of allNames) {
|
|
163
|
+
const found = this.astQuery.findHeadingByText(name) as Content | undefined;
|
|
164
|
+
if (found) return found;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
/**
|
|
132
172
|
* Returns an AstQuery scoped to the nodes underneath a particular heading,
|
|
133
173
|
* excluding the heading itself.
|
|
@@ -388,12 +428,18 @@ export class Document {
|
|
|
388
428
|
meta: Record<string, unknown>;
|
|
389
429
|
content: string;
|
|
390
430
|
ast: Root;
|
|
431
|
+
createdAt: Date | undefined;
|
|
432
|
+
updatedAt: Date | undefined;
|
|
433
|
+
size: number | undefined;
|
|
391
434
|
} {
|
|
392
435
|
return {
|
|
393
436
|
id: this.id,
|
|
394
437
|
meta: this.meta,
|
|
395
438
|
content: this.content,
|
|
396
439
|
ast: this.ast,
|
|
440
|
+
createdAt: this.createdAt,
|
|
441
|
+
updatedAt: this.updatedAt,
|
|
442
|
+
size: this.size,
|
|
397
443
|
};
|
|
398
444
|
}
|
|
399
445
|
|
|
@@ -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
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
// Core classes
|
|
2
|
-
export { Collection } from "./collection";
|
|
2
|
+
export { Collection, introspectMetaSchema } from "./collection";
|
|
3
|
+
export type { FieldInfo } from "./collection";
|
|
3
4
|
export { Document } from "./document";
|
|
4
5
|
export { AstQuery } from "./ast-query";
|
|
5
6
|
export { NodeShortcuts } from "./node-shortcuts";
|
|
6
7
|
export { parse } from "./parse";
|
|
7
8
|
export type { ParsedDocument } from "./parse";
|
|
9
|
+
export { extractSections } from "./extract-sections";
|
|
10
|
+
export type {
|
|
11
|
+
ExtractionEntry,
|
|
12
|
+
ExtractSectionsOptions,
|
|
13
|
+
SectionSource,
|
|
14
|
+
} from "./extract-sections";
|
|
8
15
|
|
|
9
16
|
// defineModel and helpers
|
|
10
|
-
export { defineModel } from "./define-model";
|
|
17
|
+
export { defineModel, generateDescription } from "./define-model";
|
|
18
|
+
export { Base } from "./base-model";
|
|
11
19
|
export { section } from "./section";
|
|
12
20
|
export { hasMany, belongsTo } from "./relationships/index";
|
|
13
21
|
|
|
14
22
|
// Query
|
|
15
23
|
export { CollectionQuery } from "./query/collection-query";
|
|
16
24
|
export { QueryBuilder } from "./query/query-builder";
|
|
25
|
+
export {
|
|
26
|
+
queryDSLSchema,
|
|
27
|
+
parseWhereClause,
|
|
28
|
+
parseSortClause,
|
|
29
|
+
executeQueryDSL,
|
|
30
|
+
} from "./query/query-dsl";
|
|
31
|
+
export type { QueryDSL } from "./query/query-dsl";
|
|
17
32
|
|
|
18
33
|
// Model instance factory (advanced use)
|
|
19
34
|
export { createModelInstance } from "./model-instance";
|
|
@@ -21,6 +36,9 @@ export { createModelInstance } from "./model-instance";
|
|
|
21
36
|
// Validation
|
|
22
37
|
export { validateDocument } from "./validator";
|
|
23
38
|
|
|
39
|
+
// Pattern matching
|
|
40
|
+
export { matchPattern, matchPatterns } from "./utils/match-pattern";
|
|
41
|
+
|
|
24
42
|
import { toString } from "mdast-util-to-string";
|
|
25
43
|
|
|
26
44
|
// Types
|
package/src/model-instance.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Document } from "./document";
|
|
|
12
12
|
import type { Collection } from "./collection";
|
|
13
13
|
import { HasManyRelationship } from "./relationships/has-many";
|
|
14
14
|
import { BelongsToRelationship } from "./relationships/belongs-to";
|
|
15
|
+
import { matchPatterns } from "./utils/match-pattern";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Creates a model instance from a document and its model definition.
|
|
@@ -26,8 +27,11 @@ export function createModelInstance<
|
|
|
26
27
|
definition: TDef,
|
|
27
28
|
collection: Collection
|
|
28
29
|
): InferModelInstance<TDef> {
|
|
29
|
-
// ─── Meta: merge defaults,
|
|
30
|
-
const
|
|
30
|
+
// ─── Meta: merge defaults, pattern-inferred values, then frontmatter ───
|
|
31
|
+
const patternMeta = definition.pattern
|
|
32
|
+
? matchPatterns(definition.pattern, document.id) ?? {}
|
|
33
|
+
: {};
|
|
34
|
+
const rawMeta = { ...(definition.defaults ?? {}), ...patternMeta, ...document.meta };
|
|
31
35
|
let meta: any;
|
|
32
36
|
try {
|
|
33
37
|
meta = definition.meta.parse(rawMeta);
|
|
@@ -46,7 +50,19 @@ export function createModelInstance<
|
|
|
46
50
|
Object.defineProperty(sections, key, {
|
|
47
51
|
get() {
|
|
48
52
|
if (!cached) {
|
|
49
|
-
|
|
53
|
+
let heading = sd.heading;
|
|
54
|
+
if (sd.alternatives?.length) {
|
|
55
|
+
const found = document.astQuery.findHeadingByText(heading);
|
|
56
|
+
if (!found) {
|
|
57
|
+
for (const alt of sd.alternatives) {
|
|
58
|
+
if (document.astQuery.findHeadingByText(alt)) {
|
|
59
|
+
heading = alt;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const sectionQuery = document.querySection(heading);
|
|
50
66
|
cached = { value: sd.extract(sectionQuery) };
|
|
51
67
|
}
|
|
52
68
|
return cached.value;
|
|
@@ -152,6 +168,9 @@ export function createModelInstance<
|
|
|
152
168
|
id: document.id,
|
|
153
169
|
title: document.title,
|
|
154
170
|
meta,
|
|
171
|
+
createdAt: document.createdAt,
|
|
172
|
+
updatedAt: document.updatedAt,
|
|
173
|
+
size: document.size,
|
|
155
174
|
};
|
|
156
175
|
|
|
157
176
|
// Include requested sections
|
|
@@ -198,8 +217,15 @@ export function createModelInstance<
|
|
|
198
217
|
get slug() {
|
|
199
218
|
return document.slug;
|
|
200
219
|
},
|
|
201
|
-
|
|
202
|
-
|
|
220
|
+
get createdAt() {
|
|
221
|
+
return document.createdAt;
|
|
222
|
+
},
|
|
223
|
+
get updatedAt() {
|
|
224
|
+
return document.updatedAt;
|
|
225
|
+
},
|
|
226
|
+
get size() {
|
|
227
|
+
return document.size;
|
|
228
|
+
},
|
|
203
229
|
meta,
|
|
204
230
|
sections,
|
|
205
231
|
relationships,
|
|
@@ -223,5 +249,9 @@ export function createModelInstance<
|
|
|
223
249
|
},
|
|
224
250
|
};
|
|
225
251
|
|
|
252
|
+
// Keep heavy back-references accessible but out of REPL output
|
|
253
|
+
Object.defineProperty(instance, "document", { value: document, enumerable: false });
|
|
254
|
+
Object.defineProperty(instance, "collection", { value: collection, enumerable: false });
|
|
255
|
+
|
|
226
256
|
return instance as InferModelInstance<TDef>;
|
|
227
257
|
}
|
package/src/node-shortcuts.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class NodeShortcuts {
|
|
|
50
50
|
return this.headings.reduce(
|
|
51
51
|
(memo, heading) => {
|
|
52
52
|
memo[heading.depth] = memo[heading.depth] || [];
|
|
53
|
-
memo[heading.depth]
|
|
53
|
+
memo[heading.depth]!.push(heading);
|
|
54
54
|
return memo;
|
|
55
55
|
},
|
|
56
56
|
{} as Record<number, Heading[]>
|