diff --git a/.github/workflows/sync-docs-discourse.yml b/.github/workflows/sync-docs-discourse.yml
index 18402084e8..0417b6b713 100644
--- a/.github/workflows/sync-docs-discourse.yml
+++ b/.github/workflows/sync-docs-discourse.yml
@@ -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\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---\nThis document is version-controlled. Suggest changes [on GitHub]({gh_url}).\n\n\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)
diff --git a/docs_sp/tools/converter.py b/docs_sp/tools/converter.py
index 38ddc6ee59..741e369eb5 100644
--- a/docs_sp/tools/converter.py
+++ b/docs_sp/tools/converter.py
@@ -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 (
)
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)
diff --git a/docs_sp/tools/test_converter.py b/docs_sp/tools/test_converter.py
index cca8b1679e..d8dab1a642 100644
--- a/docs_sp/tools/test_converter.py
+++ b/docs_sp/tools/test_converter.py
@@ -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}"