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/__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
|