contentbase 0.0.5 → 0.1.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/package.json +5 -3
- package/src/cli/commands/index.ts +1 -0
- package/src/cli/commands/update.ts +186 -0
- package/src/cli/load-collection.ts +58 -5
- package/src/collection.ts +5 -1
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contentbase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"website": "https://contentbase.soederpop.com",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "./src/index.ts",
|
|
6
7
|
"types": "./src/index.ts",
|
|
@@ -13,10 +14,11 @@
|
|
|
13
14
|
"test:watch": "vitest",
|
|
14
15
|
"typecheck": "tsc --noEmit",
|
|
15
16
|
"examples": "bun run scripts/examples/run-all.ts",
|
|
16
|
-
"compile": "bun build ./src/cli/index.ts --compile --outfile dist/cnotes --external @node-llama-cpp/*"
|
|
17
|
+
"compile": "bun build ./src/cli/index.ts --compile --outfile dist/cnotes --external @node-llama-cpp/*",
|
|
18
|
+
"compile:all": "bun build ./src/cli/index.ts --compile --target=bun-linux-x64 --outfile dist/cnotes-linux-x64 --external @node-llama-cpp/* && bun build ./src/cli/index.ts --compile --target=bun-linux-arm64 --outfile dist/cnotes-linux-arm64 --external @node-llama-cpp/* && bun build ./src/cli/index.ts --compile --target=bun-darwin-x64 --outfile dist/cnotes-darwin-x64 --external @node-llama-cpp/* && bun build ./src/cli/index.ts --compile --target=bun-darwin-arm64 --outfile dist/cnotes-darwin-arm64 --external @node-llama-cpp/* && bun build ./src/cli/index.ts --compile --target=bun-windows-x64 --outfile dist/cnotes-windows-x64.exe --external @node-llama-cpp/*"
|
|
17
19
|
},
|
|
18
20
|
"dependencies": {
|
|
19
|
-
"@soederpop/luca": "
|
|
21
|
+
"@soederpop/luca": "latest",
|
|
20
22
|
"gray-matter": "^4.0.3",
|
|
21
23
|
"js-yaml": "^4.1.0",
|
|
22
24
|
"mdast-util-mdxjs-esm": "^2.0.1",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import picomatch from 'picomatch'
|
|
3
|
+
import { commands } from '../registry.js'
|
|
4
|
+
import { loadCollection } from '../load-collection.js'
|
|
5
|
+
|
|
6
|
+
const argsSchema = z.object({
|
|
7
|
+
contentFolder: z.string().optional(),
|
|
8
|
+
force: z.boolean().default(false),
|
|
9
|
+
dry: z.boolean().default(false),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse "meta.status=spark" into a nested path assignment.
|
|
14
|
+
* Supports dotted paths like "meta.status" or bare keys like "status"
|
|
15
|
+
* (bare keys are treated as meta.<key>).
|
|
16
|
+
*/
|
|
17
|
+
function parseAssignment(raw: string): { path: string[]; value: string } | null {
|
|
18
|
+
const eqIdx = raw.indexOf('=')
|
|
19
|
+
if (eqIdx === -1) return null
|
|
20
|
+
|
|
21
|
+
const key = raw.slice(0, eqIdx).trim()
|
|
22
|
+
const value = raw.slice(eqIdx + 1).trim()
|
|
23
|
+
|
|
24
|
+
// Normalise: if path doesn't start with "meta.", prepend it
|
|
25
|
+
const fullKey = key.startsWith('meta.') ? key : `meta.${key}`
|
|
26
|
+
const path = fullKey.split('.')
|
|
27
|
+
|
|
28
|
+
return { path, value }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coerce a string value to a JS primitive if it looks like one.
|
|
33
|
+
*/
|
|
34
|
+
function coerceValue(raw: string): unknown {
|
|
35
|
+
if (raw === 'true') return true
|
|
36
|
+
if (raw === 'false') return false
|
|
37
|
+
if (raw === 'null') return null
|
|
38
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
|
|
39
|
+
return raw
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set a nested value on an object via a path array.
|
|
44
|
+
* e.g. setNested(obj, ['meta', 'status'], 'spark') → obj.meta.status = 'spark'
|
|
45
|
+
*/
|
|
46
|
+
function setNested(obj: Record<string, any>, path: string[], value: unknown) {
|
|
47
|
+
let current = obj
|
|
48
|
+
for (let i = 1; i < path.length - 1; i++) {
|
|
49
|
+
if (!(path[i] in current) || typeof current[path[i]] !== 'object') {
|
|
50
|
+
current[path[i]] = {}
|
|
51
|
+
}
|
|
52
|
+
current = current[path[i]]
|
|
53
|
+
}
|
|
54
|
+
current[path[path.length - 1]] = value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
|
|
58
|
+
const { container } = context
|
|
59
|
+
const args: string[] = container.argv._.slice(1) // everything after "update"
|
|
60
|
+
|
|
61
|
+
if (args.length < 2) {
|
|
62
|
+
console.error('Usage: cnotes update <glob> <key=value> [key=value ...] [--force] [--dry]')
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const globPattern = args[0]
|
|
67
|
+
const assignments = args.slice(1).map(parseAssignment).filter(Boolean) as { path: string[]; value: string }[]
|
|
68
|
+
|
|
69
|
+
if (assignments.length === 0) {
|
|
70
|
+
console.error('No valid key=value assignments found. Use format: meta.status=spark')
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const collection = await loadCollection({
|
|
75
|
+
contentFolder: options.contentFolder,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Match pathIds against the glob pattern
|
|
79
|
+
const isMatch = picomatch(globPattern)
|
|
80
|
+
const matchedIds = collection.available.filter((id) => isMatch(id))
|
|
81
|
+
|
|
82
|
+
if (matchedIds.length === 0) {
|
|
83
|
+
console.log(`No documents matched "${globPattern}"`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`Matched ${matchedIds.length} document(s)\n`)
|
|
88
|
+
|
|
89
|
+
let updated = 0
|
|
90
|
+
let skipped = 0
|
|
91
|
+
let validationErrors = 0
|
|
92
|
+
|
|
93
|
+
for (const pathId of matchedIds) {
|
|
94
|
+
const doc = collection.document(pathId)
|
|
95
|
+
const def = collection.findModelDefinition(pathId)
|
|
96
|
+
|
|
97
|
+
// Apply assignments to a draft copy of meta for validation
|
|
98
|
+
const draftMeta = structuredClone(doc.meta) as Record<string, any>
|
|
99
|
+
for (const { path, value } of assignments) {
|
|
100
|
+
setNested({ meta: draftMeta }, ['meta', ...path.slice(1)], coerceValue(value))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Validate unless --force
|
|
104
|
+
if (!options.force && def) {
|
|
105
|
+
const rawMeta = { ...(def.defaults ?? {}), ...draftMeta }
|
|
106
|
+
const metaResult = def.meta.safeParse(rawMeta)
|
|
107
|
+
|
|
108
|
+
if (!metaResult.success) {
|
|
109
|
+
validationErrors++
|
|
110
|
+
console.log(`INVALID: ${pathId}`)
|
|
111
|
+
for (const issue of metaResult.error.issues) {
|
|
112
|
+
console.log(` ${issue.path.join('.')}: ${issue.message}`)
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.dry) {
|
|
119
|
+
console.log(`WOULD UPDATE: ${pathId}`)
|
|
120
|
+
for (const { path, value } of assignments) {
|
|
121
|
+
console.log(` ${path.join('.')} = ${value}`)
|
|
122
|
+
}
|
|
123
|
+
updated++
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Apply to the real document
|
|
128
|
+
for (const { path, value } of assignments) {
|
|
129
|
+
setNested({ meta: doc.meta as Record<string, any> }, ['meta', ...path.slice(1)], coerceValue(value))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await doc.save({ normalize: false })
|
|
133
|
+
updated++
|
|
134
|
+
console.log(`UPDATED: ${pathId}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log()
|
|
138
|
+
console.log(`${updated} updated, ${validationErrors} invalid, ${matchedIds.length - updated - validationErrors} skipped`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
commands.register('update', {
|
|
142
|
+
description: 'Bulk-update document frontmatter by glob pattern',
|
|
143
|
+
help: `# cnotes update
|
|
144
|
+
|
|
145
|
+
Bulk-update frontmatter fields on documents matching a glob pattern. Validates changes against the model schema before writing (bypass with \`--force\`).
|
|
146
|
+
|
|
147
|
+
## Usage
|
|
148
|
+
|
|
149
|
+
\`\`\`
|
|
150
|
+
cnotes update <glob> <key=value> [key=value ...] [options]
|
|
151
|
+
\`\`\`
|
|
152
|
+
|
|
153
|
+
## Arguments
|
|
154
|
+
|
|
155
|
+
| Argument | Description |
|
|
156
|
+
|----------|-------------|
|
|
157
|
+
| \`glob\` | Glob pattern to match document path IDs (e.g. \`docs/ideas/**\`) |
|
|
158
|
+
| \`key=value\` | One or more assignments. Prefix with \`meta.\` or use bare keys (auto-prefixed). |
|
|
159
|
+
|
|
160
|
+
## Options
|
|
161
|
+
|
|
162
|
+
| Option | Description |
|
|
163
|
+
|--------|-------------|
|
|
164
|
+
| \`--force\` | Skip schema validation |
|
|
165
|
+
| \`--dry\` | Preview changes without writing |
|
|
166
|
+
| \`--contentFolder\` | Path to content folder |
|
|
167
|
+
|
|
168
|
+
## Examples
|
|
169
|
+
|
|
170
|
+
\`\`\`bash
|
|
171
|
+
# Set status on all ideas
|
|
172
|
+
cnotes update "ideas/**" meta.status=spark
|
|
173
|
+
|
|
174
|
+
# Multiple fields at once
|
|
175
|
+
cnotes update "epics/*" status=approved priority=high
|
|
176
|
+
|
|
177
|
+
# Preview what would change
|
|
178
|
+
cnotes update "ideas/**" status=spark --dry
|
|
179
|
+
|
|
180
|
+
# Force past schema validation
|
|
181
|
+
cnotes update "ideas/**" status=spark --force
|
|
182
|
+
\`\`\`
|
|
183
|
+
`,
|
|
184
|
+
argsSchema,
|
|
185
|
+
handler,
|
|
186
|
+
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { existsSync } from "fs";
|
|
3
4
|
import { Collection } from "../collection";
|
|
4
5
|
import { defineModel } from "../define-model";
|
|
5
6
|
import { singularize, upperFirst } from "../utils/inflect";
|
|
7
|
+
import * as contentbaseExports from "../index";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Search for a file with the given basename and common extensions.
|
|
@@ -97,15 +99,55 @@ async function autoDiscoverModels(collection: Collection): Promise<number> {
|
|
|
97
99
|
return registered;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/** Seed the luca VM with contentbase and common deps so models.ts can resolve imports */
|
|
103
|
+
function seedContentbaseModules(container: any): void {
|
|
104
|
+
const vm = container.feature('vm')
|
|
105
|
+
|
|
106
|
+
// Seed luca modules first
|
|
107
|
+
const helpers = container.feature('helpers')
|
|
108
|
+
if (helpers?.seedVirtualModules) {
|
|
109
|
+
helpers.seedVirtualModules()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
vm.defineModule('contentbase', contentbaseExports)
|
|
113
|
+
try { vm.defineModule('js-yaml', require('js-yaml')) } catch {}
|
|
114
|
+
try { vm.defineModule('mdast-util-to-string', require('mdast-util-to-string')) } catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build a VM-backed module loader using the luca container */
|
|
118
|
+
function createVmModuleLoader(container: any): (filePath: string) => Record<string, any> {
|
|
119
|
+
let seeded = false
|
|
120
|
+
return (filePath: string) => {
|
|
121
|
+
if (!seeded) {
|
|
122
|
+
seedContentbaseModules(container)
|
|
123
|
+
seeded = true
|
|
124
|
+
}
|
|
125
|
+
return container.feature('vm').loadModule(filePath)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
export async function loadCollection(options: {
|
|
101
130
|
contentFolder?: string;
|
|
102
131
|
modulePath?: string;
|
|
132
|
+
container?: any;
|
|
103
133
|
}): Promise<Collection> {
|
|
104
134
|
let { contentFolder, modulePath } = options;
|
|
135
|
+
let container = options.container;
|
|
105
136
|
let rootPath: string | undefined;
|
|
106
137
|
|
|
107
138
|
const cwd = process.cwd();
|
|
108
139
|
|
|
140
|
+
// If no container was passed, try to grab the luca singleton.
|
|
141
|
+
// This works when running inside the cnotes CLI (which imports @soederpop/luca/node).
|
|
142
|
+
if (!container) {
|
|
143
|
+
try {
|
|
144
|
+
const luca = await import('@soederpop/luca/node');
|
|
145
|
+
container = luca.default;
|
|
146
|
+
} catch {
|
|
147
|
+
// Not running in a luca context — that's fine, native imports will be used
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
if (contentFolder) {
|
|
110
152
|
// Resolve relative to cwd
|
|
111
153
|
rootPath = path.resolve(cwd, contentFolder);
|
|
@@ -124,13 +166,25 @@ export async function loadCollection(options: {
|
|
|
124
166
|
rootPath = rootPath ?? path.resolve(cwd, "docs");
|
|
125
167
|
}
|
|
126
168
|
|
|
169
|
+
// Determine if we need a VM-based loader (no contentbase in node_modules)
|
|
170
|
+
const needsVmLoader = container?.feature && !existsSync(path.resolve(cwd, "node_modules", "contentbase"));
|
|
171
|
+
const moduleLoader = needsVmLoader ? createVmModuleLoader(container) : undefined;
|
|
172
|
+
|
|
173
|
+
// Helper to import a module file, falling back to VM when needed
|
|
174
|
+
const importModule = async (filePath: string): Promise<Record<string, any>> => {
|
|
175
|
+
if (moduleLoader) {
|
|
176
|
+
return moduleLoader(filePath);
|
|
177
|
+
}
|
|
178
|
+
return import(filePath);
|
|
179
|
+
};
|
|
180
|
+
|
|
127
181
|
// Tier 1: index.ts — full collection with models already registered
|
|
128
182
|
if (!modulePath) {
|
|
129
183
|
modulePath = await findFile(rootPath, "index");
|
|
130
184
|
}
|
|
131
185
|
|
|
132
186
|
if (modulePath) {
|
|
133
|
-
const mod = await
|
|
187
|
+
const mod = await importModule(modulePath);
|
|
134
188
|
const collection = mod.collection ?? mod.default;
|
|
135
189
|
if (!(collection instanceof Collection)) {
|
|
136
190
|
throw new Error(
|
|
@@ -145,8 +199,8 @@ export async function loadCollection(options: {
|
|
|
145
199
|
const modelsPath = await findFile(rootPath, "models");
|
|
146
200
|
|
|
147
201
|
if (modelsPath) {
|
|
148
|
-
const mod = await
|
|
149
|
-
const collection = new Collection({ rootPath });
|
|
202
|
+
const mod = await importModule(modelsPath);
|
|
203
|
+
const collection = new Collection({ rootPath, moduleLoader });
|
|
150
204
|
|
|
151
205
|
let registered = 0;
|
|
152
206
|
for (const [, value] of Object.entries(mod)) {
|
|
@@ -156,13 +210,12 @@ export async function loadCollection(options: {
|
|
|
156
210
|
}
|
|
157
211
|
}
|
|
158
212
|
|
|
159
|
-
|
|
160
213
|
await collection.load();
|
|
161
214
|
return collection;
|
|
162
215
|
}
|
|
163
216
|
|
|
164
217
|
// Tier 3: Auto-discover models from folder structure
|
|
165
|
-
const collection = new Collection({ rootPath });
|
|
218
|
+
const collection = new Collection({ rootPath, moduleLoader });
|
|
166
219
|
const discovered = await autoDiscoverModels(collection);
|
|
167
220
|
|
|
168
221
|
if (discovered > 0) {
|
package/src/collection.ts
CHANGED
|
@@ -117,12 +117,14 @@ export class Collection {
|
|
|
117
117
|
new Map();
|
|
118
118
|
#loaded = false;
|
|
119
119
|
#autoDiscover: boolean;
|
|
120
|
+
#moduleLoader?: (filePath: string) => Record<string, any> | Promise<Record<string, any>>;
|
|
120
121
|
|
|
121
122
|
constructor(options: CollectionOptions) {
|
|
122
123
|
this.rootPath = path.resolve(options.rootPath);
|
|
123
124
|
this.name = options.name ?? options.rootPath;
|
|
124
125
|
this.extensions = options.extensions ?? ["mdx", "md"];
|
|
125
126
|
this.#autoDiscover = options.autoDiscover ?? true;
|
|
127
|
+
this.#moduleLoader = options.moduleLoader;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
// ─── Model registration ───
|
|
@@ -159,7 +161,9 @@ export class Collection {
|
|
|
159
161
|
for (const ext of ["ts", "js", "mjs"]) {
|
|
160
162
|
const candidate = path.resolve(this.rootPath, `models.${ext}`);
|
|
161
163
|
try {
|
|
162
|
-
const mod =
|
|
164
|
+
const mod = this.#moduleLoader
|
|
165
|
+
? await this.#moduleLoader(candidate)
|
|
166
|
+
: await import(candidate);
|
|
163
167
|
for (const value of Object.values(mod)) {
|
|
164
168
|
if (isModelDefinition(value) && !this.#models.has((value as any).name)) {
|
|
165
169
|
this.register(value as any);
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface CollectionOptions {
|
|
|
22
22
|
name?: string;
|
|
23
23
|
/** When true (default), load() looks for a models.{ts,js,mjs} in rootPath and auto-registers exported model definitions. */
|
|
24
24
|
autoDiscover?: boolean;
|
|
25
|
+
/** Optional custom module loader for models.ts discovery. When provided, used instead of native import(). Enables VM-based loading in compiled binaries where node_modules aren't available. */
|
|
26
|
+
moduleLoader?: (filePath: string) => Record<string, any> | Promise<Record<string, any>>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
// ─── Section system ───
|