Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}