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
doc_reader/nvim.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Jumps into running Neovim instances, the last-target state file, and nvimd.
|
|
2
|
+
|
|
3
|
+
Every Neovim (0.9+) listens on a socket of its own, `$XDG_RUNTIME_DIR/nvim.
|
|
4
|
+
<pid>.0`, without `--listen`. The server talks to each one with
|
|
5
|
+
`nvim --server <sock> --remote-expr`, so nothing needs installing in the
|
|
6
|
+
editor. The Lua that runs there is fixed text in this module; the target
|
|
7
|
+
(path, line, section) goes in as one JSON string argument, never spliced into
|
|
8
|
+
code.
|
|
9
|
+
|
|
10
|
+
A jump moves EVERY instance that has the file:
|
|
11
|
+
- shown in a window (any tab): the cursor moves there now;
|
|
12
|
+
- loaded but hidden: a one-shot BufWinEnter moves it when you `:b` to it;
|
|
13
|
+
- not loaded: nothing -- `nvimd` opens the last target instead.
|
|
14
|
+
"""
|
|
15
|
+
import asyncio
|
|
16
|
+
import glob
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from doc_reader import model
|
|
25
|
+
|
|
26
|
+
RPC_TIMEOUT_S = 1.0
|
|
27
|
+
|
|
28
|
+
# Lua run inside the editor. Kept free of newlines-in-strings and of `--`
|
|
29
|
+
# comments so it can travel as one Vim string literal.
|
|
30
|
+
_INSPECT = r"""
|
|
31
|
+
local a = vim.json.decode(_A)
|
|
32
|
+
local out = {}
|
|
33
|
+
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
|
34
|
+
local name = vim.api.nvim_buf_get_name(b)
|
|
35
|
+
if vim.api.nvim_buf_is_loaded(b) and name ~= "" and vim.loop.fs_realpath(name) == a.path then
|
|
36
|
+
table.insert(out, {
|
|
37
|
+
buf = b,
|
|
38
|
+
tick = vim.api.nvim_buf_get_changedtick(b),
|
|
39
|
+
text = table.concat(vim.api.nvim_buf_get_lines(b, 0, -1, false), "\n"),
|
|
40
|
+
})
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
return vim.json.encode(out)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# A hidden buffer's jump runs on BufWinEnter, when the entering window is
|
|
47
|
+
# current; by the time the scheduled move runs another might be, so it keeps
|
|
48
|
+
# that window and falls back to wherever the buffer is shown.
|
|
49
|
+
_MOVE = r"""
|
|
50
|
+
local a = vim.json.decode(_A)
|
|
51
|
+
local b = a.buf
|
|
52
|
+
if not vim.api.nvim_buf_is_valid(b) then return "gone" end
|
|
53
|
+
if vim.api.nvim_buf_get_changedtick(b) ~= a.tick then return "stale" end
|
|
54
|
+
local function clamp(l) return math.max(1, math.min(l, vim.api.nvim_buf_line_count(b))) end
|
|
55
|
+
local function show(win, l)
|
|
56
|
+
vim.api.nvim_win_call(win, function()
|
|
57
|
+
vim.api.nvim_win_set_cursor(win, {clamp(l), 0})
|
|
58
|
+
vim.cmd("normal! zv")
|
|
59
|
+
vim.cmd("normal! zt")
|
|
60
|
+
end)
|
|
61
|
+
end
|
|
62
|
+
local wins = vim.fn.win_findbuf(b)
|
|
63
|
+
if #wins > 0 then
|
|
64
|
+
for _, w in ipairs(wins) do pcall(show, w, a.line) end
|
|
65
|
+
vim.cmd("redraw")
|
|
66
|
+
return "moved"
|
|
67
|
+
end
|
|
68
|
+
local group = vim.api.nvim_create_augroup("doc_reader_jump_" .. b, {clear = true})
|
|
69
|
+
vim.api.nvim_create_autocmd("BufWinEnter", {
|
|
70
|
+
group = group, buffer = b, once = true,
|
|
71
|
+
callback = function()
|
|
72
|
+
local l = a.line
|
|
73
|
+
if vim.api.nvim_buf_get_changedtick(b) ~= a.tick and a.resolve ~= false then
|
|
74
|
+
local text = table.concat(vim.api.nvim_buf_get_lines(b, 0, -1, false), "\n")
|
|
75
|
+
local r = vim.fn.system(a.resolve, text)
|
|
76
|
+
if vim.v.shell_error == 0 and tonumber(r) then l = tonumber(r) end
|
|
77
|
+
end
|
|
78
|
+
local win = vim.api.nvim_get_current_win()
|
|
79
|
+
vim.schedule(function()
|
|
80
|
+
local targets = {win}
|
|
81
|
+
if not (vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == b) then
|
|
82
|
+
targets = vim.fn.win_findbuf(b)
|
|
83
|
+
end
|
|
84
|
+
for _, w in ipairs(targets) do pcall(show, w, l) end
|
|
85
|
+
end)
|
|
86
|
+
end,
|
|
87
|
+
})
|
|
88
|
+
return "armed"
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _vim_str(s):
|
|
93
|
+
"""A single-quoted Vim string literal (only `'` needs escaping)."""
|
|
94
|
+
return "'" + s.replace("'", "''") + "'"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _expr(lua, data):
|
|
98
|
+
# luaeval takes an expression, so the statements go in a function body.
|
|
99
|
+
code = " ".join(line.strip() for line in lua.strip().splitlines())
|
|
100
|
+
code = f"(function() {code} end)()"
|
|
101
|
+
return f"luaeval({_vim_str(code)}, {_vim_str(json.dumps(data))})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sockets(pattern):
|
|
105
|
+
return sorted(p for p in glob.glob(pattern) if not os.path.isdir(p))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def rpc(nvim_bin, sock, lua, data, timeout=RPC_TIMEOUT_S):
|
|
109
|
+
"""Run `lua` in the instance at `sock`; its return value as a string, or
|
|
110
|
+
None when the instance does not answer (dead socket, blocked editor)."""
|
|
111
|
+
proc = await asyncio.create_subprocess_exec(
|
|
112
|
+
nvim_bin, "--server", sock, "--remote-expr", _expr(lua, data),
|
|
113
|
+
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE,
|
|
114
|
+
stderr=asyncio.subprocess.PIPE)
|
|
115
|
+
try:
|
|
116
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
proc.kill()
|
|
119
|
+
await proc.wait()
|
|
120
|
+
return None
|
|
121
|
+
return out.decode() if proc.returncode == 0 else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def jump_instance(nvim_bin, sock, path, section_id, fallback_line,
|
|
125
|
+
resolve_cmd):
|
|
126
|
+
"""Move one instance. Returns a list of per-buffer results ("moved",
|
|
127
|
+
"armed", "gone"), or None when the instance did not answer."""
|
|
128
|
+
for _ in range(2): # once more if the buffer changes between the two calls
|
|
129
|
+
raw = await rpc(nvim_bin, sock, _INSPECT, {"path": path})
|
|
130
|
+
if raw is None:
|
|
131
|
+
return None
|
|
132
|
+
results, stale = [], False
|
|
133
|
+
for buf in json.loads(raw or "[]"):
|
|
134
|
+
line = (model.line_of(model.parse(buf["text"]), section_id)
|
|
135
|
+
if section_id else None) or fallback_line
|
|
136
|
+
r = await rpc(nvim_bin, sock, _MOVE, {
|
|
137
|
+
"buf": buf["buf"], "tick": buf["tick"], "line": line,
|
|
138
|
+
"resolve": resolve_cmd})
|
|
139
|
+
if r == "stale":
|
|
140
|
+
stale = True
|
|
141
|
+
break
|
|
142
|
+
results.append(r or "failed")
|
|
143
|
+
if not stale:
|
|
144
|
+
return results
|
|
145
|
+
return ["stale"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def jump_all(cfg, path, section_id, fallback_line, resolve_cmd):
|
|
149
|
+
nvim_bin = cfg.nvim
|
|
150
|
+
socks = sockets(cfg.nvim_socket_glob)
|
|
151
|
+
per = await asyncio.gather(*(
|
|
152
|
+
jump_instance(nvim_bin, s, path, section_id, fallback_line, resolve_cmd)
|
|
153
|
+
for s in socks))
|
|
154
|
+
moved = sum(1 for r in per if r and "moved" in r)
|
|
155
|
+
armed = sum(1 for r in per if r and "armed" in r and "moved" not in r)
|
|
156
|
+
dead = sum(1 for r in per if r is None)
|
|
157
|
+
return {"instances": len(socks), "moved": moved, "armed": armed,
|
|
158
|
+
"unreachable": dead}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def message(result):
|
|
162
|
+
if not result["moved"] and not result["armed"]:
|
|
163
|
+
return "Not open in any Neovim — run nvimd"
|
|
164
|
+
parts = []
|
|
165
|
+
if result["moved"]:
|
|
166
|
+
parts.append(f"moved {result['moved']} Neovim"
|
|
167
|
+
+ ("s" if result["moved"] > 1 else ""))
|
|
168
|
+
if result["armed"]:
|
|
169
|
+
parts.append(f"{result['armed']} with it hidden: :b to it")
|
|
170
|
+
return ", ".join(parts)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------ state, nvimd
|
|
174
|
+
|
|
175
|
+
def write_state(state_file, path, section_id, line):
|
|
176
|
+
os.makedirs(os.path.dirname(state_file), exist_ok=True)
|
|
177
|
+
record = {"path": path, "section_id": section_id, "line": line,
|
|
178
|
+
"ts": time.time()}
|
|
179
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(state_file), prefix=".last.")
|
|
180
|
+
with os.fdopen(fd, "w") as f:
|
|
181
|
+
json.dump(record, f)
|
|
182
|
+
os.replace(tmp, state_file)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_state(state_file):
|
|
186
|
+
try:
|
|
187
|
+
with open(state_file) as f:
|
|
188
|
+
return json.load(f)
|
|
189
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def nvimd_argv(cfg, args):
|
|
194
|
+
"""The nvim command line nvimd runs, and a note to print (or None)."""
|
|
195
|
+
if args:
|
|
196
|
+
return [cfg.nvim] + list(args), None
|
|
197
|
+
state = read_state(cfg.state_file)
|
|
198
|
+
if not state:
|
|
199
|
+
return [cfg.nvim], "nvimd: no target yet (press n in the reader first)"
|
|
200
|
+
path = state["path"]
|
|
201
|
+
if not os.path.exists(path):
|
|
202
|
+
return [cfg.nvim], f"nvimd: last target is gone: {path}"
|
|
203
|
+
line = state.get("line") or 1
|
|
204
|
+
if state.get("section_id"):
|
|
205
|
+
with open(path, encoding="utf-8") as f:
|
|
206
|
+
line = model.line_of(model.parse(f.read()), state["section_id"]) or line
|
|
207
|
+
return [cfg.nvim, f"+{line}", "-c", "normal! zvzt", path], None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def nvimd(cfg, args):
|
|
211
|
+
argv, note = nvimd_argv(cfg, args)
|
|
212
|
+
if note:
|
|
213
|
+
print(note, file=sys.stderr)
|
|
214
|
+
exe = shutil.which(argv[0]) or argv[0]
|
|
215
|
+
os.execv(exe, argv)
|
doc_reader/serve.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""The reader server: pages, live-reload fragments, and raw files under the
|
|
2
|
+
configured roots.
|
|
3
|
+
|
|
4
|
+
HTML is rendered on every request from the Markdown on disk; nothing is
|
|
5
|
+
cached or written out. Change detection polls (mtime_ns, size) because the
|
|
6
|
+
documents live on NFS, where inotify does not see writes made from other
|
|
7
|
+
hosts.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from urllib.parse import unquote, urlsplit
|
|
18
|
+
|
|
19
|
+
from aiohttp import web
|
|
20
|
+
|
|
21
|
+
from doc_reader import model, nvim
|
|
22
|
+
from doc_reader.urls import PREFIX, Roots
|
|
23
|
+
|
|
24
|
+
KATEX = "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9"
|
|
25
|
+
MERMAID = "https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"
|
|
26
|
+
HEARTBEAT_S = 15
|
|
27
|
+
# Raw files are served with no script and an opaque origin: an .html or .svg
|
|
28
|
+
# under a root must not be able to call this server's API as the reader.
|
|
29
|
+
RAW_HEADERS = {"Content-Security-Policy": "sandbox",
|
|
30
|
+
"X-Content-Type-Options": "nosniff"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def version(path):
|
|
34
|
+
st = os.stat(path)
|
|
35
|
+
return f"{st.st_mtime_ns}-{st.st_size}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_file(roots, real):
|
|
39
|
+
"""{version, html, warnings, title, error}; a failure fills `error` and
|
|
40
|
+
leaves `html` None, so the page keeps what it last showed."""
|
|
41
|
+
try:
|
|
42
|
+
ver = version(real)
|
|
43
|
+
with open(real, encoding="utf-8") as f:
|
|
44
|
+
text = f.read()
|
|
45
|
+
doc = model.parse(text)
|
|
46
|
+
task_href = None
|
|
47
|
+
if os.path.basename(real) == "Project.md":
|
|
48
|
+
def task_href(n):
|
|
49
|
+
task = os.path.join(os.path.dirname(real), "tasks", str(n), "Task.md")
|
|
50
|
+
return task if os.path.exists(task) else None # mapped by rewrite
|
|
51
|
+
body = model.render(doc, roots.link_rewriter(real), task_href)
|
|
52
|
+
return {"version": ver, "html": body, "warnings": doc.warnings,
|
|
53
|
+
"title": doc.title, "error": None}
|
|
54
|
+
except Exception as e: # noqa: BLE001 -- shown in the page, not swallowed
|
|
55
|
+
return {"version": None, "html": None, "warnings": [], "title": "",
|
|
56
|
+
"error": f"{type(e).__name__}: {e}"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _static(name):
|
|
60
|
+
return resources.files("doc_reader").joinpath("static", name).read_text()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _page(title, body, data):
|
|
64
|
+
# `</` cannot appear inside the inline <script>; escaped outside the f-string
|
|
65
|
+
# because 3.10 allows no backslash inside one.
|
|
66
|
+
blob = json.dumps(data).replace("</", "<\\/")
|
|
67
|
+
return f"""<!doctype html>
|
|
68
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
70
|
+
<title>{html.escape(title)}</title>
|
|
71
|
+
<link rel="stylesheet" href="{KATEX}/katex.min.css">
|
|
72
|
+
<link rel="stylesheet" href="/_static/reader.css">
|
|
73
|
+
<script defer src="{KATEX}/katex.min.js"></script>
|
|
74
|
+
<script defer src="{MERMAID}"></script>
|
|
75
|
+
<script defer src="/_static/reader.js"></script>
|
|
76
|
+
</head><body>
|
|
77
|
+
<script type="application/json" class="dr-doc-data">{blob}</script>
|
|
78
|
+
<div class="dr-banner" hidden></div>
|
|
79
|
+
<nav class="dr-outline" aria-label="Outline"></nav>
|
|
80
|
+
<main class="dr-doc">{body}</main>
|
|
81
|
+
<div class="dr-toast" role="status" aria-live="polite"></div>
|
|
82
|
+
</body></html>"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _listing(roots, real, url):
|
|
86
|
+
rows = []
|
|
87
|
+
if url.count("/") > 3:
|
|
88
|
+
rows.append('<li><a href="../">../</a></li>')
|
|
89
|
+
for name in sorted(os.listdir(real), key=str.lower):
|
|
90
|
+
if name.startswith("."):
|
|
91
|
+
continue
|
|
92
|
+
full = os.path.join(real, name)
|
|
93
|
+
if os.path.isdir(full) or name.endswith(".md"):
|
|
94
|
+
href = roots.url_path(full)
|
|
95
|
+
if href:
|
|
96
|
+
label = name + ("/" if os.path.isdir(full) else "")
|
|
97
|
+
rows.append(f'<li><a href="{html.escape(href)}">{html.escape(label)}</a></li>')
|
|
98
|
+
return (f"<h1>{html.escape(url)}</h1>\n<ul class=\"listing\">\n"
|
|
99
|
+
+ "\n".join(rows) + "\n</ul>")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def make_app(cfg):
|
|
103
|
+
roots = Roots(cfg.roots)
|
|
104
|
+
routes = web.RouteTableDef()
|
|
105
|
+
|
|
106
|
+
def lookup(request, prefix):
|
|
107
|
+
"""Map /<prefix>/<root>/<rel> to (real path, canonical /v/ url)."""
|
|
108
|
+
raw = request.raw_path.split("?", 1)[0]
|
|
109
|
+
real = roots.resolve(PREFIX + raw[len(prefix):])
|
|
110
|
+
if real is None or not os.path.exists(real):
|
|
111
|
+
raise web.HTTPNotFound(text=f"not found under any root: {request.path}")
|
|
112
|
+
return real, roots.url_path(real)
|
|
113
|
+
|
|
114
|
+
@routes.get("/")
|
|
115
|
+
async def index(request):
|
|
116
|
+
rows = "\n".join(
|
|
117
|
+
f'<li><a href="{PREFIX}{name}/">{html.escape(name)}</a> '
|
|
118
|
+
f"<code>{html.escape(path)}</code></li>"
|
|
119
|
+
for name, path in roots.roots.items())
|
|
120
|
+
return web.Response(content_type="text/html", text=_page(
|
|
121
|
+
"doc-reader", f"<h1>Roots</h1>\n<ul class=\"listing\">{rows}</ul>",
|
|
122
|
+
{"kind": "listing"}))
|
|
123
|
+
|
|
124
|
+
@routes.get(PREFIX + "{tail:.*}")
|
|
125
|
+
async def view(request):
|
|
126
|
+
real, canonical = lookup(request, PREFIX)
|
|
127
|
+
raw = request.raw_path.split("?", 1)[0]
|
|
128
|
+
# Compare decoded: aiohttp re-normalises a Location's escapes (%28 back to
|
|
129
|
+
# "("), so an encoded-only difference would redirect to itself for ever.
|
|
130
|
+
if unquote(canonical) != unquote(raw):
|
|
131
|
+
qs = "?" + request.query_string if request.query_string else ""
|
|
132
|
+
raise web.HTTPFound(canonical + qs)
|
|
133
|
+
if os.path.isdir(real):
|
|
134
|
+
return web.Response(content_type="text/html", text=_page(
|
|
135
|
+
canonical, _listing(roots, real, canonical), {"kind": "listing"}))
|
|
136
|
+
if not real.endswith(".md"):
|
|
137
|
+
ctype = mimetypes.guess_type(real)[0] or "application/octet-stream"
|
|
138
|
+
return web.FileResponse(real, headers={**RAW_HEADERS,
|
|
139
|
+
"Content-Type": ctype})
|
|
140
|
+
r = await asyncio.to_thread(render_file, roots, real)
|
|
141
|
+
data = {"kind": "doc", "path": real, "url": canonical,
|
|
142
|
+
"version": r["version"], "warnings": r["warnings"],
|
|
143
|
+
"error": r["error"]}
|
|
144
|
+
return web.Response(content_type="text/html", text=_page(
|
|
145
|
+
r["title"] or os.path.basename(real), r["html"] or "", data))
|
|
146
|
+
|
|
147
|
+
@routes.get("/frag/{tail:.*}")
|
|
148
|
+
async def frag(request):
|
|
149
|
+
real, _ = lookup(request, "/frag/")
|
|
150
|
+
return web.json_response(await asyncio.to_thread(render_file, roots, real),
|
|
151
|
+
headers={"Cache-Control": "no-store"})
|
|
152
|
+
|
|
153
|
+
@routes.get("/events/{tail:.*}")
|
|
154
|
+
async def events(request):
|
|
155
|
+
real, _ = lookup(request, "/events/")
|
|
156
|
+
resp = web.StreamResponse(headers={
|
|
157
|
+
"Content-Type": "text/event-stream", "Cache-Control": "no-cache",
|
|
158
|
+
"X-Accel-Buffering": "no"})
|
|
159
|
+
await resp.prepare(request)
|
|
160
|
+
sent = candidate = None
|
|
161
|
+
last_write = time.monotonic()
|
|
162
|
+
try:
|
|
163
|
+
while True:
|
|
164
|
+
try:
|
|
165
|
+
ver = await asyncio.to_thread(version, real)
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
ver = None # mid atomic-replace; look again next poll
|
|
168
|
+
if ver is not None:
|
|
169
|
+
if sent is None:
|
|
170
|
+
sent = ver # on (re)connect, so a missed change is caught
|
|
171
|
+
await resp.write(f"data: {ver}\n\n".encode())
|
|
172
|
+
last_write = time.monotonic()
|
|
173
|
+
elif ver != sent:
|
|
174
|
+
# Report a change once it holds for two polls, so a write in
|
|
175
|
+
# progress is not rendered half-done.
|
|
176
|
+
if ver == candidate:
|
|
177
|
+
sent = ver
|
|
178
|
+
await resp.write(f"data: {ver}\n\n".encode())
|
|
179
|
+
last_write = time.monotonic()
|
|
180
|
+
candidate = ver
|
|
181
|
+
if time.monotonic() - last_write > HEARTBEAT_S:
|
|
182
|
+
await resp.write(b": hb\n\n")
|
|
183
|
+
last_write = time.monotonic()
|
|
184
|
+
await asyncio.sleep(cfg.poll_interval_s)
|
|
185
|
+
except (ConnectionResetError, asyncio.CancelledError):
|
|
186
|
+
pass
|
|
187
|
+
return resp
|
|
188
|
+
|
|
189
|
+
@routes.post("/api/open")
|
|
190
|
+
async def api_open(request):
|
|
191
|
+
# Only the reader page may move editors. A JSON content type cannot be
|
|
192
|
+
# sent cross-site without a CORS preflight (which nothing here answers),
|
|
193
|
+
# and the Origin check covers browsers that send one anyway.
|
|
194
|
+
origin = request.headers.get("Origin")
|
|
195
|
+
if request.content_type != "application/json" or (
|
|
196
|
+
origin and urlsplit(origin).netloc != request.host):
|
|
197
|
+
raise web.HTTPForbidden(text="same-origin JSON only")
|
|
198
|
+
body = await request.json()
|
|
199
|
+
path = os.path.realpath(str(body.get("path") or ""))
|
|
200
|
+
# Checked before anything is read, written or sent to an editor.
|
|
201
|
+
if roots.canonical(path) is None or not os.path.isfile(path):
|
|
202
|
+
return web.json_response({"message": f"not under a root: {path}"},
|
|
203
|
+
status=400)
|
|
204
|
+
sid = body.get("section_id") or None
|
|
205
|
+
with open(path, encoding="utf-8") as f:
|
|
206
|
+
disk_line = model.line_of(model.parse(f.read()), sid) if sid else None
|
|
207
|
+
resolved = disk_line is not None
|
|
208
|
+
line = disk_line or int(body.get("line") or 1)
|
|
209
|
+
nvim.write_state(cfg.state_file, path, sid, line)
|
|
210
|
+
resolve_cmd = ([sys.executable, os.path.abspath(sys.argv[0]), "section",
|
|
211
|
+
"--stdin", path, sid] if sid else False)
|
|
212
|
+
result = await nvim.jump_all(cfg, path, sid, line, resolve_cmd)
|
|
213
|
+
result.update(line=line, resolved=resolved, message=nvim.message(result))
|
|
214
|
+
return web.json_response(result)
|
|
215
|
+
|
|
216
|
+
@routes.get("/_static/{name}")
|
|
217
|
+
async def static(request):
|
|
218
|
+
name = request.match_info["name"]
|
|
219
|
+
if name not in ("reader.js", "reader.css"):
|
|
220
|
+
raise web.HTTPNotFound()
|
|
221
|
+
ctype = "text/javascript" if name.endswith(".js") else "text/css"
|
|
222
|
+
return web.Response(text=_static(name), content_type=ctype,
|
|
223
|
+
headers={"Cache-Control": "no-cache"})
|
|
224
|
+
|
|
225
|
+
app = web.Application()
|
|
226
|
+
app.add_routes(routes)
|
|
227
|
+
app["cfg"] = cfg
|
|
228
|
+
app["roots"] = roots
|
|
229
|
+
return app
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def serve(cfg):
|
|
233
|
+
web.run_app(make_app(cfg), host=cfg.bind, port=cfg.port,
|
|
234
|
+
print=lambda *_: print(f"doc-reader on {cfg.base_url} "
|
|
235
|
+
f"(bind {cfg.bind}:{cfg.port})"))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-authoring
|
|
3
|
+
description: Rules for writing or editing any Markdown document for the user (research notes, reports, plans, explainers) so it reads well in doc-reader: stable section IDs, nested depth, figures, honest data, and how to answer questions about a document without editing it. Load before creating or changing such a document.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Document Authoring
|
|
7
|
+
|
|
8
|
+
The user reads these documents in doc-reader (`pip install nvim-doc-reader`): a browser
|
|
9
|
+
page where sections fold, `j`/`k` walk headings, and `n` jumps every open
|
|
10
|
+
Neovim to the section being read. That shapes the rules below.
|
|
11
|
+
|
|
12
|
+
## Source
|
|
13
|
+
|
|
14
|
+
- Markdown is the only source. Never write or commit generated HTML.
|
|
15
|
+
- Raw HTML in the Markdown is shown as text, so don't use it.
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
- Headings are the navigation. Each section starts with a short overview
|
|
20
|
+
paragraph; deeper material goes in nested subsections (intuition, worked
|
|
21
|
+
example, mechanism, derivation, evidence, caveats) that the reader can
|
|
22
|
+
leave folded.
|
|
23
|
+
- Don't skip heading levels, and don't put headings inside lists or quotes:
|
|
24
|
+
those don't become sections.
|
|
25
|
+
- A heading's ID is its GitHub slug, or an explicit `{#id}` at the end of the
|
|
26
|
+
heading. Give an explicit ID to any section other documents or messages
|
|
27
|
+
link to, so a rename doesn't break the link.
|
|
28
|
+
- When editing, keep every existing `{#id}` and the heading hierarchy. Change
|
|
29
|
+
only the section you were asked to change, and keep concurrent edits by the
|
|
30
|
+
user or other agents intact (re-read the file right before writing).
|
|
31
|
+
|
|
32
|
+
## Figures, charts, math
|
|
33
|
+
|
|
34
|
+
Add them where they help understanding, not for decoration.
|
|
35
|
+
|
|
36
|
+
- Concepts and flows: a ```` ```mermaid ```` block.
|
|
37
|
+
- Illustrations: SVG in `figures/` next to the document.
|
|
38
|
+
- Charts from data: a Python script and its data file in `figures/`, beside
|
|
39
|
+
the generated SVG/PNG it writes (e.g. `figures/perf.py`, `figures/perf.csv`,
|
|
40
|
+
`figures/perf.svg`), so the chart can be regenerated and checked.
|
|
41
|
+
- Math: `$...$` inline, `$$...$$` on their own lines for display (KaTeX).
|
|
42
|
+
- Reference assets with relative paths, give images alt text, and say in the
|
|
43
|
+
prose what the figure shows.
|
|
44
|
+
|
|
45
|
+
## Honesty
|
|
46
|
+
|
|
47
|
+
- Never fabricate empirical results. Numbers come from a run you can point
|
|
48
|
+
to; illustrative data is labelled synthetic, in the prose and the figure.
|
|
49
|
+
|
|
50
|
+
## Questions about a document
|
|
51
|
+
|
|
52
|
+
The user often pastes a question from the reader's `q` key, naming the file
|
|
53
|
+
and section. Then:
|
|
54
|
+
|
|
55
|
+
1. Answer in the conversation. Do not edit any file.
|
|
56
|
+
2. Offer three follow-ups: integrate the answer into the section, attach it
|
|
57
|
+
as a new nested subsection, or keep it as a note only.
|
|
58
|
+
3. Edit only after the user picks one, and only that section.
|
|
59
|
+
|
|
60
|
+
## Linking the user to a document
|
|
61
|
+
|
|
62
|
+
`docread url <file> <line>` prints the reader link for the section containing
|
|
63
|
+
that line. Give that link plus the file path (for editing). If `docread`
|
|
64
|
+
isn't available or the file is outside the served roots, give the path alone.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #fdfdfc;
|
|
3
|
+
--fg: #1f2328;
|
|
4
|
+
--dim: #6a737d;
|
|
5
|
+
--rule: #e3e5e8;
|
|
6
|
+
--code-bg: #f3f4f6;
|
|
7
|
+
--accent: #2f6fdd;
|
|
8
|
+
--cur-bg: #eaf1fd;
|
|
9
|
+
--banner-bg: #fff4d6;
|
|
10
|
+
--banner-fg: #5c4400;
|
|
11
|
+
--outline-w: 270px;
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root:not([data-theme="light"]) {
|
|
16
|
+
--bg: #16181c;
|
|
17
|
+
--fg: #e3e5e8;
|
|
18
|
+
--dim: #9aa3ad;
|
|
19
|
+
--rule: #2d3138;
|
|
20
|
+
--code-bg: #22252b;
|
|
21
|
+
--accent: #7aa7ff;
|
|
22
|
+
--cur-bg: #1f2a3d;
|
|
23
|
+
--banner-bg: #3d3216;
|
|
24
|
+
--banner-fg: #f3dc9b;
|
|
25
|
+
color-scheme: dark;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
:root[data-theme="dark"] {
|
|
29
|
+
--bg: #16181c; --fg: #e3e5e8; --dim: #9aa3ad; --rule: #2d3138;
|
|
30
|
+
--code-bg: #22252b; --accent: #7aa7ff; --cur-bg: #1f2a3d;
|
|
31
|
+
--banner-bg: #3d3216; --banner-fg: #f3dc9b; color-scheme: dark;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
* { box-sizing: border-box; }
|
|
35
|
+
html { scroll-padding-top: 15vh; }
|
|
36
|
+
body {
|
|
37
|
+
margin: 0;
|
|
38
|
+
background: var(--bg);
|
|
39
|
+
color: var(--fg);
|
|
40
|
+
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
41
|
+
}
|
|
42
|
+
a { color: var(--accent); }
|
|
43
|
+
a.unreachable { color: var(--dim); text-decoration: underline dotted; }
|
|
44
|
+
|
|
45
|
+
/* ---------------------------------------------------------------- layout */
|
|
46
|
+
.dr-doc {
|
|
47
|
+
max-width: 820px;
|
|
48
|
+
margin: 0 auto;
|
|
49
|
+
padding: 24px 16px 60vh;
|
|
50
|
+
}
|
|
51
|
+
.dr-outline {
|
|
52
|
+
position: fixed;
|
|
53
|
+
top: 0;
|
|
54
|
+
left: 0;
|
|
55
|
+
width: var(--outline-w);
|
|
56
|
+
height: 100vh;
|
|
57
|
+
overflow-y: auto;
|
|
58
|
+
padding: 20px 12px 20px 16px;
|
|
59
|
+
border-right: 1px solid var(--rule);
|
|
60
|
+
font-size: 13px;
|
|
61
|
+
line-height: 1.45;
|
|
62
|
+
}
|
|
63
|
+
.dr-outline ul { list-style: none; margin: 0; padding-left: 12px; }
|
|
64
|
+
.dr-outline > ul { padding-left: 0; }
|
|
65
|
+
.dr-outline a { display: block; padding: 2px 6px; border-radius: 4px; color: var(--dim);
|
|
66
|
+
text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
67
|
+
.dr-outline a:hover { color: var(--fg); }
|
|
68
|
+
.dr-outline a.cur { color: var(--fg); background: var(--cur-bg); font-weight: 600; }
|
|
69
|
+
/* Hidden until `o`. Wide screens dock it beside the text; narrow ones lay
|
|
70
|
+
it over the text. */
|
|
71
|
+
.dr-outline { display: none; background: var(--bg); z-index: 5; }
|
|
72
|
+
body.show-outline .dr-outline { display: block; }
|
|
73
|
+
@media (min-width: 1180px) {
|
|
74
|
+
body.show-outline .dr-doc { margin-left: calc(var(--outline-w) + max(24px, (100vw - var(--outline-w) - 820px) / 2)); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------------------------------------------------------------- sections */
|
|
78
|
+
section { position: relative; }
|
|
79
|
+
.sec-h { cursor: pointer; position: relative; border-radius: 4px; margin-left: -8px; padding-left: 8px; }
|
|
80
|
+
.sec-h::before {
|
|
81
|
+
content: "\25BE";
|
|
82
|
+
position: absolute;
|
|
83
|
+
left: -14px;
|
|
84
|
+
color: var(--dim);
|
|
85
|
+
font-size: 0.7em;
|
|
86
|
+
top: 50%;
|
|
87
|
+
transform: translateY(-50%);
|
|
88
|
+
}
|
|
89
|
+
section.collapsed > .sec-h::before { content: "\25B8"; }
|
|
90
|
+
section.collapsed > .sec-h::after { content: " \2026"; color: var(--dim); font-weight: 400; }
|
|
91
|
+
section.collapsed > :not(.sec-h) { display: none; }
|
|
92
|
+
section.cur > .sec-h { background: var(--cur-bg); box-shadow: -3px 0 0 var(--accent); }
|
|
93
|
+
|
|
94
|
+
h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.4em 0 0.5em; }
|
|
95
|
+
h1 { font-size: 1.9em; }
|
|
96
|
+
h2 { font-size: 1.45em; border-bottom: 1px solid var(--rule); padding-bottom: 0.2em; }
|
|
97
|
+
h3 { font-size: 1.2em; }
|
|
98
|
+
h4 { font-size: 1.05em; }
|
|
99
|
+
h5, h6 { font-size: 0.95em; color: var(--dim); }
|
|
100
|
+
|
|
101
|
+
/* ---------------------------------------------------------------- content */
|
|
102
|
+
p, ul, ol, table, pre, blockquote { margin: 0 0 1em; }
|
|
103
|
+
code { background: var(--code-bg); padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em;
|
|
104
|
+
font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
|
105
|
+
pre { background: var(--code-bg); padding: 12px 14px; border-radius: 6px; overflow-x: auto; }
|
|
106
|
+
pre code { background: none; padding: 0; }
|
|
107
|
+
pre.mermaid { background: none; text-align: center; }
|
|
108
|
+
blockquote { border-left: 3px solid var(--rule); margin-left: 0; padding-left: 14px; color: var(--dim); }
|
|
109
|
+
table { border-collapse: collapse; display: block; overflow-x: auto; }
|
|
110
|
+
th, td { border: 1px solid var(--rule); padding: 5px 10px; text-align: left; }
|
|
111
|
+
img { max-width: 100%; height: auto; }
|
|
112
|
+
div.math { overflow-x: auto; margin: 0 0 1em; }
|
|
113
|
+
ul.listing { list-style: none; padding: 0; }
|
|
114
|
+
ul.listing li { padding: 3px 0; }
|
|
115
|
+
|
|
116
|
+
/* ---------------------------------------------------------------- chrome */
|
|
117
|
+
.dr-banner {
|
|
118
|
+
position: sticky;
|
|
119
|
+
top: 0;
|
|
120
|
+
z-index: 10;
|
|
121
|
+
background: var(--banner-bg);
|
|
122
|
+
color: var(--banner-fg);
|
|
123
|
+
padding: 8px 16px;
|
|
124
|
+
font-size: 14px;
|
|
125
|
+
}
|
|
126
|
+
.dr-toast {
|
|
127
|
+
position: fixed;
|
|
128
|
+
right: 16px;
|
|
129
|
+
bottom: 16px;
|
|
130
|
+
max-width: calc(100vw - 32px);
|
|
131
|
+
background: var(--fg);
|
|
132
|
+
color: var(--bg);
|
|
133
|
+
padding: 8px 14px;
|
|
134
|
+
border-radius: 6px;
|
|
135
|
+
font-size: 14px;
|
|
136
|
+
opacity: 0;
|
|
137
|
+
transition: opacity 0.2s;
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
z-index: 20;
|
|
140
|
+
}
|
|
141
|
+
.dr-toast.show { opacity: 0.95; }
|
|
142
|
+
.overlay {
|
|
143
|
+
position: fixed;
|
|
144
|
+
inset: 0;
|
|
145
|
+
background: rgba(0, 0, 0, 0.45);
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
z-index: 30;
|
|
150
|
+
padding: 16px;
|
|
151
|
+
}
|
|
152
|
+
.overlay .panel {
|
|
153
|
+
background: var(--bg);
|
|
154
|
+
color: var(--fg);
|
|
155
|
+
border-radius: 8px;
|
|
156
|
+
padding: 18px 22px;
|
|
157
|
+
max-width: 560px;
|
|
158
|
+
width: 100%;
|
|
159
|
+
max-height: 90vh;
|
|
160
|
+
overflow: auto;
|
|
161
|
+
}
|
|
162
|
+
.overlay h2 { margin-top: 0; border: 0; }
|
|
163
|
+
.overlay td { border: 0; padding: 3px 10px 3px 0; }
|
|
164
|
+
.overlay textarea { width: 100%; font: 13px ui-monospace, monospace; }
|
|
165
|
+
kbd { background: var(--code-bg); border: 1px solid var(--rule); border-radius: 4px;
|
|
166
|
+
padding: 1px 6px; font: 13px ui-monospace, monospace; white-space: nowrap; }
|
|
167
|
+
.dim { color: var(--dim); font-size: 13px; }
|