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.
Files changed (40) hide show
  1. package/CNAME +1 -0
  2. package/LAUNCH-PLAN.md +419 -0
  3. package/README.md +90 -0
  4. package/index.html +1636 -0
  5. package/package.json +5 -4
  6. package/src/adapters/node-fs.ts +80 -0
  7. package/src/cli/commands/api/endpoints/doc.ts +2 -2
  8. package/src/cli/commands/api/endpoints/document.ts +2 -2
  9. package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
  10. package/src/cli/commands/api/endpoints/search-status.ts +3 -20
  11. package/src/cli/commands/api/endpoints/search.ts +3 -33
  12. package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
  13. package/src/cli/commands/embed.ts +8 -81
  14. package/src/cli/commands/mcp.ts +5 -1128
  15. package/src/cli/commands/search.ts +25 -90
  16. package/src/cli/commands/serve.ts +7 -32
  17. package/src/cli/index.ts +1 -1
  18. package/src/cli/load-collection.ts +2 -2
  19. package/src/collection.ts +42 -33
  20. package/src/document.ts +18 -0
  21. package/src/extract-sections.ts +3 -0
  22. package/src/index.ts +10 -0
  23. package/src/mcp/helpers.ts +7 -0
  24. package/src/mcp/model-info.ts +102 -0
  25. package/src/mcp/prompts.ts +199 -0
  26. package/src/mcp/readme.ts +153 -0
  27. package/src/mcp/resources.ts +89 -0
  28. package/src/mcp/server.ts +103 -0
  29. package/src/mcp/tools/mutation.ts +176 -0
  30. package/src/mcp/tools/query.ts +141 -0
  31. package/src/mcp/tools/search.ts +180 -0
  32. package/src/parse.ts +5 -0
  33. package/src/query/query-dsl.ts +1 -1
  34. package/src/relationships/has-many.ts +4 -2
  35. package/src/search/document-inputs.ts +85 -0
  36. package/src/search/luca-semantic-search.ts +75 -0
  37. package/src/types.ts +54 -0
  38. package/src/utils/index.ts +2 -0
  39. package/src/utils/match-pattern.ts +2 -2
  40. package/src/utils/strip-markdown.ts +59 -0
@@ -0,0 +1,176 @@
1
+ import { z } from 'zod'
2
+ import matter from 'gray-matter'
3
+ import { errorResult, textResult } from '../helpers.js'
4
+ import { resolveModelDef } from '../../cli/commands/api/helpers.js'
5
+ import { validateDocument } from '../../index.js'
6
+
7
+ export function registerMutationTools(mcpServer: any, collection: any) {
8
+ const modelDefs = collection.modelDefinitions as any[]
9
+
10
+ mcpServer.tool('validate', {
11
+ description: 'Validate a document against its model schema. Returns validation result with any errors. **ALWAYS call after create/update operations** to confirm the document conforms to its model.',
12
+ schema: z.object({
13
+ pathId: z.string().describe('Document path ID'),
14
+ model: z.string().optional().describe('Model name (auto-detected if omitted)'),
15
+ }),
16
+ handler: (args: { pathId: string; model?: string }) => {
17
+ const doc = collection.document(args.pathId)
18
+ if (!doc) return errorResult(`Document not found: ${args.pathId}`)
19
+
20
+ const def = args.model
21
+ ? resolveModelDef(collection, args.model)
22
+ : collection.findModelDefinition(args.pathId)
23
+
24
+ if (!def) {
25
+ return errorResult(`No model definition found for ${args.pathId}. Specify one with the model parameter.`)
26
+ }
27
+
28
+ const result = validateDocument(doc, def)
29
+ return textResult(JSON.stringify(result, null, 2))
30
+ },
31
+ })
32
+
33
+ mcpServer.tool('create_document', {
34
+ description: '**ALWAYS use this instead of writing markdown files directly.** Creates a new document with proper scaffolding from a model definition — generates correct frontmatter defaults and section headings. Call `validate` after creation.',
35
+ schema: z.object({
36
+ pathId: z.string().describe('Path ID for the new document (e.g. "epics/my-new-epic")'),
37
+ title: z.string().describe('Document title (used as the H1 heading)'),
38
+ meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to set'),
39
+ model: z.string().optional().describe('Model name (auto-detected from pathId prefix if omitted)'),
40
+ }),
41
+ handler: async (args: { pathId: string; title: string; meta?: Record<string, any>; model?: string }) => {
42
+ if (collection.available.includes(args.pathId)) {
43
+ return errorResult(`Document already exists: ${args.pathId}`)
44
+ }
45
+
46
+ const def = args.model
47
+ ? resolveModelDef(collection, args.model)
48
+ : collection.findModelDefinition(args.pathId)
49
+
50
+ const metaData = { ...((def as any)?.defaults || {}), ...(args.meta || {}) }
51
+
52
+ const sectionHeadings = def
53
+ ? Object.values((def as any).sections || {}).map((s: any) => `## ${s.heading}\n\n`)
54
+ : []
55
+
56
+ const body = [
57
+ `# ${args.title}`,
58
+ '',
59
+ ...sectionHeadings,
60
+ ].join('\n')
61
+
62
+ const content = matter.stringify(body, metaData)
63
+
64
+ await collection.saveItem(args.pathId, { content })
65
+ await collection.load({ refresh: true })
66
+
67
+ return textResult(JSON.stringify({
68
+ created: args.pathId,
69
+ model: def ? (def as any).name : null,
70
+ meta: metaData,
71
+ }, null, 2))
72
+ },
73
+ })
74
+
75
+ mcpServer.tool('update_document', {
76
+ description: 'Update a document\'s frontmatter and/or replace its entire content body. Use for frontmatter changes. For section-level edits, prefer `update_section` instead. Call `validate` after.',
77
+ schema: z.object({
78
+ pathId: z.string().describe('Document path ID'),
79
+ meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to merge (existing fields are preserved unless overridden)'),
80
+ content: z.string().optional().describe('New markdown content body (replaces everything after frontmatter)'),
81
+ }),
82
+ handler: async (args: { pathId: string; meta?: Record<string, any>; content?: string }) => {
83
+ const doc = collection.document(args.pathId)
84
+ if (!doc) return errorResult(`Document not found: ${args.pathId}`)
85
+
86
+ const currentMeta = { ...doc.meta }
87
+ const newMeta = args.meta ? { ...currentMeta, ...args.meta } : currentMeta
88
+ const newContent = args.content ?? doc.content
89
+
90
+ const fullContent = matter.stringify(newContent, newMeta)
91
+ await collection.saveItem(args.pathId, { content: fullContent })
92
+ await collection.load({ refresh: true })
93
+
94
+ return textResult(JSON.stringify({
95
+ updated: args.pathId,
96
+ meta: newMeta,
97
+ }, null, 2))
98
+ },
99
+ })
100
+
101
+ mcpServer.tool('update_section', {
102
+ description: 'Preferred way to edit document content. Surgically edit a specific section — replace, append, or remove. Target a section by its heading name. Call `validate` after.',
103
+ schema: z.object({
104
+ pathId: z.string().describe('Document path ID'),
105
+ heading: z.string().describe('Section heading text to target (e.g. "Overview", "Requirements")'),
106
+ action: z.enum(['replace', 'append', 'remove']).describe('What to do with the section'),
107
+ content: z.string().optional().describe('New content (required for replace/append, ignored for remove)'),
108
+ }),
109
+ handler: async (args: { pathId: string; heading: string; action: 'replace' | 'append' | 'remove'; content?: string }) => {
110
+ let doc = collection.document(args.pathId)
111
+ if (!doc) return errorResult(`Document not found: ${args.pathId}`)
112
+
113
+ switch (args.action) {
114
+ case 'replace': {
115
+ if (!args.content) return errorResult('Content is required for replace action')
116
+ doc = doc.replaceSectionContent(args.heading, args.content)
117
+ break
118
+ }
119
+ case 'append': {
120
+ if (!args.content) return errorResult('Content is required for append action')
121
+ doc = doc.appendToSection(args.heading, args.content)
122
+ break
123
+ }
124
+ case 'remove': {
125
+ doc = doc.removeSection(args.heading)
126
+ break
127
+ }
128
+ }
129
+
130
+ const fullContent = matter.stringify(doc.content, doc.meta)
131
+ await collection.saveItem(args.pathId, { content: fullContent })
132
+ await collection.load({ refresh: true })
133
+
134
+ return textResult(JSON.stringify({
135
+ updated: args.pathId,
136
+ action: args.action,
137
+ heading: args.heading,
138
+ }, null, 2))
139
+ },
140
+ })
141
+
142
+ mcpServer.tool('delete_document', {
143
+ description: 'Delete a document from the collection permanently. Cannot be undone except through version control.',
144
+ schema: z.object({
145
+ pathId: z.string().describe('Document path ID to delete'),
146
+ }),
147
+ handler: async (args: { pathId: string }) => {
148
+ if (!collection.available.includes(args.pathId)) {
149
+ return errorResult(`Document not found: ${args.pathId}`)
150
+ }
151
+
152
+ await collection.deleteItem(args.pathId)
153
+ await collection.load({ refresh: true })
154
+ return textResult(JSON.stringify({ deleted: args.pathId }, null, 2))
155
+ },
156
+ })
157
+
158
+ mcpServer.tool('run_action', {
159
+ description: 'Execute a registered collection action by name.',
160
+ schema: z.object({
161
+ name: z.string().describe('Action name'),
162
+ args: z.array(z.any()).optional().describe('Arguments to pass to the action'),
163
+ }),
164
+ handler: async (toolArgs: { name: string; args?: any[] }) => {
165
+ if (!collection.availableActions.includes(toolArgs.name)) {
166
+ return errorResult(
167
+ `Unknown action: ${toolArgs.name}. Available: ${collection.availableActions.join(', ') || '(none)'}`,
168
+ )
169
+ }
170
+
171
+ const result = await collection.runAction(toolArgs.name, ...(toolArgs.args || []))
172
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
173
+ return textResult(text)
174
+ },
175
+ })
176
+ }
@@ -0,0 +1,141 @@
1
+ import { z } from 'zod'
2
+ import { errorResult, textResult } from '../helpers.js'
3
+ import { generateReadMe } from '../readme.js'
4
+ import { generateModelInfo } from '../model-info.js'
5
+ import { resolveModelDef, buildSchemaJSON } from '../../cli/commands/api/helpers.js'
6
+ import { queryDSLSchema, executeQueryDSL } from '../../query/query-dsl.js'
7
+
8
+ export function registerQueryTools(mcpServer: any, collection: any) {
9
+ const modelDefs = collection.modelDefinitions as any[]
10
+
11
+ // -- read_me: entry-point guidance for AI agents --
12
+ const readMeContent = generateReadMe(collection, modelDefs)
13
+
14
+ mcpServer.tool('read_me', {
15
+ description: [
16
+ 'Returns the content collection guide. Call this BEFORE working with any documents.',
17
+ 'Contains model definitions, available tools, query syntax, and recommended workflow.',
18
+ 'Call this at the start of every session to understand the collection structure.',
19
+ ].join('\n'),
20
+ schema: z.object({}),
21
+ handler: () => textResult(readMeContent),
22
+ })
23
+
24
+ mcpServer.tool('inspect', {
25
+ description: 'Overview of the collection — registered models, document count, available actions. Call `read_me` first if this is your first interaction.',
26
+ schema: z.object({}),
27
+ handler: () => {
28
+ const schema = buildSchemaJSON(collection)
29
+ const overview = {
30
+ rootPath: collection.rootPath,
31
+ documentCount: collection.available.length,
32
+ models: Object.values(schema),
33
+ actions: collection.availableActions,
34
+ }
35
+ return textResult(JSON.stringify(overview, null, 2))
36
+ },
37
+ })
38
+
39
+ mcpServer.tool('get_model_info', {
40
+ description: 'Get detailed information about a single model — fields, sections, relationships, example document. Use when you need to understand a model before creating or editing its documents.',
41
+ schema: z.object({
42
+ model: z.string().describe('Model name or prefix'),
43
+ }),
44
+ handler: (args: { model: string }) => {
45
+ const def = resolveModelDef(collection, args.model)
46
+ if (!def) {
47
+ return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
48
+ }
49
+ return textResult(generateModelInfo(collection, def))
50
+ },
51
+ })
52
+
53
+ mcpServer.tool('list_documents', {
54
+ description: 'List all document path IDs in the collection, optionally filtered by model name or prefix. The prefix before the slash indicates the model.',
55
+ schema: z.object({
56
+ model: z.string().optional().describe('Filter by model name or prefix'),
57
+ }),
58
+ handler: (args: { model?: string }) => {
59
+ let ids = collection.available
60
+
61
+ if (args.model) {
62
+ const def = resolveModelDef(collection, args.model)
63
+ if (def) {
64
+ const prefix = (def as any).prefix + '/'
65
+ ids = ids.filter((id: string) => id.startsWith(prefix))
66
+ } else {
67
+ return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
68
+ }
69
+ }
70
+
71
+ return textResult(JSON.stringify(ids, null, 2))
72
+ },
73
+ })
74
+
75
+ mcpServer.tool('query', {
76
+ description: [
77
+ 'Query typed model instances with MongoDB-style filtering. See `read_me` output for full syntax reference.',
78
+ 'Where clause: keys are dot-notation paths, values are literals (implies $eq),',
79
+ 'arrays (implies $in), or operator objects like { "$gt": 5 }.',
80
+ 'Operators: $eq, $neq, $in, $notIn, $gt, $lt, $gte, $lte,',
81
+ '$contains, $startsWith, $endsWith, $regex, $exists.',
82
+ 'Supports sort, limit, offset, select, and method (fetchAll/first/last/count).',
83
+ ].join(' '),
84
+ schema: z.object({
85
+ model: z.string().describe('Model name to query'),
86
+ where: z.any().optional().describe(
87
+ 'MongoDB-style where clause. Keys are field paths, values are literals (implicit $eq), arrays (implicit $in), or operator objects like { "$gt": 5 }. Also accepts legacy array format for backward compat.',
88
+ ),
89
+ sort: z.record(z.string(), z.enum(['asc', 'desc'])).optional().describe(
90
+ 'Sort specification, e.g. { "meta.priority": "desc" }',
91
+ ),
92
+ select: z.array(z.string()).optional().describe('Fields to include in output (default: all)'),
93
+ related: z.array(z.string()).optional().describe('Relationship names to include in results (e.g. ["plans", "goal"])'),
94
+ scopes: z.array(z.string()).optional().describe('Named scopes to apply before filtering'),
95
+ limit: z.number().optional().describe('Maximum results to return'),
96
+ offset: z.number().optional().describe('Number of results to skip'),
97
+ method: z.enum(['fetchAll', 'first', 'last', 'count']).optional().describe(
98
+ 'Terminal operation (default: fetchAll)',
99
+ ),
100
+ }),
101
+ handler: async (args: any) => {
102
+ try {
103
+ // Backward compat: convert legacy array-style where to MongoDB-style
104
+ let whereClause = args.where
105
+ if (Array.isArray(whereClause)) {
106
+ const converted: Record<string, unknown> = {}
107
+ for (const cond of whereClause) {
108
+ const op = cond.operator || 'eq'
109
+ if (op === 'eq') {
110
+ converted[cond.path] = cond.value
111
+ } else if (op === 'notExists') {
112
+ converted[cond.path] = { $exists: false }
113
+ } else if (op === 'exists') {
114
+ converted[cond.path] = { $exists: true }
115
+ } else {
116
+ converted[cond.path] = { [`$${op}`]: cond.value }
117
+ }
118
+ }
119
+ whereClause = converted
120
+ }
121
+
122
+ const dsl = queryDSLSchema.parse({
123
+ model: args.model,
124
+ where: whereClause,
125
+ sort: args.sort,
126
+ select: args.select,
127
+ related: args.related,
128
+ scopes: args.scopes,
129
+ limit: args.limit,
130
+ offset: args.offset,
131
+ method: args.method,
132
+ })
133
+
134
+ const result = await executeQueryDSL(collection, dsl)
135
+ return textResult(JSON.stringify(result, null, 2))
136
+ } catch (error: any) {
137
+ return errorResult(error.message)
138
+ }
139
+ },
140
+ })
141
+ }
@@ -0,0 +1,180 @@
1
+ import { z } from 'zod'
2
+ import { errorResult, textResult } from '../helpers.js'
3
+ import { resolveModelDef } from '../../cli/commands/api/helpers.js'
4
+ import { getInitializedSemanticSearch, hasSearchIndex as searchIndexExists } from '../../search/luca-semantic-search.js'
5
+
6
+ export function registerSearchTools(mcpServer: any, collection: any, container: any) {
7
+ let _semanticSearch: any = null
8
+
9
+ function hasSearchIndex(): boolean {
10
+ return searchIndexExists(collection.rootPath)
11
+ }
12
+
13
+ async function getSemanticSearch() {
14
+ if (_semanticSearch?.state?.get('dbReady')) return _semanticSearch
15
+
16
+ _semanticSearch = await getInitializedSemanticSearch(container, collection.rootPath)
17
+ return _semanticSearch
18
+ }
19
+
20
+ mcpServer.tool('search_content', {
21
+ description: 'Full-text regex search across all document content. Returns matching document IDs with context. Searches document body text, not metadata — for metadata filtering, use `query` instead.',
22
+ schema: z.object({
23
+ pattern: z.string().describe('Regex pattern to search for'),
24
+ model: z.string().optional().describe('Limit search to a specific model'),
25
+ caseSensitive: z.boolean().default(false).describe('Case-sensitive matching'),
26
+ }),
27
+ handler: (args: { pattern: string; model?: string; caseSensitive: boolean }) => {
28
+ const flags = args.caseSensitive ? 'g' : 'gi'
29
+ let regex: RegExp
30
+ try {
31
+ regex = new RegExp(args.pattern, flags)
32
+ } catch (e: any) {
33
+ return errorResult(`Invalid regex: ${e.message}`)
34
+ }
35
+
36
+ let ids = collection.available
37
+ if (args.model) {
38
+ const def = resolveModelDef(collection, args.model)
39
+ if (def) {
40
+ const prefix = (def as any).prefix + '/'
41
+ ids = ids.filter((id: string) => id.startsWith(prefix))
42
+ }
43
+ }
44
+
45
+ const results: Array<{ pathId: string; matches: string[] }> = []
46
+
47
+ for (const pathId of ids) {
48
+ const doc = collection.document(pathId)
49
+ const content = doc.content
50
+ const matches: string[] = []
51
+
52
+ for (const line of content.split('\n')) {
53
+ if (regex.test(line)) {
54
+ matches.push(line.trim())
55
+ }
56
+ regex.lastIndex = 0
57
+ }
58
+
59
+ if (matches.length > 0) {
60
+ results.push({ pathId, matches: matches.slice(0, 10) })
61
+ }
62
+ }
63
+
64
+ return textResult(JSON.stringify(results, null, 2))
65
+ },
66
+ })
67
+
68
+ mcpServer.tool('text_search', {
69
+ description: 'Search file contents with pattern matching using ripgrep. Returns distinct file matches by default, or line-level detail with expanded=true.',
70
+ schema: z.object({
71
+ pattern: z.string().describe('Text or regex pattern to search for'),
72
+ expanded: z.boolean().default(false).describe('Return line-level matches instead of just file paths'),
73
+ include: z.string().optional().describe('Glob filter (e.g. "*.md")'),
74
+ exclude: z.string().optional().describe('Glob filter (e.g. "node_modules")'),
75
+ ignoreCase: z.boolean().default(false).describe('Case insensitive search'),
76
+ maxResults: z.number().optional().describe('Limit number of results'),
77
+ }),
78
+ handler: async (args: any) => {
79
+ const grep = container.feature('grep')
80
+ const searchPath = collection.rootPath
81
+
82
+ const grepOpts: any = {
83
+ path: searchPath,
84
+ ignoreCase: args.ignoreCase,
85
+ maxResults: args.maxResults,
86
+ include: args.include,
87
+ exclude: args.exclude,
88
+ }
89
+
90
+ if (!args.expanded) {
91
+ const files = await grep.filesContaining(args.pattern, grepOpts)
92
+ return textResult(JSON.stringify({ files, count: files.length }, null, 2))
93
+ }
94
+
95
+ const results = await grep.search({ ...grepOpts, pattern: args.pattern })
96
+ const grouped = new Map<string, Array<{ line: number; column?: number; content: string }>>()
97
+ for (const match of results) {
98
+ if (!grouped.has(match.file)) grouped.set(match.file, [])
99
+ grouped.get(match.file)!.push({ line: match.line, column: match.column, content: match.content })
100
+ }
101
+
102
+ const files = Array.from(grouped.entries()).map(([file, matches]) => ({ file, matches }))
103
+ return textResult(JSON.stringify({ files, count: files.length }, null, 2))
104
+ },
105
+ })
106
+
107
+ mcpServer.tool('keyword_search', {
108
+ description: 'Fast keyword search using BM25 ranking. Best for exact terms, identifiers, and known phrases. Requires a search index — run `cnotes embed` first if not indexed.',
109
+ schema: z.object({
110
+ query: z.string().describe('Search query text'),
111
+ limit: z.number().optional().default(10).describe('Maximum results to return'),
112
+ model: z.string().optional().describe('Filter results to a specific model name'),
113
+ }),
114
+ handler: async (args: { query: string; limit: number; model?: string }) => {
115
+ if (!hasSearchIndex()) {
116
+ return errorResult('No search index found. Run: cnotes embed')
117
+ }
118
+ try {
119
+ const ss = await getSemanticSearch()
120
+ const results = await ss.search(args.query, {
121
+ limit: args.limit,
122
+ model: args.model,
123
+ })
124
+ return textResult(JSON.stringify(results, null, 2))
125
+ } catch (error: any) {
126
+ return errorResult(`Search failed: ${error.message}`)
127
+ }
128
+ },
129
+ })
130
+
131
+ mcpServer.tool('semantic_search', {
132
+ description: 'Search by meaning using vector embeddings. Finds conceptually related documents even without keyword matches. Requires a search index — run `cnotes embed` first if not indexed.',
133
+ schema: z.object({
134
+ query: z.string().describe('Search query text'),
135
+ limit: z.number().optional().default(10).describe('Maximum results to return'),
136
+ model: z.string().optional().describe('Filter results to a specific model name'),
137
+ }),
138
+ handler: async (args: { query: string; limit: number; model?: string }) => {
139
+ if (!hasSearchIndex()) {
140
+ return errorResult('No search index found. Run: cnotes embed')
141
+ }
142
+ try {
143
+ const ss = await getSemanticSearch()
144
+ const results = await ss.vectorSearch(args.query, {
145
+ limit: args.limit,
146
+ model: args.model,
147
+ })
148
+ return textResult(JSON.stringify(results, null, 2))
149
+ } catch (error: any) {
150
+ return errorResult(`Search failed: ${error.message}`)
151
+ }
152
+ },
153
+ })
154
+
155
+ mcpServer.tool('hybrid_search', {
156
+ description: 'Combined keyword + semantic search with score fusion. Best for general questions about the collection. Requires a search index — run `cnotes embed` first if not indexed.',
157
+ schema: z.object({
158
+ query: z.string().describe('Search query text'),
159
+ limit: z.number().optional().default(10).describe('Maximum results to return'),
160
+ model: z.string().optional().describe('Filter results to a specific model name'),
161
+ where: z.record(z.string(), z.any()).optional().describe('Metadata filters, e.g. {"status": "approved"}'),
162
+ }),
163
+ handler: async (args: { query: string; limit: number; model?: string; where?: Record<string, any> }) => {
164
+ if (!hasSearchIndex()) {
165
+ return errorResult('No search index found. Run: cnotes embed')
166
+ }
167
+ try {
168
+ const ss = await getSemanticSearch()
169
+ const results = await ss.hybridSearch(args.query, {
170
+ limit: args.limit,
171
+ model: args.model,
172
+ where: args.where,
173
+ })
174
+ return textResult(JSON.stringify(results, null, 2))
175
+ } catch (error: any) {
176
+ return errorResult(`Search failed: ${error.message}`)
177
+ }
178
+ },
179
+ })
180
+ }
package/src/parse.ts CHANGED
@@ -7,6 +7,8 @@ import { toString } from "mdast-util-to-string";
7
7
  import { AstQuery } from "./ast-query";
8
8
  import { NodeShortcuts } from "./node-shortcuts";
9
9
  import { stringifyAst } from "./utils/stringify-ast";
10
+ import { stripMarkdown } from "./utils/strip-markdown";
11
+ import type { StripMarkdownOptions } from "./utils/strip-markdown";
10
12
  import type { Root, Content, RootContent } from "mdast";
11
13
 
12
14
  const processor = unified().use(remarkParse).use(remarkGfm);
@@ -26,6 +28,8 @@ export interface ParsedDocument {
26
28
  title: string;
27
29
  /** Stringify an AST back to markdown */
28
30
  stringify(ast?: Root): string;
31
+ /** Strip markdown syntax, returning plain text */
32
+ stripMarkdown(options?: StripMarkdownOptions): string;
29
33
  /** Extract a section by heading text */
30
34
  extractSection(heading: string | Content): Content[];
31
35
  /** Get a queryable AstQuery scoped to a section */
@@ -107,6 +111,7 @@ export async function parse(input: string): Promise<ParsedDocument> {
107
111
  nodes,
108
112
  title,
109
113
  stringify: (tree: Root = ast) => stringifyAst(tree),
114
+ stripMarkdown: (options?: StripMarkdownOptions) => stripMarkdown(ast, options),
110
115
  extractSection,
111
116
  querySection,
112
117
  };
@@ -122,7 +122,7 @@ export function parseWhereClause(
122
122
  );
123
123
  }
124
124
 
125
- const operator = OPERATOR_MAP[opKey];
125
+ const operator = OPERATOR_MAP[opKey]!;
126
126
 
127
127
  // Regex length guard
128
128
  if (
@@ -135,6 +135,8 @@ export class HasManyRelationship<
135
135
  // e.g. Project hasMany Plans → looks for meta.project on Plan documents.
136
136
  const fk = this.#definition.foreignKey || this.#inferForeignKey();
137
137
  const slug = this.#document.slug;
138
+ const parentPrefix = this.#document.id.split("/")[0]!;
139
+ const idSegment = this.#document.id.slice(parentPrefix.length + 1);
138
140
  const prefix = targetDef.prefix;
139
141
  const results: InferModelInstance<TTarget>[] = [];
140
142
 
@@ -147,7 +149,7 @@ export class HasManyRelationship<
147
149
  const patternMeta = matchPatterns(targetDef.pattern, pathId);
148
150
  if (patternMeta) fkValue = patternMeta[fk];
149
151
  }
150
- if (fkValue === slug) {
152
+ if (fkValue === slug || fkValue === idSegment) {
151
153
  results.push(this.#factory(doc, targetDef, this.#collection));
152
154
  }
153
155
  }
@@ -160,7 +162,7 @@ export class HasManyRelationship<
160
162
  * e.g. if the parent is in "projects/", the FK is "project" (singularized, lowercased).
161
163
  */
162
164
  #inferForeignKey(): string {
163
- const parentPrefix = this.#document.id.split("/")[0];
165
+ const parentPrefix = this.#document.id.split("/")[0]!;
164
166
  // Simple singularize: strip trailing "s"
165
167
  return parentPrefix.replace(/s$/, "");
166
168
  }
@@ -0,0 +1,85 @@
1
+ export interface SearchDocumentSection {
2
+ heading: string
3
+ headingPath: string
4
+ content: string
5
+ level: number
6
+ }
7
+
8
+ export interface SearchDocumentInput {
9
+ pathId: string
10
+ model?: string
11
+ title?: string
12
+ slug?: string
13
+ meta?: Record<string, unknown>
14
+ content: string
15
+ sections?: SearchDocumentSection[]
16
+ }
17
+
18
+ export interface SearchCollectionLike {
19
+ available: string[]
20
+ document(pathId: string): {
21
+ title?: string
22
+ slug?: string
23
+ meta?: Record<string, unknown>
24
+ content: string
25
+ }
26
+ findModelDefinition?(pathId: string): { name?: string } | undefined
27
+ }
28
+
29
+ export function collectDocumentInputs(collection: SearchCollectionLike): SearchDocumentInput[] {
30
+ const inputs: SearchDocumentInput[] = []
31
+
32
+ for (const pathId of collection.available) {
33
+ const doc = collection.document(pathId)
34
+ const modelDef = collection.findModelDefinition?.(pathId)
35
+ const sections = collectH2Sections(doc.content)
36
+
37
+ inputs.push({
38
+ pathId,
39
+ model: modelDef?.name ?? undefined,
40
+ title: doc.title,
41
+ slug: doc.slug,
42
+ meta: doc.meta,
43
+ content: doc.content,
44
+ sections: sections.length > 0 ? sections : undefined,
45
+ })
46
+ }
47
+
48
+ return inputs
49
+ }
50
+
51
+ export function collectH2Sections(content: string): SearchDocumentSection[] {
52
+ const sections: SearchDocumentSection[] = []
53
+ const lines = content.split('\n')
54
+ let currentHeading: string | null = null
55
+ let currentContent: string[] = []
56
+
57
+ for (const line of lines) {
58
+ const h2Match = line.match(/^## (.+)/)
59
+ if (h2Match) {
60
+ if (currentHeading) {
61
+ sections.push({
62
+ heading: currentHeading,
63
+ headingPath: currentHeading,
64
+ content: currentContent.join('\n').trim(),
65
+ level: 2,
66
+ })
67
+ }
68
+ currentHeading = h2Match[1].trim()
69
+ currentContent = []
70
+ } else if (currentHeading) {
71
+ currentContent.push(line)
72
+ }
73
+ }
74
+
75
+ if (currentHeading) {
76
+ sections.push({
77
+ heading: currentHeading,
78
+ headingPath: currentHeading,
79
+ content: currentContent.join('\n').trim(),
80
+ level: 2,
81
+ })
82
+ }
83
+
84
+ return sections
85
+ }