From 5508e99b790c1e00ed976e462caedceeb40e9bb0 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sun, 31 May 2026 13:41:14 +0200 Subject: [PATCH] Move each page into its own template file Refactor internal/render so every served page is a text/template in templates/, executed by a thin exported func. text/template (not html) keeps gemtext unescaped. The whitespace-heavy / union-branching sub-renders (post summaries, embeds, document blocks, doc links) stay in Go and are exposed to the templates as funcs, so layout lives in the template files while the gnarly bits keep their exact output. A golden test (testdata/golden/*.gmi) snapshots every page and is verified to match the pre-refactor output byte-for-byte; it stays as a regression guard (regenerate with `go test ./internal/render -update-golden`). --- internal/render/golden_test.go | 144 +++++++ internal/render/render.go | 358 ++++++------------ internal/render/templates.go | 88 +++++ internal/render/templates/document.tmpl | 11 + internal/render/templates/index.tmpl | 25 ++ internal/render/templates/list.tmpl | 5 + internal/render/templates/me.tmpl | 22 ++ internal/render/templates/post.tmpl | 13 + internal/render/templates/profile.tmpl | 17 + internal/render/templates/publication.tmpl | 10 + internal/render/templates/publications.tmpl | 22 ++ internal/render/testdata/golden/document.gmi | 28 ++ .../render/testdata/golden/index_anon.gmi | 20 + .../render/testdata/golden/index_signedin.gmi | 19 + internal/render/testdata/golden/list.gmi | 18 + .../render/testdata/golden/list_empty.gmi | 3 + internal/render/testdata/golden/me_anon.gmi | 7 + .../render/testdata/golden/me_signedin.gmi | 8 + .../render/testdata/golden/me_unlinked.gmi | 10 + internal/render/testdata/golden/post.gmi | 17 + internal/render/testdata/golden/profile.gmi | 18 + .../render/testdata/golden/publication.gmi | 10 + .../testdata/golden/publication_empty.gmi | 6 + .../render/testdata/golden/publications.gmi | 19 + 24 files changed, 649 insertions(+), 249 deletions(-) create mode 100644 internal/render/golden_test.go create mode 100644 internal/render/templates.go create mode 100644 internal/render/templates/document.tmpl create mode 100644 internal/render/templates/index.tmpl create mode 100644 internal/render/templates/list.tmpl create mode 100644 internal/render/templates/me.tmpl create mode 100644 internal/render/templates/post.tmpl create mode 100644 internal/render/templates/profile.tmpl create mode 100644 internal/render/templates/publication.tmpl create mode 100644 internal/render/templates/publications.tmpl create mode 100644 internal/render/testdata/golden/document.gmi create mode 100644 internal/render/testdata/golden/index_anon.gmi create mode 100644 internal/render/testdata/golden/index_signedin.gmi create mode 100644 internal/render/testdata/golden/list.gmi create mode 100644 internal/render/testdata/golden/list_empty.gmi create mode 100644 internal/render/testdata/golden/me_anon.gmi create mode 100644 internal/render/testdata/golden/me_signedin.gmi create mode 100644 internal/render/testdata/golden/me_unlinked.gmi create mode 100644 internal/render/testdata/golden/post.gmi create mode 100644 internal/render/testdata/golden/profile.gmi create mode 100644 internal/render/testdata/golden/publication.gmi create mode 100644 internal/render/testdata/golden/publication_empty.gmi create mode 100644 internal/render/testdata/golden/publications.gmi diff --git a/internal/render/golden_test.go b/internal/render/golden_test.go new file mode 100644 index 0000000..34d3089 --- /dev/null +++ b/internal/render/golden_test.go @@ -0,0 +1,144 @@ +package render + +import ( + "bytes" + "encoding/json" + "flag" + "io" + "os" + "path/filepath" + "testing" + + "tangled.org/byjp.me/deepsky/internal/bsky" + "tangled.org/byjp.me/deepsky/internal/doc" +) + +// Run `go test ./internal/render -update-golden` to (re)capture the snapshots in +// testdata/golden. Normal runs compare against them, guarding the exact gemtext +// each page emits across the template refactor. +var updateGolden = flag.Bool("update-golden", false, "rewrite testdata/golden snapshots") + +// cases maps a golden file name to the page render it captures. +func goldenCases() map[string]func(io.Writer) { + viewer := &Viewer{DID: "did:plc:abc", Handle: "byjp.me"} + fp := "su5h7nhduqnkhzm2zv4uph5jgqtdtxzq2rg5qyloprpnhbh7yf6q" + + profile := &bsky.Profile{ + DID: "did:plc:abc", Handle: "byjp.me", DisplayName: "JP", + Description: "Maker of things.\nSecond line.", + Avatar: "https://cdn.example/av.jpg", + FollowersCount: 1234, FollowsCount: 56, PostsCount: 7890, + CreatedAt: "2023-01-15T10:00:00Z", + } + + author := bsky.Author{DID: "did:plc:abc", Handle: "byjp.me", DisplayName: "JP"} + simple := func(text, tid string) *bsky.PostView { + return &bsky.PostView{ + URI: "at://did:plc:abc/app.bsky.feed.post/" + tid, Author: author, + Record: bsky.PostRecord{Text: text, CreatedAt: "2026-05-30T12:00:00Z"}, + ReplyCount: 1, RepostCount: 2, LikeCount: 3, + } + } + + imagesEmbed := &bsky.Embed{Type: "app.bsky.embed.images#view", Images: []bsky.ImageView{{Alt: "a cat", Thumb: "t", Fullsize: "f"}}} + quoteAndMedia := &bsky.Embed{ + Type: "app.bsky.embed.recordWithMedia#view", + Media: imagesEmbed, + Record: json.RawMessage(`{"record":{"$type":"app.bsky.embed.record#viewRecord",` + + `"uri":"at://did:plc:other/app.bsky.feed.post/qrkey",` + + `"author":{"did":"did:plc:other","handle":"other.bsky.social","displayName":"Other"},` + + `"value":{"text":"the quoted text"}}}`), + } + rich := &bsky.PostView{ + URI: "at://did:plc:abc/app.bsky.feed.post/postrkey", Author: author, + Record: bsky.PostRecord{ + Text: "see foo", CreatedAt: "2026-05-30T12:00:00Z", + Facets: []bsky.Facet{{ + Index: bsky.FacetIndex{ByteStart: 4, ByteEnd: 7}, + Features: []bsky.FacetFeature{{Type: "app.bsky.richtext.facet#link", URI: "https://example.com"}}, + }}, + }, + Embed: quoteAndMedia, + ReplyCount: 4, RepostCount: 5, LikeCount: 6, QuoteCount: 7, + } + + items := []ListItem{ + {Post: simple("a repost", "r1"), RepostBy: "reposter.bsky.social"}, + {Post: simple("a reply", "r2"), Reply: true}, + {Post: rich}, + } + + pub := &bsky.Publication{ + URI: "at://did:plc:abc/site.standard.publication/pub1", + URL: "https://blog.example", Name: "My Blog", Description: "Thoughts and things.", + } + doc1 := bsky.Document{RKey: "doc1", Title: "First Post", PublishedAt: "2026-05-01T00:00:00Z"} + doc2 := bsky.Document{RKey: "doc2", PublishedAt: ""} // untitled, undated + groups := []PubGroup{ + {Pub: pub, Docs: []bsky.Document{doc1, doc2}}, + {Pub: nil, Docs: []bsky.Document{{RKey: "loose1", Title: "Loose Doc", PublishedAt: "2026-04-01T00:00:00Z"}}}, + {Pub: &bsky.Publication{URI: "at://did:plc:abc/site.standard.publication/pub2", Name: "Empty Pub"}}, + } + + document := &bsky.Document{ + RKey: "doc1", Title: "My Document", Description: "A short description.", + PublishedAt: "2026-05-01T00:00:00Z", Tags: []string{"go", "gemini"}, + CanonicalURL: "https://blog.example/first", Path: "/first", + } + blocks := []doc.Block{ + {Kind: doc.Heading, Level: 1, Text: "Section"}, + {Kind: doc.Paragraph, Text: "A paragraph with a link.", Links: []doc.Link{{URI: "https://example.com", Text: "example"}}}, + {Kind: doc.Code, Text: "fmt.Println(\"hi\")"}, + {Kind: doc.Quote, Text: "Quoted line."}, + {Kind: doc.ListItem, Text: "a bullet"}, + {Kind: doc.ListItem, Text: "step one", Ordered: true}, + {Kind: doc.ListItem, Text: "step two", Ordered: true}, + {Kind: doc.Image, Alt: "a picture", MIME: "image/png"}, + {Kind: doc.Embed, URL: "https://video.example"}, + } + + return map[string]func(io.Writer){ + "index_anon": func(w io.Writer) { Index(w, nil) }, + "index_signedin": func(w io.Writer) { Index(w, viewer) }, + "me_anon": func(w io.Writer) { Me(w, nil, "") }, + "me_unlinked": func(w io.Writer) { Me(w, nil, fp) }, + "me_signedin": func(w io.Writer) { Me(w, viewer, fp) }, + "profile": func(w io.Writer) { Profile(w, "byjp.me", profile) }, + "list": func(w io.Writer) { ListPage(w, "Posts", "/p/byjp.me/posts/", items, "cursor123") }, + "list_empty": func(w io.Writer) { ListPage(w, "Likes", "/p/byjp.me/likes/", nil, "") }, + "post": func(w io.Writer) { Post(w, rich) }, + "publications": func(w io.Writer) { Publications(w, "byjp.me", groups, true) }, + "publication": func(w io.Writer) { Publication(w, "byjp.me", "pub1", pub, []bsky.Document{doc1, doc2}, "5") }, + "publication_empty": func(w io.Writer) { Publication(w, "byjp.me", "pub2", &bsky.Publication{Name: "Empty Pub"}, nil, "") }, + "document": func(w io.Writer) { Document(w, "byjp.me", document, blocks, pub) }, + } +} + +func TestGolden(t *testing.T) { + dir := filepath.Join("testdata", "golden") + for name, render := range goldenCases() { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + render(&buf) + path := filepath.Join(dir, name+".gmi") + + if *updateGolden { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -update-golden to create)", err) + } + if !bytes.Equal(want, buf.Bytes()) { + t.Errorf("output differs from %s\n--- want ---\n%s\n--- got ---\n%s", path, want, buf.Bytes()) + } + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 2e887eb..8e3bd5e 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -1,10 +1,15 @@ // Package render turns Bluesky data into gemtext (text/gemini). +// +// Each page that the server serves has a template in templates/; the exported +// funcs here build the page's data and execute its template. Sub-renders with +// fiddly whitespace or union-type branching (post summaries, embeds, document +// blocks) live in this file and are exposed to the templates as funcs (see +// templates.go). package render import ( "fmt" "io" - "net/url" "strings" "time" @@ -29,134 +34,124 @@ func (v *Viewer) Name() string { return v.DID } -// ref returns the actor reference to use in /p/ URLs (handle preferred). -func (v *Viewer) ref() string { +// Ref returns the actor reference to use in /p/ URLs (handle preferred). +func (v *Viewer) Ref() string { if v.Handle != "" { return v.Handle } return v.DID } -// Index writes the site landing page, greeting the viewer if recognised. -func Index(w io.Writer, v *Viewer) { - fmt.Fprint(w, "# ๐ŸŒŒ deepsky.byjp.me\n\n") - fmt.Fprint(w, - "A Gemini window onto Bluesky. Read any account's profile, posts, "+ - "replies, reposts, likes, and publications as gemtext โ€” no account "+ - "or sign-in required.\n\n") - - if v != nil { - fmt.Fprintf(w, "You're signed in as %s.\n\n", v.Name()) - fmt.Fprintf(w, "=> /p/%s ๐Ÿฆ‹ Your profile\n", v.ref()) - fmt.Fprint(w, "=> /me ๐Ÿชช Your identity\n\n") - } - - fmt.Fprint(w, "## Browse a profile\n\n") - fmt.Fprint(w, - "Open any account by handle or DID โ€” for example /p/bsky.app or "+ - "/p/did:plc:z72i7hdynmk6r22z27h6tvur โ€” then follow the links to its "+ - "posts and publications.\n\n") - fmt.Fprint(w, "=> /p/byjp.me ๐Ÿฆ‹ @byjp.me โ€” creator of deepsky\n") - fmt.Fprint(w, "=> /p/bsky.app ๐Ÿฆ‹ @bsky.app โ€” Bluesky's official account\n\n") - - if v == nil { - fmt.Fprint(w, "## Who are you?\n\n") - fmt.Fprint(w, - "deepsky can recognise the Bluesky account behind your Gemini client "+ - "certificate and greet you by handle.\n\n") - fmt.Fprint(w, "=> /me ๐Ÿชช Who am I?\n\n") - } - - fmt.Fprint(w, "## Elsewhere\n\n") - fmt.Fprint(w, "=> https://geminiprotocol.net ๐Ÿ›ฐ๏ธ The Gemini Protocol\n") -} - -// Me writes the identity page describing how the server sees the visitor. -// - v != nil: recognised; link to their profile. -// - v == nil && fp != "": a client cert was presented but isn't linked yet. -// - v == nil && fp == "": no client certificate was presented. -func Me(w io.Writer, v *Viewer, fp string) { - fmt.Fprint(w, "# Your identity\n\n") - switch { - case v != nil: - fmt.Fprintf(w, "You are signed in as %s.\n\n", v.Name()) - fmt.Fprintf(w, "=> /p/%s Your profile\n\n", v.ref()) - fmt.Fprintf(w, "Bound certificate fingerprint:\n%s\n", fp) - case fp != "": - fmt.Fprintf(w, "Your client certificate is recognised but not yet linked to an "+ - "atproto account.\n\nFingerprint:\n%s\n\n", fp) - fmt.Fprint(w, "To link it, publish a signed me.byjp.deepsky.identity record from your "+ - "account with this fingerprint as its record key:\n\n"+ - " deepsky identity -handle -cert -key -app-password \n") - default: - fmt.Fprint(w, "No client certificate was presented, so you are anonymous.\n\n"+ - "Configure a client certificate in your Gemini client, then link it to your "+ - "atproto account to be recognised here.\n\n=> /me Reload after enabling your certificate\n") - } +// ListItem is one entry on a post-list page. +type ListItem struct { + Post *bsky.PostView + RepostBy string // handle of the reposter, if this entry is a repost + Reply bool // this entry is a reply } -// Profile writes a profile page: account details and links to the post lists. -func Profile(w io.Writer, urlActor string, p *bsky.Profile) { - name := p.DisplayName - if name == "" { - name = p.Handle - } - fmt.Fprintf(w, "# %s\n\n", name) - fmt.Fprintf(w, "@%s\n\n", p.Handle) +// PubGroup is a publication and its documents for the publications page. Pub is +// nil for the "loose" group of documents not tied to a known publication. +type PubGroup struct { + Pub *bsky.Publication + Docs []bsky.Document +} - if d := strings.TrimSpace(p.Description); d != "" { - fmt.Fprintln(w, d) - fmt.Fprintln(w) - } +// --- page renderers: build data, execute the matching template --- - fmt.Fprintf(w, "%s posts ยท %s followers ยท %s following\n", - number(p.PostsCount), number(p.FollowersCount), number(p.FollowsCount)) - if t, ok := parseTime(p.CreatedAt); ok { - fmt.Fprintf(w, "Joined %s\n", t.Format("2 Jan 2006")) - } - if p.Avatar != "" { - fmt.Fprintf(w, "=> /p/%s/avatar ๐Ÿ–ผ๏ธ Avatar\n", urlActor) - } +// Index writes the site landing page, greeting the viewer if recognised. +func Index(w io.Writer, v *Viewer) { execute(w, "index.tmpl", v) } - fmt.Fprint(w, "\n## Browse\n\n") - base := "/p/" + urlActor - fmt.Fprintf(w, "=> %s/posts/ Posts\n", base) - fmt.Fprintf(w, "=> %s/replies/ Replies\n", base) - fmt.Fprintf(w, "=> %s/reposts/ Reposts\n", base) - fmt.Fprintf(w, "=> %s/likes/ Likes\n", base) - fmt.Fprintf(w, "=> %s/publications/ Publications\n", base) +type meData struct { + Viewer *Viewer + FP string } -// ListItem is one entry on a post-list page. -type ListItem struct { - Post *bsky.PostView - RepostBy string // handle of the reposter, if this entry is a repost - Reply bool // this entry is a reply +// Me writes the identity page describing how the server sees the visitor: +// recognised (Viewer set), a cert present but unlinked (FP set), or anonymous. +func Me(w io.Writer, v *Viewer, fp string) { execute(w, "me.tmpl", meData{v, fp}) } + +type profileData struct { + Actor string + P *bsky.Profile +} + +// Profile writes a profile page: account details and links to the post lists. +func Profile(w io.Writer, urlActor string, p *bsky.Profile) { + execute(w, "profile.tmpl", profileData{urlActor, p}) +} + +type listData struct { + Title string + BasePath string + Items []ListItem + NextCursor string } // ListPage writes a titled list of post summaries with a pagination footer. // basePath is the list's own path (e.g. "/p/alice/posts/") used for the "More" // link; nextCursor, if non-empty, yields that link. func ListPage(w io.Writer, title, basePath string, items []ListItem, nextCursor string) { - fmt.Fprintf(w, "# %s\n\n", title) + execute(w, "list.tmpl", listData{title, basePath, items, nextCursor}) +} - if len(items) == 0 { - fmt.Fprintln(w, "Nothing to show here.") - return - } +// Post writes the full page for a single post. Images are linked as relative +// .webp files served by this server (see the image route); the post's URL must +// therefore end in a slash for the links to resolve correctly. +func Post(w io.Writer, p *bsky.PostView) { execute(w, "post.tmpl", p) } + +type publicationsData struct { + Actor string + Groups []PubGroup + Truncated bool +} - for _, it := range items { - if it.Post == nil { - continue +// Any reports whether any group has renderable content, so the template can +// fall back to "No publications yet." (mirrors the old hasAny check). +func (d publicationsData) Any() bool { + for _, g := range d.Groups { + if g.Pub != nil || len(g.Docs) > 0 { + return true } - writeSummary(w, it) } + return false +} - if nextCursor != "" { - fmt.Fprintf(w, "=> %s?cursor=%s More โ†’\n", basePath, url.QueryEscape(nextCursor)) - } +// Publications writes the publications directory: each publication and its +// documents, followed by any loose documents. +func Publications(w io.Writer, urlActor string, groups []PubGroup, truncated bool) { + execute(w, "publications.tmpl", publicationsData{urlActor, groups, truncated}) +} + +type publicationData struct { + Actor string + RKey string + Pub *bsky.Publication + Docs []bsky.Document + NextCursor string } +// Publication writes a single publication's page: its details and a paginated +// list of its documents (newest first). nextCursor, if non-empty, yields a +// "More" link. +func Publication(w io.Writer, urlActor, rkey string, pub *bsky.Publication, docs []bsky.Document, nextCursor string) { + execute(w, "publication.tmpl", publicationData{urlActor, rkey, pub, docs, nextCursor}) +} + +type documentData struct { + Actor string + Doc *bsky.Document + Blocks []doc.Block + Pub *bsky.Publication +} + +// Document writes a full document page. blocks is the parsed body; pub is the +// owning publication (for the canonical web link), if known. +func Document(w io.Writer, urlActor string, d *bsky.Document, blocks []doc.Block, pub *bsky.Publication) { + execute(w, "document.tmpl", documentData{urlActor, d, blocks, pub}) +} + +// --- sub-renderers (exposed to templates via funcMap) and helpers --- + // writeSummary renders one post as a linked summary for a list page. func writeSummary(w io.Writer, it ListItem) { post := it.Post @@ -182,41 +177,6 @@ func writeSummary(w io.Writer, it ListItem) { number(post.ReplyCount), number(post.RepostCount), number(post.LikeCount)) } -// Post writes the full page for a single post. Images are linked as relative -// .webp files served by this server (see the image route); the post's URL must -// therefore end in a slash for the links to resolve correctly. -func Post(w io.Writer, p *bsky.PostView) { - fmt.Fprintf(w, "# %s\n", p.Author.Name()) - fmt.Fprintf(w, "@%s\n", p.Author.Handle) - fmt.Fprintf(w, "=> /p/%s/ View profile\n\n", actorRef(p.Author)) - - // Stats describe this post; they sit above the body so they aren't confused - // with any quoted post below. - fmt.Fprintf(w, "%s replies ยท %s reposts ยท %s likes ยท %s quotes\n\n", - number(p.ReplyCount), number(p.RepostCount), number(p.LikeCount), number(p.QuoteCount)) - - // The timestamp links to this very post; making it a link line also - // delineates the stats from the body that follows. - if t, ok := parseTime(p.Record.CreatedAt); ok { - fmt.Fprintf(w, "=> . Posted %s\n\n", t.Format("2 Jan 2006, 15:04 MST")) - } - - if text := strings.TrimSpace(p.Record.Text); text != "" { - fmt.Fprintln(w, text) - fmt.Fprintln(w) - } - - // Links from the post text are surfaced before any embed/quoted post. - if links := p.Record.Links(); len(links) > 0 { - for _, l := range links { - fmt.Fprintf(w, "=> %s %s\n", l.URI, linkLabel(l)) - } - fmt.Fprintln(w) - } - - writeEmbed(w, p.Author.DID, p.Embed) -} - // linkLabel picks the label for a post link: the post's own anchor text when it // added a custom label, or a generic note when the text is just the URL. func linkLabel(l bsky.Link) string { @@ -328,94 +288,21 @@ func attachmentSummary(e *bsky.Embed) string { // maxPubDocs is how many documents are previewed under each publication. const maxPubDocs = 3 -// PubGroup is a publication and its documents for the publications page. Pub is -// nil for the "loose" group of documents not tied to a known publication. -type PubGroup struct { - Pub *bsky.Publication - Docs []bsky.Document -} - -// Publications writes the publications directory: each publication and its -// documents, followed by any loose documents. -func Publications(w io.Writer, urlActor string, groups []PubGroup, truncated bool) { - fmt.Fprint(w, "# Publications\n\n") - - hasAny := false - for _, g := range groups { - if g.Pub == nil && len(g.Docs) == 0 { - continue - } - hasAny = true - - // The loose group has no publication page to link to, so show it in full. - if g.Pub == nil { - fmt.Fprint(w, "## Other documents\n\n") - for _, d := range g.Docs { - writeDocLink(w, urlActor, d) - } - fmt.Fprintln(w) - continue - } - - name := g.Pub.Name - if name == "" { - name = g.Pub.URL - } - fmt.Fprintf(w, "## %s\n", oneLine(name)) - if g.Pub.URL != "" { - fmt.Fprintf(w, "=> %s %s\n", g.Pub.URL, g.Pub.URL) - } - if d := strings.TrimSpace(g.Pub.Description); d != "" { - fmt.Fprintln(w, oneParagraph(d, 280)) - } - fmt.Fprintln(w) - - shown := g.Docs - if len(shown) > maxPubDocs { - shown = shown[:maxPubDocs] - } - for _, d := range shown { - writeDocLink(w, urlActor, d) - } - fmt.Fprintf(w, "=> /p/%s/publications/%s/ View more\n\n", urlActor, bsky.RKeyFromURI(g.Pub.URI)) - } - - if !hasAny { - fmt.Fprintln(w, "No publications yet.") - return - } - if truncated { - fmt.Fprintln(w, "(Showing the most recent documents only.)") +// PreviewDocs returns the documents shown inline under a publication on the +// publications directory (capped at maxPubDocs). +func (g PubGroup) PreviewDocs() []bsky.Document { + if len(g.Docs) > maxPubDocs { + return g.Docs[:maxPubDocs] } + return g.Docs } -// Publication writes a single publication's page: its details and a paginated -// list of its documents (newest first). nextCursor, if non-empty, yields a -// "More" link. -func Publication(w io.Writer, urlActor, rkey string, pub *bsky.Publication, docs []bsky.Document, nextCursor string) { - name := pub.Name - if name == "" { - name = pub.URL - } - fmt.Fprintf(w, "# %s\n\n", oneLine(name)) - if pub.URL != "" { - fmt.Fprintf(w, "=> %s %s\n", pub.URL, pub.URL) - } - if d := strings.TrimSpace(pub.Description); d != "" { - fmt.Fprintln(w, d) - } - - fmt.Fprint(w, "\n## Documents\n\n") - if len(docs) == 0 { - fmt.Fprintln(w, "No documents yet.") - return - } - for _, d := range docs { - writeDocLink(w, urlActor, d) - } - if nextCursor != "" { - fmt.Fprintf(w, "=> /p/%s/publications/%s/?cursor=%s More โ†’\n", urlActor, rkey, url.QueryEscape(nextCursor)) +// RKey is the publication's record key, for building its page link. +func (g PubGroup) RKey() string { + if g.Pub == nil { + return "" } + return bsky.RKeyFromURI(g.Pub.URI) } // writeDocLink renders one document as a link line with its date. @@ -431,33 +318,6 @@ func writeDocLink(w io.Writer, urlActor string, d bsky.Document) { fmt.Fprintf(w, "=> /p/%s/documents/%s/ %s%s\n", urlActor, d.RKey, prefix, oneLine(title)) } -// Document writes a full document page. blocks is the parsed body; pub is the -// owning publication (for the canonical web link), if known. -func Document(w io.Writer, urlActor string, d *bsky.Document, blocks []doc.Block, pub *bsky.Publication) { - title := strings.TrimSpace(d.Title) - if title == "" { - title = "(untitled)" - } - fmt.Fprintf(w, "# %s\n", oneLine(title)) - if desc := strings.TrimSpace(d.Description); desc != "" { - fmt.Fprintf(w, "\n%s\n", desc) - } - fmt.Fprintln(w) - - if t, ok := parseTime(d.PublishedAt); ok { - fmt.Fprintf(w, "Published %s\n", t.Format("2 Jan 2006")) - } - if len(d.Tags) > 0 { - fmt.Fprintf(w, "Tags: %s\n", strings.Join(d.Tags, ", ")) - } - if web := webURL(d, pub); web != "" { - fmt.Fprintf(w, "=> %s Read on the web\n", web) - } - - fmt.Fprintf(w, "\n%s\n\n", docRule) - writeBlocks(w, blocks) -} - // docRule separates a document's metadata header from its body. const docRule = "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" diff --git a/internal/render/templates.go b/internal/render/templates.go new file mode 100644 index 0000000..cb8e5ff --- /dev/null +++ b/internal/render/templates.go @@ -0,0 +1,88 @@ +package render + +import ( + "embed" + "fmt" + "io" + "net/url" + "strings" + "text/template" + + "tangled.org/byjp.me/deepsky/internal/bsky" + "tangled.org/byjp.me/deepsky/internal/doc" +) + +// Each page is a template in templates/. The whitespace-heavy or branchy +// sub-renders (post summaries, embeds, document blocks, document links) stay in +// Go and are exposed to the templates as funcs returning gemtext, so the page +// files describe layout while the gnarly bits keep their exact output. +// +//go:embed templates/*.tmpl +var templateFS embed.FS + +var funcMap = template.FuncMap{ + "oneLine": oneLine, + "oneParagraph": oneParagraph, + "number": number, + "trim": strings.TrimSpace, + "join": strings.Join, + "qescape": url.QueryEscape, + "rule": func() string { return docRule }, + "date": fmtDate, + "actorRef": actorRef, + "linkLabel": linkLabel, + "webURL": webURL, + "embed": embedGemtext, + "summary": summaryGemtext, + "blocks": blocksGemtext, + "docLink": docLinkGemtext, +} + +var templates = template.Must( + template.New("render").Funcs(funcMap).ParseFS(templateFS, "templates/*.tmpl"), +) + +// execute renders the named template. Render funcs return no error, so a +// (practically impossible, templates are compile-time parsed) execution failure +// is written inline rather than swallowed. +func execute(w io.Writer, name string, data any) { + if err := templates.ExecuteTemplate(w, name, data); err != nil { + fmt.Fprintf(w, "\n[render error: %v]\n", err) + } +} + +// fmtDate formats an RFC3339 timestamp with the given layout, or "" if it can't +// be parsed (so templates can `{{with date ...}}`). +func fmtDate(layout, s string) string { + if t, ok := parseTime(s); ok { + return t.Format(layout) + } + return "" +} + +// The gemtext-returning wrappers below reuse the imperative sub-renderers so +// their output is identical to the pre-template code. + +func embedGemtext(authorDID string, e *bsky.Embed) string { + var b strings.Builder + writeEmbed(&b, authorDID, e) + return b.String() +} + +func summaryGemtext(it ListItem) string { + var b strings.Builder + writeSummary(&b, it) + return b.String() +} + +func blocksGemtext(bs []doc.Block) string { + var b strings.Builder + writeBlocks(&b, bs) + return b.String() +} + +func docLinkGemtext(urlActor string, d bsky.Document) string { + var b strings.Builder + writeDocLink(&b, urlActor, d) + return b.String() +} diff --git a/internal/render/templates/document.tmpl b/internal/render/templates/document.tmpl new file mode 100644 index 0000000..a734e2e --- /dev/null +++ b/internal/render/templates/document.tmpl @@ -0,0 +1,11 @@ +# {{or (oneLine .Doc.Title) "(untitled)"}} +{{with trim .Doc.Description}} +{{.}} +{{end}} +{{with date "2 Jan 2006" .Doc.PublishedAt}}Published {{.}} +{{end}}{{with .Doc.Tags}}Tags: {{join . ", "}} +{{end}}{{with webURL .Doc .Pub}}=> {{.}} Read on the web +{{end}} +{{rule}} + +{{blocks .Blocks -}} diff --git a/internal/render/templates/index.tmpl b/internal/render/templates/index.tmpl new file mode 100644 index 0000000..7895c45 --- /dev/null +++ b/internal/render/templates/index.tmpl @@ -0,0 +1,25 @@ +# ๐ŸŒŒ deepsky.byjp.me + +A Gemini window onto Bluesky. Read any account's profile, posts, replies, reposts, likes, and publications as gemtext โ€” no account or sign-in required. + +{{if .}}You're signed in as {{.Name}}. + +=> /p/{{.Ref}} ๐Ÿฆ‹ Your profile +=> /me ๐Ÿชช Your identity + +{{end}}## Browse a profile + +Open any account by handle or DID โ€” for example /p/bsky.app or /p/did:plc:z72i7hdynmk6r22z27h6tvur โ€” then follow the links to its posts and publications. + +=> /p/byjp.me ๐Ÿฆ‹ @byjp.me โ€” creator of deepsky +=> /p/bsky.app ๐Ÿฆ‹ @bsky.app โ€” Bluesky's official account + +{{if not .}}## Who are you? + +deepsky can recognise the Bluesky account behind your Gemini client certificate and greet you by handle. + +=> /me ๐Ÿชช Who am I? + +{{end}}## Elsewhere + +=> https://geminiprotocol.net ๐Ÿ›ฐ๏ธ The Gemini Protocol diff --git a/internal/render/templates/list.tmpl b/internal/render/templates/list.tmpl new file mode 100644 index 0000000..0abdb07 --- /dev/null +++ b/internal/render/templates/list.tmpl @@ -0,0 +1,5 @@ +# {{.Title}} + +{{if .Items}}{{range .Items}}{{if .Post}}{{summary .}}{{end}}{{end}}{{if .NextCursor}}=> {{.BasePath}}?cursor={{qescape .NextCursor}} More โ†’ +{{end}}{{else}}Nothing to show here. +{{end -}} diff --git a/internal/render/templates/me.tmpl b/internal/render/templates/me.tmpl new file mode 100644 index 0000000..a9b6d42 --- /dev/null +++ b/internal/render/templates/me.tmpl @@ -0,0 +1,22 @@ +# Your identity + +{{if .Viewer}}You are signed in as {{.Viewer.Name}}. + +=> /p/{{.Viewer.Ref}} Your profile + +Bound certificate fingerprint: +{{.FP}} +{{else if .FP}}Your client certificate is recognised but not yet linked to an atproto account. + +Fingerprint: +{{.FP}} + +To link it, publish a signed me.byjp.deepsky.identity record from your account with this fingerprint as its record key: + + deepsky identity -handle -cert -key -app-password +{{else}}No client certificate was presented, so you are anonymous. + +Configure a client certificate in your Gemini client, then link it to your atproto account to be recognised here. + +=> /me Reload after enabling your certificate +{{end -}} diff --git a/internal/render/templates/post.tmpl b/internal/render/templates/post.tmpl new file mode 100644 index 0000000..f823259 --- /dev/null +++ b/internal/render/templates/post.tmpl @@ -0,0 +1,13 @@ +# {{.Author.Name}} +@{{.Author.Handle}} +=> /p/{{actorRef .Author}}/ View profile + +{{number .ReplyCount}} replies ยท {{number .RepostCount}} reposts ยท {{number .LikeCount}} likes ยท {{number .QuoteCount}} quotes + +{{with date "2 Jan 2006, 15:04 MST" .Record.CreatedAt}}=> . Posted {{.}} + +{{end}}{{with trim .Record.Text}}{{.}} + +{{end}}{{with .Record.Links}}{{range .}}=> {{.URI}} {{linkLabel .}} +{{end}} +{{end}}{{embed .Author.DID .Embed -}} diff --git a/internal/render/templates/profile.tmpl b/internal/render/templates/profile.tmpl new file mode 100644 index 0000000..f23992a --- /dev/null +++ b/internal/render/templates/profile.tmpl @@ -0,0 +1,17 @@ +# {{or .P.DisplayName .P.Handle}} + +@{{.P.Handle}} + +{{with trim .P.Description}}{{.}} + +{{end}}{{number .P.PostsCount}} posts ยท {{number .P.FollowersCount}} followers ยท {{number .P.FollowsCount}} following +{{with date "2 Jan 2006" .P.CreatedAt}}Joined {{.}} +{{end}}{{if .P.Avatar}}=> /p/{{.Actor}}/avatar ๐Ÿ–ผ๏ธ Avatar +{{end}} +## Browse + +=> /p/{{.Actor}}/posts/ Posts +=> /p/{{.Actor}}/replies/ Replies +=> /p/{{.Actor}}/reposts/ Reposts +=> /p/{{.Actor}}/likes/ Likes +=> /p/{{.Actor}}/publications/ Publications diff --git a/internal/render/templates/publication.tmpl b/internal/render/templates/publication.tmpl new file mode 100644 index 0000000..f32bc57 --- /dev/null +++ b/internal/render/templates/publication.tmpl @@ -0,0 +1,10 @@ +# {{oneLine (or .Pub.Name .Pub.URL)}} + +{{if .Pub.URL}}=> {{.Pub.URL}} {{.Pub.URL}} +{{end}}{{with trim .Pub.Description}}{{.}} +{{end}} +## Documents + +{{if .Docs}}{{range .Docs}}{{docLink $.Actor .}}{{end}}{{if .NextCursor}}=> /p/{{.Actor}}/publications/{{.RKey}}/?cursor={{qescape .NextCursor}} More โ†’ +{{end}}{{else}}No documents yet. +{{end -}} diff --git a/internal/render/templates/publications.tmpl b/internal/render/templates/publications.tmpl new file mode 100644 index 0000000..82fda7f --- /dev/null +++ b/internal/render/templates/publications.tmpl @@ -0,0 +1,22 @@ +# Publications + +{{if .Any -}} +{{range .Groups}}{{if or .Pub .Docs -}} +{{if .Pub -}} +## {{oneLine (or .Pub.Name .Pub.URL)}} +{{if .Pub.URL}}=> {{.Pub.URL}} {{.Pub.URL}} +{{end}}{{with trim .Pub.Description}}{{oneParagraph . 280}} +{{end}} +{{range .PreviewDocs}}{{docLink $.Actor .}}{{end}}=> /p/{{$.Actor}}/publications/{{.RKey}}/ View more + +{{else -}} +## Other documents + +{{range .Docs}}{{docLink $.Actor .}}{{end}} +{{end -}} +{{end -}} +{{end}}{{if .Truncated}}(Showing the most recent documents only.) +{{end -}} +{{else -}} +No publications yet. +{{end -}} diff --git a/internal/render/testdata/golden/document.gmi b/internal/render/testdata/golden/document.gmi new file mode 100644 index 0000000..66df23f --- /dev/null +++ b/internal/render/testdata/golden/document.gmi @@ -0,0 +1,28 @@ +# My Document + +A short description. + +Published 1 May 2026 +Tags: go, gemini +=> https://blog.example/first Read on the web + +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +## Section + +A paragraph with a link. +=> https://example.com https://example.com + +``` +fmt.Println("hi") +``` + +> Quoted line. + +* a bullet +1. step one +2. step two +=> img-0.png ๐Ÿ–ผ๏ธ a picture + +=> https://video.example ๐Ÿ”— https://video.example + diff --git a/internal/render/testdata/golden/index_anon.gmi b/internal/render/testdata/golden/index_anon.gmi new file mode 100644 index 0000000..9e2d795 --- /dev/null +++ b/internal/render/testdata/golden/index_anon.gmi @@ -0,0 +1,20 @@ +# ๐ŸŒŒ deepsky.byjp.me + +A Gemini window onto Bluesky. Read any account's profile, posts, replies, reposts, likes, and publications as gemtext โ€” no account or sign-in required. + +## Browse a profile + +Open any account by handle or DID โ€” for example /p/bsky.app or /p/did:plc:z72i7hdynmk6r22z27h6tvur โ€” then follow the links to its posts and publications. + +=> /p/byjp.me ๐Ÿฆ‹ @byjp.me โ€” creator of deepsky +=> /p/bsky.app ๐Ÿฆ‹ @bsky.app โ€” Bluesky's official account + +## Who are you? + +deepsky can recognise the Bluesky account behind your Gemini client certificate and greet you by handle. + +=> /me ๐Ÿชช Who am I? + +## Elsewhere + +=> https://geminiprotocol.net ๐Ÿ›ฐ๏ธ The Gemini Protocol diff --git a/internal/render/testdata/golden/index_signedin.gmi b/internal/render/testdata/golden/index_signedin.gmi new file mode 100644 index 0000000..f9441da --- /dev/null +++ b/internal/render/testdata/golden/index_signedin.gmi @@ -0,0 +1,19 @@ +# ๐ŸŒŒ deepsky.byjp.me + +A Gemini window onto Bluesky. Read any account's profile, posts, replies, reposts, likes, and publications as gemtext โ€” no account or sign-in required. + +You're signed in as byjp.me. + +=> /p/byjp.me ๐Ÿฆ‹ Your profile +=> /me ๐Ÿชช Your identity + +## Browse a profile + +Open any account by handle or DID โ€” for example /p/bsky.app or /p/did:plc:z72i7hdynmk6r22z27h6tvur โ€” then follow the links to its posts and publications. + +=> /p/byjp.me ๐Ÿฆ‹ @byjp.me โ€” creator of deepsky +=> /p/bsky.app ๐Ÿฆ‹ @bsky.app โ€” Bluesky's official account + +## Elsewhere + +=> https://geminiprotocol.net ๐Ÿ›ฐ๏ธ The Gemini Protocol diff --git a/internal/render/testdata/golden/list.gmi b/internal/render/testdata/golden/list.gmi new file mode 100644 index 0000000..ecab5e5 --- /dev/null +++ b/internal/render/testdata/golden/list.gmi @@ -0,0 +1,18 @@ +# Posts + +โ†ป Reposted by @reposter.bsky.social +=> /p/byjp.me/p/r1/ 2026-05-30 JP +a repost +1 replies ยท 2 reposts ยท 3 likes + +โ†ฉ Reply +=> /p/byjp.me/p/r2/ 2026-05-30 JP +a reply +1 replies ยท 2 reposts ยท 3 likes + +=> /p/byjp.me/p/postrkey/ 2026-05-30 JP +see foo +[1 image, quoted post] +4 replies ยท 5 reposts ยท 6 likes + +=> /p/byjp.me/posts/?cursor=cursor123 More โ†’ diff --git a/internal/render/testdata/golden/list_empty.gmi b/internal/render/testdata/golden/list_empty.gmi new file mode 100644 index 0000000..e5615f3 --- /dev/null +++ b/internal/render/testdata/golden/list_empty.gmi @@ -0,0 +1,3 @@ +# Likes + +Nothing to show here. diff --git a/internal/render/testdata/golden/me_anon.gmi b/internal/render/testdata/golden/me_anon.gmi new file mode 100644 index 0000000..5189787 --- /dev/null +++ b/internal/render/testdata/golden/me_anon.gmi @@ -0,0 +1,7 @@ +# Your identity + +No client certificate was presented, so you are anonymous. + +Configure a client certificate in your Gemini client, then link it to your atproto account to be recognised here. + +=> /me Reload after enabling your certificate diff --git a/internal/render/testdata/golden/me_signedin.gmi b/internal/render/testdata/golden/me_signedin.gmi new file mode 100644 index 0000000..82b502d --- /dev/null +++ b/internal/render/testdata/golden/me_signedin.gmi @@ -0,0 +1,8 @@ +# Your identity + +You are signed in as byjp.me. + +=> /p/byjp.me Your profile + +Bound certificate fingerprint: +su5h7nhduqnkhzm2zv4uph5jgqtdtxzq2rg5qyloprpnhbh7yf6q diff --git a/internal/render/testdata/golden/me_unlinked.gmi b/internal/render/testdata/golden/me_unlinked.gmi new file mode 100644 index 0000000..ed70b69 --- /dev/null +++ b/internal/render/testdata/golden/me_unlinked.gmi @@ -0,0 +1,10 @@ +# Your identity + +Your client certificate is recognised but not yet linked to an atproto account. + +Fingerprint: +su5h7nhduqnkhzm2zv4uph5jgqtdtxzq2rg5qyloprpnhbh7yf6q + +To link it, publish a signed me.byjp.deepsky.identity record from your account with this fingerprint as its record key: + + deepsky identity -handle -cert -key -app-password diff --git a/internal/render/testdata/golden/post.gmi b/internal/render/testdata/golden/post.gmi new file mode 100644 index 0000000..b90facc --- /dev/null +++ b/internal/render/testdata/golden/post.gmi @@ -0,0 +1,17 @@ +# JP +@byjp.me +=> /p/byjp.me/ View profile + +4 replies ยท 5 reposts ยท 6 likes ยท 7 quotes + +=> . Posted 30 May 2026, 12:00 UTC + +see foo + +=> https://example.com foo + +=> img-0.webp ๐Ÿ–ผ๏ธ a cat + +## Quoting Other (@other.bsky.social) +> the quoted text +=> /p/other.bsky.social/p/qrkey/ View quoted post diff --git a/internal/render/testdata/golden/profile.gmi b/internal/render/testdata/golden/profile.gmi new file mode 100644 index 0000000..4c2b487 --- /dev/null +++ b/internal/render/testdata/golden/profile.gmi @@ -0,0 +1,18 @@ +# JP + +@byjp.me + +Maker of things. +Second line. + +7,890 posts ยท 1,234 followers ยท 56 following +Joined 15 Jan 2023 +=> /p/byjp.me/avatar ๐Ÿ–ผ๏ธ Avatar + +## Browse + +=> /p/byjp.me/posts/ Posts +=> /p/byjp.me/replies/ Replies +=> /p/byjp.me/reposts/ Reposts +=> /p/byjp.me/likes/ Likes +=> /p/byjp.me/publications/ Publications diff --git a/internal/render/testdata/golden/publication.gmi b/internal/render/testdata/golden/publication.gmi new file mode 100644 index 0000000..581e911 --- /dev/null +++ b/internal/render/testdata/golden/publication.gmi @@ -0,0 +1,10 @@ +# My Blog + +=> https://blog.example https://blog.example +Thoughts and things. + +## Documents + +=> /p/byjp.me/documents/doc1/ 2026-05-01 First Post +=> /p/byjp.me/documents/doc2/ (untitled) +=> /p/byjp.me/publications/pub1/?cursor=5 More โ†’ diff --git a/internal/render/testdata/golden/publication_empty.gmi b/internal/render/testdata/golden/publication_empty.gmi new file mode 100644 index 0000000..f8b0db7 --- /dev/null +++ b/internal/render/testdata/golden/publication_empty.gmi @@ -0,0 +1,6 @@ +# Empty Pub + + +## Documents + +No documents yet. diff --git a/internal/render/testdata/golden/publications.gmi b/internal/render/testdata/golden/publications.gmi new file mode 100644 index 0000000..fe4ff39 --- /dev/null +++ b/internal/render/testdata/golden/publications.gmi @@ -0,0 +1,19 @@ +# Publications + +## My Blog +=> https://blog.example https://blog.example +Thoughts and things. + +=> /p/byjp.me/documents/doc1/ 2026-05-01 First Post +=> /p/byjp.me/documents/doc2/ (untitled) +=> /p/byjp.me/publications/pub1/ View more + +## Other documents + +=> /p/byjp.me/documents/loose1/ 2026-04-01 Loose Doc + +## Empty Pub + +=> /p/byjp.me/publications/pub2/ View more + +(Showing the most recent documents only.) -- 2.51.2