contentbase 0.1.8 → 0.4.0
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/CNAME +1 -0
- package/LAUNCH-PLAN.md +419 -0
- package/README.md +90 -0
- package/index.html +1636 -0
- package/package.json +5 -4
- package/src/adapters/node-fs.ts +80 -0
- package/src/cli/commands/api/endpoints/doc.ts +2 -2
- package/src/cli/commands/api/endpoints/document.ts +2 -2
- package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
- package/src/cli/commands/api/endpoints/search-status.ts +3 -20
- package/src/cli/commands/api/endpoints/search.ts +3 -33
- package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
- package/src/cli/commands/embed.ts +8 -81
- package/src/cli/commands/mcp.ts +5 -1128
- package/src/cli/commands/search.ts +25 -90
- package/src/cli/commands/serve.ts +7 -32
- package/src/cli/index.ts +1 -1
- package/src/cli/load-collection.ts +2 -2
- package/src/collection.ts +42 -33
- package/src/document.ts +18 -0
- package/src/extract-sections.ts +3 -0
- package/src/index.ts +10 -0
- package/src/mcp/helpers.ts +7 -0
- package/src/mcp/model-info.ts +102 -0
- package/src/mcp/prompts.ts +199 -0
- package/src/mcp/readme.ts +153 -0
- package/src/mcp/resources.ts +89 -0
- package/src/mcp/server.ts +103 -0
- package/src/mcp/tools/mutation.ts +176 -0
- package/src/mcp/tools/query.ts +141 -0
- package/src/mcp/tools/search.ts +180 -0
- package/src/parse.ts +5 -0
- package/src/query/query-dsl.ts +1 -1
- package/src/relationships/has-many.ts +4 -2
- package/src/search/document-inputs.ts +85 -0
- package/src/search/luca-semantic-search.ts +75 -0
- package/src/types.ts +54 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/match-pattern.ts +2 -2
- package/src/utils/strip-markdown.ts +59 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
3
2
|
import path from 'node:path'
|
|
4
3
|
import { commands } from '../registry.js'
|
|
5
4
|
import { loadCollection } from '../load-collection.js'
|
|
5
|
+
import { collectDocumentInputs } from '../../search/document-inputs.js'
|
|
6
|
+
import { getInitializedSemanticSearch, hasSearchIndex } from '../../search/luca-semantic-search.js'
|
|
6
7
|
|
|
7
8
|
const argsSchema = z.object({
|
|
8
9
|
mode: z.enum(['hybrid', 'keyword', 'vector']).default('hybrid'),
|
|
@@ -15,31 +16,8 @@ const argsSchema = z.object({
|
|
|
15
16
|
contentFolder: z.string().optional(),
|
|
16
17
|
})
|
|
17
18
|
|
|
18
|
-
function hasSearchIndex(rootPath: string): boolean {
|
|
19
|
-
const dbDir = path.join(rootPath, '.contentbase')
|
|
20
|
-
if (!existsSync(dbDir)) return false
|
|
21
|
-
try {
|
|
22
|
-
const files = readdirSync(dbDir) as string[]
|
|
23
|
-
return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
|
|
24
|
-
} catch {
|
|
25
|
-
return false
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function getSemanticSearch(container: any, rootPath: string) {
|
|
30
|
-
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
31
|
-
if (!container.features.available.includes('semanticSearch')) {
|
|
32
|
-
SemanticSearch.attach(container)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const dbPath = path.join(rootPath, '.contentbase/search.sqlite')
|
|
36
|
-
const ss = container.feature('semanticSearch', { dbPath })
|
|
37
|
-
await ss.initDb()
|
|
38
|
-
return ss
|
|
39
|
-
}
|
|
40
|
-
|
|
41
19
|
async function buildIndex(container: any, collection: any) {
|
|
42
|
-
const ss = await
|
|
20
|
+
const ss = await getInitializedSemanticSearch(container, collection.rootPath)
|
|
43
21
|
const docs = collectDocumentInputs(collection)
|
|
44
22
|
|
|
45
23
|
const toIndex = docs.filter((doc: any) => ss.needsReindex(doc))
|
|
@@ -61,56 +39,6 @@ async function buildIndex(container: any, collection: any) {
|
|
|
61
39
|
return ss
|
|
62
40
|
}
|
|
63
41
|
|
|
64
|
-
function collectDocumentInputs(collection: any) {
|
|
65
|
-
const inputs: any[] = []
|
|
66
|
-
for (const pathId of collection.available) {
|
|
67
|
-
const doc = collection.document(pathId)
|
|
68
|
-
const modelDef = collection.findModelDefinition(pathId)
|
|
69
|
-
|
|
70
|
-
const sections: any[] = []
|
|
71
|
-
const lines = (doc.content as string).split('\n')
|
|
72
|
-
let currentHeading: string | null = null
|
|
73
|
-
let currentContent: string[] = []
|
|
74
|
-
|
|
75
|
-
for (const line of lines) {
|
|
76
|
-
const h2Match = line.match(/^## (.+)/)
|
|
77
|
-
if (h2Match) {
|
|
78
|
-
if (currentHeading) {
|
|
79
|
-
sections.push({
|
|
80
|
-
heading: currentHeading,
|
|
81
|
-
headingPath: currentHeading,
|
|
82
|
-
content: currentContent.join('\n').trim(),
|
|
83
|
-
level: 2,
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
currentHeading = h2Match[1].trim()
|
|
87
|
-
currentContent = []
|
|
88
|
-
} else if (currentHeading) {
|
|
89
|
-
currentContent.push(line)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (currentHeading) {
|
|
93
|
-
sections.push({
|
|
94
|
-
heading: currentHeading,
|
|
95
|
-
headingPath: currentHeading,
|
|
96
|
-
content: currentContent.join('\n').trim(),
|
|
97
|
-
level: 2,
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
inputs.push({
|
|
102
|
-
pathId,
|
|
103
|
-
model: modelDef?.name ?? undefined,
|
|
104
|
-
title: doc.title,
|
|
105
|
-
slug: doc.slug,
|
|
106
|
-
meta: doc.meta,
|
|
107
|
-
content: doc.content,
|
|
108
|
-
sections: sections.length > 0 ? sections : undefined,
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
return inputs
|
|
112
|
-
}
|
|
113
|
-
|
|
114
42
|
async function handler(options: z.infer<typeof argsSchema>, { container }: { container: any }) {
|
|
115
43
|
const ui = container.feature('ui')
|
|
116
44
|
const query = container.argv._[1] as string | undefined
|
|
@@ -140,7 +68,7 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
|
|
|
140
68
|
if (options.bootstrap && !hasSearchIndex(collection.rootPath)) {
|
|
141
69
|
ss = await buildIndex(container, collection)
|
|
142
70
|
} else {
|
|
143
|
-
ss = await
|
|
71
|
+
ss = await getInitializedSemanticSearch(container, collection.rootPath)
|
|
144
72
|
}
|
|
145
73
|
|
|
146
74
|
// Parse where clause: "key=value,key2=value2"
|
|
@@ -193,22 +121,29 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
|
|
|
193
121
|
return
|
|
194
122
|
}
|
|
195
123
|
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
124
|
+
const colors = ui.colors
|
|
125
|
+
const cols = process.stdout.columns || 80
|
|
126
|
+
const pad = ' '
|
|
127
|
+
const maxSnippet = cols - pad.length - 2
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < results.length; i++) {
|
|
130
|
+
const r = results[i]
|
|
131
|
+
const title = r.title || r.pathId
|
|
132
|
+
console.log(`${colors.dim(`${i + 1}.`)} ${colors.bold(title)}`)
|
|
133
|
+
let relPath = r.pathId
|
|
134
|
+
try { relPath = path.relative(process.cwd(), collection.document(r.pathId).path) } catch {}
|
|
135
|
+
console.log(`${pad}${colors.cyan(relPath)}`)
|
|
202
136
|
if (r.snippet) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
137
|
+
let snippet = r.snippet
|
|
138
|
+
.replace(/>>>/g, '').replace(/<<</g, '')
|
|
139
|
+
.replace(/\n/g, ' ').replace(/\s+/g, ' ')
|
|
140
|
+
.replace(/[`*_~\[\]]/g, '').trim()
|
|
141
|
+
if (r.matchedSection) snippet = `${r.matchedSection} — ${snippet}`
|
|
142
|
+
if (snippet.length > maxSnippet) snippet = snippet.substring(0, maxSnippet - 1) + '…'
|
|
143
|
+
console.log(`${pad}${colors.dim(snippet)}`)
|
|
206
144
|
}
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const table = [header, separator, ...rows].join('\n')
|
|
211
|
-
ui.markdown(table)
|
|
145
|
+
if (i < results.length - 1) console.log()
|
|
146
|
+
}
|
|
212
147
|
|
|
213
148
|
if (options.full) {
|
|
214
149
|
for (const r of results) {
|
|
@@ -5,6 +5,8 @@ import { commands } from '../registry.js'
|
|
|
5
5
|
import { loadCollection } from '../load-collection.js'
|
|
6
6
|
import { buildSchemaJSON } from './api/helpers.js'
|
|
7
7
|
import { builtinEndpoints } from './api/endpoints/index.js'
|
|
8
|
+
import { collectDocumentInputs } from '../../search/document-inputs.js'
|
|
9
|
+
import { getInitializedSemanticSearch, hasSearchIndex } from '../../search/luca-semantic-search.js'
|
|
8
10
|
|
|
9
11
|
const argsSchema = z.object({
|
|
10
12
|
port: z.number().default(8000),
|
|
@@ -184,39 +186,12 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
|
|
|
184
186
|
// Search index auto-update
|
|
185
187
|
// ---------------------------------------------------------------------------
|
|
186
188
|
if (options.search) {
|
|
187
|
-
|
|
188
|
-
const dbDir = path.join(collection.rootPath, '.contentbase')
|
|
189
|
-
const hasIndex = existsSync(dbDir) && (() => {
|
|
189
|
+
if (hasSearchIndex(collection.rootPath)) {
|
|
190
190
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (hasIndex) {
|
|
196
|
-
try {
|
|
197
|
-
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
198
|
-
if (!container.features.available.includes('semanticSearch')) {
|
|
199
|
-
SemanticSearch.attach(container)
|
|
200
|
-
}
|
|
201
|
-
const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
|
|
202
|
-
const ss = container.feature('semanticSearch', { dbPath }) as any
|
|
203
|
-
await ss.initDb()
|
|
204
|
-
|
|
205
|
-
// Collect document inputs
|
|
206
|
-
const docs: any[] = []
|
|
207
|
-
for (const pathId of collection.available) {
|
|
208
|
-
const doc = collection.document(pathId) as any
|
|
209
|
-
docs.push({
|
|
210
|
-
pathId,
|
|
211
|
-
model: collection.findModelDefinition(pathId)?.name ?? undefined,
|
|
212
|
-
title: doc.title,
|
|
213
|
-
meta: doc.meta,
|
|
214
|
-
content: doc.content,
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const stale = docs.filter((d: any) => ss.needsReindex(d))
|
|
219
|
-
ss.removeStale(docs.map((d: any) => d.pathId))
|
|
191
|
+
const ss = await getInitializedSemanticSearch(container, collection.rootPath)
|
|
192
|
+
const docs = collectDocumentInputs(collection)
|
|
193
|
+
const stale = docs.filter((d) => ss.needsReindex(d))
|
|
194
|
+
ss.removeStale(docs.map((d) => d.pathId))
|
|
220
195
|
|
|
221
196
|
if (stale.length > 0) {
|
|
222
197
|
console.log(`[search] Updating ${stale.length} stale document(s)...`)
|
package/src/cli/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ async function renderMarkdown(container: any, text: string) {
|
|
|
12
12
|
|
|
13
13
|
async function main() {
|
|
14
14
|
// Dynamic import so the library stays luca-free; only the CLI pulls it in
|
|
15
|
-
const luca = await import('
|
|
15
|
+
const luca = await import('luca/node')
|
|
16
16
|
const container = luca.default
|
|
17
17
|
|
|
18
18
|
const commandName = container.argv._[0] as string | undefined
|
|
@@ -138,10 +138,10 @@ export async function loadCollection(options: {
|
|
|
138
138
|
const cwd = process.cwd();
|
|
139
139
|
|
|
140
140
|
// If no container was passed, try to grab the luca singleton.
|
|
141
|
-
// This works when running inside the cnotes CLI (which imports
|
|
141
|
+
// This works when running inside the cnotes CLI (which imports luca/node).
|
|
142
142
|
if (!container) {
|
|
143
143
|
try {
|
|
144
|
-
const luca = await import('
|
|
144
|
+
const luca = await import('luca/node');
|
|
145
145
|
container = luca.default;
|
|
146
146
|
} catch {
|
|
147
147
|
// Not running in a luca context — that's fine, native imports will be used
|
package/src/collection.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import fs from "fs/promises";
|
|
2
1
|
import path from "path";
|
|
3
2
|
import matter from "gray-matter";
|
|
4
3
|
import picomatch from "picomatch";
|
|
5
4
|
import { Document } from "./document";
|
|
6
5
|
import { CollectionQuery } from "./query/collection-query";
|
|
7
6
|
import { createModelInstance } from "./model-instance";
|
|
8
|
-
import { readDirectory } from "./utils/read-directory";
|
|
9
7
|
import { pluralize } from "./utils/inflect";
|
|
10
8
|
import { Base } from "./base-model";
|
|
9
|
+
import { NodeStorageAdapter } from "./adapters/node-fs";
|
|
11
10
|
import type {
|
|
12
11
|
ModelDefinition,
|
|
13
12
|
CollectionItem,
|
|
@@ -15,6 +14,7 @@ import type {
|
|
|
15
14
|
InferModelInstance,
|
|
16
15
|
HasManyDefinition,
|
|
17
16
|
RelationshipDefinition,
|
|
17
|
+
StorageAdapter,
|
|
18
18
|
} from "./types";
|
|
19
19
|
|
|
20
20
|
// ─── Zod schema introspection ───
|
|
@@ -110,6 +110,7 @@ export class Collection {
|
|
|
110
110
|
readonly name: string;
|
|
111
111
|
readonly extensions: string[];
|
|
112
112
|
|
|
113
|
+
#adapter: StorageAdapter;
|
|
113
114
|
#items: Map<string, CollectionItem> = new Map();
|
|
114
115
|
#documents: Map<string, Document> = new Map();
|
|
115
116
|
#models: Map<string, ModelDefinition<any, any, any, any, any>> = new Map();
|
|
@@ -120,7 +121,13 @@ export class Collection {
|
|
|
120
121
|
#moduleLoader?: (filePath: string) => Record<string, any> | Promise<Record<string, any>>;
|
|
121
122
|
|
|
122
123
|
constructor(options: CollectionOptions) {
|
|
123
|
-
this
|
|
124
|
+
this.#adapter = options.adapter ?? new NodeStorageAdapter();
|
|
125
|
+
// Resolve to an absolute path for local FS (NodeStorageAdapter default).
|
|
126
|
+
// When a custom adapter is provided, rootPath is the backend-specific root
|
|
127
|
+
// (e.g. a bucket prefix for R2) and is stored as-is.
|
|
128
|
+
this.rootPath = options.adapter
|
|
129
|
+
? options.rootPath
|
|
130
|
+
: path.resolve(options.rootPath);
|
|
124
131
|
this.name = options.name ?? options.rootPath;
|
|
125
132
|
this.extensions = options.extensions ?? ["mdx", "md"];
|
|
126
133
|
this.#autoDiscover = options.autoDiscover ?? true;
|
|
@@ -191,9 +198,17 @@ export class Collection {
|
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
get available(): string[] {
|
|
194
|
-
return Array.from(this.#items.keys())
|
|
195
|
-
(id) => !this.#isExcludedByModel(id)
|
|
196
|
-
|
|
201
|
+
return Array.from(this.#items.keys())
|
|
202
|
+
.filter((id) => !this.#isExcludedByModel(id))
|
|
203
|
+
.sort();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Match available pathIds against one or more glob patterns.
|
|
208
|
+
*/
|
|
209
|
+
matchPaths(patterns: string | string[]): string[] {
|
|
210
|
+
const isMatch = picomatch(patterns);
|
|
211
|
+
return this.available.filter((id) => isMatch(id));
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
/**
|
|
@@ -244,27 +259,26 @@ export class Collection {
|
|
|
244
259
|
`\\.(${this.extensions.join("|")})$`,
|
|
245
260
|
"i"
|
|
246
261
|
);
|
|
247
|
-
const
|
|
262
|
+
const entries = await this.#adapter.listFiles(this.rootPath, extensionPattern);
|
|
248
263
|
|
|
249
264
|
await Promise.all(
|
|
250
|
-
|
|
251
|
-
const pathId = this.getPathId(
|
|
265
|
+
entries.map(async ({ key, stat }) => {
|
|
266
|
+
const pathId = this.getPathId(key);
|
|
252
267
|
|
|
253
268
|
// Globally exclude templates directory — templates are only used
|
|
254
269
|
// for scaffolding and should never appear in queries or listings
|
|
255
270
|
if (pathId.startsWith("templates/") || pathId === "templates") return;
|
|
256
271
|
|
|
257
|
-
const raw = await
|
|
258
|
-
const stat = await fs.stat(filePath);
|
|
272
|
+
const raw = await this.#adapter.readFile(key);
|
|
259
273
|
const { data, content } = matter(raw);
|
|
260
274
|
|
|
261
275
|
this.#items.set(pathId, {
|
|
262
276
|
raw,
|
|
263
277
|
content,
|
|
264
278
|
meta: data,
|
|
265
|
-
path:
|
|
266
|
-
createdAt: stat.
|
|
267
|
-
updatedAt: stat.
|
|
279
|
+
path: key,
|
|
280
|
+
createdAt: stat.createdAt,
|
|
281
|
+
updatedAt: stat.updatedAt,
|
|
268
282
|
size: stat.size,
|
|
269
283
|
});
|
|
270
284
|
})
|
|
@@ -440,8 +454,7 @@ export class Collection {
|
|
|
440
454
|
const item = this.#items.get(pathId)!;
|
|
441
455
|
const filePath = item.path;
|
|
442
456
|
|
|
443
|
-
await
|
|
444
|
-
await fs.writeFile(filePath, options.content, "utf8");
|
|
457
|
+
await this.#adapter.writeFile(filePath, options.content);
|
|
445
458
|
|
|
446
459
|
// Update the stored item
|
|
447
460
|
item.raw = options.content;
|
|
@@ -460,11 +473,7 @@ export class Collection {
|
|
|
460
473
|
const item = this.#items.get(pathId);
|
|
461
474
|
if (!item) return this;
|
|
462
475
|
|
|
463
|
-
|
|
464
|
-
await fs.rm(item.path);
|
|
465
|
-
} catch {
|
|
466
|
-
// File might not exist
|
|
467
|
-
}
|
|
476
|
+
await this.#adapter.deleteFile(item.path);
|
|
468
477
|
this.#items.delete(pathId);
|
|
469
478
|
this.#documents.delete(pathId);
|
|
470
479
|
return this;
|
|
@@ -482,8 +491,8 @@ export class Collection {
|
|
|
482
491
|
filePath = this.resolve(`${pathId}.${extension}`);
|
|
483
492
|
}
|
|
484
493
|
|
|
485
|
-
const raw = await
|
|
486
|
-
const stat = await
|
|
494
|
+
const raw = await this.#adapter.readFile(filePath);
|
|
495
|
+
const stat = await this.#adapter.stat(filePath);
|
|
487
496
|
const { data, content } = matter(raw);
|
|
488
497
|
|
|
489
498
|
const item: CollectionItem = {
|
|
@@ -491,8 +500,8 @@ export class Collection {
|
|
|
491
500
|
content,
|
|
492
501
|
meta: data,
|
|
493
502
|
path: filePath,
|
|
494
|
-
createdAt: stat.
|
|
495
|
-
updatedAt: stat.
|
|
503
|
+
createdAt: stat.createdAt,
|
|
504
|
+
updatedAt: stat.updatedAt,
|
|
496
505
|
size: stat.size,
|
|
497
506
|
};
|
|
498
507
|
|
|
@@ -542,11 +551,11 @@ export class Collection {
|
|
|
542
551
|
// ─── Utilities ───
|
|
543
552
|
|
|
544
553
|
resolve(...args: string[]): string {
|
|
545
|
-
return
|
|
554
|
+
return this.#adapter.join(this.rootPath, ...args);
|
|
546
555
|
}
|
|
547
556
|
|
|
548
|
-
getPathId(
|
|
549
|
-
const relativePath =
|
|
557
|
+
getPathId(key: string): string {
|
|
558
|
+
const relativePath = this.#adapter.relative(this.rootPath, key);
|
|
550
559
|
return relativePath.replace(/\.[a-z]+$/i, "");
|
|
551
560
|
}
|
|
552
561
|
|
|
@@ -669,7 +678,7 @@ export class Collection {
|
|
|
669
678
|
|
|
670
679
|
for (const pathId of sorted) {
|
|
671
680
|
const item = this.#items.get(pathId)!;
|
|
672
|
-
const ext =
|
|
681
|
+
const ext = this.#adapter.extname(item.path);
|
|
673
682
|
const fullPath = `${pathId}${ext}`;
|
|
674
683
|
const parts = fullPath.split("/");
|
|
675
684
|
|
|
@@ -703,7 +712,7 @@ export class Collection {
|
|
|
703
712
|
|
|
704
713
|
#tocEntry(pathId: string, basePath: string): string {
|
|
705
714
|
const item = this.#items.get(pathId)!;
|
|
706
|
-
const ext =
|
|
715
|
+
const ext = this.#adapter.extname(item.path);
|
|
707
716
|
const relativePath = `${basePath}/${pathId}${ext}`;
|
|
708
717
|
const doc = this.document(pathId);
|
|
709
718
|
return `- [${doc.title}](${relativePath})`;
|
|
@@ -774,12 +783,12 @@ export class Collection {
|
|
|
774
783
|
*/
|
|
775
784
|
async saveModelSummary(options: { includeIds?: boolean } = {}): Promise<string> {
|
|
776
785
|
const summary = this.generateModelSummary(options);
|
|
777
|
-
const modelsPath =
|
|
786
|
+
const modelsPath = this.#adapter.join(this.rootPath, "README.md");
|
|
778
787
|
|
|
779
788
|
// Preserve existing Overview section content
|
|
780
789
|
let overview = "";
|
|
781
790
|
try {
|
|
782
|
-
const existing = await
|
|
791
|
+
const existing = await this.#adapter.readFile(modelsPath);
|
|
783
792
|
const overviewStart = existing.indexOf("## Overview");
|
|
784
793
|
if (overviewStart !== -1) {
|
|
785
794
|
const contentStart = existing.indexOf("\n", overviewStart) + 1;
|
|
@@ -807,7 +816,7 @@ export class Collection {
|
|
|
807
816
|
];
|
|
808
817
|
|
|
809
818
|
const markdown = lines.join("\n");
|
|
810
|
-
await
|
|
819
|
+
await this.#adapter.writeFile(modelsPath, markdown);
|
|
811
820
|
return summary;
|
|
812
821
|
}
|
|
813
822
|
|
package/src/document.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { unified } from "unified";
|
|
2
2
|
import remarkParse from "remark-parse";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkRehype from "remark-rehype";
|
|
5
|
+
import rehypeStringify from "rehype-stringify";
|
|
4
6
|
import yaml from "js-yaml";
|
|
5
7
|
import { toString } from "mdast-util-to-string";
|
|
6
8
|
import { kebabCase } from "./utils/inflect";
|
|
@@ -9,6 +11,8 @@ import { NodeShortcuts } from "./node-shortcuts";
|
|
|
9
11
|
import { stringifyAst } from "./utils/stringify-ast";
|
|
10
12
|
import { normalizeHeadings } from "./utils/normalize-headings";
|
|
11
13
|
import { parseTable } from "./utils/parse-table";
|
|
14
|
+
import { stripMarkdown } from "./utils/strip-markdown";
|
|
15
|
+
import type { StripMarkdownOptions } from "./utils/strip-markdown";
|
|
12
16
|
import type { Root, Content, RootContent, Heading } from "mdast";
|
|
13
17
|
import type { Collection } from "./collection";
|
|
14
18
|
|
|
@@ -443,6 +447,10 @@ export class Document {
|
|
|
443
447
|
};
|
|
444
448
|
}
|
|
445
449
|
|
|
450
|
+
stripMarkdown(options?: StripMarkdownOptions): string {
|
|
451
|
+
return stripMarkdown(this.ast, options);
|
|
452
|
+
}
|
|
453
|
+
|
|
446
454
|
toText(
|
|
447
455
|
filterFn: (node: Content) => boolean = () => true
|
|
448
456
|
): string {
|
|
@@ -457,6 +465,16 @@ export class Document {
|
|
|
457
465
|
* Each heading is indented based on its depth relative to the
|
|
458
466
|
* minimum heading depth found in the document.
|
|
459
467
|
*/
|
|
468
|
+
async toHtml(): Promise<string> {
|
|
469
|
+
const result = await unified()
|
|
470
|
+
.use(remarkParse)
|
|
471
|
+
.use(remarkGfm)
|
|
472
|
+
.use(remarkRehype)
|
|
473
|
+
.use(rehypeStringify)
|
|
474
|
+
.process(this.content);
|
|
475
|
+
return String(result);
|
|
476
|
+
}
|
|
477
|
+
|
|
460
478
|
toOutline(): string {
|
|
461
479
|
const headings = (this.ast.children as Content[]).filter(
|
|
462
480
|
(n): n is Heading => n.type === "heading"
|
package/src/extract-sections.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { toString } from "mdast-util-to-string";
|
|
|
2
2
|
import { AstQuery } from "./ast-query";
|
|
3
3
|
import { NodeShortcuts } from "./node-shortcuts";
|
|
4
4
|
import { stringifyAst } from "./utils/stringify-ast";
|
|
5
|
+
import { stripMarkdown } from "./utils/strip-markdown";
|
|
6
|
+
import type { StripMarkdownOptions } from "./utils/strip-markdown";
|
|
5
7
|
import type { Root, Content, RootContent, Heading } from "mdast";
|
|
6
8
|
import type { ParsedDocument } from "./parse";
|
|
7
9
|
|
|
@@ -150,6 +152,7 @@ function buildParsedDocument(ast: Root): ParsedDocument {
|
|
|
150
152
|
nodes,
|
|
151
153
|
title,
|
|
152
154
|
stringify: (tree: Root = ast) => stringifyAst(tree),
|
|
155
|
+
stripMarkdown: (options?: StripMarkdownOptions) => stripMarkdown(ast, options),
|
|
153
156
|
extractSection,
|
|
154
157
|
querySection,
|
|
155
158
|
};
|
package/src/index.ts
CHANGED
|
@@ -39,8 +39,15 @@ export { validateDocument } from "./validator";
|
|
|
39
39
|
// Pattern matching
|
|
40
40
|
export { matchPattern, matchPatterns } from "./utils/match-pattern";
|
|
41
41
|
|
|
42
|
+
// Markdown utilities
|
|
43
|
+
export { stripMarkdown } from "./utils/strip-markdown";
|
|
44
|
+
export type { StripMarkdownOptions } from "./utils/strip-markdown";
|
|
45
|
+
|
|
42
46
|
import { toString } from "mdast-util-to-string";
|
|
43
47
|
|
|
48
|
+
// Storage adapters
|
|
49
|
+
export { NodeStorageAdapter } from "./adapters/node-fs";
|
|
50
|
+
|
|
44
51
|
// Types
|
|
45
52
|
export type {
|
|
46
53
|
ModelDefinition,
|
|
@@ -57,6 +64,9 @@ export type {
|
|
|
57
64
|
SerializeOptions,
|
|
58
65
|
SaveOptions,
|
|
59
66
|
DocumentRef,
|
|
67
|
+
StorageAdapter,
|
|
68
|
+
FileStat,
|
|
69
|
+
FileEntry,
|
|
60
70
|
} from "./types";
|
|
61
71
|
|
|
62
72
|
// Re-export zod for convenience
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { introspectMetaSchema } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export function generateModelInfo(collection: any, def: any) {
|
|
4
|
+
const lines: string[] = []
|
|
5
|
+
const name = def.name as string
|
|
6
|
+
const prefix = def.prefix as string
|
|
7
|
+
const description = def.description || ''
|
|
8
|
+
const prefixDocs = collection.available.filter((id: string) => id.startsWith(prefix + '/'))
|
|
9
|
+
|
|
10
|
+
lines.push(`# Model: ${name}`, '')
|
|
11
|
+
lines.push(`- **Prefix:** \`${prefix}/\``)
|
|
12
|
+
lines.push(`- **Documents:** ${prefixDocs.length}`)
|
|
13
|
+
if (description) lines.push(`- **Description:** ${description}`)
|
|
14
|
+
lines.push('')
|
|
15
|
+
|
|
16
|
+
// Fields
|
|
17
|
+
const fields = introspectMetaSchema(def.meta)
|
|
18
|
+
if (fields.length > 0) {
|
|
19
|
+
lines.push('## Frontmatter Fields', '')
|
|
20
|
+
lines.push('| Field | Type | Required | Default | Description |')
|
|
21
|
+
lines.push('|-------|------|----------|---------|-------------|')
|
|
22
|
+
for (const f of fields as any[]) {
|
|
23
|
+
const req = f.required ? 'yes' : 'no'
|
|
24
|
+
const def_val = f.defaultValue !== undefined ? `\`${JSON.stringify(f.defaultValue)}\`` : ''
|
|
25
|
+
const desc = f.description || ''
|
|
26
|
+
lines.push(`| ${f.name} | ${f.type} | ${req} | ${def_val} | ${desc} |`)
|
|
27
|
+
}
|
|
28
|
+
lines.push('')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sections
|
|
32
|
+
const sections = Object.entries(def.sections || {})
|
|
33
|
+
if (sections.length > 0) {
|
|
34
|
+
lines.push('## Sections', '')
|
|
35
|
+
for (const [key, sec] of sections as [string, any][]) {
|
|
36
|
+
lines.push(`- **${sec.heading}** (key: \`${key}\`)${sec.schema ? ' — has schema validation' : ''}`)
|
|
37
|
+
if (sec.alternatives?.length) {
|
|
38
|
+
lines.push(` Alternatives: ${sec.alternatives.join(', ')}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
lines.push('')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Relationships
|
|
45
|
+
const relationships = Object.entries(def.relationships || {})
|
|
46
|
+
if (relationships.length > 0) {
|
|
47
|
+
lines.push('## Relationships', '')
|
|
48
|
+
for (const [key, rel] of relationships as [string, any][]) {
|
|
49
|
+
lines.push(`- \`${key}\` → ${rel.type} **${rel.model}**`)
|
|
50
|
+
}
|
|
51
|
+
lines.push('')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Computed & Scopes
|
|
55
|
+
const computedKeys = Object.keys(def.computed || {})
|
|
56
|
+
if (computedKeys.length > 0) {
|
|
57
|
+
lines.push('## Computed Properties', '')
|
|
58
|
+
lines.push(computedKeys.map(k => `- \`${k}\``).join('\n'))
|
|
59
|
+
lines.push('')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const scopeKeys = Object.keys(def.scopes || {})
|
|
63
|
+
if (scopeKeys.length > 0) {
|
|
64
|
+
lines.push('## Named Scopes', '')
|
|
65
|
+
lines.push(scopeKeys.map(k => `- \`${k}\``).join('\n'))
|
|
66
|
+
lines.push('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Existing documents
|
|
70
|
+
if (prefixDocs.length > 0) {
|
|
71
|
+
lines.push('## Existing Documents', '')
|
|
72
|
+
for (const id of prefixDocs) {
|
|
73
|
+
lines.push(`- \`${id}\``)
|
|
74
|
+
}
|
|
75
|
+
lines.push('')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Example scaffold
|
|
79
|
+
const defaultMeta: Record<string, any> = {}
|
|
80
|
+
for (const f of fields as any[]) {
|
|
81
|
+
if (f.defaultValue !== undefined) {
|
|
82
|
+
defaultMeta[f.name] = f.defaultValue
|
|
83
|
+
} else if (f.required) {
|
|
84
|
+
defaultMeta[f.name] = `<${f.type}>`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const sectionHeadings = sections.map(([, sec]: [string, any]) => `## ${sec.heading}\n\n`)
|
|
88
|
+
|
|
89
|
+
lines.push('## Example Document', '')
|
|
90
|
+
lines.push('```markdown')
|
|
91
|
+
lines.push('---')
|
|
92
|
+
for (const [k, v] of Object.entries(defaultMeta)) {
|
|
93
|
+
lines.push(`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
94
|
+
}
|
|
95
|
+
lines.push('---')
|
|
96
|
+
lines.push(`# Your Title Here`)
|
|
97
|
+
lines.push('')
|
|
98
|
+
lines.push(sectionHeadings.join(''))
|
|
99
|
+
lines.push('```')
|
|
100
|
+
|
|
101
|
+
return lines.join('\n')
|
|
102
|
+
}
|