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.
- package/.mcp.json +9 -0
- package/CLI.md +593 -0
- package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +289 -13
- package/bun.lock +43 -4
- package/dist/cnotes +0 -0
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +12 -3
- package/public/web-demo/index.html +813 -0
- package/showcases/node_modules/.cache/.repl_history +3 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
- package/src/__tests__/semantic-search.integration.test.ts +284 -0
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +361 -10
- package/src/define-model.ts +101 -4
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +1 -1
- package/src/index.ts +14 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +23 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/test/collection.test.ts +118 -2
- package/test/fixtures/sdlc/MODELS.md +106 -0
- package/test/fixtures/sdlc/SKILL.md +404 -0
- package/test/fixtures/sdlc/index.ts +9 -0
- package/test/fixtures/sdlc/models.ts +8 -6
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
- package/test/fixtures/sdlc/templates/epic.md +23 -0
- package/test/fixtures/sdlc/templates/story.md +19 -0
- package/test/pattern.test.ts +191 -0
- package/test/query-dsl.test.ts +431 -0
- package/test/query.test.ts +29 -0
- package/test/relationships.test.ts +130 -0
- package/test/section.test.ts +61 -0
- package/test/table-of-contents.test.ts +49 -5
|
@@ -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
|
+
}
|