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.

render.gno

13.21 Kb · 393 lines
  1package home
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"chain/runtime"
  8)
  9
 10// Render is what gnoweb calls.
 11//
 12// The operator views (system, slots, edit, manifest) are always served here,
 13// never through the theme: a panic inside a foreign realm's code aborts the
 14// whole call and cannot be recovered from this side, so a theme that panics
 15// takes the page down until it is rolled back. These views are what the
 16// rollback is done from, and the routing keeps them out of the theme's reach.
 17// Every other path, the page itself included, goes to the live theme, or to
 18// the built-in renderer when none is accepted.
 19func Render(path string) string {
 20	if isOperatorPath(path) {
 21		return Fallback(path)
 22	}
 23	if t, ok := liveTheme(); ok {
 24		return t.Render(path)
 25	}
 26	return Fallback(path)
 27}
 28
 29// operatorPaths are the render paths this realm keeps for itself.
 30var operatorPaths = []string{"system", "slots", "edit", "manifest"}
 31
 32func isOperatorPath(path string) bool {
 33	path = cleanPath(path)
 34	for _, p := range operatorPaths {
 35		if path == p || strings.HasPrefix(path, p+"/") {
 36			return true
 37		}
 38	}
 39	return false
 40}
 41
 42// cleanPath drops a query string from a render path.
 43func cleanPath(path string) string {
 44	if i := strings.Index(path, "?"); i >= 0 {
 45		return path[:i]
 46	}
 47	return path
 48}
 49
 50// Fallback is the built-in renderer. Render routes the operator views here
 51// directly, and themes delegate to it for every other path they do not draw
 52// themselves, so these views exist under any theme:
 53//
 54//	""             the assembled page: the layout slot filled from the slots
 55//	"slots"        the slot index: name, size, revision, height of last write
 56//	"slots/<slug>" one slot's raw markdown, fenced
 57//	"edit"         forms that write slots (they build the gnokey command)
 58//	"edit/<slug>"  the same, prefilled with one slot
 59//	"system"       who may write, which theme is live, pending, history
 60//	"manifest"     Manifest() as plain text, for scripts
 61func Fallback(path string) string {
 62	path = cleanPath(path)
 63	switch {
 64	case path == "":
 65		return renderPage()
 66	case path == "slots":
 67		return renderIndex()
 68	case strings.HasPrefix(path, "slots/"):
 69		return renderSlot(strings.TrimPrefix(path, "slots/"))
 70	case path == "edit":
 71		return renderEdit("")
 72	case strings.HasPrefix(path, "edit/"):
 73		return renderEdit(strings.TrimPrefix(path, "edit/"))
 74	case path == "system":
 75		return renderSystem()
 76	case path == "manifest":
 77		return Manifest()
 78	}
 79	return "# Not found\n\nNo such path: " + strconv.Quote(path) +
 80		"\n\nTry [the slot index](" + Link("slots") + ").\n"
 81}
 82
 83// ---------------------------------------------------------------------------
 84// Placeholder filling
 85
 86// Filler substitutes :name: placeholders in a layout. It is lazy: a callback
 87// runs only when its placeholder occurs in the layout, so an unused slot is
 88// never even read. Substitution is single-pass and non-recursive, so a
 89// placeholder inside a slot body is left alone and no slot can expand into
 90// another. Names never contain ':', so no placeholder is a prefix of another
 91// and the result does not depend on registration order. A placeholder that
 92// nothing claims survives into the output verbatim: a missing section should
 93// be visible, not silently blank.
 94type Filler struct {
 95	names []string
 96	fns   map[string]func() string
 97}
 98
 99// NewFiller returns a Filler with every slot and every computed placeholder
100// registered. A theme adds its own widgets with Add before calling Fill.
101func NewFiller() *Filler {
102	f := &Filler{fns: map[string]func() string{}}
103	slots.Iterate("", "", func(key string, value any) bool {
104		s := value.(*Slot) // re-bound per iteration, so each closure sees its own
105		f.Add(key, func() string { return s.Body })
106		return false
107	})
108	f.Add("realm", func() string { return selfPath })
109	f.Add("owner", Authority)
110	f.Add("chainid", runtime.ChainID)
111	f.Add("height", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) })
112	f.Add("rev", func() string { return strconv.Itoa(rev) })
113	f.Add("slots", slotLinks)
114	f.Add("theme", func() string {
115		if p := LivePath(); p != "" {
116			return p
117		}
118		return "built-in"
119	})
120	f.Add("updated", updated)
121	return f
122}
123
124// updated is the :updated: placeholder: when the slots last changed.
125func updated() string {
126	if lastHeight == 0 {
127		return "never"
128	}
129	return "block " + strconv.FormatInt(lastHeight, 10) + " (rev " + strconv.Itoa(rev) + ")"
130}
131
132// Add registers the callback for :name:. A later Add for the same name wins,
133// which is how a theme widget overrides a slot of the same name.
134func (f *Filler) Add(name string, fn func() string) {
135	if _, dup := f.fns[name]; !dup {
136		f.names = append(f.names, name)
137	}
138	f.fns[name] = fn
139}
140
141// Fill returns layout with every present placeholder replaced.
142func (f *Filler) Fill(layout string) string {
143	pairs := []string{}
144	for _, name := range f.names {
145		ph := ":" + name + ":"
146		if strings.Contains(layout, ph) {
147			pairs = append(pairs, ph, f.fns[name]())
148		}
149	}
150	if len(pairs) == 0 {
151		return layout
152	}
153	return strings.NewReplacer(pairs...).Replace(layout)
154}
155
156// ---------------------------------------------------------------------------
157// Views
158
159// defaultLayout renders before a layout slot exists and no theme is live,
160// i.e. right after the very first deploy. It uses only computed placeholders,
161// so it never shows an unresolved :slug: of its own.
162func defaultLayout() string {
163	return "# " + selfPath + "\n\n" +
164		"No theme is live and no layout slot is set, so this is the built-in page. " +
165		"Content arrives with `Set`, one slot per call, and a theme deployed under " +
166		"`" + selfPath + "/theme/` goes live with `Accept`. " +
167		"See [system](" + Link("system") + ") and [edit](" + Link("edit") + ").\n\n" +
168		"## Slots\n\n:slots:\n\n---\n\n" +
169		"rev :rev: · block :height: · :chainid: · theme :theme: · " +
170		"[edit](" + Link("edit") + ") · [system](" + Link("system") + ")\n"
171}
172
173func renderPage() string {
174	layout := Layout()
175	if layout == "" {
176		layout = defaultLayout()
177	}
178	return NewFiller().Fill(layout)
179}
180
181// slotLinks is the :slots: placeholder: a bullet list of every slot, linking
182// to its raw view.
183func slotLinks() string {
184	if slots.Size() == 0 {
185		return "_no slots yet_"
186	}
187	var b strings.Builder
188	slots.Iterate("", "", func(key string, _ any) bool {
189		b.WriteString("- [" + key + "](" + Link("slots/"+key) + ")\n")
190		return false
191	})
192	return strings.TrimSuffix(b.String(), "\n")
193}
194
195func renderIndex() string {
196	var b strings.Builder
197	b.WriteString("# Slots\n\n")
198	b.WriteString("rev " + strconv.Itoa(rev) + " · " + strconv.Itoa(slots.Size()) + " slot(s) · " +
199		"[edit](" + Link("edit") + ") · [system](" + Link("system") + ")\n\n")
200	if slots.Size() == 0 {
201		b.WriteString("_no slots yet_\n")
202		return b.String()
203	}
204	b.WriteString("| slot | bytes | rev | height | |\n")
205	b.WriteString("| --- | ---: | ---: | ---: | --- |\n")
206	slots.Iterate("", "", func(key string, value any) bool {
207		s := value.(*Slot)
208		b.WriteString("| [" + key + "](" + Link("slots/"+key) + ") | " +
209			strconv.Itoa(len(s.Body)) + " | " +
210			strconv.Itoa(s.Rev) + " | " +
211			strconv.FormatInt(s.Height, 10) + " | " +
212			"[edit](" + Link("edit/"+key) + ") |\n")
213		return false
214	})
215	return b.String()
216}
217
218func renderSlot(slug string) string {
219	s, ok := Lookup(slug)
220	if !ok {
221		return "# Not found\n\nNo slot named " + strconv.Quote(slug) +
222			".\n\nTry [the slot index](" + Link("slots") + ").\n"
223	}
224	f := fence(s.Body)
225	var b strings.Builder
226	b.WriteString("# " + slug + "\n\n")
227	b.WriteString(strconv.Itoa(len(s.Body)) + " bytes · rev " + strconv.Itoa(s.Rev) +
228		" · written at block " + strconv.FormatInt(s.Height, 10) +
229		" · [edit](" + Link("edit/"+slug) + ")\n\n")
230	b.WriteString(f + "\n" + s.Body)
231	if !strings.HasSuffix(s.Body, "\n") {
232		b.WriteString("\n")
233	}
234	b.WriteString(f + "\n")
235	return b.String()
236}
237
238// fence returns a code fence longer than any backtick run in body, so the
239// body can never close it early.
240func fence(body string) string {
241	longest, run := 0, 0
242	for i := 0; i < len(body); i++ {
243		if body[i] != '`' {
244			run = 0
245			continue
246		}
247		run++
248		if run > longest {
249			longest = run
250		}
251	}
252	n := 3
253	if longest >= 3 {
254		n = longest + 1
255	}
256	return strings.Repeat("`", n)
257}
258
259// renderEdit is the in-browser editor: gnoweb forms that build the gnokey
260// command for Set and Delete. Anyone can see them; only the authority's
261// signature makes the resulting transaction succeed.
262func renderEdit(slug string) string {
263	var b strings.Builder
264	b.WriteString("# Edit\n\n")
265	b.WriteString("Writes are restricted to `" + Authority() + "`. Each form assembles the " +
266		"`gnokey` command; sign it with that key. Slots and style knobs are the same thing: " +
267		"`style.accent` is a slot too. [Index](" + Link("slots") + ") · [system](" + Link("system") + ")\n\n")
268
269	if slug != "" {
270		s, ok := Lookup(slug)
271		if !ok {
272			b.WriteString("> [!WARNING]\n> No slot named " + strconv.Quote(slug) + " yet. Saving creates it.\n\n")
273		}
274		b.WriteString("## " + slug + "\n\n")
275		b.WriteString("<gno-form exec=\"Set\">\n")
276		b.WriteString("  <gno-input name=\"slug\" type=\"text\" value=\"" + attr(slug) + "\" placeholder=\"slot name\" />\n")
277		b.WriteString("  <gno-textarea name=\"body\" rows=\"10\" value=\"" + attr(s.Body) + "\" placeholder=\"markdown\" />\n")
278		b.WriteString("</gno-form>\n\n")
279		if ok {
280			b.WriteString("[Raw view](" + Link("slots/"+slug) + ") · " +
281				"a body that itself contains the two characters `\\n` or `\\t` is unfolded here; " +
282				"use `make push` for those.\n\n")
283		}
284	} else {
285		b.WriteString("## New or replace\n\n")
286		b.WriteString("<gno-form exec=\"Set\">\n")
287		b.WriteString("  <gno-input name=\"slug\" type=\"text\" placeholder=\"slot name: [a-z0-9._-]\" />\n")
288		b.WriteString("  <gno-textarea name=\"body\" rows=\"10\" placeholder=\"markdown\" />\n")
289		b.WriteString("</gno-form>\n\n")
290	}
291
292	b.WriteString("## Delete\n\n")
293	b.WriteString("<gno-form exec=\"Delete\">\n")
294	b.WriteString("  <gno-input name=\"slug\" type=\"text\" placeholder=\"slot name\" />\n")
295	b.WriteString("</gno-form>\n\n")
296
297	b.WriteString("## Slots\n\n")
298	if slots.Size() == 0 {
299		b.WriteString("_no slots yet_\n")
300		return b.String()
301	}
302	slots.Iterate("", "", func(key string, _ any) bool {
303		b.WriteString("- " + key + " · [view](" + Link("slots/"+key) + ") · [edit](" + Link("edit/"+key) + ")\n")
304		return false
305	})
306	return b.String()
307}
308
309// attr escapes s for a double-quoted attribute of a gnoweb form tag, which
310// must sit on one line: newlines and tabs become the \n and \t sequences the
311// textarea unfolds.
312func attr(s string) string {
313	return strings.NewReplacer(
314		"&", "&amp;",
315		"\"", "&quot;",
316		"<", "&lt;",
317		">", "&gt;",
318		"\r", "",
319		"\n", "\\n",
320		"\t", "\\t",
321	).Replace(s)
322}
323
324// renderSystem shows the upgrade wiring and offers the Accept form.
325func renderSystem() string {
326	var b strings.Builder
327	b.WriteString("# System\n\n")
328	b.WriteString("| | |\n| --- | --- |\n")
329	b.WriteString("| realm | `" + selfPath + "` |\n")
330	b.WriteString("| authority | `" + Authority() + "` |\n")
331	if p := LivePath(); p != "" {
332		b.WriteString("| theme | [" + p + "](" + sourceLink(p) + ") |\n")
333	} else {
334		b.WriteString("| theme | built-in fallback (nothing accepted yet) |\n")
335	}
336	b.WriteString("| revision | " + strconv.Itoa(rev) + " |\n")
337	b.WriteString("| last write | " + updated() + " |\n")
338	if Frozen() {
339		b.WriteString("| upgrades | **frozen** |\n")
340	} else {
341		b.WriteString("| upgrades | open |\n")
342	}
343	b.WriteString("\n")
344
345	pending := PendingPaths()
346	if len(pending) > 0 {
347		b.WriteString("## Awaiting acceptance\n\n")
348		b.WriteString("A theme registered itself by being deployed. Accepting is a separate transaction, " +
349			"naming code you can [read first](" + sourceLink(pending[0]) + ").\n\n")
350		for _, p := range pending {
351			b.WriteString("<gno-form exec=\"Accept\">\n")
352			b.WriteString("  <gno-input name=\"pkgPath\" type=\"text\" value=\"" + attr(p) + "\" placeholder=\"theme realm path\" />\n")
353			b.WriteString("</gno-form>\n\n")
354		}
355	}
356
357	if past := HistoryPaths(); len(past) > 0 {
358		b.WriteString("## Previously\n\n")
359		for _, p := range past {
360			b.WriteString("- [" + p + "](" + sourceLink(p) + ")\n")
361		}
362		b.WriteString("\n")
363	}
364
365	if !Frozen() {
366		b.WriteString("## Operations\n\n")
367		b.WriteString("- [Rollback](" + helpLink("Rollback") + ") to the previous theme\n")
368		b.WriteString("- [Accept](" + helpLink("Accept") + ") a theme by path\n")
369		b.WriteString("- [Withdraw](" + helpLink("Withdraw") + ") a candidate\n")
370		b.WriteString("- [Forget](" + helpLink("Forget") + ") the rollback history\n")
371		b.WriteString("- [Transfer authority](" + helpLink("TransferAuthority") + ")\n")
372		b.WriteString("- [Freeze](" + helpLink("Freeze") + "): no more theme upgrades, ever\n\n")
373	}
374
375	b.WriteString("## How an upgrade works\n\n")
376	b.WriteString("1. Copy `theme/v0` to `theme/v1`, change what you like, keep `Render`.\n")
377	b.WriteString("2. Deploy it at `" + selfPath + "/theme/v1`. Its init registers it here.\n")
378	b.WriteString("3. Accept that path. The slots never moved; only the code reading them changed.\n")
379	b.WriteString("4. If it misbehaves, Rollback. A theme that panics takes the page down until then, " +
380		"but this view and the other built-in ones never go through the theme, so the rollback is always reachable.\n")
381	return b.String()
382}
383
384func helpLink(fn string) string {
385	return Link("") + "$help&func=" + fn
386}
387
388func sourceLink(pkgPath string) string {
389	if i := strings.Index(pkgPath, "/"); i >= 0 {
390		pkgPath = pkgPath[i:]
391	}
392	return pkgPath + "$source"
393}