contentbase 0.0.1
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/README.md +460 -0
- package/bun.lock +473 -0
- package/examples/sdlc-queries.ts +161 -0
- package/package.json +41 -0
- package/showcases/national-parks/models.ts +74 -0
- package/showcases/national-parks/parks/acadia.mdx +40 -0
- package/showcases/national-parks/parks/yosemite.mdx +44 -0
- package/showcases/national-parks/parks/zion.mdx +44 -0
- package/showcases/national-parks/queries.ts +103 -0
- package/showcases/national-parks/trails/angels-landing.mdx +19 -0
- package/showcases/national-parks/trails/cathedral-lakes.mdx +19 -0
- package/showcases/national-parks/trails/half-dome.mdx +19 -0
- package/showcases/national-parks/trails/jordan-pond-path.mdx +19 -0
- package/showcases/national-parks/trails/mist-trail.mdx +19 -0
- package/showcases/national-parks/trails/observation-point.mdx +19 -0
- package/showcases/national-parks/trails/precipice-trail.mdx +19 -0
- package/showcases/national-parks/trails/the-narrows.mdx +19 -0
- package/showcases/recipes/cuisines/chinese.mdx +28 -0
- package/showcases/recipes/cuisines/italian.mdx +32 -0
- package/showcases/recipes/cuisines/mexican.mdx +28 -0
- package/showcases/recipes/models.ts +77 -0
- package/showcases/recipes/queries.ts +89 -0
- package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +43 -0
- package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +47 -0
- package/showcases/recipes/recipes/italian/bruschetta.mdx +38 -0
- package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +39 -0
- package/showcases/recipes/recipes/italian/tiramisu.mdx +43 -0
- package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +44 -0
- package/showcases/recipes/recipes/mexican/guacamole.mdx +39 -0
- package/showcases/vinyl-collection/albums/bitches-brew.mdx +36 -0
- package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +35 -0
- package/showcases/vinyl-collection/albums/in-rainbows.mdx +35 -0
- package/showcases/vinyl-collection/albums/kind-of-blue.mdx +32 -0
- package/showcases/vinyl-collection/albums/ok-computer.mdx +37 -0
- package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +35 -0
- package/showcases/vinyl-collection/artists/miles-davis.mdx +27 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +26 -0
- package/showcases/vinyl-collection/artists/radiohead.mdx +27 -0
- package/showcases/vinyl-collection/models.ts +73 -0
- package/showcases/vinyl-collection/queries.ts +87 -0
- package/src/ast-query.ts +132 -0
- package/src/cli/commands/action.ts +44 -0
- package/src/cli/commands/create.ts +59 -0
- package/src/cli/commands/export.ts +24 -0
- package/src/cli/commands/init.ts +75 -0
- package/src/cli/commands/inspect.ts +46 -0
- package/src/cli/commands/validate.ts +75 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/load-collection.ts +53 -0
- package/src/collection.ts +399 -0
- package/src/define-model.ts +80 -0
- package/src/document.ts +468 -0
- package/src/index.ts +47 -0
- package/src/model-instance.ts +227 -0
- package/src/node-shortcuts.ts +87 -0
- package/src/parse.ts +123 -0
- package/src/query/collection-query.ts +149 -0
- package/src/query/index.ts +5 -0
- package/src/query/operators.ts +37 -0
- package/src/query/query-builder.ts +109 -0
- package/src/relationships/belongs-to.ts +50 -0
- package/src/relationships/has-many.ts +136 -0
- package/src/relationships/index.ts +57 -0
- package/src/relationships/types.ts +7 -0
- package/src/section.ts +29 -0
- package/src/types.ts +221 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/inflect.ts +82 -0
- package/src/utils/normalize-headings.ts +31 -0
- package/src/utils/parse-table.ts +30 -0
- package/src/utils/read-directory.ts +35 -0
- package/src/utils/stringify-ast.ts +9 -0
- package/src/validator.ts +52 -0
- package/test/ast-query.test.ts +128 -0
- package/test/collection.test.ts +99 -0
- package/test/define-model.test.ts +78 -0
- package/test/document.test.ts +225 -0
- package/test/fixtures/sdlc/epics/authentication.mdx +42 -0
- package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +21 -0
- package/test/fixtures/sdlc/models.ts +89 -0
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +20 -0
- package/test/helpers.ts +21 -0
- package/test/model-instance.test.ts +197 -0
- package/test/query.test.ts +167 -0
- package/test/relationships.test.ts +84 -0
- package/test/section.test.ts +99 -0
- package/test/validator.test.ts +62 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
ModelDefinition,
|
|
4
|
+
InferModelInstance,
|
|
5
|
+
SectionDefinition,
|
|
6
|
+
HasManyDefinition,
|
|
7
|
+
BelongsToDefinition,
|
|
8
|
+
ValidationResult,
|
|
9
|
+
SerializeOptions,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import type { Document } from "./document";
|
|
12
|
+
import type { Collection } from "./collection";
|
|
13
|
+
import { HasManyRelationship } from "./relationships/has-many";
|
|
14
|
+
import { BelongsToRelationship } from "./relationships/belongs-to";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a model instance from a document and its model definition.
|
|
18
|
+
*
|
|
19
|
+
* This is the central factory function. Every typed model instance
|
|
20
|
+
* in contentbase is created here.
|
|
21
|
+
*/
|
|
22
|
+
export function createModelInstance<
|
|
23
|
+
TDef extends ModelDefinition<any, any, any, any, any>,
|
|
24
|
+
>(
|
|
25
|
+
document: Document,
|
|
26
|
+
definition: TDef,
|
|
27
|
+
collection: Collection
|
|
28
|
+
): InferModelInstance<TDef> {
|
|
29
|
+
// ─── Meta: merge defaults, parse with Zod ───
|
|
30
|
+
const rawMeta = { ...(definition.defaults ?? {}), ...document.meta };
|
|
31
|
+
let meta: any;
|
|
32
|
+
try {
|
|
33
|
+
meta = definition.meta.parse(rawMeta);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// If parsing fails, use raw meta so the instance can still be created
|
|
36
|
+
// Validation will catch the errors later
|
|
37
|
+
meta = rawMeta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Sections: lazy extraction via defineProperty ───
|
|
41
|
+
const sections = {} as Record<string, unknown>;
|
|
42
|
+
if (definition.sections) {
|
|
43
|
+
for (const [key, sectionDef] of Object.entries(definition.sections)) {
|
|
44
|
+
const sd = sectionDef as SectionDefinition<unknown>;
|
|
45
|
+
let cached: { value: unknown } | null = null;
|
|
46
|
+
Object.defineProperty(sections, key, {
|
|
47
|
+
get() {
|
|
48
|
+
if (!cached) {
|
|
49
|
+
const sectionQuery = document.querySection(sd.heading);
|
|
50
|
+
cached = { value: sd.extract(sectionQuery) };
|
|
51
|
+
}
|
|
52
|
+
return cached.value;
|
|
53
|
+
},
|
|
54
|
+
enumerable: true,
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Relationships: create accessor objects ───
|
|
61
|
+
const relationships = {} as Record<string, unknown>;
|
|
62
|
+
if (definition.relationships) {
|
|
63
|
+
for (const [key, relDef] of Object.entries(definition.relationships)) {
|
|
64
|
+
if ((relDef as any).type === "hasMany") {
|
|
65
|
+
const hm = relDef as HasManyDefinition<any>;
|
|
66
|
+
relationships[key] = new HasManyRelationship(
|
|
67
|
+
document,
|
|
68
|
+
collection,
|
|
69
|
+
hm,
|
|
70
|
+
createModelInstance
|
|
71
|
+
);
|
|
72
|
+
} else if ((relDef as any).type === "belongsTo") {
|
|
73
|
+
const bt = relDef as BelongsToDefinition<any>;
|
|
74
|
+
relationships[key] = new BelongsToRelationship(
|
|
75
|
+
document,
|
|
76
|
+
collection,
|
|
77
|
+
bt,
|
|
78
|
+
createModelInstance
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Computed: lazy getters ───
|
|
85
|
+
const computed = {} as Record<string, unknown>;
|
|
86
|
+
const selfProxy = {
|
|
87
|
+
meta,
|
|
88
|
+
sections,
|
|
89
|
+
relationships,
|
|
90
|
+
document,
|
|
91
|
+
id: document.id,
|
|
92
|
+
title: document.title,
|
|
93
|
+
slug: document.slug,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (definition.computed) {
|
|
97
|
+
for (const [key, fn] of Object.entries(definition.computed)) {
|
|
98
|
+
Object.defineProperty(computed, key, {
|
|
99
|
+
get() {
|
|
100
|
+
return (fn as Function)(selfProxy);
|
|
101
|
+
},
|
|
102
|
+
enumerable: true,
|
|
103
|
+
configurable: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Validation ───
|
|
109
|
+
const errors = new Map<string, z.ZodIssue>();
|
|
110
|
+
|
|
111
|
+
async function validate(): Promise<ValidationResult> {
|
|
112
|
+
errors.clear();
|
|
113
|
+
|
|
114
|
+
// Validate meta
|
|
115
|
+
const metaResult = definition.meta.safeParse(rawMeta);
|
|
116
|
+
if (!metaResult.success) {
|
|
117
|
+
for (const issue of metaResult.error.issues) {
|
|
118
|
+
errors.set(issue.path.join(".") || "meta", issue);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Validate sections that have schemas
|
|
123
|
+
if (definition.sections) {
|
|
124
|
+
for (const [key, sd] of Object.entries(definition.sections)) {
|
|
125
|
+
const sectionDef = sd as SectionDefinition<unknown>;
|
|
126
|
+
if (sectionDef.schema) {
|
|
127
|
+
const sectionData = (sections as any)[key];
|
|
128
|
+
const sResult = sectionDef.schema.safeParse(sectionData);
|
|
129
|
+
if (!sResult.success) {
|
|
130
|
+
for (const issue of sResult.error.issues) {
|
|
131
|
+
errors.set(
|
|
132
|
+
`sections.${key}.${issue.path.join(".")}`,
|
|
133
|
+
issue
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
valid: errors.size === 0,
|
|
143
|
+
errors: Array.from(errors.values()),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── toJSON ───
|
|
148
|
+
function toJSON(
|
|
149
|
+
options: SerializeOptions = {}
|
|
150
|
+
): Record<string, unknown> {
|
|
151
|
+
const json: Record<string, unknown> = {
|
|
152
|
+
id: document.id,
|
|
153
|
+
title: document.title,
|
|
154
|
+
meta,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Include requested sections
|
|
158
|
+
if (options.sections) {
|
|
159
|
+
for (const key of options.sections) {
|
|
160
|
+
if (key in sections) {
|
|
161
|
+
json[key] = (sections as any)[key];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Include requested computed values
|
|
167
|
+
if (options.computed) {
|
|
168
|
+
for (const key of options.computed) {
|
|
169
|
+
if (key in computed) {
|
|
170
|
+
json[key] = (computed as any)[key];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Include requested relationships
|
|
176
|
+
if (options.related) {
|
|
177
|
+
for (const key of options.related) {
|
|
178
|
+
const rel = (relationships as any)[key];
|
|
179
|
+
if (!rel) continue;
|
|
180
|
+
|
|
181
|
+
if ("fetchAll" in rel) {
|
|
182
|
+
json[key] = rel.fetchAll().map((inst: any) => inst.toJSON());
|
|
183
|
+
} else if ("fetch" in rel) {
|
|
184
|
+
json[key] = rel.fetch().toJSON();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return json;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Assemble the instance ───
|
|
193
|
+
const instance = {
|
|
194
|
+
id: document.id,
|
|
195
|
+
get title() {
|
|
196
|
+
return document.title;
|
|
197
|
+
},
|
|
198
|
+
get slug() {
|
|
199
|
+
return document.slug;
|
|
200
|
+
},
|
|
201
|
+
document,
|
|
202
|
+
collection,
|
|
203
|
+
meta,
|
|
204
|
+
sections,
|
|
205
|
+
relationships,
|
|
206
|
+
computed,
|
|
207
|
+
errors,
|
|
208
|
+
get hasErrors() {
|
|
209
|
+
return errors.size > 0;
|
|
210
|
+
},
|
|
211
|
+
validate,
|
|
212
|
+
toJSON,
|
|
213
|
+
async runAction(
|
|
214
|
+
name: string,
|
|
215
|
+
opts: Record<string, unknown> = {}
|
|
216
|
+
) {
|
|
217
|
+
const actionFn = collection.actions.get(name);
|
|
218
|
+
if (!actionFn) throw new Error(`Action "${name}" not found`);
|
|
219
|
+
return actionFn(collection, instance, opts);
|
|
220
|
+
},
|
|
221
|
+
async save(opts = {}) {
|
|
222
|
+
await document.save(opts);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return instance as InferModelInstance<TDef>;
|
|
227
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseTable } from "./utils/parse-table";
|
|
2
|
+
import type { AstQuery } from "./ast-query";
|
|
3
|
+
import type { Content, Heading } from "mdast";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convenience getters for common node queries on a document.
|
|
7
|
+
*/
|
|
8
|
+
export class NodeShortcuts {
|
|
9
|
+
#astQuery: AstQuery;
|
|
10
|
+
|
|
11
|
+
constructor(astQuery: AstQuery) {
|
|
12
|
+
this.#astQuery = astQuery;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get first(): Content | undefined {
|
|
16
|
+
return this.#astQuery.ast.children[0] as Content | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get last(): Content | undefined {
|
|
20
|
+
const children = this.#astQuery.ast.children;
|
|
21
|
+
return children[children.length - 1] as Content | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get headings(): Heading[] {
|
|
25
|
+
return this.#astQuery.selectAll("heading") as Heading[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get firstHeading(): Heading | undefined {
|
|
29
|
+
return this.headings[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get secondHeading(): Heading | undefined {
|
|
33
|
+
return this.headings[1];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get lastHeading(): Heading | undefined {
|
|
37
|
+
return this.headings[this.headings.length - 1];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get leadingElementsAfterTitle(): Content[] {
|
|
41
|
+
const { firstHeading, secondHeading } = this;
|
|
42
|
+
if (!firstHeading) return [];
|
|
43
|
+
if (secondHeading) {
|
|
44
|
+
return this.#astQuery.findBetween(firstHeading, secondHeading);
|
|
45
|
+
}
|
|
46
|
+
return this.#astQuery.findAllAfter(firstHeading);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get headingsByDepth(): Record<number, Heading[]> {
|
|
50
|
+
return this.headings.reduce(
|
|
51
|
+
(memo, heading) => {
|
|
52
|
+
memo[heading.depth] = memo[heading.depth] || [];
|
|
53
|
+
memo[heading.depth].push(heading);
|
|
54
|
+
return memo;
|
|
55
|
+
},
|
|
56
|
+
{} as Record<number, Heading[]>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get links(): Content[] {
|
|
61
|
+
return this.#astQuery.selectAll("link");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get tables(): Content[] {
|
|
65
|
+
return this.#astQuery.selectAll("table");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get tablesAsData(): Record<string, string>[][] {
|
|
69
|
+
return this.tables.map((table) => parseTable(table));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get paragraphs(): Content[] {
|
|
73
|
+
return this.#astQuery.selectAll("paragraph");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get lists(): Content[] {
|
|
77
|
+
return this.#astQuery.selectAll("list");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get codeBlocks(): Content[] {
|
|
81
|
+
return this.#astQuery.selectAll("code");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get images(): Content[] {
|
|
85
|
+
return this.#astQuery.selectAll("image");
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
import { unified } from "unified";
|
|
4
|
+
import remarkParse from "remark-parse";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import { toString } from "mdast-util-to-string";
|
|
7
|
+
import { AstQuery } from "./ast-query";
|
|
8
|
+
import { NodeShortcuts } from "./node-shortcuts";
|
|
9
|
+
import { stringifyAst } from "./utils/stringify-ast";
|
|
10
|
+
import type { Root, Content, RootContent } from "mdast";
|
|
11
|
+
|
|
12
|
+
const processor = unified().use(remarkParse).use(remarkGfm);
|
|
13
|
+
|
|
14
|
+
export interface ParsedDocument {
|
|
15
|
+
/** YAML frontmatter key/values */
|
|
16
|
+
meta: Record<string, unknown>;
|
|
17
|
+
/** Markdown content (without frontmatter) */
|
|
18
|
+
content: string;
|
|
19
|
+
/** The MDAST root node */
|
|
20
|
+
ast: Root;
|
|
21
|
+
/** Queryable AST wrapper */
|
|
22
|
+
astQuery: AstQuery;
|
|
23
|
+
/** Convenience node accessors */
|
|
24
|
+
nodes: NodeShortcuts;
|
|
25
|
+
/** First heading text, or empty string */
|
|
26
|
+
title: string;
|
|
27
|
+
/** Stringify an AST back to markdown */
|
|
28
|
+
stringify(ast?: Root): string;
|
|
29
|
+
/** Extract a section by heading text */
|
|
30
|
+
extractSection(heading: string | Content): Content[];
|
|
31
|
+
/** Get a queryable AstQuery scoped to a section */
|
|
32
|
+
querySection(heading: string | Content): AstQuery;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a markdown/MDX file or raw string into a queryable document.
|
|
37
|
+
*
|
|
38
|
+
* @param input - A file path (.md/.mdx) or raw markdown string
|
|
39
|
+
* @returns A ParsedDocument with AST query capabilities
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { parse } from "contentbase";
|
|
44
|
+
*
|
|
45
|
+
* const doc = await parse("./content/my-post.mdx");
|
|
46
|
+
* doc.meta // frontmatter
|
|
47
|
+
* doc.astQuery.selectAll("heading")
|
|
48
|
+
* doc.nodes.tables
|
|
49
|
+
* doc.querySection("Introduction").selectAll("paragraph")
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export async function parse(input: string): Promise<ParsedDocument> {
|
|
53
|
+
let raw: string;
|
|
54
|
+
|
|
55
|
+
if (looksLikeFilePath(input)) {
|
|
56
|
+
raw = await fs.readFile(input, "utf-8");
|
|
57
|
+
} else {
|
|
58
|
+
raw = input;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { data: meta, content } = matter(raw);
|
|
62
|
+
const ast = processor.parse(content);
|
|
63
|
+
const astQuery = new AstQuery(ast);
|
|
64
|
+
const nodes = new NodeShortcuts(astQuery);
|
|
65
|
+
|
|
66
|
+
const firstHeading = astQuery.select("heading");
|
|
67
|
+
const title = firstHeading ? toString(firstHeading) : "";
|
|
68
|
+
|
|
69
|
+
function extractSection(startHeading: string | Content): Content[] {
|
|
70
|
+
let heading: Content | undefined;
|
|
71
|
+
if (typeof startHeading === "string") {
|
|
72
|
+
heading = astQuery.findHeadingByText(startHeading) as Content | undefined;
|
|
73
|
+
} else {
|
|
74
|
+
heading = startHeading;
|
|
75
|
+
}
|
|
76
|
+
if (!heading) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Heading not found: ${typeof startHeading === "string" ? startHeading : toString(startHeading)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const endHeading = astQuery.findNextSiblingHeadingTo(heading as any);
|
|
83
|
+
const sectionNodes = endHeading
|
|
84
|
+
? astQuery.findBetween(heading, endHeading)
|
|
85
|
+
: astQuery.findAllAfter(heading);
|
|
86
|
+
return [heading, ...sectionNodes];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function querySection(startHeading: string | Content): AstQuery {
|
|
90
|
+
let children: Content[] = [];
|
|
91
|
+
try {
|
|
92
|
+
children = extractSection(startHeading).slice(1);
|
|
93
|
+
} catch {
|
|
94
|
+
// Section not found: return empty query
|
|
95
|
+
}
|
|
96
|
+
return new AstQuery({
|
|
97
|
+
type: "root",
|
|
98
|
+
children: children as RootContent[],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
meta,
|
|
104
|
+
content,
|
|
105
|
+
ast,
|
|
106
|
+
astQuery,
|
|
107
|
+
nodes,
|
|
108
|
+
title,
|
|
109
|
+
stringify: (tree: Root = ast) => stringifyAst(tree),
|
|
110
|
+
extractSection,
|
|
111
|
+
querySection,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function looksLikeFilePath(input: string): boolean {
|
|
116
|
+
// If it contains a newline, it's raw markdown
|
|
117
|
+
if (input.includes("\n")) return false;
|
|
118
|
+
// If it ends with a markdown extension, it's a path
|
|
119
|
+
if (/\.mdx?$/i.test(input)) return true;
|
|
120
|
+
// If it starts with . or / or ~, treat as path
|
|
121
|
+
if (/^[.\/~]/.test(input)) return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { QueryBuilder } from "./query-builder";
|
|
2
|
+
import { operators } from "./operators";
|
|
3
|
+
import { createModelInstance } from "../model-instance";
|
|
4
|
+
import type { Collection } from "../collection";
|
|
5
|
+
import type { ModelDefinition, InferModelInstance } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CollectionQuery is a typed query builder for a specific model type.
|
|
9
|
+
* Results are typed as InferModelInstance<TDef>[].
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const results = await collection
|
|
13
|
+
* .query(Epic)
|
|
14
|
+
* .where("meta.priority", "high")
|
|
15
|
+
* .fetchAll();
|
|
16
|
+
*/
|
|
17
|
+
export class CollectionQuery<
|
|
18
|
+
TDef extends ModelDefinition<any, any, any, any, any>,
|
|
19
|
+
> {
|
|
20
|
+
#collection: Collection;
|
|
21
|
+
#definition: TDef;
|
|
22
|
+
#queryBuilder: QueryBuilder;
|
|
23
|
+
|
|
24
|
+
constructor(collection: Collection, definition: TDef) {
|
|
25
|
+
this.#collection = collection;
|
|
26
|
+
this.#definition = definition;
|
|
27
|
+
this.#queryBuilder = new QueryBuilder();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
where(
|
|
31
|
+
pathOrObject: string | Record<string, unknown>,
|
|
32
|
+
operatorOrValue?: any,
|
|
33
|
+
value?: any
|
|
34
|
+
): this {
|
|
35
|
+
this.#queryBuilder.where(pathOrObject, operatorOrValue, value);
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
whereIn(path: string, values: unknown[]): this {
|
|
40
|
+
this.#queryBuilder.whereIn(path, values);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
whereNotIn(path: string, values: unknown[]): this {
|
|
45
|
+
this.#queryBuilder.whereNotIn(path, values);
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
whereGt(path: string, value: unknown): this {
|
|
50
|
+
this.#queryBuilder.whereGt(path, value);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
whereLt(path: string, value: unknown): this {
|
|
55
|
+
this.#queryBuilder.whereLt(path, value);
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
whereGte(path: string, value: unknown): this {
|
|
60
|
+
this.#queryBuilder.whereGte(path, value);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
whereLte(path: string, value: unknown): this {
|
|
65
|
+
this.#queryBuilder.whereLte(path, value);
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
whereContains(path: string, value: string): this {
|
|
70
|
+
this.#queryBuilder.whereContains(path, value);
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
whereStartsWith(path: string, value: string): this {
|
|
75
|
+
this.#queryBuilder.whereStartsWith(path, value);
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
whereEndsWith(path: string, value: string): this {
|
|
80
|
+
this.#queryBuilder.whereEndsWith(path, value);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
whereRegex(path: string, pattern: RegExp | string): this {
|
|
85
|
+
this.#queryBuilder.whereRegex(path, pattern);
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
whereExists(path: string): this {
|
|
90
|
+
this.#queryBuilder.whereExists(path);
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
whereNotExists(path: string): this {
|
|
95
|
+
this.#queryBuilder.whereNotExists(path);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async fetchAll(): Promise<InferModelInstance<TDef>[]> {
|
|
100
|
+
const collection = this.#collection;
|
|
101
|
+
if (!collection.loaded) await collection.load();
|
|
102
|
+
|
|
103
|
+
const definition = this.#definition;
|
|
104
|
+
const conditions = this.#queryBuilder.conditions;
|
|
105
|
+
const results: InferModelInstance<TDef>[] = [];
|
|
106
|
+
|
|
107
|
+
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;
|
|
115
|
+
|
|
116
|
+
const doc = collection.document(pathId);
|
|
117
|
+
const instance = createModelInstance(doc, definition, collection);
|
|
118
|
+
|
|
119
|
+
// Apply query conditions
|
|
120
|
+
const passesAll = conditions.every((cond) => {
|
|
121
|
+
const actual = getNestedValue(instance, cond.path);
|
|
122
|
+
return operators[cond.operator](actual, cond.value);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (passesAll) {
|
|
126
|
+
results.push(instance);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async first(): Promise<InferModelInstance<TDef> | undefined> {
|
|
134
|
+
return (await this.fetchAll())[0];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async last(): Promise<InferModelInstance<TDef> | undefined> {
|
|
138
|
+
const all = await this.fetchAll();
|
|
139
|
+
return all[all.length - 1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async count(): Promise<number> {
|
|
143
|
+
return (await this.fetchAll()).length;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getNestedValue(obj: any, path: string): unknown {
|
|
148
|
+
return path.split(".").reduce((acc, key) => acc?.[key], obj);
|
|
149
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type Operator =
|
|
2
|
+
| "eq"
|
|
3
|
+
| "neq"
|
|
4
|
+
| "in"
|
|
5
|
+
| "notIn"
|
|
6
|
+
| "gt"
|
|
7
|
+
| "lt"
|
|
8
|
+
| "gte"
|
|
9
|
+
| "lte"
|
|
10
|
+
| "contains"
|
|
11
|
+
| "startsWith"
|
|
12
|
+
| "endsWith"
|
|
13
|
+
| "regex"
|
|
14
|
+
| "exists";
|
|
15
|
+
|
|
16
|
+
export const operators: Record<
|
|
17
|
+
Operator,
|
|
18
|
+
(actual: any, expected: any) => boolean
|
|
19
|
+
> = {
|
|
20
|
+
eq: (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
|
21
|
+
neq: (a, b) => !operators.eq(a, b),
|
|
22
|
+
in: (a, b) => Array.isArray(b) && b.includes(a),
|
|
23
|
+
notIn: (a, b) => Array.isArray(b) && !b.includes(a),
|
|
24
|
+
gt: (a, b) => a > b,
|
|
25
|
+
lt: (a, b) => a < b,
|
|
26
|
+
gte: (a, b) => a >= b,
|
|
27
|
+
lte: (a, b) => a <= b,
|
|
28
|
+
contains: (a, b) => typeof a === "string" && a.includes(b),
|
|
29
|
+
startsWith: (a, b) => typeof a === "string" && a.startsWith(b),
|
|
30
|
+
endsWith: (a, b) => typeof a === "string" && a.endsWith(b),
|
|
31
|
+
regex: (a, b) =>
|
|
32
|
+
b instanceof RegExp
|
|
33
|
+
? b.test(String(a))
|
|
34
|
+
: new RegExp(b).test(String(a)),
|
|
35
|
+
exists: (a, b) =>
|
|
36
|
+
b ? a !== undefined && a !== null : a === undefined || a === null,
|
|
37
|
+
};
|