Transaction
816FB20032088C…EABB4AE3C34B
Block 241,295 · index 0 · indexed
Summary
- Hash
- 816FB20032088C70F185EB2FEE0D9A08B98AB2D704EAFEE7E57EEABB4AE3C34B
- Block
- 241,295
- Size
- 43145 bytes
- Gas used
- 55,066,574 / 120,000,000
- Fee
- 240000ugnot
- Status
- success
Messages
- Attached funds
- 20000000ugnot
Arguments · 17
- #1home
- #2README.md
- #3# home The realm behind the profile page at `/u/<namespace>`. gnoweb calls `Render("")` on the realm at exactly `/r/<namespace>/home`, so this path is permanent and everything that may change lives behind it: - **Content** is slots: named markdown fragments, one `Set` transaction each. - **Layout** is the slot named `layout`, a template filled from `:slug:` placeholders. - **Style** is slots under `style.` that the theme reads. - **Rendering** is a theme: a realm under `home/theme/vN` that registers itself when deployed and goes live when the authority runs `Accept`. `Rollback` reverts. The slots never move. The upgrade machinery is `p/<namespace>/upgradeable/v0`. ## Views | path | what | | --- | --- | | `:slots` | slot index | | `:slots/<slug>` | one slot, raw | | `:edit`, `:edit/<slug>` | forms that build the `Set` and `Delete` commands | | `:system` | authority, live theme, candidates, history, operations | | `:manifest` | `slug⇥rev⇥bytes⇥sha256` per slot, for tooling | These four are served by this realm directly and never go through a theme, so a theme that panics can be rolled back from `:system`. Every other path is the theme's. ## Writes Restricted to the authority: `Set`, `Append`, `Delete`; `Accept`, `Withdraw`, `Rollback`, `Forget`, `Freeze`, `TransferAuthority`. A slug is 1 to 64 bytes of `[a-z0-9._-]`; the computed placeholders `chainid height owner realm rev slots theme updated` are reserved.
- #4export_test.gno
- #5package home import ( "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/upgradeable/v0" "gno.land/p/nt/avl/v0" ) // Test hooks. Realm globals live for the whole test binary, so every test // that reads Render or Manifest starts from a known tree and a fresh proxy. // ResetForTest clears all realm state. func ResetForTest() { slots = avl.NewTree() rev = 0 lastHeight = 0 proxy = upgradeable.New(upgradeable.NewAddrAuthority(Admin)) } // SeedForTest writes a slot without the authority check. func SeedForTest(slug, body string) { rev++ slots.Set(slug, &Slot{Body: body, Rev: rev, Height: 0}) } // ReservedSlugsForTest exposes the reserved names. func ReservedSlugsForTest() []string { return reservedSlugs }
- #6gnomod.toml
- #7module = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home" gno = "0.9"
- #8home.gno
- #9// Package home is the realm behind https://gno.land/u/clockwork. // // gnoweb builds a user profile page by calling Render("") on the realm at the // exact path /r/<username>/home (gno.land/pkg/gnoweb/handler_http.go, // GetUserView). That lookup does no version resolution, so this path is the // interface with gnoweb and can never carry a /vN suffix. Everything that // might want to change therefore lives behind the path, in four layers: // // 1. CONTENT is data. The page is assembled from SLOTS: named markdown // fragments in an avl tree, each written by its own Set call. Updating // one paragraph is one small transaction. // 2. LAYOUT is data. The slot named "layout" is the page template; every // :slug: placeholder in it is filled from the slot of that name. // 3. STYLE is data. Slots under the "style." prefix are knobs (colors, // header mode) that the theme reads. Changing the accent color is a Set. // 4. RENDERING is code, but replaceable. A THEME is a separate realm nested // under this one that implements Theme and registers itself from its own // init. The authority accepts it through p/clockwork/upgradeable and can // roll it back. The slots never move: an upgrade replaces the code that // reads them, not the data. When no theme is live the built-in renderer // in render.gno serves the page, and the operator views (system, slots, // edit, manifest) are always served by this realm, so a theme that // panics can be rolled back from the page it cannot break. // // This realm holds state and forwards. It is deliberately small, because it // is the one piece that can never be redeployed: it is importable by its // themes, so it cannot be private, so AddPackage refuses a second deploy. package home import ( "crypto/sha256" "encoding/hex" "strconv" "strings" "chain/runtime" "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/upgradeable/v0" "gno.land/p/nt/avl/v0" ) // Admin is the initial authority: the only address that may write slots and // accept, roll back or freeze a theme. It is a constant so a reviewer can read // who controls the page without decoding chain state; TransferAuthority moves // it afterwards. // // The value here is the standard test1 account, so the gnodev walkthrough in // the README works unedited. `make build ADMIN=g1...` rewrites it for a real // deployment. const Admin = address("g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm") const ( // LayoutSlug names the slot used as the page template. LayoutSlug = "layout" // StylePrefix marks slots that are style knobs rather than content. // Style("accent", ...) reads the slot "style.accent". StylePrefix = "style." // maxSlugLen bounds a slot name so an index row stays readable. maxSlugLen = 64 ) // Theme is what a renderer realm promises. It is declared here, in the file // that can never change, so every theme version agrees on one type identity. // // Render receives the render path ("" is the profile page) and returns // markdown. The operator views (system, slots, edit, manifest) never reach a // theme; for any other path it does not draw itself, a theme should return // Fallback(path). A panic here aborts the query: there is no recovering a // foreign realm's panic, so a broken theme shows an error until Rollback. type Theme interface { Render(path string) string } // Slot is one named fragment of the page. type Slot struct { Body string Rev int // Revision() at the time of the last write to this slot Height int64 // block height of that write } var ( proxy *upgradeable.Proxy selfPath string slots = avl.NewTree() // slug -> *Slot rev int // total writes accepted; bumps on every mutation lastHeight int64 // height of the most recent write ) func init(cur realm) { // Captured rather than written as a constant, so links and the nesting // rule follow the path this code was actually deployed at. selfPath = cur.PkgPath() proxy = upgradeable.New(upgradeable.NewAddrAuthority(Admin)) } // --------------------------------------------------------------------------- // Authority // assertAuthority panics unless the caller of the crossing function that owns // cur is the current authority. It asks the proxy's Authority directly rather // than going through proxy.AssertAuthorized, so content writes keep working // after Freeze: freezing ends code upgrades, not editing. func assertAuthority(cur realm) { caller := cur.Previous() if !proxy.Authority().Authorized(caller.Address(), caller.PkgPath()) { panic("home: restricted to the authority") } } // --------------------------------------------------------------------------- // Slugs // reservedSlugs are placeholders computed at render time (see render.gno). A // slot may not take one of these names, so the page never depends on which // of the two would win. var reservedSlugs = []string{"chainid", "height", "owner", "realm", "rev", "slots", "theme", "updated"} // validSlug reports whether slug is a legal slot name: 1..maxSlugLen bytes of // [a-z0-9._-]. No ':' so a slug can never break out of its own :slug: // placeholder, and no uppercase so a slot has exactly one name. func validSlug(slug string) bool { if len(slug) == 0 || len(slug) > maxSlugLen { return false } for i := 0; i < len(slug); i++ { c := slug[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-', c == '_', c == '.': default: return false } } return true } func assertWritableSlug(slug string) { if !validSlug(slug) { panic("home: invalid slug " + strconv.Quote(slug) + ": want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-]") } for _, r := range reservedSlugs { if slug == r { panic("home: reserved slug " + slug + " is computed at render time") } } } // --------------------------------------------------------------------------- // Writes (authority only) // Set creates or replaces the slot named slug. This is the ordinary update: // one slot, one transaction. An empty body keeps the slot but empties it; use // Delete to remove it. func Set(cur realm, slug, body string) { assertAuthority(cur) assertWritableSlug(slug) write(slug, body) } // Append adds to the end of a slot, creating it when absent. It exists for a // body too large for one transaction: Set the first chunk, Append the rest. func Append(cur realm, slug, body string) { assertAuthority(cur) assertWritableSlug(slug) write(slug, Get(slug)+body) } // Delete removes a slot. Deleting the layout slot restores the theme's // default layout. func Delete(cur realm, slug string) { assertAuthority(cur) if _, removed := slots.Remove(slug); !removed { panic("home: no such slot: " + slug) } bump() } func write(slug, body string) { bump() slots.Set(slug, &Slot{Body: body, Rev: rev, Height: lastHeight}) } func bump() { rev++ lastHeight = runtime.ChainHeight() } // --------------------------------------------------------------------------- // Reads (anyone, including themes) // Get returns a slot body, or "" when the slot does not exist. func Get(slug string) string { v := slots.Get(slug) if v == nil { return "" } return v.(*Slot).Body } // Has reports whether a slot exists, empty or not. func Has(slug string) bool { return slots.Has(slug) } // Lookup returns a copy of a slot and whether it exists. func Lookup(slug string) (Slot, bool) { v := slots.Get(slug) if v == nil { return Slot{}, false } return *v.(*Slot), true } // Slugs returns every slot name, sorted. func Slugs() []string { out := make([]string, 0, slots.Size()) slots.Iterate("", "", func(key string, _ any) bool { out = append(out, key) return false }) return out } // Each visits every slot in name order until fn returns true. func Each(fn func(slug string, s Slot) bool) { slots.Iterate("", "", func(key string, value any) bool { return fn(key, *value.(*Slot)) }) } // Style returns the style knob named key (the slot "style."+key), or fallback // when it is absent or empty. func Style(key, fallback string) string { if v := Get(StylePrefix + key); v != "" { return v } return fallback } // Layout returns the layout slot, or "" when none has been set. Themes // substitute their own default in that case. func Layout() string { return Get(LayoutSlug) } // Revision returns the total number of writes this realm has accepted. It // changes on every mutation, so a client can cheaply tell "nothing moved". func Revision() int { return rev } // LastHeight returns the block height of the most recent write, or 0. func LastHeight() int64 { return lastHeight } // Manifest returns one tab-separated line per slot: // // <slug>\t<rev>\t<bytes>\t<sha256 of body, hex> // // It is the diff surface scripts/sync.sh reads: one query tells it exactly // which local file is out of date, without downloading any body. func Manifest() string { var b strings.Builder slots.Iterate("", "", func(key string, value any) bool { s := value.(*Slot) sum := sha256.Sum256([]byte(s.Body)) b.WriteString(key) b.WriteString("\t") b.WriteString(strconv.Itoa(s.Rev)) b.WriteString("\t") b.WriteString(strconv.Itoa(len(s.Body))) b.WriteString("\t") b.WriteString(hex.EncodeToString(sum[:])) b.WriteString("\n") return false }) return b.String() } // Path returns this realm's package path, e.g. "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home". func Path() string { return selfPath } // Link returns the gnoweb path of a render path on this realm: // Link("") is "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home", Link("slots") is "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home:slots". func Link(sub string) string { p := selfPath if i := strings.Index(p, "/"); i >= 0 { p = p[i:] } if sub == "" { return p } return p + ":" + sub } // --------------------------------------------------------------------------- // Themes: the upgradeable part. // // A theme realm nested under this path (…/home/theme/vN) calls Register from // its own init, so deploying it is what nominates it. Nothing serves until the // authority runs Accept with that path, which is a separate transaction naming // code anyone can go and read. Rollback puts the previous theme back. // Register records the calling realm's theme as a candidate. The path filed // is read off the crossing frame, never taken as an argument, so a candidate // cannot claim to live somewhere it does not. Only realms nested under this // one are admitted; a direct user call is refused. func Register(cur realm, t Theme) { proxy.Propose(0, cur, t) } // Accept makes the candidate at pkgPath the live theme. Authority only. func Accept(cur realm, pkgPath string) { proxy.Accept(0, cur, pkgPath) } // Withdraw drops a candidate. Authority only, except that a theme realm may // always drop its own. func Withdraw(cur realm, pkgPath string) { proxy.Withdraw(0, cur, pkgPath) } // Rollback restores the previous theme and returns the current one to the // pending set. Authority only. func Rollback(cur realm) { proxy.Rollback(0, cur) } // Forget drops the rollback history and the storage it holds. Authority only. func Forget(cur realm) { proxy.Forget(0, cur) } // Freeze ends theme upgrades permanently. Slots stay editable. Authority // only, and there is no way back. func Freeze(cur realm) { proxy.Freeze(0, cur) } // TransferAuthority hands both editing and upgrading to another address. func TransferAuthority(cur realm, to address) { proxy.TransferAuthority(0, cur, upgradeable.NewAddrAuthority(to)) } // LivePath returns the realm path of the theme currently serving, or "". func LivePath() string { return proxy.LivePath() } // PendingPaths returns the candidate theme paths awaiting acceptance. func PendingPaths() []string { out := []string{} for _, r := range proxy.Pending() { out = append(out, r.PkgPath()) } return out } // HistoryPaths returns the themes this realm has already served, oldest // first, excluding the live one. func HistoryPaths() []string { out := []string{} for _, r := range proxy.History() { out = append(out, r.PkgPath()) } return out } // Frozen reports whether theme upgrades have ended. func Frozen() bool { return proxy.Frozen() } // Authority returns a human-readable description of who may write and // upgrade. func Authority() string { return proxy.Authority().String() } // liveTheme returns the accepted theme, if any. func liveTheme() (Theme, bool) { v, ok := proxy.TryImpl() if !ok { return nil, false } t, ok := v.(Theme) return t, ok }
- #10home_test.gno
- #11// Package home_test drives the realm from outside, the way transactions do: // every write and every theme operation crosses into gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home, // so the authority check sees whoever testing.SetRealm names as the caller. package home_test import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home" ) var stranger = testutils.TestAddress("mallory") const ( themeA = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home/theme/v0" themeB = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home/theme/v1" ) // stubTheme wraps the page so a test can tell who answered. type stubTheme struct{ tag string } func (s stubTheme) Render(path string) string { if strings.HasPrefix(path, "custom") { return "custom by " + s.tag } if path != "" { return home.Fallback(path) } f := home.NewFiller() f.Add("widget", func() string { return "<" + s.tag + ">" }) layout := home.Layout() if layout == "" { layout = "default of " + s.tag } return f.Fill(layout) } // brokenTheme panics on the profile page. type brokenTheme struct{} func (brokenTheme) Render(path string) string { if path == "" { panic("kaboom") } return home.Fallback(path) } // testing.SetRealm applies to the frame that calls it, so every test names // its caller inline, right before the crossing call, rather than through a // helper. A code realm stands in for a theme deployment: on chain the theme's // init cross-calls Register, which the harness does not deliver as a // transaction. var ( admin = testing.NewUserRealm(home.Admin) mallory = testing.NewUserRealm(stranger) ) // --------------------------------------------------------------------------- // Slots func TestSetGetDelete(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(admin) home.Set(cross(cur), "intro", "Building on gno.") uassert.Equal(t, "Building on gno.", home.Get("intro")) uassert.Equal(t, 1, home.Revision()) uassert.True(t, home.Has("intro")) home.Set(cross(cur), "intro", "Still building.") uassert.Equal(t, "Still building.", home.Get("intro")) uassert.Equal(t, 2, home.Revision()) s, ok := home.Lookup("intro") uassert.True(t, ok) uassert.Equal(t, 2, s.Rev) home.Delete(cross(cur), "intro") uassert.Equal(t, "", home.Get("intro")) uassert.False(t, home.Has("intro")) uassert.Equal(t, 3, home.Revision()) uassert.AbortsContains(t, cur, "no such slot: intro", func() { home.Delete(cross(cur), "intro") }) } func TestAppendChunks(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(admin) home.Set(cross(cur), "long", "part one. ") home.Append(cross(cur), "long", "part two.") uassert.Equal(t, "part one. part two.", home.Get("long")) home.Append(cross(cur), "fresh", "hello") uassert.Equal(t, "hello", home.Get("fresh")) } func TestWritesAreAuthorityOnly(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(mallory) uassert.AbortsContains(t, cur, "restricted to the authority", func() { home.Set(cross(cur), "intro", "pwned") }) uassert.AbortsContains(t, cur, "restricted to the authority", func() { home.Append(cross(cur), "intro", "pwned") }) uassert.AbortsContains(t, cur, "restricted to the authority", func() { home.Delete(cross(cur), "intro") }) uassert.Equal(t, 0, home.Revision()) } func TestTransferAuthorityMovesEditing(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(admin) home.TransferAuthority(cross(cur), stranger) uassert.AbortsContains(t, cur, "restricted to the authority", func() { home.Set(cross(cur), "intro", "old owner") }) testing.SetRealm(mallory) home.Set(cross(cur), "intro", "new owner") uassert.Equal(t, "new owner", home.Get("intro")) uassert.True(t, strings.Contains(home.Authority(), stranger.String())) } func TestSlugRules(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(admin) for _, bad := range []string{"", "Intro", "a:b", "with space", strings.Repeat("x", 65)} { uassert.AbortsContains(t, cur, "invalid slug", func() { home.Set(cross(cur), bad, "x") }, bad) } for _, reserved := range home.ReservedSlugsForTest() { uassert.AbortsContains(t, cur, "reserved slug", func() { home.Set(cross(cur), reserved, "x") }, reserved) } home.Set(cross(cur), "style.accent", "#D9A441") uassert.Equal(t, "#D9A441", home.Style("accent", "#000000")) uassert.Equal(t, 1, home.Revision()) } // --------------------------------------------------------------------------- // Themes func TestThemeLifecycle(cur realm, t *testing.T) { home.ResetForTest() home.SeedForTest("layout", "page :widget: :rev:") testing.SetRealm(testing.NewCodeRealm(themeA)) home.Register(cross(cur), stubTheme{"A"}) testing.SetRealm(testing.NewCodeRealm(themeB)) home.Register(cross(cur), stubTheme{"B"}) uassert.Equal(t, "", home.LivePath()) uassert.Equal(t, 2, len(home.PendingPaths())) uassert.Equal(t, "page :widget: 1", home.Render(""), "nothing serves before Accept") testing.SetRealm(admin) home.Accept(cross(cur), themeA) uassert.Equal(t, themeA, home.LivePath()) uassert.Equal(t, "page <A> 1", home.Render("")) uassert.True(t, strings.Contains(home.Render("slots"), "# Slots"), "themes delegate other paths") uassert.True(t, strings.Contains(home.Render("system"), "["+themeA+"]"), home.Render("system")) uassert.True(t, strings.Contains(home.Render("system"), "value=\""+themeB+"\""), "pending themes get an Accept form") home.Accept(cross(cur), themeB) uassert.Equal(t, "page <B> 1", home.Render("")) uassert.Equal(t, 1, len(home.HistoryPaths())) uassert.Equal(t, themeA, home.HistoryPaths()[0]) home.Rollback(cross(cur)) uassert.Equal(t, "page <A> 1", home.Render("")) uassert.Equal(t, themeB, home.PendingPaths()[0], "the displaced theme returns to pending") // The slots were never in the theme, so switching never touched them. home.Set(cross(cur), "layout", ":widget: only") uassert.Equal(t, "<A> only", home.Render("")) } func TestBrokenThemeLeavesOperatorViews(cur realm, t *testing.T) { home.ResetForTest() home.SeedForTest("layout", "hello :rev:") testing.SetRealm(testing.NewCodeRealm(themeA)) home.Register(cross(cur), stubTheme{"A"}) testing.SetRealm(testing.NewCodeRealm(themeB)) home.Register(cross(cur), brokenTheme{}) testing.SetRealm(admin) home.Accept(cross(cur), themeA) uassert.Equal(t, "hello 1", home.Render("")) home.Accept(cross(cur), themeB) // The page is down: on chain this is a VM abort, not something this // realm can catch. uassert.AbortsContains(t, cur, "kaboom", func() { home.Render("") }) // The views the rollback is done from never touch the theme. uassert.True(t, strings.Contains(home.Render("system"), "["+themeA+"]")) uassert.True(t, strings.Contains(home.Render("slots"), "# Slots")) uassert.True(t, strings.Contains(home.Render("edit"), "<gno-form exec=\"Set\">")) uassert.Equal(t, home.Manifest(), home.Render("manifest")) home.Rollback(cross(cur)) uassert.Equal(t, themeA, home.LivePath()) uassert.Equal(t, "hello 1", home.Render("")) } func TestThemeOwnsEveryOtherPath(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(testing.NewCodeRealm(themeA)) home.Register(cross(cur), stubTheme{"A"}) testing.SetRealm(admin) home.Accept(cross(cur), themeA) uassert.Equal(t, "custom by A", home.Render("custom"), "a path the realm does not reserve reaches the theme") uassert.Equal(t, "custom by A", home.Render("custom?x=1")) uassert.True(t, strings.Contains(home.Render("slots?x=1"), "# Slots"), "reserved paths are matched without the query") uassert.True(t, strings.Contains(home.Render("slots/nope"), "# Not found")) } func TestRegisterRefusals(cur realm, t *testing.T) { home.ResetForTest() // A user cannot nominate a theme; only a deployed realm can. testing.SetRealm(admin) uassert.AbortsContains(t, cur, "only a deployed realm", func() { home.Register(cross(cur), stubTheme{"user"}) }) // A realm outside this path cannot either. testing.SetRealm(testing.NewCodeRealm("gno.land/r/mallory/theme")) uassert.AbortsContains(t, cur, "not nested", func() { home.Register(cross(cur), stubTheme{"foreign"}) }) uassert.Equal(t, 0, len(home.PendingPaths())) } func TestAcceptIsAuthorityOnly(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(testing.NewCodeRealm(themeA)) home.Register(cross(cur), stubTheme{"A"}) testing.SetRealm(mallory) uassert.AbortsContains(t, cur, "not the authority", func() { home.Accept(cross(cur), themeA) }) uassert.Equal(t, "", home.LivePath()) } func TestFreezeKeepsEditing(cur realm, t *testing.T) { home.ResetForTest() testing.SetRealm(testing.NewCodeRealm(themeA)) home.Register(cross(cur), stubTheme{"A"}) testing.SetRealm(admin) home.Accept(cross(cur), themeA) home.Freeze(cross(cur)) uassert.True(t, home.Frozen()) uassert.AbortsContains(t, cur, "frozen", func() { home.Rollback(cross(cur)) }) home.Set(cross(cur), "layout", "still editable :widget:") uassert.Equal(t, "still editable <A>", home.Render("")) uassert.True(t, strings.Contains(home.Render("system"), "**frozen**")) }
- #12render.gno
- #13package home import ( "strconv" "strings" "chain/runtime" ) // Render is what gnoweb calls. // // The operator views (system, slots, edit, manifest) are always served here, // never through the theme: a panic inside a foreign realm's code aborts the // whole call and cannot be recovered from this side, so a theme that panics // takes the page down until it is rolled back. These views are what the // rollback is done from, and the routing keeps them out of the theme's reach. // Every other path, the page itself included, goes to the live theme, or to // the built-in renderer when none is accepted. func Render(path string) string { if isOperatorPath(path) { return Fallback(path) } if t, ok := liveTheme(); ok { return t.Render(path) } return Fallback(path) } // operatorPaths are the render paths this realm keeps for itself. var operatorPaths = []string{"system", "slots", "edit", "manifest"} func isOperatorPath(path string) bool { path = cleanPath(path) for _, p := range operatorPaths { if path == p || strings.HasPrefix(path, p+"/") { return true } } return false } // cleanPath drops a query string from a render path. func cleanPath(path string) string { if i := strings.Index(path, "?"); i >= 0 { return path[:i] } return path } // Fallback is the built-in renderer. Render routes the operator views here // directly, and themes delegate to it for every other path they do not draw // themselves, so these views exist under any theme: // // "" the assembled page: the layout slot filled from the slots // "slots" the slot index: name, size, revision, height of last write // "slots/<slug>" one slot's raw markdown, fenced // "edit" forms that write slots (they build the gnokey command) // "edit/<slug>" the same, prefilled with one slot // "system" who may write, which theme is live, pending, history // "manifest" Manifest() as plain text, for scripts func Fallback(path string) string { path = cleanPath(path) switch { case path == "": return renderPage() case path == "slots": return renderIndex() case strings.HasPrefix(path, "slots/"): return renderSlot(strings.TrimPrefix(path, "slots/")) case path == "edit": return renderEdit("") case strings.HasPrefix(path, "edit/"): return renderEdit(strings.TrimPrefix(path, "edit/")) case path == "system": return renderSystem() case path == "manifest": return Manifest() } return "# Not found\n\nNo such path: " + strconv.Quote(path) + "\n\nTry [the slot index](" + Link("slots") + ").\n" } // --------------------------------------------------------------------------- // Placeholder filling // Filler substitutes :name: placeholders in a layout. It is lazy: a callback // runs only when its placeholder occurs in the layout, so an unused slot is // never even read. Substitution is single-pass and non-recursive, so a // placeholder inside a slot body is left alone and no slot can expand into // another. Names never contain ':', so no placeholder is a prefix of another // and the result does not depend on registration order. A placeholder that // nothing claims survives into the output verbatim: a missing section should // be visible, not silently blank. type Filler struct { names []string fns map[string]func() string } // NewFiller returns a Filler with every slot and every computed placeholder // registered. A theme adds its own widgets with Add before calling Fill. func NewFiller() *Filler { f := &Filler{fns: map[string]func() string{}} slots.Iterate("", "", func(key string, value any) bool { s := value.(*Slot) // re-bound per iteration, so each closure sees its own f.Add(key, func() string { return s.Body }) return false }) f.Add("realm", func() string { return selfPath }) f.Add("owner", Authority) f.Add("chainid", runtime.ChainID) f.Add("height", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) f.Add("rev", func() string { return strconv.Itoa(rev) }) f.Add("slots", slotLinks) f.Add("theme", func() string { if p := LivePath(); p != "" { return p } return "built-in" }) f.Add("updated", updated) return f } // updated is the :updated: placeholder: when the slots last changed. func updated() string { if lastHeight == 0 { return "never" } return "block " + strconv.FormatInt(lastHeight, 10) + " (rev " + strconv.Itoa(rev) + ")" } // Add registers the callback for :name:. A later Add for the same name wins, // which is how a theme widget overrides a slot of the same name. func (f *Filler) Add(name string, fn func() string) { if _, dup := f.fns[name]; !dup { f.names = append(f.names, name) } f.fns[name] = fn } // Fill returns layout with every present placeholder replaced. func (f *Filler) Fill(layout string) string { pairs := []string{} for _, name := range f.names { ph := ":" + name + ":" if strings.Contains(layout, ph) { pairs = append(pairs, ph, f.fns[name]()) } } if len(pairs) == 0 { return layout } return strings.NewReplacer(pairs...).Replace(layout) } // --------------------------------------------------------------------------- // Views // defaultLayout renders before a layout slot exists and no theme is live, // i.e. right after the very first deploy. It uses only computed placeholders, // so it never shows an unresolved :slug: of its own. func defaultLayout() string { return "# " + selfPath + "\n\n" + "No theme is live and no layout slot is set, so this is the built-in page. " + "Content arrives with `Set`, one slot per call, and a theme deployed under " + "`" + selfPath + "/theme/` goes live with `Accept`. " + "See [system](" + Link("system") + ") and [edit](" + Link("edit") + ").\n\n" + "## Slots\n\n:slots:\n\n---\n\n" + "rev :rev: · block :height: · :chainid: · theme :theme: · " + "[edit](" + Link("edit") + ") · [system](" + Link("system") + ")\n" } func renderPage() string { layout := Layout() if layout == "" { layout = defaultLayout() } return NewFiller().Fill(layout) } // slotLinks is the :slots: placeholder: a bullet list of every slot, linking // to its raw view. func slotLinks() string { if slots.Size() == 0 { return "_no slots yet_" } var b strings.Builder slots.Iterate("", "", func(key string, _ any) bool { b.WriteString("- [" + key + "](" + Link("slots/"+key) + ")\n") return false }) return strings.TrimSuffix(b.String(), "\n") } func renderIndex() string { var b strings.Builder b.WriteString("# Slots\n\n") b.WriteString("rev " + strconv.Itoa(rev) + " · " + strconv.Itoa(slots.Size()) + " slot(s) · " + "[edit](" + Link("edit") + ") · [system](" + Link("system") + ")\n\n") if slots.Size() == 0 { b.WriteString("_no slots yet_\n") return b.String() } b.WriteString("| slot | bytes | rev | height | |\n") b.WriteString("| --- | ---: | ---: | ---: | --- |\n") slots.Iterate("", "", func(key string, value any) bool { s := value.(*Slot) b.WriteString("| [" + key + "](" + Link("slots/"+key) + ") | " + strconv.Itoa(len(s.Body)) + " | " + strconv.Itoa(s.Rev) + " | " + strconv.FormatInt(s.Height, 10) + " | " + "[edit](" + Link("edit/"+key) + ") |\n") return false }) return b.String() } func renderSlot(slug string) string { s, ok := Lookup(slug) if !ok { return "# Not found\n\nNo slot named " + strconv.Quote(slug) + ".\n\nTry [the slot index](" + Link("slots") + ").\n" } f := fence(s.Body) var b strings.Builder b.WriteString("# " + slug + "\n\n") b.WriteString(strconv.Itoa(len(s.Body)) + " bytes · rev " + strconv.Itoa(s.Rev) + " · written at block " + strconv.FormatInt(s.Height, 10) + " · [edit](" + Link("edit/"+slug) + ")\n\n") b.WriteString(f + "\n" + s.Body) if !strings.HasSuffix(s.Body, "\n") { b.WriteString("\n") } b.WriteString(f + "\n") return b.String() } // fence returns a code fence longer than any backtick run in body, so the // body can never close it early. func fence(body string) string { longest, run := 0, 0 for i := 0; i < len(body); i++ { if body[i] != '`' { run = 0 continue } run++ if run > longest { longest = run } } n := 3 if longest >= 3 { n = longest + 1 } return strings.Repeat("`", n) } // renderEdit is the in-browser editor: gnoweb forms that build the gnokey // command for Set and Delete. Anyone can see them; only the authority's // signature makes the resulting transaction succeed. func renderEdit(slug string) string { var b strings.Builder b.WriteString("# Edit\n\n") b.WriteString("Writes are restricted to `" + Authority() + "`. Each form assembles the " + "`gnokey` command; sign it with that key. Slots and style knobs are the same thing: " + "`style.accent` is a slot too. [Index](" + Link("slots") + ") · [system](" + Link("system") + ")\n\n") if slug != "" { s, ok := Lookup(slug) if !ok { b.WriteString("> [!WARNING]\n> No slot named " + strconv.Quote(slug) + " yet. Saving creates it.\n\n") } b.WriteString("## " + slug + "\n\n") b.WriteString("<gno-form exec=\"Set\">\n") b.WriteString(" <gno-input name=\"slug\" type=\"text\" value=\"" + attr(slug) + "\" placeholder=\"slot name\" />\n") b.WriteString(" <gno-textarea name=\"body\" rows=\"10\" value=\"" + attr(s.Body) + "\" placeholder=\"markdown\" />\n") b.WriteString("</gno-form>\n\n") if ok { b.WriteString("[Raw view](" + Link("slots/"+slug) + ") · " + "a body that itself contains the two characters `\\n` or `\\t` is unfolded here; " + "use `make push` for those.\n\n") } } else { b.WriteString("## New or replace\n\n") b.WriteString("<gno-form exec=\"Set\">\n") b.WriteString(" <gno-input name=\"slug\" type=\"text\" placeholder=\"slot name: [a-z0-9._-]\" />\n") b.WriteString(" <gno-textarea name=\"body\" rows=\"10\" placeholder=\"markdown\" />\n") b.WriteString("</gno-form>\n\n") } b.WriteString("## Delete\n\n") b.WriteString("<gno-form exec=\"Delete\">\n") b.WriteString(" <gno-input name=\"slug\" type=\"text\" placeholder=\"slot name\" />\n") b.WriteString("</gno-form>\n\n") b.WriteString("## Slots\n\n") if slots.Size() == 0 { b.WriteString("_no slots yet_\n") return b.String() } slots.Iterate("", "", func(key string, _ any) bool { b.WriteString("- " + key + " · [view](" + Link("slots/"+key) + ") · [edit](" + Link("edit/"+key) + ")\n") return false }) return b.String() } // attr escapes s for a double-quoted attribute of a gnoweb form tag, which // must sit on one line: newlines and tabs become the \n and \t sequences the // textarea unfolds. func attr(s string) string { return strings.NewReplacer( "&", "&", "\"", """, "<", "<", ">", ">", "\r", "", "\n", "\\n", "\t", "\\t", ).Replace(s) } // renderSystem shows the upgrade wiring and offers the Accept form. func renderSystem() string { var b strings.Builder b.WriteString("# System\n\n") b.WriteString("| | |\n| --- | --- |\n") b.WriteString("| realm | `" + selfPath + "` |\n") b.WriteString("| authority | `" + Authority() + "` |\n") if p := LivePath(); p != "" { b.WriteString("| theme | [" + p + "](" + sourceLink(p) + ") |\n") } else { b.WriteString("| theme | built-in fallback (nothing accepted yet) |\n") } b.WriteString("| revision | " + strconv.Itoa(rev) + " |\n") b.WriteString("| last write | " + updated() + " |\n") if Frozen() { b.WriteString("| upgrades | **frozen** |\n") } else { b.WriteString("| upgrades | open |\n") } b.WriteString("\n") pending := PendingPaths() if len(pending) > 0 { b.WriteString("## Awaiting acceptance\n\n") b.WriteString("A theme registered itself by being deployed. Accepting is a separate transaction, " + "naming code you can [read first](" + sourceLink(pending[0]) + ").\n\n") for _, p := range pending { b.WriteString("<gno-form exec=\"Accept\">\n") b.WriteString(" <gno-input name=\"pkgPath\" type=\"text\" value=\"" + attr(p) + "\" placeholder=\"theme realm path\" />\n") b.WriteString("</gno-form>\n\n") } } if past := HistoryPaths(); len(past) > 0 { b.WriteString("## Previously\n\n") for _, p := range past { b.WriteString("- [" + p + "](" + sourceLink(p) + ")\n") } b.WriteString("\n") } if !Frozen() { b.WriteString("## Operations\n\n") b.WriteString("- [Rollback](" + helpLink("Rollback") + ") to the previous theme\n") b.WriteString("- [Accept](" + helpLink("Accept") + ") a theme by path\n") b.WriteString("- [Withdraw](" + helpLink("Withdraw") + ") a candidate\n") b.WriteString("- [Forget](" + helpLink("Forget") + ") the rollback history\n") b.WriteString("- [Transfer authority](" + helpLink("TransferAuthority") + ")\n") b.WriteString("- [Freeze](" + helpLink("Freeze") + "): no more theme upgrades, ever\n\n") } b.WriteString("## How an upgrade works\n\n") b.WriteString("1. Copy `theme/v0` to `theme/v1`, change what you like, keep `Render`.\n") b.WriteString("2. Deploy it at `" + selfPath + "/theme/v1`. Its init registers it here.\n") b.WriteString("3. Accept that path. The slots never moved; only the code reading them changed.\n") b.WriteString("4. If it misbehaves, Rollback. A theme that panics takes the page down until then, " + "but this view and the other built-in ones never go through the theme, so the rollback is always reachable.\n") return b.String() } func helpLink(fn string) string { return Link("") + "$help&func=" + fn } func sourceLink(pkgPath string) string { if i := strings.Index(pkgPath, "/"); i >= 0 { pkgPath = pkgPath[i:] } return pkgPath + "$source" }
- #14render_test.gno
- #15package home import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // Tests that need no crossing: pure helpers and the built-in views. The // crossing behavior (writes, authority, theme lifecycle) is in home_test.gno, // from outside the package, because a call into one's own realm never crosses. func TestValidSlug(t *testing.T) { for _, ok := range []string{"a", "now", "style.accent", "a-b_c.d", strings.Repeat("x", 64)} { uassert.True(t, validSlug(ok), ok) } for _, bad := range []string{"", "Now", "a:b", "a b", "a/b", strings.Repeat("x", 65)} { uassert.False(t, validSlug(bad), bad) } } func TestFence(t *testing.T) { uassert.Equal(t, "```", fence("plain")) uassert.Equal(t, "```", fence("has `one` and ``two``")) uassert.Equal(t, "````", fence("```go\nx\n```")) uassert.Equal(t, "`````", fence("````")) } func TestAttr(t *testing.T) { uassert.Equal(t, "a & b "c" <d>\\n\\te", attr("a & b \"c\" <d>\r\n\te")) } func TestFillerIsLazySinglePassAndOrderIndependent(t *testing.T) { ResetForTest() SeedForTest("a", "A says :b:") SeedForTest("b", "B") calls := 0 f := NewFiller() f.Add("expensive", func() string { calls++; return "X" }) uassert.Equal(t, "A says :b: B :nope:", f.Fill(":a: :b: :nope:")) uassert.Equal(t, 0, calls, "an absent placeholder never runs its callback") uassert.Equal(t, "X", f.Fill(":expensive:")) // A later Add for a slot's name wins: that is how a theme widget overrides. f.Add("a", func() string { return "widget" }) uassert.Equal(t, "widget", f.Fill(":a:")) } func TestRenderWithoutLayoutOrTheme(t *testing.T) { ResetForTest() out := Render("") uassert.True(t, strings.Contains(out, "# gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home"), out) uassert.True(t, strings.Contains(out, "No theme is live"), out) uassert.True(t, strings.Contains(out, "_no slots yet_"), out) uassert.True(t, strings.Contains(out, "theme built-in"), out) uassert.False(t, strings.Contains(out, ":slots:"), "computed placeholders must resolve") } func TestRenderLayoutSubstitution(t *testing.T) { ResetForTest() SeedForTest("intro", "Hello :rev: world") // placeholders inside a body stay put SeedForTest("layout", "# Page\n\n:intro:\n\nrev :rev: · :missing: · :realm:\n") uassert.Equal(t, "# Page\n\nHello :rev: world\n\nrev 2 · :missing: · gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home\n", Render("")) } func TestRenderIndexAndSlot(t *testing.T) { ResetForTest() SeedForTest("now", "```go\nx := 1\n```") idx := Render("slots") uassert.True(t, strings.Contains(idx, "| [now](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home:slots/now) |"), idx) uassert.True(t, strings.Contains(idx, "[edit](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home:edit/now)"), idx) raw := Render("slots/now") uassert.True(t, strings.Contains(raw, "# now\n"), raw) uassert.True(t, strings.Contains(raw, "````\n```go\nx := 1\n```\n````\n"), "fence must outgrow the body: "+raw) uassert.True(t, strings.Contains(Render("slots/nope"), "# Not found")) uassert.True(t, strings.Contains(Render("nope"), "# Not found")) uassert.True(t, strings.Contains(Render("slots?x=1"), "# Slots"), "query strings are ignored") uassert.Equal(t, Manifest(), Render("manifest")) } func TestSlugsEachManifest(t *testing.T) { ResetForTest() SeedForTest("b", "two") SeedForTest("a", "one") uassert.Equal(t, 2, len(Slugs())) uassert.Equal(t, "a", Slugs()[0]) visited := []string{} Each(func(slug string, s Slot) bool { visited = append(visited, slug+"="+s.Body) return false }) uassert.Equal(t, "a=one b=two", strings.Join(visited, " ")) lines := strings.Split(strings.TrimSpace(Manifest()), "\n") uassert.Equal(t, 2, len(lines)) uassert.True(t, strings.HasPrefix(lines[0], "a\t2\t3\t"), lines[0]) uassert.Equal(t, 4, len(strings.Split(lines[0], "\t"))) s, ok := Lookup("a") uassert.True(t, ok) uassert.Equal(t, 2, s.Rev) _, ok = Lookup("zzz") uassert.False(t, ok) uassert.Equal(t, "#000", Style("missing", "#000")) } func TestRenderEdit(t *testing.T) { ResetForTest() SeedForTest("intro", "line \"one\"\n<two> & three\ttab") blank := Render("edit") uassert.True(t, strings.Contains(blank, "<gno-form exec=\"Set\">"), blank) uassert.True(t, strings.Contains(blank, "<gno-form exec=\"Delete\">"), blank) uassert.True(t, strings.Contains(blank, "[edit](/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home:edit/intro)"), blank) one := Render("edit/intro") uassert.True(t, strings.Contains(one, "value=\"intro\""), one) uassert.True(t, strings.Contains(one, "value=\"line "one"\\n<two> & three\\ttab\""), one) missing := Render("edit/later") uassert.True(t, strings.Contains(missing, "Saving creates it"), missing) } func TestRenderSystem(t *testing.T) { ResetForTest() out := Render("system") uassert.True(t, strings.Contains(out, "built-in fallback"), out) uassert.True(t, strings.Contains(out, Admin.String()), out) uassert.True(t, strings.Contains(out, "$help&func=Rollback"), out) uassert.True(t, strings.Contains(out, "| last write | never |"), out) }
- #16/gno.MemPackageType
- #17 MPUserAll
Result log
msg:0,success:true,log:,events:[]