blog.gno
3.83 Kb · 155 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/runtime"
14 "chain/runtime/unsafe"
15
16 "gno.land/p/nt/avl/v0"
17)
18
19// Post is a single blog entry.
20type Post struct {
21 ID int
22 Title string
23 Body string
24 Author address
25 Height int64
26}
27
28// posts holds every published post keyed by its zero-padded id (for
29// deterministic ordering) and nextID is the id assigned to the next post.
30var (
31 posts avl.Tree // key: zero-padded id string -> *Post
32 nextID int
33)
34
35// key returns a fixed-width, zero-padded key so avl.Tree iteration is ordered
36// by numeric id (and thus by publication order).
37func key(id int) string {
38 s := strconv.Itoa(id)
39 for len(s) < 12 {
40 s = "0" + s
41 }
42 return s
43}
44
45// Publish creates a new post authored by the caller and returns its id.
46// It is a state-mutating exported function, so it takes `cur realm`.
47func Publish(cur realm, title string, body string) int {
48 title = strings.TrimSpace(title)
49 body = strings.TrimSpace(body)
50 if title == "" {
51 panic("blog: title must not be empty")
52 }
53 if body == "" {
54 panic("blog: body must not be empty")
55 }
56
57 id := nextID
58 nextID++
59
60 p := &Post{
61 ID: id,
62 Title: title,
63 Body: body,
64 Author: unsafe.PreviousRealm().Address(),
65 Height: runtime.ChainHeight(),
66 }
67 posts.Set(key(id), p)
68
69 chain.Emit(
70 "PostPublished",
71 "id", strconv.Itoa(id),
72 "author", p.Author.String(),
73 )
74 return id
75}
76
77// PostCount returns the number of published posts.
78func PostCount() int {
79 return posts.Size()
80}
81
82// snippet returns a short one-line preview of the body.
83func snippet(body string) string {
84 // collapse newlines so the preview stays on a single markdown line
85 s := strings.ReplaceAll(body, "\n", " ")
86 s = strings.TrimSpace(s)
87 const max = 140
88 if len(s) > max {
89 return s[:max] + "…"
90 }
91 return s
92}
93
94// Render lists all posts newest-first at the root, or a single post's full
95// text at path "/<id>". Render is NOT a crossing function.
96func Render(path string) string {
97 path = strings.TrimSpace(path)
98 if path == "" || path == "/" {
99 return renderList()
100 }
101 idStr := strings.TrimPrefix(path, "/")
102 id, err := strconv.Atoi(idStr)
103 if err != nil {
104 return "# Not found\n\nInvalid post path: `" + path + "`\n\n[← back](/r/REPLACE_ADDR/blog)\n"
105 }
106 v, ok := posts.Get(key(id))
107 if !ok {
108 return "# Not found\n\nNo post with id " + idStr + ".\n\n[← back](/r/REPLACE_ADDR/blog)\n"
109 }
110 return renderPost(v.(*Post))
111}
112
113func renderList() string {
114 var b strings.Builder
115 b.WriteString("# 📝 Blog\n\n")
116 if posts.Size() == 0 {
117 b.WriteString("_No posts yet. Be the first to `Publish`._\n")
118 return b.String()
119 }
120 b.WriteString(strconv.Itoa(posts.Size()) + " post(s), newest first.\n\n")
121
122 // avl iterates ascending; iterate in reverse for newest-first.
123 posts.ReverseIterate("", "", func(_ string, v interface{}) bool {
124 p := v.(*Post)
125 b.WriteString("## [")
126 b.WriteString(p.Title)
127 b.WriteString("](/r/REPLACE_ADDR/blog:/")
128 b.WriteString(strconv.Itoa(p.ID))
129 b.WriteString(")\n\n")
130 b.WriteString("by `")
131 b.WriteString(p.Author.String())
132 b.WriteString("` · height ")
133 b.WriteString(strconv.FormatInt(p.Height, 10))
134 b.WriteString("\n\n")
135 b.WriteString(snippet(p.Body))
136 b.WriteString("\n\n---\n\n")
137 return false
138 })
139 return b.String()
140}
141
142func renderPost(p *Post) string {
143 var b strings.Builder
144 b.WriteString("# ")
145 b.WriteString(p.Title)
146 b.WriteString("\n\n")
147 b.WriteString("by `")
148 b.WriteString(p.Author.String())
149 b.WriteString("` · height ")
150 b.WriteString(strconv.FormatInt(p.Height, 10))
151 b.WriteString("\n\n")
152 b.WriteString(p.Body)
153 b.WriteString("\n\n---\n\n[← back to all posts](/r/REPLACE_ADDR/blog)\n")
154 return b.String()
155}