harper-knowledge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/config.yaml +17 -0
  4. package/dist/core/embeddings.d.ts +29 -0
  5. package/dist/core/embeddings.js +199 -0
  6. package/dist/core/entries.d.ts +85 -0
  7. package/dist/core/entries.js +235 -0
  8. package/dist/core/history.d.ts +30 -0
  9. package/dist/core/history.js +119 -0
  10. package/dist/core/search.d.ts +23 -0
  11. package/dist/core/search.js +306 -0
  12. package/dist/core/tags.d.ts +32 -0
  13. package/dist/core/tags.js +76 -0
  14. package/dist/core/triage.d.ts +55 -0
  15. package/dist/core/triage.js +126 -0
  16. package/dist/http-utils.d.ts +37 -0
  17. package/dist/http-utils.js +132 -0
  18. package/dist/index.d.ts +21 -0
  19. package/dist/index.js +76 -0
  20. package/dist/mcp/server.d.ts +24 -0
  21. package/dist/mcp/server.js +124 -0
  22. package/dist/mcp/tools.d.ts +13 -0
  23. package/dist/mcp/tools.js +497 -0
  24. package/dist/oauth/authorize.d.ts +27 -0
  25. package/dist/oauth/authorize.js +438 -0
  26. package/dist/oauth/github.d.ts +28 -0
  27. package/dist/oauth/github.js +62 -0
  28. package/dist/oauth/keys.d.ts +33 -0
  29. package/dist/oauth/keys.js +100 -0
  30. package/dist/oauth/metadata.d.ts +21 -0
  31. package/dist/oauth/metadata.js +55 -0
  32. package/dist/oauth/middleware.d.ts +22 -0
  33. package/dist/oauth/middleware.js +64 -0
  34. package/dist/oauth/register.d.ts +14 -0
  35. package/dist/oauth/register.js +83 -0
  36. package/dist/oauth/token.d.ts +15 -0
  37. package/dist/oauth/token.js +178 -0
  38. package/dist/oauth/validate.d.ts +30 -0
  39. package/dist/oauth/validate.js +52 -0
  40. package/dist/resources/HistoryResource.d.ts +38 -0
  41. package/dist/resources/HistoryResource.js +38 -0
  42. package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
  43. package/dist/resources/KnowledgeEntryResource.js +157 -0
  44. package/dist/resources/QueryLogResource.d.ts +20 -0
  45. package/dist/resources/QueryLogResource.js +57 -0
  46. package/dist/resources/ServiceKeyResource.d.ts +51 -0
  47. package/dist/resources/ServiceKeyResource.js +132 -0
  48. package/dist/resources/TagResource.d.ts +25 -0
  49. package/dist/resources/TagResource.js +32 -0
  50. package/dist/resources/TriageResource.d.ts +51 -0
  51. package/dist/resources/TriageResource.js +107 -0
  52. package/dist/types.d.ts +317 -0
  53. package/dist/types.js +7 -0
  54. package/dist/webhooks/datadog.d.ts +26 -0
  55. package/dist/webhooks/datadog.js +120 -0
  56. package/dist/webhooks/github.d.ts +24 -0
  57. package/dist/webhooks/github.js +167 -0
  58. package/dist/webhooks/middleware.d.ts +14 -0
  59. package/dist/webhooks/middleware.js +161 -0
  60. package/dist/webhooks/types.d.ts +17 -0
  61. package/dist/webhooks/types.js +4 -0
  62. package/package.json +72 -0
  63. package/schema/knowledge.graphql +134 -0
  64. package/web/index.html +735 -0
  65. package/web/js/app.js +461 -0
  66. package/web/js/detail.js +223 -0
  67. package/web/js/editor.js +303 -0
  68. package/web/js/search.js +238 -0
  69. package/web/js/triage.js +305 -0
package/web/js/app.js ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Harper Knowledge Base — Main Application Module
3
+ *
4
+ * Hash-based router, API helpers, auth state management,
5
+ * and page rendering dispatcher.
6
+ */
7
+
8
+ // ============================================================================
9
+ // API Helper
10
+ // ============================================================================
11
+
12
+ const api = {
13
+ _token: null,
14
+
15
+ /** Set the auth token (Basic or Bearer) */
16
+ setToken(token) {
17
+ this._token = token;
18
+ sessionStorage.setItem("kb-auth-token", token);
19
+ },
20
+
21
+ /** Load saved token from storage */
22
+ loadToken() {
23
+ this._token = sessionStorage.getItem("kb-auth-token");
24
+ return this._token;
25
+ },
26
+
27
+ clearToken() {
28
+ this._token = null;
29
+ sessionStorage.removeItem("kb-auth-token");
30
+ },
31
+
32
+ /** Build headers including auth if available */
33
+ _headers(extra = {}) {
34
+ const headers = { "Content-Type": "application/json", ...extra };
35
+ if (this._token) {
36
+ headers["Authorization"] = this._token;
37
+ }
38
+ return headers;
39
+ },
40
+
41
+ async _request(method, path, body) {
42
+ const opts = {
43
+ method,
44
+ headers: this._headers(),
45
+ };
46
+ if (body !== undefined) {
47
+ opts.body = JSON.stringify(body);
48
+ }
49
+
50
+ const response = await fetch(path, opts);
51
+
52
+ // If the response is 204 or has no content, return null
53
+ if (response.status === 204) return null;
54
+
55
+ const contentType = response.headers.get("content-type") || "";
56
+ if (!contentType.includes("application/json")) {
57
+ if (!response.ok) {
58
+ throw new ApiError(response.status, await response.text());
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const data = await response.json();
64
+
65
+ if (!response.ok) {
66
+ throw new ApiError(
67
+ response.status,
68
+ data?.error || data?.data?.error || response.statusText,
69
+ data,
70
+ );
71
+ }
72
+
73
+ return data;
74
+ },
75
+
76
+ get(path) {
77
+ return this._request("GET", path);
78
+ },
79
+ post(path, data) {
80
+ return this._request("POST", path, data);
81
+ },
82
+ put(path, data) {
83
+ return this._request("PUT", path, data);
84
+ },
85
+ delete(path) {
86
+ return this._request("DELETE", path);
87
+ },
88
+ };
89
+
90
+ class ApiError extends Error {
91
+ constructor(status, message, data) {
92
+ super(message);
93
+ this.status = status;
94
+ this.data = data;
95
+ }
96
+ }
97
+
98
+ // ============================================================================
99
+ // Auth State
100
+ // ============================================================================
101
+
102
+ const auth = {
103
+ user: null,
104
+ authenticated: false,
105
+
106
+ async check() {
107
+ // Check for GitHub session via @harperfast/oauth
108
+ try {
109
+ const res = await fetch("/oauth/github/user");
110
+ if (res.ok) {
111
+ const data = await res.json();
112
+ if (data && (data.username || data.login)) {
113
+ this.user = data.username || data.login;
114
+ this.authenticated = true;
115
+ updateAuthUI();
116
+ return true;
117
+ }
118
+ }
119
+ } catch {
120
+ // No GitHub session or endpoint unavailable
121
+ }
122
+
123
+ // Check for stored Harper credentials (Basic auth)
124
+ api.loadToken();
125
+ if (api._token) {
126
+ this.user = sessionStorage.getItem("kb-auth-user");
127
+ this.authenticated = true;
128
+ updateAuthUI();
129
+ return true;
130
+ }
131
+
132
+ this.authenticated = false;
133
+ this.user = null;
134
+ updateAuthUI();
135
+ return false;
136
+ },
137
+
138
+ async login(username, password) {
139
+ const token = "Basic " + btoa(username + ":" + password);
140
+ api.setToken(token);
141
+
142
+ // Validate by trying a request that requires auth
143
+ try {
144
+ const res = await fetch("/Triage/", { headers: api._headers() });
145
+ if (res.status === 401) {
146
+ api.clearToken();
147
+ throw new Error("Invalid credentials");
148
+ }
149
+ } catch (err) {
150
+ if (err.message === "Invalid credentials") throw err;
151
+ // Network error — let it through, will fail on actual use
152
+ }
153
+
154
+ sessionStorage.setItem("kb-auth-user", username);
155
+ this.user = username;
156
+ this.authenticated = true;
157
+ updateAuthUI();
158
+ return true;
159
+ },
160
+
161
+ async logout() {
162
+ api.clearToken();
163
+ sessionStorage.removeItem("kb-auth-user");
164
+ this.authenticated = false;
165
+ this.user = null;
166
+ updateAuthUI();
167
+
168
+ // Clear @harperfast/oauth session
169
+ try {
170
+ await fetch("/oauth/logout", { method: "POST" });
171
+ } catch {
172
+ // Ignore — may not have a GitHub session
173
+ }
174
+
175
+ navigate("search");
176
+ },
177
+ };
178
+
179
+ function updateAuthUI() {
180
+ const el = document.getElementById("auth-status");
181
+ if (!el) return;
182
+ if (auth.authenticated) {
183
+ const name = auth.user ? escapeHtml(auth.user) : "authenticated";
184
+ el.innerHTML = `<span style="color: var(--green);">${name}</span> <a href="#" id="logout-btn" style="color: var(--text-dim); margin-left: 8px;">[logout]</a>`;
185
+ const logoutBtn = document.getElementById("logout-btn");
186
+ if (logoutBtn) {
187
+ logoutBtn.addEventListener("click", (e) => {
188
+ e.preventDefault();
189
+ auth.logout();
190
+ });
191
+ }
192
+ } else {
193
+ el.innerHTML = `<a href="#" id="login-btn" style="color: var(--text-dim);">[login]</a>`;
194
+ const loginBtn = document.getElementById("login-btn");
195
+ if (loginBtn) {
196
+ loginBtn.addEventListener("click", (e) => {
197
+ e.preventDefault();
198
+ showLoginModal();
199
+ });
200
+ }
201
+ }
202
+ }
203
+
204
+ function showLoginModal() {
205
+ const returnUrl = encodeURIComponent(
206
+ window.location.pathname + window.location.hash,
207
+ );
208
+ const githubLoginUrl = `/oauth/github/login?redirect=${returnUrl}`;
209
+
210
+ const overlay = document.createElement("div");
211
+ overlay.className = "modal-overlay";
212
+ overlay.innerHTML = `
213
+ <div class="modal">
214
+ <h2>Log In</h2>
215
+ <div id="login-error"></div>
216
+ <a href="${githubLoginUrl}" class="btn-github">
217
+ <svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
218
+ Sign in with GitHub
219
+ </a>
220
+ <div class="login-divider"><span>or</span></div>
221
+ <button type="button" id="login-cred-toggle" class="login-cred-toggle">Sign in with Harper credentials</button>
222
+ <div id="login-cred-form" class="login-cred-form">
223
+ <div class="form-group">
224
+ <label for="login-user">Username</label>
225
+ <input type="text" id="login-user" autocomplete="username">
226
+ </div>
227
+ <div class="form-group">
228
+ <label for="login-pass">Password</label>
229
+ <input type="password" id="login-pass" autocomplete="current-password">
230
+ </div>
231
+ <div class="btn-row">
232
+ <button id="login-cancel">Cancel</button>
233
+ <button id="login-submit" class="primary">Sign in</button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ `;
238
+
239
+ document.body.appendChild(overlay);
240
+
241
+ const credToggle = overlay.querySelector("#login-cred-toggle");
242
+ const credForm = overlay.querySelector("#login-cred-form");
243
+ const userInput = overlay.querySelector("#login-user");
244
+ const passInput = overlay.querySelector("#login-pass");
245
+ const submitBtn = overlay.querySelector("#login-submit");
246
+ const cancelBtn = overlay.querySelector("#login-cancel");
247
+ const errorDiv = overlay.querySelector("#login-error");
248
+
249
+ credToggle.addEventListener("click", () => {
250
+ credForm.classList.toggle("visible");
251
+ credToggle.style.display = "none";
252
+ userInput.focus();
253
+ });
254
+
255
+ const doLogin = async () => {
256
+ const username = userInput.value.trim();
257
+ const password = passInput.value;
258
+ if (!username || !password) return;
259
+
260
+ submitBtn.disabled = true;
261
+ submitBtn.textContent = "Signing in...";
262
+ errorDiv.innerHTML = "";
263
+
264
+ try {
265
+ await auth.login(username, password);
266
+ overlay.remove();
267
+ route();
268
+ } catch {
269
+ errorDiv.innerHTML =
270
+ '<div class="status-message error">Invalid credentials. Try again.</div>';
271
+ submitBtn.disabled = false;
272
+ submitBtn.textContent = "Sign in";
273
+ }
274
+ };
275
+
276
+ submitBtn.addEventListener("click", doLogin);
277
+ passInput.addEventListener("keydown", (e) => {
278
+ if (e.key === "Enter") doLogin();
279
+ });
280
+ userInput.addEventListener("keydown", (e) => {
281
+ if (e.key === "Enter") passInput.focus();
282
+ });
283
+ cancelBtn.addEventListener("click", () => overlay.remove());
284
+ overlay.addEventListener("click", (e) => {
285
+ if (e.target === overlay) overlay.remove();
286
+ });
287
+ }
288
+
289
+ // ============================================================================
290
+ // Router
291
+ // ============================================================================
292
+
293
+ function getRoute() {
294
+ const hash = location.hash.slice(1) || "search";
295
+ const parts = hash.split("/");
296
+ return { page: parts[0], id: parts.slice(1).join("/") };
297
+ }
298
+
299
+ function navigate(page, id) {
300
+ location.hash = id ? `${page}/${id}` : page;
301
+ }
302
+
303
+ function updateNav() {
304
+ const { page } = getRoute();
305
+ document.querySelectorAll("nav a").forEach((a) => {
306
+ const nav = a.dataset.nav;
307
+ a.classList.toggle("active", nav === page);
308
+ });
309
+ }
310
+
311
+ // ============================================================================
312
+ // Page Modules — loaded dynamically
313
+ // ============================================================================
314
+
315
+ let modules = {};
316
+
317
+ async function loadModule(name) {
318
+ if (!modules[name]) {
319
+ modules[name] = await import(`./${name}.js`);
320
+ }
321
+ return modules[name];
322
+ }
323
+
324
+ // ============================================================================
325
+ // Route Dispatcher
326
+ // ============================================================================
327
+
328
+ async function route() {
329
+ const { page, id } = getRoute();
330
+ const main = document.getElementById("main");
331
+ updateNav();
332
+
333
+ try {
334
+ switch (page) {
335
+ case "search":
336
+ case "browse": {
337
+ const mod = await loadModule("search");
338
+ mod.render(main, page === "browse");
339
+ break;
340
+ }
341
+ case "entry": {
342
+ const mod = await loadModule("detail");
343
+ mod.render(main, id);
344
+ break;
345
+ }
346
+ case "add": {
347
+ const mod = await loadModule("editor");
348
+ mod.render(main, null);
349
+ break;
350
+ }
351
+ case "edit": {
352
+ const mod = await loadModule("editor");
353
+ mod.render(main, id);
354
+ break;
355
+ }
356
+ case "triage": {
357
+ const mod = await loadModule("triage");
358
+ mod.render(main);
359
+ break;
360
+ }
361
+ default:
362
+ main.innerHTML = `<div class="empty-state"><h2>Not Found</h2><p>Unknown route: ${escapeHtml(page)}</p></div>`;
363
+ }
364
+ } catch (err) {
365
+ console.error("Route error:", err);
366
+ main.innerHTML = `<div class="status-message error">Error loading page: ${escapeHtml(err.message)}</div>`;
367
+ }
368
+ }
369
+
370
+ // ============================================================================
371
+ // Utility exports
372
+ // ============================================================================
373
+
374
+ /**
375
+ * Escape HTML special characters to prevent XSS.
376
+ */
377
+ function escapeHtml(str) {
378
+ if (str == null) return "";
379
+ return String(str)
380
+ .replace(/&/g, "&amp;")
381
+ .replace(/</g, "&lt;")
382
+ .replace(/>/g, "&gt;")
383
+ .replace(/"/g, "&quot;")
384
+ .replace(/'/g, "&#39;");
385
+ }
386
+
387
+ /**
388
+ * Return a confidence badge HTML string.
389
+ */
390
+ function confidenceBadge(confidence) {
391
+ const cls =
392
+ confidence === "verified"
393
+ ? "badge-verified"
394
+ : confidence === "reviewed"
395
+ ? "badge-reviewed"
396
+ : "badge-ai-generated";
397
+ return `<span class="badge ${cls}">${escapeHtml(confidence)}</span>`;
398
+ }
399
+
400
+ /**
401
+ * Return tag chip HTML string(s).
402
+ */
403
+ function tagChips(tags, activeTags = []) {
404
+ if (!tags || !tags.length) return "";
405
+ return tags
406
+ .map((t) => {
407
+ const active = activeTags.includes(t) ? " active" : "";
408
+ return `<span class="tag${active}" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</span>`;
409
+ })
410
+ .join("");
411
+ }
412
+
413
+ /**
414
+ * Format a date value for display.
415
+ */
416
+ function formatDate(d) {
417
+ if (!d) return "--";
418
+ const date = new Date(d);
419
+ if (isNaN(date.getTime())) return String(d);
420
+ return date.toLocaleDateString("en-US", {
421
+ year: "numeric",
422
+ month: "short",
423
+ day: "numeric",
424
+ hour: "2-digit",
425
+ minute: "2-digit",
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Truncate text to a maximum length.
431
+ */
432
+ function truncate(text, max = 150) {
433
+ if (!text) return "";
434
+ if (text.length <= max) return text;
435
+ return text.slice(0, max) + "...";
436
+ }
437
+
438
+ // Export utilities for use by other modules
439
+ export {
440
+ api,
441
+ auth,
442
+ navigate,
443
+ escapeHtml,
444
+ confidenceBadge,
445
+ tagChips,
446
+ formatDate,
447
+ truncate,
448
+ showLoginModal,
449
+ };
450
+
451
+ // ============================================================================
452
+ // Init
453
+ // ============================================================================
454
+
455
+ window.addEventListener("hashchange", route);
456
+
457
+ // Initial load
458
+ (async () => {
459
+ await auth.check();
460
+ route();
461
+ })();
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Harper Knowledge Base — Entry Detail View
3
+ *
4
+ * Full content display, metadata, relationships,
5
+ * and edit navigation.
6
+ */
7
+
8
+ import {
9
+ api,
10
+ auth,
11
+ escapeHtml,
12
+ confidenceBadge,
13
+ tagChips,
14
+ formatDate,
15
+ showLoginModal,
16
+ } from "./app.js";
17
+
18
+ /**
19
+ * Render the entry detail page.
20
+ * @param {HTMLElement} container
21
+ * @param {string} id - Entry ID
22
+ */
23
+ export async function render(container, id) {
24
+ if (!id) {
25
+ container.innerHTML = `<div class="empty-state"><h2>No entry selected</h2><p>Select an entry from search results.</p></div>`;
26
+ return;
27
+ }
28
+
29
+ container.innerHTML = '<div class="loading">Loading entry...</div>';
30
+
31
+ try {
32
+ const entry = await api.get(`/Knowledge/${encodeURIComponent(id)}`);
33
+
34
+ if (!entry || entry.error) {
35
+ container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>${escapeHtml(entry?.error || "The requested entry does not exist.")}</p></div>`;
36
+ return;
37
+ }
38
+
39
+ container.innerHTML = renderEntry(entry);
40
+
41
+ // Wire up events
42
+ wireEvents(container, entry);
43
+ } catch (err) {
44
+ console.error("Detail error:", err);
45
+ if (err.status === 404) {
46
+ container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>No entry exists with ID: ${escapeHtml(id)}</p></div>`;
47
+ } else {
48
+ container.innerHTML = `<div class="status-message error">Failed to load entry: ${escapeHtml(err.message)}</div>`;
49
+ }
50
+ }
51
+ }
52
+
53
+ function renderEntry(entry) {
54
+ const deprecatedBanner = entry.deprecated
55
+ ? `<div class="status-message error" style="margin-bottom: 16px;">This entry has been deprecated.</div>`
56
+ : "";
57
+
58
+ return `
59
+ ${deprecatedBanner}
60
+ <div class="detail-header">
61
+ <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
62
+ <div>
63
+ <div class="detail-title">${escapeHtml(entry.title)}</div>
64
+ <div class="card-meta" style="margin-top: 6px;">
65
+ ${confidenceBadge(entry.confidence)}
66
+ ${entry.tags?.length ? tagChips(entry.tags) : ""}
67
+ </div>
68
+ </div>
69
+ <div style="display: flex; gap: 8px; flex-shrink: 0;">
70
+ <a href="#edit/${escapeHtml(entry.id)}" class="btn">Edit</a>
71
+ <button id="deprecate-btn" class="danger" style="display: ${entry.deprecated ? "none" : "inline-flex"};">Deprecate</button>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="detail-content">${escapeHtml(entry.content)}</div>
77
+
78
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
79
+ <div class="detail-section">
80
+ <h3>Metadata</h3>
81
+ <dl class="meta-grid">
82
+ <dt>ID</dt>
83
+ <dd style="font-family: var(--font-mono); font-size: 12px;">${escapeHtml(entry.id)}</dd>
84
+ <dt>Source</dt>
85
+ <dd>${
86
+ entry.sourceUrl
87
+ ? isSafeUrl(entry.sourceUrl)
88
+ ? `<a href="${escapeHtml(entry.sourceUrl)}" target="_blank" rel="noopener">${escapeHtml(entry.source || entry.sourceUrl)}</a>`
89
+ : escapeHtml(entry.source || entry.sourceUrl)
90
+ : escapeHtml(entry.source || "--")
91
+ }</dd>
92
+ <dt>Added By</dt>
93
+ <dd>${escapeHtml(entry.addedBy || "--")}</dd>
94
+ <dt>Reviewed By</dt>
95
+ <dd>${escapeHtml(entry.reviewedBy || "--")}</dd>
96
+ <dt>Created</dt>
97
+ <dd>${formatDate(entry.createdAt)}</dd>
98
+ <dt>Updated</dt>
99
+ <dd>${formatDate(entry.updatedAt)}</dd>
100
+ </dl>
101
+ </div>
102
+
103
+ <div>
104
+ ${renderApplicability(entry.appliesTo)}
105
+ ${renderRelationships(entry)}
106
+ </div>
107
+ </div>
108
+ `;
109
+ }
110
+
111
+ function renderApplicability(appliesTo) {
112
+ if (!appliesTo) return "";
113
+
114
+ const fields = [
115
+ ["Harper", appliesTo.harper],
116
+ ["Storage Engine", appliesTo.storageEngine],
117
+ ["Node.js", appliesTo.node],
118
+ ["Platform", appliesTo.platform],
119
+ ].filter(([, v]) => v);
120
+
121
+ if (fields.length === 0) return "";
122
+
123
+ return `
124
+ <div class="detail-section">
125
+ <h3>Applies To</h3>
126
+ <dl class="meta-grid">
127
+ ${fields.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}
128
+ </dl>
129
+ </div>
130
+ `;
131
+ }
132
+
133
+ function renderRelationships(entry) {
134
+ const sections = [];
135
+
136
+ if (entry.supersedesId) {
137
+ sections.push(`
138
+ <div style="margin-bottom: 8px;">
139
+ <span style="color: var(--text-dim); font-size: 12px;">Supersedes:</span>
140
+ <a href="#entry/${escapeHtml(entry.supersedesId)}" class="entry-link">${escapeHtml(entry.supersedesId)}</a>
141
+ </div>
142
+ `);
143
+ }
144
+
145
+ if (entry.supersededById) {
146
+ sections.push(`
147
+ <div style="margin-bottom: 8px;">
148
+ <span style="color: var(--text-dim); font-size: 12px;">Superseded by:</span>
149
+ <a href="#entry/${escapeHtml(entry.supersededById)}" class="entry-link">${escapeHtml(entry.supersededById)}</a>
150
+ </div>
151
+ `);
152
+ }
153
+
154
+ if (entry.siblingIds?.length) {
155
+ sections.push(`
156
+ <div style="margin-bottom: 8px;">
157
+ <span style="color: var(--text-dim); font-size: 12px;">Siblings:</span>
158
+ <div class="entry-links" style="margin-top: 4px;">
159
+ ${entry.siblingIds.map((id) => `<a href="#entry/${escapeHtml(id)}" class="entry-link">${escapeHtml(id)}</a>`).join("")}
160
+ </div>
161
+ </div>
162
+ `);
163
+ }
164
+
165
+ if (entry.relatedIds?.length) {
166
+ sections.push(`
167
+ <div style="margin-bottom: 8px;">
168
+ <span style="color: var(--text-dim); font-size: 12px;">Related:</span>
169
+ <div class="entry-links" style="margin-top: 4px;">
170
+ ${entry.relatedIds.map((id) => `<a href="#entry/${escapeHtml(id)}" class="entry-link">${escapeHtml(id)}</a>`).join("")}
171
+ </div>
172
+ </div>
173
+ `);
174
+ }
175
+
176
+ if (sections.length === 0) return "";
177
+
178
+ return `
179
+ <div class="detail-section">
180
+ <h3>Relationships</h3>
181
+ ${sections.join("")}
182
+ </div>
183
+ `;
184
+ }
185
+
186
+ /** Only render http/https URLs as clickable links. */
187
+ function isSafeUrl(url) {
188
+ try {
189
+ const parsed = new URL(url);
190
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function wireEvents(container, entry) {
197
+ const deprecateBtn = container.querySelector("#deprecate-btn");
198
+ if (deprecateBtn) {
199
+ deprecateBtn.addEventListener("click", async () => {
200
+ if (!auth.authenticated) {
201
+ showLoginModal();
202
+ return;
203
+ }
204
+
205
+ if (!confirm(`Deprecate "${entry.title}"? This cannot be undone easily.`))
206
+ return;
207
+
208
+ deprecateBtn.disabled = true;
209
+ deprecateBtn.textContent = "Deprecating...";
210
+
211
+ try {
212
+ await api.delete(`/Knowledge/${encodeURIComponent(entry.id)}`);
213
+ // Re-render the page to show deprecated state
214
+ render(container, entry.id);
215
+ } catch (err) {
216
+ console.error("Deprecate error:", err);
217
+ alert(`Failed to deprecate: ${err.message}`);
218
+ deprecateBtn.disabled = false;
219
+ deprecateBtn.textContent = "Deprecate";
220
+ }
221
+ });
222
+ }
223
+ }