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

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}