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,31 @@
|
|
|
1
|
+
import type { Root, Heading } from "mdast";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize heading depths so the minimum heading is depth 1.
|
|
5
|
+
* If the shallowest heading is h2, all headings are shifted up by 1.
|
|
6
|
+
* Mutates the AST in place and returns it.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeHeadings(ast: Root): Root {
|
|
9
|
+
const headings = ast.children.filter(
|
|
10
|
+
(n): n is Heading => n.type === "heading"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
if (headings.length === 0) return ast;
|
|
14
|
+
|
|
15
|
+
const minDepth = Math.min(...headings.map((h) => h.depth));
|
|
16
|
+
const diff = 1 - minDepth;
|
|
17
|
+
|
|
18
|
+
if (diff !== 0) {
|
|
19
|
+
for (const heading of headings) {
|
|
20
|
+
heading.depth = Math.max(1, Math.min(6, heading.depth + diff)) as
|
|
21
|
+
| 1
|
|
22
|
+
| 2
|
|
23
|
+
| 3
|
|
24
|
+
| 4
|
|
25
|
+
| 5
|
|
26
|
+
| 6;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return ast;
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { toString } from "mdast-util-to-string";
|
|
2
|
+
import type { Content } from "mdast";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a markdown table AST node into an array of row objects.
|
|
6
|
+
* Each row is an object whose keys are the column header texts.
|
|
7
|
+
*/
|
|
8
|
+
export function parseTable(
|
|
9
|
+
tableNode: Content
|
|
10
|
+
): Record<string, string>[] {
|
|
11
|
+
const rows = (tableNode as any).children?.filter(
|
|
12
|
+
(n: any) => n.type === "tableRow"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (!rows || rows.length < 2) return [];
|
|
16
|
+
|
|
17
|
+
const [headingsRow, ...dataRows] = rows;
|
|
18
|
+
const headings: string[] = headingsRow.children.map((c: any) =>
|
|
19
|
+
toString(c)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return dataRows.map((row: any) =>
|
|
23
|
+
Object.fromEntries(
|
|
24
|
+
row.children.map((cell: any, index: number) => [
|
|
25
|
+
headings[index] ?? `col${index}`,
|
|
26
|
+
toString(cell),
|
|
27
|
+
])
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively read a directory and return file paths matching a regex.
|
|
6
|
+
*/
|
|
7
|
+
export async function readDirectory(
|
|
8
|
+
dirPath: string,
|
|
9
|
+
match: RegExp = /\.mdx?$/i,
|
|
10
|
+
recursive: boolean = true
|
|
11
|
+
): Promise<string[]> {
|
|
12
|
+
let paths: string[] = [];
|
|
13
|
+
|
|
14
|
+
let entries: string[];
|
|
15
|
+
try {
|
|
16
|
+
entries = await fs.readdir(dirPath);
|
|
17
|
+
} catch {
|
|
18
|
+
return paths;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.startsWith(".")) continue;
|
|
23
|
+
|
|
24
|
+
const filePath = path.join(dirPath, entry);
|
|
25
|
+
const stat = await fs.stat(filePath);
|
|
26
|
+
|
|
27
|
+
if (stat.isDirectory() && recursive) {
|
|
28
|
+
paths = paths.concat(await readDirectory(filePath, match, recursive));
|
|
29
|
+
} else if (stat.isFile() && match.test(filePath)) {
|
|
30
|
+
paths.push(filePath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return paths;
|
|
35
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
ModelDefinition,
|
|
4
|
+
SectionDefinition,
|
|
5
|
+
ValidationResult,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import type { Document } from "./document";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Standalone validator that checks a document against a model definition's
|
|
11
|
+
* Zod schemas (both meta and section schemas).
|
|
12
|
+
*
|
|
13
|
+
* Can be used directly for validation without creating a full model instance.
|
|
14
|
+
*/
|
|
15
|
+
export function validateDocument(
|
|
16
|
+
document: Document,
|
|
17
|
+
definition: ModelDefinition
|
|
18
|
+
): ValidationResult {
|
|
19
|
+
const errors: z.ZodIssue[] = [];
|
|
20
|
+
|
|
21
|
+
// Validate meta
|
|
22
|
+
const rawMeta = { ...(definition.defaults ?? {}), ...document.meta };
|
|
23
|
+
const metaResult = definition.meta.safeParse(rawMeta);
|
|
24
|
+
if (!metaResult.success) {
|
|
25
|
+
errors.push(...metaResult.error.issues);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Validate sections
|
|
29
|
+
if (definition.sections) {
|
|
30
|
+
for (const [key, sd] of Object.entries(definition.sections)) {
|
|
31
|
+
const sectionDef = sd as SectionDefinition<unknown>;
|
|
32
|
+
if (sectionDef.schema) {
|
|
33
|
+
const sectionQuery = document.querySection(sectionDef.heading);
|
|
34
|
+
const sectionData = sectionDef.extract(sectionQuery);
|
|
35
|
+
const sResult = sectionDef.schema.safeParse(sectionData);
|
|
36
|
+
if (!sResult.success) {
|
|
37
|
+
for (const issue of sResult.error.issues) {
|
|
38
|
+
errors.push({
|
|
39
|
+
...issue,
|
|
40
|
+
path: ["sections", key, ...issue.path],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
valid: errors.length === 0,
|
|
50
|
+
errors,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { AstQuery } from "../src/ast-query";
|
|
3
|
+
import { Collection } from "../src/collection";
|
|
4
|
+
import { createTestCollection } from "./helpers";
|
|
5
|
+
import type { Heading } from "mdast";
|
|
6
|
+
|
|
7
|
+
describe("AstQuery", () => {
|
|
8
|
+
let collection: Collection;
|
|
9
|
+
let query: AstQuery;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
collection = await createTestCollection();
|
|
13
|
+
const doc = collection.document("epics/authentication");
|
|
14
|
+
query = doc.astQuery;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("select returns first matching node", () => {
|
|
18
|
+
const heading = query.select("heading");
|
|
19
|
+
expect(heading).toBeDefined();
|
|
20
|
+
expect(heading!.type).toBe("heading");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("selectAll returns all matching nodes", () => {
|
|
24
|
+
const headings = query.selectAll("heading");
|
|
25
|
+
expect(headings.length).toBeGreaterThan(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("selectAll with attribute filter", () => {
|
|
29
|
+
const h2s = query.selectAll('heading[depth="2"]');
|
|
30
|
+
expect(h2s.length).toBeGreaterThanOrEqual(1);
|
|
31
|
+
for (const h of h2s) {
|
|
32
|
+
expect((h as Heading).depth).toBe(2);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("visit walks all nodes", () => {
|
|
37
|
+
let count = 0;
|
|
38
|
+
query.visit(() => {
|
|
39
|
+
count++;
|
|
40
|
+
});
|
|
41
|
+
expect(count).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("findBefore finds preceding node", () => {
|
|
45
|
+
const headings = query.selectAll("heading");
|
|
46
|
+
if (headings.length >= 2) {
|
|
47
|
+
const before = query.findBefore(headings[1]);
|
|
48
|
+
expect(before).toBeDefined();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("findAfter finds following node", () => {
|
|
53
|
+
const heading = query.select("heading")!;
|
|
54
|
+
const after = query.findAfter(heading);
|
|
55
|
+
expect(after).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("findAllAfter returns all following nodes", () => {
|
|
59
|
+
const heading = query.select("heading")!;
|
|
60
|
+
const after = query.findAllAfter(heading);
|
|
61
|
+
expect(after.length).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("findBetween returns nodes between two markers", () => {
|
|
65
|
+
const headings = query.selectAll("heading");
|
|
66
|
+
if (headings.length >= 2) {
|
|
67
|
+
const between = query.findBetween(headings[0], headings[1]);
|
|
68
|
+
expect(between).toBeDefined();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("headingsAtDepth returns correct headings (bug fix)", () => {
|
|
73
|
+
const h1s = query.headingsAtDepth(1);
|
|
74
|
+
expect(h1s.length).toBe(1);
|
|
75
|
+
expect(h1s[0].depth).toBe(1);
|
|
76
|
+
|
|
77
|
+
const h3s = query.headingsAtDepth(3);
|
|
78
|
+
expect(h3s.length).toBeGreaterThanOrEqual(2);
|
|
79
|
+
for (const h of h3s) {
|
|
80
|
+
expect(h.depth).toBe(3);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("findHeadingByText finds exact match (case insensitive)", () => {
|
|
85
|
+
const heading = query.findHeadingByText("stories");
|
|
86
|
+
expect(heading).toBeDefined();
|
|
87
|
+
expect(heading!.depth).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("findHeadingByText with substring matching", () => {
|
|
91
|
+
const heading = query.findHeadingByText("register", false);
|
|
92
|
+
expect(heading).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("findAllHeadingsByText returns multiple matches", () => {
|
|
96
|
+
const headings = query.findAllHeadingsByText("Acceptance Criteria");
|
|
97
|
+
expect(headings.length).toBeGreaterThanOrEqual(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("findNextSiblingHeadingTo finds same-depth sibling", () => {
|
|
101
|
+
const h3s = query.headingsAtDepth(3);
|
|
102
|
+
if (h3s.length >= 2) {
|
|
103
|
+
const next = query.findNextSiblingHeadingTo(h3s[0]);
|
|
104
|
+
expect(next).toBeDefined();
|
|
105
|
+
expect(next!.depth).toBe(3);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("findNextSiblingHeadingTo returns undefined at end", () => {
|
|
110
|
+
const h3s = query.headingsAtDepth(3);
|
|
111
|
+
const last = h3s[h3s.length - 1];
|
|
112
|
+
const next = query.findNextSiblingHeadingTo(last);
|
|
113
|
+
expect(next).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("atLine returns node at specific line", () => {
|
|
117
|
+
const first = query.ast.children[0];
|
|
118
|
+
if (first.position) {
|
|
119
|
+
const found = query.atLine(first.position.start.line);
|
|
120
|
+
expect(found).toBeDefined();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("atLine returns undefined for non-existent line", () => {
|
|
125
|
+
const found = query.atLine(99999);
|
|
126
|
+
expect(found).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Collection } from "../src/collection";
|
|
3
|
+
import { createTestCollection, FIXTURES_PATH } from "./helpers";
|
|
4
|
+
import { Epic, Story } from "./fixtures/sdlc/models";
|
|
5
|
+
|
|
6
|
+
describe("Collection", () => {
|
|
7
|
+
let collection: Collection;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
collection = await createTestCollection();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("loads all mdx files from rootPath", () => {
|
|
14
|
+
expect(collection.loaded).toBe(true);
|
|
15
|
+
expect(collection.available.length).toBeGreaterThanOrEqual(3);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns correct path IDs", () => {
|
|
19
|
+
expect(collection.available).toContain("epics/authentication");
|
|
20
|
+
expect(collection.available).toContain("epics/searching-and-browsing");
|
|
21
|
+
expect(collection.available).toContain(
|
|
22
|
+
"stories/authentication/a-user-should-be-able-to-register"
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("lazily creates documents", () => {
|
|
27
|
+
const doc = collection.document("epics/authentication");
|
|
28
|
+
expect(doc).toBeDefined();
|
|
29
|
+
expect(doc.id).toBe("epics/authentication");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("caches documents", () => {
|
|
33
|
+
const doc1 = collection.document("epics/authentication");
|
|
34
|
+
const doc2 = collection.document("epics/authentication");
|
|
35
|
+
expect(doc1).toBe(doc2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws if not loaded", () => {
|
|
39
|
+
const fresh = new Collection({ rootPath: FIXTURES_PATH });
|
|
40
|
+
expect(() => fresh.document("epics/authentication")).toThrow(
|
|
41
|
+
"Collection has not been loaded"
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws for unknown pathId", () => {
|
|
46
|
+
expect(() => collection.document("nonexistent")).toThrow(
|
|
47
|
+
'Could not find document "nonexistent"'
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("registers model definitions", () => {
|
|
52
|
+
expect(collection.getModelDefinition("Epic")).toBeDefined();
|
|
53
|
+
expect(collection.getModelDefinition("Story")).toBeDefined();
|
|
54
|
+
expect(collection.modelDefinitions.length).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("finds model definition by pathId prefix", () => {
|
|
58
|
+
const def = collection.findModelDefinition("epics/authentication");
|
|
59
|
+
expect(def?.name).toBe("Epic");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("gets typed model instances", () => {
|
|
63
|
+
const epic = collection.getModel("epics/authentication", Epic);
|
|
64
|
+
expect(epic.id).toBe("epics/authentication");
|
|
65
|
+
expect(epic.title).toBe("Authentication");
|
|
66
|
+
expect(epic.meta.status).toBe("created");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("creates typed queries", async () => {
|
|
70
|
+
const epics = await collection.query(Epic).fetchAll();
|
|
71
|
+
expect(epics.length).toBe(2);
|
|
72
|
+
expect(epics[0].meta.status).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("serializes to JSON", () => {
|
|
76
|
+
const json = collection.toJSON();
|
|
77
|
+
expect(json.models).toBeDefined();
|
|
78
|
+
expect(json.itemIds).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("supports actions", async () => {
|
|
82
|
+
let called = false;
|
|
83
|
+
collection.action("test-action", () => {
|
|
84
|
+
called = true;
|
|
85
|
+
});
|
|
86
|
+
expect(collection.availableActions).toContain("test-action");
|
|
87
|
+
await collection.runAction("test-action");
|
|
88
|
+
expect(called).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("supports plugins", () => {
|
|
92
|
+
let pluginCalled = false;
|
|
93
|
+
collection.use((coll) => {
|
|
94
|
+
pluginCalled = true;
|
|
95
|
+
expect(coll).toBe(collection);
|
|
96
|
+
});
|
|
97
|
+
expect(pluginCalled).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { defineModel, z, section, hasMany, belongsTo } from "../src/index";
|
|
3
|
+
|
|
4
|
+
describe("defineModel", () => {
|
|
5
|
+
it("creates a ModelDefinition with the given name", () => {
|
|
6
|
+
const MyModel = defineModel("MyModel");
|
|
7
|
+
expect(MyModel.name).toBe("MyModel");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("auto-pluralizes the prefix from the name", () => {
|
|
11
|
+
const Epic = defineModel("Epic");
|
|
12
|
+
expect(Epic.prefix).toBe("epics");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("uses custom prefix when provided", () => {
|
|
16
|
+
const MyModel = defineModel("MyModel", { prefix: "custom" });
|
|
17
|
+
expect(MyModel.prefix).toBe("custom");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("stores the meta Zod schema", () => {
|
|
21
|
+
const schema = z.object({ status: z.string() });
|
|
22
|
+
const MyModel = defineModel("MyModel", { meta: schema });
|
|
23
|
+
expect(MyModel.meta).toBe(schema);
|
|
24
|
+
expect(MyModel.schema).toBe(schema);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates passthrough schema when meta is omitted", () => {
|
|
28
|
+
const MyModel = defineModel("MyModel");
|
|
29
|
+
const result = MyModel.meta.safeParse({ anything: "goes" });
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("stores sections", () => {
|
|
34
|
+
const MyModel = defineModel("MyModel", {
|
|
35
|
+
sections: {
|
|
36
|
+
items: section("Items", {
|
|
37
|
+
extract: (q) => q.selectAll("listItem"),
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(MyModel.sections.items.heading).toBe("Items");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("stores relationships", () => {
|
|
45
|
+
const Target = defineModel("Target");
|
|
46
|
+
const MyModel = defineModel("MyModel", {
|
|
47
|
+
relationships: {
|
|
48
|
+
targets: hasMany(() => Target, { heading: "Targets" }),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
expect(MyModel.relationships.targets.type).toBe("hasMany");
|
|
52
|
+
expect(MyModel.relationships.targets.heading).toBe("Targets");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("stores computed properties", () => {
|
|
56
|
+
const MyModel = defineModel("MyModel", {
|
|
57
|
+
computed: {
|
|
58
|
+
foo: () => 42,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
expect(MyModel.computed.foo).toBeDefined();
|
|
62
|
+
expect(MyModel.computed.foo({})).toBe(42);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("stores match function", () => {
|
|
66
|
+
const fn = (doc: any) => doc.id.startsWith("special");
|
|
67
|
+
const MyModel = defineModel("MyModel", { match: fn });
|
|
68
|
+
expect(MyModel.match).toBe(fn);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("stores defaults", () => {
|
|
72
|
+
const MyModel = defineModel("MyModel", {
|
|
73
|
+
meta: z.object({ status: z.string().default("draft") }),
|
|
74
|
+
defaults: { status: "draft" },
|
|
75
|
+
});
|
|
76
|
+
expect(MyModel.defaults).toEqual({ status: "draft" });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Collection } from "../src/collection";
|
|
3
|
+
import { createTestCollection } from "./helpers";
|
|
4
|
+
import { toString } from "mdast-util-to-string";
|
|
5
|
+
|
|
6
|
+
describe("Document", () => {
|
|
7
|
+
let collection: Collection;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
collection = await createTestCollection();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("lazily parses AST", () => {
|
|
14
|
+
const doc = collection.document("epics/authentication");
|
|
15
|
+
// Content should exist but AST is not parsed yet
|
|
16
|
+
expect(doc.content).toBeDefined();
|
|
17
|
+
// Accessing ast triggers parsing
|
|
18
|
+
expect(doc.ast.type).toBe("root");
|
|
19
|
+
expect(doc.ast.children.length).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("extracts title from first heading", () => {
|
|
23
|
+
const doc = collection.document("epics/authentication");
|
|
24
|
+
expect(doc.title).toBe("Authentication");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to id if no heading", () => {
|
|
28
|
+
const doc = collection.createDocument({
|
|
29
|
+
id: "test/no-heading",
|
|
30
|
+
content: "Just some text without a heading.",
|
|
31
|
+
});
|
|
32
|
+
expect(doc.title).toBe("test/no-heading");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("generates slug from title", () => {
|
|
36
|
+
const doc = collection.document("epics/authentication");
|
|
37
|
+
expect(doc.slug).toBe("authentication");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("exposes parsed frontmatter as meta", () => {
|
|
41
|
+
const doc = collection.document("epics/authentication");
|
|
42
|
+
expect(doc.meta.priority).toBe("high");
|
|
43
|
+
expect(doc.meta.status).toBe("created");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("serializes rawContent with frontmatter", () => {
|
|
47
|
+
const doc = collection.document("epics/authentication");
|
|
48
|
+
expect(doc.rawContent).toContain("---");
|
|
49
|
+
expect(doc.rawContent).toContain("priority: high");
|
|
50
|
+
expect(doc.rawContent).toContain("# Authentication");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("omits frontmatter block if meta is empty", () => {
|
|
54
|
+
const doc = collection.createDocument({
|
|
55
|
+
id: "test/no-meta",
|
|
56
|
+
content: "# Hello\n",
|
|
57
|
+
});
|
|
58
|
+
expect(doc.rawContent).not.toContain("---");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("provides astQuery", () => {
|
|
62
|
+
const doc = collection.document("epics/authentication");
|
|
63
|
+
expect(doc.astQuery.select("heading")).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("provides nodes shortcuts", () => {
|
|
67
|
+
const doc = collection.document("epics/authentication");
|
|
68
|
+
expect(doc.nodes.headings.length).toBeGreaterThan(0);
|
|
69
|
+
expect(doc.nodes.firstHeading).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("extractSection", () => {
|
|
73
|
+
it("extracts section by heading text", () => {
|
|
74
|
+
const doc = collection.document("epics/authentication");
|
|
75
|
+
const section = doc.extractSection("Stories");
|
|
76
|
+
expect(section.length).toBeGreaterThan(1);
|
|
77
|
+
expect(toString(section[0])).toBe("Stories");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("throws for non-existent heading", () => {
|
|
81
|
+
const doc = collection.document("epics/authentication");
|
|
82
|
+
expect(() => doc.extractSection("Nonexistent")).toThrow(
|
|
83
|
+
"Heading not found"
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("querySection", () => {
|
|
89
|
+
it("returns scoped AstQuery without the heading", () => {
|
|
90
|
+
const doc = collection.document("epics/authentication");
|
|
91
|
+
const query = doc.querySection("Stories");
|
|
92
|
+
const headings = query.selectAll("heading");
|
|
93
|
+
// Should have the story sub-headings but not "Stories" itself
|
|
94
|
+
expect(headings.length).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns empty query for non-existent heading", () => {
|
|
98
|
+
const doc = collection.document("epics/authentication");
|
|
99
|
+
const query = doc.querySection("Nonexistent");
|
|
100
|
+
expect(query.selectAll("heading").length).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("immutable section mutations", () => {
|
|
105
|
+
it("removeSection returns new Document", () => {
|
|
106
|
+
const doc = collection.document("epics/authentication");
|
|
107
|
+
const originalContent = doc.content;
|
|
108
|
+
const newDoc = doc.removeSection("Stories");
|
|
109
|
+
expect(newDoc).not.toBe(doc);
|
|
110
|
+
expect(doc.content).toBe(originalContent);
|
|
111
|
+
expect(newDoc.content).not.toContain("A User should be able to register");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("replaceSectionContent returns new Document", () => {
|
|
115
|
+
const doc = collection.document("epics/authentication");
|
|
116
|
+
const newDoc = doc.replaceSectionContent(
|
|
117
|
+
"Stories",
|
|
118
|
+
"New stories content here."
|
|
119
|
+
);
|
|
120
|
+
expect(newDoc).not.toBe(doc);
|
|
121
|
+
expect(newDoc.content).toContain("New stories content here");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("insertBefore returns new Document", () => {
|
|
125
|
+
const doc = collection.document("epics/authentication");
|
|
126
|
+
const heading = doc.astQuery.findHeadingByText("Stories");
|
|
127
|
+
expect(heading).toBeDefined();
|
|
128
|
+
const newDoc = doc.insertBefore(heading!, "Inserted before.");
|
|
129
|
+
expect(newDoc).not.toBe(doc);
|
|
130
|
+
expect(newDoc.content).toContain("Inserted before");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("insertAfter returns new Document", () => {
|
|
134
|
+
const doc = collection.document("epics/authentication");
|
|
135
|
+
const heading = doc.astQuery.findHeadingByText("Stories");
|
|
136
|
+
expect(heading).toBeDefined();
|
|
137
|
+
const newDoc = doc.insertAfter(heading!, "Inserted after.");
|
|
138
|
+
expect(newDoc).not.toBe(doc);
|
|
139
|
+
expect(newDoc.content).toContain("Inserted after");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("appendToSection returns new Document", () => {
|
|
143
|
+
const doc = collection.document("epics/authentication");
|
|
144
|
+
const newDoc = doc.appendToSection(
|
|
145
|
+
"Stories",
|
|
146
|
+
"### New Story\n\nAppended story."
|
|
147
|
+
);
|
|
148
|
+
expect(newDoc).not.toBe(doc);
|
|
149
|
+
expect(newDoc.content).toContain("New Story");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("mutable mutations", () => {
|
|
154
|
+
it("removeSection with mutate modifies in place", () => {
|
|
155
|
+
const doc = collection.createDocument({
|
|
156
|
+
id: "test/mutable",
|
|
157
|
+
content:
|
|
158
|
+
"# Test\n\n## Section A\n\nContent A\n\n## Section B\n\nContent B\n",
|
|
159
|
+
});
|
|
160
|
+
const result = doc.removeSection("Section A", { mutate: true });
|
|
161
|
+
expect(result).toBe(doc);
|
|
162
|
+
expect(doc.content).not.toContain("Content A");
|
|
163
|
+
expect(doc.content).toContain("Content B");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("replaceContent returns new Document", () => {
|
|
168
|
+
const doc = collection.document("epics/authentication");
|
|
169
|
+
const newDoc = doc.replaceContent("# Completely new content");
|
|
170
|
+
expect(newDoc).not.toBe(doc);
|
|
171
|
+
expect(newDoc.content).toBe("# Completely new content");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("appendContent returns new Document", () => {
|
|
175
|
+
const doc = collection.createDocument({
|
|
176
|
+
id: "test/append",
|
|
177
|
+
content: "# Start\n",
|
|
178
|
+
});
|
|
179
|
+
const newDoc = doc.appendContent("\n## Added\n");
|
|
180
|
+
expect(newDoc).not.toBe(doc);
|
|
181
|
+
expect(newDoc.content).toContain("Added");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("toJSON serializes document", () => {
|
|
185
|
+
const doc = collection.document("epics/authentication");
|
|
186
|
+
const json = doc.toJSON();
|
|
187
|
+
expect(json.id).toBe("epics/authentication");
|
|
188
|
+
expect(json.meta).toBeDefined();
|
|
189
|
+
expect(json.content).toBeDefined();
|
|
190
|
+
expect(json.ast).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("toText extracts plain text", () => {
|
|
194
|
+
const doc = collection.document("epics/authentication");
|
|
195
|
+
const text = doc.toText();
|
|
196
|
+
expect(text).toContain("Authentication");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("toOutline", () => {
|
|
200
|
+
it("returns indented heading outline", () => {
|
|
201
|
+
const doc = collection.document("epics/authentication");
|
|
202
|
+
const outline = doc.toOutline();
|
|
203
|
+
expect(outline).toBe(
|
|
204
|
+
[
|
|
205
|
+
"- Authentication",
|
|
206
|
+
" - Stories",
|
|
207
|
+
" - A User should be able to register.",
|
|
208
|
+
" - Acceptance Criteria",
|
|
209
|
+
" - Mockups",
|
|
210
|
+
" - A User should be able to login.",
|
|
211
|
+
" - Acceptance Criteria",
|
|
212
|
+
" - Mockups",
|
|
213
|
+
].join("\n")
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns empty string for document with no headings", () => {
|
|
218
|
+
const doc = collection.createDocument({
|
|
219
|
+
id: "test/no-headings",
|
|
220
|
+
content: "Just some text without any headings.",
|
|
221
|
+
});
|
|
222
|
+
expect(doc.toOutline()).toBe("");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|