nvim-doc-reader 0.1.0__py3-none-any.whl

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,485 @@
1
+ // doc-reader: keyboard reader over server-rendered nested <section>s.
2
+ (() => {
3
+ "use strict";
4
+
5
+ const DATA = JSON.parse(document.querySelector("body > .dr-doc-data").textContent);
6
+ const docEl = document.querySelector("body > .dr-doc");
7
+ const outlineEl = document.querySelector("body > .dr-outline");
8
+ const bannerEl = document.querySelector("body > .dr-banner");
9
+ const toastEl = document.querySelector("body > .dr-toast");
10
+ const isDoc = DATA.kind === "doc";
11
+ const STORE_KEY = "doc-reader:" + (DATA.url || location.pathname);
12
+ const OUTLINE_KEY = "doc-reader:outline"; // one setting for every document
13
+ const tail = isDoc ? DATA.url.slice("/v/".length) : "";
14
+
15
+ let cur = null;
16
+ let version = DATA.version;
17
+
18
+ // ------------------------------------------------------------ structure
19
+ const sections = () => [...docEl.querySelectorAll("section")];
20
+ const heading = (s) => s.querySelector(":scope > .sec-h");
21
+ const parentSec = (s) => s.parentElement.closest("section");
22
+ const childSecs = (s) => [...s.querySelectorAll(":scope > section")];
23
+ const collapsed = (s) => s.classList.contains("collapsed");
24
+ const hasBody = (s) => [...s.children].some((c) => !c.classList.contains("sec-h"));
25
+ function hidden(s) {
26
+ for (let p = parentSec(s); p; p = parentSec(p)) if (collapsed(p)) return true;
27
+ return false;
28
+ }
29
+ const visible = () => sections().filter((s) => !hidden(s));
30
+ // Section IDs come from the document, so look only among its sections: a
31
+ // heading may be called anything, including what the page itself uses.
32
+ const byId = (id) =>
33
+ (id ? docEl.querySelector(`section[id="${CSS.escape(id)}"]`) : null);
34
+
35
+ // ------------------------------------------------------------ storage
36
+ function load() {
37
+ try { return JSON.parse(localStorage.getItem(STORE_KEY) || "{}"); } catch { return {}; }
38
+ }
39
+ function save() {
40
+ if (!isDoc) return;
41
+ const state = { collapsed: sections().filter(collapsed).map((s) => s.id),
42
+ cur: cur && cur.id };
43
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch { /* private mode */ }
44
+ }
45
+
46
+ // ------------------------------------------------------------ view
47
+ function inView(s) {
48
+ const r = heading(s).getBoundingClientRect();
49
+ return r.top >= 0 && r.bottom <= window.innerHeight;
50
+ }
51
+ function scrollToSec(s) {
52
+ const top = heading(s).getBoundingClientRect().top;
53
+ window.scrollTo({ top: window.scrollY + top - window.innerHeight * 0.15 });
54
+ }
55
+ function setCur(s, { scroll = true } = {}) {
56
+ if (cur) cur.classList.remove("cur");
57
+ cur = s;
58
+ if (s) {
59
+ s.classList.add("cur");
60
+ history.replaceState(null, "", "#" + s.id);
61
+ if (scroll && !inView(s)) scrollToSec(s);
62
+ }
63
+ markOutline();
64
+ save();
65
+ }
66
+ function fromViewport() {
67
+ const vis = visible();
68
+ let best = null;
69
+ for (const s of vis) {
70
+ if (heading(s).getBoundingClientRect().top <= window.innerHeight * 0.3) best = s;
71
+ else break;
72
+ }
73
+ return best || vis[0] || null;
74
+ }
75
+ function reveal(s) {
76
+ for (let p = parentSec(s); p; p = parentSec(p)) p.classList.remove("collapsed");
77
+ }
78
+ function collapse(s) {
79
+ if (!hasBody(s)) return;
80
+ s.classList.add("collapsed");
81
+ if (cur && hidden(cur)) setCur(s);
82
+ save();
83
+ }
84
+ function expand(s) {
85
+ s.classList.remove("collapsed");
86
+ renderMermaid(s);
87
+ save();
88
+ }
89
+ function goTo(id) {
90
+ const s = byId(id);
91
+ if (!s || s.tagName !== "SECTION") return;
92
+ reveal(s);
93
+ renderMermaid(docEl);
94
+ setCur(s, { scroll: false });
95
+ scrollToSec(s);
96
+ }
97
+
98
+ // ------------------------------------------------------------ outline
99
+ function buildOutline() {
100
+ if (!isDoc) { outlineEl.hidden = true; return; }
101
+ const build = (secs) => {
102
+ if (!secs.length) return "";
103
+ return "<ul>" + secs.map((s) =>
104
+ `<li><a href="#${s.id}" data-id="${s.id}">${escapeHtml(heading(s).textContent)}</a>` +
105
+ build(childSecs(s)) + "</li>").join("") + "</ul>";
106
+ };
107
+ outlineEl.innerHTML = build([...docEl.querySelectorAll(":scope > section")]);
108
+ markOutline();
109
+ }
110
+ function markOutline() {
111
+ outlineEl.querySelectorAll("a.cur").forEach((a) => a.classList.remove("cur"));
112
+ if (cur) {
113
+ const a = outlineEl.querySelector(`a[data-id="${CSS.escape(cur.id)}"]`);
114
+ if (a) { a.classList.add("cur"); a.scrollIntoView({ block: "nearest" }); }
115
+ }
116
+ }
117
+ function escapeHtml(s) {
118
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;",
119
+ '"': "&quot;", "'": "&#39;" }[c]));
120
+ }
121
+
122
+ // ------------------------------------------------------------ math, diagrams
123
+ function renderMath(root) {
124
+ if (!window.katex) return; // CDN unavailable: the TeX source stays readable
125
+ root.querySelectorAll(".math:not([data-done])").forEach((el) => {
126
+ try {
127
+ katex.render(el.textContent, el, { displayMode: el.dataset.display === "1",
128
+ throwOnError: false });
129
+ } catch { /* leave the source */ }
130
+ el.dataset.done = "1";
131
+ });
132
+ }
133
+ // Mermaid lays out wrong inside display:none, so diagrams render when their
134
+ // section is first visible.
135
+ async function renderMermaid(root) {
136
+ if (!window.mermaid) return;
137
+ const nodes = [...root.querySelectorAll("pre.mermaid:not([data-processed])")]
138
+ .filter((n) => n.offsetParent !== null);
139
+ if (!nodes.length) return;
140
+ try { await mermaid.run({ nodes, suppressErrors: true }); } catch { /* keep source */ }
141
+ }
142
+ if (window.mermaid) {
143
+ const dark = matchMedia("(prefers-color-scheme: dark)").matches;
144
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict",
145
+ theme: dark ? "dark" : "default" });
146
+ }
147
+
148
+ // ------------------------------------------------------------ messages
149
+ let toastTimer = null;
150
+ function toast(msg) {
151
+ toastEl.textContent = msg;
152
+ toastEl.classList.add("show");
153
+ clearTimeout(toastTimer);
154
+ toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2500);
155
+ }
156
+ function banner(msg) {
157
+ bannerEl.hidden = !msg;
158
+ bannerEl.textContent = msg || "";
159
+ }
160
+
161
+ // ------------------------------------------------------------ clipboard
162
+ // Plain http over the tailnet is not a secure context, so navigator.clipboard
163
+ // is usually missing; the textarea + execCommand route still works there.
164
+ function copyText(text, okMsg) {
165
+ const sel = window.getSelection();
166
+ const ranges = [];
167
+ for (let i = 0; i < sel.rangeCount; i++) ranges.push(sel.getRangeAt(i));
168
+ const ta = document.createElement("textarea");
169
+ ta.value = text;
170
+ ta.setAttribute("readonly", "");
171
+ ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
172
+ document.body.appendChild(ta);
173
+ ta.select();
174
+ let ok = false;
175
+ try { ok = document.execCommand("copy"); } catch { ok = false; }
176
+ ta.remove();
177
+ sel.removeAllRanges();
178
+ ranges.forEach((r) => sel.addRange(r));
179
+ if (ok) { toast(okMsg); return; }
180
+ if (navigator.clipboard && window.isSecureContext) {
181
+ navigator.clipboard.writeText(text).then(() => toast(okMsg), () => copyBox(text));
182
+ } else {
183
+ copyBox(text);
184
+ }
185
+ }
186
+ function copyBox(text) {
187
+ const box = overlay(`<p>Copy failed &mdash; select and copy by hand:</p>
188
+ <textarea readonly rows="10"></textarea>`);
189
+ const ta = box.querySelector("textarea");
190
+ ta.value = text;
191
+ ta.focus();
192
+ ta.select();
193
+ }
194
+ let overlayEl = null;
195
+ function overlay(inner) {
196
+ closeOverlay();
197
+ overlayEl = document.createElement("div");
198
+ overlayEl.className = "overlay";
199
+ overlayEl.innerHTML = `<div class="panel">${inner}<p class="dim">Esc to close</p></div>`;
200
+ overlayEl.addEventListener("click", (e) => { if (e.target === overlayEl) closeOverlay(); });
201
+ document.body.appendChild(overlayEl);
202
+ return overlayEl;
203
+ }
204
+ function closeOverlay() {
205
+ if (overlayEl) { overlayEl.remove(); overlayEl = null; }
206
+ }
207
+
208
+ // ------------------------------------------------------------ actions
209
+ function sectionUrl(s) {
210
+ return location.origin + location.pathname + (s ? "#" + s.id : "");
211
+ }
212
+ function question() {
213
+ const sel = window.getSelection();
214
+ const text = sel.toString().trim();
215
+ let s = cur;
216
+ if (text && sel.anchorNode) {
217
+ const el = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
218
+ s = (el && el.closest("section")) || s;
219
+ }
220
+ const where = s
221
+ ? `section #${s.id} ("${heading(s).textContent.trim()}"), lines ${s.dataset.line}-${s.dataset.end}`
222
+ : "(whole document)";
223
+ const lines = [`Question about ${DATA.path} ${where}.`];
224
+ if (text) lines.push("Selected text:", text.split("\n").map((l) => "> " + l).join("\n"));
225
+ lines.push("Answer only. Do not edit any files. Afterwards, offer: integrate into the",
226
+ "section, attach as a nested subsection, or keep as a note.", "", "");
227
+ copyText(lines.join("\n"), "Question prompt copied — paste it into your agent");
228
+ }
229
+ async function openInNvim() {
230
+ const s = cur || fromViewport();
231
+ try {
232
+ const resp = await fetch("/api/open", {
233
+ method: "POST", headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify({ path: DATA.path, section_id: s ? s.id : null,
235
+ line: s ? Number(s.dataset.line) : 1, version }) });
236
+ const r = await resp.json();
237
+ toast(r.message || (resp.ok ? "sent" : "failed"));
238
+ } catch (e) {
239
+ toast("Open in Neovim failed: " + e);
240
+ }
241
+ }
242
+
243
+ const HELP = [
244
+ ["j / k", "next / previous heading"],
245
+ ["h", "collapse, or go to parent"],
246
+ ["l", "expand, or go to first child"],
247
+ ["za zo zc", "toggle / open / close section"],
248
+ ["zO zC", "open / close section and everything in it"],
249
+ ["zM zR", "close all / open all"],
250
+ ["gg / G", "top / bottom"],
251
+ ["Ctrl-d / Ctrl-u", "half-page down / up"],
252
+ ["Tab, Enter", "focus and follow links"],
253
+ ["n", "open in Neovim (run nvimd if none has the file)"],
254
+ ["q", "copy a question prompt for your agent"],
255
+ ["y", "copy this section's link"],
256
+ ["o", "show / hide the outline"],
257
+ ["?", "this help"],
258
+ ];
259
+ function help() {
260
+ if (overlayEl) { closeOverlay(); return; }
261
+ overlay("<h2>Keys</h2><table>" + HELP.map(([k, d]) =>
262
+ `<tr><td><kbd>${k}</kbd></td><td>${d}</td></tr>`).join("") + "</table>");
263
+ }
264
+
265
+ // ------------------------------------------------------------ keys
266
+ function step(delta) {
267
+ const vis = visible();
268
+ if (!vis.length) return;
269
+ if (!cur || hidden(cur) || !inView(cur)) {
270
+ const s = fromViewport();
271
+ if (!cur || !inView(cur)) { setCur(s); return; }
272
+ }
273
+ const i = vis.indexOf(cur);
274
+ const next = vis[Math.min(vis.length - 1, Math.max(0, i + delta))];
275
+ if (next) setCur(next);
276
+ }
277
+ function setAll(fn) { sections().forEach(fn); }
278
+ function subtree(s) { return [s, ...s.querySelectorAll("section")]; }
279
+
280
+ const PREFIX = { g: { g: () => { const v = visible(); if (v.length) setCur(v[0], { scroll: false }); window.scrollTo({ top: 0 }); } },
281
+ z: { a: () => cur && (collapsed(cur) ? expand(cur) : collapse(cur)),
282
+ o: () => cur && expand(cur),
283
+ c: () => cur && collapse(cur),
284
+ O: () => cur && subtree(cur).forEach(expand),
285
+ C: () => cur && [...subtree(cur)].reverse().forEach(collapse),
286
+ M: () => {
287
+ setAll((s) => hasBody(s) && s.classList.add("collapsed"));
288
+ // A lone top-level section is the document title: closing it
289
+ // would leave one line, so the outline starts one level down.
290
+ const tops = [...docEl.querySelectorAll(":scope > section")];
291
+ if (tops.length === 1) tops[0].classList.remove("collapsed");
292
+ if (cur && hidden(cur)) {
293
+ let s = cur;
294
+ while (hidden(s)) s = parentSec(s);
295
+ setCur(s);
296
+ }
297
+ save();
298
+ },
299
+ R: () => { setAll((s) => s.classList.remove("collapsed")); renderMermaid(docEl);
300
+ save(); if (cur) scrollToSec(cur); } } };
301
+ const KEYS = {
302
+ j: () => step(1),
303
+ k: () => step(-1),
304
+ h: () => {
305
+ if (!cur) return step(0);
306
+ if (hasBody(cur) && !collapsed(cur)) collapse(cur);
307
+ else if (parentSec(cur)) setCur(parentSec(cur));
308
+ },
309
+ l: () => {
310
+ if (!cur) return step(0);
311
+ if (collapsed(cur)) expand(cur);
312
+ else if (childSecs(cur).length) setCur(childSecs(cur)[0]);
313
+ },
314
+ G: () => { const v = visible(); if (v.length) setCur(v[v.length - 1], { scroll: false });
315
+ window.scrollTo({ top: document.body.scrollHeight }); },
316
+ n: () => openInNvim(),
317
+ q: () => question(),
318
+ y: () => copyText(sectionUrl(cur), "Link copied"),
319
+ o: () => {
320
+ const on = document.body.classList.toggle("show-outline");
321
+ try { localStorage.setItem(OUTLINE_KEY, on ? "1" : "0"); } catch { /* private mode */ }
322
+ if (on) markOutline();
323
+ },
324
+ "?": () => help(),
325
+ };
326
+
327
+ let pending = null, pendingTimer = null;
328
+ document.addEventListener("keydown", (e) => {
329
+ if (e.key === "Escape") { closeOverlay(); pending = null; return; }
330
+ const t = e.target;
331
+ if (t.closest && t.closest("input, textarea, select, [contenteditable]")) return;
332
+ if (e.altKey || e.metaKey) return;
333
+ if (e.ctrlKey) {
334
+ if (e.key === "d" || e.key === "u") {
335
+ e.preventDefault();
336
+ window.scrollBy({ top: (e.key === "d" ? 1 : -1) * window.innerHeight / 2 });
337
+ requestAnimationFrame(() => setCur(fromViewport(), { scroll: false }));
338
+ }
339
+ return;
340
+ }
341
+ if (overlayEl && e.key !== "?") return;
342
+ if (!isDoc && !"?".includes(e.key)) return;
343
+ if (pending) {
344
+ const fn = PREFIX[pending][e.key];
345
+ pending = null;
346
+ clearTimeout(pendingTimer);
347
+ if (fn) { e.preventDefault(); fn(); }
348
+ return;
349
+ }
350
+ if (PREFIX[e.key]) {
351
+ e.preventDefault();
352
+ pending = e.key;
353
+ pendingTimer = setTimeout(() => { pending = null; }, 1200);
354
+ return;
355
+ }
356
+ const fn = KEYS[e.key];
357
+ if (fn) { e.preventDefault(); fn(); }
358
+ });
359
+
360
+ // ------------------------------------------------------------ mouse
361
+ docEl.addEventListener("click", (e) => {
362
+ const h = e.target.closest(".sec-h");
363
+ if (!h || e.target.closest("a") || window.getSelection().toString()) return;
364
+ const s = h.parentElement;
365
+ collapsed(s) ? expand(s) : collapse(s);
366
+ setCur(s, { scroll: false });
367
+ });
368
+ outlineEl.addEventListener("click", (e) => {
369
+ const a = e.target.closest("a[data-id]");
370
+ if (!a) return;
371
+ e.preventDefault();
372
+ goTo(a.dataset.id);
373
+ });
374
+ window.addEventListener("hashchange", () => goTo(decodeURIComponent(location.hash.slice(1))));
375
+
376
+ // ------------------------------------------------------------ live reload
377
+ function snapshot() {
378
+ return { collapsed: sections().filter(collapsed).map((s) => s.id),
379
+ cur: cur && cur.id,
380
+ offset: cur ? heading(cur).getBoundingClientRect().top : null,
381
+ scrollY: window.scrollY };
382
+ }
383
+ function applyState(state) {
384
+ (state.collapsed || []).forEach((id) => { const s = byId(id); if (s && s.tagName === "SECTION") s.classList.add("collapsed"); });
385
+ const s = byId(state.cur);
386
+ cur = null;
387
+ if (s && s.tagName === "SECTION") { cur = s; s.classList.add("cur"); }
388
+ }
389
+
390
+ // One reload at a time; a version that arrives meanwhile is applied after,
391
+ // and every async step checks it is still the newest reload. The server
392
+ // announces each version once, so a fetch that fails is retried here
393
+ // (backing off) rather than waiting for the next edit.
394
+ let gen = 0, reloading = false, wanted = null, retryMs = 1000;
395
+ function onVersion(v) {
396
+ if (!v || v === version) return;
397
+ wanted = v;
398
+ if (!reloading) runReloads();
399
+ }
400
+ async function runReloads() {
401
+ reloading = true;
402
+ let failed = null;
403
+ try {
404
+ while (wanted && wanted !== version) {
405
+ const v = wanted;
406
+ wanted = null;
407
+ if (!(await reload())) { failed = v; break; }
408
+ }
409
+ } finally { reloading = false; }
410
+ if (failed) {
411
+ // Retry the newest version announced, which may have come in while the
412
+ // failed fetch was in flight (onVersion left it queued for this loop).
413
+ const v = wanted || failed;
414
+ wanted = null;
415
+ setTimeout(() => onVersion(v), retryMs);
416
+ retryMs = Math.min(retryMs * 2, 30000);
417
+ }
418
+ }
419
+ // False only for a failure worth retrying: the fetch itself.
420
+ async function reload() {
421
+ const my = ++gen;
422
+ const snap = snapshot();
423
+ let r;
424
+ try {
425
+ const resp = await fetch("/frag/" + tail, { cache: "no-store" });
426
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
427
+ r = await resp.json();
428
+ } catch (e) { banner("Reload failed, retrying: " + e); return false; }
429
+ retryMs = 1000;
430
+ if (my !== gen) return true;
431
+ if (r.error) { banner("Render error, showing the last good version: " + r.error); return true; }
432
+ docEl.innerHTML = r.html;
433
+ version = r.version;
434
+ banner((r.warnings || []).join("; "));
435
+ if (r.title) document.title = r.title;
436
+ applyState(snap);
437
+ buildOutline();
438
+ renderMath(docEl);
439
+ await renderMermaid(docEl);
440
+ if (my !== gen) return true;
441
+ if (cur && snap.offset !== null) {
442
+ window.scrollTo({ top: window.scrollY + heading(cur).getBoundingClientRect().top - snap.offset });
443
+ } else {
444
+ window.scrollTo({ top: snap.scrollY });
445
+ }
446
+ save();
447
+ return true;
448
+ }
449
+
450
+ // Scrolling by mouse, PageDown or the scrollbar: once it settles, a current
451
+ // section that has left the screen gives way to the one being read, so `n`,
452
+ // `q` and `y` act on what is in front of you. One still on screen stays.
453
+ let scrollTimer = null;
454
+ window.addEventListener("scroll", () => {
455
+ clearTimeout(scrollTimer);
456
+ scrollTimer = setTimeout(() => {
457
+ if (!isDoc || (cur && heading(cur).getClientRects().length && onScreen(cur))) return;
458
+ setCur(fromViewport(), { scroll: false });
459
+ }, 150);
460
+ }, { passive: true });
461
+ function onScreen(s) {
462
+ const r = heading(s).getBoundingClientRect();
463
+ return r.bottom > 0 && r.top < window.innerHeight;
464
+ }
465
+
466
+ // ------------------------------------------------------------ start
467
+ if (isDoc) {
468
+ try {
469
+ if (localStorage.getItem(OUTLINE_KEY) === "1") document.body.classList.add("show-outline");
470
+ } catch { /* private mode */ }
471
+ if (DATA.error) banner("Render error: " + DATA.error);
472
+ else if (DATA.warnings && DATA.warnings.length) banner(DATA.warnings.join("; "));
473
+ const saved = load();
474
+ const hashId = decodeURIComponent(location.hash.slice(1));
475
+ applyState({ collapsed: saved.collapsed, cur: hashId || saved.cur });
476
+ buildOutline();
477
+ renderMath(docEl);
478
+ if (cur) { reveal(cur); requestAnimationFrame(() => scrollToSec(cur)); }
479
+ renderMermaid(docEl);
480
+ const es = new EventSource("/events/" + tail);
481
+ es.onmessage = (e) => onVersion(e.data);
482
+ } else {
483
+ outlineEl.hidden = true;
484
+ }
485
+ })();
doc_reader/urls.py ADDED
@@ -0,0 +1,87 @@
1
+ """Filesystem path <-> reader URL, over named roots.
2
+
3
+ /efs/nikos/projects/ai/tasks/1/Task.md -> /v/nikos/projects/ai/tasks/1/Task.md
4
+
5
+ The root's name is part of the URL, so two roots holding the same relative
6
+ path cannot shadow each other. A file under several (nested) roots belongs to
7
+ the most specific one; that is its canonical URL, and any other spelling of it
8
+ -- through the outer root, or through a symlink -- redirects there, so one
9
+ file has one URL (and one saved reading position).
10
+ """
11
+ import os
12
+ from urllib.parse import quote, unquote, urlsplit
13
+
14
+ PREFIX = "/v/"
15
+
16
+
17
+ class Roots:
18
+ def __init__(self, roots):
19
+ """`roots`: name -> directory (already expanded)."""
20
+ self.roots = {name: os.path.realpath(path) for name, path in roots.items()}
21
+
22
+ def canonical(self, path):
23
+ """(name, relative path) of `path`'s real location, or None when it is
24
+ outside every root."""
25
+ real = os.path.realpath(path)
26
+ best = None
27
+ for name, root in self.roots.items():
28
+ if real == root or real.startswith(root.rstrip("/") + "/"):
29
+ if best is None or len(root) > len(self.roots[best]):
30
+ best = name
31
+ if best is None:
32
+ return None
33
+ root = self.roots[best]
34
+ return best, "" if real == root else os.path.relpath(real, root)
35
+
36
+ def url_path(self, path):
37
+ """`/v/<name>/<rel>` for a file or directory, or None outside the roots."""
38
+ found = self.canonical(path)
39
+ if found is None:
40
+ return None
41
+ name, rel = found
42
+ url = PREFIX + quote(name) + "/" + quote(rel)
43
+ if os.path.isdir(path) and not url.endswith("/"):
44
+ url += "/"
45
+ return url
46
+
47
+ def resolve(self, url_path):
48
+ """The real path behind `/v/<name>/<rel>`, or None if the name is unknown
49
+ or the path escapes its root (`..`, a symlink pointing out)."""
50
+ if not url_path.startswith(PREFIX):
51
+ return None
52
+ name, _, rel = url_path[len(PREFIX):].partition("/")
53
+ root = self.roots.get(unquote(name))
54
+ if root is None:
55
+ return None
56
+ real = os.path.realpath(os.path.join(root, unquote(rel)))
57
+ if real != root and not real.startswith(root.rstrip("/") + "/"):
58
+ return None
59
+ return real
60
+
61
+ def link_rewriter(self, doc_path):
62
+ """For links inside the document at `doc_path`: local targets resolved
63
+ against the document's REAL directory and mapped to canonical URLs, with
64
+ query and fragment kept; None for local targets outside every root;
65
+ anything with a scheme, or a bare `#fragment`, unchanged."""
66
+ base = os.path.dirname(os.path.realpath(doc_path))
67
+
68
+ def rewrite(href):
69
+ if not href or href.startswith("#"):
70
+ return href
71
+ parts = urlsplit(href)
72
+ if parts.scheme or parts.netloc:
73
+ return href
74
+ target = unquote(parts.path)
75
+ if not target:
76
+ return href
77
+ target = target if os.path.isabs(target) else os.path.join(base, target)
78
+ url = self.url_path(target)
79
+ if url is None:
80
+ return None
81
+ if parts.query:
82
+ url += "?" + parts.query
83
+ if parts.fragment:
84
+ url += "#" + parts.fragment
85
+ return url
86
+
87
+ return rewrite
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: nvim-doc-reader
3
+ Version: 0.1.0
4
+ Summary: Keyboard-driven HTML reader for Markdown docs, with jumps into running Neovim
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: markdown-it-py<3,>=2.1
8
+ Requires-Dist: mdit-py-plugins<0.4,>=0.3.4
9
+ Requires-Dist: aiohttp>=3.8
10
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
11
+
12
+ # doc-reader
13
+
14
+ A keyboard-driven HTML reader for Markdown documents, with jumps into the
15
+ Neovim instances you are editing them in. Markdown stays the only source: the
16
+ server renders it on every request, and the page follows edits live.
17
+
18
+ - Nested, foldable sections (`zM` to an outline, `l` to drill in), Vim keys.
19
+ - Math (KaTeX), Mermaid diagrams, SVG/PNG figures, tables.
20
+ - `n` moves every running Neovim that has the file to the section you are
21
+ reading; `nvimd` opens it when none has.
22
+ - `q` copies a question prompt for a coding agent; `y` copies a link.
23
+ - `<leader>h` in Neovim prints the reader URL of the section under the cursor.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install nvim-doc-reader
29
+ docread serve
30
+ ```
31
+
32
+ Requires Python 3.9+ and, for the editor features, Neovim 0.9+.
33
+
34
+ ## Configure
35
+
36
+ `$XDG_CONFIG_HOME/doc-reader/config.toml` (or `$DOCREADER_CONFIG`):
37
+
38
+ ```toml
39
+ # Optional, with their defaults.
40
+ bind = "0.0.0.0"
41
+ port = 8765
42
+ public_url = "http://desknikos:8765" # what `docread url` prints; default http://localhost:<port>
43
+ nvim = "nvim"
44
+ # nvim_socket_glob: default is where Neovim puts its own socket
45
+ # ($XDG_RUNTIME_DIR/nvim.*.0, else $TMPDIR/nvim.<user>/*/nvim.*.0)
46
+ state_file = "$XDG_STATE_HOME/doc-reader/last"
47
+ poll_interval_s = 0.5
48
+
49
+ # Which directories are served, by name. The name is part of the URL:
50
+ # /efs/nikos/notes/a.md -> /v/nikos/notes/a.md
51
+ # Default: the home directory, as "home".
52
+ [roots]
53
+ nikos = "/efs/nikos"
54
+ ```
55
+
56
+ Every scalar key can be overridden with `DOCREADER_<KEY>`, and the roots with
57
+ `DOCREADER_ROOTS=name=/dir:name=/dir`. Nested roots are fine: a file belongs to
58
+ the most specific one, and any other URL for it redirects there.
59
+
60
+ Only files under a root are served. Raw files are served with
61
+ `Content-Security-Policy: sandbox`, and raw HTML in Markdown is shown as text.
62
+ There is no authentication: run it on a trusted network.
63
+
64
+ ## Keys
65
+
66
+ | key | action |
67
+ |---|---|
68
+ | `j` / `k` | next / previous heading |
69
+ | `h` / `l` | collapse or go to parent / expand or go to first child |
70
+ | `za` `zo` `zc` `zO` `zC` | toggle / open / close / open all inside / close all inside |
71
+ | `zM` / `zR` | close all / open all |
72
+ | `gg` / `G`, `Ctrl-d` / `Ctrl-u` | top / bottom, half page |
73
+ | `Tab`, `Enter` | focus and follow links |
74
+ | `n` | open in Neovim |
75
+ | `q` | copy a question prompt |
76
+ | `y` | copy the section's link |
77
+ | `o` | show / hide the outline |
78
+ | `?` | help |
79
+
80
+ ## Neovim
81
+
82
+ `n` needs nothing in the editor: every Neovim listens on its own socket, and
83
+ the server finds them by `nvim_socket_glob`. Windows showing the file move to
84
+ the section; a buffer that is loaded but hidden moves when you next switch to
85
+ it. `nvimd` with no arguments opens the last target; with arguments it is
86
+ plain `nvim`.
87
+
88
+ For `<leader>h`:
89
+
90
+ ```lua
91
+ vim.opt.rtp:append(vim.fn.trim(vim.fn.system({ "docread", "nvim-path" })))
92
+ require("doc_reader").setup({ cmd = { "docread" }, keymap = "<leader>h" })
93
+ ```
94
+
95
+ ## Section IDs
96
+
97
+ A heading's ID is its explicit `{#id}` if it has one, otherwise GitHub's slug
98
+ of its text, with `-1`, `-2` for repeats. Give a section an explicit ID when
99
+ links to it must survive a rename.
100
+
101
+ ## Agents
102
+
103
+ Documents written by coding agents read best when they follow a few rules:
104
+ stable `{#id}`s, short overviews with depth in nested subsections, figures
105
+ kept with their source, no fabricated data, and questions answered without
106
+ editing. The rules ship as a Claude Code skill:
107
+
108
+ ```bash
109
+ docread install-skill # -> ~/.claude/skills/doc-authoring/
110
+ docread install-skill --dir .claude/skills # or per project
111
+ ```
112
+
113
+ Then tell agents to use it, in your global `~/.claude/CLAUDE.md` (or a
114
+ project's `CLAUDE.md`):
115
+
116
+ ```markdown
117
+ ## Writing Documents
118
+
119
+ Markdown documents you write for the user (research notes, reports, plans,
120
+ explainers) follow the `doc-authoring` skill — load it before creating or
121
+ editing one.
122
+ ```
123
+
124
+ Other agents: if they read `SKILL.md` skills, point `--dir` at their skills
125
+ directory; otherwise paste the skill's body into `AGENTS.md` / `GEMINI.md`.