func Append
crossing ActionAppend adds to the end of a post's body. It exists for a body too large to fit in one transaction: Set the first chunk, Append the rest. An ordinary edit uses Set, which is idempotent and therefore safe to retry.
Package blog is moul's personal blog on gno.land: one realm, one author, posts that live in chain storage rather than...
gno.land/r/moul/blogmoul's personal blog, on chain. One author, posts stored as data rather than code, and a URL that is meant to survive being pasted into someone else's chat.
gno.land/r/moul/blog the index, newest first
gno.land/r/moul/blog:<slug> one post
gno.land/r/moul/blog:t/<tag> every post carrying a tag
/vNThese are the same decision seen from two sides.
A post URL leaves this repository the moment it is published: it goes into
chats, into other people's articles, into a link someone bookmarked. None of
those can be rewritten. A /v1 would break all of them at once, which is the
one failure a blog cannot absorb, so the module line carries no version.
What normally pays for a version is the ability to change the code. Here that
comes from private = true instead: a private package may be re-added at the
same path by the address in [addpkg].creator
(gno.land/pkg/sdk/vm/keeper.go, checkRedeployPermission), and nothing else
may import it. So the code can be replaced without the path moving.
The bill for that arrives as a wipe. A redeploy re-runs init() and resets
every package-level variable, so every post is erased. That is survivable
only because the markdown is kept off chain and tools/gnoblog -all pushes all
of it back in one transaction. The chain holds the published copy; it is not
the only copy.
tools/gnocontracts records the exemption from the repository's "every path
ends in /vN" rule, next to r/moul/home's, with the reason.
Writes are restricted to g1manfred47kzduec920z88wfr64ylksmdcedlf5.
| function | what it does |
|---|---|
Set(cur, slug, title, date, tags, body) |
create or replace a post, the ordinary publish |
Append(cur, slug, body) |
append, for a body too large for one transaction |
Delete(cur, slug) |
remove a post |
SetIntro(cur, body) |
the markdown above the index; "" restores the default |
Get(slug) |
one post's raw markdown |
Count() · Revision() |
how many posts, and how many writes have been accepted |
Manifest() |
slug⇥rev⇥date⇥len⇥sha256 per post, the diff surface |
A slug is 1 to 64 bytes of [a-z0-9._-]. No ':' and no '/', so a slug can
never break out of its own render path. intro is refused, because intro.md
names the index header on the local side and a post there would have nowhere to
live.
A date is exactly YYYY-MM-DD, and that is load-bearing rather than cosmetic:
posts are kept in a second avl tree keyed by "<date>/<slug>" and walked in
reverse, so lexical order has to be chronological order. gno has no
sort.Slice; this is what replaces it.
Manifest() is the whole sync protocolOne vm/qeval returns a line per post carrying a sha256 over the title, date,
tags and body together. So gnoblog status can say exactly which local
file needs a transaction without downloading a single body, and changing any
one of the four fields changes the hash.
Both halves build that hashed record the same way, and neither can check the other at run time: the realm's test asserts the record, and the tool's test pins its digest as a literal. If one side ever drifts, the tool reports every post as up to date while the chain holds the old text.
The body renders verbatim. It is markdown written by the one address that
can call Set, and escaping it would turn every heading and link in a post
into literal text.
Titles, tags and index excerpts go through p/moul/kit/ui anyway. Not because
the author is untrusted, but because a title with a ] in it would otherwise
break the link it sits inside, and that is a rendering bug with no attacker in
it at all.
The markdown lives outside this repository; tools/gnoblog never defaults to a
path to it.
1go -C tools tool gnoblog -content DIR status # what differs from the chain
2go -C tools tool gnoblog -content DIR tx # the transaction that fixes it
tx writes an unsigned transaction document and the two gnokey commands that
sign and broadcast it. Several posts go in one document, so publishing three is
one signature and one atomic broadcast. After a redeploy, tx -all.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Package blog is moul's personal blog on gno.land: one realm, one author, posts that live in chain storage rather than in the code.
A post is markdown plus four fields (slug, title, date, tags). Writing one is a single Set transaction, so publishing never redeploys the realm and fixing a typo costs one small call instead of a new package path.
The reader-facing paths are deliberately short, because they get pasted into other people's chats:
1gno.land/r/moul/blog the index, newest first
2gno.land/r/moul/blog:<slug> one post
3gno.land/r/moul/blog:t/<tag> every post carrying a tag
gno has no sort.Slice, and avl.Tree iterates by key. So posts live in two trees: `posts` keyed by slug, for the O(log n) lookup a permalink needs, and `order` keyed by "<date>/<slug>", walked in reverse for newest-first. The date is YYYY-MM-DD precisely so that lexical order is chronological order, which is what makes the second tree free.
gnomod.toml declares private = true, which lets the creator re-add the package at this exact path. That redeploy re-runs init() and WIPES every post here, so the markdown kept off chain is the source of truth and tools/gnoblog pushes it back. Trading that risk for a stable URL is the whole design: a /v1 would break every link already shared, a redeploy does not, and Manifest() makes restoring the content one diff.
Append adds to the end of a post's body. It exists for a body too large to fit in one transaction: Set the first chunk, Append the rest. An ordinary edit uses Set, which is idempotent and therefore safe to retry.
Count returns the number of published posts.
Delete removes a post and its index entry.
Get returns a post's raw markdown body, or "" when there is no such post.
Manifest returns one tab-separated line per post, plus one for the intro:
1<slug>\t<rev>\t<date>\t<len of body>\t<sha256 of the canonical record>
It is the diff surface tools/gnoblog reads with a single vm/qeval: the hash covers the title, date, tags and body together, so one read says exactly which local file needs a transaction without downloading a single body.
The intro is reported under the key "!intro" with an empty date. '!' is not a legal slug character, so that row can never be confused with a post.
Revision returns the total number of writes this realm has accepted. It changes on every mutation, so a client can cheaply tell "nothing moved".
Set creates or replaces the post at slug. This is the ordinary publish: one post, one transaction, idempotent.
Changing the date moves the post in the index, so the old order entry is removed before the new one is written.
SetIntro replaces the markdown shown above the index. Passing "" restores the built-in header.