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