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,109 @@
|
|
|
1
|
+
import type { Operator } from "./operators";
|
|
2
|
+
|
|
3
|
+
export interface Condition {
|
|
4
|
+
path: string;
|
|
5
|
+
operator: Operator;
|
|
6
|
+
value: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class QueryBuilder {
|
|
10
|
+
#conditions: Condition[] = [];
|
|
11
|
+
|
|
12
|
+
get conditions(): Condition[] {
|
|
13
|
+
return [...this.#conditions];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Add a where condition.
|
|
18
|
+
*
|
|
19
|
+
* Three call signatures:
|
|
20
|
+
* where({ "meta.status": "active" }) -- object shorthand, implicit eq
|
|
21
|
+
* where("meta.status", "active") -- two args, implicit eq
|
|
22
|
+
* where("meta.priority", "gt", 5) -- three args, explicit operator
|
|
23
|
+
*
|
|
24
|
+
* Always returns `this` for chaining (fixes bug in original).
|
|
25
|
+
*/
|
|
26
|
+
where(
|
|
27
|
+
pathOrObject: string | Record<string, unknown>,
|
|
28
|
+
operatorOrValue?: Operator | unknown,
|
|
29
|
+
value?: unknown
|
|
30
|
+
): this {
|
|
31
|
+
if (typeof pathOrObject === "object" && pathOrObject !== null) {
|
|
32
|
+
for (const [k, v] of Object.entries(pathOrObject)) {
|
|
33
|
+
this.#conditions.push({ path: k, operator: "eq", value: v });
|
|
34
|
+
}
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
// Two-arg form: where("path", value) -- implicit eq
|
|
40
|
+
this.#conditions.push({
|
|
41
|
+
path: pathOrObject,
|
|
42
|
+
operator: "eq",
|
|
43
|
+
value: operatorOrValue,
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
// Three-arg form: where("path", operator, value)
|
|
47
|
+
this.#conditions.push({
|
|
48
|
+
path: pathOrObject,
|
|
49
|
+
operator: operatorOrValue as Operator,
|
|
50
|
+
value,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
whereIn(path: string, values: unknown[]): this {
|
|
57
|
+
this.#conditions.push({
|
|
58
|
+
path,
|
|
59
|
+
operator: "in",
|
|
60
|
+
value: values.filter(Boolean),
|
|
61
|
+
});
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
whereNotIn(path: string, values: unknown[]): this {
|
|
66
|
+
this.#conditions.push({ path, operator: "notIn", value: values });
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
whereGt(path: string, value: unknown): this {
|
|
71
|
+
return this.where(path, "gt", value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
whereLt(path: string, value: unknown): this {
|
|
75
|
+
return this.where(path, "lt", value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
whereGte(path: string, value: unknown): this {
|
|
79
|
+
return this.where(path, "gte", value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
whereLte(path: string, value: unknown): this {
|
|
83
|
+
return this.where(path, "lte", value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
whereContains(path: string, value: string): this {
|
|
87
|
+
return this.where(path, "contains", value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
whereStartsWith(path: string, value: string): this {
|
|
91
|
+
return this.where(path, "startsWith", value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
whereEndsWith(path: string, value: string): this {
|
|
95
|
+
return this.where(path, "endsWith", value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
whereRegex(path: string, pattern: RegExp | string): this {
|
|
99
|
+
return this.where(path, "regex", pattern);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
whereExists(path: string): this {
|
|
103
|
+
return this.where(path, "exists", true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
whereNotExists(path: string): this {
|
|
107
|
+
return this.where(path, "exists", false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Document } from "../document";
|
|
2
|
+
import type { Collection } from "../collection";
|
|
3
|
+
import type {
|
|
4
|
+
BelongsToDefinition,
|
|
5
|
+
ModelDefinition,
|
|
6
|
+
InferModelInstance,
|
|
7
|
+
BelongsToAccessor,
|
|
8
|
+
ModelInstanceFactory,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
export class BelongsToRelationship<
|
|
12
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
13
|
+
> implements BelongsToAccessor<TTarget>
|
|
14
|
+
{
|
|
15
|
+
#document: Document;
|
|
16
|
+
#collection: Collection;
|
|
17
|
+
#definition: BelongsToDefinition<TTarget>;
|
|
18
|
+
#factory: ModelInstanceFactory;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
document: Document,
|
|
22
|
+
collection: Collection,
|
|
23
|
+
definition: BelongsToDefinition<TTarget>,
|
|
24
|
+
factory: ModelInstanceFactory
|
|
25
|
+
) {
|
|
26
|
+
this.#document = document;
|
|
27
|
+
this.#collection = collection;
|
|
28
|
+
this.#definition = definition;
|
|
29
|
+
this.#factory = factory;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fetch(): InferModelInstance<TTarget> {
|
|
33
|
+
const targetDef = this.#definition.target();
|
|
34
|
+
const foreignKeyValue = this.#definition.foreignKey({
|
|
35
|
+
id: this.#document.id,
|
|
36
|
+
meta: this.#document.meta,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const relatedId = `${targetDef.prefix}/${foreignKeyValue}`;
|
|
40
|
+
|
|
41
|
+
if (!this.#collection.items.has(relatedId)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Could not find ${targetDef.name} with id "${relatedId}"`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const doc = this.#collection.document(relatedId);
|
|
48
|
+
return this.#factory(doc, targetDef, this.#collection);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { toString } from "mdast-util-to-string";
|
|
2
|
+
import { kebabCase } from "../utils/inflect";
|
|
3
|
+
import type { Document } from "../document";
|
|
4
|
+
import type { Collection } from "../collection";
|
|
5
|
+
import type {
|
|
6
|
+
HasManyDefinition,
|
|
7
|
+
ModelDefinition,
|
|
8
|
+
InferModelInstance,
|
|
9
|
+
HasManyAccessor,
|
|
10
|
+
ModelInstanceFactory,
|
|
11
|
+
} from "../types";
|
|
12
|
+
import type { Root, Heading, Content, RootContent } from "mdast";
|
|
13
|
+
|
|
14
|
+
interface ChildNode {
|
|
15
|
+
title: string;
|
|
16
|
+
id: string;
|
|
17
|
+
ast: Root;
|
|
18
|
+
section: Content[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class HasManyRelationship<
|
|
22
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
23
|
+
> implements HasManyAccessor<TTarget>
|
|
24
|
+
{
|
|
25
|
+
#document: Document;
|
|
26
|
+
#collection: Collection;
|
|
27
|
+
#definition: HasManyDefinition<TTarget>;
|
|
28
|
+
#factory: ModelInstanceFactory;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
document: Document,
|
|
32
|
+
collection: Collection,
|
|
33
|
+
definition: HasManyDefinition<TTarget>,
|
|
34
|
+
factory: ModelInstanceFactory
|
|
35
|
+
) {
|
|
36
|
+
this.#document = document;
|
|
37
|
+
this.#collection = collection;
|
|
38
|
+
this.#definition = definition;
|
|
39
|
+
this.#factory = factory;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract child nodes from the document AST.
|
|
44
|
+
*
|
|
45
|
+
* Algorithm (matching the original):
|
|
46
|
+
* 1. Find the parent heading by text (e.g., "Stories")
|
|
47
|
+
* 2. Extract the section under that heading
|
|
48
|
+
* 3. Filter for child headings at depth = parentHeading.depth + 1
|
|
49
|
+
* 4. For each child heading, extract its sub-section
|
|
50
|
+
* 5. Compute an ID from the parent title and child slug
|
|
51
|
+
*/
|
|
52
|
+
private extractChildNodes(): ChildNode[] {
|
|
53
|
+
const { astQuery } = this.#document;
|
|
54
|
+
const parentHeading = astQuery.findHeadingByText(this.#definition.heading);
|
|
55
|
+
if (!parentHeading) return [];
|
|
56
|
+
|
|
57
|
+
const sectionNodes = this.#document
|
|
58
|
+
.extractSection(parentHeading as Content)
|
|
59
|
+
.slice(1);
|
|
60
|
+
|
|
61
|
+
const childDepth = (parentHeading as Heading).depth + 1;
|
|
62
|
+
|
|
63
|
+
// Get all child headings at the expected depth
|
|
64
|
+
const childHeadings = sectionNodes.filter(
|
|
65
|
+
(n: any) => n.type === "heading" && n.depth === childDepth
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return childHeadings.map((heading: any) => {
|
|
69
|
+
// For each child heading, find its sub-section
|
|
70
|
+
// (from this heading to the next heading at the same depth, or end of parent section)
|
|
71
|
+
const headingIdx = sectionNodes.indexOf(heading);
|
|
72
|
+
const nextIdx = sectionNodes.findIndex(
|
|
73
|
+
(n: any, i: number) =>
|
|
74
|
+
i > headingIdx && n.type === "heading" && n.depth === childDepth
|
|
75
|
+
);
|
|
76
|
+
const section =
|
|
77
|
+
nextIdx === -1
|
|
78
|
+
? sectionNodes.slice(headingIdx)
|
|
79
|
+
: sectionNodes.slice(headingIdx, nextIdx);
|
|
80
|
+
|
|
81
|
+
const title = toString(heading);
|
|
82
|
+
const slug = kebabCase(title.toLowerCase());
|
|
83
|
+
const targetDef = this.#definition.target();
|
|
84
|
+
|
|
85
|
+
const id = this.#definition.id
|
|
86
|
+
? this.#definition.id(slug)
|
|
87
|
+
: `${targetDef.prefix}/${kebabCase(this.#document.title.toLowerCase())}/${slug}`;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
title,
|
|
91
|
+
id,
|
|
92
|
+
section,
|
|
93
|
+
ast: {
|
|
94
|
+
type: "root" as const,
|
|
95
|
+
children: section as RootContent[],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fetchAll(): InferModelInstance<TTarget>[] {
|
|
102
|
+
const targetDef = this.#definition.target();
|
|
103
|
+
const childNodes = this.extractChildNodes();
|
|
104
|
+
|
|
105
|
+
return childNodes.map(({ id, ast }) => {
|
|
106
|
+
// If the document already exists in the collection, use it
|
|
107
|
+
if (this.#collection.items.has(id)) {
|
|
108
|
+
const doc = this.#collection.document(id);
|
|
109
|
+
return this.#factory(doc, targetDef, this.#collection);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Otherwise create an in-memory document from the extracted AST
|
|
113
|
+
const doc = this.#collection.createDocument({
|
|
114
|
+
id,
|
|
115
|
+
meta: this.#definition.meta ? this.#definition.meta({}) : {},
|
|
116
|
+
ast: ast as Root,
|
|
117
|
+
});
|
|
118
|
+
return this.#factory(doc, targetDef, this.#collection);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
first(): InferModelInstance<TTarget> | undefined {
|
|
123
|
+
return this.fetchAll()[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
last(): InferModelInstance<TTarget> | undefined {
|
|
127
|
+
const all = this.fetchAll();
|
|
128
|
+
return all[all.length - 1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async create(): Promise<InferModelInstance<TTarget>[]> {
|
|
132
|
+
const models = this.fetchAll();
|
|
133
|
+
await Promise.all(models.map((m: any) => m.save()));
|
|
134
|
+
return models;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HasManyDefinition,
|
|
3
|
+
BelongsToDefinition,
|
|
4
|
+
ModelDefinition,
|
|
5
|
+
DocumentRef,
|
|
6
|
+
} from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Declare a hasMany relationship.
|
|
10
|
+
* Child models are extracted from sub-headings under a parent heading.
|
|
11
|
+
*
|
|
12
|
+
* The target parameter is a thunk (() => ModelDef) to allow circular references.
|
|
13
|
+
*/
|
|
14
|
+
export function hasMany<
|
|
15
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
16
|
+
>(
|
|
17
|
+
target: () => TTarget,
|
|
18
|
+
options: {
|
|
19
|
+
heading: string;
|
|
20
|
+
meta?: (self: any) => Record<string, unknown>;
|
|
21
|
+
id?: (slug: string) => string;
|
|
22
|
+
}
|
|
23
|
+
): HasManyDefinition<TTarget> {
|
|
24
|
+
return {
|
|
25
|
+
type: "hasMany",
|
|
26
|
+
target,
|
|
27
|
+
heading: options.heading,
|
|
28
|
+
meta: options.meta,
|
|
29
|
+
id: options.id,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declare a belongsTo relationship.
|
|
35
|
+
* The foreign key function receives a DocumentRef (with id and meta)
|
|
36
|
+
* and returns the id fragment of the parent.
|
|
37
|
+
*/
|
|
38
|
+
export function belongsTo<
|
|
39
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
40
|
+
>(
|
|
41
|
+
target: () => TTarget,
|
|
42
|
+
options: {
|
|
43
|
+
foreignKey: (doc: DocumentRef) => string;
|
|
44
|
+
}
|
|
45
|
+
): BelongsToDefinition<TTarget> {
|
|
46
|
+
return {
|
|
47
|
+
type: "belongsTo",
|
|
48
|
+
target,
|
|
49
|
+
foreignKey: options.foreignKey,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type {
|
|
54
|
+
HasManyDefinition,
|
|
55
|
+
BelongsToDefinition,
|
|
56
|
+
RelationshipDefinition,
|
|
57
|
+
} from "../types";
|
package/src/section.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { AstQuery } from "./ast-query";
|
|
3
|
+
import type { SectionDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helper function to create a SectionDefinition with proper type inference.
|
|
7
|
+
* The generic T is inferred from the extract function's return type.
|
|
8
|
+
*
|
|
9
|
+
* Without this helper, TypeScript would widen the return type of extract to `unknown`.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* section("Acceptance Criteria", {
|
|
13
|
+
* extract: (query) => query.selectAll("listItem").map(toString),
|
|
14
|
+
* schema: z.array(z.string()).min(1),
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
export function section<T>(
|
|
18
|
+
heading: string,
|
|
19
|
+
options: {
|
|
20
|
+
extract: (query: AstQuery) => T;
|
|
21
|
+
schema?: z.ZodType<T>;
|
|
22
|
+
}
|
|
23
|
+
): SectionDefinition<T> {
|
|
24
|
+
return {
|
|
25
|
+
heading,
|
|
26
|
+
extract: options.extract,
|
|
27
|
+
schema: options.schema,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Root } from "mdast";
|
|
3
|
+
import type { AstQuery } from "./ast-query";
|
|
4
|
+
|
|
5
|
+
// ─── Fundamental types ───
|
|
6
|
+
|
|
7
|
+
/** The raw item stored in Collection.items before a Document is created */
|
|
8
|
+
export interface CollectionItem {
|
|
9
|
+
raw: string;
|
|
10
|
+
content: string;
|
|
11
|
+
meta: Record<string, unknown>;
|
|
12
|
+
path: string;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
updatedAt: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Options when constructing a Collection */
|
|
18
|
+
export interface CollectionOptions {
|
|
19
|
+
rootPath: string;
|
|
20
|
+
extensions?: string[];
|
|
21
|
+
name?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Section system ───
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A section definition declares how to extract structured data from
|
|
28
|
+
* a heading-based section of a document.
|
|
29
|
+
*/
|
|
30
|
+
export interface SectionDefinition<T = unknown> {
|
|
31
|
+
/** The heading text to find in the document */
|
|
32
|
+
heading: string;
|
|
33
|
+
/** Extract structured data from the section's AST query */
|
|
34
|
+
extract: (query: AstQuery) => T;
|
|
35
|
+
/** Optional Zod schema to validate the extracted value */
|
|
36
|
+
schema?: z.ZodType<T>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Relationship system ───
|
|
40
|
+
|
|
41
|
+
export interface HasManyDefinition<
|
|
42
|
+
TTarget extends ModelDefinition<any, any, any, any, any> = any,
|
|
43
|
+
> {
|
|
44
|
+
type: "hasMany";
|
|
45
|
+
target: () => TTarget;
|
|
46
|
+
heading: string;
|
|
47
|
+
meta?: (self: any) => Record<string, unknown>;
|
|
48
|
+
id?: (slug: string) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BelongsToDefinition<
|
|
52
|
+
TTarget extends ModelDefinition<any, any, any, any, any> = any,
|
|
53
|
+
> {
|
|
54
|
+
type: "belongsTo";
|
|
55
|
+
target: () => TTarget;
|
|
56
|
+
foreignKey: (doc: DocumentRef) => string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RelationshipDefinition<
|
|
60
|
+
TTarget extends ModelDefinition<any, any, any, any, any> = any,
|
|
61
|
+
> = HasManyDefinition<TTarget> | BelongsToDefinition<TTarget>;
|
|
62
|
+
|
|
63
|
+
/** A minimal document reference for relationship foreign key functions */
|
|
64
|
+
export interface DocumentRef {
|
|
65
|
+
id: string;
|
|
66
|
+
meta: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Model Definition (the config object) ───
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ModelDefinition is the static config object produced by defineModel().
|
|
73
|
+
* It carries all type parameters needed to fully type a model instance.
|
|
74
|
+
*
|
|
75
|
+
* TName - string literal type for the model name
|
|
76
|
+
* TMeta - the Zod schema type for frontmatter metadata
|
|
77
|
+
* TSections - record of section definitions
|
|
78
|
+
* TRelationships - record of relationship definitions
|
|
79
|
+
* TComputed - record of computed property functions
|
|
80
|
+
*/
|
|
81
|
+
export interface ModelDefinition<
|
|
82
|
+
TName extends string = string,
|
|
83
|
+
TMeta extends z.ZodTypeAny = z.ZodTypeAny,
|
|
84
|
+
TSections extends Record<string, SectionDefinition<any>> = Record<
|
|
85
|
+
string,
|
|
86
|
+
never
|
|
87
|
+
>,
|
|
88
|
+
TRelationships extends Record<string, RelationshipDefinition<any>> = Record<
|
|
89
|
+
string,
|
|
90
|
+
never
|
|
91
|
+
>,
|
|
92
|
+
TComputed extends Record<string, (self: any) => any> = Record<
|
|
93
|
+
string,
|
|
94
|
+
never
|
|
95
|
+
>,
|
|
96
|
+
> {
|
|
97
|
+
readonly name: TName;
|
|
98
|
+
prefix: string;
|
|
99
|
+
meta: TMeta;
|
|
100
|
+
sections: TSections;
|
|
101
|
+
relationships: TRelationships;
|
|
102
|
+
computed: TComputed;
|
|
103
|
+
match?: (doc: DocumentRef) => boolean;
|
|
104
|
+
defaults?: Partial<z.input<TMeta>>;
|
|
105
|
+
|
|
106
|
+
/** The inferred Zod schema for convenience (same as meta) */
|
|
107
|
+
schema: TMeta;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Model Instance (the runtime object) ───
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* InferModelInstance takes a ModelDefinition and produces the shape
|
|
114
|
+
* of the runtime model instance.
|
|
115
|
+
*/
|
|
116
|
+
export type InferModelInstance<
|
|
117
|
+
TDef extends ModelDefinition<any, any, any, any, any>,
|
|
118
|
+
> =
|
|
119
|
+
TDef extends ModelDefinition<
|
|
120
|
+
infer _TName,
|
|
121
|
+
infer TMeta,
|
|
122
|
+
infer TSections,
|
|
123
|
+
infer TRelationships,
|
|
124
|
+
infer TComputed
|
|
125
|
+
>
|
|
126
|
+
? {
|
|
127
|
+
// Core properties
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly title: string;
|
|
130
|
+
readonly slug: string;
|
|
131
|
+
readonly document: import("./document.js").Document;
|
|
132
|
+
readonly collection: import("./collection.js").Collection;
|
|
133
|
+
|
|
134
|
+
// Typed meta from Zod schema
|
|
135
|
+
readonly meta: z.infer<TMeta>;
|
|
136
|
+
|
|
137
|
+
// Sections: each key maps to the return type of its extract function
|
|
138
|
+
readonly sections: {
|
|
139
|
+
readonly [K in keyof TSections]: TSections[K] extends SectionDefinition<
|
|
140
|
+
infer U
|
|
141
|
+
>
|
|
142
|
+
? U
|
|
143
|
+
: never;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Relationships: each key becomes an accessor object
|
|
147
|
+
readonly relationships: {
|
|
148
|
+
[K in keyof TRelationships]: TRelationships[K] extends HasManyDefinition<
|
|
149
|
+
infer TTarget
|
|
150
|
+
>
|
|
151
|
+
? HasManyAccessor<TTarget>
|
|
152
|
+
: TRelationships[K] extends BelongsToDefinition<infer TTarget>
|
|
153
|
+
? BelongsToAccessor<TTarget>
|
|
154
|
+
: never;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Computed: each key maps to the return type of the computed function
|
|
158
|
+
readonly computed: {
|
|
159
|
+
readonly [K in keyof TComputed]: TComputed[K] extends (
|
|
160
|
+
self: any,
|
|
161
|
+
) => infer R
|
|
162
|
+
? R
|
|
163
|
+
: never;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Validation
|
|
167
|
+
validate(): Promise<ValidationResult>;
|
|
168
|
+
readonly errors: Map<string, import("zod").ZodIssue>;
|
|
169
|
+
readonly hasErrors: boolean;
|
|
170
|
+
|
|
171
|
+
// Serialization
|
|
172
|
+
toJSON(options?: SerializeOptions): Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
// Actions
|
|
175
|
+
runAction(
|
|
176
|
+
name: string,
|
|
177
|
+
options?: Record<string, unknown>,
|
|
178
|
+
): Promise<unknown>;
|
|
179
|
+
|
|
180
|
+
// Persistence
|
|
181
|
+
save(options?: SaveOptions): Promise<void>;
|
|
182
|
+
}
|
|
183
|
+
: never;
|
|
184
|
+
|
|
185
|
+
export interface HasManyAccessor<
|
|
186
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
187
|
+
> {
|
|
188
|
+
fetchAll(): InferModelInstance<TTarget>[];
|
|
189
|
+
first(): InferModelInstance<TTarget> | undefined;
|
|
190
|
+
last(): InferModelInstance<TTarget> | undefined;
|
|
191
|
+
create(): Promise<InferModelInstance<TTarget>[]>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface BelongsToAccessor<
|
|
195
|
+
TTarget extends ModelDefinition<any, any, any, any, any>,
|
|
196
|
+
> {
|
|
197
|
+
fetch(): InferModelInstance<TTarget>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ValidationResult {
|
|
201
|
+
valid: boolean;
|
|
202
|
+
errors: import("zod").ZodIssue[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface SerializeOptions {
|
|
206
|
+
related?: string[];
|
|
207
|
+
sections?: string[];
|
|
208
|
+
computed?: string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface SaveOptions {
|
|
212
|
+
normalize?: boolean;
|
|
213
|
+
extension?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Factory function type used to break circular dependency between model-instance and relationships */
|
|
217
|
+
export type ModelInstanceFactory = (
|
|
218
|
+
doc: import("./document.js").Document,
|
|
219
|
+
definition: ModelDefinition<any, any, any, any, any>,
|
|
220
|
+
collection: import("./collection.js").Collection,
|
|
221
|
+
) => any;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
pluralize,
|
|
3
|
+
singularize,
|
|
4
|
+
kebabCase,
|
|
5
|
+
camelCase,
|
|
6
|
+
upperFirst,
|
|
7
|
+
} from "./inflect";
|
|
8
|
+
export { stringifyAst } from "./stringify-ast";
|
|
9
|
+
export { parseTable } from "./parse-table";
|
|
10
|
+
export { normalizeHeadings } from "./normalize-headings";
|
|
11
|
+
export { readDirectory } from "./read-directory";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const IRREGULAR: Record<string, string> = {
|
|
2
|
+
person: "people",
|
|
3
|
+
child: "children",
|
|
4
|
+
man: "men",
|
|
5
|
+
woman: "women",
|
|
6
|
+
mouse: "mice",
|
|
7
|
+
goose: "geese",
|
|
8
|
+
ox: "oxen",
|
|
9
|
+
datum: "data",
|
|
10
|
+
index: "indices",
|
|
11
|
+
matrix: "matrices",
|
|
12
|
+
vertex: "vertices",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const UNCOUNTABLE = new Set([
|
|
16
|
+
"sheep",
|
|
17
|
+
"fish",
|
|
18
|
+
"deer",
|
|
19
|
+
"series",
|
|
20
|
+
"species",
|
|
21
|
+
"money",
|
|
22
|
+
"rice",
|
|
23
|
+
"information",
|
|
24
|
+
"equipment",
|
|
25
|
+
"media",
|
|
26
|
+
"data",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function pluralize(word: string): string {
|
|
30
|
+
const lower = word.toLowerCase();
|
|
31
|
+
|
|
32
|
+
if (UNCOUNTABLE.has(lower)) return word;
|
|
33
|
+
if (IRREGULAR[lower]) {
|
|
34
|
+
return word[0] + IRREGULAR[lower].slice(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (/s$/i.test(word)) return word;
|
|
38
|
+
if (/([^aeiou])y$/i.test(word)) return word.replace(/y$/i, "ies");
|
|
39
|
+
if (/(x|ch|ss|sh)$/i.test(word)) return word + "es";
|
|
40
|
+
|
|
41
|
+
return word + "s";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function singularize(word: string): string {
|
|
45
|
+
const lower = word.toLowerCase();
|
|
46
|
+
|
|
47
|
+
if (UNCOUNTABLE.has(lower)) return word;
|
|
48
|
+
|
|
49
|
+
const irregularEntry = Object.entries(IRREGULAR).find(
|
|
50
|
+
([, v]) => v === lower
|
|
51
|
+
);
|
|
52
|
+
if (irregularEntry) {
|
|
53
|
+
return word[0] + irregularEntry[0].slice(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
57
|
+
if (/ses$/i.test(word) || /xes$/i.test(word) || /ches$/i.test(word) || /shes$/i.test(word)) {
|
|
58
|
+
return word.slice(0, -2);
|
|
59
|
+
}
|
|
60
|
+
if (word.endsWith("s") && !word.endsWith("ss")) {
|
|
61
|
+
return word.slice(0, -1);
|
|
62
|
+
}
|
|
63
|
+
return word;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function kebabCase(str: string): string {
|
|
67
|
+
return str
|
|
68
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
69
|
+
.replace(/[\s_]+/g, "-")
|
|
70
|
+
.replace(/[^\w-]/g, "")
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function camelCase(str: string): string {
|
|
75
|
+
return str
|
|
76
|
+
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
|
|
77
|
+
.replace(/^[A-Z]/, (c) => c.toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function upperFirst(str: string): string {
|
|
81
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
82
|
+
}
|