render.gno
15.60 Kb · 476 lines
1package wiki
2
3import (
4 "strings"
5
6 "gno.land/p/moul/md/v0"
7 "gno.land/p/nt/markdown/sanitize/v0"
8 "gno.land/p/nt/ufmt/v0"
9)
10
11// DepositPerByte is the storage deposit a realm write locks per byte, in
12// ugnot (gnolang/gno#6171). It is used only to show a reader what a page
13// costs; the chain, not this package, does the accounting.
14const DepositPerByte = 100
15
16// Action builds a transaction link for a realm function. The realm supplies
17// it (txlink.Realm("…").Call is the usual value); a nil Action renders a page
18// with no edit controls, which is what an archived or read-only mirror wants.
19type Action func(fn string, args ...string) string
20
21// RenderArticle renders a page for reading: the sanitized body with its
22// wikilinks resolved, a header identifying the current revision, and a footer
23// with categories and cost.
24func RenderArticle(c Ctx, w *Wiki, p *Page, act Action) string {
25 var out strings.Builder
26 out.WriteString(md.H1(p.Title.String()))
27
28 if p.head == nil {
29 out.WriteString(md.Paragraph("_This page has no revisions._"))
30 return out.String()
31 }
32 out.WriteString(articleMeta(c, w, p, act))
33
34 if p.Blanked {
35 out.WriteString(md.Blockquote("This page was blanked. Its history is still on chain: " +
36 md.Link("view history", c.Sub(p.Title, "history"))))
37 return out.String()
38 }
39
40 body, kept := p.Body()
41 switch {
42 case !kept:
43 out.WriteString(md.Blockquote("The current revision's body is no longer held on chain. " +
44 "Recover it from the transaction that wrote it and check it against `" + p.head.ShortHash() + "`."))
45 case p.redirect != "":
46 out.WriteString(md.Paragraph("Redirects to " + md.Link(p.redirect, escapeURL(c.Base+":"+strings.ReplaceAll(p.redirect, " ", "_")))))
47 default:
48 // sanitize.BlockRich wraps its output in blank lines on both sides,
49 // and that padding is load-bearing: a CommonMark HTML block of type
50 // 6 or 7 is not escaped in any mode, and without the blank line it
51 // would swallow the footer this function appends next. Do not trim
52 // it to tidy the output.
53 out.WriteString(RewriteLinks(c, sanitize.BlockRich(body)))
54 }
55
56 out.WriteString(articleFooter(c, w, p))
57 return out.String()
58}
59
60func articleMeta(c Ctx, w *Wiki, p *Page, act Action) string {
61 h := p.head
62 parts := []string{
63 ufmt.Sprintf("rev %d", h.ID),
64 h.Time.Format("2006-01-02 15:04"),
65 "by " + shortAddr(h.Author),
66 }
67 if p.Protection != Open {
68 parts = append(parts, p.Protection.String())
69 }
70 line := strings.Join(parts, " · ")
71
72 nav := []string{
73 md.Link("history", c.Sub(p.Title, "history")),
74 md.Link("source", c.Sub(p.Title, "raw")),
75 md.Link("what links here", c.SpecialURL("Backlinks", "page="+p.Title.Slug())),
76 }
77 if act != nil {
78 nav = append(nav, md.Link("edit", act("Edit", "title", p.Title.String())))
79 }
80 // The count sits outside the link text on purpose: md.Link sanitizes its
81 // label, so parentheses inside it would render as "\(1\)".
82 nav = append(nav, md.Link("discussion", c.Sub(p.Title, "talk"))+
83 ufmt.Sprintf(" (%d)", w.NumComments(p.Title)))
84 return md.Paragraph(line + "\n\n" + strings.Join(nav, " · "))
85}
86
87func articleFooter(c Ctx, w *Wiki, p *Page) string {
88 var out strings.Builder
89 out.WriteString(md.HorizontalRule())
90
91 if len(p.cats) > 0 {
92 cats := []string{}
93 for _, key := range p.cats {
94 t := Title{NS: NSCategory, Name: key[len(NSCategory.Prefix()):]}
95 cats = append(cats, md.Link(t.Name, c.URL(t)))
96 }
97 out.WriteString(md.Paragraph("**Categories:** " + strings.Join(cats, " · ")))
98 }
99
100 held := 0
101 p.revs.Iterator(0, p.revs.Size()-1, func(_ int, v any) bool {
102 r := v.(*Revision)
103 if r.kept {
104 held += r.Size
105 }
106 return false
107 })
108 out.WriteString(md.Paragraph(ufmt.Sprintf(
109 "%s · %d bytes of text held on chain · %s of storage deposit · %s link here",
110 plural(p.NumRevisions(), "revision", "revisions"),
111 held, formatGNOT(held*DepositPerByte),
112 plural(len(w.Backlinks(p.Title)), "page", "pages"))))
113 return out.String()
114}
115
116// RenderMissing renders the stub shown for a title with no page: the red-link
117// destination, listing whoever already points at it.
118func RenderMissing(c Ctx, w *Wiki, t Title, act Action) string {
119 var out strings.Builder
120 out.WriteString(md.H1(t.String()))
121 out.WriteString(md.Paragraph("_This page does not exist yet._"))
122 if act != nil {
123 out.WriteString(md.Paragraph(md.Link("Create it", act("Edit", "title", t.String()))))
124 }
125 if in := w.Backlinks(t); len(in) > 0 {
126 out.WriteString(md.H2("Pages that already link here"))
127 out.WriteString(titleList(c, in))
128 }
129 return out.String()
130}
131
132// RenderHistory renders a page's revision list, newest first.
133func RenderHistory(c Ctx, p *Page, offset, count int, act Action) string {
134 var out strings.Builder
135 out.WriteString(md.H1("History of " + p.Title.String()))
136 out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title))))
137
138 revs := p.History(offset, count)
139 if len(revs) == 0 {
140 out.WriteString(md.Paragraph("_No revisions in this range._"))
141 return out.String()
142 }
143 items := []string{}
144 for _, r := range revs {
145 line := ufmt.Sprintf("**rev %d** · %s · %s · %s · %d bytes · `%s`",
146 r.ID, r.Time.Format("2006-01-02 15:04"), shortAddr(r.Author), string(r.Kind), r.Size, r.ShortHash())
147 if r.Summary != "" {
148 line += "\n\n" + sanitize.InlineText(r.Summary)
149 }
150 extra := []string{}
151 if r.Prev != 0 {
152 extra = append(extra, md.Link("diff", c.Sub(p.Title, "diff")+"?from="+ufmt.Sprintf("%d", r.Prev)+"&to="+ufmt.Sprintf("%d", r.ID)))
153 }
154 if r.kept {
155 extra = append(extra, md.Link("view", c.Sub(p.Title, "rev")+"/"+ufmt.Sprintf("%d", r.ID)))
156 if act != nil {
157 extra = append(extra, md.Link("revert to this", act("Revert",
158 "title", p.Title.String(), "rev", ufmt.Sprintf("%d", r.ID))))
159 }
160 } else {
161 extra = append(extra, "_body evicted_")
162 }
163 items = append(items, line+"\n\n"+strings.Join(extra, " · "))
164 }
165 out.WriteString(md.BulletList(items))
166
167 if offset+len(revs) < p.NumRevisions() {
168 out.WriteString(md.Paragraph(md.Link("older →",
169 c.Sub(p.Title, "history")+"?offset="+ufmt.Sprintf("%d", offset+count))))
170 }
171 return out.String()
172}
173
174// RenderRevision renders one stored revision verbatim.
175func RenderRevision(c Ctx, p *Page, r *Revision) string {
176 var out strings.Builder
177 out.WriteString(md.H1(ufmt.Sprintf("%s: revision %d", p.Title.String(), r.ID)))
178 out.WriteString(md.Paragraph(ufmt.Sprintf("%s · %s · `%s`",
179 r.Time.Format("2006-01-02 15:04"), shortAddr(r.Author), r.Hash)))
180 body, kept := r.Body()
181 if !kept {
182 out.WriteString(md.Blockquote("This revision's body is no longer held on chain."))
183 return out.String()
184 }
185 out.WriteString(RewriteLinks(c, sanitize.BlockRich(body)))
186 return out.String()
187}
188
189// RenderRaw renders a revision's source inside a code block, which is what a
190// reader needs before editing and what a verifier needs to re-hash.
191func RenderRaw(c Ctx, p *Page, r *Revision) string {
192 var out strings.Builder
193 out.WriteString(md.H1(ufmt.Sprintf("Source of %s (rev %d)", p.Title.String(), r.ID)))
194 out.WriteString(md.Paragraph("sha256 `" + r.Hash + "`"))
195 body, kept := r.Body()
196 if !kept {
197 out.WriteString(md.Blockquote("This revision's body is no longer held on chain."))
198 return out.String()
199 }
200 out.WriteString(sanitize.CodeBlock(body))
201 out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title))))
202 return out.String()
203}
204
205// RenderDiff renders the line diff between two revisions of a page.
206func RenderDiff(c Ctx, p *Page, from, to *Revision) string {
207 var out strings.Builder
208 out.WriteString(md.H1(ufmt.Sprintf("%s: rev %d → rev %d", p.Title.String(), from.ID, to.ID)))
209
210 a, okA := from.Body()
211 b, okB := to.Body()
212 if !okA || !okB {
213 out.WriteString(md.Blockquote("One of these revisions' bodies is no longer held on chain, so the diff cannot be computed. " +
214 "Their hashes are `" + from.ShortHash() + "` and `" + to.ShortHash() + "`."))
215 return out.String()
216 }
217
218 lines, exact := DiffLines(a, b)
219 added, removed := DiffStat(lines)
220 note := ufmt.Sprintf("+%d −%d lines", added, removed)
221 if !exact {
222 note += " · changed region larger than " + ufmt.Sprintf("%d", DiffMaxLines) + " lines, shown as a block replacement"
223 }
224 out.WriteString(md.Paragraph(note))
225
226 var d strings.Builder
227 for _, l := range lines {
228 switch l.Op {
229 case OpInsert:
230 d.WriteString("+" + l.Text + "\n")
231 case OpDelete:
232 d.WriteString("-" + l.Text + "\n")
233 default:
234 d.WriteString(" " + l.Text + "\n")
235 }
236 }
237 out.WriteString(sanitize.LanguageCodeBlock("diff", d.String()))
238 out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title))))
239 return out.String()
240}
241
242// RenderIndex renders the wiki's front page: recent changes and a page count.
243func RenderIndex(c Ctx, w *Wiki, recent int) string {
244 var out strings.Builder
245 s := w.Stats()
246 out.WriteString(md.H1("Wiki"))
247 out.WriteString(md.Paragraph(strings.Join([]string{
248 md.Link("all pages", c.SpecialURL("AllPages", "")),
249 md.Link("categories", c.SpecialURL("Categories", "")),
250 md.Link("recent changes", c.SpecialURL("RecentChanges", "")),
251 md.Link("stats", c.SpecialURL("Stats", "")),
252 }, " · ")))
253 out.WriteString(md.Paragraph(ufmt.Sprintf("%d pages · %d revisions · %d bytes on chain",
254 s.Pages, s.Revisions, s.BytesHeld)))
255 out.WriteString(md.H2("Recent changes"))
256 out.WriteString(changeList(c, w.Recent(recent)))
257 return out.String()
258}
259
260// RenderRecent renders the recent-changes feed.
261func RenderRecent(c Ctx, w *Wiki, n int) string {
262 return md.H1("Recent changes") + changeList(c, w.Recent(n))
263}
264
265// RenderAllPages renders the page index for a namespace prefix.
266func RenderAllPages(c Ctx, w *Wiki, ns Namespace, offset, count int) string {
267 var out strings.Builder
268 out.WriteString(md.H1("All pages"))
269
270 tabs := []string{}
271 for i := range namespaces {
272 n := Namespace(i)
273 if n == NSSpecial {
274 continue
275 }
276 label := n.String()
277 if label == "" {
278 label = "Articles"
279 }
280 if n == ns {
281 label = "**" + label + "**"
282 }
283 tabs = append(tabs, md.Link(label, c.SpecialURL("AllPages", "ns="+ufmt.Sprintf("%d", i))))
284 }
285 out.WriteString(md.Paragraph(strings.Join(tabs, " · ")))
286
287 titles := w.Titles(ns.Prefix(), offset, count)
288 if len(titles) == 0 {
289 out.WriteString(md.Paragraph("_No pages in this namespace._"))
290 return out.String()
291 }
292 out.WriteString(titleList(c, titles))
293 if len(titles) == count {
294 out.WriteString(md.Paragraph(md.Link("next →",
295 c.SpecialURL("AllPages", ufmt.Sprintf("ns=%d&offset=%d", uint8(ns), offset+count)))))
296 }
297 return out.String()
298}
299
300// RenderCategory renders a category page: its own text, then its members.
301func RenderCategory(c Ctx, w *Wiki, t Title, p *Page, act Action) string {
302 var out strings.Builder
303 if p != nil {
304 out.WriteString(RenderArticle(c, w, p, act))
305 } else {
306 out.WriteString(md.H1(t.String()))
307 out.WriteString(md.Paragraph("_This category has no description page._"))
308 }
309 members := w.CategoryMembers(t)
310 out.WriteString(md.H2(ufmt.Sprintf("Pages in this category (%d)", len(members))))
311 if len(members) == 0 {
312 out.WriteString(md.Paragraph("_None._"))
313 return out.String()
314 }
315 out.WriteString(titleList(c, members))
316 return out.String()
317}
318
319// RenderCategories lists every category that has at least one member.
320func RenderCategories(c Ctx, w *Wiki) string {
321 cats := w.Categories()
322 var out strings.Builder
323 out.WriteString(md.H1("Categories"))
324 if len(cats) == 0 {
325 out.WriteString(md.Paragraph("_No categories yet._"))
326 return out.String()
327 }
328 items := []string{}
329 for _, t := range cats {
330 items = append(items, ufmt.Sprintf("%s (%d)", md.Link(t.Name, c.URL(t)), len(w.CategoryMembers(t))))
331 }
332 out.WriteString(md.BulletList(items))
333 return out.String()
334}
335
336// RenderBacklinks renders "what links here" for a title.
337func RenderBacklinks(c Ctx, w *Wiki, t Title) string {
338 var out strings.Builder
339 out.WriteString(md.H1("Pages that link to " + t.String()))
340 out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(t))))
341 in := w.Backlinks(t)
342 if len(in) == 0 {
343 out.WriteString(md.Paragraph("_Nothing links here._"))
344 return out.String()
345 }
346 out.WriteString(titleList(c, in))
347 return out.String()
348}
349
350// RenderStats renders the wiki's size and what it is paying the chain.
351func RenderStats(c Ctx, w *Wiki) string {
352 s := w.Stats()
353 rows := []string{
354 ufmt.Sprintf("pages: %d", s.Pages),
355 ufmt.Sprintf("revisions: %d", s.Revisions),
356 ufmt.Sprintf("comments: %d", s.Comments),
357 ufmt.Sprintf("body bytes on chain: %d", s.BytesHeld),
358 ufmt.Sprintf("storage deposit locked by bodies: %s", formatGNOT(s.BytesHeld*DepositPerByte)),
359 ufmt.Sprintf("bodies kept per page: %d", s.Retention),
360 }
361 return md.H1("Wiki stats") + md.BulletList(rows)
362}
363
364// RenderTalk renders a page's discussion: top-level messages oldest first,
365// each with its replies.
366func RenderTalk(c Ctx, w *Wiki, p *Page, offset, count int, act Action) string {
367 var out strings.Builder
368 out.WriteString(md.H1("Discussion: " + p.Title.String()))
369
370 nav := []string{md.Link("← back to the article", c.URL(p.Title))}
371 if act != nil {
372 nav = append(nav, md.Link("add a message", act("Comment", "title", p.Title.String(), "replyTo", "0")))
373 }
374 out.WriteString(md.Paragraph(strings.Join(nav, " · ")))
375
376 threads := w.Comments(p.Title, offset, count)
377 if len(threads) == 0 {
378 out.WriteString(md.Paragraph("_No messages yet._"))
379 return out.String()
380 }
381
382 items := []string{}
383 for _, th := range threads {
384 item := commentLine(c, th.Root, act, p.Title)
385 for _, r := range th.Replies {
386 item += "\n" + md.Nested(commentLine(c, r, nil, p.Title), " - ")
387 }
388 items = append(items, item)
389 }
390 out.WriteString(md.BulletList(items))
391
392 total := w.NumComments(p.Title)
393 if offset+len(threads) < total {
394 out.WriteString(md.Paragraph(md.Link("older →",
395 c.Sub(p.Title, "talk")+"?offset="+ufmt.Sprintf("%d", offset+count))))
396 }
397 return out.String()
398}
399
400func commentLine(c Ctx, cm *Comment, act Action, t Title) string {
401 head := ufmt.Sprintf("**#%d** · %s · %s",
402 cm.ID, cm.Time.Format("2006-01-02 15:04"), shortAddr(cm.Author))
403 if cm.Hidden {
404 return head + "\n\n_This message was removed by a steward._"
405 }
406 body := sanitize.Block(cm.Body)
407 if act != nil {
408 body += "\n\n" + md.Link("reply", act("Comment",
409 "title", t.String(), "replyTo", ufmt.Sprintf("%d", cm.ID)))
410 }
411 return head + body
412}
413
414func titleList(c Ctx, titles []Title) string {
415 items := []string{}
416 for _, t := range titles {
417 items = append(items, md.Link(t.String(), c.URL(t)))
418 }
419 return md.BulletList(items)
420}
421
422func changeList(c Ctx, changes []*Change) string {
423 if len(changes) == 0 {
424 return md.Paragraph("_Nothing has happened yet._")
425 }
426 items := []string{}
427 for _, ch := range changes {
428 line := ufmt.Sprintf("%s · %s · %s · %s · rev %d",
429 md.Link(ch.Title.String(), c.URL(ch.Title)),
430 string(ch.Rev.Kind),
431 ch.Rev.Time.Format("2006-01-02 15:04"),
432 shortAddr(ch.Rev.Author),
433 ch.Rev.ID)
434 if ch.Rev.Summary != "" {
435 line += " · " + sanitize.InlineText(ch.Rev.Summary)
436 }
437 items = append(items, line)
438 }
439 return md.BulletList(items)
440}
441
442// shortAddr abbreviates an address for display without losing its prefix.
443func shortAddr(a address) string {
444 s := a.String()
445 if len(s) <= 12 {
446 return "`" + s + "`"
447 }
448 return "`" + s[:10] + "…" + s[len(s)-4:] + "`"
449}
450
451// formatGNOT renders a ugnot amount as GNOT.
452//
453// The zero padding is written out by hand: gno's ufmt supports no width or
454// padding flags, so ufmt.Sprintf("%06d", 42) returns "42" silently and the
455// fractional part of every amount would be wrong by three orders of magnitude.
456func formatGNOT(ugnot int) string {
457 whole := ugnot / 1000000
458 frac := ugnot % 1000000
459 s := ufmt.Sprintf("%d", frac)
460 for len(s) < 6 {
461 s = "0" + s
462 }
463 s = strings.TrimRight(s, "0")
464 if s == "" {
465 return ufmt.Sprintf("%d GNOT", whole)
466 }
467 return ufmt.Sprintf("%d.%s GNOT", whole, s)
468}
469
470// plural renders "1 revision" and "2 revisions".
471func plural(n int, one, many string) string {
472 if n == 1 {
473 return ufmt.Sprintf("%d %s", n, one)
474 }
475 return ufmt.Sprintf("%d %s", n, many)
476}