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

15.61 Kb · 476 lines
  1package wiki
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/nym-moultest123/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}