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