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