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 | import SwiftUI |
1 | import 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. |
| 5 | struct ManPageBrowserView: View { |
6 | struct 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 @@ |
| |
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()) |