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,813 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Contentbase Explorer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
12
+ <style>
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+
15
+ :root {
16
+ --bg: #0f0f14;
17
+ --surface: #1a1a24;
18
+ --surface2: #22222e;
19
+ --border: #2e2e3e;
20
+ --text: #e0e0e8;
21
+ --text-dim: #8888a0;
22
+ --accent: #7c6aef;
23
+ --accent-dim: #5a4ec0;
24
+ --green: #4ade80;
25
+ --orange: #fb923c;
26
+ --pink: #f472b6;
27
+ --blue: #60a5fa;
28
+ --mono: 'Fira Code', 'Cascadia Code', monospace;
29
+ --sans: 'Inter', system-ui, -apple-system, sans-serif;
30
+ }
31
+
32
+ body {
33
+ font-family: var(--sans);
34
+ background: var(--bg);
35
+ color: var(--text);
36
+ line-height: 1.6;
37
+ min-height: 100vh;
38
+ }
39
+
40
+ /* Layout */
41
+ .app { display: flex; height: 100vh; overflow: hidden; }
42
+
43
+ .sidebar {
44
+ width: 280px;
45
+ min-width: 280px;
46
+ background: var(--surface);
47
+ border-right: 1px solid var(--border);
48
+ display: flex;
49
+ flex-direction: column;
50
+ overflow: hidden;
51
+ }
52
+
53
+ .sidebar-header {
54
+ padding: 1.25rem 1rem;
55
+ border-bottom: 1px solid var(--border);
56
+ }
57
+
58
+ .sidebar-header h1 {
59
+ font-size: 0.95rem;
60
+ font-weight: 700;
61
+ letter-spacing: 0.04em;
62
+ text-transform: uppercase;
63
+ color: var(--accent);
64
+ }
65
+
66
+ .sidebar-header .stats {
67
+ font-size: 0.75rem;
68
+ color: var(--text-dim);
69
+ margin-top: 0.25rem;
70
+ }
71
+
72
+ .sidebar-nav {
73
+ flex: 1;
74
+ overflow-y: auto;
75
+ padding: 0.5rem 0;
76
+ }
77
+
78
+ .nav-section {
79
+ padding: 0.5rem 1rem 0.25rem;
80
+ font-size: 0.7rem;
81
+ font-weight: 600;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.08em;
84
+ color: var(--text-dim);
85
+ }
86
+
87
+ .nav-item {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.5rem;
91
+ padding: 0.4rem 1rem;
92
+ font-size: 0.85rem;
93
+ color: var(--text);
94
+ cursor: pointer;
95
+ transition: background 0.1s;
96
+ text-decoration: none;
97
+ }
98
+
99
+ .nav-item:hover { background: var(--surface2); }
100
+ .nav-item.active { background: var(--accent-dim); color: #fff; }
101
+
102
+ .nav-item .badge {
103
+ margin-left: auto;
104
+ font-size: 0.7rem;
105
+ font-family: var(--mono);
106
+ background: var(--surface2);
107
+ padding: 0.1rem 0.4rem;
108
+ border-radius: 4px;
109
+ color: var(--text-dim);
110
+ }
111
+
112
+ .nav-item.active .badge { background: rgba(255,255,255,0.15); color: #fff; }
113
+
114
+ .doc-list {
115
+ padding: 0;
116
+ list-style: none;
117
+ }
118
+
119
+ .doc-item {
120
+ display: block;
121
+ padding: 0.35rem 1rem 0.35rem 2rem;
122
+ font-size: 0.8rem;
123
+ color: var(--text-dim);
124
+ cursor: pointer;
125
+ transition: background 0.1s;
126
+ white-space: nowrap;
127
+ overflow: hidden;
128
+ text-overflow: ellipsis;
129
+ }
130
+
131
+ .doc-item:hover { background: var(--surface2); color: var(--text); }
132
+ .doc-item.active { color: var(--accent); background: var(--surface2); }
133
+
134
+ /* Main content */
135
+ .main {
136
+ flex: 1;
137
+ display: flex;
138
+ flex-direction: column;
139
+ overflow: hidden;
140
+ }
141
+
142
+ .topbar {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 0.75rem;
146
+ padding: 0.75rem 1.25rem;
147
+ border-bottom: 1px solid var(--border);
148
+ background: var(--surface);
149
+ }
150
+
151
+ .search-box {
152
+ flex: 1;
153
+ position: relative;
154
+ }
155
+
156
+ .search-box input {
157
+ width: 100%;
158
+ padding: 0.5rem 0.75rem 0.5rem 2rem;
159
+ background: var(--surface2);
160
+ border: 1px solid var(--border);
161
+ border-radius: 6px;
162
+ color: var(--text);
163
+ font-family: var(--sans);
164
+ font-size: 0.85rem;
165
+ outline: none;
166
+ transition: border-color 0.15s;
167
+ }
168
+
169
+ .search-box input:focus { border-color: var(--accent); }
170
+
171
+ .search-box::before {
172
+ content: "\u26B2";
173
+ position: absolute;
174
+ left: 0.65rem;
175
+ top: 50%;
176
+ transform: translateY(-50%);
177
+ color: var(--text-dim);
178
+ font-size: 0.85rem;
179
+ }
180
+
181
+ .tab-bar {
182
+ display: flex;
183
+ gap: 0;
184
+ }
185
+
186
+ .tab {
187
+ padding: 0.4rem 0.9rem;
188
+ font-size: 0.8rem;
189
+ font-weight: 500;
190
+ color: var(--text-dim);
191
+ cursor: pointer;
192
+ border: 1px solid var(--border);
193
+ background: var(--surface2);
194
+ transition: all 0.1s;
195
+ }
196
+
197
+ .tab:first-child { border-radius: 6px 0 0 6px; }
198
+ .tab:last-child { border-radius: 0 6px 6px 0; }
199
+ .tab + .tab { border-left: none; }
200
+ .tab:hover { color: var(--text); }
201
+ .tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
202
+
203
+ .content {
204
+ flex: 1;
205
+ overflow-y: auto;
206
+ padding: 1.5rem;
207
+ }
208
+
209
+ /* Panels */
210
+ .panel { display: none; }
211
+ .panel.active { display: block; }
212
+
213
+ /* Document view */
214
+ .doc-view { max-width: 52rem; }
215
+ .doc-meta {
216
+ display: flex;
217
+ flex-wrap: wrap;
218
+ gap: 0.5rem;
219
+ margin-bottom: 1.25rem;
220
+ padding-bottom: 1rem;
221
+ border-bottom: 1px solid var(--border);
222
+ }
223
+
224
+ .meta-tag {
225
+ font-size: 0.75rem;
226
+ font-family: var(--mono);
227
+ padding: 0.2rem 0.5rem;
228
+ border-radius: 4px;
229
+ background: var(--surface2);
230
+ color: var(--text-dim);
231
+ }
232
+
233
+ .meta-tag .label { color: var(--text-dim); }
234
+ .meta-tag .value { color: var(--green); }
235
+ .meta-tag.model .value { color: var(--accent); }
236
+
237
+ .doc-body {
238
+ line-height: 1.75;
239
+ }
240
+
241
+ .doc-body h1 { font-size: 1.75rem; margin: 1.5rem 0 0.75rem; color: #fff; }
242
+ .doc-body h2 { font-size: 1.35rem; margin: 2rem 0 0.6rem; color: #fff; border-bottom: 1px solid var(--border); padding-bottom: 0.4rem; }
243
+ .doc-body h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #fff; }
244
+ .doc-body p { margin: 0 0 0.75rem; }
245
+ .doc-body a { color: var(--blue); }
246
+ .doc-body ul, .doc-body ol { padding-left: 1.5rem; margin: 0.5rem 0 1rem; }
247
+ .doc-body li { margin: 0.25rem 0; }
248
+ .doc-body blockquote {
249
+ border-left: 3px solid var(--accent);
250
+ padding: 0.5rem 1rem;
251
+ margin: 1rem 0;
252
+ background: var(--surface2);
253
+ border-radius: 0 6px 6px 0;
254
+ }
255
+ .doc-body code {
256
+ font-family: var(--mono);
257
+ font-size: 0.875em;
258
+ background: var(--surface2);
259
+ padding: 0.15em 0.4em;
260
+ border-radius: 3px;
261
+ color: var(--pink);
262
+ }
263
+ .doc-body pre {
264
+ margin: 1rem 0;
265
+ border-radius: 8px;
266
+ overflow: hidden;
267
+ background: #1e1e2a;
268
+ border: 1px solid var(--border);
269
+ }
270
+ .doc-body pre code {
271
+ display: block;
272
+ padding: 1rem 1.25rem;
273
+ overflow-x: auto;
274
+ background: none;
275
+ color: #abb2bf;
276
+ border-radius: 0;
277
+ }
278
+ .doc-body table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
279
+ .doc-body th { background: var(--surface2); font-weight: 600; text-align: left; }
280
+ .doc-body td, .doc-body th { border: 1px solid var(--border); padding: 0.5em 0.75em; font-size: 0.9rem; }
281
+
282
+ /* Search results */
283
+ .search-results { max-width: 52rem; }
284
+ .search-result {
285
+ background: var(--surface);
286
+ border: 1px solid var(--border);
287
+ border-radius: 8px;
288
+ padding: 1rem 1.25rem;
289
+ margin-bottom: 0.75rem;
290
+ cursor: pointer;
291
+ transition: border-color 0.15s;
292
+ }
293
+ .search-result:hover { border-color: var(--accent); }
294
+ .search-result .path {
295
+ font-family: var(--mono);
296
+ font-size: 0.8rem;
297
+ color: var(--accent);
298
+ margin-bottom: 0.4rem;
299
+ }
300
+ .search-result .match-line {
301
+ font-size: 0.85rem;
302
+ color: var(--text-dim);
303
+ padding: 0.15rem 0;
304
+ white-space: nowrap;
305
+ overflow: hidden;
306
+ text-overflow: ellipsis;
307
+ }
308
+ .search-result .match-line mark {
309
+ background: rgba(124, 106, 239, 0.3);
310
+ color: var(--text);
311
+ padding: 0.05em 0.15em;
312
+ border-radius: 2px;
313
+ }
314
+
315
+ /* Models view */
316
+ .model-card {
317
+ background: var(--surface);
318
+ border: 1px solid var(--border);
319
+ border-radius: 8px;
320
+ padding: 1.25rem;
321
+ margin-bottom: 1rem;
322
+ max-width: 52rem;
323
+ }
324
+
325
+ .model-card h3 {
326
+ font-size: 1.1rem;
327
+ color: var(--accent);
328
+ margin-bottom: 0.25rem;
329
+ }
330
+
331
+ .model-card .prefix {
332
+ font-family: var(--mono);
333
+ font-size: 0.8rem;
334
+ color: var(--text-dim);
335
+ margin-bottom: 0.75rem;
336
+ }
337
+
338
+ .model-card .description {
339
+ font-size: 0.85rem;
340
+ color: var(--text-dim);
341
+ margin-bottom: 0.75rem;
342
+ }
343
+
344
+ .field-table {
345
+ width: 100%;
346
+ font-size: 0.8rem;
347
+ }
348
+
349
+ .field-table th {
350
+ text-align: left;
351
+ padding: 0.3rem 0.5rem;
352
+ color: var(--text-dim);
353
+ font-weight: 500;
354
+ border-bottom: 1px solid var(--border);
355
+ }
356
+
357
+ .field-table td {
358
+ padding: 0.3rem 0.5rem;
359
+ border-bottom: 1px solid var(--border);
360
+ }
361
+
362
+ .field-table .fname { font-family: var(--mono); color: var(--green); }
363
+ .field-table .ftype { font-family: var(--mono); color: var(--orange); }
364
+
365
+ /* Welcome */
366
+ .welcome {
367
+ max-width: 40rem;
368
+ margin: 3rem auto;
369
+ text-align: center;
370
+ }
371
+
372
+ .welcome h2 {
373
+ font-size: 1.5rem;
374
+ color: #fff;
375
+ margin-bottom: 0.5rem;
376
+ }
377
+
378
+ .welcome p {
379
+ color: var(--text-dim);
380
+ margin-bottom: 1rem;
381
+ }
382
+
383
+ .welcome kbd {
384
+ font-family: var(--mono);
385
+ font-size: 0.8rem;
386
+ background: var(--surface2);
387
+ border: 1px solid var(--border);
388
+ padding: 0.15rem 0.4rem;
389
+ border-radius: 4px;
390
+ }
391
+
392
+ /* Loading */
393
+ .loading {
394
+ color: var(--text-dim);
395
+ padding: 2rem;
396
+ text-align: center;
397
+ font-size: 0.9rem;
398
+ }
399
+
400
+ .error-msg {
401
+ color: var(--pink);
402
+ background: rgba(244, 114, 182, 0.1);
403
+ border: 1px solid rgba(244, 114, 182, 0.2);
404
+ padding: 0.75rem 1rem;
405
+ border-radius: 6px;
406
+ font-size: 0.85rem;
407
+ }
408
+
409
+ /* JSON view */
410
+ .json-view {
411
+ max-width: 52rem;
412
+ background: var(--surface);
413
+ border: 1px solid var(--border);
414
+ border-radius: 8px;
415
+ padding: 1.25rem;
416
+ font-family: var(--mono);
417
+ font-size: 0.8rem;
418
+ white-space: pre-wrap;
419
+ word-break: break-word;
420
+ overflow-x: auto;
421
+ color: var(--text-dim);
422
+ }
423
+
424
+ /* Scrollbar */
425
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
426
+ ::-webkit-scrollbar-track { background: transparent; }
427
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
428
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
429
+ </style>
430
+ </head>
431
+ <body>
432
+ <div class="app">
433
+ <nav class="sidebar">
434
+ <div class="sidebar-header">
435
+ <h1>Contentbase</h1>
436
+ <div class="stats" id="stats">Loading...</div>
437
+ </div>
438
+ <div class="sidebar-nav" id="sidebar-nav"></div>
439
+ </nav>
440
+
441
+ <main class="main">
442
+ <div class="topbar">
443
+ <div class="search-box">
444
+ <input type="text" id="search-input" placeholder="Search documents..." />
445
+ </div>
446
+ <div class="tab-bar">
447
+ <div class="tab active" data-tab="rendered">Rendered</div>
448
+ <div class="tab" data-tab="json">JSON</div>
449
+ </div>
450
+ </div>
451
+
452
+ <div class="content" id="content">
453
+ <div class="welcome" id="welcome-panel">
454
+ <h2>Contentbase Explorer</h2>
455
+ <p>Browse your collection using the sidebar, or search with <kbd>/</kbd></p>
456
+ </div>
457
+
458
+ <div class="panel" id="doc-panel">
459
+ <div class="doc-view" id="doc-rendered"></div>
460
+ <div class="json-view" id="doc-json" style="display:none"></div>
461
+ </div>
462
+
463
+ <div class="panel" id="search-panel">
464
+ <div class="search-results" id="search-results"></div>
465
+ </div>
466
+
467
+ <div class="panel" id="models-panel">
468
+ <div id="models-content"></div>
469
+ </div>
470
+ </div>
471
+ </main>
472
+ </div>
473
+
474
+ <script>
475
+ const API = window.location.origin;
476
+ let state = {
477
+ models: {},
478
+ documents: [],
479
+ activeDoc: null,
480
+ activeTab: 'rendered',
481
+ expandedModels: new Set(),
482
+ };
483
+
484
+ // ── API helpers ──────────────────────────────────────────────────────────────
485
+
486
+ async function api(path) {
487
+ const res = await fetch(`${API}${path}`);
488
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
489
+ return res.json();
490
+ }
491
+
492
+ async function apiHtml(path) {
493
+ const res = await fetch(`${API}${path}`, { headers: { Accept: 'text/html' } });
494
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
495
+ return res.text();
496
+ }
497
+
498
+ // ── Init ─────────────────────────────────────────────────────────────────────
499
+
500
+ async function init() {
501
+ try {
502
+ const [inspect, models, docs] = await Promise.all([
503
+ api('/api/inspect'),
504
+ api('/api/models'),
505
+ api('/api/documents'),
506
+ ]);
507
+
508
+ state.models = models;
509
+ state.documents = docs;
510
+
511
+ document.getElementById('stats').textContent =
512
+ `${inspect.documentCount} documents \u00b7 ${Object.keys(models).length} models`;
513
+
514
+ renderSidebar();
515
+ setupEvents();
516
+ } catch (e) {
517
+ document.getElementById('stats').textContent = 'Failed to connect';
518
+ document.getElementById('content').innerHTML =
519
+ `<div class="error-msg">Could not reach the API at ${API}<br><br>Make sure <code>cnotes serve</code> is running.</div>`;
520
+ }
521
+ }
522
+
523
+ // ── Sidebar ──────────────────────────────────────────────────────────────────
524
+
525
+ function renderSidebar() {
526
+ const nav = document.getElementById('sidebar-nav');
527
+ const modelNames = Object.keys(state.models);
528
+
529
+ // Group docs by model prefix
530
+ const groups = {};
531
+ const ungrouped = [];
532
+
533
+ for (const doc of state.documents) {
534
+ let matched = false;
535
+ for (const [name, def] of Object.entries(state.models)) {
536
+ if (doc.id.startsWith(def.prefix + '/')) {
537
+ if (!groups[name]) groups[name] = { def, docs: [] };
538
+ groups[name].docs.push(doc);
539
+ matched = true;
540
+ break;
541
+ }
542
+ }
543
+ if (!matched) ungrouped.push(doc);
544
+ }
545
+
546
+ let html = '';
547
+
548
+ // Models link
549
+ html += `<div class="nav-section">Collection</div>`;
550
+ html += `<div class="nav-item" data-action="show-models">Models<span class="badge">${modelNames.length}</span></div>`;
551
+
552
+ // Model groups
553
+ html += `<div class="nav-section">Documents</div>`;
554
+ for (const [name, group] of Object.entries(groups)) {
555
+ const expanded = state.expandedModels.has(name);
556
+ html += `<div class="nav-item" data-model="${name}">${name}<span class="badge">${group.docs.length}</span></div>`;
557
+ if (expanded) {
558
+ html += `<ul class="doc-list">`;
559
+ for (const doc of group.docs) {
560
+ const label = doc.title || doc.id.split('/').pop();
561
+ const cls = state.activeDoc === doc.id ? 'active' : '';
562
+ html += `<li class="doc-item ${cls}" data-doc="${doc.id}" title="${doc.id}">${label}</li>`;
563
+ }
564
+ html += `</ul>`;
565
+ }
566
+ }
567
+
568
+ if (ungrouped.length) {
569
+ html += `<div class="nav-item" data-model="_other">Other<span class="badge">${ungrouped.length}</span></div>`;
570
+ if (state.expandedModels.has('_other')) {
571
+ html += `<ul class="doc-list">`;
572
+ for (const doc of ungrouped) {
573
+ const label = doc.title || doc.id;
574
+ const cls = state.activeDoc === doc.id ? 'active' : '';
575
+ html += `<li class="doc-item ${cls}" data-doc="${doc.id}" title="${doc.id}">${label}</li>`;
576
+ }
577
+ html += `</ul>`;
578
+ }
579
+ }
580
+
581
+ nav.innerHTML = html;
582
+ }
583
+
584
+ // ── Events ───────────────────────────────────────────────────────────────────
585
+
586
+ function setupEvents() {
587
+ const nav = document.getElementById('sidebar-nav');
588
+ nav.addEventListener('click', (e) => {
589
+ const modelEl = e.target.closest('[data-model]');
590
+ const docEl = e.target.closest('[data-doc]');
591
+ const actionEl = e.target.closest('[data-action]');
592
+
593
+ if (actionEl) {
594
+ const action = actionEl.dataset.action;
595
+ if (action === 'show-models') showModels();
596
+ return;
597
+ }
598
+
599
+ if (docEl) {
600
+ loadDocument(docEl.dataset.doc);
601
+ return;
602
+ }
603
+
604
+ if (modelEl) {
605
+ const name = modelEl.dataset.model;
606
+ if (state.expandedModels.has(name)) {
607
+ state.expandedModels.delete(name);
608
+ } else {
609
+ state.expandedModels.add(name);
610
+ }
611
+ renderSidebar();
612
+ }
613
+ });
614
+
615
+ // Tab switching
616
+ document.querySelectorAll('.tab').forEach(tab => {
617
+ tab.addEventListener('click', () => {
618
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
619
+ tab.classList.add('active');
620
+ state.activeTab = tab.dataset.tab;
621
+ updateDocView();
622
+ });
623
+ });
624
+
625
+ // Search
626
+ const searchInput = document.getElementById('search-input');
627
+ let searchTimeout;
628
+ searchInput.addEventListener('input', () => {
629
+ clearTimeout(searchTimeout);
630
+ const q = searchInput.value.trim();
631
+ if (q.length < 2) {
632
+ if (state.activeDoc) showPanel('doc-panel');
633
+ else showPanel(null);
634
+ return;
635
+ }
636
+ searchTimeout = setTimeout(() => doSearch(q), 250);
637
+ });
638
+
639
+ // Keyboard shortcut
640
+ document.addEventListener('keydown', (e) => {
641
+ if (e.key === '/' && document.activeElement !== searchInput) {
642
+ e.preventDefault();
643
+ searchInput.focus();
644
+ }
645
+ if (e.key === 'Escape') {
646
+ searchInput.value = '';
647
+ searchInput.blur();
648
+ if (state.activeDoc) showPanel('doc-panel');
649
+ else showPanel(null);
650
+ }
651
+ });
652
+ }
653
+
654
+ // ── Panels ───────────────────────────────────────────────────────────────────
655
+
656
+ function showPanel(id) {
657
+ document.getElementById('welcome-panel').style.display = id ? 'none' : 'block';
658
+ ['doc-panel', 'search-panel', 'models-panel'].forEach(p => {
659
+ document.getElementById(p).classList.toggle('active', p === id);
660
+ });
661
+ }
662
+
663
+ // ── Document loading ─────────────────────────────────────────────────────────
664
+
665
+ async function loadDocument(docId) {
666
+ state.activeDoc = docId;
667
+ renderSidebar();
668
+ showPanel('doc-panel');
669
+
670
+ const rendered = document.getElementById('doc-rendered');
671
+ const jsonView = document.getElementById('doc-json');
672
+ rendered.innerHTML = '<div class="loading">Loading...</div>';
673
+ jsonView.textContent = 'Loading...';
674
+
675
+ try {
676
+ const [htmlContent, jsonData] = await Promise.all([
677
+ apiHtml(`/docs/${docId}`),
678
+ api(`/docs/${docId}.json`),
679
+ ]);
680
+
681
+ // Extract body content from the full HTML page
682
+ const bodyMatch = htmlContent.match(/<body[^>]*>([\s\S]*)<\/body>/i);
683
+ const bodyHtml = bodyMatch ? bodyMatch[1] : htmlContent;
684
+
685
+ // Build meta bar
686
+ let metaHtml = '<div class="doc-meta">';
687
+ if (jsonData.model) metaHtml += `<span class="meta-tag model"><span class="label">model:</span> <span class="value">${jsonData.model}</span></span>`;
688
+ if (jsonData.meta) {
689
+ for (const [k, v] of Object.entries(jsonData.meta)) {
690
+ if (k === 'title') continue;
691
+ const val = Array.isArray(v) ? v.join(', ') : v;
692
+ metaHtml += `<span class="meta-tag"><span class="label">${k}:</span> <span class="value">${val}</span></span>`;
693
+ }
694
+ }
695
+ if (jsonData.updatedAt) {
696
+ metaHtml += `<span class="meta-tag"><span class="label">updated:</span> <span class="value">${new Date(jsonData.updatedAt).toLocaleDateString()}</span></span>`;
697
+ }
698
+ metaHtml += '</div>';
699
+
700
+ rendered.innerHTML = metaHtml + `<div class="doc-body">${bodyHtml}</div>`;
701
+ rendered.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
702
+
703
+ // Rewrite any doc links to load within the explorer
704
+ rendered.querySelectorAll('a[href^="/docs/"]').forEach(a => {
705
+ a.addEventListener('click', (e) => {
706
+ e.preventDefault();
707
+ const path = a.getAttribute('href').replace('/docs/', '').replace(/\.html$/, '');
708
+ loadDocument(path);
709
+ });
710
+ });
711
+
712
+ jsonView.textContent = JSON.stringify(jsonData, null, 2);
713
+
714
+ updateDocView();
715
+ } catch (e) {
716
+ rendered.innerHTML = `<div class="error-msg">Failed to load: ${e.message}</div>`;
717
+ jsonView.textContent = `Error: ${e.message}`;
718
+ }
719
+ }
720
+
721
+ function updateDocView() {
722
+ const rendered = document.getElementById('doc-rendered');
723
+ const jsonView = document.getElementById('doc-json');
724
+ if (state.activeTab === 'rendered') {
725
+ rendered.style.display = 'block';
726
+ jsonView.style.display = 'none';
727
+ } else {
728
+ rendered.style.display = 'none';
729
+ jsonView.style.display = 'block';
730
+ }
731
+ }
732
+
733
+ // ── Search ───────────────────────────────────────────────────────────────────
734
+
735
+ async function doSearch(query) {
736
+ showPanel('search-panel');
737
+ const resultsEl = document.getElementById('search-results');
738
+ resultsEl.innerHTML = '<div class="loading">Searching...</div>';
739
+
740
+ try {
741
+ const escaped = encodeURIComponent(query);
742
+ const results = await api(`/api/search?pattern=${escaped}`);
743
+
744
+ if (results.length === 0) {
745
+ resultsEl.innerHTML = `<div class="loading">No results for "${query}"</div>`;
746
+ return;
747
+ }
748
+
749
+ const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
750
+
751
+ resultsEl.innerHTML = results.map(r => {
752
+ const matchHtml = r.matches.slice(0, 3).map(m => {
753
+ const highlighted = m.replace(re, '<mark>$1</mark>');
754
+ return `<div class="match-line">${highlighted}</div>`;
755
+ }).join('');
756
+ return `<div class="search-result" data-doc="${r.pathId}">
757
+ <div class="path">${r.pathId}</div>
758
+ ${matchHtml}
759
+ </div>`;
760
+ }).join('');
761
+
762
+ resultsEl.querySelectorAll('.search-result').forEach(el => {
763
+ el.addEventListener('click', () => {
764
+ document.getElementById('search-input').value = '';
765
+ loadDocument(el.dataset.doc);
766
+ });
767
+ });
768
+ } catch (e) {
769
+ resultsEl.innerHTML = `<div class="error-msg">Search failed: ${e.message}</div>`;
770
+ }
771
+ }
772
+
773
+ // ── Models view ──────────────────────────────────────────────────────────────
774
+
775
+ function showModels() {
776
+ showPanel('models-panel');
777
+ const container = document.getElementById('models-content');
778
+
779
+ container.innerHTML = Object.entries(state.models).map(([name, def]) => {
780
+ const fields = def.fields || [];
781
+ const fieldRows = fields.map(f => {
782
+ return `<tr><td class="fname">${f.name}</td><td class="ftype">${f.type || 'unknown'}</td><td>${f.description || ''}</td></tr>`;
783
+ }).join('');
784
+
785
+ const sections = Array.isArray(def.sections) ? def.sections : Object.keys(def.sections || {});
786
+ const relationships = Array.isArray(def.relationships) ? def.relationships : Object.keys(def.relationships || {});
787
+ const computed = def.computed || def.computedKeys || [];
788
+
789
+ const sectionNames = sections.map(s => typeof s === 'string' ? s : s.name || s.heading || JSON.stringify(s));
790
+ const relNames = relationships.map(r => typeof r === 'string' ? r : r.name || r.key || JSON.stringify(r));
791
+ const compNames = computed.map(c => typeof c === 'string' ? c : c.name || JSON.stringify(c));
792
+
793
+ let extras = '';
794
+ if (sectionNames.length) extras += `<div style="margin-top:0.5rem;font-size:0.8rem;color:var(--text-dim)">Sections: <span style="color:var(--blue)">${sectionNames.join(', ')}</span></div>`;
795
+ if (relNames.length) extras += `<div style="font-size:0.8rem;color:var(--text-dim)">Relationships: <span style="color:var(--orange)">${relNames.join(', ')}</span></div>`;
796
+ if (compNames.length) extras += `<div style="font-size:0.8rem;color:var(--text-dim)">Computed: <span style="color:var(--pink)">${compNames.join(', ')}</span></div>`;
797
+
798
+ return `<div class="model-card">
799
+ <h3>${name}</h3>
800
+ <div class="prefix">${def.prefix}/</div>
801
+ ${def.description ? `<div class="description">${def.description}</div>` : ''}
802
+ ${fieldRows ? `<table class="field-table"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody>${fieldRows}</tbody></table>` : ''}
803
+ ${extras}
804
+ </div>`;
805
+ }).join('');
806
+ }
807
+
808
+ // ── Boot ─────────────────────────────────────────────────────────────────────
809
+
810
+ init();
811
+ </script>
812
+ </body>
813
+ </html>