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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

blog.gno

9.32 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/nym-moultest123/blog             the index, newest first
 14//	gno.land/r/nym-moultest123/blog:<slug>      one post
 15//	gno.land/r/nym-moultest123/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/nym-moultest123/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}