"""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)