const DefaultRetention, DefaultMaxBody, DefaultRecent, MaxSummaryLen
Defaults for New. Retention is the number of most-recent revisions per page whose body stays on chain; MaxBody caps a single revision.
Package wiki is a Wikipedia-shaped wiki engine for gno.land: namespaced titles, an append-only revision chain, wikili...
gno.land/p/moul/x/wiki/v0A Wikipedia-shaped wiki engine: namespaced titles, an append-only revision chain, wikilinks with backlinks, categories, redirects, protection levels, per-page discussion threads, line diffs, and markdown rendering.
Pure: no realm globals, no chain imports. Every mutating call takes the author address, the wall clock and the block height from its caller, so the engine is unit-testable off-chain and the realm keeps all the authority.
Live demo: gno.land/r/moul/x/wiki/v0.
A realm write locks a storage deposit proportional to the bytes it adds
(100ugnot per byte, gnolang/gno#6171). A wiki that kept every revision in
full would therefore charge its editors rent on the whole history forever: at
5 KB per revision that is 0.5 GNOT locked per edit, permanently.
This engine keeps a content-addressed spine plus a bounded body window:
| kept forever, per revision | kept only inside the window |
|---|---|
| id, parent, kind, author, time, height, summary, byte size, SHA-256 of the body, minor flag | the body text |
Roughly 200 bytes per revision regardless of article size, plus the bodies of
the newest Retention revisions of each page (3 by default).
An evicted body is not lost. Its bytes were an argument of the transaction
that wrote it, so they are still in the chain's transaction history, and the
retained hash proves which bytes were the real ones. The realm stops paying
rent for deep history; the archive lives where archives belong. What you lose
is the ability to diff or revert to an evicted revision from inside the realm,
and both failures are explicit (ErrBodyEvicted) rather than silent.
Purge evicts every body of a page at once and returns the bytes released. It
is the lever for content that must stop being served out of realm state; it
cannot and does not remove the transactions that wrote it.
Not who paid it. Verified in processStorageDeposit
(gno.land/pkg/sdk/vm/keeper.go, gno master, 2026-09-19):
rlm.Deposit and rlm.Storage. There is no
per-depositor accounting.rlm.Deposit * released / rlm.Storage (big-integer,
truncating, so dust accrues in the pool) to the caller of the transaction
that frees the bytes, at the realm's blended rate.params.StorageFeeCollector instead, not to any user.For a wiki that inverts a comfortable assumption. Adding bytes costs the adder,
but removing bytes pays the remover. Replacing a long article with a short
one evicts an old body in the same transaction, so deletion can be profitable,
and reverting the damage costs the good actor who reverts it. That asymmetry is
why Blank, Purge and HideComment are steward-gated, and why the realm
keeps a ban list, protection levels and an optional cooldown rather than
relying on the deposit alone.
Article bodies are attacker-controlled markdown rendered by gnoweb, so the render path has a fixed order:
sanitize.BlockRich the body (gno.land/p/nt/markdown/sanitize/v0).Not the other way around. The sanitizer escapes every [, so [[Gno land]]
becomes \[\[Gno land\]\] in its output; a rewriter that ran first would hand
the markdown links it just generated to the escaper and every link on the wiki
would render as literal text. That is why ScanLinks takes its delimiters as
parameters: indexing scans the raw body, rendering scans the escaped one.
The blank lines BlockRich adds around its output are load-bearing, not
cosmetic. A CommonMark HTML block of type 6 or 7 is not escaped in any mode,
and without the surrounding blank line it would absorb the realm chrome
appended after the body.
Two layers, two jobs: sanitize stops markdown structure injection, and
gnoweb's own link extension stops URL scheme abuse (javascript: and
friends). Neither replaces the other.
The title charset is narrower than MediaWiki's for the same reason. /, |,
#, *, [, <, ?, % and & are rejected rather than escaped, which
keeps Title.String, Title.Slug and the rendered link byte-identical.
TestTitleSurvivesTheRenderPipeline pins that coupling end to end.
Render runs under maxGasQuery (3e9 in gno.land/pkg/sdk/vm/keeper.go),
which a reader cannot raise, so an unbounded render makes a page permanently
unreadable. Three bounds keep it away from that ceiling:
MaxBody (32 KiB) caps a revision, and therefore caps every render.DiffMaxLines (80) caps the changed region a diff computes exactly. The
common prefix and suffix are trimmed first, so an ordinary edit to a long
article still diffs exactly; past the bound a diff degrades to a block
replacement instead of failing.Measured with gno test -v on gno master.184 (2026-09-17): the diff of a 33 KB
body against itself plus one line costs 691M gas, and the article render of a
33 KB body with links costs 693M, each about 23% of the ceiling.
Everything else follows gno's determinism rules: no map iteration anywhere in a
render path, avl for every ordered index, and namespace keys padded by hand
because ufmt has no width flags (ufmt.Sprintf("%02d", 7) silently returns
"7", which would sort User between two main-namespace pages).
Discussion is an append-only comment store attached to a page, not a Talk:
article. A talk page is an article, so whoever edits last can rewrite what
someone else said, and moderating one bad message means editing the whole page.
A comment store gives each message its own author, timestamp and moderation:
HideComment clears one body and releases its bytes while the message stays in
the thread, marked as removed.
Replies nest exactly one level (MaxReplyDepth). Deeper nesting needs
recursive rendering with no natural bound, which is the shape a query gas
ceiling punishes hardest. Comments are sanitized like article bodies but are
not a wikilink slot: brackets in a comment stay literal.
| syntax | meaning |
|---|---|
[[Target]] |
link, rendered from the canonical title |
[[Target|label]] |
link with a display label |
[[Category:Name]] |
join a category; removed from the text flow |
[[:Category:Name]] |
link to the category instead of joining it |
#REDIRECT [[Target]] on line 1 |
redirect, followed one hop only |
Links to pages that do not exist yet are still indexed, so creating a page immediately knows who was already pointing at it.
Page.Revision(id) is a linear scan of the page's history, and so is
comment lookup within a thread.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:

🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Package wiki is a Wikipedia-shaped wiki engine for gno.land: namespaced titles, an append-only revision chain, wikilinks with backlinks, categories, redirects, protection levels, line diffs, and markdown rendering.
It is pure: no realm globals, no chain imports. Every call takes the context it needs (author address, wall clock, block height) from the caller, so the whole engine is unit-testable off-chain. The live demo realm is gno.land/r/moul/x/wiki/v0.
A realm write locks a storage deposit proportional to the bytes it adds (100ugnot per byte since gnolang/gno#6171), so a wiki that kept every revision in full would charge its editors for the whole history forever. This engine instead stores a content-addressed spine plus a bounded body window:
An evicted body is not lost: its bytes were an argument of the transaction that wrote it, so they remain in the chain's transaction history, and the retained hash proves which bytes were the real ones. The realm stops paying rent for deep history; the archive lives where archives belong.
Article bodies are attacker-controlled markdown. Render passes every body through gno.land/p/nt/markdown/sanitize/v0 before any wikilink rewriting, never after: sanitize escapes "[" to "\\[", so a rewriter that ran first would hand its own generated links to the escaper. See links.gno.
Defaults for New. Retention is the number of most-recent revisions per page whose body stays on chain; MaxBody caps a single revision.
DepositPerByte is the storage deposit a realm write locks per byte, in ugnot (gnolang/gno#6171). It is used only to show a reader what a page costs; the chain, not this package, does the accounting.
DiffMaxLines bounds the changed region a diff will compute exactly.
The cost of an LCS over the changed region is O(n*m), and this runs inside vm/qrender, which has a gas ceiling a reader cannot raise. 80 lines caps the table at 6,400 cells; beyond it, a diff degrades to "this block was replaced" rather than making the page unrenderable. Both revisions are still available in full through the raw view, so nothing is hidden by the degradation.
MaxCommentLen bounds one comment in bytes.
MaxReplyDepth is 1: a comment replies to a top-level comment, and that is all. Deeper nesting needs recursive rendering with no natural bound, which is exactly the shape a query gas ceiling punishes.
MaxTitleLen bounds a title name in bytes, matching MediaWiki's limit.
There is deliberately no Talk namespace. Discussion is not an article with a prefix: it is an append-only thread attached to a page, which threads properly, moderates per message, and cannot be silently rewritten by whoever edits last. See talk.gno.
1var (
2 ErrNoSuchPage = errors.New("wiki: no such page")
3 ErrPageExists = errors.New("wiki: page already exists")
4 ErrBodyTooLarge = errors.New("wiki: body exceeds the size limit")
5 ErrEmptyBody = errors.New("wiki: empty body")
6 ErrNoChange = errors.New("wiki: body is identical to the current revision")
7 ErrSummaryTooLong = errors.New("wiki: edit summary too long")
8 ErrBadProtection = errors.New("wiki: unknown protection level")
9 ErrNoSuchRevision = errors.New("wiki: no such revision")
10 ErrBodyEvicted = errors.New("wiki: that revision's body is no longer held on chain")
11 ErrSpecial = errors.New("wiki: the Special namespace is generated, not stored")
12)DiffStat counts added and removed lines.
RenderAllPages renders the page index for a namespace prefix.
RenderArticle renders a page for reading: the sanitized body with its wikilinks resolved, a header identifying the current revision, and a footer with categories and cost.
RenderBacklinks renders "what links here" for a title.
RenderCategories lists every category that has at least one member.
RenderCategory renders a category page: its own text, then its members.
RenderDiff renders the line diff between two revisions of a page.
RenderHistory renders a page's revision list, newest first.
RenderIndex renders the wiki's front page: recent changes and a page count.
RenderMissing renders the stub shown for a title with no page: the red-link destination, listing whoever already points at it.
RenderRaw renders a revision's source inside a code block, which is what a reader needs before editing and what a verifier needs to re-hash.
RenderRecent renders the recent-changes feed.
RenderRevision renders one stored revision verbatim.
RenderStats renders the wiki's size and what it is paying the chain.
RenderTalk renders a page's discussion: top-level messages oldest first, each with its replies.
RewriteLinks turns the wikilinks of an already-sanitized body into markdown links. Category declarations are removed from the flow: membership is shown by the rendered footer, not inline, which is also what MediaWiki does.
s MUST be the output of sanitize.BlockRich or sanitize.Block, and open/close MUST be the escaped delimiters. Passing a raw body here would emit links built from unsanitized bytes.
DiffLines compares two bodies line by line.
exact reports whether the result is a real line diff. When it is false the changed region was larger than DiffMaxLines and the result is the coarse form: every removed line, then every added line.
ScanLinks finds every [[target|label]] occurrence delimited by open and close.
The delimiters are parameters because the same syntax has to be found twice with different bytes. Indexing reads the raw body, where a link is "[[X]]". Rendering reads the body after sanitize.BlockRich, where the very same link is "\[\[X\]\]" because the sanitizer escapes every "[". Rendering must sanitize first and rewrite second: a rewriter that ran first would hand the markdown links it just generated to the escaper, and every link on the wiki would render as literal text.
A link whose inner text spans a newline or is empty is not a link. When a nearer opener appears inside the inner text, scanning restarts from it, so "[[a [[b]]" yields b rather than a mis-parsed a.
ParseProtection maps a user-supplied level name onto a Protection.
MustParseTitle is ParseTitle for titles known at development time.
ParseTitle normalizes a user-supplied title.
The normalization is a deliberate subset of MediaWiki's: underscores become spaces, runs of whitespace collapse, the first letter is upper-cased, and a recognized "Namespace:" prefix (case-insensitive) selects the namespace. "Talk:gno land" and "talk:Gno_land" both parse to Talk:Gno land.
The accepted character set is narrower than MediaWiki's on purpose. A title is rendered inside markdown and inside a URL path, so anything that is markdown-significant ("*", "`", "[", "|", "#", "<"), path-significant ("/", "?", "%", "&") or invisible (bidi and zero-width controls) is rejected rather than escaped. Rejecting keeps Title.String, Title.Slug and the sanitized render byte-identical, which is what TestTitleCharsSurviveSanitize pins.
New returns an empty wiki. A retention or maxBody below 1 falls back to the package default.
Action builds a transaction link for a realm function. The realm supplies it (txlink.Realm("…").Call is the usual value); a nil Action renders a page with no edit controls, which is what an archived or read-only mirror wants.
Change is a recent-changes entry: a revision plus the page it landed on.
Comment is one message in a page's discussion.
Discussion is a comment store rather than a "Talk:" article, which is the one place this design departs from MediaWiki on purpose. A talk page is an article, so the last editor can rewrite what someone else said, and moderating one bad message means editing the whole page. An append-only store gives each message its own author, its own timestamp and its own moderation, and nobody can silently rewrite anyone else's words.
Ctx carries what rendering needs from the realm: where the realm lives, and which titles exist. Its function fields are read during a single Render call and never stored, so no closure is ever persisted.
SpecialURL is the render path of a Special: page.
Sub is the render path of a sub-route of a title, e.g. "history".
URL is the render path of a title under this realm.
DiffLine is one line of a rendered diff.
DiffOp is what happened to one line between two revisions.
Kind labels what a revision did, for the recent-changes feed.
1type Link struct {
2 Target string // target text as written, before ParseTitle
3 Label string // display text; "" means render the target
4 Explicit bool // written [[:Category:X]]: link to the category, do not join it
5 Start int // byte offset of the opening delimiter
6 End int // byte offset just past the closing delimiter
7}Link is one wikilink occurrence in a body.
Namespace partitions the title space the way MediaWiki does: the same name can exist once per namespace, and each namespace gets its own rendering and its own listing.
1type Page struct {
2 Title Title
3 Protection Protection
4 Created time.Time
5 Blanked bool // a steward blanked it; history is retained
6
7 revs *ulist.List // *Revision, oldest first
8 head *Revision
9
10 redirect string // target title, "" when this page is not a redirect
11 links []string // outgoing wikilink target keys of head
12 cats []string // category keys head belongs to
13}Page is one title's history plus the indexes derived from its current revision. Revisions live in a ulist: append is O(1) and does not rewrite the existing entries, which matters when a popular page accumulates thousands of edits and every write would otherwise re-serialize the whole slice.
Body returns the current text and whether it is held on chain.
Contributors returns the distinct authors of the page, oldest edit first.
Head returns the current revision, or nil for a page with no revisions.
History returns up to count revisions, newest first, skipping the newest offset of them.
NumRevisions returns how many revisions the page has.
Redirect returns the title this page redirects to, or "" if it does not.
Revision returns the revision with the given id, or nil.
Protection is a page's edit gate. The engine stores it and reports it; it never enforces it, because authority belongs to the realm that owns the wiki, not to a pure library. See Wiki.Edit.
Revision is one entry in a page's append-only history.
The exported fields are the permanent spine: they are never evicted, and they are what makes the history tamper-evident. Hash is the SHA-256 of the body bytes as submitted, so a body recovered from transaction history can be checked against the chain's own record of it.
body is the only field under a retention policy. When a revision falls out of the page's body window it is evicted, which releases the storage deposit those bytes locked.
Body returns the revision's text and whether it is still held on chain. A false second return is not corruption: the body aged out of the retention window and must be recovered from the transaction that wrote it, then checked against Hash.
Kept reports whether this revision's body is still on chain.
ShortHash is the first 12 hex characters of Hash, for display.
Stats is a snapshot of the wiki's size and of what it is paying for.
Thread is a top-level comment and its replies.
Title is a normalized (namespace, name) pair. The zero Title is invalid; build one with ParseTitle.
Key is the avl key: a fixed-width namespace code followed by the name, so iteration yields main-namespace articles first, then Talk, and so on, each group in name order.
Slug is the URL form used in render paths, "Talk:Gno_land". Titles cannot contain "/", so a slug is always exactly one path segment and the segment after it is unambiguously a sub-route.
String is the canonical display form, "Talk:Gno land".
1type Wiki struct {
2 pages *avl.Tree // Title.Key() -> *Page
3 backlinks *avl.Tree // target Title.Key() -> *avl.Tree (source key -> Title)
4 cats *avl.Tree // category Title.Key() -> *avl.Tree (member key -> Title)
5 talk *avl.Tree // Title.Key() -> *ulist.List of *Comment (see talk.gno)
6 recent *fifo.List
7
8 nextRev uint64
9 nextComment uint64
10 retention int
11 maxBody int
12
13 numPages int // pages with at least one revision and not blanked
14 numRevs int
15 numComments int
16 bytesHeld int // retained body and comment bytes, the realm's visible rent
17}Wiki is the whole encyclopedia: pages, the indexes derived from them, and the recent-changes feed.
Every mutating method takes author, now and height from the caller instead of reading them from the chain, so the engine has no chain imports and the tests drive time and height explicitly.
Backlinks returns the titles whose current revision links to t, in key order. Redlinks are indexed too, so a page created later immediately knows who was already pointing at it.
1func (w *Wiki) Blank(author address, now time.Time, height int64, raw, reason string) (*Revision, error)Blank replaces a page's content with a tombstone revision. It is the deletion a chain can honestly offer: the page stops rendering and stops costing rent as its bodies age out, while the revision spine stays as proof that something was there and who removed it. Use Purge to release the retained bytes immediately.
Categories returns every category that has at least one member.
CategoryMembers returns the pages that declare [[Category:name]].
1func (w *Wiki) Comment(author address, now time.Time, height int64, raw, body string, replyTo uint64) (*Comment, error)Comment appends a message to a page's discussion. replyTo is 0 for a top-level message, or the id of a top-level message to reply to.
The engine does not decide who may comment: as with Edit, that is the realm's call. It does enforce the shape, because the shape is what bounds the render.
Comments returns up to count top-level messages of a page's discussion, oldest first, skipping offset of them, each paired with its replies in the order they were written.
1func (w *Wiki) Edit(author address, now time.Time, height int64, raw, body, summary string, minor bool) (*Revision, error)Edit writes a new revision. It does not check authority: the caller decides who may write, using Page.Protection and whatever roster it keeps. Passing a body byte-identical to the current one is an error, so a no-op edit cannot be used to spam the history or the recent-changes feed.
Exists reports whether a title has a page. A blanked page still exists: its history is the point of the wiki.
HideComment clears one message's body and returns the bytes released. The message itself stays in the thread, so a deleted comment reads as "removed" rather than as a gap someone has to reconstruct from block explorers.
1func (w *Wiki) Move(author address, now time.Time, height int64, from, to, summary string) errorMove renames a page, keeping its history, and leaves a redirect behind at the old title so existing links keep resolving.
NumComments returns how many messages a page's discussion holds, replies and hidden messages included.
Page returns the page stored at raw, without following redirects.
PageByTitle is Page for an already-parsed title.
Purge evicts every retained body of a page immediately and returns the number of bytes released. The spine, including each body's hash, is untouched. This is the lever for content that must stop being served from realm state; it cannot and does not remove the transactions that wrote it.
Recent returns up to n changes, newest first.
Resolve follows at most one redirect hop and returns the destination page along with the page that was asked for. MediaWiki also stops at one hop: chains are a vandalism vector and a loop is unrenderable.
1func (w *Wiki) Revert(author address, now time.Time, height int64, raw string, revID uint64, summary string) (*Revision, error)Revert restores the body of an earlier revision as a new revision, the way a wiki undo works: the vandalized revision stays in the history, it is just no longer current. It fails if that revision's body has aged out of the retention window, which is the honest failure mode of a bounded history.
1func (w *Wiki) SetProtection(author address, now time.Time, height int64, raw, level string) errorSetProtection changes a page's edit gate.
Stats returns the current counters.
Titles returns up to count page titles in key order, skipping offset of them. An empty ns prefix walks every namespace.