Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}