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 "&", "&",
315 "\"", """,
316 "<", "<",
317 ">", ">",
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}