krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

89c90d11d80fde63997ca029e14806df0319a541

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T08:27:37Z

Sync bundled man page catalog from man.sr.ht (#7)

The More tab's official man-page list was a hardcoded array, updated by
hand. Move it to a checked-in Hutch/Resources/man-pages.json (bundled via
the synchronized group) loaded by a new ManPageCatalog, with the previous
list kept as a built-in fallback.

Add scripts/sync_man_pages.py, which re-derives the catalog from the
man.sr.ht landing page, and a weekly scheduled workflow that runs it and
opens a pull request when the list diverges upstream. The script refuses to
write a suspiciously short list so a markup change can't gut the catalog.

This first sync also picks up chat.sr.ht, which upstream added since the
list was last hand-edited. Tests cover catalog loading, the fallback, and
JSON decoding.
 .github/workflows/sync-man-pages.yml      | 55 +++++++++++++++++++++++++++
 Hutch/Resources/man-pages.json            | 50 ++++++++++++++++++++++++
 Hutch/Views/More/ManPageBrowserView.swift | 19 ++--------
 Hutch/Views/More/ManPageCatalog.swift     | 43 +++++++++++++++++++++
 HutchTests/ManPageCatalogTests.swift      | 35 +++++++++++++++++
 scripts/sync_man_pages.py                 | 63 +++++++++++++++++++++++++++++++
 6 files changed, 250 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/sync-man-pages.yml b/.github/workflows/sync-man-pages.yml
new file mode 100644
index 0000000..ea73e26
--- /dev/null
+++ b/.github/workflows/sync-man-pages.yml
@@ -0,0 +1,55 @@
+name: Sync man pages
+
+# Keeps Hutch/Resources/man-pages.json — the official man-page list the More tab
+# renders — in step with man.sr.ht, so the catalog is never hand-maintained.
+#
+# scripts/sync_man_pages.py re-derives the list from the man.sr.ht landing page.
+# If the result differs from the committed copy, this opens (or updates) a pull
+# request with the change. The script refuses to write a suspiciously short list,
+# so an upstream markup change can't silently gut the catalog.
+#
+# Note: PRs opened with the default GITHUB_TOKEN do not themselves trigger other
+# workflows (GitHub blocks that to avoid recursion), so the Tests workflow won't
+# run on the auto-PR. That's fine here — the change is a bundled JSON resource —
+# and a maintainer can push an empty commit to run CI if they ever want it.
+on:
+  schedule:
+    # Weekly, Mondays 06:00 UTC. Upstream churns rarely; this is frequent enough.
+    - cron: "0 6 * * 1"
+  workflow_dispatch:
+
+permissions:
+  contents: write
+  pull-requests: write
+
+concurrency:
+  group: sync-man-pages
+  cancel-in-progress: true
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.12"
+
+      - name: Regenerate man page catalog
+        run: python3 scripts/sync_man_pages.py
+
+      - name: Open pull request on change
+        uses: peter-evans/create-pull-request@v7
+        with:
+          add-paths: Hutch/Resources/man-pages.json
+          branch: automated/man-page-catalog
+          delete-branch: true
+          commit-message: "Sync bundled man page catalog with man.sr.ht"
+          title: "Sync bundled man page catalog"
+          body: |
+            Automated update: the official man-page list on man.sr.ht changed.
+
+            Regenerated by `scripts/sync_man_pages.py`. Review the diff to
+            `Hutch/Resources/man-pages.json` before merging.
+          labels: documentation
diff --git a/Hutch/Resources/man-pages.json b/Hutch/Resources/man-pages.json
new file mode 100644
index 0000000..7adf284
--- /dev/null
+++ b/Hutch/Resources/man-pages.json
@@ -0,0 +1,50 @@
+[
+  {
+    "title": "builds.sr.ht",
+    "url": "https://man.sr.ht/builds.sr.ht/"
+  },
+  {
+    "title": "chat.sr.ht",
+    "url": "https://man.sr.ht/chat.sr.ht/"
+  },
+  {
+    "title": "git.sr.ht",
+    "url": "https://man.sr.ht/git.sr.ht/"
+  },
+  {
+    "title": "hg.sr.ht",
+    "url": "https://man.sr.ht/hg.sr.ht/"
+  },
+  {
+    "title": "hub.sr.ht",
+    "url": "https://man.sr.ht/hub.sr.ht/"
+  },
+  {
+    "title": "lists.sr.ht",
+    "url": "https://man.sr.ht/lists.sr.ht/"
+  },
+  {
+    "title": "man.sr.ht",
+    "url": "https://man.sr.ht/man.sr.ht/"
+  },
+  {
+    "title": "meta.sr.ht",
+    "url": "https://man.sr.ht/meta.sr.ht/"
+  },
+  {
+    "title": "paste.sr.ht",
+    "url": "https://man.sr.ht/paste.sr.ht/"
+  },
+  {
+    "title": "sr.ht",
+    "url": "https://man.sr.ht/sr.ht/"
+  },
+  {
+    "title": "srht.site",
+    "url": "https://srht.site/"
+  },
+  {
+    "title": "todo.sr.ht",
+    "url": "https://man.sr.ht/todo.sr.ht/"
+  }
+]
diff --git a/Hutch/Views/More/ManPageBrowserView.swift b/Hutch/Views/More/ManPageBrowserView.swift
index c22eafe..575b3ae 100644
--- a/Hutch/Views/More/ManPageBrowserView.swift
+++ b/Hutch/Views/More/ManPageBrowserView.swift
@@ -1,26 +1,15 @@
 import SwiftUI
 
 /// Entry point for the man.sr.ht browser in the More tab.
-/// Shows a pre-populated list of official sr.ht man pages.
+/// Shows the official sr.ht man pages from the bundled catalog, which the
+/// scheduled sync workflow keeps current with man.sr.ht.
 struct ManPageBrowserView: View {
-    private let officialDocs: [(title: String, url: URL)] = [
-        ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!),
-        ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!),
-        ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!),
-        ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!),
-        ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!),
-        ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!),
-        ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!),
-        ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!),
-        ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!),
-        ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!),
-        ("srht.site", URL(string: "https://srht.site/")!)
-    ]
+    private let officialDocs = ManPageCatalog.load()
 
     var body: some View {
         List {
             Section("Official Man Pages") {
-                ForEach(officialDocs, id: \.title) { doc in
+                ForEach(officialDocs) { doc in
                     NavigationLink(value: MoreRoute.manPage(doc.url)) {
                         Text(doc.title)
                     }
diff --git a/Hutch/Views/More/ManPageCatalog.swift b/Hutch/Views/More/ManPageCatalog.swift
new file mode 100644
index 0000000..faff0c5
--- /dev/null
+++ b/Hutch/Views/More/ManPageCatalog.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+struct ManPageCatalogEntry: Decodable, Hashable, Identifiable {
+    let title: String
+    let url: URL
+
+    var id: String { title }
+}
+
+enum ManPageCatalog {
+    /// Official sr.ht man pages, loaded from the bundled `man-pages.json` that
+    /// the scheduled sync workflow keeps in step with man.sr.ht. Falls back to a
+    /// built-in list if the resource is missing or unreadable, so the browser is
+    /// never empty.
+    static func load(bundle: Bundle = .main) -> [ManPageCatalogEntry] {
+        guard let url = bundle.url(forResource: "man-pages", withExtension: "json"),
+              let data = try? Data(contentsOf: url),
+              let entries = try? JSONDecoder().decode([ManPageCatalogEntry].self, from: data),
+              !entries.isEmpty else {
+            return fallback
+        }
+        return entries
+    }
+
+    static let fallback: [ManPageCatalogEntry] = [
+        entry("builds.sr.ht", "https://man.sr.ht/builds.sr.ht/"),
+        entry("chat.sr.ht", "https://man.sr.ht/chat.sr.ht/"),
+        entry("git.sr.ht", "https://man.sr.ht/git.sr.ht/"),
+        entry("hg.sr.ht", "https://man.sr.ht/hg.sr.ht/"),
+        entry("hub.sr.ht", "https://man.sr.ht/hub.sr.ht/"),
+        entry("lists.sr.ht", "https://man.sr.ht/lists.sr.ht/"),
+        entry("man.sr.ht", "https://man.sr.ht/man.sr.ht/"),
+        entry("meta.sr.ht", "https://man.sr.ht/meta.sr.ht/"),
+        entry("paste.sr.ht", "https://man.sr.ht/paste.sr.ht/"),
+        entry("sr.ht", "https://man.sr.ht/sr.ht/"),
+        entry("srht.site", "https://srht.site/"),
+        entry("todo.sr.ht", "https://man.sr.ht/todo.sr.ht/")
+    ]
+
+    private static func entry(_ title: String, _ urlString: String) -> ManPageCatalogEntry {
+        ManPageCatalogEntry(title: title, url: URL(string: urlString)!)
+    }
+}
diff --git a/HutchTests/ManPageCatalogTests.swift b/HutchTests/ManPageCatalogTests.swift
new file mode 100644
index 0000000..df995f8
--- /dev/null
+++ b/HutchTests/ManPageCatalogTests.swift
@@ -0,0 +1,35 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ManPageCatalogTests {
+    @Test
+    func loadsBundledCatalog() {
+        let entries = ManPageCatalog.load()
+        #expect(!entries.isEmpty)
+        #expect(entries.contains { $0.title == "git.sr.ht" })
+        #expect(entries.allSatisfy { $0.url.scheme == "https" })
+    }
+
+    @Test
+    func fallsBackWhenResourceMissing() {
+        // Foundation's own bundle has no man-pages.json, so this exercises the
+        // fallback path rather than the bundled resource.
+        let entries = ManPageCatalog.load(bundle: Bundle(for: JSONDecoder.self))
+        #expect(entries == ManPageCatalog.fallback)
+    }
+
+    @Test
+    func decodesCatalogJSON() throws {
+        let json = """
+        [
+          { "title": "git.sr.ht", "url": "https://man.sr.ht/git.sr.ht/" },
+          { "title": "srht.site", "url": "https://srht.site/" }
+        ]
+        """
+        let entries = try JSONDecoder().decode([ManPageCatalogEntry].self, from: Data(json.utf8))
+        #expect(entries.count == 2)
+        #expect(entries.first?.title == "git.sr.ht")
+        #expect(entries.first?.url == URL(string: "https://man.sr.ht/git.sr.ht/"))
+    }
+}
diff --git a/scripts/sync_man_pages.py b/scripts/sync_man_pages.py
new file mode 100755
index 0000000..c59197a
--- /dev/null
+++ b/scripts/sync_man_pages.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Regenerate the bundled sr.ht man page catalog from man.sr.ht.
+
+Fetches the man.sr.ht landing page, extracts the per-service "User Manual"
+links, and writes them to ``Hutch/Resources/man-pages.json``. The scheduled
+GitHub workflow that runs this opens a pull request whenever the result differs
+from the committed copy, so the in-app list stays in sync with upstream without
+hand edits.
+
+Run locally with ``python3 scripts/sync_man_pages.py``; exits non-zero (without
+writing) if upstream markup changed enough that too few pages were found, so a
+bad scrape can never wipe the bundled list.
+"""
+import json
+import re
+import sys
+import urllib.request
+from pathlib import Path
+
+INDEX_URL = "https://man.sr.ht/"
+OUTPUT = Path(__file__).resolve().parent.parent / "Hutch" / "Resources" / "man-pages.json"
+# The suite has ~12 service manuals; a scrape returning far fewer means the page
+# structure changed and we should fail loudly rather than commit a gutted list.
+MINIMUM_EXPECTED = 8
+
+
+def fetch(url: str) -> str:
+    request = urllib.request.Request(url, headers={"User-Agent": "hutch-man-page-sync"})
+    with urllib.request.urlopen(request, timeout=30) as response:
+        return response.read().decode("utf-8")
+
+
+def build_catalog(html: str) -> list[dict[str, str]]:
+    """Extract official man-page links, deduplicated and sorted by title."""
+    entries: dict[str, str] = {}
+    for href in re.findall(r'href="([^"]+)"', html):
+        service = re.fullmatch(r"/([a-z0-9][a-z0-9.-]*\.sr\.ht)/?", href)
+        if service:
+            title = service.group(1)
+            entries[title] = f"https://man.sr.ht/{title}/"
+        elif re.fullmatch(r"sr\.ht/?", href):
+            entries["sr.ht"] = "https://man.sr.ht/sr.ht/"
+        elif re.fullmatch(r"https://srht\.site/?", href):
+            entries["srht.site"] = "https://srht.site/"
+    return [{"title": title, "url": entries[title]} for title in sorted(entries)]
+
+
+def main() -> int:
+    catalog = build_catalog(fetch(INDEX_URL))
+    if len(catalog) < MINIMUM_EXPECTED:
+        print(
+            f"Refusing to write catalog with only {len(catalog)} entries; "
+            "man.sr.ht markup may have changed.",
+            file=sys.stderr,
+        )
+        return 1
+    OUTPUT.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n")
+    print(f"Wrote {len(catalog)} man page(s) to {OUTPUT}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())