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,46 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { loadCollection } from "../load-collection";
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "inspect",
|
|
7
|
+
description: "Display collection info and registered models",
|
|
8
|
+
},
|
|
9
|
+
args: {
|
|
10
|
+
rootPath: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Root path for the collection",
|
|
13
|
+
alias: "r",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
async run({ args }) {
|
|
17
|
+
const collection = await loadCollection({
|
|
18
|
+
rootPath: args.rootPath as string | undefined,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
console.log(`Collection: ${collection.name}`);
|
|
22
|
+
console.log(`Root: ${collection.rootPath}`);
|
|
23
|
+
console.log(`Items: ${collection.available.length}`);
|
|
24
|
+
console.log();
|
|
25
|
+
|
|
26
|
+
for (const def of collection.modelDefinitions) {
|
|
27
|
+
const matchingItems = collection.available.filter((id) =>
|
|
28
|
+
id.startsWith(def.prefix)
|
|
29
|
+
);
|
|
30
|
+
console.log(` Model: ${def.name}`);
|
|
31
|
+
console.log(` Prefix: ${def.prefix}`);
|
|
32
|
+
console.log(
|
|
33
|
+
` Sections: ${Object.keys(def.sections).join(", ") || "(none)"}`
|
|
34
|
+
);
|
|
35
|
+
console.log(
|
|
36
|
+
` Relationships: ${Object.keys(def.relationships).join(", ") || "(none)"}`
|
|
37
|
+
);
|
|
38
|
+
console.log(` Documents: ${matchingItems.length}`);
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (collection.availableActions.length > 0) {
|
|
43
|
+
console.log(`Actions: ${collection.availableActions.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { loadCollection } from "../load-collection";
|
|
3
|
+
import { validateDocument } from "../../validator";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "validate",
|
|
8
|
+
description: "Validate documents against their model schemas",
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
target: {
|
|
12
|
+
type: "positional",
|
|
13
|
+
description: "Path ID, model name, or 'all'",
|
|
14
|
+
required: false,
|
|
15
|
+
},
|
|
16
|
+
rootPath: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Root path for the collection",
|
|
19
|
+
alias: "r",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
async run({ args }) {
|
|
23
|
+
const collection = await loadCollection({
|
|
24
|
+
rootPath: args.rootPath as string | undefined,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const target = (args.target as string) || "all";
|
|
28
|
+
let pathIds: string[];
|
|
29
|
+
|
|
30
|
+
if (target === "all") {
|
|
31
|
+
pathIds = collection.available;
|
|
32
|
+
} else if (collection.items.has(target)) {
|
|
33
|
+
pathIds = [target];
|
|
34
|
+
} else {
|
|
35
|
+
// Try to match as model name
|
|
36
|
+
const def = collection.getModelDefinition(target);
|
|
37
|
+
if (def) {
|
|
38
|
+
pathIds = collection.available.filter((id) =>
|
|
39
|
+
id.startsWith(def.prefix)
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
console.error(`Not found: "${target}"`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let valid = 0;
|
|
48
|
+
let invalid = 0;
|
|
49
|
+
|
|
50
|
+
for (const pathId of pathIds) {
|
|
51
|
+
const def = collection.findModelDefinition(pathId);
|
|
52
|
+
if (!def) continue;
|
|
53
|
+
|
|
54
|
+
const doc = collection.document(pathId);
|
|
55
|
+
const result = validateDocument(doc, def);
|
|
56
|
+
|
|
57
|
+
if (result.valid) {
|
|
58
|
+
valid++;
|
|
59
|
+
} else {
|
|
60
|
+
invalid++;
|
|
61
|
+
console.log(`INVALID: ${pathId}`);
|
|
62
|
+
for (const error of result.errors) {
|
|
63
|
+
console.log(` ${error.path.join(".")}: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log();
|
|
69
|
+
console.log(
|
|
70
|
+
`Validated ${valid + invalid} documents: ${valid} valid, ${invalid} invalid`
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (invalid > 0) process.exit(1);
|
|
74
|
+
},
|
|
75
|
+
});
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
|
|
4
|
+
const main = defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "contentbase",
|
|
7
|
+
version: "0.1.0",
|
|
8
|
+
description: "An ORM for Markdown/MDX files",
|
|
9
|
+
},
|
|
10
|
+
subCommands: {
|
|
11
|
+
init: () => import("./commands/init.js").then((m) => m.default),
|
|
12
|
+
create: () => import("./commands/create.js").then((m) => m.default),
|
|
13
|
+
inspect: () => import("./commands/inspect.js").then((m) => m.default),
|
|
14
|
+
validate: () => import("./commands/validate.js").then((m) => m.default),
|
|
15
|
+
export: () => import("./commands/export.js").then((m) => m.default),
|
|
16
|
+
action: () => import("./commands/action.js").then((m) => m.default),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
runMain(main);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Collection } from "../collection";
|
|
4
|
+
|
|
5
|
+
export async function loadCollection(options: {
|
|
6
|
+
rootPath?: string;
|
|
7
|
+
modulePath?: string;
|
|
8
|
+
}): Promise<Collection> {
|
|
9
|
+
let { rootPath, modulePath } = options;
|
|
10
|
+
|
|
11
|
+
if (!rootPath) {
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
const pkgPath = path.resolve(cwd, "package.json");
|
|
14
|
+
try {
|
|
15
|
+
const manifest = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
16
|
+
if (manifest.contentbase?.rootPath) {
|
|
17
|
+
rootPath = path.resolve(cwd, manifest.contentbase.rootPath);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// No package.json found
|
|
21
|
+
}
|
|
22
|
+
rootPath = rootPath ?? cwd;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!modulePath) {
|
|
26
|
+
for (const ext of ["ts", "js", "mjs"]) {
|
|
27
|
+
const candidate = path.resolve(rootPath, `index.${ext}`);
|
|
28
|
+
try {
|
|
29
|
+
await fs.stat(candidate);
|
|
30
|
+
modulePath = candidate;
|
|
31
|
+
break;
|
|
32
|
+
} catch {
|
|
33
|
+
// Not found, try next
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (modulePath) {
|
|
39
|
+
const mod = await import(modulePath);
|
|
40
|
+
const collection = mod.collection ?? mod.default;
|
|
41
|
+
if (!(collection instanceof Collection)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"Module must export a Collection as 'collection' or default export."
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
await collection.load();
|
|
47
|
+
return collection;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const collection = new Collection({ rootPath });
|
|
51
|
+
await collection.load();
|
|
52
|
+
return collection;
|
|
53
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { Document } from "./document";
|
|
5
|
+
import { CollectionQuery } from "./query/collection-query";
|
|
6
|
+
import { createModelInstance } from "./model-instance";
|
|
7
|
+
import { readDirectory } from "./utils/read-directory";
|
|
8
|
+
import type {
|
|
9
|
+
ModelDefinition,
|
|
10
|
+
CollectionItem,
|
|
11
|
+
CollectionOptions,
|
|
12
|
+
InferModelInstance,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
export class Collection {
|
|
16
|
+
readonly rootPath: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly extensions: string[];
|
|
19
|
+
|
|
20
|
+
#items: Map<string, CollectionItem> = new Map();
|
|
21
|
+
#documents: Map<string, Document> = new Map();
|
|
22
|
+
#models: Map<string, ModelDefinition<any, any, any, any, any>> = new Map();
|
|
23
|
+
#actions: Map<string, (collection: Collection, ...args: any[]) => any> =
|
|
24
|
+
new Map();
|
|
25
|
+
#loaded = false;
|
|
26
|
+
|
|
27
|
+
constructor(options: CollectionOptions) {
|
|
28
|
+
this.rootPath = path.resolve(options.rootPath);
|
|
29
|
+
this.name = options.name ?? options.rootPath;
|
|
30
|
+
this.extensions = options.extensions ?? ["mdx", "md"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Model registration ───
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register a model definition with this collection.
|
|
37
|
+
* Accepts the output of defineModel().
|
|
38
|
+
*/
|
|
39
|
+
register<TDef extends ModelDefinition<any, any, any, any, any>>(
|
|
40
|
+
definition: TDef
|
|
41
|
+
): this {
|
|
42
|
+
this.#models.set(definition.name, definition);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get a model definition by name */
|
|
47
|
+
getModelDefinition(
|
|
48
|
+
name: string
|
|
49
|
+
): ModelDefinition<any, any, any, any, any> | undefined {
|
|
50
|
+
return this.#models.get(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get modelDefinitions(): ModelDefinition<any, any, any, any, any>[] {
|
|
54
|
+
return Array.from(this.#models.values());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Loading ───
|
|
58
|
+
|
|
59
|
+
get loaded(): boolean {
|
|
60
|
+
return this.#loaded;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get items(): Map<string, CollectionItem> {
|
|
64
|
+
return this.#items;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get documents(): Map<string, Document> {
|
|
68
|
+
return this.#documents;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get available(): string[] {
|
|
72
|
+
return Array.from(this.#items.keys());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Recursively load all markdown files from rootPath.
|
|
77
|
+
* Parses frontmatter with gray-matter, stores content and metadata.
|
|
78
|
+
*/
|
|
79
|
+
async load(options: { refresh?: boolean } = {}): Promise<this> {
|
|
80
|
+
const refresh = options.refresh ?? false;
|
|
81
|
+
|
|
82
|
+
if (this.#loaded && !refresh) {
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.#loaded && refresh) {
|
|
87
|
+
this.#items.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const extensionPattern = new RegExp(
|
|
91
|
+
`\\.(${this.extensions.join("|")})$`,
|
|
92
|
+
"i"
|
|
93
|
+
);
|
|
94
|
+
const paths = await readDirectory(this.rootPath, extensionPattern);
|
|
95
|
+
|
|
96
|
+
await Promise.all(
|
|
97
|
+
paths.map(async (filePath) => {
|
|
98
|
+
const pathId = this.getPathId(filePath);
|
|
99
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
100
|
+
const stat = await fs.stat(filePath);
|
|
101
|
+
const { data, content } = matter(raw);
|
|
102
|
+
|
|
103
|
+
this.#items.set(pathId, {
|
|
104
|
+
raw,
|
|
105
|
+
content,
|
|
106
|
+
meta: data,
|
|
107
|
+
path: filePath,
|
|
108
|
+
createdAt: stat.ctime,
|
|
109
|
+
updatedAt: stat.mtime,
|
|
110
|
+
});
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Refresh any already-created documents
|
|
115
|
+
if (this.#loaded && refresh) {
|
|
116
|
+
await Promise.all(
|
|
117
|
+
Array.from(this.#documents.values()).map((doc) => doc.reload())
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.#loaded = true;
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Document access (lazy creation) ───
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get or create a Document for the given pathId.
|
|
129
|
+
* Documents are cached: calling document("foo") twice returns the same instance.
|
|
130
|
+
*/
|
|
131
|
+
document(pathId: string): Document {
|
|
132
|
+
if (!this.#loaded) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"Collection has not been loaded. Call load() first."
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let doc = this.#documents.get(pathId);
|
|
139
|
+
if (doc) return doc;
|
|
140
|
+
|
|
141
|
+
const item = this.#items.get(pathId);
|
|
142
|
+
if (!item) {
|
|
143
|
+
throw new Error(`Could not find document "${pathId}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
doc = new Document({
|
|
147
|
+
id: pathId,
|
|
148
|
+
content: item.content,
|
|
149
|
+
meta: item.meta,
|
|
150
|
+
collection: this,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.#documents.set(pathId, doc);
|
|
154
|
+
return doc;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Creates a new Document tied to this collection without it
|
|
159
|
+
* existing in the items map. Used for in-memory documents
|
|
160
|
+
* (e.g., extracted from hasMany relationships).
|
|
161
|
+
*/
|
|
162
|
+
createDocument(attrs: {
|
|
163
|
+
id: string;
|
|
164
|
+
content?: string;
|
|
165
|
+
meta?: Record<string, unknown>;
|
|
166
|
+
ast?: import("mdast").Root;
|
|
167
|
+
}): Document {
|
|
168
|
+
return new Document({
|
|
169
|
+
id: attrs.id,
|
|
170
|
+
content: attrs.content ?? "",
|
|
171
|
+
meta: attrs.meta ?? {},
|
|
172
|
+
collection: this,
|
|
173
|
+
ast: attrs.ast,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Model access ───
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get a typed model instance for a document.
|
|
181
|
+
* The definition parameter carries the type information.
|
|
182
|
+
*/
|
|
183
|
+
getModel<TDef extends ModelDefinition<any, any, any, any, any>>(
|
|
184
|
+
pathId: string,
|
|
185
|
+
definition: TDef
|
|
186
|
+
): InferModelInstance<TDef> {
|
|
187
|
+
const doc = this.document(pathId);
|
|
188
|
+
return createModelInstance(doc, definition, this);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Determine which model definition matches a document,
|
|
193
|
+
* using match functions or prefix matching.
|
|
194
|
+
*/
|
|
195
|
+
findModelDefinition(
|
|
196
|
+
pathId: string
|
|
197
|
+
): ModelDefinition<any, any, any, any, any> | undefined {
|
|
198
|
+
const item = this.#items.get(pathId);
|
|
199
|
+
if (!item) return undefined;
|
|
200
|
+
|
|
201
|
+
for (const def of this.#models.values()) {
|
|
202
|
+
if (def.match) {
|
|
203
|
+
if (def.match({ id: pathId, meta: item.meta })) return def;
|
|
204
|
+
} else {
|
|
205
|
+
if (pathId.startsWith(def.prefix)) return def;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Querying ───
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create a typed query for model instances.
|
|
215
|
+
*/
|
|
216
|
+
query<TDef extends ModelDefinition<any, any, any, any, any>>(
|
|
217
|
+
definition: TDef
|
|
218
|
+
): CollectionQuery<TDef> {
|
|
219
|
+
return new CollectionQuery<TDef>(this, definition);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Persistence ───
|
|
223
|
+
|
|
224
|
+
async saveItem(
|
|
225
|
+
pathId: string,
|
|
226
|
+
options: { content: string; extension?: string }
|
|
227
|
+
): Promise<CollectionItem> {
|
|
228
|
+
const extension = options.extension ?? ".mdx";
|
|
229
|
+
const { data, content } = matter(options.content);
|
|
230
|
+
|
|
231
|
+
if (!this.#items.has(pathId)) {
|
|
232
|
+
const filePath = this.resolve(`${pathId}${extension}`);
|
|
233
|
+
this.#items.set(pathId, {
|
|
234
|
+
raw: options.content,
|
|
235
|
+
content,
|
|
236
|
+
meta: data,
|
|
237
|
+
path: filePath,
|
|
238
|
+
createdAt: new Date(),
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const item = this.#items.get(pathId)!;
|
|
244
|
+
const filePath = item.path;
|
|
245
|
+
|
|
246
|
+
await fs.mkdir(path.parse(filePath).dir, { recursive: true });
|
|
247
|
+
await fs.writeFile(filePath, options.content, "utf8");
|
|
248
|
+
|
|
249
|
+
// Update the stored item
|
|
250
|
+
item.raw = options.content;
|
|
251
|
+
item.content = content;
|
|
252
|
+
item.meta = data;
|
|
253
|
+
item.updatedAt = new Date();
|
|
254
|
+
|
|
255
|
+
return item;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async deleteItem(pathId: string): Promise<this> {
|
|
259
|
+
const item = this.#items.get(pathId);
|
|
260
|
+
if (!item) return this;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await fs.rm(item.path);
|
|
264
|
+
} catch {
|
|
265
|
+
// File might not exist
|
|
266
|
+
}
|
|
267
|
+
this.#items.delete(pathId);
|
|
268
|
+
this.#documents.delete(pathId);
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async readItem(
|
|
273
|
+
pathId: string,
|
|
274
|
+
extension: string = "mdx"
|
|
275
|
+
): Promise<CollectionItem> {
|
|
276
|
+
let filePath: string;
|
|
277
|
+
|
|
278
|
+
if (this.#items.has(pathId)) {
|
|
279
|
+
filePath = this.#items.get(pathId)!.path;
|
|
280
|
+
} else {
|
|
281
|
+
filePath = this.resolve(`${pathId}.${extension}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
285
|
+
const stat = await fs.stat(filePath);
|
|
286
|
+
const { data, content } = matter(raw);
|
|
287
|
+
|
|
288
|
+
const item: CollectionItem = {
|
|
289
|
+
raw,
|
|
290
|
+
content,
|
|
291
|
+
meta: data,
|
|
292
|
+
path: filePath,
|
|
293
|
+
createdAt: stat.ctime,
|
|
294
|
+
updatedAt: stat.mtime,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
this.#items.set(pathId, item);
|
|
298
|
+
return item;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── Actions ───
|
|
302
|
+
|
|
303
|
+
action(
|
|
304
|
+
name: string,
|
|
305
|
+
fn: (collection: Collection, ...args: any[]) => any
|
|
306
|
+
): this {
|
|
307
|
+
if (typeof fn !== "function") {
|
|
308
|
+
throw new Error("Expected a function for collection action");
|
|
309
|
+
}
|
|
310
|
+
this.#actions.set(name, fn);
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
get actions(): Map<string, Function> {
|
|
315
|
+
return this.#actions;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
get availableActions(): string[] {
|
|
319
|
+
return Array.from(this.#actions.keys());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async runAction(name: string, ...args: any[]): Promise<any> {
|
|
323
|
+
const fn = this.#actions.get(name);
|
|
324
|
+
if (!fn) {
|
|
325
|
+
throw new Error(`Action "${name}" does not exist on this collection.`);
|
|
326
|
+
}
|
|
327
|
+
return fn(this, ...args);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Plugin system ───
|
|
331
|
+
|
|
332
|
+
use(
|
|
333
|
+
plugin: (collection: Collection, options?: any) => void,
|
|
334
|
+
options?: any
|
|
335
|
+
): this {
|
|
336
|
+
plugin(this, options);
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Utilities ───
|
|
341
|
+
|
|
342
|
+
resolve(...args: string[]): string {
|
|
343
|
+
return path.resolve(this.rootPath, ...args);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
getPathId(absolutePath: string): string {
|
|
347
|
+
const relativePath = path.relative(this.rootPath, absolutePath);
|
|
348
|
+
return relativePath.replace(/\.[a-z]+$/i, "");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Serialization ───
|
|
352
|
+
|
|
353
|
+
toJSON(options: { content?: boolean } = {}): Record<string, unknown> {
|
|
354
|
+
const models = this.modelDefinitions.map((def) => ({
|
|
355
|
+
name: def.name,
|
|
356
|
+
prefix: def.prefix,
|
|
357
|
+
matchingPaths: this.available.filter((id) =>
|
|
358
|
+
id.startsWith(def.prefix)
|
|
359
|
+
),
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
const result: Record<string, unknown> = {
|
|
363
|
+
models,
|
|
364
|
+
itemIds: Array.from(this.#items.keys()),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (options.content) {
|
|
368
|
+
const items: Record<string, CollectionItem> = {};
|
|
369
|
+
for (const [id, item] of this.#items.entries()) {
|
|
370
|
+
items[id] = item;
|
|
371
|
+
}
|
|
372
|
+
result.items = items;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async export(
|
|
379
|
+
options: Record<string, unknown> = {}
|
|
380
|
+
): Promise<Record<string, unknown>> {
|
|
381
|
+
if (!this.#loaded) await this.load();
|
|
382
|
+
|
|
383
|
+
const json = this.toJSON(options);
|
|
384
|
+
const modelData: Record<string, unknown[]> = {};
|
|
385
|
+
|
|
386
|
+
for (const def of this.modelDefinitions) {
|
|
387
|
+
const query = new CollectionQuery(this, def);
|
|
388
|
+
const instances = await query.fetchAll();
|
|
389
|
+
modelData[def.name] = instances.map((inst: any) => inst.toJSON());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
...json,
|
|
394
|
+
modelData,
|
|
395
|
+
rootPath: this.rootPath,
|
|
396
|
+
name: this.name,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { pluralize } from "./utils/inflect";
|
|
3
|
+
import type {
|
|
4
|
+
ModelDefinition,
|
|
5
|
+
SectionDefinition,
|
|
6
|
+
RelationshipDefinition,
|
|
7
|
+
DocumentRef,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration input for defineModel. This is what the user writes.
|
|
12
|
+
*/
|
|
13
|
+
export interface DefineModelConfig<
|
|
14
|
+
TMeta extends z.ZodTypeAny,
|
|
15
|
+
TSections extends Record<string, SectionDefinition<any>>,
|
|
16
|
+
TRelationships extends Record<string, RelationshipDefinition<any>>,
|
|
17
|
+
TComputed extends Record<string, (self: any) => any>,
|
|
18
|
+
> {
|
|
19
|
+
prefix?: string;
|
|
20
|
+
meta?: TMeta;
|
|
21
|
+
sections?: TSections;
|
|
22
|
+
relationships?: TRelationships;
|
|
23
|
+
computed?: TComputed;
|
|
24
|
+
match?: (doc: DocumentRef) => boolean;
|
|
25
|
+
defaults?: Partial<z.input<TMeta>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* defineModel creates a ModelDefinition with full type inference.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* const Story = defineModel("Story", {
|
|
33
|
+
* prefix: "stories",
|
|
34
|
+
* meta: z.object({ status: z.enum(["created","complete"]) }),
|
|
35
|
+
* sections: { ... },
|
|
36
|
+
* relationships: { ... },
|
|
37
|
+
* computed: { ... },
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* The returned object is both a runtime config AND carries all type info.
|
|
41
|
+
* typeof Story is ModelDefinition<"Story", typeof metaSchema, ...>.
|
|
42
|
+
*/
|
|
43
|
+
export function defineModel<
|
|
44
|
+
TName extends string,
|
|
45
|
+
TMeta extends z.ZodTypeAny = z.ZodObject<{}, "passthrough">,
|
|
46
|
+
TSections extends Record<string, SectionDefinition<any>> = Record<
|
|
47
|
+
string,
|
|
48
|
+
never
|
|
49
|
+
>,
|
|
50
|
+
TRelationships extends Record<string, RelationshipDefinition<any>> = Record<
|
|
51
|
+
string,
|
|
52
|
+
never
|
|
53
|
+
>,
|
|
54
|
+
TComputed extends Record<string, (self: any) => any> = Record<
|
|
55
|
+
string,
|
|
56
|
+
never
|
|
57
|
+
>,
|
|
58
|
+
>(
|
|
59
|
+
name: TName,
|
|
60
|
+
config: DefineModelConfig<
|
|
61
|
+
TMeta,
|
|
62
|
+
TSections,
|
|
63
|
+
TRelationships,
|
|
64
|
+
TComputed
|
|
65
|
+
> = {} as any
|
|
66
|
+
): ModelDefinition<TName, TMeta, TSections, TRelationships, TComputed> {
|
|
67
|
+
const meta = (config.meta ?? z.object({}).passthrough()) as TMeta;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
prefix: config.prefix ?? pluralize(name.toLowerCase()),
|
|
72
|
+
meta,
|
|
73
|
+
schema: meta,
|
|
74
|
+
sections: (config.sections ?? ({} as any)) as TSections,
|
|
75
|
+
relationships: (config.relationships ?? ({} as any)) as TRelationships,
|
|
76
|
+
computed: (config.computed ?? ({} as any)) as TComputed,
|
|
77
|
+
match: config.match,
|
|
78
|
+
defaults: config.defaults,
|
|
79
|
+
};
|
|
80
|
+
}
|