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.)