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 @@
1name: 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.
15on:
16 schedule:
17 # Weekly, Mondays 06:00 UTC. Upstream churns rarely; this is frequent enough.
18 - cron: "0 6 * * 1"
19 workflow_dispatch:
20
21permissions:
22 contents: write
23 pull-requests: write
24
25concurrency:
26 group: sync-man-pages
27 cancel-in-progress: true
28
29jobs:
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 @@
1import SwiftUI 1import SwiftUI
2 2
3/// Entry point for the man.sr.ht browser in the More tab. 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.
5struct ManPageBrowserView: View { 6struct ManPageBrowserView: View {
6 private let officialDocs: [(title: String, url: URL)] = [ 7 private let officialDocs = ManPageCatalog.load()
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 ]
19 8
20 var body: some View { 9 var body: some View {
21 List { 10 List {
22 Section("Official Man Pages") { 11 Section("Official Man Pages") {
23 ForEach(officialDocs, id: \.title) { doc in 12 ForEach(officialDocs) { doc in
24 NavigationLink(value: MoreRoute.manPage(doc.url)) { 13 NavigationLink(value: MoreRoute.manPage(doc.url)) {
25 Text(doc.title) 14 Text(doc.title)
26 } 15 }
Hutch/Views/More/ManPageCatalog.swift added +43
@@ -0,0 +1,43 @@
1import Foundation
2
3struct ManPageCatalogEntry: Decodable, Hashable, Identifiable {
4 let title: String
5 let url: URL
6
7 var id: String { title }
8}
9
10enum 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 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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
4Fetches the man.sr.ht landing page, extracts the per-service "User Manual"
5links, and writes them to ``Hutch/Resources/man-pages.json``. The scheduled
6GitHub workflow that runs this opens a pull request whenever the result differs
7from the committed copy, so the in-app list stays in sync with upstream without
8hand edits.
9
10Run locally with ``python3 scripts/sync_man_pages.py``; exits non-zero (without
11writing) if upstream markup changed enough that too few pages were found, so a
12bad scrape can never wipe the bundled list.
13"""
14import json
15import re
16import sys
17import urllib.request
18from pathlib import Path
19
20INDEX_URL = "https://man.sr.ht/"
21OUTPUT = 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.
24MINIMUM_EXPECTED = 8
25
26
27def 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
33def 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
48def 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
62if __name__ == "__main__":
63 sys.exit(main())