blog.gno
9.28 Kb · 304 lines
1// Package blog is moul's personal blog on gno.land: one realm, one author,
2// posts that live in chain storage rather than in the code.
3//
4// # The shape
5//
6// A post is markdown plus four fields (slug, title, date, tags). Writing one
7// is a single Set transaction, so publishing never redeploys the realm and
8// fixing a typo costs one small call instead of a new package path.
9//
10// The reader-facing paths are deliberately short, because they get pasted into
11// other people's chats:
12//
13// gno.land/r/moul/blog the index, newest first
14// gno.land/r/moul/blog:<slug> one post
15// gno.land/r/moul/blog:t/<tag> every post carrying a tag
16//
17// # Ordering without sort
18//
19// gno has no sort.Slice, and avl.Tree iterates by key. So posts live in two
20// trees: `posts` keyed by slug, for the O(log n) lookup a permalink needs, and
21// `order` keyed by "<date>/<slug>", walked in reverse for newest-first. The
22// date is YYYY-MM-DD precisely so that lexical order is chronological order,
23// which is what makes the second tree free.
24//
25// # Versioning
26//
27// gnomod.toml declares private = true, which lets the creator re-add the
28// package at this exact path. That redeploy re-runs init() and WIPES every
29// post here, so the markdown kept off chain is the source of truth and
30// tools/gnoblog pushes it back. Trading that risk for a stable URL is the
31// whole design: a /v1 would break every link already shared, a redeploy does
32// not, and Manifest() makes restoring the content one diff.
33package blog
34
35import (
36 "crypto/sha256"
37 "encoding/hex"
38 "strconv"
39 "strings"
40
41 "chain/runtime"
42
43 "gno.land/p/nt/avl/v0"
44)
45
46// author is the only address allowed to write. Deliberately a constant rather
47// than transferable ownership: this is one person's blog, and the same address
48// is the only one the chain lets redeploy the realm anyway, so a second owner
49// could publish posts it could never restore after a wipe.
50const author = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
51
52// realmPath is this realm's own path, used to build the links Render emits.
53const realmPath = "gno.land/r/moul/blog"
54
55// maxSlugLen bounds a slug so a permalink stays typable and a key stays cheap.
56const maxSlugLen = 64
57
58// excerptLen is how much of a post body the index shows.
59const excerptLen = 200
60
61// introKey is the manifest key of the index header. It starts with '!', which
62// validSlug rejects, so it can never collide with a post.
63const introKey = "!intro"
64
65// introSlug is refused as a post slug. Not because this realm needs it: the
66// intro lives in its own variable. It is refused so that the local half stays
67// representable, where a post is <slug>.md and the header is intro.md. A post
68// published at this slug by a hand-written transaction would have no file it
69// could live in, and tools/gnoblog would report it as an extra forever.
70const introSlug = "intro"
71
72type post struct {
73 slug string
74 title string
75 date string // YYYY-MM-DD, the sort key
76 tags string // comma-separated, normalized by splitTags
77 body string // raw markdown, authored by `author`, rendered unescaped
78 rev int // rev at the time of the last write to this post
79 updated int64 // block height of the last write
80}
81
82var (
83 posts = avl.NewTree() // slug -> *post
84 order = avl.NewTree() // "<date>/<slug>" -> *post
85 intro string // markdown shown above the index
86 rev int // total writes accepted, bumped by every mutation
87)
88
89func assertAuthor(cur realm) {
90 if cur.Previous().Address() != author {
91 panic("restricted to the author")
92 }
93}
94
95// validSlug reports whether slug is a legal permalink: 1..maxSlugLen bytes of
96// [a-z0-9._-]. The character set excludes ':' and '/' so a slug can never
97// break out of its own render path, and excludes uppercase so a post has
98// exactly one URL.
99func validSlug(slug string) bool {
100 if len(slug) == 0 || len(slug) > maxSlugLen {
101 return false
102 }
103 for i := 0; i < len(slug); i++ {
104 c := slug[i]
105 switch {
106 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
107 case c == '-', c == '_', c == '.':
108 default:
109 return false
110 }
111 }
112 return true
113}
114
115// validDate reports whether date is exactly YYYY-MM-DD. The format is not
116// cosmetic: `order` relies on lexical order being chronological order, so a
117// date of any other shape would silently sort into the wrong place.
118func validDate(date string) bool {
119 if len(date) != 10 {
120 return false
121 }
122 for i := 0; i < 10; i++ {
123 c := date[i]
124 if i == 4 || i == 7 {
125 if c != '-' {
126 return false
127 }
128 continue
129 }
130 if c < '0' || c > '9' {
131 return false
132 }
133 }
134 return true
135}
136
137// orderKey is the key a post takes in `order`. The slug is appended so two
138// posts published on the same day do not collide.
139func orderKey(date, slug string) string { return date + "/" + slug }
140
141// splitTags normalizes a comma-separated tag list: trimmed, lowercased,
142// deduplicated, empties dropped. It returns the canonical string that gets
143// stored, so the hash a client computes locally matches the one on chain.
144func splitTags(tags string) []string {
145 var out []string
146 for _, t := range strings.Split(tags, ",") {
147 t = strings.ToLower(strings.TrimSpace(t))
148 if t == "" {
149 continue
150 }
151 dup := false
152 for _, seen := range out {
153 if seen == t {
154 dup = true
155 break
156 }
157 }
158 if !dup {
159 out = append(out, t)
160 }
161 }
162 return out
163}
164
165func joinTags(tags []string) string { return strings.Join(tags, ",") }
166
167// record is the canonical serialization a post hashes to. Both this realm and
168// tools/gnoblog build it the same way, which is what lets one Manifest() read
169// decide whether a local file needs a transaction at all.
170func record(title, date, tags, body string) string {
171 return title + "\n" + date + "\n" + tags + "\n" + body
172}
173
174func hashOf(s string) string {
175 sum := sha256.Sum256([]byte(s))
176 return hex.EncodeToString(sum[:])
177}
178
179// Set creates or replaces the post at slug. This is the ordinary publish: one
180// post, one transaction, idempotent.
181//
182// Changing the date moves the post in the index, so the old order entry is
183// removed before the new one is written.
184func Set(cur realm, slug, title, date, tags, body string) {
185 assertAuthor(cur)
186 if !validSlug(slug) {
187 panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug))
188 }
189 if slug == introSlug {
190 panic("reserved slug: " + introSlug + " names the index header, set it with SetIntro")
191 }
192 if !validDate(date) {
193 panic("invalid date: want YYYY-MM-DD, got " + strconv.Quote(date))
194 }
195 title = strings.TrimSpace(title)
196 if title == "" {
197 panic("a post needs a title")
198 }
199
200 if v := posts.Get(slug); v != nil {
201 order.Remove(orderKey(v.(*post).date, slug))
202 }
203
204 rev++
205 p := &post{
206 slug: slug,
207 title: title,
208 date: date,
209 tags: joinTags(splitTags(tags)),
210 body: body,
211 rev: rev,
212 updated: runtime.ChainHeight(),
213 }
214 posts.Set(slug, p)
215 order.Set(orderKey(date, slug), p)
216}
217
218// Append adds to the end of a post's body. It exists for a body too large to
219// fit in one transaction: Set the first chunk, Append the rest. An ordinary
220// edit uses Set, which is idempotent and therefore safe to retry.
221func Append(cur realm, slug, body string) {
222 assertAuthor(cur)
223 v := posts.Get(slug)
224 if v == nil {
225 panic("no such post: " + strconv.Quote(slug))
226 }
227 p := v.(*post)
228 rev++
229 p.body += body
230 p.rev = rev
231 p.updated = runtime.ChainHeight()
232}
233
234// Delete removes a post and its index entry.
235func Delete(cur realm, slug string) {
236 assertAuthor(cur)
237 v, removed := posts.Remove(slug)
238 if !removed {
239 panic("no such post: " + strconv.Quote(slug))
240 }
241 order.Remove(orderKey(v.(*post).date, slug))
242 rev++
243}
244
245// SetIntro replaces the markdown shown above the index. Passing "" restores
246// the built-in header.
247func SetIntro(cur realm, body string) {
248 assertAuthor(cur)
249 rev++
250 intro = body
251}
252
253// Get returns a post's raw markdown body, or "" when there is no such post.
254func Get(slug string) string {
255 v := posts.Get(slug)
256 if v == nil {
257 return ""
258 }
259 return v.(*post).body
260}
261
262// Count returns the number of published posts.
263func Count() int { return posts.Size() }
264
265// Revision returns the total number of writes this realm has accepted. It
266// changes on every mutation, so a client can cheaply tell "nothing moved".
267func Revision() int { return rev }
268
269// Manifest returns one tab-separated line per post, plus one for the intro:
270//
271// <slug>\t<rev>\t<date>\t<len of body>\t<sha256 of the canonical record>
272//
273// It is the diff surface tools/gnoblog reads with a single vm/qeval: the hash
274// covers the title, date, tags and body together, so one read says exactly
275// which local file needs a transaction without downloading a single body.
276//
277// The intro is reported under the key "!intro" with an empty date. '!' is not
278// a legal slug character, so that row can never be confused with a post.
279func Manifest() string {
280 var b strings.Builder
281
282 b.WriteString(introKey)
283 b.WriteString("\t0\t\t")
284 b.WriteString(strconv.Itoa(len(intro)))
285 b.WriteString("\t")
286 b.WriteString(hashOf(intro))
287 b.WriteString("\n")
288
289 posts.Iterate("", "", func(key string, value any) bool {
290 p := value.(*post)
291 b.WriteString(p.slug)
292 b.WriteString("\t")
293 b.WriteString(strconv.Itoa(p.rev))
294 b.WriteString("\t")
295 b.WriteString(p.date)
296 b.WriteString("\t")
297 b.WriteString(strconv.Itoa(len(p.body)))
298 b.WriteString("\t")
299 b.WriteString(hashOf(record(p.title, p.date, p.tags, p.body)))
300 b.WriteString("\n")
301 return false
302 })
303 return b.String()
304}