llm-wiki-compiler 0.7.0 → 0.8.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.
@@ -22,43 +22,101 @@
22
22
  import { wireSearch } from "./viewer-search.js";
23
23
  import { renderSidebar, markActive } from "./viewer-sidebar.js";
24
24
  import { renderProjectRail, renderSupportRail, clearSupportRail } from "./viewer-rail.js";
25
+ import { loadGraph } from "./viewer-graph.js";
25
26
 
26
27
  const PAGE_INDEX_SELECTOR = "#page-index";
27
28
  const MAIN_SELECTOR = "[data-main-pane]";
28
29
  const TITLE_SELECTOR = "[data-app-title]";
30
+ const DEFAULT_TITLE = "llmwiki";
31
+ const EMPTY_INDEX = { pages: [] };
32
+
33
+ /** Hashes that all map to the home route — `#`, `#/`, and empty/missing. */
34
+ const HOME_HASHES = new Set(["", "#", "#/"]);
35
+
36
+ /** Static routes whose hash uniquely names the kind (no slug segment). */
37
+ const STATIC_ROUTES = new Map([
38
+ ["#/index", { kind: "index" }],
39
+ ["#/health", { kind: "health" }],
40
+ ["#/graph", { kind: "graph" }],
41
+ ]);
42
+
43
+ /** Pattern matching `#/(concepts|queries)/<slug>` hash routes. */
44
+ const PAGE_HASH_PATTERN = /^#\/(concepts|queries)\/(.+)$/;
45
+
46
+ /** Rows for the home dashboard counts grid: `[label, envelope.counts key]`. */
47
+ const COUNT_ROWS = [
48
+ ["Concepts", "concepts"],
49
+ ["Saved queries", "queries"],
50
+ ["Source files", "sourceFiles"],
51
+ ["Pending reviews", "pendingReviews"],
52
+ ];
53
+
54
+ /** Rows for the /api/health metrics block: `[label, health key]`. */
55
+ const HEALTH_METRIC_ROWS = [
56
+ ["Concepts", "concepts"],
57
+ ["Saved queries", "queries"],
58
+ ["Compiled sources", "sources"],
59
+ ["Source files", "sourceFiles"],
60
+ ["Pending reviews", "pendingReviews"],
61
+ ];
62
+
63
+ /** Rows for the lint block: `[label, key, fallback]`. */
64
+ const LINT_METRIC_ROWS = [
65
+ ["Warnings", "warnings", 0],
66
+ ["Errors", "errors", 0],
67
+ ["Last run", "at", ""],
68
+ ];
29
69
 
30
70
  /** Parse the server-embedded page-index JSON. Empty list if absent or malformed. */
31
71
  function readEmbeddedIndex() {
32
72
  const node = document.querySelector(PAGE_INDEX_SELECTOR);
33
- if (!node || !node.textContent) return { pages: [] };
73
+ const text = node?.textContent;
74
+ if (!text) return EMPTY_INDEX;
75
+ return parsePageIndex(text);
76
+ }
77
+
78
+ /** Best-effort JSON.parse of the embedded blob. Always returns a `{pages}` shape. */
79
+ function parsePageIndex(text) {
34
80
  try {
35
- const data = JSON.parse(node.textContent);
36
- return Array.isArray(data?.pages) ? { pages: data.pages } : { pages: [] };
81
+ const data = JSON.parse(text);
82
+ if (Array.isArray(data?.pages)) return { pages: data.pages };
37
83
  } catch {
38
- return { pages: [] };
84
+ // Malformed JSON in the embedded blob is not a user-facing error.
39
85
  }
86
+ return EMPTY_INDEX;
40
87
  }
41
88
 
42
-
43
89
  /**
44
- * Parse `location.hash` into a route descriptor. Malformed percent-
45
- * encoding in the slug segment falls back to the home route so a typo
46
- * or hand-edited URL cannot throw from `decodeURIComponent` and crash
47
- * the client (`#/concepts/%E0%A4%A` is the canonical bad-input case).
90
+ * Parse `location.hash` into a route descriptor. Static routes resolve
91
+ * via `STATIC_ROUTES`; page routes fall through to {@link parsePageRoute}.
92
+ * Malformed percent-encoding in the slug segment falls back to the home
93
+ * route so a hand-edited URL cannot throw from `decodeURIComponent`
94
+ * (`#/concepts/%E0%A4%A` is the canonical bad-input case).
48
95
  */
49
96
  function parseRoute(hash) {
50
- if (!hash || hash === "#" || hash === "#/" || hash === "") return { kind: "home" };
51
- if (hash === "#/index") return { kind: "index" };
52
- if (hash === "#/health") return { kind: "health" };
53
- const match = hash.match(/^#\/(concepts|queries)\/(.+)$/);
97
+ const key = hash ?? "";
98
+ if (HOME_HASHES.has(key)) return { kind: "home" };
99
+ const staticRoute = STATIC_ROUTES.get(key);
100
+ if (staticRoute) return staticRoute;
101
+ return parsePageRoute(key);
102
+ }
103
+
104
+ /** Resolve a `#/(concepts|queries)/<slug>` hash; non-matches return home. */
105
+ function parsePageRoute(hash) {
106
+ const match = hash.match(PAGE_HASH_PATTERN);
54
107
  if (!match) return { kind: "home" };
55
- let slug;
108
+ const slug = decodeSlug(match[2]);
109
+ if (slug === null) return { kind: "home" };
110
+ return { kind: "page", directory: match[1], slug };
111
+ }
112
+
113
+ /** Safely percent-decode a slug; returns null on malformed input. */
114
+ function decodeSlug(raw) {
56
115
  try {
57
- slug = decodeURIComponent(match[2]);
116
+ return decodeURIComponent(raw);
58
117
  } catch {
59
- return { kind: "home" };
118
+ return null;
60
119
  }
61
- return { kind: "page", directory: match[1], slug };
62
120
  }
63
121
 
64
122
  /** Render the home dashboard from the `/api/pages` envelope. */
@@ -67,25 +125,46 @@ function renderHome(envelope) {
67
125
  if (!main) return;
68
126
  main.innerHTML = "";
69
127
  main.className = "main-pane home-dashboard";
70
- const title = document.createElement("h1");
71
- title.textContent = envelope.project?.title || "llmwiki";
72
- main.appendChild(title);
73
- main.appendChild(buildCountsBlock(envelope.counts || {}));
74
- if (envelope.index?.available) main.appendChild(buildIndexLink(envelope.index.href));
75
- if (Array.isArray(envelope.recentPages) && envelope.recentPages.length > 0) {
128
+ appendHomeContent(main, envelope);
129
+ renderProjectRail(envelope);
130
+ }
131
+
132
+ /** Append every section of the home dashboard to the main pane. */
133
+ function appendHomeContent(main, envelope) {
134
+ main.appendChild(buildHeading("h1", projectTitle(envelope)));
135
+ main.appendChild(buildCountsBlock(envelope?.counts));
136
+ appendIndexLinkIfAvailable(main, envelope);
137
+ appendRecentBlockIfAny(main, envelope);
138
+ }
139
+
140
+ /** Append the compiled-index link, if the envelope flagged it available. */
141
+ function appendIndexLinkIfAvailable(main, envelope) {
142
+ if (envelope?.index?.available) {
143
+ main.appendChild(buildIndexLink(envelope.index.href));
144
+ }
145
+ }
146
+
147
+ /** Append the recent-updates block, if the envelope carried any rows. */
148
+ function appendRecentBlockIfAny(main, envelope) {
149
+ if (hasRecentPages(envelope)) {
76
150
  main.appendChild(buildRecentBlock(envelope.recentPages));
77
151
  }
78
- renderProjectRail(envelope);
152
+ }
153
+
154
+ /** Display title for the envelope, with a stable fallback. */
155
+ function projectTitle(envelope) {
156
+ return envelope?.project?.title || DEFAULT_TITLE;
157
+ }
158
+
159
+ /** True when the envelope carries at least one recent page. */
160
+ function hasRecentPages(envelope) {
161
+ return Array.isArray(envelope?.recentPages) && envelope.recentPages.length > 0;
79
162
  }
80
163
 
81
164
  /** Render a `<dl>` of project counts on the home dashboard. */
82
165
  function buildCountsBlock(counts) {
83
- const dl = buildDefinitionList([
84
- ["Concepts", counts.concepts ?? 0],
85
- ["Saved queries", counts.queries ?? 0],
86
- ["Source files", counts.sourceFiles ?? 0],
87
- ["Pending reviews", counts.pendingReviews ?? 0],
88
- ]);
166
+ const rows = COUNT_ROWS.map(([label, key]) => [label, counts?.[key] ?? 0]);
167
+ const dl = buildDefinitionList(rows);
89
168
  dl.className = "metric-grid";
90
169
  return dl;
91
170
  }
@@ -120,8 +199,6 @@ function buildIndexLink(href) {
120
199
 
121
200
  /** Render the recent-pages list on the home dashboard. */
122
201
  function buildRecentBlock(recent) {
123
- const h2 = document.createElement("h2");
124
- h2.textContent = "Recently updated";
125
202
  const ul = document.createElement("ul");
126
203
  ul.className = "recent-list";
127
204
  for (const page of recent) {
@@ -134,11 +211,34 @@ function buildRecentBlock(recent) {
134
211
  }
135
212
  const wrap = document.createElement("section");
136
213
  wrap.className = "recent-section";
137
- wrap.appendChild(h2);
214
+ wrap.appendChild(buildHeading("h2", "Recently updated"));
138
215
  wrap.appendChild(ul);
139
216
  return wrap;
140
217
  }
141
218
 
219
+ /** Build a heading element with the given tag and text content. */
220
+ function buildHeading(tag, text) {
221
+ const el = document.createElement(tag);
222
+ el.textContent = text;
223
+ return el;
224
+ }
225
+
226
+ /** Build a `<p class="placeholder">` with the given message. */
227
+ function buildPlaceholder(text) {
228
+ const p = document.createElement("p");
229
+ p.className = "placeholder";
230
+ p.textContent = text;
231
+ return p;
232
+ }
233
+
234
+ /** Dispatch table: route.kind → handler for routes that fit the (main) signature. */
235
+ const ROUTE_RENDERERS = {
236
+ home: () => loadAndRenderHome(),
237
+ index: (main) => renderIndexPane(main),
238
+ health: (main) => renderHealthPane(main),
239
+ graph: (main) => renderGraphPane(main),
240
+ };
241
+
142
242
  /** Fetch and render the page at the current hash route. */
143
243
  async function renderRoute() {
144
244
  const route = parseRoute(location.hash);
@@ -146,9 +246,8 @@ async function renderRoute() {
146
246
  const main = document.querySelector(MAIN_SELECTOR);
147
247
  if (!main) return;
148
248
  main.className = "main-pane";
149
- if (route.kind === "home") return loadAndRenderHome();
150
- if (route.kind === "index") return renderIndexPane(main);
151
- if (route.kind === "health") return renderHealthPane(main);
249
+ const handler = ROUTE_RENDERERS[route.kind];
250
+ if (handler) return handler(main);
152
251
  return renderPagePane(main, route.directory, route.slug);
153
252
  }
154
253
 
@@ -157,9 +256,7 @@ async function renderHealthPane(main) {
157
256
  try {
158
257
  const health = await fetchJson("/api/health");
159
258
  main.innerHTML = "";
160
- const h1 = document.createElement("h1");
161
- h1.textContent = "Health";
162
- main.appendChild(h1);
259
+ main.appendChild(buildHeading("h1", "Health"));
163
260
  main.appendChild(buildHealthDashboard(health));
164
261
  clearSupportRail();
165
262
  } catch (err) {
@@ -171,37 +268,24 @@ async function renderHealthPane(main) {
171
268
  function buildHealthDashboard(health) {
172
269
  const wrap = document.createElement("section");
173
270
  wrap.className = "health-dashboard";
174
- const metrics = buildDefinitionList([
175
- ["Concepts", health.concepts ?? 0],
176
- ["Saved queries", health.queries ?? 0],
177
- ["Compiled sources", health.sources ?? 0],
178
- ["Source files", health.sourceFiles ?? 0],
179
- ["Pending reviews", health.pendingReviews ?? 0],
180
- ]);
271
+ const rows = HEALTH_METRIC_ROWS.map(([label, key]) => [label, health?.[key] ?? 0]);
272
+ const metrics = buildDefinitionList(rows);
181
273
  metrics.className = "metric-list";
182
274
  wrap.appendChild(metrics);
183
- wrap.appendChild(buildLintBlock(health.lint));
275
+ wrap.appendChild(buildLintBlock(health?.lint));
184
276
  return wrap;
185
277
  }
186
278
 
187
279
  /** Render the lint summary, or a "lint has not been run yet" placeholder. */
188
280
  function buildLintBlock(lint) {
189
281
  const wrap = document.createElement("section");
190
- const h2 = document.createElement("h2");
191
- h2.textContent = "Lint";
192
- wrap.appendChild(h2);
282
+ wrap.appendChild(buildHeading("h2", "Lint"));
193
283
  if (!lint) {
194
- const note = document.createElement("p");
195
- note.className = "placeholder";
196
- note.textContent = "No cached lint summary yet — run `llmwiki lint`.";
197
- wrap.appendChild(note);
284
+ wrap.appendChild(buildPlaceholder("No cached lint summary yet — run `llmwiki lint`."));
198
285
  return wrap;
199
286
  }
200
- wrap.appendChild(buildDefinitionList([
201
- ["Warnings", lint.warnings ?? 0],
202
- ["Errors", lint.errors ?? 0],
203
- ["Last run", lint.at ?? ""],
204
- ]));
287
+ const rows = LINT_METRIC_ROWS.map(([label, key, fallback]) => [label, lint[key] ?? fallback]);
288
+ wrap.appendChild(buildDefinitionList(rows));
205
289
  return wrap;
206
290
  }
207
291
 
@@ -209,69 +293,89 @@ function buildLintBlock(lint) {
209
293
  async function loadAndRenderHome() {
210
294
  try {
211
295
  const envelope = await fetchJson("/api/pages");
212
- document.querySelector(TITLE_SELECTOR).textContent = envelope.project?.title || "llmwiki";
213
- renderSidebar(envelope.pages || []);
214
- renderHome(envelope);
296
+ applyHomeEnvelope(envelope);
215
297
  } catch (err) {
216
298
  renderError(`Could not load /api/pages: ${err.message}`);
217
299
  }
218
300
  }
219
301
 
302
+ /** Apply a successfully fetched /api/pages envelope to the chrome + main pane. */
303
+ function applyHomeEnvelope(envelope) {
304
+ const titleEl = document.querySelector(TITLE_SELECTOR);
305
+ titleEl.textContent = projectTitle(envelope);
306
+ renderSidebar(envelope?.pages || []);
307
+ renderHome(envelope);
308
+ }
309
+
220
310
  /** Fetch /api/index and render the rendered HTML coming back from the server. */
221
311
  async function renderIndexPane(main) {
222
312
  clearSupportRail();
223
313
  try {
224
314
  const payload = await fetchJson("/api/index");
225
315
  main.innerHTML = "";
226
- const h1 = document.createElement("h1");
227
- h1.textContent = "Index";
228
- main.appendChild(h1);
316
+ main.appendChild(buildHeading("h1", "Index"));
229
317
  appendRenderedBody(main, payload.html);
230
318
  } catch (err) {
231
- if (err.status === 404) {
232
- main.innerHTML = "";
233
- const note = document.createElement("p");
234
- note.className = "placeholder";
235
- note.textContent = "wiki/index.md is not available. Run `llmwiki compile`.";
236
- main.appendChild(note);
237
- } else {
238
- renderError(`Could not load /api/index: ${err.message}`);
239
- }
319
+ handleIndexError(main, err);
240
320
  }
241
321
  }
242
322
 
323
+ /** Render either the "wiki/index.md missing" placeholder or a generic error. */
324
+ function handleIndexError(main, err) {
325
+ if (err.status !== 404) {
326
+ renderError(`Could not load /api/index: ${err.message}`);
327
+ return;
328
+ }
329
+ main.innerHTML = "";
330
+ main.appendChild(buildPlaceholder("wiki/index.md is not available. Run `llmwiki compile`."));
331
+ }
332
+
243
333
  /** Fetch /api/page/:dir/:slug and render. */
244
334
  async function renderPagePane(main, directory, slug) {
245
335
  try {
246
- const payload = await fetchJson(
247
- `/api/page/${encodeURIComponent(directory)}/${encodeURIComponent(slug)}`,
248
- );
249
- main.innerHTML = "";
250
- const h1 = document.createElement("h1");
251
- h1.textContent = payload.title || slug;
252
- main.appendChild(h1);
253
- if (payload.pageDirectory === "queries") {
254
- const question = document.createElement("p");
255
- question.className = "query-question";
256
- question.textContent = `Question: ${payload.title || slug}`;
257
- main.appendChild(question);
258
- }
259
- appendWarnings(main, payload.warnings || []);
260
- const body = appendRenderedBody(main, payload.html);
261
- removeDuplicateLeadingHeading(body, payload.title || slug);
262
- renderSupportRail(payload);
336
+ const payload = await fetchJson(pageApiPath(directory, slug));
337
+ renderPagePayload(main, payload, slug);
263
338
  } catch (err) {
264
- if (err.status === 404) {
265
- main.innerHTML = "";
266
- const note = document.createElement("p");
267
- note.className = "placeholder";
268
- note.textContent = `Page not found: ${directory}/${slug}`;
269
- main.appendChild(note);
270
- clearSupportRail();
271
- } else {
272
- renderError(`Could not load page: ${err.message}`);
273
- }
339
+ handlePageError(main, err, directory, slug);
340
+ }
341
+ }
342
+
343
+ /** Build the `/api/page/:dir/:slug` URL with both segments percent-encoded. */
344
+ function pageApiPath(directory, slug) {
345
+ return `/api/page/${encodeURIComponent(directory)}/${encodeURIComponent(slug)}`;
346
+ }
347
+
348
+ /** Render the body of a successful /api/page response into the main pane. */
349
+ function renderPagePayload(main, payload, slug) {
350
+ const title = payload.title || slug;
351
+ main.innerHTML = "";
352
+ main.appendChild(buildHeading("h1", title));
353
+ if (payload.pageDirectory === "queries") {
354
+ main.appendChild(buildQueryQuestion(title));
355
+ }
356
+ appendWarnings(main, payload.warnings || []);
357
+ const body = appendRenderedBody(main, payload.html);
358
+ removeDuplicateLeadingHeading(body, title);
359
+ renderSupportRail(payload);
360
+ }
361
+
362
+ /** Question banner shown above the body for saved-query pages. */
363
+ function buildQueryQuestion(title) {
364
+ const p = document.createElement("p");
365
+ p.className = "query-question";
366
+ p.textContent = `Question: ${title}`;
367
+ return p;
368
+ }
369
+
370
+ /** Render the 404 placeholder or a generic error for /api/page failures. */
371
+ function handlePageError(main, err, directory, slug) {
372
+ if (err.status !== 404) {
373
+ renderError(`Could not load page: ${err.message}`);
374
+ return;
274
375
  }
376
+ main.innerHTML = "";
377
+ main.appendChild(buildPlaceholder(`Page not found: ${directory}/${slug}`));
378
+ clearSupportRail();
275
379
  }
276
380
 
277
381
  /**
@@ -290,18 +394,31 @@ function appendRenderedBody(main, html) {
290
394
  main.appendChild(body);
291
395
  return body;
292
396
  }
293
- const note = emptyBodyNote();
397
+ const note = buildPlaceholder("No rendered content.");
294
398
  main.appendChild(note);
295
399
  return note;
296
400
  }
297
401
 
298
402
  /** Drop a duplicated first Markdown H1 when it matches the viewer page title. */
299
403
  function removeDuplicateLeadingHeading(body, title) {
300
- if (!body || !title) return;
301
- const first = body.firstElementChild;
302
- if (!first || first.tagName !== "H1") return;
303
- if (first.textContent?.trim() !== title.trim()) return;
304
- first.remove();
404
+ const heading = leadingH1(body);
405
+ if (!heading) return;
406
+ if (!hasMatchingHeadingText(heading, title)) return;
407
+ heading.remove();
408
+ }
409
+
410
+ /** Return `body.firstElementChild` if it is an H1, else null. */
411
+ function leadingH1(body) {
412
+ const first = body?.firstElementChild;
413
+ if (!first) return null;
414
+ return first.tagName === "H1" ? first : null;
415
+ }
416
+
417
+ /** True when the heading text matches `title` after trimming both sides. */
418
+ function hasMatchingHeadingText(heading, title) {
419
+ if (!title) return false;
420
+ const headingText = heading.textContent?.trim();
421
+ return headingText === title.trim();
305
422
  }
306
423
 
307
424
  /** Render every payload warning as a banner above the page body. */
@@ -314,14 +431,6 @@ function appendWarnings(main, warnings) {
314
431
  }
315
432
  }
316
433
 
317
- /** Visible "no content" fallback for pages whose body is empty after frontmatter. */
318
- function emptyBodyNote() {
319
- const note = document.createElement("p");
320
- note.className = "placeholder";
321
- note.textContent = "No rendered content.";
322
- return note;
323
- }
324
-
325
434
  /** Render a top-of-main error banner without crashing the rest of the UI. */
326
435
  function renderError(message) {
327
436
  const main = document.querySelector(MAIN_SELECTOR);
@@ -334,6 +443,14 @@ function renderError(message) {
334
443
  clearSupportRail();
335
444
  }
336
445
 
446
+ /** Fetch /api/graph and render the force-directed graph view. */
447
+ async function renderGraphPane(main) {
448
+ clearSupportRail();
449
+ main.innerHTML = "";
450
+ main.className = "main-pane graph-pane";
451
+ await loadGraph(main);
452
+ }
453
+
337
454
  /** Promise-returning fetch helper that surfaces non-2xx statuses as errors. */
338
455
  async function fetchJson(pathname) {
339
456
  const res = await fetch(pathname, { credentials: "same-origin" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-wiki-compiler",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A knowledge compiler CLI — raw sources in, interlinked wiki out",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,10 +10,12 @@
10
10
  "build": "tsup",
11
11
  "dev": "tsup --watch",
12
12
  "dev:view": "node scripts/dev-viewer.mjs",
13
+ "release:check-docs": "node scripts/check-release-docs.mjs",
14
+ "release:check-docs:current": "node scripts/check-release-docs.mjs --current-version",
13
15
  "test": "vitest run",
14
16
  "test:watch": "vitest",
15
17
  "fallow:ci": "bash scripts/fallow-ci.sh",
16
- "prepublishOnly": "npm run build && npm test",
18
+ "prepublishOnly": "npm run release:check-docs:current && npm run build && npm pack --dry-run --ignore-scripts",
17
19
  "prepare": "husky"
18
20
  },
19
21
  "keywords": [
@@ -36,6 +38,7 @@
36
38
  },
37
39
  "files": [
38
40
  "dist/",
41
+ "CHANGELOG.md",
39
42
  "LICENSE",
40
43
  "README.md"
41
44
  ],
@@ -67,7 +70,7 @@
67
70
  "@types/markdown-it": "^14.1.2",
68
71
  "@types/sanitize-html": "^2.16.1",
69
72
  "@types/turndown": "^5.0.0",
70
- "fallow": "2.42.0",
73
+ "fallow": "2.82.0",
71
74
  "husky": "^9.1.7",
72
75
  "tsup": "^8.0.0",
73
76
  "typescript": "^5.7.0",