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/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; }