2026-06-03 12:07:25 +00:00
|
|
|
"""
|
|
|
|
|
Python-Markdown extension: converts [[Wikilinks]] to standard Markdown links.
|
|
|
|
|
|
|
|
|
|
Mirrors the behavior of mkdocs-ezlinks-plugin's WikiLinkScanner, but as a
|
|
|
|
|
pure Markdown preprocessor (no MkDocs plugin API needed). Zensical handles
|
2026-06-03 12:38:15 +00:00
|
|
|
the resulting links natively.
|
2026-06-03 12:07:25 +00:00
|
|
|
|
|
|
|
|
Syntax:
|
2026-06-03 12:38:15 +00:00
|
|
|
[[Page Name]] → [Page Name](/page-name/)
|
|
|
|
|
[[Page Name|Display Text]] → [Display Text](/page-name/)
|
|
|
|
|
[[Page Name#anchor]] → [Page Name](/page-name/#anchor)
|
|
|
|
|
[[Page Name#anchor|Text]] → [Text](/page-name/#anchor)
|
|
|
|
|
![[image.png]] → 
|
|
|
|
|
|
|
|
|
|
Tolaria-compatible: when the vault root is the project root, links like
|
|
|
|
|
``[[docs/contributing/index]]`` are stripped of the ``docs/`` prefix and
|
|
|
|
|
converted to root-relative URLs (e.g. ``/contributing/``) that work from
|
|
|
|
|
any page regardless of depth.
|
2026-06-03 12:07:25 +00:00
|
|
|
|
|
|
|
|
Code blocks (fenced and inline) are skipped — wiki links inside them pass
|
|
|
|
|
through unchanged.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from markdown.preprocessors import Preprocessor
|
|
|
|
|
from markdown.extensions import Extension
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Regex for a wiki link. Borrowed from mkdocs-ezlinks-plugin's
|
|
|
|
|
# WikiLinkScanner with minor adjustments.
|
|
|
|
|
_WIKILINK_RE = re.compile(
|
|
|
|
|
r"""
|
|
|
|
|
(?P<image>\!?) # optional ! for image embeds
|
|
|
|
|
\[\[ # opening [[
|
|
|
|
|
(?P<link>[^#\|\]]*?) # target (everything before #, |, or ]])
|
|
|
|
|
(?:\#(?P<anchor>[^\|\]]+)?)? # optional #anchor
|
|
|
|
|
(?:\|(?P<text>[^\]]+)?)? # optional |display text
|
|
|
|
|
\]\] # closing ]]
|
|
|
|
|
""",
|
|
|
|
|
re.VERBOSE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Match fenced code blocks or inline code spans (single capture group).
|
|
|
|
|
_CODE_RE = re.compile(
|
|
|
|
|
r"(```.*?```|`[^`\n]+`)",
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-03 12:38:15 +00:00
|
|
|
# Prefix to strip from Tolaria-generated links when the vault root is
|
|
|
|
|
# the project root (so links look like [[docs/foo/bar]]).
|
|
|
|
|
_DOCS_PREFIX = "docs/"
|
|
|
|
|
|
2026-06-03 12:07:25 +00:00
|
|
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
|
|
|
"""Convert a human page name into a filename slug.
|
|
|
|
|
|
|
|
|
|
Mirrors ezlinks: lowercase, spaces → hyphens, strip unsupported chars.
|
|
|
|
|
Keeps forward slashes for subdirectory paths, dots for extensions, and CJK.
|
|
|
|
|
"""
|
|
|
|
|
slug = text.strip().lower()
|
|
|
|
|
slug = re.sub(r"\s+", "-", slug)
|
|
|
|
|
# Keep: word chars, hyphens, dots, forward slashes, CJK
|
|
|
|
|
slug = re.sub(r"[^\w./\u4e00-\u9fff\-]", "", slug)
|
|
|
|
|
return slug
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 12:38:15 +00:00
|
|
|
def _url_from_link(link: str) -> str:
|
|
|
|
|
"""Convert a wiki link target to a root-relative URL.
|
|
|
|
|
|
|
|
|
|
Handles Tolaria-style full paths: strips ``docs/`` prefix,
|
|
|
|
|
removes ``.md`` extension, collapses ``/index`` suffixes,
|
|
|
|
|
slugifies each component, and prepends ``/``.
|
|
|
|
|
"""
|
|
|
|
|
raw = link.strip()
|
|
|
|
|
|
|
|
|
|
# Strip .md extension
|
|
|
|
|
raw = re.sub(r"\.md$", "", raw, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Strip docs/ prefix (Tolaria vault root = project root)
|
|
|
|
|
if raw.lower().startswith(_DOCS_PREFIX):
|
|
|
|
|
raw = raw[len(_DOCS_PREFIX) :]
|
|
|
|
|
|
|
|
|
|
# Collapse trailing /index — section index pages resolve to the dir
|
|
|
|
|
raw = re.sub(r"/index$", "", raw)
|
|
|
|
|
|
|
|
|
|
if not raw:
|
|
|
|
|
return "/"
|
|
|
|
|
|
|
|
|
|
# Slugify each path component
|
|
|
|
|
parts = [_slugify(p) for p in raw.split("/") if p]
|
|
|
|
|
return "/" + "/".join(parts) + "/"
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 12:07:25 +00:00
|
|
|
class WikilinksPreprocessor(Preprocessor):
|
2026-06-03 12:38:15 +00:00
|
|
|
"""Preprocessor that converts [[wikilinks]] to root-relative [text](/url/)."""
|
2026-06-03 12:07:25 +00:00
|
|
|
|
|
|
|
|
def run(self, lines: list[str]) -> list[str]:
|
|
|
|
|
text = "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
# Walk through the text, skipping code spans. Use finditer to
|
|
|
|
|
# extract code regions, then transform wiki links in the gaps.
|
|
|
|
|
result: list[str] = []
|
|
|
|
|
cursor = 0
|
|
|
|
|
for m in _CODE_RE.finditer(text):
|
|
|
|
|
before = text[cursor : m.start()]
|
|
|
|
|
if before:
|
|
|
|
|
result.append(_WIKILINK_RE.sub(self._replace, before))
|
|
|
|
|
result.append(m.group(0))
|
|
|
|
|
cursor = m.end()
|
|
|
|
|
|
|
|
|
|
tail = text[cursor:]
|
|
|
|
|
if tail:
|
|
|
|
|
result.append(_WIKILINK_RE.sub(self._replace, tail))
|
|
|
|
|
|
|
|
|
|
return "\n".join(result).split("\n")
|
|
|
|
|
|
|
|
|
|
def _replace(self, match: re.Match) -> str:
|
|
|
|
|
groups = match.groupdict()
|
|
|
|
|
is_image = groups.get("image") or ""
|
|
|
|
|
link = groups.get("link") or ""
|
|
|
|
|
anchor = groups.get("anchor") or ""
|
|
|
|
|
text = groups.get("text") or link or anchor
|
|
|
|
|
|
|
|
|
|
if not (link or text or anchor):
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
2026-06-03 12:38:15 +00:00
|
|
|
# Build the href: root-relative URL for pages; relative for images
|
|
|
|
|
if is_image:
|
|
|
|
|
# Images: keep filename, strip docs/ prefix, make root-relative
|
|
|
|
|
raw = link.strip()
|
|
|
|
|
if raw.lower().startswith(_DOCS_PREFIX):
|
|
|
|
|
raw = raw[len(_DOCS_PREFIX) :]
|
|
|
|
|
# Non-image assets might have extensions; keep them
|
|
|
|
|
href = raw if "." in raw else _slugify(raw)
|
|
|
|
|
if not href.startswith("/"):
|
|
|
|
|
href = "/" + href
|
|
|
|
|
else:
|
|
|
|
|
href = _url_from_link(link)
|
|
|
|
|
|
|
|
|
|
# Append anchor if present
|
2026-06-03 12:07:25 +00:00
|
|
|
if anchor:
|
|
|
|
|
anchor_slug = _slugify(anchor)
|
|
|
|
|
if anchor_slug:
|
2026-06-03 12:38:15 +00:00
|
|
|
href = href.rstrip("/") + "#" + anchor_slug
|
2026-06-03 12:07:25 +00:00
|
|
|
|
|
|
|
|
if is_image:
|
|
|
|
|
return f""
|
|
|
|
|
else:
|
|
|
|
|
return f"[{text}]({href})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WikilinksExtension(Extension):
|
|
|
|
|
"""Register the WikilinksPreprocessor."""
|
|
|
|
|
|
|
|
|
|
def extendMarkdown(self, md):
|
|
|
|
|
# Priority 35: after normalize_whitespace (30), before html_block (20)
|
|
|
|
|
md.preprocessors.register(WikilinksPreprocessor(md), "wikilinks", 35)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def makeExtension(**kwargs):
|
|
|
|
|
return WikilinksExtension(**kwargs)
|