blog.gno
4.15 Kb · 142 lines
1// Package blog is a multi-author on-chain blog realm for gno.land.
2//
3// Any caller can Publish a post; posts are stored persistently and rendered
4// newest-first. Each post records its author (the calling address) and the
5// block height at which it was published.
6package blog
7
8import (
9 "strconv"
10 "strings"
11
12 "chain"
13 "chain/markdown"
14 "chain/runtime"
15
16 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/store/v0"
17 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
18 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
19)
20
21// selfPath is this realm's own page. A root-relative link resolves against
22// the domain, not the realm, so every link back into it names it in full.
23const selfPath = "/r/moul/x/daily/blog/v1"
24
25// Post is a single blog entry. It carries no id field: the id belongs to the
26// store, which hands it back on lookup and iteration.
27type Post struct {
28 Title string
29 Body string
30 Author address
31 Height int64
32}
33
34// posts holds every published post. The store keys by seqid, so iteration is
35// in publication order for any number of posts; v0 kept its own nextID plus a
36// key() that zero-padded to width 12 and stopped ordering past it.
37var posts = store.Named("post")
38
39// Publish creates a new post authored by the caller and returns its id.
40// It is a state-mutating exported function, so it takes `cur realm`.
41func Publish(cur realm, title string, body string) int {
42 if !cur.IsCurrent() {
43 panic("blog: spoofed realm")
44 }
45 title = strings.TrimSpace(title)
46 body = strings.TrimSpace(body)
47 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(title)) == "" {
48 panic("blog: title must not be empty")
49 }
50 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(body)) == "" {
51 panic("blog: body must not be empty")
52 }
53
54 author := cur.Previous().Address()
55 id := posts.Add(&Post{
56 Title: title,
57 Body: body,
58 Author: author,
59 Height: runtime.ChainHeight(),
60 })
61
62 chain.Emit(
63 "PostPublished",
64 "id", id.String(),
65 "author", author.String(),
66 )
67 return int(id)
68}
69
70// PostCount returns the number of published posts.
71func PostCount() int {
72 return posts.Len()
73}
74
75// Render lists all posts newest-first at the root, or a single post's full
76// text at path "/<id>". Render is NOT a crossing function.
77func Render(path string) string {
78 path = strings.TrimSpace(path)
79 if path == "" || path == "/" {
80 return renderList()
81 }
82 idStr := strings.TrimPrefix(path, "/")
83 id, ok := store.ParseID(idStr)
84 if !ok {
85 return "# Not found\n\nInvalid post path: " + md.InlineCode(path) + "\n\n[← back](" + selfPath + ")\n"
86 }
87 v, ok := posts.Get(id)
88 if !ok {
89 return "# Not found\n\nNo post with id " + idStr + ".\n\n[← back](" + selfPath + ")\n"
90 }
91 return renderPost(v.(*Post))
92}
93
94func renderList() string {
95 var b strings.Builder
96 b.WriteString("# 📝 Blog\n\n")
97 if posts.Len() == 0 {
98 b.WriteString("_No posts yet. Be the first to `Publish`._\n")
99 return b.String()
100 }
101 b.WriteString(strconv.Itoa(posts.Len()) + " post(s), newest first.\n\n")
102
103 // Ids ascend with publication, so reverse iteration is newest-first.
104 posts.EachReverse(func(id store.ID, v any) {
105 p := v.(*Post)
106 b.WriteString("## [")
107 b.WriteString(ui.Inline(p.Title))
108 b.WriteString("](" + selfPath + ":/")
109 b.WriteString(id.String())
110 b.WriteString(")\n\n")
111 b.WriteString("by `")
112 b.WriteString(p.Author.String())
113 b.WriteString("` · height ")
114 b.WriteString(strconv.FormatInt(p.Height, 10))
115 b.WriteString("\n\n")
116 b.WriteString(ui.Excerpt(strings.TrimSpace(p.Body), 140))
117 b.WriteString("\n\n---\n\n")
118 })
119 return b.String()
120}
121
122func renderPost(p *Post) string {
123 var b strings.Builder
124 b.WriteString("# ")
125 b.WriteString(ui.Inline(p.Title))
126 b.WriteString("\n\n")
127 b.WriteString("by `")
128 b.WriteString(p.Author.String())
129 b.WriteString("` · height ")
130 b.WriteString(strconv.FormatInt(p.Height, 10))
131 b.WriteString("\n\n")
132 // Anyone may publish, so the body is the caller's text, not this realm's
133 // markdown: each line is escaped, and a blank line still breaks a paragraph.
134 for i, line := range strings.Split(p.Body, "\n") {
135 if i > 0 {
136 b.WriteString(" \n")
137 }
138 b.WriteString(ui.Inline(line))
139 }
140 b.WriteString("\n\n---\n\n[← back to all posts](" + selfPath + ")\n")
141 return b.String()
142}