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,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
artist: miles-davis
|
|
3
|
+
year: 1959
|
|
4
|
+
genre: Modal Jazz
|
|
5
|
+
format: LP
|
|
6
|
+
rating: 5
|
|
7
|
+
condition: very-good
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Kind of Blue
|
|
11
|
+
|
|
12
|
+
Recorded over two sessions in spring 1959, this album defined modal jazz. Miles handed his sextet sketches and scales instead of chord changes, and they improvised nearly everything on the first take.
|
|
13
|
+
|
|
14
|
+
## Tracklist
|
|
15
|
+
|
|
16
|
+
| # | Title | Duration |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| 1 | So What | 9:22 |
|
|
19
|
+
| 2 | Freddie Freeloader | 9:46 |
|
|
20
|
+
| 3 | Blue in Green | 5:27 |
|
|
21
|
+
| 4 | All Blues | 11:33 |
|
|
22
|
+
| 5 | Flamenco Sketches | 9:26 |
|
|
23
|
+
|
|
24
|
+
## Personnel
|
|
25
|
+
|
|
26
|
+
- Miles Davis — trumpet
|
|
27
|
+
- John Coltrane — tenor saxophone
|
|
28
|
+
- Cannonball Adderley — alto saxophone
|
|
29
|
+
- Bill Evans — piano (tracks 1, 3, 4, 5)
|
|
30
|
+
- Wynton Kelly — piano (track 2)
|
|
31
|
+
- Paul Chambers — bass
|
|
32
|
+
- Jimmy Cobb — drums
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
artist: radiohead
|
|
3
|
+
year: 1997
|
|
4
|
+
genre: Alternative Rock
|
|
5
|
+
format: 2xLP
|
|
6
|
+
rating: 5
|
|
7
|
+
condition: near-mint
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# OK Computer
|
|
11
|
+
|
|
12
|
+
Radiohead's third album traded the angst of their earlier work for something bigger: a panoramic meditation on disconnection in the digital age. Dense, layered, and somehow more relevant with every passing year.
|
|
13
|
+
|
|
14
|
+
## Tracklist
|
|
15
|
+
|
|
16
|
+
| # | Title | Duration |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| 1 | Airbag | 4:44 |
|
|
19
|
+
| 2 | Paranoid Android | 6:23 |
|
|
20
|
+
| 3 | Subterranean Homesick Alien | 4:27 |
|
|
21
|
+
| 4 | Exit Music (For a Film) | 4:24 |
|
|
22
|
+
| 5 | Let Down | 4:59 |
|
|
23
|
+
| 6 | Karma Police | 4:21 |
|
|
24
|
+
| 7 | Fitter Happier | 1:57 |
|
|
25
|
+
| 8 | Electioneering | 3:50 |
|
|
26
|
+
| 9 | Climbing Up the Walls | 4:45 |
|
|
27
|
+
| 10 | No Surprises | 3:48 |
|
|
28
|
+
| 11 | Lucky | 4:19 |
|
|
29
|
+
| 12 | The Tourist | 5:24 |
|
|
30
|
+
|
|
31
|
+
## Personnel
|
|
32
|
+
|
|
33
|
+
- Thom Yorke — vocals, guitar, keyboards
|
|
34
|
+
- Jonny Greenwood — guitar, keyboards, ondes Martenot
|
|
35
|
+
- Ed O'Brien — guitar, vocals
|
|
36
|
+
- Colin Greenwood — bass
|
|
37
|
+
- Phil Selway — drums
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
artist: nina-simone
|
|
3
|
+
year: 1966
|
|
4
|
+
genre: Jazz / Blues
|
|
5
|
+
format: LP
|
|
6
|
+
rating: 4
|
|
7
|
+
condition: very-good
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Wild Is the Wind
|
|
11
|
+
|
|
12
|
+
Stripped back compared to its predecessor, this album foregrounds Nina's piano and voice. The material ranges from tender ballads to biting social commentary, often within the same song.
|
|
13
|
+
|
|
14
|
+
## Tracklist
|
|
15
|
+
|
|
16
|
+
| # | Title | Duration |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| 1 | I Love Your Lovin' Ways | 2:34 |
|
|
19
|
+
| 2 | Four Women | 4:09 |
|
|
20
|
+
| 3 | What More Can I Say | 3:11 |
|
|
21
|
+
| 4 | Lilac Wine | 4:17 |
|
|
22
|
+
| 5 | That's All I Ask | 3:01 |
|
|
23
|
+
| 6 | Break Down and Let It All Out | 2:39 |
|
|
24
|
+
| 7 | Why Keep On Breaking My Heart | 2:41 |
|
|
25
|
+
| 8 | Wild Is the Wind | 6:49 |
|
|
26
|
+
| 9 | Black Is the Color of My True Love's Hair | 3:07 |
|
|
27
|
+
| 10 | If I Should Lose You | 2:22 |
|
|
28
|
+
| 11 | Either Way I Lose | 4:38 |
|
|
29
|
+
|
|
30
|
+
## Personnel
|
|
31
|
+
|
|
32
|
+
- Nina Simone — vocals, piano
|
|
33
|
+
- Rudy Stevenson — guitar, flute
|
|
34
|
+
- Lisle Atkinson — bass
|
|
35
|
+
- Bobby Hamilton — drums
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
genre: Jazz
|
|
3
|
+
origin: Alton, Illinois
|
|
4
|
+
active: "1944–1991"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Miles Davis
|
|
8
|
+
|
|
9
|
+
Trumpeter, bandleader, and relentless innovator. Miles didn't just play jazz — he bent it into new shapes every decade, from bebop to cool jazz to fusion and beyond.
|
|
10
|
+
|
|
11
|
+
## Influences
|
|
12
|
+
|
|
13
|
+
- Charlie Parker
|
|
14
|
+
- Dizzy Gillespie
|
|
15
|
+
- Duke Ellington
|
|
16
|
+
- Ahmad Jamal
|
|
17
|
+
- Jimi Hendrix
|
|
18
|
+
|
|
19
|
+
## Discography
|
|
20
|
+
|
|
21
|
+
### Kind of Blue
|
|
22
|
+
|
|
23
|
+
The best-selling jazz album of all time. Modal jazz distilled to its essence.
|
|
24
|
+
|
|
25
|
+
### Bitches Brew
|
|
26
|
+
|
|
27
|
+
The album that invented jazz fusion. Chaotic, electric, and revolutionary.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
genre: Jazz / Soul
|
|
3
|
+
origin: Tryon, North Carolina
|
|
4
|
+
active: "1954–2003"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Nina Simone
|
|
8
|
+
|
|
9
|
+
Classically trained pianist who became one of the most powerful voices in American music. Her work fused jazz, blues, folk, and classical into something entirely her own — always political, always personal.
|
|
10
|
+
|
|
11
|
+
## Influences
|
|
12
|
+
|
|
13
|
+
- Johann Sebastian Bach
|
|
14
|
+
- Billie Holiday
|
|
15
|
+
- Langston Hughes
|
|
16
|
+
- Lorraine Hansberry
|
|
17
|
+
|
|
18
|
+
## Discography
|
|
19
|
+
|
|
20
|
+
### I Put a Spell on You
|
|
21
|
+
|
|
22
|
+
Lush orchestral arrangements meet Nina's incomparable voice. Sophisticated, powerful, and hauntingly intimate.
|
|
23
|
+
|
|
24
|
+
### Wild Is the Wind
|
|
25
|
+
|
|
26
|
+
A stripped-down collection that showcases her piano and voice at their most raw and emotive.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
genre: Alternative Rock
|
|
3
|
+
origin: Abingdon, Oxfordshire
|
|
4
|
+
active: "1985–present"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Radiohead
|
|
8
|
+
|
|
9
|
+
Five musicians from Oxfordshire who spent three decades proving that popular music could be challenging, experimental, and emotionally devastating — all at the same time.
|
|
10
|
+
|
|
11
|
+
## Influences
|
|
12
|
+
|
|
13
|
+
- Talking Heads
|
|
14
|
+
- R.E.M.
|
|
15
|
+
- Can
|
|
16
|
+
- Aphex Twin
|
|
17
|
+
- Olivier Messiaen
|
|
18
|
+
|
|
19
|
+
## Discography
|
|
20
|
+
|
|
21
|
+
### OK Computer
|
|
22
|
+
|
|
23
|
+
Anxiety about technology and modern alienation, wrapped in some of the most ambitious guitar rock ever committed to tape.
|
|
24
|
+
|
|
25
|
+
### In Rainbows
|
|
26
|
+
|
|
27
|
+
A warm, intimate counterpoint to the paranoia of their earlier work. Released via pay-what-you-want download.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineModel,
|
|
3
|
+
section,
|
|
4
|
+
hasMany,
|
|
5
|
+
belongsTo,
|
|
6
|
+
z,
|
|
7
|
+
} from "../../src/index";
|
|
8
|
+
import { toString } from "mdast-util-to-string";
|
|
9
|
+
import { parseTable } from "../../src/utils/parse-table";
|
|
10
|
+
|
|
11
|
+
// ─── Artist (parent) ───
|
|
12
|
+
|
|
13
|
+
export const Artist = defineModel("Artist", {
|
|
14
|
+
prefix: "artists",
|
|
15
|
+
meta: z.object({
|
|
16
|
+
genre: z.string(),
|
|
17
|
+
origin: z.string(),
|
|
18
|
+
active: z.string(),
|
|
19
|
+
}),
|
|
20
|
+
sections: {
|
|
21
|
+
influences: section("Influences", {
|
|
22
|
+
extract: (q) => q.selectAll("listItem").map((n) => toString(n)),
|
|
23
|
+
schema: z.array(z.string()),
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
relationships: {
|
|
27
|
+
albums: hasMany(() => Album, { heading: "Discography" }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ─── Album ───
|
|
32
|
+
|
|
33
|
+
export const Album = defineModel("Album", {
|
|
34
|
+
prefix: "albums",
|
|
35
|
+
meta: z.object({
|
|
36
|
+
artist: z.string(),
|
|
37
|
+
year: z.number(),
|
|
38
|
+
genre: z.string(),
|
|
39
|
+
format: z.enum(["LP", "EP", "Single", "2xLP"]).default("LP"),
|
|
40
|
+
rating: z.number().min(1).max(5).optional(),
|
|
41
|
+
condition: z
|
|
42
|
+
.enum(["mint", "near-mint", "very-good", "good", "fair", "poor"])
|
|
43
|
+
.default("very-good"),
|
|
44
|
+
}),
|
|
45
|
+
sections: {
|
|
46
|
+
tracklist: section("Tracklist", {
|
|
47
|
+
extract: (q) => {
|
|
48
|
+
const tables = q.selectAll("table");
|
|
49
|
+
if (tables.length > 0) {
|
|
50
|
+
return parseTable(tables[0]);
|
|
51
|
+
}
|
|
52
|
+
return q.selectAll("listItem").map((n) => toString(n));
|
|
53
|
+
},
|
|
54
|
+
schema: z.union([
|
|
55
|
+
z.array(z.record(z.string(), z.string())),
|
|
56
|
+
z.array(z.string()),
|
|
57
|
+
]),
|
|
58
|
+
}),
|
|
59
|
+
personnel: section("Personnel", {
|
|
60
|
+
extract: (q) => q.selectAll("listItem").map((n) => toString(n)),
|
|
61
|
+
schema: z.array(z.string()),
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
relationships: {
|
|
65
|
+
artist: belongsTo(() => Artist, {
|
|
66
|
+
foreignKey: (doc) => doc.meta.artist as string,
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
computed: {
|
|
70
|
+
isClassic: (self: any) => self.meta.year < 1980,
|
|
71
|
+
decade: (self: any) => `${Math.floor(self.meta.year / 10) * 10}s`,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example queries for the Vinyl Collection showcase.
|
|
3
|
+
*
|
|
4
|
+
* Run with: bun showcases/vinyl-collection/queries.ts
|
|
5
|
+
*/
|
|
6
|
+
import { Collection } from "../../src/index";
|
|
7
|
+
import { Artist, Album } from "./models";
|
|
8
|
+
|
|
9
|
+
const collection = new Collection({
|
|
10
|
+
rootPath: new URL(".", import.meta.url).pathname,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
collection.register(Artist);
|
|
14
|
+
collection.register(Album);
|
|
15
|
+
await collection.load();
|
|
16
|
+
|
|
17
|
+
// ── Full collection ──
|
|
18
|
+
const allAlbums = await collection.query(Album).fetchAll();
|
|
19
|
+
console.log(`Total albums in collection: ${allAlbums.length}`);
|
|
20
|
+
|
|
21
|
+
// ── Filter by genre ──
|
|
22
|
+
const jazzAlbums = await collection
|
|
23
|
+
.query(Album)
|
|
24
|
+
.where("meta.genre", "contains", "Jazz")
|
|
25
|
+
.fetchAll();
|
|
26
|
+
console.log(
|
|
27
|
+
`\nJazz albums: ${jazzAlbums.map((a) => `${a.title} (${a.meta.year})`).join(", ")}`
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// ── Filter by decade (computed) ──
|
|
31
|
+
console.log(`\nAlbums by decade:`);
|
|
32
|
+
for (const album of allAlbums) {
|
|
33
|
+
console.log(` ${album.title} — ${album.computed.decade}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── 5-star records ──
|
|
37
|
+
const topRated = await collection
|
|
38
|
+
.query(Album)
|
|
39
|
+
.where("meta.rating", 5)
|
|
40
|
+
.fetchAll();
|
|
41
|
+
console.log(
|
|
42
|
+
`\n5-star albums: ${topRated.map((a) => a.title).join(", ")}`
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// ── Classics (pre-1980) ──
|
|
46
|
+
console.log(`\nClassics (pre-1980):`);
|
|
47
|
+
for (const album of allAlbums) {
|
|
48
|
+
if (album.computed.isClassic) {
|
|
49
|
+
console.log(` ${album.title} (${album.meta.year})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Condition report ──
|
|
54
|
+
const mint = await collection
|
|
55
|
+
.query(Album)
|
|
56
|
+
.whereIn("meta.condition", ["mint", "near-mint"])
|
|
57
|
+
.fetchAll();
|
|
58
|
+
console.log(
|
|
59
|
+
`\nMint / near-mint condition: ${mint.map((a) => a.title).join(", ")}`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// ── Tracklist as structured data ──
|
|
63
|
+
const kob = collection.getModel("albums/kind-of-blue", Album);
|
|
64
|
+
console.log(`\n--- ${kob.title} ---`);
|
|
65
|
+
console.log("Tracklist:", kob.sections.tracklist);
|
|
66
|
+
console.log("Personnel:", kob.sections.personnel);
|
|
67
|
+
|
|
68
|
+
// ── Artist → Albums relationship ──
|
|
69
|
+
const miles = collection.getModel("artists/miles-davis", Artist);
|
|
70
|
+
console.log(`\n--- ${miles.title} ---`);
|
|
71
|
+
console.log("Influences:", miles.sections.influences);
|
|
72
|
+
const milesAlbums = miles.relationships.albums.fetchAll();
|
|
73
|
+
console.log(
|
|
74
|
+
`Albums in discography: ${milesAlbums.map((a) => a.title).join(", ")}`
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// ── Album → Artist relationship ──
|
|
78
|
+
const okc = collection.getModel("albums/ok-computer", Album);
|
|
79
|
+
const artist = okc.relationships.artist.fetch();
|
|
80
|
+
console.log(`\n${okc.title} by ${artist.title} (${artist.meta.origin})`);
|
|
81
|
+
|
|
82
|
+
// ── Serialize ──
|
|
83
|
+
const json = kob.toJSON({
|
|
84
|
+
sections: ["tracklist", "personnel"],
|
|
85
|
+
computed: ["isClassic", "decade"],
|
|
86
|
+
});
|
|
87
|
+
console.log(`\nKind of Blue as JSON:`, JSON.stringify(json, null, 2));
|
package/src/ast-query.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { findBefore } from "unist-util-find-before";
|
|
2
|
+
import { findAfter } from "unist-util-find-after";
|
|
3
|
+
import { findAllBefore } from "unist-util-find-all-before";
|
|
4
|
+
import { findAllAfter } from "unist-util-find-all-after";
|
|
5
|
+
import { visit } from "unist-util-visit";
|
|
6
|
+
import { selectAll, select } from "unist-util-select";
|
|
7
|
+
import { toString } from "mdast-util-to-string";
|
|
8
|
+
import type { Root, Content, Heading, RootContent } from "mdast";
|
|
9
|
+
|
|
10
|
+
export class AstQuery {
|
|
11
|
+
readonly ast: Root;
|
|
12
|
+
|
|
13
|
+
constructor(ast: Root) {
|
|
14
|
+
this.ast = ast;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Find the first node matching a unist-util-select selector. */
|
|
18
|
+
select(selector: string): Content | null {
|
|
19
|
+
return select(selector, this.ast) as Content | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Find all nodes matching a unist-util-select selector. */
|
|
23
|
+
selectAll(selector: string): Content[] {
|
|
24
|
+
return selectAll(selector, this.ast) as Content[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Walk the tree, calling visitor for each node. */
|
|
28
|
+
visit(visitor: (node: Content) => void): void {
|
|
29
|
+
visit(this.ast, (node) => {
|
|
30
|
+
visitor(node as Content);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Find all nodes before the given node. */
|
|
35
|
+
findAllBefore(
|
|
36
|
+
node: Content,
|
|
37
|
+
test?: string | ((node: Content) => boolean)
|
|
38
|
+
): Content[] {
|
|
39
|
+
return findAllBefore(this.ast, node as RootContent, test as any) as Content[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find all nodes after the given node. */
|
|
43
|
+
findAllAfter(
|
|
44
|
+
node: Content,
|
|
45
|
+
test?: string | ((node: Content) => boolean)
|
|
46
|
+
): Content[] {
|
|
47
|
+
return findAllAfter(this.ast, node as RootContent, test as any) as Content[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Find the first node before the given node matching the test. */
|
|
51
|
+
findBefore(
|
|
52
|
+
node: Content,
|
|
53
|
+
test?: string | ((node: Content) => boolean)
|
|
54
|
+
): Content | null {
|
|
55
|
+
return findBefore(this.ast, node as RootContent, test as any) as Content | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find the first node after the given node matching the test. */
|
|
59
|
+
findAfter(
|
|
60
|
+
node: Content,
|
|
61
|
+
test?: string | ((node: Content) => boolean)
|
|
62
|
+
): Content | null {
|
|
63
|
+
return findAfter(this.ast, node as RootContent, test as any) as Content | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find all nodes between two nodes (exclusive on both ends),
|
|
68
|
+
* based on line position.
|
|
69
|
+
*/
|
|
70
|
+
findBetween(nodeOne: Content, nodeTwo: Content): Content[] {
|
|
71
|
+
const startLine = nodeOne.position?.end?.line ?? 0;
|
|
72
|
+
const endLine = nodeTwo.position?.start?.line ?? Infinity;
|
|
73
|
+
return this.ast.children.filter(
|
|
74
|
+
(child) =>
|
|
75
|
+
(child.position?.start?.line ?? 0) > startLine &&
|
|
76
|
+
(child.position?.end?.line ?? 0) < endLine
|
|
77
|
+
) as Content[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Get the node at a given line number. */
|
|
81
|
+
atLine(lineNumber: number): Content | undefined {
|
|
82
|
+
return this.ast.children.find(
|
|
83
|
+
(child) => child.position?.start?.line === lineNumber
|
|
84
|
+
) as Content | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get all headings at a given depth (1-6).
|
|
89
|
+
* Fixed: original had a bug calling this.astQuery.selectAll instead of this.selectAll
|
|
90
|
+
*/
|
|
91
|
+
headingsAtDepth(depth: number): Heading[] {
|
|
92
|
+
return (this.selectAll("heading") as Heading[]).filter(
|
|
93
|
+
(h) => h.depth === depth
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Find the next heading node with the same depth as the given heading. */
|
|
98
|
+
findNextSiblingHeadingTo(headingNode: Heading): Heading | undefined {
|
|
99
|
+
const startLine = headingNode.position?.end?.line ?? 0;
|
|
100
|
+
return (this.selectAll("heading") as Heading[]).find(
|
|
101
|
+
(h) =>
|
|
102
|
+
h.depth === headingNode.depth &&
|
|
103
|
+
(h.position?.start?.line ?? 0) > startLine
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find a heading by its text content. Case insensitive.
|
|
109
|
+
* Pass exact=false for substring matching.
|
|
110
|
+
*/
|
|
111
|
+
findHeadingByText(text: string, exact = true): Heading | undefined {
|
|
112
|
+
return (this.selectAll("heading") as Heading[]).find((heading) => {
|
|
113
|
+
const headingText = toString(heading).toLowerCase();
|
|
114
|
+
return exact
|
|
115
|
+
? headingText.trim() === text.toLowerCase()
|
|
116
|
+
: headingText.includes(text.toLowerCase());
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find all headings matching the text. Case insensitive.
|
|
122
|
+
* Pass exact=false for substring matching.
|
|
123
|
+
*/
|
|
124
|
+
findAllHeadingsByText(text: string, exact = true): Heading[] {
|
|
125
|
+
return (this.selectAll("heading") as Heading[]).filter((heading) => {
|
|
126
|
+
const headingText = toString(heading).toLowerCase();
|
|
127
|
+
return exact
|
|
128
|
+
? headingText.trim() === text.toLowerCase()
|
|
129
|
+
: headingText.includes(text.toLowerCase());
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { loadCollection } from "../load-collection";
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "action",
|
|
7
|
+
description: "Run a named action on the collection",
|
|
8
|
+
},
|
|
9
|
+
args: {
|
|
10
|
+
name: {
|
|
11
|
+
type: "positional",
|
|
12
|
+
description: "Action name",
|
|
13
|
+
required: true,
|
|
14
|
+
},
|
|
15
|
+
rootPath: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Root path for the collection",
|
|
18
|
+
alias: "r",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
async run({ args }) {
|
|
22
|
+
const collection = await loadCollection({
|
|
23
|
+
rootPath: args.rootPath as string | undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const actionName = args.name as string;
|
|
27
|
+
|
|
28
|
+
if (!collection.actions.has(actionName)) {
|
|
29
|
+
console.error(
|
|
30
|
+
`Action "${actionName}" not found. Available: ${collection.availableActions.join(", ") || "(none)"}`
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await collection.runAction(actionName);
|
|
36
|
+
if (result !== undefined) {
|
|
37
|
+
console.log(
|
|
38
|
+
typeof result === "string"
|
|
39
|
+
? result
|
|
40
|
+
: JSON.stringify(result, null, 2)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { loadCollection } from "../load-collection";
|
|
5
|
+
import { kebabCase } from "../../utils/inflect";
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: "create",
|
|
10
|
+
description: "Create a new document for a model type",
|
|
11
|
+
},
|
|
12
|
+
args: {
|
|
13
|
+
model: {
|
|
14
|
+
type: "positional",
|
|
15
|
+
description: "Model name",
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
title: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Document title",
|
|
21
|
+
required: true,
|
|
22
|
+
},
|
|
23
|
+
rootPath: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Root path for the collection",
|
|
26
|
+
alias: "r",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
async run({ args }) {
|
|
30
|
+
const collection = await loadCollection({
|
|
31
|
+
rootPath: args.rootPath as string | undefined,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const modelName = args.model as string;
|
|
35
|
+
const title = args.title as string;
|
|
36
|
+
const def = collection.getModelDefinition(modelName);
|
|
37
|
+
|
|
38
|
+
if (!def) {
|
|
39
|
+
console.error(
|
|
40
|
+
`Model "${modelName}" not found. Available: ${collection.modelDefinitions.map((d) => d.name).join(", ")}`
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const slug = kebabCase(title.toLowerCase());
|
|
46
|
+
const pathId = `${def.prefix}/${slug}`;
|
|
47
|
+
const filePath = path.resolve(
|
|
48
|
+
collection.rootPath,
|
|
49
|
+
`${pathId}.mdx`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const content = `---\n---\n\n# ${title}\n`;
|
|
53
|
+
|
|
54
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
55
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
56
|
+
|
|
57
|
+
console.log(`Created ${filePath}`);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { loadCollection } from "../load-collection";
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "export",
|
|
7
|
+
description: "Export collection as JSON",
|
|
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
|
+
const data = await collection.export();
|
|
22
|
+
console.log(JSON.stringify(data, null, 2));
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "init",
|
|
8
|
+
description: "Initialize a new contentbase project",
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
name: {
|
|
12
|
+
type: "positional",
|
|
13
|
+
description: "Project name",
|
|
14
|
+
required: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
async run({ args }) {
|
|
18
|
+
const name = (args.name as string) || "my-content";
|
|
19
|
+
const dir = path.resolve(process.cwd(), name);
|
|
20
|
+
|
|
21
|
+
await fs.mkdir(dir, { recursive: true });
|
|
22
|
+
await fs.mkdir(path.join(dir, "posts"), { recursive: true });
|
|
23
|
+
|
|
24
|
+
// Create a sample model file
|
|
25
|
+
await fs.writeFile(
|
|
26
|
+
path.join(dir, "models.ts"),
|
|
27
|
+
`import { defineModel, z } from "contentbase";
|
|
28
|
+
|
|
29
|
+
export const Post = defineModel("Post", {
|
|
30
|
+
prefix: "posts",
|
|
31
|
+
meta: z.object({
|
|
32
|
+
status: z.enum(["draft", "published"]).default("draft"),
|
|
33
|
+
author: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
`,
|
|
37
|
+
"utf8"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Create a sample post
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(dir, "posts", "hello-world.mdx"),
|
|
43
|
+
`---
|
|
44
|
+
status: draft
|
|
45
|
+
author: me
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Hello World
|
|
49
|
+
|
|
50
|
+
Welcome to your contentbase project!
|
|
51
|
+
`,
|
|
52
|
+
"utf8"
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Create index.ts
|
|
56
|
+
await fs.writeFile(
|
|
57
|
+
path.join(dir, "index.ts"),
|
|
58
|
+
`import { Collection } from "contentbase";
|
|
59
|
+
import { Post } from "./models";
|
|
60
|
+
|
|
61
|
+
export const collection = new Collection({
|
|
62
|
+
rootPath: import.meta.dir,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
collection.register(Post);
|
|
66
|
+
`,
|
|
67
|
+
"utf8"
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
console.log(`Created contentbase project at ${dir}`);
|
|
71
|
+
console.log(` ${name}/models.ts`);
|
|
72
|
+
console.log(` ${name}/index.ts`);
|
|
73
|
+
console.log(` ${name}/posts/hello-world.mdx`);
|
|
74
|
+
},
|
|
75
|
+
});
|