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

home.gno

8.91 Kb · 285 lines
  1// Package home is David G.'s profile page on gno.land.
  2//
  3// gnoweb renders a profile by calling Render("") on the realm at exactly
  4// /r/<address>/home, so this path carries no /vN suffix.
  5//
  6// The page is a list of sections, each a markdown body the owner edits with
  7// one Set transaction, so changing the text never needs a redeploy. The slot
  8// named "order" lists the sections to show. Six sections are drawn rather
  9// than stored: "hero", the page banner, "banner" and "reviews", the activity
 10// charts, "areachart", the ring of pull requests by area, "game", today's
 11// Gnordle, and "hearts", the heart count and wall.
 12package home
 13
 14import (
 15	"strconv"
 16	"strings"
 17
 18	"chain/runtime"
 19
 20	"gno.land/p/nt/avl/v0"
 21)
 22
 23// admin is the only address allowed to edit sections.
 24const admin = address("g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2")
 25
 26const (
 27	home       = "/r/g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2/home"
 28	orderSlot  = "order"
 29	maxNameLen = 32
 30	// maxPathLen is far above any link the page writes, and turns a long
 31	// crafted link away before the query parser, whose cost grows with it.
 32	maxPathLen = 256
 33)
 34
 35var (
 36	slots  = avl.NewTree() // section name -> markdown body
 37	hearts = avl.NewTree() // address -> block height of the heart
 38)
 39
 40func init() {
 41	for _, s := range defaultSlots {
 42		slots.Set(s.name, s.body)
 43	}
 44}
 45
 46// Set writes one section of the page. Only the admin can call it.
 47//
 48// name is the section to write and body is its markdown. The text sections are
 49// "header", the bio at the top, then "gno", "projects", "peerdev" and
 50// "unrelated"; any other name creates a new section.
 51//
 52// name "order" decides which sections show and in which order. Its body lists
 53// every section name, comma separated, with no spaces. A name left out of the
 54// list is hidden, so copy the current list, from Get("order"), and edit it.
 55//
 56// name "herotext" is the banner's text: "key value" pairs split by ";", with
 57// the keys kicker, the small line on top, title, subtitle, tagline, footline,
 58// the small line at the bottom, and alt, the banner's description for screen
 59// readers. A key left out keeps its current default.
 60//
 61// "hero", "banner", "reviews", "areachart", "game" and "hearts" are drawn by
 62// the page itself: they cannot be set, only placed through "order".
 63//
 64// Examples, as the two fields of the form:
 65//
 66//	name: header
 67//	body: I'm a developer engineer at [Samouraï Coop](https://www.samourai.world/).
 68//
 69//	name: herotext
 70//	body: title David G.; subtitle Developer engineer at Samouraï Coop; footline Open for contribution
 71//
 72//	name: talks
 73//	body: ## Talks
 74//
 75//	name: order
 76//	body: hero,header,activityhead,banner,reviews,gno,areachart,projects,peerdev,unrelated,talks,game,hearts
 77func Set(cur realm, name, body string) {
 78	assertAdmin(cur)
 79	if name == "" || len(name) > maxNameLen || strings.ContainsAny(name, ", \n") {
 80		panic("invalid section name")
 81	}
 82	if drawnSections[name] {
 83		panic("reserved section: " + name)
 84	}
 85	slots.Set(name, body)
 86}
 87
 88// SetActivity refreshes the three charts in one transaction. Only the admin
 89// can call it. Its three arguments are the bodies of the "activity",
 90// "reviewactivity" and "areas" sections, as scripts/activity.py prints them;
 91// an empty argument leaves that chart as it is.
 92func SetActivity(cur realm, activity, reviews, areas string) {
 93	assertAdmin(cur)
 94	for _, s := range []struct{ name, body string }{
 95		{activitySlot, activity},
 96		{reviewActivitySlot, reviews},
 97		{areasSlot, areas},
 98	} {
 99		if s.body != "" {
100			slots.Set(s.name, s.body)
101		}
102	}
103}
104
105// Delete removes one section. Only the admin can call it.
106//
107// name is the section to remove, "talks" for example. Its name can stay in
108// "order": a name with no section shows nothing.
109func Delete(cur realm, name string) {
110	assertAdmin(cur)
111	if _, ok := slots.Remove(name); !ok {
112		panic("no such section: " + name)
113	}
114}
115
116// Get returns a section's markdown, or "" when it does not exist.
117// Get("order") returns the current section list, the one to edit before
118// setting "order".
119func Get(name string) string {
120	if v := slots.Get(name); v != nil {
121		return v.(string)
122	}
123	return ""
124}
125
126// Heart records one heart from the caller. Each address can send exactly one.
127func Heart(cur realm) {
128	from := cur.Previous().Address().String()
129	if hearts.Has(from) {
130		panic("already sent: one heart per address")
131	}
132	hearts.Set(from, runtime.ChainHeight())
133}
134
135// Hearts returns how many distinct addresses sent a heart.
136func Hearts() int { return hearts.Size() }
137
138// HasHeart reports whether addr already sent a heart.
139func HasHeart(addr address) bool { return hearts.Has(addr.String()) }
140
141func assertAdmin(cur realm) {
142	if cur.Previous().Address() != admin {
143		panic("unauthorized")
144	}
145}
146
147// Render serves:
148//
149//	""             the profile page
150//	"wordle/..."   the Gnordle game
151//	"hearts[/<n>]" the addresses that sent a heart, heartsPerPage per page
152func Render(path string) string {
153	if len(path) > maxPathLen {
154		return "# Not found\n\n[Back home](" + home + ")\n"
155	}
156	// Only the game reads a query; elsewhere one is dropped, so a shared link
157	// carrying ?utm_source=... still shows the page.
158	if !strings.HasPrefix(path, "wordle") {
159		path, _, _ = strings.Cut(path, "?")
160	}
161	switch {
162	case path == "":
163		return renderPage()
164	case path == "wordle" || strings.HasPrefix(path, "wordle/") || strings.HasPrefix(path, "wordle?"):
165		return renderWordle(path)
166	case path == "hearts" || strings.HasPrefix(path, "hearts/"):
167		return renderHearts(strings.TrimPrefix(strings.TrimPrefix(path, "hearts"), "/"))
168	}
169	return "# Not found\n\n[Back home](" + home + ")\n"
170}
171
172func renderPage() string {
173	var b strings.Builder
174	for _, name := range strings.Split(Get(orderSlot), ",") {
175		switch name = strings.TrimSpace(name); name {
176		case "":
177		case "hero":
178			b.WriteString(heroImage())
179		case "areachart":
180			b.WriteString(renderAreas())
181		case "banner":
182			b.WriteString(renderChart(activitySlot))
183		case "reviews":
184			b.WriteString(renderChart(reviewActivitySlot))
185		case "game":
186			b.WriteString(renderGameTeaser())
187		case "hearts":
188			b.WriteString(renderHeartsTeaser())
189		default:
190			b.WriteString(Get(name))
191		}
192		b.WriteString("\n\n")
193	}
194	b.WriteString("---\n\nThis page is a contract: [gno.land" + home + "](" + home + "$source).\n")
195	return b.String()
196}
197
198func renderHeartsTeaser() string {
199	return "## Hearts\n\n**" + strconv.Itoa(Hearts()) + "** so far. " +
200		"[Send one](" + home + "$help&func=Heart): one transaction, one per address, " +
201		"forever on chain. [See who did](" + home + ":hearts).\n\n" + renderHeartsWall()
202}
203
204// drawnSections are rendered from code and state, so Set refuses their names.
205var drawnSections = map[string]bool{
206	"hero": true, "banner": true, "reviews": true, "areachart": true, "game": true, "hearts": true,
207}
208
209// heroSlot holds the banner's text, one "key value" pair per field, split by
210// ";": kicker, title, subtitle, tagline, footline and alt, the image's
211// description. A field the section leaves out keeps its value from hero.
212const heroSlot = "herotext"
213
214func currentHero() heroText {
215	h := hero
216	for _, pair := range strings.Split(Get(heroSlot), ";") {
217		key, value, _ := strings.Cut(strings.TrimSpace(pair), " ")
218		value = strings.TrimSpace(value)
219		switch key {
220		case "kicker":
221			h.kicker = value
222		case "title":
223			h.title = value
224		case "subtitle":
225			h.subtitle = value
226		case "tagline":
227			h.tagline = value
228		case "footline":
229			h.footline = value
230		case "alt":
231			h.alt = value
232		}
233	}
234	return h
235}
236
237// mdText keeps s from closing a markdown link or image label.
238func mdText(s string) string {
239	return strings.NewReplacer("[", "(", "]", ")").Replace(s)
240}
241
242// xmlText escapes s for SVG text and attributes.
243func xmlText(s string) string {
244	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;").Replace(s)
245}
246
247// heartsPerPage keeps one page of the list far under the query gas limit,
248// which a list of every heart would pass at about 15,000 of them.
249const heartsPerPage = 100
250
251func renderHearts(page string) string {
252	n := 1
253	if page != "" {
254		v, err := strconv.Atoi(page)
255		if err != nil || v < 1 {
256			return "# Hearts\n\nThere is no such page.\n\n[Back home](" + home + ")\n"
257		}
258		n = v
259	}
260	pages := (hearts.Size() + heartsPerPage - 1) / heartsPerPage
261	if n > 1 && n > pages {
262		return "# Hearts\n\nThere is no such page.\n\n[Back home](" + home + ")\n"
263	}
264	var b strings.Builder
265	b.WriteString("# Hearts\n\n")
266	if hearts.Size() == 0 {
267		b.WriteString("Nobody yet. [Be the first](" + home + "$help&func=Heart).\n")
268	}
269	hearts.IterateByOffset((n-1)*heartsPerPage, heartsPerPage, func(addr string, h any) bool {
270		b.WriteString("- " + addr + ", block " + strconv.FormatInt(h.(int64), 10) + "\n")
271		return false
272	})
273	if pages > 1 {
274		b.WriteString("\nPage " + strconv.Itoa(n) + " of " + strconv.Itoa(pages) + ".")
275		if n > 1 {
276			b.WriteString(" [Previous](" + home + ":hearts/" + strconv.Itoa(n-1) + ")")
277		}
278		if n < pages {
279			b.WriteString(" [Next](" + home + ":hearts/" + strconv.Itoa(n+1) + ")")
280		}
281		b.WriteString("\n")
282	}
283	b.WriteString("\n[Back home](" + home + ")\n")
284	return b.String()
285}