up/_extensions/wikilinks.py
Tolaria 69119f347d
Some checks failed
Deploy Docs / deploy (push) Failing after 24s
feat: add wiki link support ([[page]]) via custom Python-Markdown extension
- New package _extensions/wikilinks.py: preprocessor that converts
  [[Page Name]], [[Page|Text]], [[Page#anchor|Text]], and ![[img]]
  to standard Markdown links before parsing
- Skips wiki links inside fenced/inline code blocks
- Slugifies targets: lowercase, spaces→hyphens
- Registered as markdown extension in zensical.toml
- Updated CI workflow to pip install -e . for the _extensions package
- First usage: [[docs/contributing/index]] in markdown.md
2026-06-03 20:07:25 +08:00

123 lines
4.1 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 .md links natively.
Syntax:
[[Page Name]] → [Page Name](page-name.md)
[[Page Name|Display Text]] → [Display Text](page-name.md)
[[Page Name#anchor]] → [Page Name](page-name.md#anchor)
[[Page Name#anchor|Text]] → [Text](page-name.md#anchor)
![[image.png]] → ![image.png](image.png)
[[folder/Page Name]] → [Page Name](folder/page-name.md)
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,
)
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
class WikilinksPreprocessor(Preprocessor):
"""Preprocessor that converts [[wikilinks]] to [text](slug.md)."""
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):
# Transform the non-code region before this match
before = text[cursor : m.start()]
if before:
result.append(_WIKILINK_RE.sub(self._replace, before))
# Pass through the code region unchanged
result.append(m.group(0))
cursor = m.end()
# Transform any remaining text after the last code region
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)
slug = _slugify(link) if link else ""
href = slug
if anchor:
anchor_slug = _slugify(anchor)
if anchor_slug:
href = f"{slug}#{anchor_slug}" if slug else f"#{anchor_slug}"
if not href:
return match.group(0)
# Build standard markdown link / image
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)