"""Zensical -> Discourse-compatible Markdown converter.
Converts Zensical/MkDocs Material syntax to Discourse-friendly markdown:
1. Strip YAML front matter
2. Convert admonitions (!!!/???/???+) to Discourse callouts (> [!TYPE])
3. Convert Material tabs (=== "Tab Name") to bold headings + ---
4. Strip grid card HTML (
)
5. Convert Material emoji shortcodes (:material-*:) to Unicode or strip
6. Resolve internal .md links to Discourse search URLs (via docs-sync-id)
7. Clean up excessive blank lines
"""
from __future__ import annotations
import os
import re
import urllib.parse
from pathlib import Path
DISCOURSE_FORUM_URL = os.environ.get(
"DISCOURSE_URL", "https://community.sunnypilot.ai"
).rstrip("/")
ADMONITION_MAP: dict[str, str] = {
"note": "NOTE",
"abstract": "ABSTRACT",
"info": "INFO",
"tip": "TIP",
"success": "SUCCESS",
"question": "QUESTION",
"warning": "WARNING",
"failure": "FAILURE",
"danger": "DANGER",
"bug": "BUG",
"example": "EXAMPLE",
"quote": "QUOTE",
}
EMOJI_MAP: dict[str, str] = {
":material-rocket-launch:": "",
":material-cog:": "",
":material-car:": "",
":material-shield:": "",
":material-download:": "",
":material-check:": "Y",
":material-close:": "N",
":material-alert:": "!",
":material-information:": "i",
":material-help-circle:": "?",
":material-star:": "*",
":material-link:": "",
":material-eye:": "",
":material-map:": "",
":material-wifi:": "",
":material-cellphone:": "",
":material-steering:": "",
":material-speedometer:": "",
}
_ADMONITION_RE = re.compile(r"^(\s*)(!{3}|\?{3}\+?)\s+(\w+)(?:\s+\"([^\"]*)\")?\s*$")
_TAB_RE = re.compile(r'^(\s*)===\s+"([^"]+)"\s*$')
_FRONT_MATTER_RE = re.compile(r"\A---\n.*?\n---\n*", re.DOTALL)
_GRID_CARD_OPEN_RE = re.compile(r'
')
_GRID_CARD_CLOSE_RE = re.compile(r"
")
_INTERNAL_LINK_RE = re.compile(r"\]\(([^)]+\.md(?:#[^)]*)?)\)")
_EMOJI_SHORTCODE_RE = re.compile(r":material-[\w-]+:")
_EXCESSIVE_BLANKS_RE = re.compile(r"\n{4,}")
def convert(content: str, *, file_path: str) -> str:
"""Convert Zensical markdown to Discourse-compatible markdown.
Args:
content: Raw markdown content.
file_path: Path to the source file relative to docs_sp/
(e.g. "getting-started/what-is-sunnypilot.md").
Used for resolving relative internal links.
Returns:
Converted markdown string.
"""
result = content
result = strip_front_matter(result)
result = convert_admonitions(result)
result = convert_tabs(result)
result = convert_grid_cards(result)
result = convert_emoji_shortcodes(result)
result = resolve_internal_links(result, file_path=file_path)
result = clean_blank_lines(result)
return result.strip() + "\n"
def strip_front_matter(content: str) -> str:
"""Remove YAML front matter (--- ... ---) from the start of content."""
return _FRONT_MATTER_RE.sub("", content)
def convert_admonitions(content: str) -> str:
"""Convert MkDocs admonitions to Discourse/Obsidian callouts.
Input:
!!! warning "Title"
Content line 1
Content line 2
Output:
> [!WARNING] Title
> Content line 1
> Content line 2
"""
lines = content.splitlines(keepends=True)
result: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
m = _ADMONITION_RE.match(line)
if m:
indent = m.group(1)
marker = m.group(2)
ad_type = m.group(3).lower()
title = m.group(4)
callout_type = ADMONITION_MAP.get(ad_type, ad_type.upper())
header = f"{indent}> [!{callout_type}]"
if title:
header += f" {title}"
if marker.startswith("???"):
collapsed = "+" not in marker
if not title:
action = "expand" if collapsed else "collapse"
header += f" *(click to {action})*"
result.append(header + "\n")
i += 1
content_indent = indent + " "
while i < len(lines):
content_line = lines[i]
if content_line.startswith(content_indent):
stripped = content_line[len(content_indent):]
result.append(f"{indent}> {stripped}")
i += 1
elif content_line.strip() == "":
# Check if blank line is internal to the admonition
j = i + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
if j < len(lines) and lines[j].startswith(content_indent):
result.append(f"{indent}>\n")
i += 1
else:
break
else:
break
else:
result.append(line)
i += 1
return "".join(result)
def convert_tabs(content: str) -> str:
"""Convert Material tabs to bold headings with horizontal rules.
Input:
=== "Tab Name"
Content
Output:
**Tab Name**
Content
---
"""
lines = content.splitlines(keepends=True)
result: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
m = _TAB_RE.match(line)
if m:
indent = m.group(1)
tab_name = m.group(2)
result.append(f"{indent}**{tab_name}**\n")
result.append("\n")
i += 1
content_indent = indent + " "
while i < len(lines):
content_line = lines[i]
if content_line.startswith(content_indent) or content_line.strip() == "":
if content_line.strip() == "":
result.append("\n")
else:
stripped = content_line[len(content_indent):]
result.append(f"{indent}{stripped}")
i += 1
else:
break
# Ensure blank line before the horizontal rule
if result and result[-1] != "\n":
result.append("\n")
result.append(f"{indent}---\n")
result.append("\n")
else:
result.append(line)
i += 1
return "".join(result)
def convert_grid_cards(content: str) -> str:
"""Strip grid card HTML wrappers (Discourse doesn't support them)."""
result = _GRID_CARD_OPEN_RE.sub("", content)
result = _GRID_CARD_CLOSE_RE.sub("", result)
return result
def convert_emoji_shortcodes(content: str) -> str:
"""Convert Material emoji shortcodes to plain text equivalents."""
result = content
for shortcode, replacement in EMOJI_MAP.items():
result = result.replace(shortcode, replacement)
# Strip any remaining :material-*: shortcodes not in the map
result = _EMOJI_SHORTCODE_RE.sub("", result)
return result
def resolve_internal_links(content: str, *, file_path: str) -> str:
"""Resolve internal .md links to Discourse search URLs via docs-sync-id.
Converts: [text](../features/icbm.md) ->
[text](https://community.sunnypilot.ai/search?q=%22docs-sync-id%3A+features/icbm.md%22)
Anchors are stripped since Discourse search cannot target sections.
"""
current_dir = str(Path(file_path).parent)
def _replace_link(match: re.Match[str]) -> str:
raw_path = match.group(1)
# Skip external URLs
if raw_path.startswith("http"):
return match.group(0)
# Strip anchor (Discourse search cannot target sections)
if "#" in raw_path:
raw_path = raw_path.rsplit("#", 1)[0]
# Resolve relative path from current file's directory
resolved = os.path.normpath(os.path.join(current_dir, raw_path))
# Build Discourse search URL for the sync ID
query = urllib.parse.quote(f'"docs-sync-id: {resolved}"', safe="")
return f"]({DISCOURSE_FORUM_URL}/search?q={query})"
return _INTERNAL_LINK_RE.sub(_replace_link, content)
def clean_blank_lines(content: str) -> str:
"""Collapse 4+ consecutive blank lines down to 3."""
return _EXCESSIVE_BLANKS_RE.sub("\n\n\n", content)