llm-wiki-compiler 0.6.0 → 0.7.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.
@@ -0,0 +1,71 @@
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>llmwiki viewer</title>
7
+ <link rel="icon" type="image/png" href="/assets/llmwiki-logo-64.png" />
8
+ <link rel="stylesheet" href="/assets/viewer.css" />
9
+ </head>
10
+ <body>
11
+ <a class="skip-link" href="#main-pane">Skip to main content</a>
12
+ <header class="app-header">
13
+ <div class="app-brand">
14
+ <img
15
+ class="app-logo"
16
+ src="/assets/llmwiki-logo-64.png"
17
+ width="32"
18
+ height="32"
19
+ alt=""
20
+ aria-hidden="true"
21
+ />
22
+ <strong class="app-title" data-app-title>llmwiki</strong>
23
+ </div>
24
+ <a
25
+ class="github-link"
26
+ href="https://github.com/atomicmemory/llm-wiki-compiler"
27
+ target="_blank"
28
+ rel="noopener noreferrer"
29
+ aria-label="Open llm-wiki-compiler on GitHub"
30
+ >
31
+ <svg class="github-mark" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
32
+ <path
33
+ d="M8 0C3.58 0 0 3.64 0 8.13c0 3.59 2.29 6.63 5.47 7.71.4.08.55-.18.55-.39 0-.19-.01-.83-.01-1.5-2.01.37-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.15-.28-.15-.68-.52-.01-.53.63-.01 1.08.59 1.23.83.72 1.23 1.87.88 2.33.67.07-.53.28-.88.51-1.08-1.78-.21-3.64-.9-3.64-4 0-.88.31-1.61.82-2.18-.08-.2-.36-1.03.08-2.15 0 0 .67-.22 2.2.83A7.47 7.47 0 0 1 8 3.96c.68 0 1.36.09 2 .27 1.53-1.05 2.2-.83 2.2-.83.44 1.12.16 1.95.08 2.15.51.57.82 1.29.82 2.18 0 3.11-1.87 3.79-3.65 4 .29.25.54.74.54 1.5 0 1.08-.01 1.95-.01 2.22 0 .21.15.47.55.39A8.05 8.05 0 0 0 16 8.13C16 3.64 12.42 0 8 0Z"
34
+ />
35
+ </svg>
36
+ <span>GitHub</span>
37
+ </a>
38
+ </header>
39
+ <div class="app-layout">
40
+ <nav class="sidebar" aria-label="Wiki navigation">
41
+ <form class="sidebar-search" role="search" data-search-form autocomplete="off">
42
+ <label for="search-input" class="visually-hidden">Search wiki</label>
43
+ <input
44
+ id="search-input"
45
+ type="search"
46
+ placeholder="Search…"
47
+ aria-controls="search-results"
48
+ data-search-input
49
+ />
50
+ </form>
51
+ <ul
52
+ id="search-results"
53
+ class="search-results"
54
+ role="listbox"
55
+ aria-label="Search results"
56
+ data-search-results
57
+ hidden
58
+ ></ul>
59
+ <div data-sidebar>
60
+ <p class="placeholder">Loading…</p>
61
+ </div>
62
+ </nav>
63
+ <main id="main-pane" class="main-pane" tabindex="-1" data-main-pane>
64
+ <p class="placeholder">Select a page from the sidebar.</p>
65
+ </main>
66
+ <aside class="support-rail" aria-label="Page metadata" data-support-rail></aside>
67
+ </div>
68
+ <!--PAGE_INDEX-->
69
+ <script type="module" src="/assets/viewer.js"></script>
70
+ </body>
71
+ </html>
@@ -0,0 +1,181 @@
1
+ /**
2
+ * llmwiki viewer — right-hand support rail renderer.
3
+ *
4
+ * Populates `[data-support-rail]` with the page metadata fields the
5
+ * spec's §Support Rail section requires: kind, sources, confidence,
6
+ * provenanceState, contradictedBy, tags, aliases, created/updated
7
+ * timestamps, plus a "Warnings" block fed by `payload.warnings`
8
+ * (parser issues, unresolved citations, malformed citation entries).
9
+ *
10
+ * Fields render only when the frontmatter actually carries a value, so
11
+ * a legacy page with no provenance metadata shows a compact rail
12
+ * rather than a wall of `(none)` rows. Labels mirror `review show`
13
+ * where practical.
14
+ */
15
+
16
+ const SUPPORT_SELECTOR = "[data-support-rail]";
17
+
18
+ const RAIL_FIELDS = [
19
+ { key: "kind", label: "Kind", type: "string" },
20
+ { key: "sources", label: "Sources", type: "stringArray" },
21
+ { key: "confidence", label: "Confidence", type: "confidence" },
22
+ { key: "provenanceState", label: "Provenance state", type: "string" },
23
+ { key: "contradictedBy", label: "Contradicted by", type: "contradictedBy" },
24
+ { key: "tags", label: "Tags", type: "stringArray" },
25
+ { key: "aliases", label: "Aliases", type: "stringArray" },
26
+ { key: "createdAt", label: "Created", type: "string" },
27
+ { key: "updatedAt", label: "Updated", type: "string" },
28
+ ];
29
+
30
+ /** Render project-level metadata for the dashboard route. */
31
+ export function renderProjectRail(envelope) {
32
+ const support = document.querySelector(SUPPORT_SELECTOR);
33
+ if (!support) return;
34
+ support.innerHTML = "";
35
+ const dl = document.createElement("dl");
36
+ appendPlainRailField(dl, "Project", envelope.project?.title || "llmwiki");
37
+ appendPlainRailField(dl, "Root", envelope.project?.rootName || "");
38
+ appendPlainRailField(dl, "Generated", envelope.generatedAt || "");
39
+ appendPlainRailField(dl, "Pages", String((envelope.pages || []).length));
40
+ if (envelope.index?.available) appendPlainRailField(dl, "Index", "Available");
41
+ support.appendChild(dl);
42
+ }
43
+
44
+ /**
45
+ * Render the page metadata into the support rail. Replaces whatever
46
+ * was there before — callers don't need to clear separately.
47
+ */
48
+ export function renderSupportRail(payload) {
49
+ const support = document.querySelector(SUPPORT_SELECTOR);
50
+ if (!support) return;
51
+ support.innerHTML = "";
52
+ const fm = (payload && payload.frontmatter) || {};
53
+ const dl = document.createElement("dl");
54
+ for (const field of RAIL_FIELDS) appendRailField(dl, field, fm[field.key]);
55
+ if (dl.children.length > 0) support.appendChild(dl);
56
+ const warnings = (payload && Array.isArray(payload.warnings)) ? payload.warnings : [];
57
+ if (warnings.length > 0) support.appendChild(buildRailWarnings(warnings));
58
+ }
59
+
60
+ /** Clear the support rail entirely (used on non-page routes). */
61
+ export function clearSupportRail() {
62
+ const support = document.querySelector(SUPPORT_SELECTOR);
63
+ if (support) support.innerHTML = "";
64
+ }
65
+
66
+ /** Append one (dt, dd) pair to the rail's <dl> when the value renders. */
67
+ function appendRailField(dl, field, value) {
68
+ const dd = renderRailValue(field.type, value);
69
+ if (!dd) return;
70
+ appendDtDd(dl, field.label, dd);
71
+ }
72
+
73
+ /** Append a plain text rail field when `value` is non-empty. */
74
+ function appendPlainRailField(dl, label, value) {
75
+ if (typeof value !== "string" || value.length === 0) return;
76
+ appendDtDd(dl, label, buildPlainDd(value));
77
+ }
78
+
79
+ /** Append a complete rail definition row. */
80
+ function appendDtDd(dl, label, dd) {
81
+ const dt = document.createElement("dt");
82
+ dt.textContent = label;
83
+ dl.appendChild(dt);
84
+ dl.appendChild(dd);
85
+ }
86
+
87
+ /** Dispatch on field type and produce a <dd>, or null to skip the row. */
88
+ function renderRailValue(type, value) {
89
+ if (type === "string") return renderStringValue(value);
90
+ if (type === "stringArray") return renderStringArrayValue(value);
91
+ if (type === "confidence") return renderConfidenceValue(value);
92
+ if (type === "contradictedBy") return renderContradictionList(value);
93
+ return null;
94
+ }
95
+
96
+ /** String field — empty/non-string values omit the row. */
97
+ function renderStringValue(value) {
98
+ if (typeof value !== "string" || value.length === 0) return null;
99
+ return buildPlainDd(value);
100
+ }
101
+
102
+ /** Array-of-strings field — joined with commas, empty array omits the row. */
103
+ function renderStringArrayValue(value) {
104
+ if (!Array.isArray(value)) return null;
105
+ const strings = value.filter((v) => typeof v === "string" && v.length > 0);
106
+ if (strings.length === 0) return null;
107
+ return buildPlainDd(strings.join(", "));
108
+ }
109
+
110
+ /** Numeric confidence in 0..1 rendered as a percentage. */
111
+ function renderConfidenceValue(value) {
112
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
113
+ const clamped = Math.max(0, Math.min(1, value));
114
+ return buildPlainDd(`${Math.round(clamped * 100)}%`);
115
+ }
116
+
117
+ /** `contradictedBy` is an array of `{ slug, reason? }` references. */
118
+ function renderContradictionList(value) {
119
+ if (!Array.isArray(value) || value.length === 0) return null;
120
+ const dd = document.createElement("dd");
121
+ const ul = document.createElement("ul");
122
+ let any = false;
123
+ for (const ref of value) {
124
+ const li = buildContradictionItem(ref);
125
+ if (!li) continue;
126
+ any = true;
127
+ ul.appendChild(li);
128
+ }
129
+ if (!any) return null;
130
+ dd.appendChild(ul);
131
+ return dd;
132
+ }
133
+
134
+ /** One contradiction <li> — slug link plus optional reason. */
135
+ function buildContradictionItem(ref) {
136
+ const slug = ref && typeof ref.slug === "string" ? ref.slug : "";
137
+ if (!slug) return null;
138
+ const li = document.createElement("li");
139
+ li.dataset.contradictionSlug = slug;
140
+ const a = document.createElement("a");
141
+ a.href = `#/concepts/${encodeURIComponent(slug)}`;
142
+ a.textContent = slug;
143
+ li.appendChild(a);
144
+ if (ref && typeof ref.reason === "string" && ref.reason.length > 0) {
145
+ const reason = document.createElement("span");
146
+ reason.className = "support-rail-reason";
147
+ reason.textContent = ` — ${ref.reason}`;
148
+ li.appendChild(reason);
149
+ }
150
+ return li;
151
+ }
152
+
153
+ /** Build a plain `<dd>` with a single text node — used by the simpler field types. */
154
+ function buildPlainDd(text) {
155
+ const dd = document.createElement("dd");
156
+ dd.textContent = text;
157
+ return dd;
158
+ }
159
+
160
+ /**
161
+ * Render the warnings block at the bottom of the rail. Each warning is
162
+ * a `<li>` carrying `data-code` so styling/tests can target specific
163
+ * warning kinds (`unresolved_citation`, `malformed_citation`,
164
+ * `missing_title`, etc.).
165
+ */
166
+ function buildRailWarnings(warnings) {
167
+ const wrap = document.createElement("section");
168
+ wrap.className = "support-rail-warnings";
169
+ const h = document.createElement("h2");
170
+ h.textContent = "Warnings";
171
+ wrap.appendChild(h);
172
+ const ul = document.createElement("ul");
173
+ for (const w of warnings) {
174
+ const li = document.createElement("li");
175
+ if (w && typeof w.code === "string") li.dataset.code = w.code;
176
+ li.textContent = (w && w.message) || (w && w.code) || "";
177
+ ul.appendChild(li);
178
+ }
179
+ wrap.appendChild(ul);
180
+ return wrap;
181
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * llmwiki viewer — sidebar search UI.
3
+ *
4
+ * Wires the search input in the shell template to `/api/search?q=...`.
5
+ * Input is debounced before each fetch (spec §Slice 5 acceptance:
6
+ * "client input is debounced before calling `/api/search`"). The
7
+ * sidebar's results <ul> doubles as a focus-cyclable result list:
8
+ * ArrowDown from the input jumps to the first result; ArrowUp/Down
9
+ * within results cycles; Escape returns focus to the input; Enter
10
+ * activates the focused anchor (the browser handles `<a href="#/...">`
11
+ * activation natively, so we don't intercept it).
12
+ *
13
+ * Imported by `viewer.js` and invoked from `main()` after the sidebar
14
+ * first paint. Keeps the entry file small (CLAUDE.md 400-LOC rule) and
15
+ * isolates the keyboard-navigation surface for unit testing.
16
+ */
17
+
18
+ const SEARCH_INPUT_SELECTOR = "[data-search-input]";
19
+ const SEARCH_RESULTS_SELECTOR = "[data-search-results]";
20
+ const SEARCH_DEBOUNCE_MS = 200;
21
+
22
+ /**
23
+ * Wire the search input + results panel. Idempotent: calling more than
24
+ * once attaches multiple listeners, so call exactly once in bootstrap.
25
+ * Returns silently when either the input or results element is missing
26
+ * (the shell template owns those selectors).
27
+ *
28
+ * Concurrency contract: a monotonically increasing `generation` counter
29
+ * (bumped on every input event) gates BOTH the pending-debounce timer
30
+ * AND any in-flight `fetch`. An older response that arrives after the
31
+ * user has cleared the input or typed a newer query is discarded
32
+ * silently — the results panel only ever reflects the most recent
33
+ * input-vs-render decision.
34
+ */
35
+ export function wireSearch({ fetchJson }) {
36
+ const input = document.querySelector(SEARCH_INPUT_SELECTOR);
37
+ const results = document.querySelector(SEARCH_RESULTS_SELECTOR);
38
+ if (!input || !results) return;
39
+ const sidebar = input.closest(".sidebar");
40
+ let currentGeneration = 0;
41
+ let pendingTimer = 0;
42
+ const cancelPending = () => {
43
+ if (pendingTimer) {
44
+ clearTimeout(pendingTimer);
45
+ pendingTimer = 0;
46
+ }
47
+ };
48
+ input.addEventListener("input", () => {
49
+ currentGeneration += 1;
50
+ cancelPending();
51
+ const value = input.value.trim();
52
+ if (value.length === 0) {
53
+ hideSearchResults(results, sidebar);
54
+ return;
55
+ }
56
+ const generation = currentGeneration;
57
+ pendingTimer = setTimeout(() => {
58
+ pendingTimer = 0;
59
+ void runSearchAndRender(value, results, fetchJson, () => generation === currentGeneration);
60
+ }, SEARCH_DEBOUNCE_MS);
61
+ });
62
+ input.addEventListener("keydown", (event) => onSearchInputKeydown(event, results));
63
+ results.addEventListener("keydown", (event) => onSearchResultsKeydown(event, input));
64
+ results.addEventListener("click", (event) => {
65
+ if (event.target instanceof HTMLElement && event.target.closest("a")) {
66
+ currentGeneration += 1;
67
+ cancelPending();
68
+ hideSearchResults(results, sidebar);
69
+ input.value = "";
70
+ }
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Fetch /api/search for `query` and render the results list, but only
76
+ * when `stillCurrent()` returns true at the moment the response
77
+ * resolves. A later-typed query supersedes an earlier one regardless
78
+ * of which network response arrives first.
79
+ *
80
+ * Search-failure UX intentionally stays inline in the results panel
81
+ * (rather than blowing away the main pane) so an ephemeral network
82
+ * blip doesn't drop the user out of the page they're reading.
83
+ */
84
+ async function runSearchAndRender(query, results, fetchJson, stillCurrent) {
85
+ try {
86
+ const data = await fetchJson(`/api/search?q=${encodeURIComponent(query)}`);
87
+ if (!stillCurrent()) return;
88
+ renderSearchResults(data.results ?? [], results);
89
+ } 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;
97
+ }
98
+ }
99
+
100
+ /** Render rows into the results <ul>; show an "empty" message for zero hits. */
101
+ function renderSearchResults(rows, results) {
102
+ results.innerHTML = "";
103
+ results.hidden = false;
104
+ results.closest(".sidebar")?.classList.add("search-active");
105
+ if (rows.length === 0) {
106
+ const li = document.createElement("li");
107
+ li.className = "empty";
108
+ li.textContent = "No matches.";
109
+ results.appendChild(li);
110
+ return;
111
+ }
112
+ for (const row of rows) results.appendChild(buildSearchResultRow(row));
113
+ }
114
+
115
+ /** Build one search-result <li> with anchor + kind tag + snippet. */
116
+ function buildSearchResultRow(row) {
117
+ const li = document.createElement("li");
118
+ li.setAttribute("role", "option");
119
+ const link = document.createElement("a");
120
+ const slug = deriveSlug(row.id);
121
+ link.href = `#/${encodeURIComponent(row.pageDirectory)}/${encodeURIComponent(slug)}`;
122
+ link.dataset.searchResult = "true";
123
+ const kind = document.createElement("span");
124
+ kind.className = "result-kind";
125
+ kind.textContent = row.pageDirectory === "queries" ? "query" : "concept";
126
+ const title = document.createElement("span");
127
+ title.className = "result-title";
128
+ title.textContent = row.title || row.id;
129
+ const snippet = document.createElement("span");
130
+ snippet.className = "result-snippet";
131
+ snippet.textContent = cleanSnippet(row.snippet || "");
132
+ link.appendChild(kind);
133
+ link.appendChild(title);
134
+ link.appendChild(snippet);
135
+ li.appendChild(link);
136
+ return li;
137
+ }
138
+
139
+ /** Mirror server-side snippet cleanup so existing viewer processes improve after asset reload. */
140
+ function cleanSnippet(value) {
141
+ return value
142
+ .replace(/\[\[([^\]|\n]+)\|([^\]\n]+)\]\]/g, "$2")
143
+ .replace(/\[\[([^\]\n]+)\]\]/g, "$1");
144
+ }
145
+
146
+ /** Extract the slug from a PageId of the form `concepts/<slug>`. */
147
+ function deriveSlug(id) {
148
+ const slash = String(id).indexOf("/");
149
+ return slash >= 0 ? id.slice(slash + 1) : id;
150
+ }
151
+
152
+ /** Hide the results panel and restore the standing sidebar contents. */
153
+ function hideSearchResults(results, sidebar) {
154
+ results.innerHTML = "";
155
+ results.hidden = true;
156
+ sidebar?.classList.remove("search-active");
157
+ }
158
+
159
+ /** ArrowDown from the search input moves focus to the first result anchor. */
160
+ function onSearchInputKeydown(event, results) {
161
+ if (event.key !== "ArrowDown") return;
162
+ const first = results.querySelector("a[data-search-result]");
163
+ if (!first) return;
164
+ event.preventDefault();
165
+ first.focus();
166
+ }
167
+
168
+ /** ArrowUp/Down within results cycle focus; Escape returns focus to input. */
169
+ function onSearchResultsKeydown(event, input) {
170
+ if (event.key === "Escape") {
171
+ event.preventDefault();
172
+ input.focus();
173
+ return;
174
+ }
175
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
176
+ const target = event.target instanceof HTMLAnchorElement ? event.target : null;
177
+ if (!target) return;
178
+ event.preventDefault();
179
+ const all = Array.from(target.closest("ul").querySelectorAll("a[data-search-result]"));
180
+ 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];
184
+ if (next) next.focus();
185
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * llmwiki viewer — sidebar renderer.
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
7
+ * `<details><summary>` so keyboard users get Enter/Space collapse for
8
+ * free without bespoke ARIA wiring.
9
+ *
10
+ * First paint runs against the embedded page-index blob (which now
11
+ * includes `kind` so the grouping is correct from the first byte);
12
+ * the full `/api/pages` envelope replaces the contents once it
13
+ * arrives.
14
+ */
15
+
16
+ const SIDEBAR_SELECTOR = "[data-sidebar]";
17
+ const DEFAULT_KIND = "concept";
18
+
19
+ /** Render the sidebar groups + standing Health entry, then mark active. */
20
+ export function renderSidebar(pages) {
21
+ const sidebar = document.querySelector(SIDEBAR_SELECTOR);
22
+ if (!sidebar) return;
23
+ 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) {
28
+ sidebar.appendChild(buildCollapsibleGroup(formatKindLabel(kind), groupPages, "kind", kind));
29
+ }
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();
41
+ }
42
+
43
+ /**
44
+ * Mark the sidebar entry matching the current hash route as
45
+ * `aria-current="page"` and clear it from every other entry. Exported
46
+ * so `viewer.js` can call it after route changes without duplicating
47
+ * the parsing logic. Reads `location.hash` directly so the call site
48
+ * doesn't need to thread the route descriptor through.
49
+ */
50
+ export function markActive() {
51
+ const hash = location.hash;
52
+ const expectedId = parseExpectedPageId(hash);
53
+ const links = document.querySelectorAll(`${SIDEBAR_SELECTOR} a`);
54
+ for (const link of links) link.removeAttribute("aria-current");
55
+ if (!expectedId) return;
56
+ for (const link of links) {
57
+ if (link.dataset.pageId === expectedId) {
58
+ link.setAttribute("aria-current", "page");
59
+ return;
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Group concept pages by their `kind` field. Missing/non-string kinds
66
+ * fall back to `"concept"` per spec §Sidebar. Group order is stable
67
+ * by kind name (locale-aware), with the default `concept` bucket
68
+ * floated to the top so a typical wiki shows "Concept" first.
69
+ */
70
+ function groupConceptsByKind(concepts) {
71
+ const byKind = new Map();
72
+ 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);
76
+ }
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
+ });
82
+ return kinds.map((kind) => /** @type {[string, Array]} */ ([kind, byKind.get(kind)]));
83
+ }
84
+
85
+ /** Title-case a kind for the group heading. */
86
+ function formatKindLabel(kind) {
87
+ if (kind === DEFAULT_KIND) return "Concepts";
88
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
89
+ }
90
+
91
+ /** Build a collapsible `<details>` group with a flat link list of pages. */
92
+ function buildCollapsibleGroup(label, pages, datasetKey, datasetValue) {
93
+ const wrap = document.createElement("details");
94
+ wrap.open = true;
95
+ if (datasetKey) wrap.dataset[datasetKey] = datasetValue;
96
+ const summary = document.createElement("summary");
97
+ summary.textContent = label;
98
+ wrap.appendChild(summary);
99
+ const list = document.createElement("ul");
100
+ for (const page of pages) list.appendChild(buildPageListItem(page));
101
+ wrap.appendChild(list);
102
+ return wrap;
103
+ }
104
+
105
+ /** Build one `<li><a>` entry for a sidebar page list. */
106
+ function buildPageListItem(page) {
107
+ const li = document.createElement("li");
108
+ const a = document.createElement("a");
109
+ a.href = `#/${encodeURIComponent(page.pageDirectory)}/${encodeURIComponent(page.slug)}`;
110
+ a.dataset.pageId = page.id;
111
+ a.textContent = page.title || page.slug;
112
+ li.appendChild(a);
113
+ return li;
114
+ }
115
+
116
+ /** Build the standing "Health" sidebar entry that routes to #/health. */
117
+ function buildHealthEntry() {
118
+ const wrap = document.createElement("section");
119
+ wrap.className = "sidebar-health";
120
+ const heading = document.createElement("h2");
121
+ heading.textContent = "Project";
122
+ wrap.appendChild(heading);
123
+ const list = document.createElement("ul");
124
+ const item = document.createElement("li");
125
+ const link = document.createElement("a");
126
+ link.href = "#/health";
127
+ link.dataset.healthLink = "true";
128
+ link.textContent = "Health";
129
+ item.appendChild(link);
130
+ list.appendChild(item);
131
+ wrap.appendChild(list);
132
+ return wrap;
133
+ }
134
+
135
+ /**
136
+ * Read `location.hash` and return the namespaced `<dir>/<slug>` that
137
+ * should carry `aria-current` — or null if the route is not a page
138
+ * route. Malformed percent-encoding falls through to null rather than
139
+ * throwing.
140
+ */
141
+ function parseExpectedPageId(hash) {
142
+ const match = hash.match(/^#\/(concepts|queries)\/(.+)$/);
143
+ if (!match) return null;
144
+ let slug;
145
+ try {
146
+ slug = decodeURIComponent(match[2]);
147
+ } catch {
148
+ return null;
149
+ }
150
+ return `${match[1]}/${slug}`;
151
+ }