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