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.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}