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.
- package/CHANGELOG.md +251 -0
- package/README.md +71 -9
- package/dist/cli.js +3072 -387
- package/dist/cli.js.map +1 -1
- package/dist/viewer/assets/THIRD_PARTY_NOTICES.txt +22 -0
- package/dist/viewer/assets/d3.min.js +2 -0
- package/dist/viewer/assets/index.html +4 -2
- package/dist/viewer/assets/viewer-graph.js +369 -0
- package/dist/viewer/assets/viewer-rail.js +115 -39
- package/dist/viewer/assets/viewer-search.js +48 -16
- package/dist/viewer/assets/viewer-sidebar.js +105 -35
- package/dist/viewer/assets/viewer.css +68 -1
- package/dist/viewer/assets/viewer.js +232 -115
- package/package.json +6 -3
|
@@ -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
|
-
|
|
88
|
-
renderSearchResults(data.results ?? [], results);
|
|
87
|
+
handleSearchSuccess(data, results, stillCurrent);
|
|
89
88
|
} catch (err) {
|
|
90
|
-
|
|
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
|
|
172
|
-
input.focus();
|
|
181
|
+
handleSearchEscape(event, input);
|
|
173
182
|
return;
|
|
174
183
|
}
|
|
175
|
-
|
|
176
|
-
|
|
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 =
|
|
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
|
|
5
|
-
* "concept" when absent — spec
|
|
6
|
-
*
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
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 "
|
|
117
|
-
function
|
|
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 =
|
|
127
|
-
link.dataset.
|
|
128
|
-
link.textContent =
|
|
198
|
+
link.href = href;
|
|
199
|
+
link.dataset.route = route;
|
|
200
|
+
link.textContent = label;
|
|
129
201
|
item.appendChild(link);
|
|
130
|
-
|
|
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:
|
|
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
|
+
}
|