Files
2026-07-13 13:13:17 +08:00

227 lines
8.3 KiB
Python

"""MkDocs hook that turns `[OperatorName]` in prose into links to the operator reference.
Loads `.help/docs/operators/index.json` (written by `ExportWikiDocumentation.cs`) once at
build start, then rewrites bracketed operator references in every page's markdown.
Resolution rules (see `.agentic/Plans/Plan_UpdateHelp.md`, Section 4c):
- `[Lib.image.color.AdjustColors]` — full path → link to that operator.
- `[lib.image.color.AdjustColors]` — lowercase-prefix tolerant → normalised and resolved.
- `[AdjustColors]` — short name with exactly one match → linked.
- `[Value]` — short name with more than one match → left as-is, with a build warning.
- Anything else → left as-is (likely prose, not an operator).
Never touches:
- Fenced code blocks (``` … ```) or indented code blocks.
- Inline code spans (`…`).
- Existing markdown links (`[Foo](…)` — detected by the trailing `(`).
- Reference-style link definitions (`[Foo]: …` at column 0).
Silent unless there's something actionable to report, so we don't drown the build log.
"""
from __future__ import annotations
import json
import logging
import posixpath
import re
from pathlib import Path
from typing import Any
log = logging.getLogger("mkdocs.plugins.op_autolinks")
# `[Name]` or `[Some.Path.Name]` — bracketed identifier, not followed by `(` or `:`.
# Requires the inside to start with a letter and contain only [A-Za-z0-9.]; spaces rule it out,
# so English phrases in brackets (e.g. `[some note]`) are ignored.
_BRACKET_RE = re.compile(
r"(?<!\\)\[([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)*)\](?![\(\[:])"
)
_FENCE_RE = re.compile(r"^(\s*)(```+|~~~+)")
_INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
_index: dict[str, Any] | None = None
_index_error_reported = False
def _load_index(config) -> dict[str, Any] | None:
global _index, _index_error_reported
if _index is not None:
return _index
docs_dir = Path(config["docs_dir"])
index_path = docs_dir / "operators" / "index.json"
if not index_path.is_file():
if not _index_error_reported:
log.info(
"op_autolinks: %s not found — operator autolinks disabled. "
"Run the 'Documentation → Export as WIKI' menu in TiXL to generate it.",
index_path,
)
_index_error_reported = True
return None
try:
with index_path.open(encoding="utf-8") as f:
_index = json.load(f)
except Exception as exc: # noqa: BLE001 — surface any load failure to the user
log.warning("op_autolinks: failed to load %s: %s", index_path, exc)
_index_error_reported = True
return None
by_fullpath = _index.get("by_fullpath", {})
by_shortname = _index.get("by_shortname", {})
log.info(
"op_autolinks: loaded %d operators (%d short names) from %s",
len(by_fullpath),
len(by_shortname),
index_path,
)
return _index
def _resolve(name: str, index: dict[str, Any]) -> tuple[str | None, list[str]]:
"""Return (fullpath, candidates). fullpath is None if unresolved or ambiguous."""
by_fullpath: dict[str, dict[str, str]] = index.get("by_fullpath", {})
by_shortname: dict[str, list[str]] = index.get("by_shortname", {})
if "." in name:
# Normalise a lowercase or mixed-case dotted prefix back to the canonical form we
# stored in `by_fullpath`. The canonical form starts with a PascalCase root segment
# (`Lib.…`) while intermediate segments are lowercased already. A fully-lowercased
# input like `lib.image.color.AdjustColors` should still resolve.
candidates = [
full for full in by_fullpath if full.lower() == name.lower()
]
if len(candidates) == 1:
return candidates[0], candidates
return None, candidates
matches = by_shortname.get(name, [])
if len(matches) == 1:
return matches[0], matches
return None, matches
def _fullpath_to_docs_relpath(fullpath: str) -> str:
"""`Lib.io.audio.AudioReaction` → `operators/lib/io/audio/AudioReaction.md` (docs_dir-relative)."""
ns, _, op = fullpath.rpartition(".")
ns_dir = ns.lower().replace(".", "/")
return f"operators/{ns_dir}/{op}.md"
def _relative_link(page_src: str, target_docs_path: str) -> str:
"""Compute the markdown link path from `page_src` to `target_docs_path`.
Both paths are docs_dir-relative (forward slashes). Result is also forward-slash.
"""
page_dir = posixpath.dirname(page_src.replace("\\", "/"))
if not page_dir:
return target_docs_path
return posixpath.relpath(target_docs_path, page_dir)
def _rewrite_segment(text: str, index: dict[str, Any], page_src: str) -> str:
def replace(match: re.Match[str]) -> str:
name = match.group(1)
# Heuristic: plain prose words inside brackets (e.g. `[note]`) — skip anything that
# looks lowercase-only or single-word-lowercase. We still accept lowercase dotted
# prefixes (`lib.image.…`) because those are intentional namespace references.
if "." not in name and not name[0].isupper():
return match.group(0)
fullpath, candidates = _resolve(name, index)
if fullpath is None:
if len(candidates) > 1:
log.warning(
"op_autolinks: ambiguous [%s] in %s — candidates: %s. "
"Qualify with the full namespace.",
name,
page_src,
", ".join(candidates),
)
return match.group(0)
entry = index["by_fullpath"].get(fullpath, {})
summary = entry.get("summary", "") or ""
target_docs_path = _fullpath_to_docs_relpath(fullpath)
link = _relative_link(page_src, target_docs_path)
# Show only the last path segment in the rendered link; nobody wants to read
# `Lib.field.adjust.PushPullSDF` inline.
label = name.rsplit(".", 1)[-1]
title_attr = f' "{summary}"' if summary else ""
return f"[{label}]({link}{title_attr})"
return _BRACKET_RE.sub(replace, text)
def _rewrite_markdown(markdown: str, index: dict[str, Any], page_src: str) -> str:
"""Walk the markdown line-by-line so we can skip fenced code blocks cleanly."""
out: list[str] = []
in_fence = False
fence_marker: str | None = None
for line in markdown.splitlines(keepends=True):
stripped = line.lstrip()
if in_fence:
out.append(line)
if fence_marker and stripped.startswith(fence_marker):
in_fence = False
fence_marker = None
continue
fence_match = _FENCE_RE.match(line)
if fence_match:
in_fence = True
fence_marker = fence_match.group(2)[0] * 3 # normalise to 3-char marker
out.append(line)
continue
# Indented code block (4+ spaces after a blank line) — best effort: skip lines that
# look like code. Cheap heuristic; we accept the odd miss rather than carrying a full
# markdown parser into a build hook.
if line.startswith(" ") and not line.lstrip().startswith(("-", "*", "+", "1.")):
out.append(line)
continue
# Protect inline code spans from replacement, then restore them.
spans: list[str] = []
def _stash(match: re.Match[str]) -> str:
spans.append(match.group(0))
return f"\x00CODE{len(spans) - 1}\x00"
protected = _INLINE_CODE_RE.sub(_stash, line)
rewritten = _rewrite_segment(protected, index, page_src)
if spans:
def _restore(match: re.Match[str]) -> str:
return spans[int(match.group(1))]
rewritten = re.sub(r"\x00CODE(\d+)\x00", _restore, rewritten)
out.append(rewritten)
return "".join(out)
# MkDocs hook entry point.
def on_page_markdown(markdown: str, page, config, files): # noqa: ARG001 — mkdocs API
index = _load_index(config)
if index is None:
return markdown
page_src = page.file.src_path if page and page.file else "<unknown>"
# Don't rewrite the generated operator pages themselves — they already link correctly,
# and we'd double-wrap the namespace back-link.
if page_src.startswith("operators" + ("/" if "/" in page_src else "\\")):
return markdown
return _rewrite_markdown(markdown, index, page_src)