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.
@@ -84,19 +84,29 @@ export function wireSearch({ fetchJson }) {
84
84
  async function runSearchAndRender(query, results, fetchJson, stillCurrent) {
85
85
  try {
86
86
  const data = await fetchJson(`/api/search?q=${encodeURIComponent(query)}`);
87
- if (!stillCurrent()) return;
88
- renderSearchResults(data.results ?? [], results);
87
+ handleSearchSuccess(data, results, stillCurrent);
89
88
  } catch (err) {
90
- if (!stillCurrent()) return;
91
- results.innerHTML = "";
92
- const li = document.createElement("li");
93
- li.className = "empty";
94
- li.textContent = `Search failed: ${err.message}`;
95
- results.appendChild(li);
96
- results.hidden = false;
89
+ handleSearchFailure(err, results, stillCurrent);
97
90
  }
98
91
  }
99
92
 
93
+ /** Render the response rows when the user hasn't moved past this query yet. */
94
+ function handleSearchSuccess(data, results, stillCurrent) {
95
+ if (!stillCurrent()) return;
96
+ renderSearchResults(data.results ?? [], results);
97
+ }
98
+
99
+ /** Show an inline error in the results panel when the search fetch threw. */
100
+ function handleSearchFailure(err, results, stillCurrent) {
101
+ if (!stillCurrent()) return;
102
+ results.innerHTML = "";
103
+ const li = document.createElement("li");
104
+ li.className = "empty";
105
+ li.textContent = `Search failed: ${err.message}`;
106
+ results.appendChild(li);
107
+ results.hidden = false;
108
+ }
109
+
100
110
  /** Render rows into the results <ul>; show an "empty" message for zero hits. */
101
111
  function renderSearchResults(rows, results) {
102
112
  results.innerHTML = "";
@@ -168,18 +178,40 @@ function onSearchInputKeydown(event, results) {
168
178
  /** ArrowUp/Down within results cycle focus; Escape returns focus to input. */
169
179
  function onSearchResultsKeydown(event, input) {
170
180
  if (event.key === "Escape") {
171
- event.preventDefault();
172
- input.focus();
181
+ handleSearchEscape(event, input);
173
182
  return;
174
183
  }
175
- if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
176
- const target = event.target instanceof HTMLAnchorElement ? event.target : null;
184
+ const direction = arrowDirection(event.key);
185
+ if (!direction) return;
186
+ const target = anchorTarget(event.target);
177
187
  if (!target) return;
178
188
  event.preventDefault();
189
+ focusNeighborAnchor(target, direction);
190
+ }
191
+
192
+ /** Bounce focus back to the search input on Escape. */
193
+ function handleSearchEscape(event, input) {
194
+ event.preventDefault();
195
+ input.focus();
196
+ }
197
+
198
+ /** Map ArrowDown/ArrowUp to +1/-1; return 0 for any other key. */
199
+ function arrowDirection(key) {
200
+ if (key === "ArrowDown") return 1;
201
+ if (key === "ArrowUp") return -1;
202
+ return 0;
203
+ }
204
+
205
+ /** Narrow an EventTarget to an HTMLAnchorElement, or null when it isn't one. */
206
+ function anchorTarget(target) {
207
+ return target instanceof HTMLAnchorElement ? target : null;
208
+ }
209
+
210
+ /** Wrap focus across the result anchors by `direction` (+1 or -1). */
211
+ function focusNeighborAnchor(target, direction) {
179
212
  const all = Array.from(target.closest("ul").querySelectorAll("a[data-search-result]"));
213
+ if (all.length === 0) return;
180
214
  const idx = all.indexOf(target);
181
- const next = event.key === "ArrowDown"
182
- ? all[(idx + 1) % all.length]
183
- : all[(idx - 1 + all.length) % all.length];
215
+ const next = all[(idx + direction + all.length) % all.length];
184
216
  if (next) next.focus();
185
217
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * llmwiki viewer — sidebar renderer.
3
3
  *
4
- * Renders concept pages grouped by frontmatter `kind` (defaulting to
5
- * "concept" when absent — spec line 347), then a "Saved Queries"
6
- * group, then the standing "Health" entry. Groups use native
4
+ * Renders the standing project links first, then concept pages grouped
5
+ * by frontmatter `kind` (defaulting to "concept" when absent — spec
6
+ * line 347), then a "Saved Queries" group. Groups use native
7
7
  * `<details><summary>` so keyboard users get Enter/Space collapse for
8
8
  * free without bespoke ARIA wiring.
9
9
  *
@@ -15,29 +15,57 @@
15
15
 
16
16
  const SIDEBAR_SELECTOR = "[data-sidebar]";
17
17
  const DEFAULT_KIND = "concept";
18
+ const EMPTY_PLACEHOLDER_TEXT = "No pages yet — run `llmwiki compile`.";
19
+
20
+ /**
21
+ * Static (non-page) hash routes that have a dedicated sidebar link.
22
+ * `markActive` highlights the entry via `a[data-route="<route>"]`
23
+ * without needing to parse the route descriptor.
24
+ */
25
+ const STATIC_ROUTE_LINK_SELECTORS = new Map([
26
+ ["#/graph", 'a[data-route="graph"]'],
27
+ ["#/health", 'a[data-route="health"]'],
28
+ ]);
18
29
 
19
30
  /** Render the sidebar groups + standing Health entry, then mark active. */
20
31
  export function renderSidebar(pages) {
21
32
  const sidebar = document.querySelector(SIDEBAR_SELECTOR);
22
33
  if (!sidebar) return;
23
34
  sidebar.innerHTML = "";
24
- const concepts = pages.filter((p) => p.pageDirectory === "concepts");
25
- const queries = pages.filter((p) => p.pageDirectory === "queries");
26
- const conceptGroups = groupConceptsByKind(concepts);
27
- for (const [kind, groupPages] of conceptGroups) {
35
+ sidebar.appendChild(buildProjectSection());
36
+ const concepts = filterByDirectory(pages, "concepts");
37
+ const queries = filterByDirectory(pages, "queries");
38
+ appendConceptGroups(sidebar, concepts);
39
+ appendQueryGroup(sidebar, queries);
40
+ appendEmptyPlaceholderIfNeeded(sidebar, concepts, queries);
41
+ markActive();
42
+ }
43
+
44
+ /** Filter pages to those whose `pageDirectory` matches the given bucket. */
45
+ function filterByDirectory(pages, directory) {
46
+ return pages.filter((p) => p.pageDirectory === directory);
47
+ }
48
+
49
+ /** Append one collapsible `<details>` group per concept kind. */
50
+ function appendConceptGroups(sidebar, concepts) {
51
+ for (const [kind, groupPages] of groupConceptsByKind(concepts)) {
28
52
  sidebar.appendChild(buildCollapsibleGroup(formatKindLabel(kind), groupPages, "kind", kind));
29
53
  }
30
- if (queries.length > 0) {
31
- sidebar.appendChild(buildCollapsibleGroup("Saved Queries", queries, "kind", "query"));
32
- }
33
- if (concepts.length === 0 && queries.length === 0) {
34
- const empty = document.createElement("p");
35
- empty.className = "placeholder";
36
- empty.textContent = "No pages yet — run `llmwiki compile`.";
37
- sidebar.appendChild(empty);
38
- }
39
- sidebar.appendChild(buildHealthEntry());
40
- markActive();
54
+ }
55
+
56
+ /** Append the Saved Queries group when at least one query page exists. */
57
+ function appendQueryGroup(sidebar, queries) {
58
+ if (queries.length === 0) return;
59
+ sidebar.appendChild(buildCollapsibleGroup("Saved Queries", queries, "kind", "query"));
60
+ }
61
+
62
+ /** Render the "No pages yet" placeholder when both buckets are empty. */
63
+ function appendEmptyPlaceholderIfNeeded(sidebar, concepts, queries) {
64
+ if (concepts.length > 0 || queries.length > 0) return;
65
+ const empty = document.createElement("p");
66
+ empty.className = "placeholder";
67
+ empty.textContent = EMPTY_PLACEHOLDER_TEXT;
68
+ sidebar.appendChild(empty);
41
69
  }
42
70
 
43
71
  /**
@@ -49,9 +77,31 @@ export function renderSidebar(pages) {
49
77
  */
50
78
  export function markActive() {
51
79
  const hash = location.hash;
52
- const expectedId = parseExpectedPageId(hash);
53
80
  const links = document.querySelectorAll(`${SIDEBAR_SELECTOR} a`);
81
+ clearCurrentAttribute(links);
82
+ if (markStaticRoute(hash)) return;
83
+ markPageRoute(links, parseExpectedPageId(hash));
84
+ }
85
+
86
+ /** Remove `aria-current` from every sidebar link in `links`. */
87
+ function clearCurrentAttribute(links) {
54
88
  for (const link of links) link.removeAttribute("aria-current");
89
+ }
90
+
91
+ /**
92
+ * Apply `aria-current="page"` to the static-route link for `hash`,
93
+ * if the hash names a known static route. Returns true when handled
94
+ * so the page-route fallback can be skipped.
95
+ */
96
+ function markStaticRoute(hash) {
97
+ const selector = STATIC_ROUTE_LINK_SELECTORS.get(hash);
98
+ if (!selector) return false;
99
+ document.querySelector(selector)?.setAttribute("aria-current", "page");
100
+ return true;
101
+ }
102
+
103
+ /** Apply `aria-current="page"` to the link whose pageId matches `expectedId`. */
104
+ function markPageRoute(links, expectedId) {
55
105
  if (!expectedId) return;
56
106
  for (const link of links) {
57
107
  if (link.dataset.pageId === expectedId) {
@@ -70,18 +120,32 @@ export function markActive() {
70
120
  function groupConceptsByKind(concepts) {
71
121
  const byKind = new Map();
72
122
  for (const page of concepts) {
73
- const kind = (typeof page.kind === "string" && page.kind.length > 0) ? page.kind : DEFAULT_KIND;
74
- if (!byKind.has(kind)) byKind.set(kind, []);
75
- byKind.get(kind).push(page);
123
+ addPageToKindBucket(byKind, page);
76
124
  }
77
- const kinds = Array.from(byKind.keys()).sort((a, b) => {
78
- if (a === DEFAULT_KIND) return -1;
79
- if (b === DEFAULT_KIND) return 1;
80
- return a.localeCompare(b);
81
- });
125
+ const kinds = Array.from(byKind.keys()).sort(compareKinds);
82
126
  return kinds.map((kind) => /** @type {[string, Array]} */ ([kind, byKind.get(kind)]));
83
127
  }
84
128
 
129
+ /** Push `page` onto the bucket for its resolved kind, creating it if needed. */
130
+ function addPageToKindBucket(byKind, page) {
131
+ const kind = resolveKind(page);
132
+ if (!byKind.has(kind)) byKind.set(kind, []);
133
+ byKind.get(kind).push(page);
134
+ }
135
+
136
+ /** Read `page.kind` defensively, falling back to DEFAULT_KIND when absent. */
137
+ function resolveKind(page) {
138
+ if (typeof page.kind === "string" && page.kind.length > 0) return page.kind;
139
+ return DEFAULT_KIND;
140
+ }
141
+
142
+ /** Sort comparator that floats DEFAULT_KIND first, then locale-orders the rest. */
143
+ function compareKinds(a, b) {
144
+ if (a === DEFAULT_KIND) return -1;
145
+ if (b === DEFAULT_KIND) return 1;
146
+ return a.localeCompare(b);
147
+ }
148
+
85
149
  /** Title-case a kind for the group heading. */
86
150
  function formatKindLabel(kind) {
87
151
  if (kind === DEFAULT_KIND) return "Concepts";
@@ -113,23 +177,29 @@ function buildPageListItem(page) {
113
177
  return li;
114
178
  }
115
179
 
116
- /** Build the standing "Health" sidebar entry that routes to #/health. */
117
- function buildHealthEntry() {
180
+ /** Build the standing "Project" sidebar section with Health and Graph links. */
181
+ function buildProjectSection() {
118
182
  const wrap = document.createElement("section");
119
183
  wrap.className = "sidebar-health";
120
184
  const heading = document.createElement("h2");
121
185
  heading.textContent = "Project";
122
186
  wrap.appendChild(heading);
123
187
  const list = document.createElement("ul");
188
+ list.appendChild(buildProjectRouteItem("#/health", "health", "Health"));
189
+ list.appendChild(buildProjectRouteItem("#/graph", "graph", "Graph"));
190
+ wrap.appendChild(list);
191
+ return wrap;
192
+ }
193
+
194
+ /** Build one `<li><a>` entry for the standing Project section. */
195
+ function buildProjectRouteItem(href, route, label) {
124
196
  const item = document.createElement("li");
125
197
  const link = document.createElement("a");
126
- link.href = "#/health";
127
- link.dataset.healthLink = "true";
128
- link.textContent = "Health";
198
+ link.href = href;
199
+ link.dataset.route = route;
200
+ link.textContent = label;
129
201
  item.appendChild(link);
130
- list.appendChild(item);
131
- wrap.appendChild(list);
132
- return wrap;
202
+ return item;
133
203
  }
134
204
 
135
205
  /**
@@ -104,7 +104,8 @@ a:hover, a:focus-visible { text-decoration: underline; }
104
104
  fill: currentColor;
105
105
  }
106
106
  .github-stars {
107
- font-size: 1.05em;
107
+ font-size: 0.82rem;
108
+ font-weight: 500;
108
109
  font-variant-numeric: tabular-nums;
109
110
  }
110
111
 
@@ -312,3 +313,69 @@ a:hover, a:focus-visible { text-decoration: underline; }
312
313
  padding: 1rem;
313
314
  }
314
315
  }
316
+
317
+ /* Graph pane */
318
+ .graph-pane {
319
+ width: 100%;
320
+ height: calc(100vh - 3rem);
321
+ min-height: 32rem;
322
+ padding: 0;
323
+ overflow: hidden;
324
+ position: relative;
325
+ }
326
+ .graph-pane svg { display: block; width: 100%; height: 100%; }
327
+ .graph-tooltip {
328
+ position: absolute;
329
+ background: #1f2937;
330
+ border: 1px solid #374151;
331
+ border-radius: 6px;
332
+ padding: 8px 12px;
333
+ font-size: 12px;
334
+ pointer-events: none;
335
+ display: none;
336
+ z-index: 10;
337
+ max-width: 220px;
338
+ }
339
+ .graph-tooltip .tip-title { font-weight: 600; color: #e2e8f0; }
340
+ .graph-tooltip .tip-meta { font-size: 11px; color: #7a8fa6; margin-top: 2px; }
341
+ .graph-tooltip .tip-hint { font-size: 10px; color: #4a5568; margin-top: 4px; }
342
+ .graph-legend {
343
+ position: absolute;
344
+ bottom: 16px;
345
+ left: 16px;
346
+ background: #111827;
347
+ border: 1px solid #1f2937;
348
+ border-radius: 6px;
349
+ padding: 8px 12px;
350
+ font-size: 11px;
351
+ pointer-events: none;
352
+ z-index: 5;
353
+ }
354
+ .graph-legend-heading {
355
+ color: #4a5568;
356
+ text-transform: uppercase;
357
+ letter-spacing: 0.06em;
358
+ font-size: 10px;
359
+ margin-bottom: 6px;
360
+ }
361
+ .graph-legend-heading + .graph-legend-heading { margin-top: 8px; }
362
+ .graph-legend-item {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 6px;
366
+ color: #7a8fa6;
367
+ margin-bottom: 3px;
368
+ }
369
+ .graph-legend-item:last-child { margin-bottom: 0; }
370
+ .graph-legend-dot {
371
+ width: 8px;
372
+ height: 8px;
373
+ border-radius: 50%;
374
+ flex-shrink: 0;
375
+ }
376
+ .node-label {
377
+ fill: #e2e8f0;
378
+ }
379
+ @media (prefers-color-scheme: light) {
380
+ .node-label { fill: #1a202c; }
381
+ }