up/_extensions/wikilinks.py
Tolaria a1db08f32e
Some checks failed
Deploy Docs / deploy (push) Failing after 23s
fix: wiki links now root-relative — handles Tolaria [[docs/path/to/page]] format
Strips docs/ prefix from Tolaria-generated links, collapses /index suffixes,
removes .md extensions, and produces root-relative URLs (/page/path/) that
work from any page regardless of depth. Images get root-relative asset paths.
2026-06-03 20:38:15 +08:00

163 lines
5.4 KiB
Python

"""
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
the resulting links natively.
Syntax:
[[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]] → ![image.png](/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.
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,
)
# 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/"
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
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) + "/"
class WikilinksPreprocessor(Preprocessor):
"""Preprocessor that converts [[wikilinks]] to root-relative [text](/url/)."""
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)
# 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
if anchor:
anchor_slug = _slugify(anchor)
if anchor_slug:
href = href.rstrip("/") + "#" + anchor_slug
if is_image:
return f"![{text}]({href})"
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)