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 ADDED
@@ -0,0 +1 @@
1
+ """doc-reader: keyboard-driven HTML reader for Markdown, with Neovim jumps."""
doc_reader/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """`docread` (serve, url, section, nvim-path) and `nvimd`."""
2
+ import argparse
3
+ import os
4
+ import sys
5
+ from importlib import resources
6
+
7
+ from doc_reader import config, model
8
+ from doc_reader.urls import Roots
9
+
10
+
11
+ def _read(path, use_stdin):
12
+ if use_stdin:
13
+ return sys.stdin.read()
14
+ with open(path, encoding="utf-8") as f:
15
+ return f.read()
16
+
17
+
18
+ def cmd_serve(cfg, args):
19
+ from doc_reader import serve # aiohttp only when serving
20
+ serve.serve(cfg)
21
+
22
+
23
+ def cmd_url(cfg, args):
24
+ """The reader URL of the section containing `line` -- computed from the
25
+ buffer text when it is piped in, so unsaved edits are accounted for."""
26
+ url = Roots(cfg.roots).url_path(args.path)
27
+ if url is None:
28
+ print(f"{args.path} is not under any root: {sorted(cfg.roots)}",
29
+ file=sys.stderr)
30
+ return 1
31
+ sec = model.section_at(model.parse(_read(args.path, args.stdin)), args.line)
32
+ print(cfg.base_url + url + (f"#{sec.id}" if sec else ""))
33
+ return 0
34
+
35
+
36
+ def cmd_section(cfg, args):
37
+ """The 1-based line of section `id` in the text (stdin or file), or exit 1."""
38
+ line = model.line_of(model.parse(_read(args.path, args.stdin)), args.id)
39
+ if line is None:
40
+ return 1
41
+ print(line)
42
+ return 0
43
+
44
+
45
+ def cmd_nvim_path(cfg, args):
46
+ print(resources.files("doc_reader").joinpath("nvim"))
47
+ return 0
48
+
49
+
50
+ def cmd_install_skill(cfg, args):
51
+ """Copy the bundled agent skill(s) into a skills directory."""
52
+ dest = os.path.expanduser(args.dir)
53
+ src = resources.files("doc_reader").joinpath("skills")
54
+ for skill in src.iterdir():
55
+ if not skill.is_dir():
56
+ continue
57
+ os.makedirs(os.path.join(dest, skill.name), exist_ok=True)
58
+ for f in skill.iterdir():
59
+ out = os.path.join(dest, skill.name, f.name)
60
+ with open(out, "w", encoding="utf-8") as fh:
61
+ fh.write(f.read_text(encoding="utf-8"))
62
+ print(out)
63
+ return 0
64
+
65
+
66
+ def main(argv=None):
67
+ p = argparse.ArgumentParser(prog="docread", description=__doc__)
68
+ p.add_argument("--config", help="config file (default: $DOCREADER_CONFIG or "
69
+ "$XDG_CONFIG_HOME/doc-reader/config.toml)")
70
+ sub = p.add_subparsers(dest="cmd", required=True)
71
+ s = sub.add_parser("serve", help="run the reader server")
72
+ s.add_argument("--port", type=int)
73
+ s.add_argument("--bind")
74
+ s.add_argument("--root", action="append", metavar="NAME=DIR",
75
+ help="replace the configured roots (repeatable)")
76
+ s.set_defaults(fn=cmd_serve)
77
+ s = sub.add_parser("url", help="reader URL for a file and line")
78
+ s.add_argument("path")
79
+ s.add_argument("line", type=int, nargs="?", default=1)
80
+ s.add_argument("--stdin", action="store_true", help="read the text from stdin")
81
+ s.set_defaults(fn=cmd_url)
82
+ s = sub.add_parser("section", help="line of a section id")
83
+ s.add_argument("path")
84
+ s.add_argument("id")
85
+ s.add_argument("--stdin", action="store_true")
86
+ s.set_defaults(fn=cmd_section)
87
+ s = sub.add_parser("nvim-path", help="directory to add to Neovim's runtimepath")
88
+ s.set_defaults(fn=cmd_nvim_path)
89
+ s = sub.add_parser("install-skill",
90
+ help="install the doc-authoring agent skill")
91
+ s.add_argument("--dir", default="~/.claude/skills",
92
+ help="skills directory (default: ~/.claude/skills)")
93
+ s.set_defaults(fn=cmd_install_skill)
94
+ args = p.parse_args(argv)
95
+ overrides = {"port": getattr(args, "port", None),
96
+ "bind": getattr(args, "bind", None)}
97
+ if getattr(args, "root", None):
98
+ overrides["roots"] = config.parse_roots(":".join(args.root))
99
+ cfg = config.load(args.config, overrides=overrides)
100
+ return args.fn(cfg, args) or 0
101
+
102
+
103
+ def nvimd_main(argv=None):
104
+ from doc_reader import nvim
105
+ return nvim.nvimd(config.load(), sys.argv[1:] if argv is None else argv)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ sys.exit(main())
doc_reader/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """Settings: defaults, then the TOML file, then DOCREADER_* env, then CLI flags.
2
+
3
+ Nothing here names a host or a directory of ours. The defaults are the
4
+ generic ones (XDG dirs, the home directory as the only root), and a site
5
+ config supplies the rest.
6
+ """
7
+ import os
8
+ import pwd
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else:
17
+ import tomli as tomllib
18
+
19
+
20
+ def _xdg(var, fallback):
21
+ return os.environ.get(var) or os.path.expanduser(fallback)
22
+
23
+
24
+ def _runtime_dir():
25
+ if os.environ.get("XDG_RUNTIME_DIR"):
26
+ return os.environ["XDG_RUNTIME_DIR"]
27
+ run = f"/run/user/{os.getuid()}"
28
+ return run if os.path.isdir(run) else "/tmp"
29
+
30
+
31
+ def default_socket_glob():
32
+ """Where Neovim puts the socket it opens on startup: its stdpath("run"),
33
+ which is $XDG_RUNTIME_DIR or else a private random directory under the
34
+ temp dir (e.g. /tmp/nvim.root/8CVDuf/nvim.3950.0)."""
35
+ if os.environ.get("XDG_RUNTIME_DIR"):
36
+ return os.path.join(os.environ["XDG_RUNTIME_DIR"], "nvim.*.0")
37
+ user = pwd.getpwuid(os.getuid()).pw_name
38
+ return os.path.join(tempfile.gettempdir(), f"nvim.{user}", "*", "nvim.*.0")
39
+
40
+
41
+ def expand(value):
42
+ """`~` and `$VAR`, with the XDG vars filled in when the environment lacks
43
+ them, so `$XDG_STATE_HOME/...` never expands to a path starting at `/`."""
44
+ env = dict(os.environ)
45
+ env.setdefault("XDG_CONFIG_HOME", _xdg("XDG_CONFIG_HOME", "~/.config"))
46
+ env.setdefault("XDG_STATE_HOME", _xdg("XDG_STATE_HOME", "~/.local/state"))
47
+ env.setdefault("XDG_RUNTIME_DIR", _runtime_dir())
48
+ out = os.path.expanduser(str(value))
49
+ for k in sorted(env, key=len, reverse=True):
50
+ out = out.replace("${" + k + "}", env[k]).replace("$" + k, env[k])
51
+ return out
52
+
53
+
54
+ @dataclass
55
+ class Config:
56
+ roots: dict = field(default_factory=lambda: {"home": os.path.expanduser("~")})
57
+ bind: str = "0.0.0.0"
58
+ port: int = 8765
59
+ public_url: str = ""
60
+ nvim: str = "nvim"
61
+ nvim_socket_glob: str = "" # empty: default_socket_glob()
62
+ state_file: str = "$XDG_STATE_HOME/doc-reader/last"
63
+ poll_interval_s: float = 0.5
64
+
65
+ @property
66
+ def base_url(self):
67
+ return (self.public_url or f"http://localhost:{self.port}").rstrip("/")
68
+
69
+
70
+ SCALARS = {"bind": str, "port": int, "public_url": str, "nvim": str,
71
+ "nvim_socket_glob": str,
72
+ "state_file": str, "poll_interval_s": float}
73
+
74
+
75
+ def config_path():
76
+ if os.environ.get("DOCREADER_CONFIG"):
77
+ return Path(expand(os.environ["DOCREADER_CONFIG"]))
78
+ return Path(expand("$XDG_CONFIG_HOME/doc-reader/config.toml"))
79
+
80
+
81
+ def parse_roots(spec):
82
+ """`name=/dir:name=/dir` (the DOCREADER_ROOTS form)."""
83
+ roots = {}
84
+ for part in filter(None, spec.split(":")):
85
+ name, sep, path = part.partition("=")
86
+ if not sep or not name or not path:
87
+ raise ValueError(f"bad root {part!r}: expected name=/dir")
88
+ roots[name] = path
89
+ return roots
90
+
91
+
92
+ def load(path=None, env=None, overrides=None):
93
+ env = os.environ if env is None else env
94
+ cfg = Config()
95
+ path = Path(path) if path else config_path()
96
+ data = {}
97
+ if path.exists():
98
+ with open(path, "rb") as f:
99
+ data = tomllib.load(f)
100
+ unknown = set(data) - set(SCALARS) - {"roots"}
101
+ if unknown:
102
+ raise ValueError(f"{path}: unknown keys {sorted(unknown)}")
103
+ for key, kind in SCALARS.items():
104
+ if key in data:
105
+ setattr(cfg, key, kind(data[key]))
106
+ if env.get(f"DOCREADER_{key.upper()}"):
107
+ setattr(cfg, key, kind(env[f"DOCREADER_{key.upper()}"]))
108
+ if "roots" in data:
109
+ if not isinstance(data["roots"], dict):
110
+ raise ValueError(f"{path}: [roots] must be a table of name = \"/dir\"")
111
+ cfg.roots = dict(data["roots"])
112
+ if env.get("DOCREADER_ROOTS"):
113
+ cfg.roots = parse_roots(env["DOCREADER_ROOTS"])
114
+ for key, value in (overrides or {}).items():
115
+ if value is not None:
116
+ setattr(cfg, key, value)
117
+ for name in cfg.roots:
118
+ if "/" in name or not name:
119
+ raise ValueError(f"root name {name!r} must be a single path segment")
120
+ cfg.roots = {k: os.path.realpath(expand(v)) for k, v in cfg.roots.items()}
121
+ cfg.nvim_socket_glob = (expand(cfg.nvim_socket_glob) if cfg.nvim_socket_glob
122
+ else default_socket_glob())
123
+ cfg.state_file = os.path.abspath(expand(cfg.state_file))
124
+ return cfg
doc_reader/model.py ADDED
@@ -0,0 +1,281 @@
1
+ """One parse of a Markdown document: sections, their IDs and source lines, and
2
+ the HTML, all from the same markdown-it token stream.
3
+
4
+ Everything that needs to know where a section is -- the page, `docread url`,
5
+ the jump into Neovim -- goes through `parse`, so the browser and the editor
6
+ can never disagree about what counts as a heading. A `#` line in a fenced
7
+ code block is not one; a Setext heading is; a heading inside a list item or a
8
+ blockquote renders but is not a section (it cannot hold nested content).
9
+ """
10
+ import copy
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from markdown_it import MarkdownIt
15
+ from markdown_it.common.utils import escapeHtml
16
+ from markdown_it.renderer import RendererHTML
17
+ from markdown_it.token import Token
18
+ from mdit_py_plugins.dollarmath import dollarmath_plugin
19
+ from mdit_py_plugins.front_matter import front_matter_plugin
20
+
21
+ # A trailing `{#id}` on a heading. The attrs plugin in mdit-py-plugins 0.3
22
+ # does not handle it, so this is a core rule of our own that edits the parsed
23
+ # inline token -- never the source text, which would shift line numbers.
24
+ EXPLICIT_ID_RE = re.compile(r"\s*\{#([A-Za-z][\w:.-]*)\}\s*$")
25
+ # pm-tui's own task-row pattern (project_manager_tui/core.py parse_tasks).
26
+ TASK_RE = re.compile(r"^(\d+)\.\s+\[([^\]])\]\s*(.*)$")
27
+
28
+
29
+ @dataclass
30
+ class Section:
31
+ id: str
32
+ level: int
33
+ title: str
34
+ line: int # 1-based line of the heading
35
+ end: int # 1-based last line of the section, children included
36
+ parent: "Section" = None
37
+ children: list = field(default_factory=list)
38
+
39
+
40
+ @dataclass
41
+ class Doc:
42
+ text: str
43
+ tokens: list
44
+ sections: list # document order
45
+ warnings: list
46
+
47
+ @property
48
+ def title(self):
49
+ return self.sections[0].title if self.sections else ""
50
+
51
+
52
+ def _explicit_ids(state):
53
+ tokens = state.tokens
54
+ for i, tok in enumerate(tokens):
55
+ if tok.type != "heading_open":
56
+ continue
57
+ inline = tokens[i + 1]
58
+ m = EXPLICIT_ID_RE.search(inline.content)
59
+ if not m or not inline.children or inline.children[-1].type != "text":
60
+ continue
61
+ last = inline.children[-1]
62
+ m_last = EXPLICIT_ID_RE.search(last.content)
63
+ if not m_last:
64
+ continue
65
+ last.content = last.content[:m_last.start()]
66
+ inline.content = inline.content[:m.start()]
67
+ tok.meta["explicit_id"] = m.group(1)
68
+
69
+
70
+ def make_markdown():
71
+ md = (MarkdownIt("commonmark", {"html": False})
72
+ .enable(["table", "strikethrough"])
73
+ .use(front_matter_plugin)
74
+ # double_inline: a `$$...$$` written inside a paragraph (no blank line
75
+ # around it) is display math too, not `$` + inline math + `$`.
76
+ .use(dollarmath_plugin, double_inline=True))
77
+ md.core.ruler.after("inline", "explicit_heading_ids", _explicit_ids)
78
+ for name, display in (("math_inline", False), ("math_inline_double", True)):
79
+ md.add_render_rule(name, _math_span(display))
80
+ for name in ("math_block", "math_block_label"):
81
+ md.add_render_rule(name, _math_div)
82
+ md.add_render_rule("fence", _fence)
83
+ md.add_render_rule("link_open", _link_open)
84
+ md.add_render_rule("image", _image)
85
+ md.add_render_rule("list_item_open", _list_item_open)
86
+ return md
87
+
88
+
89
+ def slug(text):
90
+ """GitHub's heading anchor: lowercase, drop punctuation other than `-` and
91
+ `_`, spaces to `-`."""
92
+ return re.sub(r"[^\w\- ]", "", text.strip().lower()).replace(" ", "-")
93
+
94
+
95
+ def _plain(inline):
96
+ return "".join(c.content for c in inline.children or []
97
+ if c.type in ("text", "code_inline", "math_inline",
98
+ "math_inline_double")).strip()
99
+
100
+
101
+ def parse(text):
102
+ tokens = _MD.parse(text)
103
+ heads = []
104
+ for i, tok in enumerate(tokens):
105
+ if tok.type == "heading_open" and tok.level == 0 and tok.map:
106
+ heads.append((tok, tokens[i + 1]))
107
+
108
+ warnings, used = [], set()
109
+ explicit = [tok.meta.get("explicit_id") for tok, _ in heads]
110
+ for eid in filter(None, explicit):
111
+ if eid in used:
112
+ warnings.append(f"duplicate section id #{eid}")
113
+ used.add(eid)
114
+
115
+ def unique(base):
116
+ base = base or "section"
117
+ if base not in used:
118
+ used.add(base)
119
+ return base
120
+ n = 1
121
+ while f"{base}-{n}" in used:
122
+ n += 1
123
+ used.add(f"{base}-{n}")
124
+ return f"{base}-{n}"
125
+
126
+ seen_explicit = set()
127
+ n_lines = text.count("\n") + (0 if text.endswith("\n") or not text else 1)
128
+ sections, stack = [], []
129
+ for (tok, inline), eid in zip(heads, explicit):
130
+ title = _plain(inline)
131
+ if eid and eid not in seen_explicit:
132
+ seen_explicit.add(eid)
133
+ sid = eid
134
+ elif eid:
135
+ sid = unique(eid) # the first owner keeps it; this one gets a suffix
136
+ else:
137
+ sid = unique(slug(title))
138
+ level = int(tok.tag[1])
139
+ sec = Section(sid, level, title, tok.map[0] + 1, n_lines)
140
+ while stack and stack[-1].level >= level:
141
+ stack.pop().end = sec.line - 1
142
+ if stack:
143
+ sec.parent = stack[-1]
144
+ stack[-1].children.append(sec)
145
+ stack.append(sec)
146
+ sections.append(sec)
147
+ tok.meta["section"] = sec
148
+ return Doc(text, tokens, sections, warnings)
149
+
150
+
151
+ def section_at(doc, line):
152
+ """The deepest section containing 1-based `line`, or None above the first
153
+ heading."""
154
+ best = None
155
+ for sec in doc.sections:
156
+ if sec.line <= line <= sec.end:
157
+ best = sec
158
+ return best
159
+
160
+
161
+ def line_of(doc, section_id):
162
+ for sec in doc.sections:
163
+ if sec.id == section_id:
164
+ return sec.line
165
+ return None
166
+
167
+
168
+ # ------------------------------------------------------------------ render
169
+
170
+ def render(doc, rewrite=None, task_href=None):
171
+ """HTML with each section wrapped in nested `<section>` elements.
172
+
173
+ `rewrite(href)` maps a local link to its URL, or returns None for a target
174
+ the reader cannot serve. `task_href(n)` gives the link for task row `n` of
175
+ a Project.md, or None."""
176
+ # Render rules set attributes on tokens; work on a copy so a Doc can be
177
+ # rendered more than once.
178
+ tokens = copy.deepcopy(doc.tokens)
179
+ if task_href:
180
+ tokens = _link_task_rows(doc, tokens, task_href)
181
+ out, depth_levels = [], []
182
+ for tok in tokens:
183
+ sec = tok.meta.get("section") if tok.type == "heading_open" else None
184
+ if sec:
185
+ while depth_levels and depth_levels[-1] >= sec.level:
186
+ depth_levels.pop()
187
+ out.append(_html("</section>\n"))
188
+ out.append(_html(
189
+ f'<section id="{escapeHtml(sec.id)}" data-level="{sec.level}" '
190
+ f'data-line="{sec.line}" data-end="{sec.end}">\n'))
191
+ depth_levels.append(sec.level)
192
+ tok.attrSet("class", "sec-h")
193
+ out.append(tok)
194
+ out.extend(_html("</section>\n") for _ in depth_levels)
195
+ return _MD.renderer.render(out, _MD.options,
196
+ {"rewrite": rewrite or (lambda h: h)})
197
+
198
+
199
+ def _html(content):
200
+ tok = Token("html_block", "", 0)
201
+ tok.content = content
202
+ return tok
203
+
204
+
205
+ def _link_task_rows(doc, tokens, task_href):
206
+ lines = doc.text.split("\n")
207
+ out = list(tokens)
208
+ i = 0
209
+ while i < len(out):
210
+ tok = out[i]
211
+ if tok.type == "list_item_open" and tok.map:
212
+ m = TASK_RE.match(lines[tok.map[0]].lstrip())
213
+ href = m and task_href(int(m.group(1)))
214
+ if href:
215
+ for j in range(i + 1, len(out)):
216
+ if out[j].type == "inline":
217
+ link_open = Token("link_open", "a", 1)
218
+ link_open.attrSet("href", href)
219
+ link_open.attrSet("class", "task-link")
220
+ inline = out[j].copy()
221
+ inline.children = ([link_open] + list(inline.children or [])
222
+ + [Token("link_close", "a", -1)])
223
+ out[j] = inline
224
+ break
225
+ if out[j].type == "list_item_close":
226
+ break
227
+ i += 1
228
+ return out
229
+
230
+
231
+ def _math_span(display):
232
+ def rule(self, tokens, idx, options, env):
233
+ return (f'<span class="math" data-display="{int(display)}">'
234
+ f"{escapeHtml(tokens[idx].content)}</span>")
235
+ return rule
236
+
237
+
238
+ def _math_div(self, tokens, idx, options, env):
239
+ return (f'<div class="math" data-display="1">'
240
+ f"{escapeHtml(tokens[idx].content)}</div>\n")
241
+
242
+
243
+ def _fence(self, tokens, idx, options, env):
244
+ tok = tokens[idx]
245
+ if tok.info.strip().split(" ")[0] == "mermaid":
246
+ return f'<pre class="mermaid">{escapeHtml(tok.content)}</pre>\n'
247
+ return RendererHTML.fence(self, tokens, idx, options, env)
248
+
249
+
250
+ def _link_open(self, tokens, idx, options, env):
251
+ tok = tokens[idx]
252
+ href = tok.attrGet("href")
253
+ if href is not None:
254
+ new = env["rewrite"](href)
255
+ if new is None:
256
+ tok.attrs.pop("href", None)
257
+ tok.attrSet("class", "unreachable")
258
+ tok.attrSet("title", f"{href} (not under a served root)")
259
+ else:
260
+ tok.attrSet("href", new)
261
+ return self.renderToken(tokens, idx, options, env)
262
+
263
+
264
+ def _image(self, tokens, idx, options, env):
265
+ tok = tokens[idx]
266
+ src = env["rewrite"](tok.attrGet("src") or "")
267
+ tok.attrSet("src", src or "")
268
+ return RendererHTML.image(self, tokens, idx, options, env)
269
+
270
+
271
+ def _list_item_open(self, tokens, idx, options, env):
272
+ tok = tokens[idx]
273
+ # Keep the source number: markdown renumbers an ordered list from its
274
+ # first item, which would show Project.md's sparse task numbers wrong.
275
+ if tok.info and tok.info.isdigit():
276
+ tok.attrSet("value", tok.info)
277
+ return self.renderToken(tokens, idx, options, env)
278
+
279
+
280
+ # After the render rules it refers to.
281
+ _MD = make_markdown()
@@ -0,0 +1,43 @@
1
+ -- doc-reader: <leader>h prints the reader URL of the section under the cursor.
2
+ -- The section is found by `docread url --stdin` from the buffer's text, so
3
+ -- unsaved edits count and the editor and the page share one parser.
4
+ local M = {}
5
+
6
+ M.cmd = { "docread" }
7
+
8
+ function M.url()
9
+ local path = vim.api.nvim_buf_get_name(0)
10
+ if path == "" then
11
+ vim.notify("doc-reader: buffer has no file", vim.log.levels.WARN)
12
+ return
13
+ end
14
+ local line = vim.api.nvim_win_get_cursor(0)[1]
15
+ local text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n")
16
+ local argv = vim.list_extend(vim.deepcopy(M.cmd), { "url", "--stdin", path, tostring(line) })
17
+ local out = vim.fn.system(argv, text)
18
+ if vim.v.shell_error ~= 0 then
19
+ vim.notify("doc-reader: " .. vim.trim(out), vim.log.levels.WARN)
20
+ return
21
+ end
22
+ local chunks = { { vim.trim(out) } }
23
+ if vim.bo.modified then
24
+ table.insert(chunks, { " (unsaved: the page may differ)", "Comment" })
25
+ end
26
+ vim.api.nvim_echo(chunks, false, {})
27
+ return chunks[1][1]
28
+ end
29
+
30
+ function M.setup(opts)
31
+ opts = opts or {}
32
+ M.cmd = opts.cmd or M.cmd
33
+ local keymap = opts.keymap or "<leader>h"
34
+ vim.api.nvim_create_autocmd("FileType", {
35
+ pattern = "markdown",
36
+ group = vim.api.nvim_create_augroup("doc_reader", { clear = true }),
37
+ callback = function(ev)
38
+ vim.keymap.set("n", keymap, M.url, { buffer = ev.buf, desc = "doc-reader: section URL" })
39
+ end,
40
+ })
41
+ end
42
+
43
+ return M