diff --git a/.github/workflows/forum-docs.yaml b/.github/workflows/forum-docs.yaml
new file mode 100644
index 0000000000..78a2038314
--- /dev/null
+++ b/.github/workflows/forum-docs.yaml
@@ -0,0 +1,47 @@
+name: Sync docs to Discourse
+
+on:
+ workflow_run:
+ workflows: ["sunnypilot docs"]
+ types:
+ - completed
+ branches:
+ - docs
+
+concurrency:
+ group: forum-docs-sync
+ cancel-in-progress: true
+
+jobs:
+ sync:
+ name: sync docs to Discourse
+ runs-on: ubuntu-24.04
+ if: >
+ github.event.workflow_run.conclusion == 'success' &&
+ github.repository == 'sunnypilot/sunnypilot'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: docs
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+
+ - name: Restore sync cache
+ uses: actions/cache@v4
+ with:
+ path: .discourse_sync_cache
+ key: discourse-sync-${{ hashFiles('docs_sp/**/*.md') }}
+ restore-keys: |
+ discourse-sync-
+
+ - name: Sync to Discourse
+ env:
+ DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }}
+ DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }}
+ DISCOURSE_API_USER: ${{ secrets.DISCOURSE_API_USER }}
+ DISCOURSE_CATEGORY: ${{ vars.DISCOURSE_DOCS_CATEGORY || 'documentation' }}
+ DOCS_BASE_URL: https://docs.sunnypilot.ai
+ run: ruby docs_sp/tools/sync_docs_discourse.rb --verbose
diff --git a/.gitignore b/.gitignore
index 081fcaf1af..aefa569ddd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ a.out
/docs_site/
/docs_site_sp/
+/.discourse_sync_cache/
*.mp4
*.dylib
diff --git a/docs_sp/tools/sync_docs_discourse.rb b/docs_sp/tools/sync_docs_discourse.rb
new file mode 100644
index 0000000000..6e20cbb3f7
--- /dev/null
+++ b/docs_sp/tools/sync_docs_discourse.rb
@@ -0,0 +1,611 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# sync_docs_discourse.rb
+#
+# Syncs sunnypilot documentation from docs_sp/ to a Discourse forum.
+# Reads raw .md files, converts MkDocs Material syntax to Discourse-compatible
+# markdown (Obsidian-style callouts), resolves internal links to Discourse URLs,
+# and creates or updates topics via the Discourse API.
+#
+# Usage:
+# ruby sync_docs_discourse.rb [--dry-run] [--verbose]
+#
+# Environment variables (required unless --dry-run):
+# DISCOURSE_URL - Base URL of the Discourse instance (e.g. https://forum.sunnypilot.ai)
+# DISCOURSE_API_KEY - API key with topic create/update permissions
+# DISCOURSE_API_USER - Username for API requests (e.g. system or a bot account)
+# DISCOURSE_CATEGORY - Category slug or ID for documentation topics (default: "documentation")
+#
+# Optional:
+# DOCS_BASE_URL - Base URL for the static docs site (default: https://docs.sunnypilot.ai)
+
+require "net/http"
+require "uri"
+require "json"
+require "yaml"
+require "digest"
+require "fileutils"
+require "optparse"
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+DOCS_DIR = File.expand_path("../../", __FILE__) # docs_sp/
+MKDOCS_YML = File.expand_path("../../../mkdocs-sp.yml", __FILE__)
+CACHE_DIR = File.expand_path("../../../.discourse_sync_cache", __FILE__)
+DOCS_BASE_URL = ENV.fetch("DOCS_BASE_URL", "https://docs.sunnypilot.ai")
+
+DISCOURSE_URL = ENV["DISCOURSE_URL"]
+DISCOURSE_API_KEY = ENV["DISCOURSE_API_KEY"]
+DISCOURSE_API_USER = ENV.fetch("DISCOURSE_API_USER", "system")
+DISCOURSE_CATEGORY = ENV.fetch("DISCOURSE_CATEGORY", "documentation")
+
+# MkDocs admonition type → Obsidian/Discourse callout type
+ADMONITION_MAP = {
+ "note" => "NOTE",
+ "abstract" => "ABSTRACT",
+ "info" => "INFO",
+ "tip" => "TIP",
+ "success" => "SUCCESS",
+ "question" => "QUESTION",
+ "warning" => "WARNING",
+ "failure" => "FAILURE",
+ "danger" => "DANGER",
+ "bug" => "BUG",
+ "example" => "EXAMPLE",
+ "quote" => "QUOTE",
+}.freeze
+
+# Pages to skip (not meaningful as standalone Discourse topics)
+SKIP_FILES = %w[
+ index.md
+ README.md
+].freeze
+
+# ---------------------------------------------------------------------------
+# CLI Options
+# ---------------------------------------------------------------------------
+
+options = { dry_run: false, verbose: false }
+OptionParser.new do |opts|
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
+ opts.on("--dry-run", "Show what would be synced without making API calls") { options[:dry_run] = true }
+ opts.on("--verbose", "Print detailed conversion output") { options[:verbose] = true }
+ opts.on("-h", "--help", "Show this help") { puts opts; exit }
+end.parse!
+
+DRY_RUN = options[:dry_run]
+VERBOSE = options[:verbose]
+
+unless DRY_RUN
+ %w[DISCOURSE_URL DISCOURSE_API_KEY].each do |var|
+ abort "Error: #{var} environment variable is required" unless ENV[var]
+ end
+end
+
+# ---------------------------------------------------------------------------
+# MkDocs → Discourse Markdown Converter
+# ---------------------------------------------------------------------------
+
+module MkDocsConverter
+ module_function
+
+ # Main entry point: convert a full MkDocs Material markdown string
+ # to Discourse-compatible markdown.
+ def convert(content, file_path:, nav_slug_map: {})
+ result = content.dup
+
+ # 1. Strip YAML front matter
+ result = strip_front_matter(result)
+
+ # 2. Convert admonitions (!!! type "title" / ??? type "title")
+ result = convert_admonitions(result)
+
+ # 3. Convert Material tabs (=== "Tab Name")
+ result = convert_tabs(result)
+
+ # 4. Convert grid cards
+ result = convert_grid_cards(result)
+
+ # 5. Convert Material emoji shortcodes to Unicode or strip
+ result = convert_emoji_shortcodes(result)
+
+ # 6. Resolve internal .md links to docs site URLs
+ result = resolve_internal_links(result, file_path: file_path)
+
+ # 7. Clean up excessive blank lines
+ result = result.gsub(/\n{4,}/, "\n\n\n")
+
+ result.strip + "\n"
+ end
+
+ # Remove YAML front matter (--- ... ---)
+ def strip_front_matter(content)
+ content.sub(/\A---\n.*?\n---\n*/m, "")
+ end
+
+ # Convert MkDocs admonitions to Obsidian/Discourse callouts.
+ #
+ # Input:
+ # !!! warning "Title"
+ # Content line 1
+ # Content line 2
+ #
+ # Output:
+ # > [!WARNING] Title
+ # > Content line 1
+ # > Content line 2
+ #
+ # Also handles collapsible (??? / ???+) variants.
+ def convert_admonitions(content)
+ lines = content.lines
+ result = []
+ i = 0
+
+ while i < lines.length
+ line = lines[i]
+
+ # Match admonition opener: !!! type "title" or ??? type "title" or ???+ type "title"
+ if line =~ /^(\s*)(\!{3}|\?{3}\+?) (\w+)(?: "([^"]*)")?/
+ indent = $1
+ marker = $2
+ ad_type = $3.downcase
+ title = $4
+
+ callout_type = ADMONITION_MAP[ad_type] || ad_type.upcase
+
+ # Build the callout header
+ header = "#{indent}> [!#{callout_type}]"
+ header += " #{title}" if title && !title.empty?
+
+ # For collapsible (???), add a note
+ if marker.start_with?("???")
+ collapsed = !marker.include?("+")
+ header += " *(click to #{collapsed ? 'expand' : 'collapse'})*" if title.nil? || title.empty?
+ end
+
+ result << header + "\n"
+ i += 1
+
+ # Collect indented content lines (4 spaces deeper than the opener)
+ content_indent = indent + " "
+ while i < lines.length
+ content_line = lines[i]
+
+ if content_line =~ /^#{Regexp.escape(content_indent)}/
+ # Indented content line — part of the admonition
+ stripped = content_line.sub(/^#{Regexp.escape(content_indent)}/, "")
+ result << "#{indent}> #{stripped}"
+ i += 1
+ elsif content_line.strip.empty?
+ # Blank line: only part of admonition if the next non-blank
+ # line is still indented at content level
+ j = i + 1
+ j += 1 while j < lines.length && lines[j].strip.empty?
+ if j < lines.length && lines[j] =~ /^#{Regexp.escape(content_indent)}/
+ result << "#{indent}>\n"
+ i += 1
+ else
+ # Blank line ends the admonition
+ break
+ end
+ else
+ break
+ end
+ end
+ else
+ result << line
+ i += 1
+ end
+ end
+
+ result.join
+ end
+
+ # Convert Material tabs to Discourse-friendly headings with horizontal rules.
+ #
+ # Input:
+ # === "Tab Name"
+ # Content
+ #
+ # Output:
+ # **Tab Name**
+ #
+ # Content
+ #
+ # ---
+ def convert_tabs(content)
+ lines = content.lines
+ result = []
+ i = 0
+
+ while i < lines.length
+ line = lines[i]
+
+ if line =~ /^(\s*)=== "([^"]+)"/
+ indent = $1
+ tab_name = $2
+
+ result << "#{indent}**#{tab_name}**\n"
+ result << "\n"
+ i += 1
+
+ # Collect indented content
+ content_indent = indent + " "
+ while i < lines.length
+ content_line = lines[i]
+ if content_line =~ /^#{Regexp.escape(content_indent)}/ || content_line.strip.empty?
+ if content_line.strip.empty?
+ result << "\n"
+ else
+ stripped = content_line.sub(/^#{Regexp.escape(content_indent)}/, "")
+ result << "#{indent}#{stripped}"
+ end
+ i += 1
+ else
+ break
+ end
+ end
+
+ result << "#{indent}---\n"
+ result << "\n"
+ else
+ result << line
+ i += 1
+ end
+ end
+
+ result.join
+ end
+
+ # Convert grid cards to simple lists (Discourse doesn't support grid cards)
+ def convert_grid_cards(content)
+ content
+ .gsub(/
/, "")
+ .gsub(/<\/div>/, "")
+ end
+
+ # Convert Material emoji shortcodes to plain text or remove
+ # e.g., :material-rocket-launch: → 🚀 (or just strip)
+ EMOJI_MAP = {
+ ":material-rocket-launch:" => "🚀",
+ ":material-cog:" => "⚙️",
+ ":material-car:" => "🚗",
+ ":material-shield:" => "🛡️",
+ }.freeze
+
+ def convert_emoji_shortcodes(content)
+ result = content.dup
+ EMOJI_MAP.each do |shortcode, emoji|
+ result.gsub!(shortcode, emoji)
+ end
+ # Strip any remaining :material-*: shortcodes
+ result.gsub(/:material-[\w-]+:/, "")
+ end
+
+ # Resolve internal links: (../features/icbm.md) → (https://docs.sunnypilot.ai/features/icbm/)
+ def resolve_internal_links(content, file_path:)
+ content.gsub(/\]\(([^)]+\.md)\)/) do |match|
+ relative_path = $1
+ # Skip external URLs
+ next match if relative_path.start_with?("http")
+
+ # Resolve the relative path from the current file's directory
+ current_dir = File.dirname(file_path)
+ resolved = File.expand_path(relative_path, current_dir)
+
+ # Make it relative to docs_sp/
+ docs_relative = resolved.sub(%r{^.*/docs_sp/}, "")
+
+ # Convert to URL path: remove .md, add trailing slash
+ url_path = docs_relative.sub(/\.md$/, "/")
+ "](#{DOCS_BASE_URL}/#{url_path})"
+ end
+ end
+end
+
+# ---------------------------------------------------------------------------
+# Discourse API Client
+# ---------------------------------------------------------------------------
+
+module DiscourseAPI
+ module_function
+
+ def base_uri
+ URI.parse(DISCOURSE_URL)
+ end
+
+ def headers
+ {
+ "Content-Type" => "application/json",
+ "Api-Key" => DISCOURSE_API_KEY,
+ "Api-Username" => DISCOURSE_API_USER,
+ }
+ end
+
+ # Look up category ID by slug
+ def category_id(slug)
+ uri = URI.join(DISCOURSE_URL, "/c/#{slug}/show.json")
+ response = http_get(uri)
+ return nil unless response.is_a?(Net::HTTPSuccess)
+
+ data = JSON.parse(response.body)
+ data.dig("category", "id")
+ end
+
+ # Search for an existing topic by external_id (stored in the topic's first post)
+ # We use a convention: embed an HTML comment
+ def find_topic_by_sync_id(sync_id)
+ search_query = ""
+ uri = URI.join(DISCOURSE_URL, "/search.json?q=#{URI.encode_www_form_component(search_query)}")
+ response = http_get(uri)
+ return nil unless response.is_a?(Net::HTTPSuccess)
+
+ data = JSON.parse(response.body)
+ topics = data.dig("topics") || []
+ topics.first
+ end
+
+ # Create a new topic
+ def create_topic(title:, raw:, category_id:, tags: [])
+ uri = URI.join(DISCOURSE_URL, "/posts.json")
+ payload = {
+ title: title,
+ raw: raw,
+ category: category_id,
+ tags: tags,
+ }
+ http_post(uri, payload)
+ end
+
+ # Update an existing topic's first post
+ def update_post(post_id:, raw:, edit_reason: "Documentation sync")
+ uri = URI.join(DISCOURSE_URL, "/posts/#{post_id}.json")
+ payload = {
+ post: {
+ raw: raw,
+ edit_reason: edit_reason,
+ },
+ }
+ http_put(uri, payload)
+ end
+
+ # Get a topic's first post ID
+ def first_post_id(topic_id)
+ uri = URI.join(DISCOURSE_URL, "/t/#{topic_id}.json")
+ response = http_get(uri)
+ return nil unless response.is_a?(Net::HTTPSuccess)
+
+ data = JSON.parse(response.body)
+ data.dig("post_stream", "posts", 0, "id")
+ end
+
+ # --- HTTP helpers ---
+
+ def http_get(uri)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ request = Net::HTTP::Get.new(uri, headers)
+ http.request(request)
+ end
+
+ def http_post(uri, payload)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ request = Net::HTTP::Post.new(uri, headers)
+ request.body = payload.to_json
+ http.request(request)
+ end
+
+ def http_put(uri, payload)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ request = Net::HTTP::Put.new(uri, headers)
+ request.body = payload.to_json
+ http.request(request)
+ end
+end
+
+# ---------------------------------------------------------------------------
+# Nav Parser — extract title + path from mkdocs-sp.yml nav
+# ---------------------------------------------------------------------------
+
+module NavParser
+ module_function
+
+ # Parse the mkdocs nav structure into a flat list of { title:, path: }
+ def parse(mkdocs_yml_path)
+ config = YAML.safe_load(File.read(mkdocs_yml_path))
+ nav = config["nav"] || []
+ flatten_nav(nav)
+ end
+
+ def flatten_nav(items, prefix_parts = [])
+ result = []
+ items.each do |item|
+ case item
+ when Hash
+ item.each do |key, value|
+ case value
+ when String
+ # Skip external links
+ next if value.start_with?("http")
+ result << { title: key, path: value, breadcrumb: prefix_parts + [key] }
+ when Array
+ result.concat(flatten_nav(value, prefix_parts + [key]))
+ end
+ end
+ when String
+ # Bare path without title (unlikely in our nav)
+ result << { title: File.basename(item, ".md").tr("-", " ").capitalize, path: item }
+ end
+ end
+ result
+ end
+end
+
+# ---------------------------------------------------------------------------
+# Content Cache — skip unchanged files
+# ---------------------------------------------------------------------------
+
+module ContentCache
+ module_function
+
+ def cache_path(file_path)
+ slug = file_path.gsub("/", "_").gsub(".md", "")
+ File.join(CACHE_DIR, "#{slug}.sha256")
+ end
+
+ def changed?(file_path, content_hash)
+ cached = cache_path(file_path)
+ return true unless File.exist?(cached)
+ File.read(cached).strip != content_hash
+ end
+
+ def save(file_path, content_hash)
+ FileUtils.mkdir_p(CACHE_DIR)
+ File.write(cache_path(file_path), content_hash)
+ end
+end
+
+# ---------------------------------------------------------------------------
+# Main Sync Logic
+# ---------------------------------------------------------------------------
+
+def sync_doc(entry, category_id)
+ file_path = File.join(DOCS_DIR, entry[:path])
+ unless File.exist?(file_path)
+ puts " ⚠ File not found: #{file_path}"
+ return :skipped
+ end
+
+ raw_content = File.read(file_path, encoding: "utf-8")
+ content_hash = Digest::SHA256.hexdigest(raw_content)
+
+ # Skip unchanged files
+ unless ContentCache.changed?(entry[:path], content_hash)
+ puts " ✓ Unchanged: #{entry[:path]}" if VERBOSE
+ return :unchanged
+ end
+
+ # Convert MkDocs → Discourse markdown
+ converted = MkDocsConverter.convert(raw_content, file_path: file_path)
+
+ # Prepend breadcrumb navigation
+ if entry[:breadcrumb] && entry[:breadcrumb].length > 1
+ breadcrumb = entry[:breadcrumb][0..-2].join(" › ")
+ converted = "*#{breadcrumb}*\n\n#{converted}"
+ end
+
+ # Append sync ID comment and docs site link
+ docs_url = "#{DOCS_BASE_URL}/#{entry[:path].sub(/\.md$/, '/')}"
+ converted += "\n\n---\n"
+ converted += "*This documentation is automatically synced from the [sunnypilot docs site](#{docs_url}).*\n"
+ converted += "\n"
+
+ title = entry[:title]
+
+ if DRY_RUN
+ puts " → Would sync: \"#{title}\" (#{entry[:path]})"
+ if VERBOSE
+ puts " --- Converted content (first 500 chars) ---"
+ puts " #{converted[0..500].gsub("\n", "\n ")}"
+ puts " ---"
+ end
+ ContentCache.save(entry[:path], content_hash) # Cache even in dry-run for testing
+ return :would_sync
+ end
+
+ # Check if topic already exists
+ existing = DiscourseAPI.find_topic_by_sync_id(entry[:path])
+
+ if existing
+ # Update existing topic
+ post_id = DiscourseAPI.first_post_id(existing["id"])
+ if post_id
+ response = DiscourseAPI.update_post(post_id: post_id, raw: converted)
+ if response.is_a?(Net::HTTPSuccess)
+ puts " ✓ Updated: \"#{title}\" (topic ##{existing['id']})"
+ ContentCache.save(entry[:path], content_hash)
+ return :updated
+ else
+ puts " ✗ Failed to update: #{response.code} #{response.body[0..200]}"
+ return :error
+ end
+ else
+ puts " ✗ Could not find first post for topic ##{existing['id']}"
+ return :error
+ end
+ else
+ # Create new topic
+ response = DiscourseAPI.create_topic(
+ title: "#{title} — sunnypilot Docs",
+ raw: converted,
+ category_id: category_id,
+ tags: ["docs", "auto-sync"]
+ )
+ if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
+ data = JSON.parse(response.body)
+ puts " ✓ Created: \"#{title}\" (topic ##{data['topic_id']})"
+ ContentCache.save(entry[:path], content_hash)
+ return :created
+ else
+ puts " ✗ Failed to create: #{response.code} #{response.body[0..200]}"
+ return :error
+ end
+ end
+end
+
+# ---------------------------------------------------------------------------
+# Entry Point
+# ---------------------------------------------------------------------------
+
+def main
+ puts "sunnypilot Documentation → Discourse Sync"
+ puts "=" * 50
+ puts "Mode: #{DRY_RUN ? 'DRY RUN' : 'LIVE'}"
+ puts "Docs dir: #{DOCS_DIR}"
+ puts "Discourse: #{DISCOURSE_URL || '(dry-run, no URL)'}"
+ puts
+
+ # Parse nav entries
+ nav_entries = NavParser.parse(MKDOCS_YML)
+ puts "Found #{nav_entries.length} nav entries"
+
+ # Filter out skipped files
+ nav_entries.reject! { |e| SKIP_FILES.include?(File.basename(e[:path])) }
+ puts "After filtering: #{nav_entries.length} pages to sync"
+ puts
+
+ # Resolve category ID
+ category_id = nil
+ unless DRY_RUN
+ category_id = DiscourseAPI.category_id(DISCOURSE_CATEGORY)
+ abort "Error: Could not find category '#{DISCOURSE_CATEGORY}'" unless category_id
+ puts "Category: #{DISCOURSE_CATEGORY} (ID: #{category_id})"
+ puts
+ end
+
+ # Sync each page
+ stats = { created: 0, updated: 0, unchanged: 0, skipped: 0, error: 0, would_sync: 0 }
+ nav_entries.each_with_index do |entry, idx|
+ # Rate limiting: 1 request per second for live mode
+ sleep(1) if !DRY_RUN && idx > 0
+
+ result = sync_doc(entry, category_id)
+ stats[result] = (stats[result] || 0) + 1
+ end
+
+ # Summary
+ puts
+ puts "=" * 50
+ puts "Sync complete!"
+ stats.each do |status, count|
+ next if count == 0
+ puts " #{status}: #{count}"
+ end
+end
+
+main
diff --git a/docs_sp/tools/test_converter.py b/docs_sp/tools/test_converter.py
new file mode 100644
index 0000000000..f9befb8fbe
--- /dev/null
+++ b/docs_sp/tools/test_converter.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+"""Test the MkDocs → Discourse admonition conversion logic.
+
+This mirrors the Ruby converter's logic to verify correctness.
+Run: python3 docs_sp/tools/test_converter.py
+"""
+
+import re
+import sys
+
+ADMONITION_MAP = {
+ "note": "NOTE",
+ "abstract": "ABSTRACT",
+ "info": "INFO",
+ "tip": "TIP",
+ "success": "SUCCESS",
+ "question": "QUESTION",
+ "warning": "WARNING",
+ "failure": "FAILURE",
+ "danger": "DANGER",
+ "bug": "BUG",
+ "example": "EXAMPLE",
+ "quote": "QUOTE",
+}
+
+
+def convert_admonitions(content: str) -> str:
+ """Convert MkDocs admonitions to Obsidian/Discourse callouts."""
+ lines = content.splitlines(keepends=True)
+ result = []
+ i = 0
+
+ pattern = re.compile(r'^(\s*)(!{3}|\?{3}\+?) (\w+)(?: "([^"]*)")?')
+
+ while i < len(lines):
+ line = lines[i]
+ m = pattern.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:
+ header += f" *(click to {'expand' if collapsed else 'collapse'})*"
+
+ 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() == "":
+ # Blank line: only treat as part of admonition if the
+ # next non-blank line is still indented at content level
+ 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:
+ # Blank line ends the admonition
+ break
+ else:
+ break
+ else:
+ result.append(line)
+ i += 1
+
+ return "".join(result)
+
+
+# ---- Tests ----
+
+def test_basic_warning():
+ input_text = '''!!! warning "Important"
+ sunnypilot is a **driver assistance** system.
+ Always pay attention.
+'''
+ expected = '''> [!WARNING] Important
+> sunnypilot is a **driver assistance** system.
+> Always pay attention.
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL basic_warning:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: basic_warning")
+
+
+def test_info_no_title():
+ input_text = '''!!! info
+ Content line 1
+ Content line 2
+'''
+ expected = '''> [!INFO]
+> Content line 1
+> Content line 2
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL info_no_title:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: info_no_title")
+
+
+def test_info_with_title():
+ input_text = '''!!! info "Requirements"
+ - Longitudinal control must be available
+ - ICBM must be enabled
+'''
+ expected = '''> [!INFO] Requirements
+> - Longitudinal control must be available
+> - ICBM must be enabled
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL info_with_title:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: info_with_title")
+
+
+def test_danger():
+ input_text = '''!!! danger "Important"
+ sunnypilot is a **driver assistance** system. It is **NOT** a self-driving system.
+'''
+ expected = '''> [!DANGER] Important
+> sunnypilot is a **driver assistance** system. It is **NOT** a self-driving system.
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL danger:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: danger")
+
+
+def test_tip():
+ input_text = '''!!! tip
+ The more detail you provide, the faster we can diagnose and fix the issue.
+'''
+ expected = '''> [!TIP]
+> The more detail you provide, the faster we can diagnose and fix the issue.
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL tip:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: tip")
+
+
+def test_multiline_with_blank():
+ input_text = '''!!! warning
+ Line 1
+
+ Line 2 after blank
+'''
+ expected = '''> [!WARNING]
+> Line 1
+>
+> Line 2 after blank
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL multiline_with_blank:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: multiline_with_blank")
+
+
+def test_collapsible():
+ input_text = '''??? warning "Click to see"
+ Hidden content
+'''
+ expected = '''> [!WARNING] Click to see
+> Hidden content
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL collapsible:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: collapsible")
+
+
+def test_collapsible_open():
+ input_text = '''???+ info "Open by default"
+ Visible content
+'''
+ expected = '''> [!INFO] Open by default
+> Visible content
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL collapsible_open:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: collapsible_open")
+
+
+def test_surrounded_by_content():
+ input_text = '''Some text before.
+
+!!! note "Note Title"
+ Note content here.
+
+Some text after.
+'''
+ expected = '''Some text before.
+
+> [!NOTE] Note Title
+> Note content here.
+
+Some text after.
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL surrounded:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: surrounded_by_content")
+
+
+def test_multiple_admonitions():
+ input_text = '''!!! info "Requirements"
+ - Req 1
+ - Req 2
+
+!!! warning "Vehicle Restrictions"
+ - Tesla: disabled on release
+ - Rivian: always disabled
+'''
+ expected = '''> [!INFO] Requirements
+> - Req 1
+> - Req 2
+
+> [!WARNING] Vehicle Restrictions
+> - Tesla: disabled on release
+> - Rivian: always disabled
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL multiple:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: multiple_admonitions")
+
+
+def test_real_doc_snippet():
+ """Test with an actual snippet from docs_sp/settings/speed-limit.md."""
+ input_text = '''## Speed Limit Mode
+
+| Property | Value |
+|----------|-------|
+| **Param** | `SpeedLimitMode` |
+| **Type** | Multi-button selector |
+
+!!! info "Requirements"
+ - Longitudinal control must be available, **or** ICBM must be enabled
+
+!!! warning "Vehicle Restrictions"
+ - **Tesla:** Speed Limit Assist mode is disabled on release branches
+ - **Rivian:** Speed Limit Assist mode is always disabled
+
+---
+'''
+ expected = '''## Speed Limit Mode
+
+| Property | Value |
+|----------|-------|
+| **Param** | `SpeedLimitMode` |
+| **Type** | Multi-button selector |
+
+> [!INFO] Requirements
+> - Longitudinal control must be available, **or** ICBM must be enabled
+
+> [!WARNING] Vehicle Restrictions
+> - **Tesla:** Speed Limit Assist mode is disabled on release branches
+> - **Rivian:** Speed Limit Assist mode is always disabled
+
+---
+'''
+ result = convert_admonitions(input_text)
+ assert result == expected, f"FAIL real_doc:\n{result!r}\n!=\n{expected!r}"
+ print(" PASS: real_doc_snippet")
+
+
+if __name__ == "__main__":
+ print("Testing MkDocs → Discourse admonition conversion:")
+ tests = [
+ test_basic_warning,
+ test_info_no_title,
+ test_info_with_title,
+ test_danger,
+ test_tip,
+ test_multiline_with_blank,
+ test_collapsible,
+ test_collapsible_open,
+ test_surrounded_by_content,
+ test_multiple_admonitions,
+ test_real_doc_snippet,
+ ]
+ passed = 0
+ failed = 0
+ for test in tests:
+ try:
+ test()
+ passed += 1
+ except AssertionError as e:
+ print(f" FAIL: {test.__name__}: {e}")
+ failed += 1
+ except Exception as e:
+ print(f" FAIL: {test.__name__}: {e}")
+ failed += 1
+
+ print(f"\n{passed}/{passed + failed} tests passed")
+ sys.exit(1 if failed > 0 else 0)