Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}