contentbase 0.0.6 → 0.1.3

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "contentbase",
3
- "version": "0.0.6",
3
+ "version": "0.1.3",
4
+ "repository": "https://github.com/soederpop/contentbase",
4
5
  "website": "https://contentbase.soederpop.com",
5
6
  "type": "module",
6
7
  "main": "./src/index.ts",
@@ -14,10 +15,12 @@
14
15
  "test:watch": "vitest",
15
16
  "typecheck": "tsc --noEmit",
16
17
  "examples": "bun run scripts/examples/run-all.ts",
17
- "compile": "bun build ./src/cli/index.ts --compile --outfile dist/cnotes --external @node-llama-cpp/*"
18
+ "compile": "bun build ./src/cli/index.ts --compile --outfile dist/cnotes --external @node-llama-cpp/*",
19
+ "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/*",
20
+ "release": "./scripts/release.sh"
18
21
  },
19
22
  "dependencies": {
20
- "@soederpop/luca": "^0.0.5",
23
+ "@soederpop/luca": "latest",
21
24
  "gray-matter": "^4.0.3",
22
25
  "js-yaml": "^4.1.0",
23
26
  "mdast-util-mdxjs-esm": "^2.0.1",
@@ -43,12 +43,11 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
43
43
  }
44
44
 
45
45
  // Parse --meta.* flags from raw argv
46
+ // minimist nests dotted keys: --meta.status active → { meta: { status: 'active' } }
46
47
  const metaOverrides: Record<string, unknown> = {}
47
- const rawArgs = context.container.argv
48
- for (const key of Object.keys(rawArgs)) {
49
- if (key.startsWith('meta.')) {
50
- metaOverrides[key.slice(5)] = rawArgs[key]
51
- }
48
+ const rawMeta = context.container.argv.meta
49
+ if (rawMeta && typeof rawMeta === 'object') {
50
+ Object.assign(metaOverrides, rawMeta)
52
51
  }
53
52
 
54
53
  // Build meta from priority layers: zod defaults < definition.defaults < template frontmatter < CLI overrides
@@ -15,3 +15,4 @@ import './help.js'
15
15
  import './text-search.js'
16
16
  import './search.js'
17
17
  import './embed.js'
18
+ import './update.js'
@@ -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
+ })
package/src/cli/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { commands } from './registry.js'
3
+ import pkg from '../../package.json'
3
4
 
4
5
  // Side-effect imports register all commands
5
6
  import './commands/index.js'
@@ -14,6 +15,11 @@ async function main() {
14
15
  const luca = await import('@soederpop/luca/node')
15
16
  const container = luca.default
16
17
 
18
+ if (container.argv.version || container.argv.v) {
19
+ console.log(`cnotes ${pkg.version}\n${pkg.repository}`)
20
+ return
21
+ }
22
+
17
23
  const commandName = container.argv._[0] as string | undefined
18
24
  const wantsHelp = container.argv.help || container.argv.h
19
25
 
@@ -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 import(modulePath);
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 import(modelsPath);
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 = await import(candidate);
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 ───