render.gno
42.84 Kb · 1133 lines
1package bolo
2
3import (
4 "math"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/moul/txlink/v0"
10 "gno.land/p/nt/markdown/sanitize/v0"
11)
12
13// Render (API §5): gnoweb's pages, each laid out for who reads it (UI spec §4.13). The
14// path is attacker-controlled: it is parsed into typed values, and anything that fails
15// gets one fixed page that echoes nothing from the request. User text is escaped by
16// who wrote it: strangers and moderators through inline, the organizer through
17// Blockquote; addresses go into code spans, where gnoweb makes no link of them. Render
18// writes nothing and reads balances only through views.
19
20const (
21 webPath = "/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0" // this realm on gnoweb
22 maxPathLen = 256 // longer than any valid path
23 listPage = 20 // entries a list page shows
24 updatesPage = 5 // updates a page shows: each can cost 0.18 billion gas to render
25 addressSection = 10 // entries each address-page section shows
26 barWide = 1000 // a progress bar's width across a page, scaled down to fit
27 barCard = 400 // and in a card, half a row
28 notFound = "# Not found\n\nThere is nothing here. [Bolo](" + webPath + ")\n"
29)
30
31// Render returns the markdown page for path (API §5).
32func Render(path string) string {
33 if len(path) > maxPathLen {
34 return notFound
35 }
36 route, rawQuery, _ := strings.Cut(path, "?")
37 q, ok := parseQuery(rawQuery)
38 if !ok {
39 return notFound
40 }
41 if out, ok := route2page(route, q); ok {
42 return out
43 }
44 return notFound
45}
46
47// route2page renders route, or reports false when route names nothing or a value in
48// q doesn't parse. Every prefix of a route renders a page, as gnoweb's header links
49// each one: "c" the home page, "c/{id}/r" the gifts, "u" the form to find an address.
50func route2page(route string, q map[string]string) (string, bool) {
51 switch route {
52 case "", "c":
53 return renderHome(q)
54 case "u":
55 return renderLookup(q), true
56 case "about":
57 return renderAbout(), true
58 case "params":
59 return renderParams(), true
60 }
61 segs := strings.Split(route, "/")
62 switch {
63 case segs[0] == "c" && len(segs) >= 2 && len(segs) <= 4:
64 c, ok := lookup(segs[1])
65 if !ok {
66 return "", false
67 }
68 switch {
69 case len(segs) == 2:
70 return renderCampaign(c), true
71 case len(segs) == 3 && (segs[2] == "donations" || segs[2] == "r"):
72 return pageOf(q, func(page int) string { return renderDonations(c, page) })
73 case len(segs) == 3 && segs[2] == "give":
74 return renderGive(c, q), true
75 case len(segs) == 3 && segs[2] == "ledger":
76 return pageOf(q, func(page int) string { return renderLedger(c, page) })
77 case len(segs) == 3 && segs[2] == "updates":
78 return pageOf(q, func(page int) string { return renderUpdates(c, page) })
79 case len(segs) == 4 && segs[2] == "r":
80 seq, ok := parseID(segs[3])
81 if !ok || c.receipts.Get(seq.Binary()) == nil {
82 return "", false
83 }
84 return renderReceipt(c, int64(seq)), true
85 }
86 case segs[0] == "u" && (len(segs) == 2 || len(segs) == 3):
87 addr, ok := parseAddress(segs[1])
88 if !ok {
89 return "", false
90 }
91 if len(segs) == 2 {
92 return renderAddress(addr), true
93 }
94 list, heading := addressList(segs[2])
95 if list == nil {
96 return "", false
97 }
98 after, ok := q["after"]
99 if ok {
100 if _, canonical := parseID(after); !canonical {
101 return "", false
102 }
103 }
104 return renderAddressList(addr, segs[2], heading, list, after), true
105 }
106 return "", false
107}
108
109// parseQuery splits a query into its key=value pairs: an empty pair is skipped, a key
110// given twice fails. Values are taken as they are: the routes parse them.
111func parseQuery(raw string) (map[string]string, bool) {
112 q := map[string]string{}
113 if raw == "" {
114 return q, true
115 }
116 for _, pair := range strings.Split(raw, "&") {
117 if pair == "" {
118 continue
119 }
120 k, v, _ := strings.Cut(pair, "=")
121 if _, seen := q[k]; seen {
122 return nil, false
123 }
124 q[k] = v
125 }
126 return q, true
127}
128
129// pageOf calls render with the page q asks for, 1 when it asks none, or fails when
130// its page isn't a canonical number of at least 1.
131func pageOf(q map[string]string, render func(page int) string) (string, bool) {
132 page := 1
133 if v, ok := q["page"]; ok {
134 n, ok := parsePage(v)
135 if !ok {
136 return "", false
137 }
138 page = n
139 }
140 return render(page), true
141}
142
143// parsePage parses a page number: canonical decimal — no sign, no leading zero — from
144// 1, at most nine digits.
145func parsePage(s string) (int, bool) {
146 if s == "" || len(s) > 9 || s[0] == '0' {
147 return 0, false
148 }
149 n := 0
150 for i := 0; i < len(s); i++ {
151 if s[i] < '0' || s[i] > '9' {
152 return 0, false
153 }
154 n = n*10 + int(s[i]-'0')
155 }
156 return n, true
157}
158
159// parseAddress checks an address from a path as C12 checks an argument: exactly 40
160// characters, a lowercase "g" first, then IsValid.
161func parseAddress(s string) (address, bool) {
162 if len(s) != 40 || s[0] != 'g' || !address(s).IsValid() {
163 return "", false
164 }
165 return address(s), true
166}
167
168// renderHome is the home page: the guarantees in one line, how it works, the links to
169// start a campaign and to find an address, the newest active campaigns in a category
170// or all, 20 at a time by cursor, and the categories.
171func renderHome(q map[string]string) (string, bool) {
172 after, ok := q["after"]
173 if ok {
174 if _, canonical := parseID(after); !canonical {
175 return "", false
176 }
177 }
178 cat, set := q["cat"]
179 if set && !categories.Has(cat) {
180 return "", false
181 }
182 var b strings.Builder
183 b.WriteString("# Bolo\n\nGive together. See where it goes.\n\n")
184 b.WriteString("**0% platform fee · every gift checkable · nothing in Bolo can freeze your gift** — [what Bolo guarantees, and what it doesn't](" + webPath + ":about)\n\n")
185 b.WriteString("How it works: give from your wallet → get a receipt → check where the money went.\n\n")
186 writeStart(&b)
187 if cat == "" {
188 b.WriteString("## Newest campaigns\n\n")
189 } else {
190 b.WriteString("## Newest campaigns in " + cat + "\n\n")
191 }
192 views, next := ListCampaigns(after, listPage, cat)
193 writeCards(&b, views)
194 switch {
195 case len(views) > 0:
196 case after == "" && next == "":
197 b.WriteString("No campaigns here yet.\n\n")
198 default:
199 b.WriteString("None on this page.\n\n")
200 }
201 if next != "" {
202 more := webPath + ":?after=" + next
203 if cat != "" {
204 more += "&cat=" + cat
205 }
206 b.WriteString("[More](" + more + ")\n\n")
207 }
208 b.WriteString("## Categories\n\n")
209 for i, code := range categoryOrder {
210 if i > 0 {
211 b.WriteString(" · ")
212 }
213 b.WriteString("[" + code + "](" + webPath + ":?cat=" + code + ")")
214 }
215 b.WriteString("\n")
216 return b.String(), true
217}
218
219// writeStart writes the link to start a campaign — CreateCampaign's form, the creation
220// deposit attached — while this version takes new campaigns, or why it doesn't; then
221// the link to find an address.
222func writeStart(b *strings.Builder) {
223 switch {
224 case sunset:
225 b.WriteString("This version of Bolo takes no new campaigns: [see where they start](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo)")
226 case paused:
227 b.WriteString("Bolo is paused: new campaigns and gifts wait until it resumes")
228 default:
229 link := txlink.Realm(pkgPath).NewLink("CreateCampaign").SetSend(strconv.FormatInt(params.CreationDeposit, 10) + "ugnot").URL()
230 b.WriteString("[Start a campaign](" + link + ") — a " + gnot(params.CreationDeposit) + " deposit, returned when it completes")
231 }
232 b.WriteString(" · [Find an address](" + webPath + ":u)\n\n")
233}
234
235// writeCards writes campaigns as cards, two to a row in gnoweb's columns (UI spec
236// §4.13): the title as a link, the progress bar, raised of the goal and donors, then
237// the mode, the category, the time left or how it ended, and a bond or a moderator's
238// notice when there is one. The one user text, the title, goes through inline, whose
239// InlineText escapes "<": nothing in it can open, separate or close a block. A
240// separator is written self-closing, the spelling every gnoweb reads as one.
241func writeCards(b *strings.Builder, views []CampaignView) {
242 for i, v := range views {
243 if i%2 == 0 {
244 b.WriteString("<gno-columns>\n")
245 } else {
246 b.WriteString("<gno-columns-sep />\n")
247 }
248 b.WriteString("### [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + ")\n\n")
249 if bar := progressBar(v.NetRaised, v.Goal, barCard); bar != "" {
250 b.WriteString(bar + "\n\n")
251 }
252 b.WriteString("**" + gnot(v.NetRaised) + "**")
253 if v.Goal > 0 {
254 b.WriteString(" of " + gnot(v.Goal))
255 } else {
256 b.WriteString(" raised")
257 }
258 b.WriteString(" · " + count(v.DonorCount, "donor", "donors") + " \n")
259 b.WriteString(modeWord(v.Mode) + " · " + v.Category + " · " + timeWord(v))
260 if v.Bond > 0 {
261 if v.BondSlashed {
262 b.WriteString(" · bond slashed")
263 } else {
264 b.WriteString(" · bond " + gnot(v.Bond))
265 }
266 }
267 if v.Notice != "" {
268 b.WriteString(" · **moderator's notice**")
269 }
270 b.WriteString("\n")
271 if i%2 == 1 || i == len(views)-1 {
272 if i%2 == 0 { // a row's last card alone: an empty column keeps it half wide
273 b.WriteString("<gno-columns-sep />\n")
274 }
275 b.WriteString("</gno-columns>\n\n")
276 }
277 }
278}
279
280// timeWord says where a campaign stands in time, on a card: the time left, no
281// deadline, or how it ended.
282func timeWord(v CampaignView) string {
283 switch {
284 case v.Status == statusSettled:
285 return "completed"
286 case v.Status != statusOpen:
287 return "closed to new donations"
288 case v.Deadline != 0:
289 return left(v.Deadline)
290 }
291 return "no deadline"
292}
293
294// renderCampaign is a campaign's page (API §5, UI spec §4.13), Bolo's own blocks
295// before the organizer's words: the notice, the photo, the title and byline, the
296// figures, the mode, the state banner, the donate links, where the money is, the
297// verification block; then the story as a quote; then the recent gifts, the latest
298// update, and last the actions of the organizer, the beneficiary and anyone.
299func renderCampaign(c *campaign) string {
300 v := campaignView(c)
301 space := room(v)
302 anyone, reason := CanClose(v.ID, "")
303 var b strings.Builder
304 if v.Notice != "" {
305 b.WriteString("> [!WARNING] Notice from Bolo's moderator\n> " + lineStart(v.Notice) + "\n\n")
306 }
307 if v.ImageCID != "" && !v.ImageHidden {
308 b.WriteString("\n\n")
309 }
310 b.WriteString("# " + lineStart(v.Title) + "\n\n")
311 b.WriteString(v.Category + " · by " + addrLink(v.Organizer) + " · for " + addrLink(v.Beneficiary))
312 if !v.Listed {
313 b.WriteString(" · **hidden from listings**")
314 }
315 b.WriteString("\n\n")
316 writeFigures(&b, v)
317 writeMode(&b, v)
318 writeBanner(&b, v, space, anyone && reason == reasonDormant)
319 writeGive(&b, v, space)
320 writeMoney(&b, v)
321 writeVerify(&b, v)
322 if v.Description != "" {
323 b.WriteString("---\n\n## In the organizer's words\n")
324 b.WriteString(sanitize.Blockquote(v.Description))
325 b.WriteString("---\n\n")
326 }
327 b.WriteString("## Recent gifts\n\n")
328 recent := ListRecent(v.ID)
329 for _, r := range recent {
330 writeGift(&b, r)
331 }
332 if len(recent) == 0 {
333 b.WriteString("No gifts yet.\n")
334 }
335 b.WriteString("\n[All gifts](" + webPath + ":c/" + v.ID + "/donations)")
336 if v.Mode == modeEscrow {
337 b.WriteString(" · [Money out](" + webPath + ":c/" + v.ID + "/ledger)")
338 }
339 b.WriteString("\n\n")
340 if v.UpdateCount > 0 {
341 b.WriteString("## Latest update\n\n")
342 u := ListUpdates(v.ID, 1, 1)[0]
343 b.WriteString("**" + when(u.At) + "**\n")
344 b.WriteString(sanitize.Blockquote(u.Body))
345 b.WriteString("[All updates](" + webPath + ":c/" + v.ID + "/updates)\n\n")
346 }
347 writeManage(&b, v, anyone)
348 return b.String()
349}
350
351// writeFigures writes the progress bar, then three figures, a short line each, which
352// stay short on a phone: raised, of the goal; donors and gifts; the time left, or how
353// it ended.
354func writeFigures(b *strings.Builder, v CampaignView) {
355 if bar := progressBar(v.NetRaised, v.Goal, barWide); bar != "" {
356 b.WriteString(bar + "\n\n")
357 }
358 b.WriteString("**" + gnot(v.NetRaised) + "** raised")
359 if v.Goal > 0 {
360 b.WriteString(" · " + percent(v.NetRaised, v.Goal) + " of a " + gnot(v.Goal) + " goal")
361 }
362 b.WriteString(" \n**" + count(v.DonorCount, "donor", "donors") + "** · " + count(v.DonationCount, "gift", "gifts"))
363 if v.RefundCount > 0 {
364 b.WriteString(", " + count(v.RefundCount, "refund", "refunds"))
365 }
366 b.WriteString(" \n")
367 switch {
368 case v.Status == statusSettled:
369 b.WriteString("**Completed** · closed on " + day(v.ClosedAt))
370 case v.Status != statusOpen:
371 b.WriteString("**Closed to new donations** · on " + day(v.ClosedAt))
372 case v.Deadline != 0:
373 b.WriteString("**" + left(v.Deadline) + "** · until " + day(v.Deadline))
374 default:
375 b.WriteString("**No deadline** · open until it is closed")
376 }
377 b.WriteString("\n\n")
378}
379
380// writeMode writes the custody mode in words and what it means now (UI spec §4.3,
381// ModeChip), with the refund link while a refund window is open to some donor — as
382// far as the books can tell: after every donor's exit claim, rounding can leave a few
383// ugnot that keep the window open in the books (API §2.4, §5).
384func writeMode(b *strings.Builder, v CampaignView) {
385 if v.Mode != modeEscrow {
386 b.WriteString("**Direct**: each gift goes straight to the beneficiary, in the same transaction. Gifts can't be refunded.\n\n")
387 return
388 }
389 b.WriteString("**Escrow**: ")
390 switch {
391 case v.Status != statusOpen && v.Balance == 0:
392 b.WriteString("no gift is held any more.")
393 case v.Status != statusOpen && now() >= v.ExitOpensAt: // the beneficiary's window has passed
394 b.WriteString("the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time.")
395 case v.EverWithdrawn:
396 b.WriteString("the beneficiary has withdrawn funds, so gifts can't be refunded now.")
397 default:
398 b.WriteString("the gifts stay in escrow until the beneficiary withdraws them. Until then, a donor can take their gift back.")
399 }
400 if v.ExitOpen || !v.EverWithdrawn && v.Balance > 0 {
401 b.WriteString(" [Refund](" + txlink.Realm(pkgPath).NewLink("Refund").AddArgs("id", v.ID).URL() + ")")
402 }
403 b.WriteString("\n\n")
404}
405
406// writeBanner writes the one state banner the UI spec's state matrix gives the
407// campaign now (§6), as gnoweb's alert box, which no user text can open (API §5): how
408// it was closed and what remains to happen, that it completed, that Bolo is paused,
409// that the escrow cap leaves no room for a gift, or that anyone may close it as it is
410// dormant. space is the room the cap leaves.
411func writeBanner(b *strings.Builder, v CampaignView, space int64, dormant bool) {
412 kind, title, text := "NOTE", "", ""
413 switch {
414 case v.Status == statusSettled:
415 kind, title = "SUCCESS", "Completed"
416 text = "Closed to new donations on " + when(v.ClosedAt) + ", then settled: the organizer's deposit went back to them."
417 case v.Status != statusOpen:
418 title, text = "Closed to new donations", closedText(v)
419 case paused:
420 kind, title, text = "CAUTION", "Bolo is paused", "New gifts wait until it resumes. Withdrawals and refunds still work."
421 case space < minDonation:
422 title, text = "Full for now", "This campaign can't take more until the beneficiary withdraws."
423 if escrowCap < minDonation { // no withdrawal makes room
424 text = "Bolo's escrow cap is set below its smallest gift, so no campaign in escrow can take a gift for now."
425 }
426 case dormant:
427 title, text = "Inactive", "No gift and no update for "+days(v.Params.DormancyPeriod)+": anyone can close it now."
428 default:
429 return
430 }
431 b.WriteString("> [!" + kind + "] " + title + "\n> " + text + "\n\n")
432}
433
434// closedText says how a campaign was closed and, in escrow, what remains: the
435// beneficiary's countdown to the exit window, or the window open to donors.
436func closedText(v CampaignView) string {
437 var out string
438 switch v.CloseReason {
439 case reasonDeadline:
440 out = "It ended at its deadline, on " + when(v.ClosedAt) + "."
441 case reasonDormant:
442 out = "Closed on " + when(v.ClosedAt) + ", after " + days(v.Params.DormancyPeriod) + " with no gift and no update."
443 default:
444 out = "Closed by its organizer on " + when(v.ClosedAt) + "."
445 }
446 if v.Mode != modeEscrow || v.Balance == 0 {
447 return out
448 }
449 if now() < v.ExitOpensAt {
450 return out + " The beneficiary can withdraw the " + gnot(v.Balance) + " held until " + when(v.ExitOpensAt) + ", " + left(v.ExitOpensAt) + "; after that, donors can reclaim their share."
451 }
452 return out + " The beneficiary didn't collect in time: a donor who hasn't reclaimed their share yet can, at any time."
453}
454
455// room is how much more campaign v can take: what the escrow cap leaves, in escrow
456// with a cap (checkRoom); otherwise as much as an amount can be.
457func room(v CampaignView) int64 {
458 if v.Mode != modeEscrow || escrowCap == 0 {
459 return math.MaxInt64
460 }
461 return escrowCap - v.Balance
462}
463
464// writeGive writes the one-click donate links (API §5): while the campaign is open,
465// listed and Bolo isn't paused, one per preset Donate would take — at least the
466// smallest gift, and no more than the room the escrow cap leaves, space — then the
467// page for another amount, with the room said when it left a preset out.
468func writeGive(b *strings.Builder, v CampaignView, space int64) {
469 if v.Status != statusOpen || !v.Listed || paused || space < minDonation {
470 return
471 }
472 var links []string
473 capped := false
474 for _, preset := range presets {
475 if preset > space {
476 capped = true
477 continue
478 }
479 if preset < minDonation {
480 continue
481 }
482 links = append(links, giveLink(v.ID, preset))
483 }
484 links = append(links, "[Another amount]("+webPath+":c/"+v.ID+"/give)")
485 b.WriteString("## Give\n\n" + strings.Join(links, " · ") + "\n\n")
486 if capped {
487 b.WriteString("It can take " + gnot(space) + " more for now: the beneficiary's next withdrawal makes room.\n\n")
488 }
489}
490
491// giveLink is a one-click link that gives ugnot to campaign id, with no tip and no
492// message: gnoweb's form attaches the coins once the donor confirms them.
493func giveLink(id string, ugnot int64) string {
494 link := txlink.Realm(pkgPath).NewLink("Donate").
495 AddArgs("id", id, "tip", "0", "message", "", "anonymous", "false").
496 SetSend(strconv.FormatInt(ugnot, 10) + "ugnot").URL()
497 return "[Give " + gnot(ugnot) + "](" + link + ")"
498}
499
500// renderGive is the page for another amount (API §5). gnoweb's function form attaches
501// only the coins a link carries, so this page asks the amount in gnoweb's own form,
502// which submits back as ?amount=, and answers an amount Donate would take with its
503// one-click link. Any other amount gets the form again with what the campaign can
504// take, echoing nothing; a campaign Bolo offers no gift link for says so.
505func renderGive(c *campaign, q map[string]string) string {
506 v := campaignView(c)
507 space := room(v)
508 back := "[Back to the campaign](" + webPath + ":c/" + v.ID + ")\n"
509 var b strings.Builder
510 b.WriteString("# Give to [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + ")\n\n")
511 if v.Status != statusOpen || !v.Listed || paused || space < minDonation {
512 b.WriteString("Bolo offers no gift link for this campaign now: its page says why.\n\n" + back)
513 return b.String()
514 }
515 raw, asked := q["amount"]
516 if asked {
517 if ugnot, ok := parseGNOT(raw); ok && ugnot >= minDonation && ugnot <= space {
518 b.WriteString("**" + giveLink(v.ID, ugnot) + "**\n\n")
519 b.WriteString("gnoweb's form asks you to confirm the coins before it adds them.\n\n" + back)
520 return b.String()
521 }
522 b.WriteString("That isn't an amount this campaign can take.\n\n")
523 }
524 b.WriteString("Any amount from " + gnot(minDonation))
525 if space < math.MaxInt64 {
526 b.WriteString(" to " + gnot(space))
527 }
528 b.WriteString(", in GNOT.\n\n")
529 b.WriteString("<gno-form path=\"c/" + v.ID + "/give\">\n <gno-input name=\"amount\" placeholder=\"7.5\" />\n</gno-form>\n\n" + back)
530 return b.String()
531}
532
533// parseGNOT parses an amount in GNOT into ugnot (API §5): digits without a sign or a
534// leading zero, at most 12 of them, then optionally a decimal point — "." or, as a
535// form sends a comma, "%2C" — and one to six digits; but not a comma and exactly
536// three, which may be a thousands separator. The spaces a form sends around a value,
537// as "+", are dropped first.
538func parseGNOT(s string) (int64, bool) {
539 s = strings.Trim(s, "+")
540 whole, frac, point := strings.Cut(s, ".")
541 if !point {
542 whole, frac, point = strings.Cut(s, "%2C")
543 if point && len(frac) == 3 { // "1,000": a thousand, or one?
544 return 0, false
545 }
546 }
547 if whole == "" || len(whole) > 12 || len(whole) > 1 && whole[0] == '0' || point && (frac == "" || len(frac) > 6) {
548 return 0, false
549 }
550 var n int64
551 for i := 0; i < len(whole); i++ {
552 if whole[i] < '0' || whole[i] > '9' {
553 return 0, false
554 }
555 n = n*10 + int64(whole[i]-'0')
556 }
557 n *= 1_000_000
558 unit := int64(100_000)
559 for i := 0; i < len(frac); i++ {
560 if frac[i] < '0' || frac[i] > '9' {
561 return 0, false
562 }
563 n += int64(frac[i]-'0') * unit
564 unit /= 10
565 }
566 return n, true
567}
568
569// writeMoney writes where the money is, a table of amounts: given, then, in escrow,
570// refunded, raised net of refunds, withdrawn and held; in direct mode, sent straight
571// to the beneficiary.
572func writeMoney(b *strings.Builder, v CampaignView) {
573 b.WriteString("## Where the money is\n\n| | GNOT |\n|---|--:|\n")
574 b.WriteString("| Given: " + count(v.DonationCount, "gift", "gifts") + " from " + count(v.DonorCount, "donor", "donors") + " | " + amount(v.Raised) + " |\n")
575 if v.Mode != modeEscrow {
576 b.WriteString("| Sent straight to the beneficiary | " + amount(v.Raised) + " |\n\n")
577 return
578 }
579 b.WriteString("| Refunded to donors | " + amount(v.Refunded) + " |\n")
580 b.WriteString("| **Raised**, net of refunds | **" + amount(v.NetRaised) + "** |\n")
581 b.WriteString("| Withdrawn by the beneficiary | " + amount(v.Withdrawn) + " |\n")
582 b.WriteString("| Held in escrow | " + amount(v.Balance) + " |\n\n")
583}
584
585// writeVerify writes the verification block: where to check every figure on the chain
586// itself (UI spec §2, level 3), each address in full.
587func writeVerify(b *strings.Builder, v CampaignView) {
588 b.WriteString("## Check it yourself\n\n")
589 if v.Mode == modeEscrow {
590 b.WriteString("- **Escrow** `" + v.EscrowAddress.String() + "`: its bank balance is the " + gnot(v.Balance) + " held, plus " + gnot(v.Untracked) + " sent there outside Bolo\n")
591 }
592 b.WriteString("- **Bond address** `" + v.BondAddress.String() + "`: the organizer's " + gnot(v.Deposit) + " deposit")
593 if v.Status == statusSettled {
594 b.WriteString(", returned when the campaign completed")
595 } else {
596 b.WriteString(", returned when the campaign completes")
597 }
598 switch {
599 case v.Bond == 0:
600 case v.BondSlashed:
601 b.WriteString("; a " + gnot(v.Bond) + " bond, slashed")
602 case v.Status != statusOpen && now() >= v.BondReleasableAt:
603 b.WriteString("; a " + gnot(v.Bond) + " bond the organizer staked, whose hold ended " + when(v.BondReleasableAt) + ": no longer at stake, it goes back to the organizer")
604 default:
605 b.WriteString("; a " + gnot(v.Bond) + " bond the organizer staked, lost if the campaign is delisted for fraud — not a guarantee")
606 }
607 b.WriteString("\n- **Organizer** `" + v.Organizer.String() + "`\n- **Beneficiary** `" + v.Beneficiary.String() + "`\n\n")
608 b.WriteString("Created " + when(v.CreatedAt))
609 if v.Deadline != 0 {
610 b.WriteString(" · deadline " + when(v.Deadline))
611 }
612 if v.ClosedAt != 0 {
613 b.WriteString(" · closed " + when(v.ClosedAt))
614 }
615 b.WriteString("\n\n")
616}
617
618// writeManage writes the actions of the organizer, the beneficiary and anyone, each
619// with who may take it, when that call could succeed for someone (API §5): last, as
620// the web app puts maintenance last (UI spec §4.3). anyone is whether CanClose lets
621// anyone close it.
622func writeManage(b *strings.Builder, v CampaignView, anyone bool) {
623 id := v.ID
624 var links []string
625 if v.Mode == modeEscrow && v.Balance+v.Untracked > 0 && (v.Status == statusOpen || v.Status == statusClosed && now() < v.ExitOpensAt) {
626 links = append(links, "[Withdraw]("+txlink.Realm(pkgPath).NewLink("Withdraw").AddArgs("id", id).URL()+"): the beneficiary")
627 }
628 if anyone || v.Status == statusOpen {
629 who := "the organizer"
630 if anyone {
631 who = "anyone"
632 }
633 links = append(links, "[Close]("+txlink.Realm(pkgPath).NewLink("Close").AddArgs("id", id).URL()+"): "+who)
634 }
635 if ok, _ := CanFinalize(id); ok {
636 links = append(links, "[Finalize]("+txlink.Realm(pkgPath).NewLink("Finalize").AddArgs("id", id).URL()+"): anyone")
637 }
638 links = append(links, "[Post an update]("+txlink.Realm(pkgPath).NewLink("PostUpdate").AddArgs("id", id).URL()+"): the organizer")
639 b.WriteString("## Manage this campaign\n\n- " + strings.Join(links, "\n- ") + "\n")
640}
641
642// writeGift writes one gift in two lines: its amount, and its message unless
643// moderation hid it; then who gave it — a short address linking to its page, or
644// anonymous — when, how much its donor was refunded since (INV-12), and its receipt.
645func writeGift(b *strings.Builder, r ReceiptView) {
646 b.WriteString("- **" + gnot(r.Amount) + "**")
647 if r.MessageHidden {
648 b.WriteString(" · message hidden by moderation")
649 } else if r.Message != "" {
650 b.WriteString(" · “" + inline(r.Message) + "”")
651 }
652 b.WriteString(" \n ")
653 if r.Anonymous {
654 b.WriteString("Anonymous")
655 } else {
656 b.WriteString("from " + addrLink(r.Donor))
657 }
658 b.WriteString(" · " + when(r.At))
659 if r.DonorRefunded > 0 {
660 b.WriteString(" · **" + gnot(r.DonorRefunded) + " refunded since**")
661 }
662 b.WriteString(" · [receipt](" + webPath + ":c/" + r.CampaignID + "/r/" + strconv.FormatInt(r.Seq, 10) + ")\n")
663}
664
665// renderReceipt is a gift's receipt: the shareable object (PRD §5). When its donor was
666// refunded since, the refund banner comes first, so a refunded receipt never looks like
667// a live gift (UI spec §4.5, INV-12).
668func renderReceipt(c *campaign, seq int64) string {
669 r := GetReceipt(idString(c.id), seq)
670 v := campaignView(c)
671 var b strings.Builder
672 if r.DonorRefunded > 0 {
673 b.WriteString("> [!CAUTION] Refunded since\n> " + gnot(r.DonorRefunded) + " went back to this donor after this gift.\n\n")
674 }
675 b.WriteString("# Gift receipt\n\n")
676 b.WriteString("**" + gnot(r.Amount) + "** to [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + "), on " + when(r.At) + ".\n\n")
677 b.WriteString("| Receipt | " + r.ID + " |\n|---|---|\n")
678 if r.Anonymous {
679 b.WriteString("| From | Anonymous |\n")
680 } else {
681 b.WriteString("| From | `" + r.Donor.String() + "` |\n")
682 }
683 if r.Tip > 0 {
684 b.WriteString("| Tip to Bolo | " + gnot(r.Tip) + " |\n")
685 }
686 b.WriteString("| The campaign's total right after it | " + gnot(r.RaisedAfter) + " |\n\n")
687 if r.MessageHidden {
688 b.WriteString("Message: hidden by moderation.\n\n")
689 } else if r.Message != "" {
690 b.WriteString("Message: “" + inline(r.Message) + "”\n\n")
691 }
692 b.WriteString("## Where it went\n\n")
693 if bar := progressBar(v.NetRaised, v.Goal, barWide); bar != "" {
694 b.WriteString(bar + "\n\n")
695 }
696 b.WriteString("The campaign is " + strings.ToLower(statusWord(v.Status)) + ": " + gnot(v.NetRaised) + " raised net of refunds")
697 if v.Goal > 0 {
698 b.WriteString(", " + percent(v.NetRaised, v.Goal) + " of its " + gnot(v.Goal) + " goal")
699 }
700 b.WriteString(".\n\n")
701 if v.Mode == modeEscrow {
702 b.WriteString("- " + gnot(v.Withdrawn) + " withdrawn by the beneficiary, `" + v.Beneficiary.String() + "`\n")
703 b.WriteString("- " + gnot(v.Balance) + " held in escrow, at `" + v.EscrowAddress.String() + "`\n\n")
704 } else {
705 b.WriteString("Every gift went straight to the beneficiary, `" + v.Beneficiary.String() + "`, in the same transaction.\n\n")
706 }
707 b.WriteString("[The campaign](" + webPath + ":c/" + v.ID + ") · [All gifts](" + webPath + ":c/" + v.ID + "/donations)")
708 if v.Mode == modeEscrow {
709 b.WriteString(" · [Money out](" + webPath + ":c/" + v.ID + "/ledger)")
710 }
711 b.WriteString("\n")
712 return b.String()
713}
714
715// renderDonations is a page of a campaign's gifts, newest first, under one line of
716// totals.
717func renderDonations(c *campaign, page int) string {
718 id := idString(c.id)
719 s := c.state
720 var b strings.Builder
721 b.WriteString("# Gifts to [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
722 b.WriteString(count(s.donationCount, "gift", "gifts") + " from " + count(s.donorCount, "donor", "donors") + " · " + gnot(s.raised) + " given")
723 if s.refunded > 0 {
724 b.WriteString(" · " + gnot(s.refunded) + " refunded")
725 }
726 b.WriteString("\n\n")
727 gifts := ListReceipts(id, page, listPage)
728 for _, r := range gifts {
729 writeGift(&b, r)
730 }
731 if len(gifts) == 0 {
732 b.WriteString(empty(page, "No gifts yet.\n", "No gifts on this page.\n"))
733 }
734 writePager(&b, webPath+":c/"+id+"/donations", page, listPage, c.receipts.Size())
735 return b.String()
736}
737
738// renderLedger is a page of a campaign's money out of its escrow: withdrawals, then
739// refunds, each newest first. In direct mode, it says that nothing goes out: Withdraw
740// and Refund refuse a direct campaign, so neither list ever holds one.
741func renderLedger(c *campaign, page int) string {
742 id := idString(c.id)
743 var b strings.Builder
744 b.WriteString("# Money out of [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
745 if c.mode != modeEscrow {
746 b.WriteString("**Direct**: each gift went straight to the beneficiary, `" + c.beneficiary.String() + "`, when it was given. Nothing is held, so nothing goes out.\n\n")
747 b.WriteString("[All gifts](" + webPath + ":c/" + id + "/donations)\n")
748 return b.String()
749 }
750 b.WriteString("Every coin that left the escrow, `" + escrowAddr(c.id).String() + "`: to the beneficiary, or back to donors.\n\n")
751 b.WriteString("## Withdrawals\n\n")
752 if ws := ListWithdrawals(id, page, listPage); len(ws) > 0 {
753 b.WriteString("| When (UTC) | GNOT | Outside Bolo | To |\n|---|--:|--:|---|\n")
754 for _, w := range ws {
755 b.WriteString("| " + stamp(w.At) + " | " + amount(w.Amount) + " | " + amount(w.Untracked) + " | `" + w.To.String() + "` |\n")
756 }
757 b.WriteString("\nOutside Bolo: coins sent to the escrow directly, which the withdrawal carried too.\n")
758 } else {
759 b.WriteString(empty(page, "None.\n", "None on this page.\n"))
760 }
761 b.WriteString("\n## Refunds\n\n")
762 if rs := ListRefunds(id, page, listPage); len(rs) > 0 {
763 b.WriteString("| When (UTC) | GNOT | How | To |\n|---|--:|---|---|\n")
764 for _, r := range rs {
765 to := "Anonymous"
766 if r.Donor != "" {
767 to = "`" + r.Donor.String() + "`"
768 }
769 how := "refund"
770 if r.Kind == windowExit {
771 how = "exit claim"
772 }
773 b.WriteString("| " + stamp(r.At) + " | " + amount(r.Amount) + " | " + how + " | " + to + " |\n")
774 }
775 } else {
776 b.WriteString(empty(page, "None.\n", "None on this page.\n"))
777 }
778 n := c.withdrawals.Size()
779 if m := c.refunds.Size(); m > n {
780 n = m
781 }
782 writePager(&b, webPath+":c/"+id+"/ledger", page, listPage, n)
783 return b.String()
784}
785
786// renderUpdates is a page of a campaign's updates, newest first, each as a quote.
787func renderUpdates(c *campaign, page int) string {
788 id := idString(c.id)
789 var b strings.Builder
790 b.WriteString("# Updates on [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
791 updates := ListUpdates(id, page, updatesPage)
792 for _, u := range updates {
793 b.WriteString("## " + when(u.At) + "\n")
794 b.WriteString(sanitize.Blockquote(u.Body))
795 }
796 if len(updates) == 0 {
797 b.WriteString(empty(page, "No updates yet.\n", "No updates on this page.\n"))
798 }
799 writePager(&b, webPath+":c/"+id+"/updates", page, updatesPage, c.updates.Size())
800 return b.String()
801}
802
803// empty says a page of a campaign's list has nothing on it: first on the list's first
804// page, where it means the list is empty, later past it (API §5).
805func empty(page int, first, later string) string {
806 if page == 1 {
807 return first
808 }
809 return later
810}
811
812// writePager writes links to the pages before and after page, of n entries, size a
813// page.
814func writePager(b *strings.Builder, base string, page, size, n int) {
815 var links []string
816 if page > 1 {
817 links = append(links, "[Newer]("+base+"?page="+strconv.Itoa(page-1)+")")
818 }
819 if page*size < n {
820 links = append(links, "[Older]("+base+"?page="+strconv.Itoa(page+1)+")")
821 }
822 if len(links) > 0 {
823 b.WriteString("\n" + strings.Join(links, " · ") + "\n")
824 }
825}
826
827// addressList returns the address page's list a path segment names, and its heading;
828// nil for any other segment. They show listed campaigns only, as they promote (API §5).
829func addressList(name string) (func(address, string, int) ([]CampaignView, string), string) {
830 switch name {
831 case "organized":
832 return ListByOrganizer, "Organized"
833 case "named":
834 return ListByBeneficiary, "Named as beneficiary by an organizer"
835 case "gave":
836 return ListByDonor, "Gave to, without anonymity"
837 }
838 return nil, ""
839}
840
841// renderAddress is the public address page: the short address as its title and the
842// full one under it, then the 10 newest campaigns of each list, as cards, with "More"
843// while a list goes on.
844func renderAddress(addr address) string {
845 var b strings.Builder
846 b.WriteString("# `" + short(addr) + "` on Bolo\n\n")
847 b.WriteString("`" + addr.String() + "`: the campaigns this address organized, is named beneficiary of, or gave to without anonymity. Campaigns hidden from listings and anonymous gifts aren't shown.\n\n")
848 for _, name := range []string{"organized", "named", "gave"} {
849 list, heading := addressList(name)
850 b.WriteString("## " + heading + "\n\n")
851 views, next := list(addr, "", addressSection)
852 writeListed(&b, views, next != "")
853 if next != "" {
854 b.WriteString("[More](" + webPath + ":u/" + addr.String() + "/" + name + "?after=" + next + ")\n\n")
855 }
856 }
857 return b.String()
858}
859
860// renderAddressList is one of the address page's lists, 20 at a time by cursor.
861func renderAddressList(addr address, name, heading string, list func(address, string, int) ([]CampaignView, string), after string) string {
862 var b strings.Builder
863 b.WriteString("# `" + short(addr) + "`: " + strings.ToLower(heading[:1]) + heading[1:] + "\n\n")
864 b.WriteString("[All of `" + addr.String() + "` on Bolo](" + webPath + ":u/" + addr.String() + ")\n\n")
865 views, next := list(addr, after, listPage)
866 writeListed(&b, views, after != "" || next != "")
867 if next != "" {
868 b.WriteString("[More](" + webPath + ":u/" + addr.String() + "/" + name + "?after=" + next + ")\n")
869 }
870 return b.String()
871}
872
873// writeListed writes the cards of the listed campaigns among views: a delisted one is
874// left out of a list that promotes. With none to show, it says so, and whether this is
875// only one page of the list: a later one, or one a "More" follows (paged).
876func writeListed(b *strings.Builder, views []CampaignView, paged bool) {
877 var listed []CampaignView
878 for _, v := range views {
879 if v.Listed {
880 listed = append(listed, v)
881 }
882 }
883 switch {
884 case len(listed) > 0:
885 writeCards(b, listed)
886 case paged:
887 b.WriteString("None on this page.\n\n")
888 default:
889 b.WriteString("None.\n\n")
890 }
891}
892
893// renderLookup is the page that finds an address: gnoweb's form, which submits back
894// here as ?address=. A valid address gets its page; anything else, the form again with
895// one fixed line, echoing nothing from the request (API §5).
896func renderLookup(q map[string]string) string {
897 raw, asked := q["address"]
898 if asked {
899 if addr, ok := parseAddress(raw); ok {
900 return renderAddress(addr)
901 }
902 }
903 var b strings.Builder
904 b.WriteString("# Find an address\n\n")
905 if asked {
906 b.WriteString("That isn't a gno.land address.\n\n")
907 }
908 b.WriteString("See what an address has done on Bolo: the campaigns it organized, is named beneficiary of, or gave to without anonymity.\n\n")
909 b.WriteString("<gno-form path=\"u\">\n <gno-input name=\"address\" placeholder=\"g1…\" />\n</gno-form>\n")
910 return b.String()
911}
912
913// renderAbout is the guarantees table, what Bolo costs, where tips go, how to check it,
914// and what not to do (PRD §3, UI spec §4.11).
915func renderAbout() string {
916 return "# What Bolo guarantees, and what it doesn't\n\n" +
917 "| A donor can verify themselves | A donor must still take on faith |\n|---|---|\n" +
918 "| The exact amount the campaign has received | That the campaign's story is true |\n" +
919 "| Every payout: amount, destination, timestamp | That the beneficiary spends it as promised |\n" +
920 "| That no fee was taken | That the person in the photo consented to it |\n" +
921 "| That the organizer cannot quietly change the beneficiary | That the organizer is who they claim to be |\n" +
922 "| That their own donation arrived and is counted | |\n" +
923 "| That nothing in Bolo, and nobody running it, can freeze or seize it | |\n\n" +
924 "The last row is scoped to Bolo: the gno.land chain's own governance sits above every application on it.\n\n" +
925 "**A chain fixes custody fraud. It does not fix claim fraud.** \"Verified on-chain\" never means \"verified true\".\n\n" +
926 "## Fees\n\n0% platform fee. Your wallet pays a small network fee. Tips to Bolo are optional.\n\n" +
927 "## Where tips go\n\nTo `" + tipRecipient.String() + "`, in the same transaction as your gift: Bolo keeps no treasury.\n\n" +
928 "## How to check\n\nEvery campaign page lists its escrow and bond addresses: read their balances on any explorer, and every transaction behind each figure. The code doing all of this is public, at `" + pkgPath + "`, and nobody can change it.\n\n" +
929 "## Don't send coins to Bolo's addresses directly\n\nGive through a campaign's page. Coins sent straight to this version's own address can be swept to the tip recipient; coins sent to the version registry can't be recovered.\n\n" +
930 "[Bolo](" + webPath + ")\n"
931}
932
933// renderParams shows the parameters new campaigns get, the live settings in words,
934// and the admin.
935func renderParams() string {
936 s := GetSettings()
937 p := params
938 a, pending := GetAdmin()
939 var b strings.Builder
940 b.WriteString("# Parameters\n\n## For new campaigns\n\n")
941 b.WriteString("- Creation deposit: " + gnot(p.CreationDeposit) + "\n")
942 b.WriteString("- Dormancy: " + days(p.DormancyPeriod) + "; exit delay: " + days(p.ExitDelay) + "; bond hold: " + days(p.BondHold) + "\n\n")
943 b.WriteString("## Settings\n\n")
944 if s.Paused {
945 b.WriteString("- Gifts: paused — withdrawals and refunds still work\n")
946 } else {
947 b.WriteString("- Gifts: accepted\n")
948 }
949 switch {
950 case s.Sunset:
951 b.WriteString("- New campaigns: not on this version\n")
952 case s.Paused:
953 b.WriteString("- New campaigns: paused\n")
954 default:
955 b.WriteString("- New campaigns: accepted\n")
956 }
957 b.WriteString("- Smallest gift: " + gnot(s.MinDonation) + "\n")
958 b.WriteString("- One-click amounts: ")
959 for i, preset := range s.Presets {
960 if i > 0 {
961 b.WriteString(" · ")
962 }
963 b.WriteString(gnot(preset))
964 }
965 b.WriteString("\n- Escrow cap: ")
966 if s.MaxEscrowBalance == 0 {
967 b.WriteString("none")
968 } else {
969 b.WriteString(gnot(s.MaxEscrowBalance))
970 }
971 b.WriteString("\n- Tips go to: `" + s.TipRecipient.String() + "`\n")
972 b.WriteString("- Categories: " + strings.Join(s.Categories, ", ") + "\n\n")
973 b.WriteString("## Admin\n\n`" + a.String() + "`")
974 if pending != "" {
975 b.WriteString(", handing over to `" + pending.String() + "`")
976 }
977 b.WriteString("\n")
978 return b.String()
979}
980
981// progressBar draws how much of its goal a campaign raised (UI spec §5, ProgressBar):
982// an SVG image of two numbers, which gnoweb accepts inline as a data URI, width wide.
983// It fills min(net, goal) of goal; its text, the percentage, is uncapped. With no goal,
984// there is no bar.
985func progressBar(net, goal int64, width int) string {
986 if goal <= 0 {
987 return ""
988 }
989 filled := net
990 if filled > goal {
991 filled = goal
992 }
993 w := strconv.Itoa(width)
994 return "), 10) + "'%20height='10'%20rx='5'%20fill='%23E4A11B'/%3E" +
998 "%3C/svg%3E)"
999}
1000
1001// percent writes net as a share of goal, rounded down and uncapped: "124%".
1002func percent(net, goal int64) string {
1003 return strconv.FormatInt(share(net, goal, 100), 10) + "%"
1004}
1005
1006// share is part×scale/whole rounded down, for whole > 0, and 0 for part ≤ 0: exact for
1007// any part up to math.MaxInt64/scale, past every amount of ugnot there is, and held
1008// there beyond, so the product never overflows.
1009func share(part, whole, scale int64) int64 {
1010 if part <= 0 {
1011 return 0
1012 }
1013 if part > math.MaxInt64/scale {
1014 part = math.MaxInt64 / scale
1015 }
1016 return part * scale / whole
1017}
1018
1019// short shortens an address where a reader recognizes it rather than checks it: its
1020// first eight and last six characters (UI spec §4.13).
1021func short(a address) string {
1022 s := a.String()
1023 if len(s) <= 14 {
1024 return s
1025 }
1026 return s[:8] + "…" + s[len(s)-6:]
1027}
1028
1029// addrLink links an address's short form, in a code span, to its address page: Bolo's
1030// own link, where gnoweb makes no mention (API §5).
1031func addrLink(a address) string {
1032 return "[`" + short(a) + "`](" + webPath + ":u/" + a.String() + ")"
1033}
1034
1035// gnot writes an amount of ugnot in GNOT: up to six decimals, trailing zeros dropped.
1036func gnot(ugnot int64) string {
1037 return amount(ugnot) + " GNOT"
1038}
1039
1040// amount writes an amount of ugnot as a number of GNOT, for a column headed GNOT.
1041func amount(ugnot int64) string {
1042 sign := ""
1043 if ugnot < 0 {
1044 sign, ugnot = "-", -ugnot
1045 }
1046 whole, frac := ugnot/1_000_000, ugnot%1_000_000
1047 out := sign + strconv.FormatInt(whole, 10)
1048 if frac != 0 {
1049 f := strconv.FormatInt(frac+1_000_000, 10)[1:]
1050 out += "." + strings.TrimRight(f, "0")
1051 }
1052 return out
1053}
1054
1055// when writes a Unix time in UTC.
1056func when(unix int64) string {
1057 return stamp(unix) + " UTC"
1058}
1059
1060// stamp writes a Unix time in UTC without saying so, for a column that does.
1061func stamp(unix int64) string {
1062 return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04")
1063}
1064
1065// day writes a Unix time's day in UTC, where the minute is written nearby: a figure's
1066// caption, under the full time in the dates line.
1067func day(unix int64) string {
1068 return time.Unix(unix, 0).UTC().Format("2006-01-02")
1069}
1070
1071// left writes a real timer's countdown (UI spec §5, Countdown): whole days left, then
1072// "less than a day left".
1073func left(until int64) string {
1074 d := (until - now()) / secondsPerDay
1075 if d < 1 {
1076 return "less than a day left"
1077 }
1078 return count(int(d), "day left", "days left")
1079}
1080
1081// count writes n of something: one, or many.
1082func count(n int, one, many string) string {
1083 if n == 1 {
1084 return "1 " + one
1085 }
1086 return strconv.Itoa(n) + " " + many
1087}
1088
1089// days writes a duration in seconds as whole days.
1090func days(seconds int64) string {
1091 return strconv.FormatInt(seconds/secondsPerDay, 10) + " days"
1092}
1093
1094// statusWord names a status in the UI spec's words (§8.1).
1095func statusWord(status string) string {
1096 switch status {
1097 case statusOpen:
1098 return "Open"
1099 case statusClosed:
1100 return "Closed to new donations"
1101 }
1102 return "Completed"
1103}
1104
1105// modeWord names a custody mode in the UI spec's words (§8.1).
1106func modeWord(mode string) string {
1107 if mode == modeEscrow {
1108 return "Escrow"
1109 }
1110 return "Direct"
1111}
1112
1113// inline escapes text for an inline slot: sanitize.InlineText, which leaves no link and
1114// no image, then the mentions gnoweb would still make links of — "@name", and an
1115// address after a space — each to a page whose owner writes it (API §5). A backslash
1116// keeps "@" as it reads; a no-break space looks like the space it replaces.
1117func inline(s string) string {
1118 s = strings.ReplaceAll(sanitize.InlineText(s), "@", `\@`)
1119 return strings.ReplaceAll(s, " g1", "\u00a0g1")
1120}
1121
1122// lineStart escapes text for an inline slot that opens a line — the title's heading,
1123// the notice's line — where gnoweb also links an address with nothing before it:
1124// "g" reads as the "g" it replaces. Only there: gnoweb's table of contents shows a
1125// card's heading as written, and a card's title sits in a link's text, where gnoweb
1126// makes no mention (API §5).
1127func lineStart(s string) string {
1128 s = inline(s)
1129 if strings.HasPrefix(s, "g1") {
1130 s = "g" + s[1:]
1131 }
1132 return s
1133}