blog.gno
3.65 Kb · 138 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/moul/kit/store/v0"
17)
18
19// Post is a single blog entry. It carries no id field: the id belongs to the
20// store, which hands it back on lookup and iteration.
21type Post struct {
22 Title string
23 Body string
24 Author address
25 Height int64
26}
27
28// posts holds every published post. The store keys by seqid, so iteration is
29// in publication order for any number of posts; v0 kept its own nextID plus a
30// key() that zero-padded to width 12 and stopped ordering past it.
31var posts = store.Named("post")
32
33// Publish creates a new post authored by the caller and returns its id.
34// It is a state-mutating exported function, so it takes `cur realm`.
35func Publish(cur realm, title string, body string) int {
36 title = strings.TrimSpace(title)
37 body = strings.TrimSpace(body)
38 if title == "" {
39 panic("blog: title must not be empty")
40 }
41 if body == "" {
42 panic("blog: body must not be empty")
43 }
44
45 author := unsafe.PreviousRealm().Address()
46 id := posts.Add(&Post{
47 Title: title,
48 Body: body,
49 Author: author,
50 Height: runtime.ChainHeight(),
51 })
52
53 chain.Emit(
54 "PostPublished",
55 "id", id.String(),
56 "author", author.String(),
57 )
58 return int(id)
59}
60
61// PostCount returns the number of published posts.
62func PostCount() int {
63 return posts.Len()
64}
65
66// snippet returns a short one-line preview of the body.
67func snippet(body string) string {
68 // collapse newlines so the preview stays on a single markdown line
69 s := strings.ReplaceAll(body, "\n", " ")
70 s = strings.TrimSpace(s)
71 const max = 140
72 if len(s) > max {
73 return s[:max] + "…"
74 }
75 return s
76}
77
78// Render lists all posts newest-first at the root, or a single post's full
79// text at path "/<id>". Render is NOT a crossing function.
80func Render(path string) string {
81 path = strings.TrimSpace(path)
82 if path == "" || path == "/" {
83 return renderList()
84 }
85 idStr := strings.TrimPrefix(path, "/")
86 id, ok := store.ParseID(idStr)
87 if !ok {
88 return "# Not found\n\nInvalid post path: `" + path + "`\n\n[← back](/r/REPLACE_ADDR/blog)\n"
89 }
90 v, ok := posts.Get(id)
91 if !ok {
92 return "# Not found\n\nNo post with id " + idStr + ".\n\n[← back](/r/REPLACE_ADDR/blog)\n"
93 }
94 return renderPost(v.(*Post))
95}
96
97func renderList() string {
98 var b strings.Builder
99 b.WriteString("# 📝 Blog\n\n")
100 if posts.Len() == 0 {
101 b.WriteString("_No posts yet. Be the first to `Publish`._\n")
102 return b.String()
103 }
104 b.WriteString(strconv.Itoa(posts.Len()) + " post(s), newest first.\n\n")
105
106 // Ids ascend with publication, so reverse iteration is newest-first.
107 posts.EachReverse(func(id store.ID, v any) {
108 p := v.(*Post)
109 b.WriteString("## [")
110 b.WriteString(p.Title)
111 b.WriteString("](/r/REPLACE_ADDR/blog:/")
112 b.WriteString(id.String())
113 b.WriteString(")\n\n")
114 b.WriteString("by `")
115 b.WriteString(p.Author.String())
116 b.WriteString("` · height ")
117 b.WriteString(strconv.FormatInt(p.Height, 10))
118 b.WriteString("\n\n")
119 b.WriteString(snippet(p.Body))
120 b.WriteString("\n\n---\n\n")
121 })
122 return b.String()
123}
124
125func renderPost(p *Post) string {
126 var b strings.Builder
127 b.WriteString("# ")
128 b.WriteString(p.Title)
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(p.Body)
136 b.WriteString("\n\n---\n\n[← back to all posts](/r/REPLACE_ADDR/blog)\n")
137 return b.String()
138}