← Back to Realms

Realm

gno.land/r/moul/blog

Overview

Realm Path
gno.land/r/moul/blog
Exported Functions
9
State Entries
14
Source Files
6
Total Package Entries
41

Exported Functions

9 exported functions

State

14 state entries

Source Code

FILES
blog.gno
go
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}
305

Raw Package Data

Raw JSON data