mdmint 0.2.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.
- mdmint/__init__.py +18 -0
- mdmint/__main__.py +6 -0
- mdmint/admonitions.py +70 -0
- mdmint/assets.py +84 -0
- mdmint/backends.py +244 -0
- mdmint/cli.py +164 -0
- mdmint/core.py +425 -0
- mdmint/docxtemplate.py +1250 -0
- mdmint/mathrender.py +114 -0
- mdmint/mermaid.py +149 -0
- mdmint/mkdocs.py +310 -0
- mdmint/render.py +343 -0
- mdmint/resources/default.css +122 -0
- mdmint-0.2.0.dist-info/METADATA +196 -0
- mdmint-0.2.0.dist-info/RECORD +20 -0
- mdmint-0.2.0.dist-info/WHEEL +5 -0
- mdmint-0.2.0.dist-info/entry_points.txt +2 -0
- mdmint-0.2.0.dist-info/licenses/LICENSE +201 -0
- mdmint-0.2.0.dist-info/licenses/NOTICE +5 -0
- mdmint-0.2.0.dist-info/top_level.txt +1 -0
mdmint/mathrender.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""LaTeX math rendering — optional, with graceful fallback.
|
|
2
|
+
|
|
3
|
+
Static output formats (PDF/DOCX) cannot run MathJax, so math has to become an
|
|
4
|
+
image. If ``matplotlib`` is installed we render each expression with its built-in
|
|
5
|
+
``mathtext`` engine (a sizeable LaTeX-math subset, no system LaTeX required) to a
|
|
6
|
+
transparent PNG. If matplotlib is absent, or an expression isn't supported, we fall
|
|
7
|
+
back to the literal source text and warn once.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import html
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("mdmint")
|
|
21
|
+
|
|
22
|
+
_DPI = 200 # render resolution; combined with pixel size to derive on-page pt size
|
|
23
|
+
_MATPLOTLIB: ModuleType | Literal[False] | None = None # None = not probed yet
|
|
24
|
+
_WARNED = False
|
|
25
|
+
_cache: dict[str, str] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_matplotlib() -> ModuleType | None:
|
|
29
|
+
global _MATPLOTLIB
|
|
30
|
+
if _MATPLOTLIB is None:
|
|
31
|
+
try:
|
|
32
|
+
import matplotlib
|
|
33
|
+
|
|
34
|
+
matplotlib.use("Agg") # headless backend, no display needed
|
|
35
|
+
_MATPLOTLIB = matplotlib
|
|
36
|
+
except Exception: # pragma: no cover - depends on environment
|
|
37
|
+
_MATPLOTLIB = False
|
|
38
|
+
return _MATPLOTLIB or None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _warn_missing() -> None:
|
|
42
|
+
global _WARNED
|
|
43
|
+
if not _WARNED:
|
|
44
|
+
log.warning(
|
|
45
|
+
"math: matplotlib not installed; rendering math as literal text. "
|
|
46
|
+
"Install with 'pip install mdmint[math]' for rendered equations."
|
|
47
|
+
)
|
|
48
|
+
_WARNED = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fallback(expr: str, block: bool) -> str:
|
|
52
|
+
escaped = html.escape(expr)
|
|
53
|
+
if block:
|
|
54
|
+
return f'<p class="math-block-fallback"><code>$$ {escaped} $$</code></p>'
|
|
55
|
+
return f'<code class="math-inline-fallback">${escaped}$</code>'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render(expr: str, block: bool, work_dir: Path) -> str:
|
|
59
|
+
"""Return an HTML snippet (an ``<img>`` or literal fallback) for one expression."""
|
|
60
|
+
mpl = _get_matplotlib()
|
|
61
|
+
if mpl is None:
|
|
62
|
+
_warn_missing()
|
|
63
|
+
return _fallback(expr, block)
|
|
64
|
+
|
|
65
|
+
key = ("B:" if block else "I:") + expr
|
|
66
|
+
if key in _cache:
|
|
67
|
+
return _cache[key]
|
|
68
|
+
|
|
69
|
+
digest = hashlib.sha1(key.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
|
|
70
|
+
out_path = work_dir / f"math-{digest}.png"
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
74
|
+
from matplotlib.figure import Figure
|
|
75
|
+
|
|
76
|
+
fig = Figure(figsize=(0.1, 0.1))
|
|
77
|
+
FigureCanvasAgg(fig)
|
|
78
|
+
# mathtext expects the expression wrapped in $...$.
|
|
79
|
+
fig.text(0, 0, f"${expr}$", fontsize=14)
|
|
80
|
+
fig.savefig(
|
|
81
|
+
out_path,
|
|
82
|
+
dpi=_DPI,
|
|
83
|
+
format="png",
|
|
84
|
+
bbox_inches="tight",
|
|
85
|
+
pad_inches=0.02,
|
|
86
|
+
transparent=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Derive on-page size in points so the image matches surrounding text.
|
|
90
|
+
from PIL import Image # Pillow ships as a matplotlib dependency
|
|
91
|
+
|
|
92
|
+
with Image.open(out_path) as im:
|
|
93
|
+
w_px, h_px = im.size
|
|
94
|
+
h_pt = h_px / _DPI * 72.0
|
|
95
|
+
w_pt = w_px / _DPI * 72.0
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
log.warning("math: could not render %r (%s); using literal text.", expr, type(exc).__name__)
|
|
98
|
+
result = _fallback(expr, block)
|
|
99
|
+
_cache[key] = result
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
src = html.escape(os.path.abspath(out_path))
|
|
103
|
+
if block:
|
|
104
|
+
snippet = (
|
|
105
|
+
f'<p class="math-block"><img class="math-block" src="{src}" '
|
|
106
|
+
f'alt="{html.escape(expr)}" style="height:{h_pt:.1f}pt" /></p>'
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
snippet = (
|
|
110
|
+
f'<img class="math-inline" src="{src}" alt="{html.escape(expr)}" '
|
|
111
|
+
f'style="height:{h_pt:.1f}pt;width:{w_pt:.1f}pt" />'
|
|
112
|
+
)
|
|
113
|
+
_cache[key] = snippet
|
|
114
|
+
return snippet
|
mdmint/mermaid.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Mermaid diagram rendering — optional, with graceful fallback.
|
|
2
|
+
|
|
3
|
+
There is no pure-Python Mermaid renderer. If the mermaid CLI (``mmdc``, from
|
|
4
|
+
``@mermaid-js/mermaid-cli``) happens to be installed and on PATH, we shell out to
|
|
5
|
+
it and embed the resulting image. Otherwise we emit the diagram source as a styled
|
|
6
|
+
code block and warn once. ``mmdc`` is never required to install or use mdmint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import html
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import struct
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("mdmint")
|
|
22
|
+
|
|
23
|
+
# Cached result of the PATH lookup (False once we know it's absent).
|
|
24
|
+
_MMDC_PATH: str | Literal[False] | None = None
|
|
25
|
+
_WARNED = False
|
|
26
|
+
|
|
27
|
+
# mmdc emits PNGs at CSS-pixel dimensions; place them on the page treating those
|
|
28
|
+
# pixels as 96 DPI (xhtml2pdf's default image DPI).
|
|
29
|
+
_PNG_DPI = 96.0
|
|
30
|
+
# Cap diagram width to the printable page width so wide diagrams don't overflow the
|
|
31
|
+
# margin. A4 (210mm) minus the 2cm side margins set in resources/default.css.
|
|
32
|
+
_MAX_WIDTH_PT = (210.0 - 2 * 20.0) / 25.4 * 72.0 # ~481.9pt
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_mmdc() -> str | None:
|
|
36
|
+
"""Locate the mermaid CLI on PATH, caching the result for the process."""
|
|
37
|
+
global _MMDC_PATH
|
|
38
|
+
if _MMDC_PATH is None:
|
|
39
|
+
# On Windows the executable is usually ``mmdc.cmd``; shutil.which handles
|
|
40
|
+
# the extension resolution via PATHEXT.
|
|
41
|
+
_MMDC_PATH = shutil.which("mmdc") or False
|
|
42
|
+
return _MMDC_PATH or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_snap_confined() -> bool:
|
|
46
|
+
"""True if the available mmdc is a snap package.
|
|
47
|
+
|
|
48
|
+
Snap strict confinement cannot read ``/tmp`` or hidden dot-directories, so when
|
|
49
|
+
rendering with a snap mmdc the work directory must live in a non-hidden path
|
|
50
|
+
under the user's home (handled by :func:`mdmint.core._make_work_dir`).
|
|
51
|
+
"""
|
|
52
|
+
mmdc = _find_mmdc()
|
|
53
|
+
return bool(mmdc and "/snap/" in mmdc.replace("\\", "/"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _codeblock(source: str) -> str:
|
|
57
|
+
"""Render the diagram source as a styled, escaped code block (no logging)."""
|
|
58
|
+
return f'<pre class="mermaid"><code>{html.escape(source)}</code></pre>'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _png_size_pt(path: Path) -> tuple[float, float] | None:
|
|
62
|
+
"""Return a rendered PNG's on-page (width, height) in points, capped to the page
|
|
63
|
+
width and preserving aspect ratio. ``None`` if the size can't be read.
|
|
64
|
+
|
|
65
|
+
Reads the dimensions straight from the PNG IHDR header (no image library), so it
|
|
66
|
+
works in the pure-Python core install.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
with open(path, "rb") as fh:
|
|
70
|
+
header = fh.read(24)
|
|
71
|
+
except OSError:
|
|
72
|
+
return None
|
|
73
|
+
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
|
|
74
|
+
return None
|
|
75
|
+
w_px, h_px = struct.unpack(">II", header[16:24])
|
|
76
|
+
if not w_px or not h_px:
|
|
77
|
+
return None
|
|
78
|
+
w_pt = w_px * 72.0 / _PNG_DPI
|
|
79
|
+
h_pt = h_px * 72.0 / _PNG_DPI
|
|
80
|
+
if w_pt > _MAX_WIDTH_PT: # scale down to fit; never upscale small diagrams
|
|
81
|
+
h_pt *= _MAX_WIDTH_PT / w_pt
|
|
82
|
+
w_pt = _MAX_WIDTH_PT
|
|
83
|
+
return w_pt, h_pt
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _warn_once(message: str) -> None:
|
|
87
|
+
global _WARNED
|
|
88
|
+
if not _WARNED:
|
|
89
|
+
log.warning(message)
|
|
90
|
+
_WARNED = True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render(source: str, work_dir: Path) -> str:
|
|
94
|
+
"""Return an HTML snippet for a single mermaid diagram.
|
|
95
|
+
|
|
96
|
+
If ``mmdc`` is available, render ``source`` to a PNG inside ``work_dir`` and
|
|
97
|
+
return an ``<img>`` tag pointing at the absolute path. Otherwise fall back to a
|
|
98
|
+
code block. Rendering failures also fall back, so a bad diagram never aborts the
|
|
99
|
+
whole conversion.
|
|
100
|
+
"""
|
|
101
|
+
mmdc = _find_mmdc()
|
|
102
|
+
if not mmdc:
|
|
103
|
+
_warn_once(
|
|
104
|
+
"mermaid: no local renderer found (mmdc not on PATH); rendering "
|
|
105
|
+
"diagrams as code blocks. Install '@mermaid-js/mermaid-cli' for real "
|
|
106
|
+
"diagrams."
|
|
107
|
+
)
|
|
108
|
+
return _codeblock(source)
|
|
109
|
+
|
|
110
|
+
digest = hashlib.sha1(source.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
|
|
111
|
+
out_path = work_dir / f"mermaid-{digest}.png"
|
|
112
|
+
|
|
113
|
+
if not out_path.exists():
|
|
114
|
+
in_path = work_dir / f"mermaid-{digest}.mmd"
|
|
115
|
+
in_path.write_text(source, encoding="utf-8")
|
|
116
|
+
cmd = [
|
|
117
|
+
mmdc,
|
|
118
|
+
"-i",
|
|
119
|
+
str(in_path),
|
|
120
|
+
"-o",
|
|
121
|
+
str(out_path),
|
|
122
|
+
"-b",
|
|
123
|
+
"transparent",
|
|
124
|
+
]
|
|
125
|
+
try:
|
|
126
|
+
subprocess.run( # noqa: S603 - argv is the mmdc path and our own temp files
|
|
127
|
+
cmd,
|
|
128
|
+
check=True,
|
|
129
|
+
stdout=subprocess.PIPE,
|
|
130
|
+
stderr=subprocess.PIPE,
|
|
131
|
+
timeout=60,
|
|
132
|
+
)
|
|
133
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
134
|
+
detail = getattr(exc, "stderr", b"")
|
|
135
|
+
if isinstance(detail, bytes):
|
|
136
|
+
detail = detail.decode("utf-8", "replace")
|
|
137
|
+
_warn_once(
|
|
138
|
+
"mermaid: rendering failed (%s); falling back to code block. %s"
|
|
139
|
+
% (type(exc).__name__, detail.strip())
|
|
140
|
+
)
|
|
141
|
+
return _codeblock(source)
|
|
142
|
+
|
|
143
|
+
src = os.path.abspath(out_path)
|
|
144
|
+
size = _png_size_pt(out_path)
|
|
145
|
+
style = f' style="width:{size[0]:.1f}pt;height:{size[1]:.1f}pt"' if size else ""
|
|
146
|
+
return (
|
|
147
|
+
f'<p class="mermaid-diagram"><img src="{html.escape(src)}"{style} '
|
|
148
|
+
'alt="mermaid diagram" /></p>'
|
|
149
|
+
)
|
mdmint/mkdocs.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Build one document from an mkdocs project: its ``nav`` decides what goes in and
|
|
2
|
+
in which order, and its section nesting decides the heading structure.
|
|
3
|
+
|
|
4
|
+
Each page is rendered through the normal pipeline, then stitched together here:
|
|
5
|
+
|
|
6
|
+
* **Sections** become headings. With ``section_style="part"`` (default) a
|
|
7
|
+
top-level section is a *part* divider (``<h1 class="part">``) that does not use
|
|
8
|
+
up a heading level, so its pages keep their own ``#`` as chapters; nested
|
|
9
|
+
sections become ``h1``, ``h2``… and demote their pages accordingly. With
|
|
10
|
+
``section_style="heading"`` every section is a plain heading one level above
|
|
11
|
+
its pages.
|
|
12
|
+
* **Appendices** — pages under a section titled *Annex(es)* / *Appendix* /
|
|
13
|
+
*Appendices* get their chapter heading marked ``class="appendix"``.
|
|
14
|
+
* **Links** between pages (``../other/index.md#anchor``) are rewritten to
|
|
15
|
+
in-document anchors; links to pages left out of the nav are unwrapped to text.
|
|
16
|
+
* **Ids** are namespaced per page (``p3-…``) so headings and footnotes from
|
|
17
|
+
different pages never collide.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import html
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
30
|
+
from urllib.parse import unquote
|
|
31
|
+
|
|
32
|
+
from . import render
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("mdmint")
|
|
38
|
+
|
|
39
|
+
SECTION_STYLES = ("part", "heading")
|
|
40
|
+
_APPENDIX_TITLE = re.compile(r"^\s*(annex(es)?|appendix|appendices)\b", re.IGNORECASE)
|
|
41
|
+
_ANCHOR = re.compile(r'<a\b([^>]*?)\bhref="([^"]*)"([^>]*)>(.*?)</a>', re.DOTALL)
|
|
42
|
+
_URL_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
|
|
43
|
+
# Keys of ``extra.mdmint`` that configure mdmint rather than fill template placeholders.
|
|
44
|
+
_OPTION_KEYS = ("template", "section_style", "styles")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NavEntry:
|
|
49
|
+
kind: str # "section" or "page"
|
|
50
|
+
title: str | None
|
|
51
|
+
depth: int # number of enclosing sections
|
|
52
|
+
path: Path | None = None # pages only (absolute)
|
|
53
|
+
appendix: bool = False # inside an annex/appendix section
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Project:
|
|
58
|
+
config_path: Path
|
|
59
|
+
docs_dir: Path
|
|
60
|
+
site_name: str
|
|
61
|
+
entries: list[NavEntry]
|
|
62
|
+
meta: dict[str, str] = field(default_factory=dict)
|
|
63
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ config
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _yaml_loader() -> type[yaml.SafeLoader]:
|
|
70
|
+
"""A SafeLoader that tolerates mkdocs' custom tags instead of failing on them.
|
|
71
|
+
|
|
72
|
+
mkdocs.yml routinely contains ``!!python/name:…`` (pymdownx fences) and
|
|
73
|
+
``!ENV`` / ``!relative`` tags; we never need their values, only the nav.
|
|
74
|
+
"""
|
|
75
|
+
import yaml
|
|
76
|
+
|
|
77
|
+
class Loader(yaml.SafeLoader):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def plain(loader: yaml.SafeLoader, _suffix: str, node: yaml.Node) -> Any:
|
|
81
|
+
if isinstance(node, yaml.ScalarNode):
|
|
82
|
+
return loader.construct_scalar(node)
|
|
83
|
+
if isinstance(node, yaml.SequenceNode):
|
|
84
|
+
return loader.construct_sequence(node)
|
|
85
|
+
return loader.construct_mapping(cast("yaml.MappingNode", node))
|
|
86
|
+
|
|
87
|
+
Loader.add_multi_constructor("tag:yaml.org,2002:python/", lambda *a: None)
|
|
88
|
+
Loader.add_multi_constructor("!", plain)
|
|
89
|
+
return Loader
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load(config_path: str | os.PathLike[str]) -> Project:
|
|
93
|
+
"""Read an mkdocs.yml and resolve its nav into an ordered list of entries."""
|
|
94
|
+
import yaml
|
|
95
|
+
|
|
96
|
+
config_path = Path(config_path).resolve()
|
|
97
|
+
if not config_path.is_file():
|
|
98
|
+
raise FileNotFoundError(f"mkdocs config not found: {config_path}")
|
|
99
|
+
try:
|
|
100
|
+
text = config_path.read_text(encoding="utf-8")
|
|
101
|
+
cfg = yaml.load(text, Loader=_yaml_loader()) or {} # noqa: S506 (a SafeLoader)
|
|
102
|
+
except yaml.YAMLError as exc:
|
|
103
|
+
raise ValueError(f"{config_path}: invalid YAML — {exc}") from exc
|
|
104
|
+
if not isinstance(cfg, dict):
|
|
105
|
+
raise ValueError(f"{config_path}: expected a mapping at the top level")
|
|
106
|
+
|
|
107
|
+
docs_dir = (config_path.parent / (cfg.get("docs_dir") or "docs")).resolve()
|
|
108
|
+
if not docs_dir.is_dir():
|
|
109
|
+
raise FileNotFoundError(f"docs_dir not found: {docs_dir}")
|
|
110
|
+
|
|
111
|
+
entries: list[NavEntry] = []
|
|
112
|
+
if cfg.get("nav"):
|
|
113
|
+
_walk_nav(cfg["nav"], docs_dir, 0, False, entries)
|
|
114
|
+
else:
|
|
115
|
+
log.info("mkdocs: no nav in %s; using the docs_dir file tree", config_path.name)
|
|
116
|
+
_walk_tree(docs_dir, 0, entries)
|
|
117
|
+
if not any(e.kind == "page" for e in entries):
|
|
118
|
+
raise ValueError(f"{config_path}: nav contains no Markdown pages")
|
|
119
|
+
|
|
120
|
+
site_name = str(cfg.get("site_name") or config_path.parent.name)
|
|
121
|
+
extra = (cfg.get("extra") or {}).get("mdmint") or {}
|
|
122
|
+
if not isinstance(extra, dict):
|
|
123
|
+
extra = {}
|
|
124
|
+
meta = {"title": site_name}
|
|
125
|
+
if cfg.get("site_description"):
|
|
126
|
+
meta["subtitle"] = str(cfg["site_description"])
|
|
127
|
+
if cfg.get("site_author"):
|
|
128
|
+
meta["author"] = str(cfg["site_author"])
|
|
129
|
+
meta.update(
|
|
130
|
+
{
|
|
131
|
+
str(k).lower(): str(v)
|
|
132
|
+
for k, v in extra.items()
|
|
133
|
+
if k not in _OPTION_KEYS and v is not None
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
options = {k: extra[k] for k in _OPTION_KEYS if k in extra}
|
|
137
|
+
if "template" in options:
|
|
138
|
+
options["template"] = (config_path.parent / str(options["template"])).resolve()
|
|
139
|
+
return Project(config_path, docs_dir, site_name, entries, meta, options)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _walk_nav(items: Any, docs_dir: Path, depth: int, appendix: bool, out: list[NavEntry]) -> None:
|
|
143
|
+
if isinstance(items, (str, dict)):
|
|
144
|
+
items = [items]
|
|
145
|
+
for item in items or []:
|
|
146
|
+
if isinstance(item, str):
|
|
147
|
+
_add_page(None, item, docs_dir, depth, appendix, out)
|
|
148
|
+
elif isinstance(item, dict):
|
|
149
|
+
for title, value in item.items():
|
|
150
|
+
title = None if title is None else str(title)
|
|
151
|
+
if isinstance(value, list):
|
|
152
|
+
in_appendix = appendix or bool(title and _APPENDIX_TITLE.match(title))
|
|
153
|
+
section = NavEntry("section", title, depth, appendix=in_appendix)
|
|
154
|
+
start = len(out)
|
|
155
|
+
out.append(section)
|
|
156
|
+
_walk_nav(value, docs_dir, depth + 1, in_appendix, out)
|
|
157
|
+
if not any(e.kind == "page" for e in out[start + 1 :]):
|
|
158
|
+
del out[start:] # drop sections with nothing to show
|
|
159
|
+
elif isinstance(value, str):
|
|
160
|
+
_add_page(title, value, docs_dir, depth, appendix, out)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _add_page(
|
|
164
|
+
title: str | None,
|
|
165
|
+
ref: str,
|
|
166
|
+
docs_dir: Path,
|
|
167
|
+
depth: int,
|
|
168
|
+
appendix: bool,
|
|
169
|
+
out: list[NavEntry],
|
|
170
|
+
) -> None:
|
|
171
|
+
if _URL_SCHEME.match(ref) or ref.startswith("//"):
|
|
172
|
+
log.info("mkdocs: skipping external nav link %s", ref)
|
|
173
|
+
return
|
|
174
|
+
path = (docs_dir / ref).resolve()
|
|
175
|
+
if path.is_dir():
|
|
176
|
+
path = next((path / n for n in ("index.md", "README.md") if (path / n).is_file()), path)
|
|
177
|
+
if not path.is_file():
|
|
178
|
+
log.warning("mkdocs: nav entry not found, skipping: %s", ref)
|
|
179
|
+
return
|
|
180
|
+
if path.suffix.lower() not in (".md", ".markdown"):
|
|
181
|
+
log.info("mkdocs: skipping non-Markdown nav entry %s", ref)
|
|
182
|
+
return
|
|
183
|
+
out.append(NavEntry("page", title, depth, path, appendix))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _walk_tree(directory: Path, depth: int, out: list[NavEntry]) -> None:
|
|
187
|
+
"""Implicit mkdocs nav: index/README first, then files and dirs by name."""
|
|
188
|
+
|
|
189
|
+
def key(p: Path) -> tuple[bool, str]:
|
|
190
|
+
return (p.stem.lower() not in ("index", "readme"), p.name.lower())
|
|
191
|
+
|
|
192
|
+
for child in sorted(directory.iterdir(), key=key):
|
|
193
|
+
if child.name.startswith("."):
|
|
194
|
+
continue
|
|
195
|
+
if child.is_dir():
|
|
196
|
+
title = child.name.replace("-", " ").replace("_", " ").strip().capitalize()
|
|
197
|
+
start = len(out)
|
|
198
|
+
out.append(NavEntry("section", title, depth))
|
|
199
|
+
_walk_tree(child, depth + 1, out)
|
|
200
|
+
if not any(e.kind == "page" for e in out[start + 1 :]):
|
|
201
|
+
del out[start:]
|
|
202
|
+
elif child.suffix.lower() in (".md", ".markdown"):
|
|
203
|
+
out.append(NavEntry("page", None, depth, child.resolve()))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------- assembly
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _levels(entry: NavEntry, section_style: str) -> int:
|
|
210
|
+
"""Section: its heading level (0 = part). Page: how far to demote it."""
|
|
211
|
+
if section_style == "heading":
|
|
212
|
+
return entry.depth + 1 if entry.kind == "section" else entry.depth
|
|
213
|
+
if entry.kind == "section":
|
|
214
|
+
return entry.depth # 0 → part divider, 1 → h1, …
|
|
215
|
+
return max(0, entry.depth - 1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def assemble(
|
|
219
|
+
project: Project, render_page: Callable[[Path], str], *, section_style: str = "part"
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Render every nav page with ``render_page(path) -> html`` and join them.
|
|
222
|
+
|
|
223
|
+
Returns the combined HTML fragment (no TOC; the caller adds one if wanted).
|
|
224
|
+
"""
|
|
225
|
+
if section_style not in SECTION_STYLES:
|
|
226
|
+
raise ValueError(f"Unknown section_style {section_style!r}; use one of {SECTION_STYLES}")
|
|
227
|
+
|
|
228
|
+
# Pass 1: render pages, namespace their ids, fix up heading levels.
|
|
229
|
+
prefixes: dict[Path, str] = {}
|
|
230
|
+
first_ids: dict[Path, str] = {}
|
|
231
|
+
blocks: list[tuple[Path | None, str]] = [] # (page path, None for a section; html)
|
|
232
|
+
for idx, entry in enumerate(project.entries):
|
|
233
|
+
if entry.kind == "section":
|
|
234
|
+
level = _levels(entry, section_style)
|
|
235
|
+
cls = ' class="part"' if level == 0 else ""
|
|
236
|
+
anchor = f"s{idx}-{render.slugify(entry.title or 'section')}"
|
|
237
|
+
label = html.escape(entry.title or "")
|
|
238
|
+
blocks.append(
|
|
239
|
+
(None, f'<h{max(level, 1)} id="{anchor}"{cls}>{label}</h{max(level, 1)}>\n')
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
path = entry.path
|
|
244
|
+
if path is None: # pragma: no cover - every page entry has a path
|
|
245
|
+
continue
|
|
246
|
+
prefix = f"p{idx}-"
|
|
247
|
+
prefixes.setdefault(path, prefix)
|
|
248
|
+
body = render_page(path)
|
|
249
|
+
if not re.search(r"<h1\b", body):
|
|
250
|
+
# No H1 of its own: use the nav title (or file name) as the page title.
|
|
251
|
+
title = entry.title or path.stem.replace("-", " ").replace("_", " ").capitalize()
|
|
252
|
+
body = f'<h1 id="{render.slugify(title)}">{html.escape(title)}</h1>\n' + body
|
|
253
|
+
body = render.namespace_ids(body, prefix)
|
|
254
|
+
offset = _levels(entry, section_style)
|
|
255
|
+
if entry.appendix and offset == 0:
|
|
256
|
+
body = _mark_first_h1(body, "appendix")
|
|
257
|
+
body = render.shift_headings(body, offset)
|
|
258
|
+
heads = render.headings_from_html(body)
|
|
259
|
+
first_id = heads[0][2] if heads else None
|
|
260
|
+
if first_id:
|
|
261
|
+
first_ids.setdefault(path, first_id)
|
|
262
|
+
blocks.append((path, body))
|
|
263
|
+
|
|
264
|
+
# Pass 2: rewrite links between pages now that every target id is known.
|
|
265
|
+
parts = []
|
|
266
|
+
for page, body in blocks:
|
|
267
|
+
if page is not None:
|
|
268
|
+
body = _rewrite_links(body, page, prefixes, first_ids)
|
|
269
|
+
parts.append(body)
|
|
270
|
+
return "\n".join(parts)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _mark_first_h1(body: str, cls: str) -> str:
|
|
274
|
+
def repl(m: re.Match[str]) -> str:
|
|
275
|
+
attrs = m.group(1)
|
|
276
|
+
if 'class="' in attrs:
|
|
277
|
+
attrs = attrs.replace('class="', f'class="{cls} ', 1)
|
|
278
|
+
else:
|
|
279
|
+
attrs += f' class="{cls}"'
|
|
280
|
+
return f"<h1{attrs}>"
|
|
281
|
+
|
|
282
|
+
return re.sub(r"<h1\b([^>]*)>", repl, body, count=1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _rewrite_links(
|
|
286
|
+
body: str, page: Path, prefixes: dict[Path, str], first_ids: dict[Path, str]
|
|
287
|
+
) -> str:
|
|
288
|
+
def repl(m: re.Match[str]) -> str:
|
|
289
|
+
before, href, after, inner = m.groups()
|
|
290
|
+
if not href or href.startswith("#") or _URL_SCHEME.match(href) or href.startswith("//"):
|
|
291
|
+
return m.group(0)
|
|
292
|
+
target, _, frag = html.unescape(href).partition("#")
|
|
293
|
+
target_path = (page.parent / unquote(target)).resolve() if target else page
|
|
294
|
+
if target_path.is_dir():
|
|
295
|
+
target_path = target_path / "index.md"
|
|
296
|
+
if target_path.suffix.lower() not in (".md", ".markdown"):
|
|
297
|
+
return m.group(0) # a relative link to some other file; leave it
|
|
298
|
+
if target_path not in prefixes:
|
|
299
|
+
log.info(
|
|
300
|
+
"mkdocs: %s links to %s, which is not in the nav; keeping text only",
|
|
301
|
+
page.name,
|
|
302
|
+
target,
|
|
303
|
+
)
|
|
304
|
+
return inner
|
|
305
|
+
anchor = f"{prefixes[target_path]}{frag}" if frag else first_ids.get(target_path)
|
|
306
|
+
if not anchor:
|
|
307
|
+
return inner
|
|
308
|
+
return f'<a{before}href="#{html.escape(anchor)}"{after}>{inner}</a>'
|
|
309
|
+
|
|
310
|
+
return _ANCHOR.sub(repl, body)
|