1package home23import (4 "chain/runtime"5 "encoding/base64"6 "html"7 "strconv"8 "strings"910 "gno.land/p/nt/markdown/sanitize/v0"11)1213// Suggested amount on the Tip button (1 GNOT); visitors can change it.14const tipSuggestion = "1000000ugnot"1516// expand replaces every widget token in one pass. strings.Replacer does not17// rescan its output, so visitor text inside a widget can never trigger a18// token. Only the tokens present in md are built, found with a single scan19// of md.20func expand(md, tab string, page int) string {21 words := map[string]bool{}22 for _, w := range strings.Split(md, ":") {23 words[w] = true24 }25 var pairs []string26 add := func(tok string, build func() string) {27 if words[tok[1:len(tok)-1]] {28 pairs = append(pairs, tok, build())29 }30 }31 add(":banner:", bannerMD)32 add(":badges:", badgesMD)33 add(":now:", nowMD)34 add(":waves:", wavesMD)35 add(":tip:", tipMD)36 add(":sign:", signMD)37 add(":guestbook:", func() string {38 if tab != guestbookTab {39 return "" // a form would target /u/<name> on the profile view40 }41 return guestbookMD(page)42 })43 add(":blog:", func() string {44 if tab != blogTab {45 return "" // its page links lead to the Blog tab46 }47 return blogMD(page)48 })49 add(":posts:", postsMD)50 add(":height:", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) })51 add(":owner:", func() string { return owner.String() })52 if len(pairs) == 0 {53 return md54 }55 return strings.NewReplacer(pairs...).Replace(md)56}5758func itoa(n int) string { return strconv.Itoa(n) }5960// plural renders "1 wave", "2 waves".61func plural(n int, word string) string {62 if n == 1 {63 return "1 " + word64 }65 return itoa(n) + " " + word + "s"66}6768// svgImage embeds an SVG as a base64 data URI, the one inline image form69// gnoweb keeps.70func svgImage(alt, svg string) string {71 return ") + ")"73}7475// ----- colors -----7677// gno.land's own greens, from gnoweb's palette. An image can't follow78// gnoweb's theme toggle, so each one reads on both its white and its79// near-black background.80const (81 colBrand = "#226C57" // gnoweb's links and buttons82 colDeep = "#144134" // banner background, text on colSoft83 colAccent = "#60AB96" // banner accents, pill outlines84 colSoft = "#E7EFED" // pale surface85 colGhost = "#F0F0F0"86 fontUI = "system-ui, -apple-system, Segoe UI, Helvetica, Arial, sans-serif"87)8889// ----- banner -----9091func bannerMD() string {92 return svgImage(profile.name+" banner", bannerSVG(runtime.ChainHeight()))93}9495// bannerSVG draws the 720x160 banner: the ghost, a coffee cup, the name and96// the tagline. The face depends on height % 4: calm, happy, wink, surprised.97func bannerSVG(height int64) string {98 var sb strings.Builder99 sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160">`)100 sb.WriteString(`<rect width="720" height="160" rx="16" fill="` + colDeep + `"/>`)101 sb.WriteString(`<path d="M60 140 V72 A50 50 0 0 1 160 72 V140 Q147.5 124 135 140 Q122.5 124 110 140 Q97.5 124 85 140 Q72.5 124 60 140 Z" fill="` + colGhost + `"/>`)102 switch height % 4 {103 case 0: // calm104 sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)105 sb.WriteString(`<ellipse cx="128" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)106 sb.WriteString(`<path d="M100 100 Q110 106 120 100" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)107 case 1: // happy108 sb.WriteString(`<path d="M84 78 Q92 66 100 78 M120 78 Q128 66 136 78" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)109 sb.WriteString(`<path d="M96 96 Q110 116 124 96 Z" fill="` + colDeep + `"/>`)110 case 2: // wink111 sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)112 sb.WriteString(`<path d="M120 76 H136" stroke="` + colDeep + `" stroke-width="4" stroke-linecap="round"/>`)113 sb.WriteString(`<path d="M98 98 Q110 110 122 98" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)114 default: // surprised115 sb.WriteString(`<circle cx="92" cy="72" r="8" fill="` + colDeep + `"/>`)116 sb.WriteString(`<circle cx="128" cy="72" r="8" fill="` + colDeep + `"/>`)117 sb.WriteString(`<ellipse cx="110" cy="104" rx="7" ry="9" fill="` + colDeep + `"/>`)118 }119 // Coffee cup with steam: "I turn coffee into code".120 sb.WriteString(`<rect x="180" y="108" width="30" height="30" rx="5" fill="` + colAccent + `"/>`)121 sb.WriteString(`<path d="M210 114 h6 a7 7 0 0 1 0 14 h-6" stroke="` + colAccent + `" stroke-width="4" fill="none"/>`)122 sb.WriteString(`<path d="M188 100 q-5 -7 0 -14 M200 100 q-5 -7 0 -14" stroke="` + colAccent + `" stroke-width="3" fill="none" stroke-linecap="round"/>`)123 sb.WriteString(`<text x="260" y="78" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="` + fontSize(profile.name, 44, 26) + `" font-weight="700" fill="` + colGhost + `">` + html.EscapeString(profile.name) + `</text>`)124 sb.WriteString(`<text x="260" y="116" font-family="` + fontUI + `" font-size="` + fontSize(profile.tagline, 22, 12) + `" fill="` + colAccent + `">` + html.EscapeString(profile.tagline) + `</text>`)125 sb.WriteString(`</svg>`)126 return sb.String()127}128129// fontSize shrinks size so text fits the 440 px between x=260 and the130// banner's right edge. perChar is the rough glyph width at that size; runes131// from U+1100 on (CJK, emoji and the like) count double. Scaling the font,132// rather than setting textLength, never stretches text that already fits.133func fontSize(s string, size, perChar int) string {134 w := 0135 for _, r := range s {136 w += perChar137 if r >= 0x1100 {138 w += perChar139 }140 }141 if w > 440 {142 size = size * 440 / w143 }144 return itoa(size)145}146147// ----- pills: tabs, badges, buttons -----148149// Pills come in two heights: tabs, and chips (badges and buttons). gnoweb150// draws a 16 px icon on a linked image, 8 px from its left edge: images in151// a row get an 8 px margin, and the icon's center sits that far above the152// image's bottom edge. It lands on the center line of a chip about 16 px153// high, so chips are 17 px, with iconGap pixels left clear for it.154// External and $help links get the icon; tabs and the guestbook button155// link inside the realm's own namespace and don't.156const (157 tabH = 26158 chipH = 17159 iconGap = 30160)161162// seg is one colored part of a pill.163type seg struct{ text, fill, ink string }164165func segOn(s string) seg { return seg{s, colBrand, "#fff"} }166func segSoft(s string) seg { return seg{s, colSoft, colDeep} }167168// pillSVG draws a pill h pixels high from segments, left to right, with169// gap empty pixels before the first text. SVG can't measure text before170// drawing, so widths come from the rune count, wide runes (U+1100 on)171// counting double. The outline gives the pale parts an edge on a white172// page.173func pillSVG(h, gap int, segs ...seg) string {174 font, char := 11, 7175 if h == tabH {176 font, char = 13, 8177 }178 widths := make([]int, len(segs))179 w := gap180 for i, s := range segs {181 widths[i] = h // half a pill's height of padding on each side182 for _, r := range s.text {183 widths[i] += char184 if r >= 0x1100 {185 widths[i] += char186 }187 }188 w += widths[i]189 }190 mid := half(h)191 var sb strings.Builder192 sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(w) + `" height="` + itoa(h) + `" viewBox="0 0 ` + itoa(w) + ` ` + itoa(h) + `">`)193 sb.WriteString(`<clipPath id="c"><rect width="` + itoa(w) + `" height="` + itoa(h) + `" rx="` + mid + `"/></clipPath>`)194 sb.WriteString(`<g clip-path="url(#c)" font-family="` + fontUI + `" font-size="` + itoa(font) + `" font-weight="600" text-anchor="middle" dominant-baseline="central">`)195 x := 0196 for i, s := range segs {197 pad := 0198 if i == 0 {199 pad = gap200 }201 sb.WriteString(`<rect x="` + itoa(x) + `" width="` + itoa(pad+widths[i]) + `" height="` + itoa(h) + `" fill="` + s.fill + `"/>`)202 sb.WriteString(`<text x="` + itoa(x+pad+widths[i]/2) + `" y="` + mid + `" fill="` + s.ink + `">` + html.EscapeString(s.text) + `</text>`)203 x += pad + widths[i]204 }205 sb.WriteString(`</g><rect x=".5" y=".5" width="` + itoa(w-1) + `" height="` + itoa(h-1) + `" rx="` + half(h-1) + `" fill="none" stroke="` + colAccent + `"/></svg>`)206 return sb.String()207}208209// half writes n/2 with its .5, for SVG coordinates.210func half(n int) string {211 if n%2 == 1 {212 return itoa(n/2) + ".5"213 }214 return itoa(n / 2)215}216217// pillLink is a pill image linked to url, with alt text for screen readers.218func pillLink(alt, url string, h, gap int, segs ...seg) string {219 return "[" + svgImage(alt, pillSVG(h, gap, segs...)) + "](" + url + ")"220}221222// badgesMD draws the badges as one paragraph. A new row starts after a223// hard line break (a backslash ending the line), so rows sit as close as224// wrapped ones.225func badgesMD() string {226 var sb strings.Builder227 for i, b := range badges {228 switch {229 case i > 0 && b.row:230 sb.WriteString("\\\n")231 case i > 0:232 sb.WriteString(" ")233 }234 sb.WriteString(pillLink(b.label+": "+b.value, b.url, chipH, iconGap, segSoft(b.label), segOn(b.value)))235 }236 return sb.String()237}238239// ----- now, buttons -----240241func nowMD() string {242 if now.text == "" {243 return ""244 }245 return "> [!SUCCESS] Now\n> " + now.text + " (block " + strconv.FormatInt(now.height, 10) + ")"246}247248// button is a linked chip: the action, then its count. $help links get249// gnoweb's transaction icon, hence the gap.250func button(action, count, url string, gap int) string {251 return pillLink(action+", "+count, url, chipH, gap, segOn(action), segSoft(count))252}253254func wavesMD() string {255 return button("Wave", plural(waves, "wave"), base+"$help&func=Wave", iconGap)256}257258func tipMD() string {259 return button("Tip 1 GNOT", plural(tips.count, "tip"), base+"$help&func=Tip&.send="+tipSuggestion, iconGap)260}261262// signMD counts hidden entries too: their authors did sign.263func signMD() string {264 return button("Sign the guestbook", plural(order.Size(), "signature"), tabURL(guestbookTab), 0)265}266267// shortAddr keeps the ends of an address: g1jg8mtu…sqf5.268func shortAddr(a address) string {269 s := a.String()270 if len(s) < 16 {271 return s272 }273 return s[:8] + "…" + s[len(s)-4:]274}275276// userLink links an address to its profile page under its short form. The277// address comes from the chain, never from visitor text.278func userLink(a address) string {279 return "[" + shortAddr(a) + "](/u/" + a.String() + ")"280}281282// ----- paging -----283284// clampPage returns the page to show, at most the last one, and the last285// page for total items.286func clampPage(page, total int) (int, int) {287 last := (total + pageSize - 1) / pageSize288 if page > last {289 page = last290 }291 return page, last292}293294// pageNav links the pages around page on tab: Newer, then Older.295func pageNav(tab string, page, last int) string {296 var nav []string297 if page > 1 {298 nav = append(nav, "[Newer]("+tabURL(tab)+"?page="+itoa(page-1)+")")299 }300 if page < last {301 nav = append(nav, "[Older]("+tabURL(tab)+"?page="+itoa(page+1)+")")302 }303 if len(nav) == 0 {304 return ""305 }306 return "\n" + strings.Join(nav, " · ") + "\n"307}308309// ----- guestbook -----310311const signForm = `<gno-form exec="Sign">312 <gno-input name="msg" type="text" placeholder="Say hi in 140 characters" required="true" />313</gno-form>`314315// visitorText makes a stored message safe as one markdown list item.316// sanitize.InlineText escapes links, images, HTML and emphasis; the escaped317// @ and the g entity for the g of g1 keep gnoweb from turning "@name"318// or a typed g1 address into a profile link. Neither replacement produces319// the other's pattern, so the order doesn't matter.320func visitorText(msg string) string {321 s := strings.ReplaceAll(sanitize.InlineText(msg), "@", `\@`)322 return strings.ReplaceAll(s, "g1", "g1")323}324325// guestbookMD shows the form and one page of entries, newest first. Hidden326// entries still count toward the offset, so a page can show fewer than 20.327func guestbookMD(page int) string {328 var sb strings.Builder329 sb.WriteString(signForm)330 sb.WriteString("\n\n")331 total := order.Size()332 if total == 0 {333 sb.WriteString("No entries yet.\n")334 return sb.String()335 }336 page, last := clampPage(page, total)337 order.ReverseIterateByOffset((page-1)*pageSize, pageSize, func(_ string, v any) bool {338 addr, _ := v.(address)339 e, ok := entries.Get(addr.String()).(*entry)340 if ok && !e.hidden { // Render never panics, even on a broken invariant341 sb.WriteString("- " + userLink(addr) + ": " + visitorText(e.msg) + "\n")342 }343 return false344 })345 sb.WriteString(pageNav(guestbookTab, page, last))346 return sb.String()347}348349// ----- blog -----350351// postList lists count posts from offset, newest first: one item each with352// the publish date as a code chip and the linked title.353func postList(offset, count int) string {354 var sb strings.Builder355 postOrder.ReverseIterateByOffset(offset, count, func(_ string, v any) bool {356 slug, _ := v.(string)357 if p, ok := posts.Get(slug).(*post); ok { // Render never panics, even on a broken invariant358 sb.WriteString("- `" + p.published + "` [" + sanitize.InlineText(p.title) + "](" + tabURL(blogTab) + "/" + slug + ")\n")359 }360 return false361 })362 return sb.String()363}364365// blogMD lists one page of posts.366func blogMD(page int) string {367 total := postOrder.Size()368 if total == 0 {369 return "No posts yet.\n"370 }371 page, last := clampPage(page, total)372 return postList((page-1)*pageSize, pageSize) + pageNav(blogTab, page, last)373}374375// postsMD is the Latest posts section, or nothing while there are no posts,376// so no empty heading shows.377func postsMD() string {378 if postOrder.Size() == 0 {379 return ""380 }381 return "## Latest posts\n\n" + postList(0, latestPosts) + "\n[All posts](" + tabURL(blogTab) + ")\n"382}383384// postMD is a post's page. The body is shown as written: no tokens.385func postMD(p *post) string {386 date := p.published387 if p.edited != "" && p.edited != p.published {388 date += " · edited " + p.edited389 }390 return "# " + sanitize.InlineText(p.title) + "\n\n*" + date + "*\n\n" + p.body +391 "\n\n[All posts](" + tabURL(blogTab) + ")"392}393AcceptOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Hide(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, author string)
Owner() string
RemovePage(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, tab string)
RemovePost(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, slug string)
Render(path string) string
SetBadges(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, spec string)
SetNow(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, text string)
SetPage(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, tab string, md string)
SetPost(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, slug string, title string, body string)
SetProfile(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, tagline string)
Sign(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, msg string)
Tip(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
TransferOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, newOwner string)
Wave(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.