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,314 @@
1
+ /* llmwiki viewer — minimal typography + layout. Dark-mode-friendly defaults. */
2
+
3
+ :root {
4
+ color-scheme: light dark;
5
+ --bg: #ffffff;
6
+ --bg-muted: #f7f7f8;
7
+ --surface: #ffffff;
8
+ --surface-raised: #fbfbfc;
9
+ --fg: #16181d;
10
+ --fg-muted: #5f6470;
11
+ --border: #e1e2e6;
12
+ --accent: #2a6df4;
13
+ --accent-soft: rgba(42, 109, 244, 0.1);
14
+ --missing: #b00020;
15
+ --max-content-width: 72ch;
16
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
17
+ font-size: 16px;
18
+ line-height: 1.55;
19
+ }
20
+
21
+ @media (prefers-color-scheme: dark) {
22
+ :root {
23
+ --bg: #16181d;
24
+ --bg-muted: #1e2026;
25
+ --surface: #14161b;
26
+ --surface-raised: #20232a;
27
+ --fg: #e7e8ec;
28
+ --fg-muted: #9ea3ad;
29
+ --border: #2c2f36;
30
+ --accent: #7eb1ff;
31
+ --accent-soft: rgba(126, 177, 255, 0.14);
32
+ --missing: #ff8a8a;
33
+ }
34
+ }
35
+
36
+ * { box-sizing: border-box; }
37
+ body { margin: 0; background: var(--bg); color: var(--fg); }
38
+ a { color: var(--accent); text-decoration: none; }
39
+ a:hover, a:focus-visible { text-decoration: underline; }
40
+
41
+ /* Universal visible-focus ring so keyboard navigation is always
42
+ * obvious. Spec §Slice 5 acceptance: "keyboard focus states are
43
+ * visible." */
44
+ :focus-visible {
45
+ outline: 2px solid var(--accent);
46
+ outline-offset: 2px;
47
+ }
48
+
49
+ .visually-hidden {
50
+ position: absolute;
51
+ width: 1px; height: 1px;
52
+ padding: 0; margin: -1px;
53
+ overflow: hidden;
54
+ clip: rect(0, 0, 0, 0);
55
+ white-space: nowrap;
56
+ border: 0;
57
+ }
58
+
59
+ .skip-link {
60
+ position: absolute; top: -100px; left: 0;
61
+ padding: 0.5rem 1rem; background: var(--accent); color: #fff; z-index: 100;
62
+ }
63
+ .skip-link:focus { top: 0; }
64
+
65
+ .app-header {
66
+ padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
67
+ background: var(--surface);
68
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
69
+ }
70
+ .app-brand {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ min-width: 0;
74
+ gap: 0.6rem;
75
+ }
76
+ .app-logo {
77
+ width: 2rem;
78
+ height: 2rem;
79
+ flex: none;
80
+ object-fit: contain;
81
+ }
82
+ .app-title { font-size: 1.05rem; }
83
+ .github-link {
84
+ display: inline-flex;
85
+ align-items: center;
86
+ gap: 0.5rem;
87
+ padding: 0.35rem 0.65rem;
88
+ border: 1px solid var(--border);
89
+ border-radius: 999px;
90
+ color: var(--fg);
91
+ background: var(--surface-raised);
92
+ font-weight: 700;
93
+ line-height: 1;
94
+ }
95
+ .github-link:hover,
96
+ .github-link:focus-visible {
97
+ background: var(--accent-soft);
98
+ text-decoration: none;
99
+ }
100
+ .github-mark {
101
+ width: 1.25rem;
102
+ height: 1.25rem;
103
+ flex: none;
104
+ fill: currentColor;
105
+ }
106
+ .github-stars {
107
+ font-size: 1.05em;
108
+ font-variant-numeric: tabular-nums;
109
+ }
110
+
111
+ .app-layout {
112
+ display: grid;
113
+ grid-template-columns: 240px minmax(0, 1fr) 260px;
114
+ gap: 0;
115
+ min-height: calc(100vh - 3rem);
116
+ }
117
+
118
+ .sidebar {
119
+ border-right: 1px solid var(--border);
120
+ background: var(--bg-muted);
121
+ padding: 1rem;
122
+ overflow-y: auto;
123
+ }
124
+ .sidebar.search-active [data-sidebar] { display: none; }
125
+ .sidebar h2 {
126
+ font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;
127
+ color: var(--fg-muted); margin: 1rem 0 0.5rem;
128
+ }
129
+ .sidebar details { margin: 0.65rem 0; }
130
+ .sidebar summary {
131
+ cursor: pointer;
132
+ font-weight: 700;
133
+ color: var(--fg);
134
+ margin-bottom: 0.35rem;
135
+ }
136
+ .sidebar ul { list-style: none; padding: 0; margin: 0; }
137
+ .sidebar li a {
138
+ display: block;
139
+ padding: 0.28rem 0.5rem;
140
+ border-radius: 6px;
141
+ color: var(--fg);
142
+ line-height: 1.35;
143
+ }
144
+ .sidebar li a:hover, .sidebar li a:focus-visible { background: var(--surface); }
145
+ .sidebar li a[aria-current="page"] { background: var(--accent); color: #fff; }
146
+
147
+ .sidebar-search {
148
+ margin-bottom: 0.75rem;
149
+ }
150
+ .sidebar-search input[type="search"] {
151
+ width: 100%;
152
+ padding: 0.4rem 0.6rem;
153
+ background: var(--bg); color: var(--fg);
154
+ border: 1px solid var(--border);
155
+ border-radius: 6px;
156
+ font-size: 0.95rem;
157
+ }
158
+
159
+ .search-results {
160
+ list-style: none;
161
+ margin: 0 0 1rem;
162
+ padding: 0;
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 0.15rem;
166
+ }
167
+ .search-results[hidden] { display: none; }
168
+ .search-results .empty {
169
+ font-style: italic;
170
+ color: var(--fg-muted);
171
+ padding: 0.4rem 0.5rem;
172
+ }
173
+ .search-results a {
174
+ display: block;
175
+ padding: 0.55rem 0.6rem;
176
+ border-radius: 6px;
177
+ color: var(--fg);
178
+ }
179
+ .search-results a:hover,
180
+ .search-results a:focus-visible {
181
+ background: var(--surface);
182
+ }
183
+ .search-results .result-title {
184
+ display: block;
185
+ font-weight: 600;
186
+ }
187
+ .search-results .result-snippet {
188
+ display: block;
189
+ font-size: 0.85rem;
190
+ color: var(--fg-muted);
191
+ margin-top: 0.1rem;
192
+ }
193
+ .search-results .result-kind {
194
+ font-size: 0.75rem;
195
+ color: var(--fg-muted);
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.05em;
198
+ margin-right: 0.4rem;
199
+ }
200
+
201
+ .main-pane {
202
+ padding: 2rem;
203
+ width: min(100%, var(--max-content-width));
204
+ overflow-x: auto;
205
+ }
206
+ .main-pane:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
207
+ .main-pane h1, .main-pane h2, .main-pane h3 { line-height: 1.25; }
208
+ .main-pane h1 { margin-top: 0; }
209
+ .main-pane pre {
210
+ background: var(--bg-muted); padding: 0.75rem 1rem; border-radius: 6px;
211
+ overflow-x: auto;
212
+ }
213
+ .main-pane code { background: var(--bg-muted); padding: 0.1em 0.3em; border-radius: 3px; }
214
+ .main-pane pre code { background: none; padding: 0; }
215
+
216
+ .support-rail {
217
+ border-left: 1px solid var(--border);
218
+ padding: 1rem; background: var(--bg-muted); font-size: 0.875rem;
219
+ }
220
+ .support-rail dt { font-weight: 700; color: var(--fg-muted); margin-top: 0.7rem; }
221
+ .support-rail dd { margin: 0.25rem 0; }
222
+
223
+ .placeholder { color: var(--fg-muted); font-style: italic; }
224
+ [data-missing="true"] {
225
+ color: var(--missing);
226
+ text-decoration: underline dotted;
227
+ text-underline-offset: 0.18em;
228
+ }
229
+
230
+ .warning-banner {
231
+ background: rgba(176, 0, 32, 0.08); color: var(--missing);
232
+ padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem;
233
+ }
234
+
235
+ .health-dashboard {
236
+ display: grid;
237
+ gap: 1rem;
238
+ }
239
+ .metric-list {
240
+ display: grid;
241
+ grid-template-columns: max-content 1fr;
242
+ column-gap: 1rem;
243
+ row-gap: 0.25rem;
244
+ margin: 0;
245
+ }
246
+ .metric-list .metric { display: contents; }
247
+ .health-dashboard dt { color: var(--fg-muted); }
248
+ .health-dashboard dd { margin: 0; font-variant-numeric: tabular-nums; }
249
+
250
+ .home-dashboard {
251
+ width: min(100%, 58rem);
252
+ }
253
+ .metric-grid {
254
+ display: grid;
255
+ grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
256
+ gap: 0.75rem;
257
+ margin: 1.25rem 0;
258
+ }
259
+ .metric-grid .metric {
260
+ padding: 0.85rem;
261
+ background: var(--surface-raised);
262
+ border: 1px solid var(--border);
263
+ border-radius: 8px;
264
+ }
265
+ .metric-grid dt {
266
+ color: var(--fg-muted);
267
+ font-size: 0.82rem;
268
+ }
269
+ .metric-grid dd {
270
+ margin: 0.15rem 0 0;
271
+ font-size: 1.65rem;
272
+ font-weight: 750;
273
+ line-height: 1.1;
274
+ font-variant-numeric: tabular-nums;
275
+ }
276
+ .home-action a {
277
+ display: inline-flex;
278
+ padding: 0.5rem 0.7rem;
279
+ border-radius: 6px;
280
+ background: var(--accent-soft);
281
+ }
282
+ .recent-section { margin-top: 1.5rem; }
283
+ .recent-list {
284
+ list-style: none;
285
+ padding: 0;
286
+ margin: 0;
287
+ display: grid;
288
+ gap: 0.35rem;
289
+ }
290
+ .recent-list a {
291
+ display: block;
292
+ padding: 0.5rem 0;
293
+ border-bottom: 1px solid var(--border);
294
+ }
295
+
296
+ @media (max-width: 800px) {
297
+ .app-layout {
298
+ grid-template-columns: 1fr;
299
+ grid-template-rows: auto auto auto auto;
300
+ }
301
+ .sidebar {
302
+ border-right: none;
303
+ border-bottom: 1px solid var(--border);
304
+ overflow-y: auto;
305
+ max-height: 16rem;
306
+ }
307
+ .support-rail {
308
+ border-left: none;
309
+ border-top: 1px solid var(--border);
310
+ }
311
+ .main-pane {
312
+ padding: 1rem;
313
+ }
314
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * llmwiki viewer — vanilla-JS client.
3
+ *
4
+ * Three responsibilities, kept deliberately small:
5
+ * 1. First paint from the server-embedded `<script type="application/json"
6
+ * id="page-index">` blob so the sidebar shows pages before any fetch.
7
+ * 2. Full data from `/api/pages` once the page loads — replaces the
8
+ * first-paint sidebar with grouped concepts/queries, and renders
9
+ * the dashboard home.
10
+ * 3. Hash router (`#/`, `#/concepts/<slug>`, `#/queries/<slug>`,
11
+ * `#/index`, `#/health`) that fetches `/api/page/...`,
12
+ * `/api/index`, or `/api/health` and drops the result into the
13
+ * main pane. The server returns already-sanitized HTML in `html`
14
+ * (see `src/viewer/render.ts`), so the client only has to set
15
+ * `innerHTML` and link up the support rail.
16
+ *
17
+ * No external dependencies, no client-side markdown rendering, no
18
+ * inline event handlers — the spec's CSP only allows scripts from
19
+ * `'self'`. The search-input wiring lives in `viewer-search.js`.
20
+ */
21
+
22
+ import { wireSearch } from "./viewer-search.js";
23
+ import { renderSidebar, markActive } from "./viewer-sidebar.js";
24
+ import { renderProjectRail, renderSupportRail, clearSupportRail } from "./viewer-rail.js";
25
+
26
+ const PAGE_INDEX_SELECTOR = "#page-index";
27
+ const MAIN_SELECTOR = "[data-main-pane]";
28
+ const TITLE_SELECTOR = "[data-app-title]";
29
+
30
+ /** Parse the server-embedded page-index JSON. Empty list if absent or malformed. */
31
+ function readEmbeddedIndex() {
32
+ const node = document.querySelector(PAGE_INDEX_SELECTOR);
33
+ if (!node || !node.textContent) return { pages: [] };
34
+ try {
35
+ const data = JSON.parse(node.textContent);
36
+ return Array.isArray(data?.pages) ? { pages: data.pages } : { pages: [] };
37
+ } catch {
38
+ return { pages: [] };
39
+ }
40
+ }
41
+
42
+
43
+ /**
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).
48
+ */
49
+ 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)\/(.+)$/);
54
+ if (!match) return { kind: "home" };
55
+ let slug;
56
+ try {
57
+ slug = decodeURIComponent(match[2]);
58
+ } catch {
59
+ return { kind: "home" };
60
+ }
61
+ return { kind: "page", directory: match[1], slug };
62
+ }
63
+
64
+ /** Render the home dashboard from the `/api/pages` envelope. */
65
+ function renderHome(envelope) {
66
+ const main = document.querySelector(MAIN_SELECTOR);
67
+ if (!main) return;
68
+ main.innerHTML = "";
69
+ 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) {
76
+ main.appendChild(buildRecentBlock(envelope.recentPages));
77
+ }
78
+ renderProjectRail(envelope);
79
+ }
80
+
81
+ /** Render a `<dl>` of project counts on the home dashboard. */
82
+ 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
+ ]);
89
+ dl.className = "metric-grid";
90
+ return dl;
91
+ }
92
+
93
+ /** Build a `<dl>` from a list of `[label, value]` rows. */
94
+ function buildDefinitionList(rows) {
95
+ const dl = document.createElement("dl");
96
+ for (const [label, value] of rows) {
97
+ const row = document.createElement("div");
98
+ row.className = "metric";
99
+ const dt = document.createElement("dt");
100
+ dt.textContent = label;
101
+ const dd = document.createElement("dd");
102
+ dd.textContent = String(value);
103
+ row.appendChild(dt);
104
+ row.appendChild(dd);
105
+ dl.appendChild(row);
106
+ }
107
+ return dl;
108
+ }
109
+
110
+ /** Build the link that takes the user to the compiled wiki/index.md page. */
111
+ function buildIndexLink(href) {
112
+ const p = document.createElement("p");
113
+ p.className = "home-action";
114
+ const a = document.createElement("a");
115
+ a.href = href;
116
+ a.textContent = "Browse the compiled index →";
117
+ p.appendChild(a);
118
+ return p;
119
+ }
120
+
121
+ /** Render the recent-pages list on the home dashboard. */
122
+ function buildRecentBlock(recent) {
123
+ const h2 = document.createElement("h2");
124
+ h2.textContent = "Recently updated";
125
+ const ul = document.createElement("ul");
126
+ ul.className = "recent-list";
127
+ for (const page of recent) {
128
+ const li = document.createElement("li");
129
+ const a = document.createElement("a");
130
+ a.href = `#/${encodeURIComponent(page.pageDirectory)}/${encodeURIComponent(page.slug)}`;
131
+ a.textContent = page.title || page.slug;
132
+ li.appendChild(a);
133
+ ul.appendChild(li);
134
+ }
135
+ const wrap = document.createElement("section");
136
+ wrap.className = "recent-section";
137
+ wrap.appendChild(h2);
138
+ wrap.appendChild(ul);
139
+ return wrap;
140
+ }
141
+
142
+ /** Fetch and render the page at the current hash route. */
143
+ async function renderRoute() {
144
+ const route = parseRoute(location.hash);
145
+ markActive();
146
+ const main = document.querySelector(MAIN_SELECTOR);
147
+ if (!main) return;
148
+ 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);
152
+ return renderPagePane(main, route.directory, route.slug);
153
+ }
154
+
155
+ /** Fetch /api/health and render the dashboard. */
156
+ async function renderHealthPane(main) {
157
+ try {
158
+ const health = await fetchJson("/api/health");
159
+ main.innerHTML = "";
160
+ const h1 = document.createElement("h1");
161
+ h1.textContent = "Health";
162
+ main.appendChild(h1);
163
+ main.appendChild(buildHealthDashboard(health));
164
+ clearSupportRail();
165
+ } catch (err) {
166
+ renderError(`Could not load /api/health: ${err.message}`);
167
+ }
168
+ }
169
+
170
+ /** Build the health dashboard DOM from the `/api/health` payload. */
171
+ function buildHealthDashboard(health) {
172
+ const wrap = document.createElement("section");
173
+ 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
+ ]);
181
+ metrics.className = "metric-list";
182
+ wrap.appendChild(metrics);
183
+ wrap.appendChild(buildLintBlock(health.lint));
184
+ return wrap;
185
+ }
186
+
187
+ /** Render the lint summary, or a "lint has not been run yet" placeholder. */
188
+ function buildLintBlock(lint) {
189
+ const wrap = document.createElement("section");
190
+ const h2 = document.createElement("h2");
191
+ h2.textContent = "Lint";
192
+ wrap.appendChild(h2);
193
+ 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);
198
+ return wrap;
199
+ }
200
+ wrap.appendChild(buildDefinitionList([
201
+ ["Warnings", lint.warnings ?? 0],
202
+ ["Errors", lint.errors ?? 0],
203
+ ["Last run", lint.at ?? ""],
204
+ ]));
205
+ return wrap;
206
+ }
207
+
208
+ /** Fetch /api/pages and render the dashboard. */
209
+ async function loadAndRenderHome() {
210
+ try {
211
+ const envelope = await fetchJson("/api/pages");
212
+ document.querySelector(TITLE_SELECTOR).textContent = envelope.project?.title || "llmwiki";
213
+ renderSidebar(envelope.pages || []);
214
+ renderHome(envelope);
215
+ } catch (err) {
216
+ renderError(`Could not load /api/pages: ${err.message}`);
217
+ }
218
+ }
219
+
220
+ /** Fetch /api/index and render the rendered HTML coming back from the server. */
221
+ async function renderIndexPane(main) {
222
+ clearSupportRail();
223
+ try {
224
+ const payload = await fetchJson("/api/index");
225
+ main.innerHTML = "";
226
+ const h1 = document.createElement("h1");
227
+ h1.textContent = "Index";
228
+ main.appendChild(h1);
229
+ appendRenderedBody(main, payload.html);
230
+ } 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
+ }
240
+ }
241
+ }
242
+
243
+ /** Fetch /api/page/:dir/:slug and render. */
244
+ async function renderPagePane(main, directory, slug) {
245
+ 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);
263
+ } 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
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Append the server-sanitized HTML body to `main`. The server always
279
+ * returns sanitized markup in `payload.html` (see Slice 4 — `src/viewer/
280
+ * render.ts`), so the client only sets `innerHTML` on a wrapper. Empty
281
+ * `html` means the page had no body after the frontmatter block;
282
+ * surface a visible "no content" placeholder rather than rendering an
283
+ * empty pane.
284
+ */
285
+ function appendRenderedBody(main, html) {
286
+ if (typeof html === "string" && html.length > 0) {
287
+ const body = document.createElement("div");
288
+ body.className = "rendered-body";
289
+ body.innerHTML = html;
290
+ main.appendChild(body);
291
+ return body;
292
+ }
293
+ const note = emptyBodyNote();
294
+ main.appendChild(note);
295
+ return note;
296
+ }
297
+
298
+ /** Drop a duplicated first Markdown H1 when it matches the viewer page title. */
299
+ 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();
305
+ }
306
+
307
+ /** Render every payload warning as a banner above the page body. */
308
+ function appendWarnings(main, warnings) {
309
+ for (const w of warnings) {
310
+ const banner = document.createElement("div");
311
+ banner.className = "warning-banner";
312
+ banner.textContent = w.message || w.code;
313
+ main.appendChild(banner);
314
+ }
315
+ }
316
+
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
+ /** Render a top-of-main error banner without crashing the rest of the UI. */
326
+ function renderError(message) {
327
+ const main = document.querySelector(MAIN_SELECTOR);
328
+ if (!main) return;
329
+ main.innerHTML = "";
330
+ const banner = document.createElement("div");
331
+ banner.className = "warning-banner";
332
+ banner.textContent = message;
333
+ main.appendChild(banner);
334
+ clearSupportRail();
335
+ }
336
+
337
+ /** Promise-returning fetch helper that surfaces non-2xx statuses as errors. */
338
+ async function fetchJson(pathname) {
339
+ const res = await fetch(pathname, { credentials: "same-origin" });
340
+ if (!res.ok) {
341
+ const err = new Error(`HTTP ${res.status}`);
342
+ err.status = res.status;
343
+ throw err;
344
+ }
345
+ return res.json();
346
+ }
347
+
348
+ /** Bootstrap: first-paint from embedded blob, then full fetch + router. */
349
+ function main() {
350
+ const embedded = readEmbeddedIndex();
351
+ renderSidebar(embedded.pages);
352
+ wireSearch({ fetchJson });
353
+ window.addEventListener("hashchange", () => {
354
+ void renderRoute();
355
+ });
356
+ void renderRoute();
357
+ }
358
+
359
+ if (document.readyState === "loading") {
360
+ document.addEventListener("DOMContentLoaded", main, { once: true });
361
+ } else {
362
+ main();
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-wiki-compiler",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A knowledge compiler CLI — raw sources in, interlinked wiki out",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "build": "tsup",
11
11
  "dev": "tsup --watch",
12
+ "dev:view": "node scripts/dev-viewer.mjs",
12
13
  "test": "vitest run",
13
14
  "test:watch": "vitest",
14
15
  "fallow:ci": "bash scripts/fallow-ci.sh",
@@ -50,9 +51,11 @@
50
51
  "dotenv": "^17.4.0",
51
52
  "js-yaml": "^4.1.1",
52
53
  "jsdom": "^25.0.0",
54
+ "markdown-it": "^14.1.1",
53
55
  "openai": "^4.0.0",
54
56
  "p-limit": "^6.0.0",
55
57
  "pdf-parse": "^2.4.5",
58
+ "sanitize-html": "^2.17.3",
56
59
  "turndown": "^7.2.0",
57
60
  "youtube-transcript": "^1.3.1",
58
61
  "zod": "^3.25.76"
@@ -61,6 +64,8 @@
61
64
  "@copilotkit/aimock": "^1.15.1",
62
65
  "@types/js-yaml": "^4.0.9",
63
66
  "@types/jsdom": "^21.1.0",
67
+ "@types/markdown-it": "^14.1.2",
68
+ "@types/sanitize-html": "^2.16.1",
64
69
  "@types/turndown": "^5.0.0",
65
70
  "fallow": "2.42.0",
66
71
  "husky": "^9.1.7",