summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/sync-man-pages.yml55
-rw-r--r--Hutch/Resources/man-pages.json50
-rw-r--r--Hutch/Views/More/ManPageBrowserView.swift19
-rw-r--r--Hutch/Views/More/ManPageCatalog.swift43
-rw-r--r--HutchTests/ManPageCatalogTests.swift35
-rwxr-xr-xscripts/sync_man_pages.py63
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())