contentbase 0.0.2 → 0.0.4

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 (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
@@ -0,0 +1,3 @@
1
+ luca.networing
2
+ luca.networking
3
+ container.feature('networking')
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  genre: Jazz / Soul
3
3
  origin: Tryon, North Carolina
4
- active: "1954–2003"
4
+ active: 1954–2003
5
5
  ---
6
6
 
7
7
  # Nina Simone
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Semantic Search Integration Tests
3
+ *
4
+ * Run with: bun test src/__tests__/semantic-search.integration.test.ts
5
+ *
6
+ * Requires:
7
+ * - bun runtime (uses bun:sqlite)
8
+ * - OPENAI_API_KEY env var for embedding tests
9
+ *
10
+ * Tests the full pipeline: collection → index → search → results
11
+ */
12
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
13
+ import path from "path";
14
+ import fs from "fs/promises";
15
+ import { existsSync } from "fs";
16
+ import { Collection } from "../../src/collection";
17
+
18
+ const dir = import.meta.dirname ?? new URL(".", import.meta.url).pathname;
19
+ const FIXTURES_PATH = path.resolve(dir, "../../test/fixtures/sdlc");
20
+ const DB_DIR = path.join(FIXTURES_PATH, ".contentbase-test");
21
+ const DB_PATH = path.join(DB_DIR, "search.sqlite");
22
+
23
+ const HAS_API_KEY = !!process.env.OPENAI_API_KEY;
24
+
25
+ function collectDocumentInputs(collection: Collection) {
26
+ const inputs: any[] = [];
27
+ for (const pathId of collection.available) {
28
+ const doc = collection.document(pathId);
29
+ const modelDef = (collection as any).findModelDefinition?.(pathId);
30
+
31
+ const sections: any[] = [];
32
+ const lines = doc.content.split("\n");
33
+ let currentHeading: string | null = null;
34
+ let currentContent: string[] = [];
35
+
36
+ for (const line of lines) {
37
+ const h2Match = line.match(/^## (.+)/);
38
+ if (h2Match) {
39
+ if (currentHeading) {
40
+ sections.push({
41
+ heading: currentHeading,
42
+ headingPath: currentHeading,
43
+ content: currentContent.join("\n").trim(),
44
+ level: 2,
45
+ });
46
+ }
47
+ currentHeading = h2Match[1].trim();
48
+ currentContent = [];
49
+ } else if (currentHeading) {
50
+ currentContent.push(line);
51
+ }
52
+ }
53
+ if (currentHeading) {
54
+ sections.push({
55
+ heading: currentHeading,
56
+ headingPath: currentHeading,
57
+ content: currentContent.join("\n").trim(),
58
+ level: 2,
59
+ });
60
+ }
61
+
62
+ inputs.push({
63
+ pathId,
64
+ model: modelDef?.name ?? undefined,
65
+ title: doc.title,
66
+ slug: (doc as any).slug,
67
+ meta: doc.meta,
68
+ content: doc.content,
69
+ sections: sections.length > 0 ? sections : undefined,
70
+ });
71
+ }
72
+ return inputs;
73
+ }
74
+
75
+ describe("Semantic Search Integration", () => {
76
+ let collection: Collection;
77
+ let SemanticSearchClass: any;
78
+ let ss: any;
79
+
80
+ beforeAll(async () => {
81
+ // Import models
82
+ const { Epic, Story } = await import("../../test/fixtures/sdlc/models");
83
+
84
+ // Load collection
85
+ collection = new Collection({ rootPath: FIXTURES_PATH, name: "test-sdlc" });
86
+ collection.register(Epic);
87
+ collection.register(Story);
88
+ await collection.load();
89
+
90
+ // Import SemanticSearch
91
+ const mod = await import("@soederpop/luca/agi");
92
+ SemanticSearchClass = mod.SemanticSearch;
93
+
94
+ // Clean up any previous test index
95
+ if (existsSync(DB_DIR)) {
96
+ await fs.rm(DB_DIR, { recursive: true });
97
+ }
98
+ });
99
+
100
+ afterAll(async () => {
101
+ if (ss) {
102
+ try { await ss.close(); } catch {}
103
+ }
104
+ if (existsSync(DB_DIR)) {
105
+ await fs.rm(DB_DIR, { recursive: true });
106
+ }
107
+ });
108
+
109
+ describe("Database Layer", () => {
110
+ it("creates and initializes database tables", async () => {
111
+ // Use the class directly - create an instance through the static attach pattern
112
+ // We need a minimal container mock for the Feature constructor
113
+ const { Database } = await import("bun:sqlite");
114
+
115
+ // Clean first
116
+ if (existsSync(DB_DIR)) await fs.rm(DB_DIR, { recursive: true });
117
+
118
+ // Test the Database creation directly using bun:sqlite
119
+ const { mkdirSync } = await import("fs");
120
+ mkdirSync(DB_DIR, { recursive: true });
121
+ const dbPath = path.join(DB_DIR, "search.openai-text-embedding-3-small.sqlite");
122
+ const db = new Database(dbPath);
123
+
124
+ db.exec("PRAGMA journal_mode = WAL");
125
+ db.exec("PRAGMA foreign_keys = ON");
126
+
127
+ // Create the same tables as SemanticSearch
128
+ db.exec(`CREATE TABLE search_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
129
+ db.exec(`CREATE TABLE documents (
130
+ path_id TEXT PRIMARY KEY, model TEXT, title TEXT, slug TEXT,
131
+ meta_json TEXT, content TEXT, sections_json TEXT,
132
+ content_hash TEXT, indexed_at TEXT
133
+ )`);
134
+ db.exec(`CREATE VIRTUAL TABLE documents_fts USING fts5(
135
+ path_id, title, content, sections_text, tokenize='porter unicode61'
136
+ )`);
137
+ db.exec(`CREATE TABLE chunks (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ path_id TEXT NOT NULL, section TEXT, heading_path TEXT,
140
+ seq INTEGER NOT NULL, content TEXT NOT NULL,
141
+ content_hash TEXT NOT NULL, embedding BLOB,
142
+ FOREIGN KEY (path_id) REFERENCES documents(path_id) ON DELETE CASCADE
143
+ )`);
144
+
145
+ // Verify tables exist
146
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as any[];
147
+ const tableNames = tables.map((t: any) => t.name);
148
+ expect(tableNames).toContain("search_meta");
149
+ expect(tableNames).toContain("documents");
150
+ expect(tableNames).toContain("chunks");
151
+
152
+ db.close();
153
+ // Clean up for the real test
154
+ await fs.rm(DB_DIR, { recursive: true });
155
+ });
156
+ });
157
+
158
+ describe("Document Chunking", () => {
159
+ it("collectDocumentInputs extracts sections from fixture documents", () => {
160
+ const docs = collectDocumentInputs(collection);
161
+ expect(docs.length).toBeGreaterThan(0);
162
+
163
+ const authEpic = docs.find((d: any) => d.pathId === "epics/authentication");
164
+ expect(authEpic).toBeDefined();
165
+ expect(authEpic.title).toBe("Authentication");
166
+ expect(authEpic.model).toBe("Epic");
167
+ expect(authEpic.sections).toBeDefined();
168
+ expect(authEpic.sections.length).toBeGreaterThan(0);
169
+ });
170
+
171
+ it("documents without h2 sections get no sections array", () => {
172
+ const docs = collectDocumentInputs(collection);
173
+ // Stories may or may not have h2 sections depending on fixture
174
+ for (const doc of docs) {
175
+ if (doc.sections) {
176
+ expect(doc.sections.length).toBeGreaterThan(0);
177
+ for (const section of doc.sections) {
178
+ expect(section).toHaveProperty("heading");
179
+ expect(section).toHaveProperty("content");
180
+ expect(section).toHaveProperty("level");
181
+ }
182
+ }
183
+ }
184
+ });
185
+ });
186
+
187
+ describe("Full Pipeline (requires OPENAI_API_KEY)", () => {
188
+ it.skipIf(!HAS_API_KEY)("indexes documents and generates embeddings", async () => {
189
+ // Create a container-like environment for SemanticSearch
190
+ const container = (await import("@soederpop/luca")).default;
191
+
192
+ if (!container.features.available.includes("semanticSearch")) {
193
+ SemanticSearchClass.attach(container);
194
+ }
195
+
196
+ ss = container.feature("semanticSearch", {
197
+ dbPath: DB_PATH,
198
+ embeddingProvider: "openai",
199
+ embeddingModel: "text-embedding-3-small",
200
+ });
201
+
202
+ await ss.initDb();
203
+ expect(ss.state.get("dbReady")).toBe(true);
204
+
205
+ const docs = collectDocumentInputs(collection);
206
+ await ss.indexDocuments(docs);
207
+
208
+ const stats = ss.getStats();
209
+ expect(stats.documentCount).toBe(docs.length);
210
+ expect(stats.chunkCount).toBeGreaterThan(0);
211
+ expect(stats.embeddingCount).toBeGreaterThan(0);
212
+ expect(stats.provider).toBe("openai");
213
+ expect(stats.model).toBe("text-embedding-3-small");
214
+ expect(stats.dimensions).toBe(1536);
215
+ }, 60000);
216
+
217
+ it.skipIf(!HAS_API_KEY)("keyword search returns BM25-ranked results", async () => {
218
+ const results = await ss.search("authentication");
219
+ expect(results.length).toBeGreaterThan(0);
220
+ expect(results[0].score).toBeGreaterThan(0);
221
+ expect(results[0].snippet).toBeTruthy();
222
+ });
223
+
224
+ it.skipIf(!HAS_API_KEY)("vector search finds semantically related docs", async () => {
225
+ const results = await ss.vectorSearch("user login and registration");
226
+ expect(results.length).toBeGreaterThan(0);
227
+ const authResult = results.find((r: any) => r.pathId.includes("authentication"));
228
+ expect(authResult).toBeDefined();
229
+ });
230
+
231
+ it.skipIf(!HAS_API_KEY)("hybrid search combines both modes", async () => {
232
+ const results = await ss.hybridSearch("authentication login");
233
+ expect(results.length).toBeGreaterThan(0);
234
+ expect(results[0].score).toBeGreaterThan(0);
235
+ });
236
+
237
+ it.skipIf(!HAS_API_KEY)("model filter restricts results", async () => {
238
+ const epicResults = await ss.search("authentication", { model: "Epic" });
239
+ for (const r of epicResults) {
240
+ expect(r.model).toBe("Epic");
241
+ }
242
+ });
243
+
244
+ it.skipIf(!HAS_API_KEY)("search results include citation fields", async () => {
245
+ const results = await ss.hybridSearch("authentication");
246
+ expect(results.length).toBeGreaterThan(0);
247
+ const r = results[0];
248
+ expect(r).toHaveProperty("pathId");
249
+ expect(r).toHaveProperty("model");
250
+ expect(r).toHaveProperty("title");
251
+ expect(r).toHaveProperty("score");
252
+ expect(r).toHaveProperty("snippet");
253
+ });
254
+
255
+ it.skipIf(!HAS_API_KEY)("needsReindex returns false for unchanged docs", async () => {
256
+ const docs = collectDocumentInputs(collection);
257
+ for (const doc of docs) {
258
+ expect(ss.needsReindex(doc)).toBe(false);
259
+ }
260
+ });
261
+
262
+ it.skipIf(!HAS_API_KEY)("search with no index throws actionable error", async () => {
263
+ // Create a fresh instance with different dbPath
264
+ const container = (await import("@soederpop/luca")).default;
265
+ const freshSs = container.feature("semanticSearch", {
266
+ dbPath: path.join(DB_DIR, "nonexistent.sqlite"),
267
+ embeddingProvider: "openai",
268
+ });
269
+
270
+ // Searching without initDb should throw
271
+ expect(() => freshSs.search("test")).toThrow();
272
+ });
273
+
274
+ it.skipIf(!HAS_API_KEY)("getStats returns correct index status", async () => {
275
+ const stats = ss.getStats();
276
+ expect(stats.documentCount).toBeGreaterThan(0);
277
+ expect(stats.chunkCount).toBeGreaterThan(0);
278
+ expect(stats.embeddingCount).toBe(stats.chunkCount);
279
+ expect(stats.lastIndexedAt).toBeTruthy();
280
+ expect(stats.dimensions).toBe(1536);
281
+ expect(stats.dbSizeBytes).toBeGreaterThan(0);
282
+ });
283
+ });
284
+ });
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod'
2
+
3
+ export const path = '/api/actions'
4
+ export const description = 'List or execute collection actions'
5
+ export const tags = ['actions']
6
+
7
+ export async function get(_params: any, ctx: any) {
8
+ const collection = ctx.container._contentbaseCollection
9
+ return { actions: collection.availableActions }
10
+ }
11
+
12
+ export const postSchema = z.object({
13
+ name: z.string(),
14
+ args: z.array(z.any()).optional(),
15
+ })
16
+
17
+ export async function post(params: any, ctx: any) {
18
+ if (ctx.container._contentbaseReadOnly) {
19
+ ctx.response.status(403)
20
+ return { error: 'Server is running in read-only mode' }
21
+ }
22
+
23
+ const collection = ctx.container._contentbaseCollection
24
+
25
+ if (!collection.availableActions.includes(params.name)) {
26
+ ctx.response.status(400)
27
+ return {
28
+ error: `Unknown action: ${params.name}. Available: ${collection.availableActions.join(', ') || '(none)'}`,
29
+ }
30
+ }
31
+
32
+ const result = await collection.runAction(params.name, ...(params.args || []))
33
+ return typeof result === 'string' ? { result } : result
34
+ }
@@ -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
+ }