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.
- doc_reader/__init__.py +1 -0
- doc_reader/cli.py +109 -0
- doc_reader/config.py +124 -0
- doc_reader/model.py +281 -0
- doc_reader/nvim/lua/doc_reader.lua +43 -0
- doc_reader/nvim.py +215 -0
- doc_reader/serve.py +235 -0
- doc_reader/skills/doc-authoring/SKILL.md +64 -0
- doc_reader/static/reader.css +167 -0
- doc_reader/static/reader.js +485 -0
- doc_reader/urls.py +87 -0
- nvim_doc_reader-0.1.0.dist-info/METADATA +125 -0
- nvim_doc_reader-0.1.0.dist-info/RECORD +16 -0
- nvim_doc_reader-0.1.0.dist-info/WHEEL +5 -0
- nvim_doc_reader-0.1.0.dist-info/entry_points.txt +3 -0
- nvim_doc_reader-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">",
|
|
119
|
+
'"': """, "'": "'" }[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 — 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`.
|