contentbase 0.0.2 → 0.0.5

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 (130) hide show
  1. package/CLI.md +593 -0
  2. package/MCP-SERVER-SPEC.md +453 -0
  3. package/PRIMER.md +540 -0
  4. package/README.md +289 -13
  5. package/docs/README.md +110 -0
  6. package/docs/TABLE-OF-CONTENTS.md +7 -0
  7. package/docs/models.ts +38 -0
  8. package/models.ts +38 -0
  9. package/package.json +12 -3
  10. package/src/api/endpoints/actions.ts +34 -0
  11. package/src/api/endpoints/doc.ts +187 -0
  12. package/src/api/endpoints/docs-index.ts +26 -0
  13. package/src/api/endpoints/document.ts +171 -0
  14. package/src/api/endpoints/documents.ts +71 -0
  15. package/src/api/endpoints/inspect.ts +16 -0
  16. package/src/api/endpoints/models.ts +10 -0
  17. package/src/api/endpoints/query.ts +88 -0
  18. package/src/api/endpoints/root.ts +7 -0
  19. package/src/api/endpoints/search-reindex.ts +65 -0
  20. package/src/api/endpoints/search-status.ts +55 -0
  21. package/src/api/endpoints/search.ts +104 -0
  22. package/src/api/endpoints/semantic-search.ts +120 -0
  23. package/src/api/endpoints/text-search.ts +63 -0
  24. package/src/api/endpoints/validate.ts +34 -0
  25. package/src/api/helpers.ts +97 -0
  26. package/src/base-model.ts +12 -0
  27. package/src/cli/commands/action.ts +82 -44
  28. package/src/cli/commands/console.ts +124 -0
  29. package/src/cli/commands/create.ts +179 -53
  30. package/src/cli/commands/embed.ts +323 -0
  31. package/src/cli/commands/export.ts +58 -24
  32. package/src/cli/commands/extract.ts +174 -0
  33. package/src/cli/commands/help.ts +81 -0
  34. package/src/cli/commands/index.ts +17 -0
  35. package/src/cli/commands/init.ts +72 -48
  36. package/src/cli/commands/inspect.ts +56 -46
  37. package/src/cli/commands/mcp.ts +1219 -0
  38. package/src/cli/commands/search.ts +285 -0
  39. package/src/cli/commands/serve.ts +348 -0
  40. package/src/cli/commands/summary.ts +60 -0
  41. package/src/cli/commands/teach.ts +86 -0
  42. package/src/cli/commands/text-search.ts +134 -0
  43. package/src/cli/commands/validate.ts +126 -64
  44. package/src/cli/index.ts +88 -19
  45. package/src/cli/load-collection.ts +144 -17
  46. package/src/cli/registry.ts +28 -0
  47. package/src/collection.ts +361 -10
  48. package/src/define-model.ts +101 -4
  49. package/src/document.ts +47 -1
  50. package/src/extract-sections.ts +1 -1
  51. package/src/index.ts +14 -2
  52. package/src/model-instance.ts +35 -5
  53. package/src/node-shortcuts.ts +1 -1
  54. package/src/query/collection-query.ts +96 -9
  55. package/src/query/index.ts +7 -0
  56. package/src/query/query-dsl.ts +259 -0
  57. package/src/relationships/has-many.ts +39 -0
  58. package/src/relationships/index.ts +7 -2
  59. package/src/section.ts +2 -0
  60. package/src/types.ts +23 -1
  61. package/src/utils/index.ts +1 -0
  62. package/src/utils/match-pattern.ts +65 -0
  63. package/src/validator.ts +18 -1
  64. package/bun.lock +0 -473
  65. package/examples/sdlc-queries.ts +0 -161
  66. package/scripts/examples/01-collection-setup.ts +0 -46
  67. package/scripts/examples/02-querying.ts +0 -67
  68. package/scripts/examples/03-sections.ts +0 -36
  69. package/scripts/examples/04-relationships.ts +0 -54
  70. package/scripts/examples/05-document-api.ts +0 -54
  71. package/scripts/examples/06-extract-sections.ts +0 -55
  72. package/scripts/examples/07-validation.ts +0 -46
  73. package/scripts/examples/08-serialization.ts +0 -51
  74. package/scripts/examples/lib/format.ts +0 -87
  75. package/scripts/examples/lib/setup.ts +0 -21
  76. package/scripts/examples/run-all.ts +0 -43
  77. package/showcases/national-parks/models.ts +0 -74
  78. package/showcases/national-parks/parks/acadia.mdx +0 -40
  79. package/showcases/national-parks/parks/yosemite.mdx +0 -44
  80. package/showcases/national-parks/parks/zion.mdx +0 -44
  81. package/showcases/national-parks/queries.ts +0 -103
  82. package/showcases/national-parks/trails/angels-landing.mdx +0 -19
  83. package/showcases/national-parks/trails/cathedral-lakes.mdx +0 -19
  84. package/showcases/national-parks/trails/half-dome.mdx +0 -19
  85. package/showcases/national-parks/trails/jordan-pond-path.mdx +0 -19
  86. package/showcases/national-parks/trails/mist-trail.mdx +0 -19
  87. package/showcases/national-parks/trails/observation-point.mdx +0 -19
  88. package/showcases/national-parks/trails/precipice-trail.mdx +0 -19
  89. package/showcases/national-parks/trails/the-narrows.mdx +0 -19
  90. package/showcases/recipes/cuisines/chinese.mdx +0 -28
  91. package/showcases/recipes/cuisines/italian.mdx +0 -32
  92. package/showcases/recipes/cuisines/mexican.mdx +0 -28
  93. package/showcases/recipes/models.ts +0 -77
  94. package/showcases/recipes/queries.ts +0 -89
  95. package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +0 -43
  96. package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +0 -47
  97. package/showcases/recipes/recipes/italian/bruschetta.mdx +0 -38
  98. package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +0 -39
  99. package/showcases/recipes/recipes/italian/tiramisu.mdx +0 -43
  100. package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +0 -44
  101. package/showcases/recipes/recipes/mexican/guacamole.mdx +0 -39
  102. package/showcases/vinyl-collection/albums/bitches-brew.mdx +0 -36
  103. package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +0 -35
  104. package/showcases/vinyl-collection/albums/in-rainbows.mdx +0 -35
  105. package/showcases/vinyl-collection/albums/kind-of-blue.mdx +0 -32
  106. package/showcases/vinyl-collection/albums/ok-computer.mdx +0 -37
  107. package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +0 -35
  108. package/showcases/vinyl-collection/artists/miles-davis.mdx +0 -27
  109. package/showcases/vinyl-collection/artists/nina-simone.mdx +0 -26
  110. package/showcases/vinyl-collection/artists/radiohead.mdx +0 -27
  111. package/showcases/vinyl-collection/models.ts +0 -73
  112. package/showcases/vinyl-collection/queries.ts +0 -87
  113. package/test/ast-query.test.ts +0 -128
  114. package/test/collection.test.ts +0 -99
  115. package/test/define-model.test.ts +0 -78
  116. package/test/document.test.ts +0 -225
  117. package/test/extract-sections.test.ts +0 -356
  118. package/test/fixtures/sdlc/epics/authentication.mdx +0 -42
  119. package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +0 -21
  120. package/test/fixtures/sdlc/models.ts +0 -89
  121. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +0 -20
  122. package/test/helpers.ts +0 -21
  123. package/test/model-instance.test.ts +0 -197
  124. package/test/query.test.ts +0 -167
  125. package/test/relationships.test.ts +0 -84
  126. package/test/section.test.ts +0 -99
  127. package/test/table-of-contents.test.ts +0 -91
  128. package/test/validator.test.ts +0 -62
  129. package/tsconfig.json +0 -18
  130. package/vitest.config.ts +0 -11
@@ -0,0 +1,187 @@
1
+ import matter from 'gray-matter'
2
+ import { renderMarkdownToHtml, rewriteDocLinks } from '../helpers.js'
3
+
4
+ export const path = '/docs/:docPath(.*)'
5
+ export const description = 'Content-negotiated document serving (JSON, HTML, or Markdown)'
6
+ export const tags = ['docs']
7
+
8
+ export async function get(params: any, ctx: any) {
9
+ const collection = ctx.container._contentbaseCollection
10
+ let docPath: string = params.docPath || ''
11
+
12
+ // Determine format from extension or Accept header
13
+ let format = 'json'
14
+ if (docPath.endsWith('.json')) {
15
+ format = 'json'
16
+ docPath = docPath.slice(0, -5)
17
+ } else if (docPath.endsWith('.html')) {
18
+ format = 'html'
19
+ docPath = docPath.slice(0, -5)
20
+ } else if (docPath.endsWith('.md')) {
21
+ format = 'md'
22
+ docPath = docPath.slice(0, -3)
23
+ } else {
24
+ const accept = ctx.request.headers?.accept || ''
25
+ if (accept.includes('text/html')) format = 'html'
26
+ else if (accept.includes('text/markdown')) format = 'md'
27
+ }
28
+
29
+ if (!collection.available.includes(docPath)) {
30
+ ctx.response.status(404)
31
+ return { error: `Document not found: ${docPath}` }
32
+ }
33
+
34
+ const doc = collection.document(docPath)
35
+ const modelDef = collection.findModelDefinition(docPath)
36
+
37
+ switch (format) {
38
+ case 'md': {
39
+ const raw = matter.stringify(doc.content, doc.meta)
40
+ ctx.response.type('text/markdown')
41
+ ctx.response.send(raw)
42
+ return
43
+ }
44
+ case 'html': {
45
+ const html = rewriteDocLinks(await renderMarkdownToHtml(doc.content))
46
+ const page = `<!DOCTYPE html>
47
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
48
+ <title>${doc.title}</title>
49
+ <link rel="preconnect" href="https://fonts.googleapis.com">
50
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
51
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
52
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
53
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"><\/script>
54
+ <style>
55
+ *, *::before, *::after { box-sizing: border-box; }
56
+ body {
57
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
58
+ max-width: 52rem;
59
+ margin: 0 auto;
60
+ padding: 2rem 1.5rem;
61
+ line-height: 1.7;
62
+ color: #1a1a2e;
63
+ background: #fafafa;
64
+ }
65
+ h1 { font-size: 2rem; font-weight: 600; margin: 2rem 0 1rem; color: #0f0f23; }
66
+ h2 { font-size: 1.5rem; font-weight: 600; margin: 2.5rem 0 0.75rem; color: #16163a; border-bottom: 1px solid #e2e2e8; padding-bottom: 0.4rem; }
67
+ h3 { font-size: 1.2rem; font-weight: 600; margin: 2rem 0 0.5rem; color: #1a1a2e; }
68
+ h4, h5, h6 { font-weight: 600; margin: 1.5rem 0 0.5rem; }
69
+ p { margin: 0 0 1rem; }
70
+ a { color: #2563eb; text-decoration: none; }
71
+ a:hover { text-decoration: underline; }
72
+ blockquote {
73
+ border-left: 3px solid #6366f1;
74
+ margin: 1rem 0;
75
+ padding: 0.5rem 1rem;
76
+ background: #f0f0ff;
77
+ color: #3730a3;
78
+ border-radius: 0 6px 6px 0;
79
+ }
80
+ blockquote p { margin: 0; }
81
+ code {
82
+ font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
83
+ font-feature-settings: "liga" 1, "calt" 1;
84
+ -webkit-font-feature-settings: "liga" 1, "calt" 1;
85
+ font-size: 0.875em;
86
+ background: #ededf0;
87
+ padding: 0.15em 0.4em;
88
+ border-radius: 4px;
89
+ color: #d6336c;
90
+ }
91
+ pre {
92
+ margin: 1rem 0;
93
+ border-radius: 8px;
94
+ overflow: hidden;
95
+ background: #282c34;
96
+ box-shadow: 0 2px 8px rgba(0,0,0,0.12);
97
+ }
98
+ pre code {
99
+ display: block;
100
+ padding: 1.25rem 1.5rem;
101
+ overflow-x: auto;
102
+ font-size: 0.9rem;
103
+ line-height: 1.6;
104
+ background: none;
105
+ color: #abb2bf;
106
+ border-radius: 0;
107
+ }
108
+ pre code .hljs-comment { font-style: italic; }
109
+ ul, ol { padding-left: 1.5rem; margin: 0.5rem 0 1rem; }
110
+ li { margin: 0.3rem 0; }
111
+ li > ul, li > ol { margin: 0.2rem 0; }
112
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
113
+ th { background: #f0f0f5; font-weight: 600; text-align: left; }
114
+ td, th { border: 1px solid #ddd; padding: 0.6em 0.8em; font-size: 0.95rem; }
115
+ tr:nth-child(even) { background: #f8f8fb; }
116
+ hr { border: none; border-top: 1px solid #e2e2e8; margin: 2rem 0; }
117
+ img { max-width: 100%; height: auto; border-radius: 6px; }
118
+ .meta-header { color: #6b7280; font-size: 0.85rem; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #e2e2e8; }
119
+ .meta-header span { margin-right: 1.5rem; }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ ${doc.meta && Object.keys(doc.meta).length > 0 ? `<div class="meta-header">${Object.entries(doc.meta).filter(([k]) => k !== 'title').map(([k, v]) => `<span><strong>${k}:</strong> ${v}</span>`).join('')}</div>` : ''}
124
+ ${html}
125
+ <script>hljs.highlightAll();<\/script>
126
+ </body></html>`
127
+ ctx.response.type('text/html')
128
+ ctx.response.send(page)
129
+ return
130
+ }
131
+ default: {
132
+ const result: Record<string, unknown> = {
133
+ id: doc.id,
134
+ title: doc.title,
135
+ meta: doc.meta,
136
+ content: doc.content,
137
+ outline: doc.toOutline(),
138
+ model: modelDef?.name || null,
139
+ createdAt: doc.createdAt,
140
+ updatedAt: doc.updatedAt,
141
+ size: doc.size,
142
+ }
143
+
144
+ if (modelDef) {
145
+ const instance = collection.getModel(docPath, modelDef)
146
+ const sectionKeys = modelDef.sections ? Object.keys(modelDef.sections) : []
147
+ const computedKeys = modelDef.computed ? Object.keys(modelDef.computed) : []
148
+ const relationshipKeys = modelDef.relationships ? Object.keys(modelDef.relationships) : []
149
+
150
+ if (sectionKeys.length) {
151
+ result.sections = {}
152
+ for (const key of sectionKeys) {
153
+ try {
154
+ (result.sections as any)[key] = instance.sections[key]
155
+ } catch {}
156
+ }
157
+ }
158
+
159
+ if (computedKeys.length) {
160
+ result.computed = {}
161
+ for (const key of computedKeys) {
162
+ try {
163
+ (result.computed as any)[key] = instance.computed[key]
164
+ } catch {}
165
+ }
166
+ }
167
+
168
+ if (relationshipKeys.length) {
169
+ result.relationships = {}
170
+ for (const key of relationshipKeys) {
171
+ try {
172
+ const rel = (instance.relationships as any)[key]
173
+ if ('fetchAll' in rel) {
174
+ (result.relationships as any)[key] = rel.fetchAll().map((i: any) => ({ id: i.id, title: i.title }))
175
+ } else if ('fetch' in rel) {
176
+ const parent = rel.fetch()
177
+ (result.relationships as any)[key] = parent ? { id: parent.id, title: parent.title } : null
178
+ }
179
+ } catch {}
180
+ }
181
+ }
182
+ }
183
+
184
+ return result
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,26 @@
1
+ import { renderTocPage } from '../helpers.js'
2
+
3
+ export const path = '/docs'
4
+ export const description = 'Table of contents for all documents'
5
+ export const tags = ['docs']
6
+
7
+ export async function get(_params: any, ctx: any) {
8
+ const collection = ctx.container._contentbaseCollection
9
+ const accept = ctx.request.headers?.accept || ''
10
+
11
+ if (accept.includes('text/html') || !accept.includes('application/json')) {
12
+ const page = await renderTocPage(collection)
13
+ ctx.response.type('text/html')
14
+ ctx.response.send(page)
15
+ return
16
+ }
17
+
18
+ return {
19
+ title: 'Table of Contents',
20
+ documents: collection.available.map((id: string) => {
21
+ const doc = collection.document(id)
22
+ const modelDef = collection.findModelDefinition(id)
23
+ return { id, title: doc.title, model: modelDef?.name || null }
24
+ })
25
+ }
26
+ }
@@ -0,0 +1,171 @@
1
+ import { z } from 'zod'
2
+ import matter from 'gray-matter'
3
+
4
+ export const path = '/api/documents/:pathId(.*)'
5
+ export const description = 'CRUD operations on a single document'
6
+ export const tags = ['documents']
7
+
8
+ export async function get(params: any, ctx: any) {
9
+ const collection = ctx.container._contentbaseCollection
10
+ const pathId = params.pathId
11
+
12
+ if (!collection.available.includes(pathId)) {
13
+ ctx.response.status(404)
14
+ return { error: `Document not found: ${pathId}` }
15
+ }
16
+
17
+ const doc = collection.document(pathId)
18
+ const modelDef = collection.findModelDefinition(pathId)
19
+
20
+ const result: Record<string, unknown> = {
21
+ id: doc.id,
22
+ title: doc.title,
23
+ meta: doc.meta,
24
+ content: doc.content,
25
+ outline: doc.toOutline(),
26
+ model: modelDef?.name || null,
27
+ }
28
+
29
+ if (modelDef) {
30
+ const instance = collection.getModel(pathId, modelDef)
31
+ const sectionKeys = modelDef.sections ? Object.keys(modelDef.sections) : []
32
+ const computedKeys = modelDef.computed ? Object.keys(modelDef.computed) : []
33
+ const relationshipKeys = modelDef.relationships ? Object.keys(modelDef.relationships) : []
34
+
35
+ if (sectionKeys.length) {
36
+ result.sections = {}
37
+ for (const key of sectionKeys) {
38
+ try {
39
+ (result.sections as any)[key] = instance.sections[key]
40
+ } catch {}
41
+ }
42
+ }
43
+
44
+ if (computedKeys.length) {
45
+ result.computed = {}
46
+ for (const key of computedKeys) {
47
+ try {
48
+ (result.computed as any)[key] = instance.computed[key]
49
+ } catch {}
50
+ }
51
+ }
52
+
53
+ if (relationshipKeys.length) {
54
+ result.relationships = {}
55
+ for (const key of relationshipKeys) {
56
+ try {
57
+ const rel = (instance.relationships as any)[key]
58
+ if ('fetchAll' in rel) {
59
+ (result.relationships as any)[key] = rel.fetchAll().map((i: any) => ({ id: i.id, title: i.title }))
60
+ } else if ('fetch' in rel) {
61
+ const parent = rel.fetch()
62
+ (result.relationships as any)[key] = parent ? { id: parent.id, title: parent.title } : null
63
+ }
64
+ } catch {}
65
+ }
66
+ }
67
+ }
68
+
69
+ return result
70
+ }
71
+
72
+ export const putSchema = z.object({
73
+ meta: z.record(z.string(), z.any()).optional(),
74
+ content: z.string().optional(),
75
+ })
76
+
77
+ export async function put(params: any, ctx: any) {
78
+ if (ctx.container._contentbaseReadOnly) {
79
+ ctx.response.status(403)
80
+ return { error: 'Server is running in read-only mode' }
81
+ }
82
+
83
+ const collection = ctx.container._contentbaseCollection
84
+ const pathId = ctx.params.pathId
85
+
86
+ if (!collection.available.includes(pathId)) {
87
+ ctx.response.status(404)
88
+ return { error: `Document not found: ${pathId}` }
89
+ }
90
+
91
+ const doc = collection.document(pathId)
92
+ const currentMeta = { ...doc.meta }
93
+ const newMeta = params.meta ? { ...currentMeta, ...params.meta } : currentMeta
94
+ const newContent = params.content ?? doc.content
95
+
96
+ const fullContent = matter.stringify(newContent, newMeta)
97
+ await collection.saveItem(pathId, { content: fullContent })
98
+
99
+ return { updated: pathId, meta: newMeta }
100
+ }
101
+
102
+ export const patchSchema = z.object({
103
+ heading: z.string(),
104
+ action: z.enum(['replace', 'append', 'remove']),
105
+ content: z.string().optional(),
106
+ })
107
+
108
+ export async function patch(params: any, ctx: any) {
109
+ if (ctx.container._contentbaseReadOnly) {
110
+ ctx.response.status(403)
111
+ return { error: 'Server is running in read-only mode' }
112
+ }
113
+
114
+ const collection = ctx.container._contentbaseCollection
115
+ const pathId = ctx.params.pathId
116
+
117
+ if (!collection.available.includes(pathId)) {
118
+ ctx.response.status(404)
119
+ return { error: `Document not found: ${pathId}` }
120
+ }
121
+
122
+ let doc = collection.document(pathId)
123
+
124
+ switch (params.action) {
125
+ case 'replace': {
126
+ if (!params.content) {
127
+ ctx.response.status(400)
128
+ return { error: 'Content is required for replace action' }
129
+ }
130
+ doc = doc.replaceSectionContent(params.heading, params.content)
131
+ break
132
+ }
133
+ case 'append': {
134
+ if (!params.content) {
135
+ ctx.response.status(400)
136
+ return { error: 'Content is required for append action' }
137
+ }
138
+ doc = doc.appendToSection(params.heading, params.content)
139
+ break
140
+ }
141
+ case 'remove': {
142
+ doc = doc.removeSection(params.heading)
143
+ break
144
+ }
145
+ }
146
+
147
+ const fullContent = matter.stringify(doc.content, doc.meta)
148
+ await collection.saveItem(pathId, { content: fullContent })
149
+
150
+ return { updated: pathId, action: params.action, heading: params.heading }
151
+ }
152
+
153
+ async function del(_params: any, ctx: any) {
154
+ if (ctx.container._contentbaseReadOnly) {
155
+ ctx.response.status(403)
156
+ return { error: 'Server is running in read-only mode' }
157
+ }
158
+
159
+ const collection = ctx.container._contentbaseCollection
160
+ const pathId = ctx.params.pathId
161
+
162
+ if (!collection.available.includes(pathId)) {
163
+ ctx.response.status(404)
164
+ return { error: `Document not found: ${pathId}` }
165
+ }
166
+
167
+ await collection.deleteItem(pathId)
168
+ return { deleted: pathId }
169
+ }
170
+
171
+ export { del as delete }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod'
2
+ import matter from 'gray-matter'
3
+ import { resolveModelDef } from '../helpers.js'
4
+
5
+ export const path = '/api/documents'
6
+ export const description = 'List or create documents'
7
+ export const tags = ['documents']
8
+
9
+ export const getSchema = z.object({
10
+ model: z.string().optional(),
11
+ })
12
+
13
+ export async function get(params: any, ctx: any) {
14
+ const collection = ctx.container._contentbaseCollection
15
+
16
+ let ids = collection.available as string[]
17
+
18
+ if (params.model) {
19
+ const def = resolveModelDef(collection, params.model)
20
+ if (!def) {
21
+ ctx.response.status(400)
22
+ return { error: `Unknown model: ${params.model}` }
23
+ }
24
+ const prefix = (def as any).prefix + '/'
25
+ ids = ids.filter((id: string) => id.startsWith(prefix))
26
+ }
27
+
28
+ return ids.map((id: string) => {
29
+ const doc = collection.document(id)
30
+ return { id, title: doc.title, meta: doc.meta, size: doc.size, createdAt: doc.createdAt, updatedAt: doc.updatedAt }
31
+ })
32
+ }
33
+
34
+ export const postSchema = z.object({
35
+ pathId: z.string(),
36
+ title: z.string(),
37
+ meta: z.record(z.string(), z.any()).optional(),
38
+ model: z.string().optional(),
39
+ })
40
+
41
+ export async function post(params: any, ctx: any) {
42
+ if (ctx.container._contentbaseReadOnly) {
43
+ ctx.response.status(403)
44
+ return { error: 'Server is running in read-only mode' }
45
+ }
46
+
47
+ const collection = ctx.container._contentbaseCollection
48
+
49
+ if (collection.available.includes(params.pathId)) {
50
+ ctx.response.status(409)
51
+ return { error: `Document already exists: ${params.pathId}` }
52
+ }
53
+
54
+ const def = params.model
55
+ ? resolveModelDef(collection, params.model)
56
+ : collection.findModelDefinition(params.pathId)
57
+
58
+ const metaData = { ...((def as any)?.defaults || {}), ...(params.meta || {}) }
59
+
60
+ const sectionHeadings = def
61
+ ? Object.values((def as any).sections || {}).map((s: any) => `## ${s.heading}\n\n`)
62
+ : []
63
+
64
+ const body = [`# ${params.title}`, '', ...sectionHeadings].join('\n')
65
+ const content = matter.stringify(body, metaData)
66
+
67
+ await collection.saveItem(params.pathId, { content })
68
+
69
+ ctx.response.status(201)
70
+ return { created: params.pathId, model: def ? (def as any).name : null, meta: metaData }
71
+ }
@@ -0,0 +1,16 @@
1
+ import { buildSchemaJSON } from '../helpers.js'
2
+
3
+ export const path = '/api/inspect'
4
+ export const description = 'Collection overview — models, document count, actions'
5
+ export const tags = ['collection']
6
+
7
+ export async function get(_params: any, ctx: any) {
8
+ const collection = ctx.container._contentbaseCollection
9
+ const schema = buildSchemaJSON(collection)
10
+ return {
11
+ rootPath: collection.rootPath,
12
+ documentCount: collection.available.length,
13
+ models: Object.values(schema),
14
+ actions: collection.availableActions,
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ import { buildSchemaJSON } from '../helpers.js'
2
+
3
+ export const path = '/api/models'
4
+ export const description = 'All model definitions with schemas, sections, and relationships'
5
+ export const tags = ['collection']
6
+
7
+ export async function get(_params: any, ctx: any) {
8
+ const collection = ctx.container._contentbaseCollection
9
+ return buildSchemaJSON(collection)
10
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod'
2
+ import { resolveModelDef } from '../helpers.js'
3
+ import { queryDSLSchema, executeQueryDSL } from '../../query/query-dsl.js'
4
+
5
+ export const path = '/api/query'
6
+ export const description = 'Query model instances with filtering'
7
+ export const tags = ['query']
8
+
9
+ export const getSchema = z.object({
10
+ model: z.string(),
11
+ where: z.string().optional(),
12
+ select: z.string().optional(),
13
+ related: z.string().optional(),
14
+ })
15
+
16
+ export const postSchema = queryDSLSchema
17
+
18
+ export async function get(params: any, ctx: any) {
19
+ const collection = ctx.container._contentbaseCollection
20
+
21
+ const def = resolveModelDef(collection, params.model)
22
+ if (!def) {
23
+ ctx.response.status(400)
24
+ return { error: `Unknown model: ${params.model}` }
25
+ }
26
+
27
+ let q = collection.query(def)
28
+
29
+ if (params.where) {
30
+ let conditions: any[]
31
+ try {
32
+ conditions = JSON.parse(params.where)
33
+ } catch {
34
+ ctx.response.status(400)
35
+ return { error: 'Invalid JSON in where parameter' }
36
+ }
37
+
38
+ for (const condition of conditions) {
39
+ const { path: fieldPath, operator = 'eq', value } = condition
40
+ switch (operator) {
41
+ case 'eq': q = q.where(fieldPath, value); break
42
+ case 'in': q = q.whereIn(fieldPath, value); break
43
+ case 'notIn': q = q.whereNotIn(fieldPath, value); break
44
+ case 'gt': q = q.whereGt(fieldPath, value); break
45
+ case 'lt': q = q.whereLt(fieldPath, value); break
46
+ case 'gte': q = q.whereGte(fieldPath, value); break
47
+ case 'lte': q = q.whereLte(fieldPath, value); break
48
+ case 'contains': q = q.whereContains(fieldPath, value); break
49
+ case 'startsWith': q = q.whereStartsWith(fieldPath, value); break
50
+ case 'endsWith': q = q.whereEndsWith(fieldPath, value); break
51
+ case 'regex': q = q.whereRegex(fieldPath, value); break
52
+ case 'exists': q = q.whereExists(fieldPath); break
53
+ case 'notExists': q = q.whereNotExists(fieldPath); break
54
+ }
55
+ }
56
+ }
57
+
58
+ const results = await q.fetchAll()
59
+ const selectFields = params.select ? params.select.split(',').map((s: string) => s.trim()) : null
60
+ const relatedFields = params.related ? params.related.split(',').map((s: string) => s.trim()) : undefined
61
+
62
+ return results.map((instance: any) => {
63
+ const json = instance.toJSON({ related: relatedFields })
64
+ if (selectFields && selectFields.length > 0) {
65
+ const filtered: Record<string, any> = {}
66
+ for (const key of selectFields) {
67
+ if (key in json) filtered[key] = json[key]
68
+ else if (key.startsWith('meta.') && json.meta) {
69
+ filtered[key] = json.meta[key.slice(5)]
70
+ }
71
+ }
72
+ return filtered
73
+ }
74
+ return json
75
+ })
76
+ }
77
+
78
+ export async function post(params: any, ctx: any) {
79
+ const collection = ctx.container._contentbaseCollection
80
+
81
+ try {
82
+ const dsl = queryDSLSchema.parse(params)
83
+ return await executeQueryDSL(collection, dsl)
84
+ } catch (error: any) {
85
+ ctx.response.status(400)
86
+ return { error: error.message }
87
+ }
88
+ }
@@ -0,0 +1,7 @@
1
+ export const path = '/'
2
+ export const description = 'Redirect to docs table of contents when no static index.html exists'
3
+ export const tags = ['docs']
4
+
5
+ export async function get(_params: any, ctx: any) {
6
+ ctx.response.redirect('/docs/')
7
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod'
2
+ import pathModule from 'node:path'
3
+
4
+ export const path = '/api/search/reindex'
5
+ export const description = 'Trigger search index rebuild'
6
+ export const tags = ['mutation']
7
+
8
+ export const postSchema = z.object({
9
+ pathIds: z.array(z.string()).optional(),
10
+ force: z.boolean().optional(),
11
+ })
12
+
13
+ export async function post(params: any, ctx: any) {
14
+ const collection = ctx.container._contentbaseCollection
15
+ const rootPath = collection.rootPath
16
+
17
+ const { SemanticSearch } = await import('@soederpop/luca/agi')
18
+ if (!ctx.container.features.available.includes('semanticSearch')) {
19
+ SemanticSearch.attach(ctx.container)
20
+ }
21
+
22
+ const dbPath = pathModule.join(rootPath, '.contentbase/search.sqlite')
23
+ const ss = ctx.container.feature('semanticSearch', { dbPath })
24
+ await ss.initDb()
25
+
26
+ if (params.pathIds) {
27
+ await ss.reindex(params.pathIds)
28
+ } else if (params.force) {
29
+ await ss.reindex()
30
+ }
31
+
32
+ // Collect and re-index documents
33
+ const docs: any[] = []
34
+ const targetIds = params.pathIds || collection.available
35
+
36
+ for (const pathId of targetIds) {
37
+ if (!collection.available.includes(pathId)) continue
38
+ const doc = collection.document(pathId)
39
+ const modelDef = collection.findModelDefinition(pathId)
40
+
41
+ docs.push({
42
+ pathId,
43
+ model: modelDef?.name ?? undefined,
44
+ title: doc.title,
45
+ slug: doc.slug,
46
+ meta: doc.meta,
47
+ content: doc.content,
48
+ })
49
+ }
50
+
51
+ const toIndex = params.force ? docs : docs.filter((d: any) => ss.needsReindex(d))
52
+
53
+ if (toIndex.length > 0) {
54
+ await ss.indexDocuments(toIndex)
55
+ }
56
+
57
+ ss.removeStale(collection.available)
58
+
59
+ const stats = ss.getStats()
60
+ return {
61
+ reindexed: toIndex.length,
62
+ total: docs.length,
63
+ ...stats,
64
+ }
65
+ }