This commit is contained in:
Jason Wen
2026-03-12 22:38:21 -04:00
parent 801c107aa1
commit c8d543a8fe
3 changed files with 47 additions and 59 deletions
+7 -12
View File
@@ -34,21 +34,21 @@ jobs:
DISCOURSE_CATEGORY_MAP: '{"getting-started": 133}'
run: |
uv run --python 3.12 python -c "
import sys
import os, sys
sys.path.insert(0, 'docs_sp/tools')
from pathlib import Path
from converter import convert
from discourse_client import DiscourseClient, DiscourseConfig
DOCS_BASE_URL = 'https://docs.sunnypilot.ai'
DOC_PATH = 'getting-started/what-is-sunnypilot.md'
GITHUB_BRANCH = os.environ.get('GITHUB_REF_NAME', 'master')
raw = (Path('docs_sp') / DOC_PATH).read_text()
body = convert(raw, file_path=DOC_PATH, docs_base_url=DOCS_BASE_URL)
body = convert(raw, file_path=DOC_PATH)
# Append sync metadata
docs_url = f'{DOCS_BASE_URL}/{DOC_PATH.replace(\".md\", \"/\")}'
body = body.rstrip('\n') + f'\n\n---\n:link: [View on docs site]({docs_url})\n\n<!-- docs-sync-id: {DOC_PATH} -->\n'
# Append sync footer
gh_url = f'https://github.com/sunnypilot/sunnypilot/blob/{GITHUB_BRANCH}/docs_sp/{DOC_PATH}'
body = body.rstrip('\n') + f'\n\n---\n<small>This document is version-controlled. Suggest changes [on GitHub]({gh_url}).</small>\n\n<!-- docs-sync-id: {DOC_PATH} -->\n'
# Extract title from front matter or first heading
title = None
@@ -67,12 +67,7 @@ jobs:
config = DiscourseConfig.from_env()
client = DiscourseClient(config)
def category_id_for(path: str) -> int:
top_level = path.split('/')[0]
return config.category_mapping.get(top_level, config.category_mapping.get('documentation', 114))
category_id = category_id_for(DOC_PATH)
category_id = config.category_id_for(DOC_PATH)
print(f'Category ID: {category_id}')
existing = client.find_topic_by_sync_id(DOC_PATH)
+24 -33
View File
@@ -1,12 +1,12 @@
"""MkDocs Material -> Discourse-compatible Markdown converter.
"""Zensical -> Discourse-compatible Markdown converter.
Converts MkDocs Material syntax to Discourse-friendly markdown:
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 (<div class="grid cards" markdown>)
5. Convert Material emoji shortcodes (:material-*:) to Unicode or strip
6. Resolve internal .md links to docs site URLs
6. Resolve internal .md links to Discourse search URLs (via docs-sync-id)
7. Clean up excessive blank lines
"""
@@ -14,9 +14,12 @@ from __future__ import annotations
import os
import re
import urllib.parse
from pathlib import Path
DOCS_BASE_URL = os.environ.get("DOCS_BASE_URL", "https://docs.sunnypilot.ai")
DISCOURSE_FORUM_URL = os.environ.get(
"DISCOURSE_URL", "https://community.sunnypilot.ai"
).rstrip("/")
ADMONITION_MAP: dict[str, str] = {
"note": "NOTE",
@@ -64,19 +67,18 @@ _EMOJI_SHORTCODE_RE = re.compile(r":material-[\w-]+:")
_EXCESSIVE_BLANKS_RE = re.compile(r"\n{4,}")
def convert(content: str, *, file_path: str, docs_base_url: str | None = None) -> str:
"""Convert MkDocs Material markdown to Discourse-compatible markdown.
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/ or absolute).
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.
docs_base_url: Base URL for the docs site. Defaults to DOCS_BASE_URL env var.
Returns:
Converted markdown string.
"""
base_url = docs_base_url or DOCS_BASE_URL
result = content
result = strip_front_matter(result)
@@ -84,7 +86,7 @@ def convert(content: str, *, file_path: str, docs_base_url: str | None = None) -
result = convert_tabs(result)
result = convert_grid_cards(result)
result = convert_emoji_shortcodes(result)
result = resolve_internal_links(result, file_path=file_path, docs_base_url=base_url)
result = resolve_internal_links(result, file_path=file_path)
result = clean_blank_lines(result)
return result.strip() + "\n"
@@ -235,13 +237,13 @@ def convert_emoji_shortcodes(content: str) -> str:
return result
def resolve_internal_links(
content: str, *, file_path: str, docs_base_url: str
) -> str:
"""Resolve internal .md links to docs site URLs.
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://docs.sunnypilot.ai/features/icbm/)
Converts: [text](../features/icbm.md#section) -> [text](https://docs.sunnypilot.ai/features/icbm/#section)
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)
@@ -251,27 +253,16 @@ def resolve_internal_links(
if raw_path.startswith("http"):
return match.group(0)
# Split path and optional anchor
anchor = ""
# Strip anchor (Discourse search cannot target sections)
if "#" in raw_path:
raw_path, anchor = raw_path.rsplit("#", 1)
anchor = f"#{anchor}"
raw_path = raw_path.rsplit("#", 1)[0]
# Resolve relative path from current file's directory
resolved = Path(current_dir, raw_path).resolve()
resolved_str = str(resolved)
resolved = os.path.normpath(os.path.join(current_dir, raw_path))
# Extract docs-relative path
docs_sp_idx = resolved_str.find("docs_sp/")
if docs_sp_idx >= 0:
docs_relative = resolved_str[docs_sp_idx + len("docs_sp/"):]
else:
# Fallback: use the resolved path relative to file
docs_relative = raw_path
# Convert to URL: remove .md, add trailing slash
url_path = re.sub(r"\.md$", "/", docs_relative)
return f"]({docs_base_url}/{url_path}{anchor})"
# 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)
+16 -14
View File
@@ -353,11 +353,13 @@ def test_internal_link_relative():
input_text = "See [ICBM](../features/cruise/icbm.md) for details."
result = resolve_internal_links(
input_text,
file_path="docs_sp/settings/cruise/speed-limit.md",
docs_base_url="https://docs.sunnypilot.ai",
file_path="settings/cruise/speed-limit.md",
)
assert "/search?q=" in result, f"FAIL internal_link missing search URL:\n{result!r}"
assert "docs-sync-id" in result, f"FAIL internal_link missing sync-id:\n{result!r}"
assert "features%2Fcruise%2Ficbm.md" in result or "features/cruise/icbm.md" in result, (
f"FAIL internal_link wrong path:\n{result!r}"
)
assert "https://docs.sunnypilot.ai/" in result, f"FAIL internal_link:\n{result!r}"
assert ".md" not in result.split("](")[1], f"FAIL internal_link still has .md:\n{result!r}"
print(" PASS: internal_link_relative")
@@ -365,11 +367,12 @@ def test_internal_link_with_anchor():
input_text = "See [section](./safety.md#driver-responsibility)."
result = resolve_internal_links(
input_text,
file_path="docs_sp/safety/index.md",
docs_base_url="https://docs.sunnypilot.ai",
file_path="safety/index.md",
)
assert "#driver-responsibility" in result, f"FAIL anchor preserved:\n{result!r}"
assert ".md" not in result.split("](")[1].split("#")[0], f"FAIL link has .md:\n{result!r}"
# Anchors are stripped — Discourse search cannot target sections
assert "#driver-responsibility" not in result, f"FAIL anchor should be stripped:\n{result!r}"
assert "docs-sync-id" in result, f"FAIL missing sync-id:\n{result!r}"
assert "safety" in result, f"FAIL wrong path:\n{result!r}"
print(" PASS: internal_link_with_anchor")
@@ -377,8 +380,7 @@ def test_external_link_untouched():
input_text = "Visit [GitHub](https://github.com/sunnypilot/sunnypilot)."
result = resolve_internal_links(
input_text,
file_path="docs_sp/index.md",
docs_base_url="https://docs.sunnypilot.ai",
file_path="index.md",
)
assert result == input_text, f"FAIL external_link:\n{result!r}"
print(" PASS: external_link_untouched")
@@ -425,16 +427,16 @@ See [safety info](../safety/safety.md) for more.
"""
result = convert(
input_text,
file_path="docs_sp/features/index.md",
docs_base_url="https://docs.sunnypilot.ai",
file_path="features/index.md",
)
# Front matter stripped
assert "---\ntitle:" not in result, f"FAIL front matter not stripped:\n{result!r}"
# Admonition converted
assert "> [!WARNING] Important" in result, f"FAIL admonition:\n{result!r}"
assert "> Pay attention to the road." in result, f"FAIL admonition content:\n{result!r}"
# Link resolved
assert ".md" not in result.split("](")[1].split(")")[0], f"FAIL link:\n{result!r}"
# Link resolved to Discourse search
assert "/search?q=" in result, f"FAIL link not converted to search:\n{result!r}"
assert "docs-sync-id" in result, f"FAIL link missing sync-id:\n{result!r}"
# Emoji converted
assert ":material-check:" not in result, f"FAIL emoji:\n{result!r}"
assert "Y Feature supported" in result, f"FAIL emoji replacement:\n{result!r}"