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

6.14 Kb · 220 lines
  1package blog
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"gno.land/p/nym-moultest123/kit/ui/v0"
  8	"gno.land/r/nym-moultest123/config/v1"
  9)
 10
 11// linkPrefix is what a permalink starts with. gnoweb addresses a realm's
 12// render path with ':' after the package path, so this is realmPath with the
 13// leading "gno.land" dropped and the separator appended.
 14const linkPrefix = "/r/nym-moultest123/blog:"
 15
 16// indexLink is the index, which is the realm root and therefore has NO ':'.
 17// Spelled separately rather than reusing linkPrefix, because linkPrefix with
 18// an empty path produces a trailing colon and a link nobody wants to paste.
 19const indexLink = "/r/nym-moultest123/blog"
 20
 21// defaultIntro is the header shown before SetIntro has ever been called, i.e.
 22// right after the first deploy or after a redeploy wiped the state.
 23const defaultIntro = "# moul's blog\n\nNotes on gno.land, from the person building on it.\n"
 24
 25// Render serves three views:
 26//
 27//	""         the index, newest first
 28//	"<slug>"   one post
 29//	"t/<tag>"  every post carrying a tag
 30// Render wraps every view in the notice blocks from gno.land/r/nym-moultest123/config: a
 31// warning, a changelog line or a bit of news, set either on every realm of
 32// moul's at once or on this one alone, plus a pause banner if this realm is
 33// ever held back. Both are empty by default, so an unconfigured chain renders
 34// exactly what it rendered before.
 35//
 36// The path is passed explicitly rather than using config's zero-argument
 37// helpers. Both work, but this realm already states its own path for its
 38// permalinks, and a constant cannot be wrong.
 39func Render(path string) string {
 40	return config.TopBlockFor(realmPath) + render(path) + config.BottomBlockFor(realmPath)
 41}
 42
 43func render(path string) string {
 44	path = strings.TrimSpace(path)
 45	switch {
 46	case path == "":
 47		return renderIndex()
 48	case strings.HasPrefix(path, "t/"):
 49		return renderTag(strings.TrimPrefix(path, "t/"))
 50	default:
 51		return renderPost(path)
 52	}
 53}
 54
 55func header() string {
 56	if intro == "" {
 57		return defaultIntro
 58	}
 59	return strings.TrimRight(intro, "\n") + "\n"
 60}
 61
 62func renderIndex() string {
 63	var b strings.Builder
 64	b.WriteString(header())
 65
 66	if posts.Size() == 0 {
 67		b.WriteString("\n")
 68		b.WriteString(ui.Empty("Nothing published yet."))
 69		return b.String()
 70	}
 71
 72	// order is keyed by "<date>/<slug>", so walking it backwards is
 73	// newest-first without a sort the language does not have.
 74	order.ReverseIterate("", "", func(_ string, value any) bool {
 75		b.WriteString("\n")
 76		b.WriteString(entry(value.(*post)))
 77		return false
 78	})
 79	return b.String()
 80}
 81
 82func renderTag(tag string) string {
 83	tag = strings.ToLower(strings.TrimSpace(tag))
 84
 85	var b strings.Builder
 86	b.WriteString("# Tagged ")
 87	b.WriteString(ui.Inline(tag))
 88	b.WriteString("\n")
 89
 90	found := 0
 91	order.ReverseIterate("", "", func(_ string, value any) bool {
 92		p := value.(*post)
 93		if !hasTag(p, tag) {
 94			return false
 95		}
 96		found++
 97		b.WriteString("\n")
 98		b.WriteString(entry(p))
 99		return false
100	})
101	if found == 0 {
102		b.WriteString("\n")
103		b.WriteString(ui.Empty("No post carries this tag."))
104	}
105	b.WriteString("\n[← all posts](" + indexLink + ")\n")
106	return b.String()
107}
108
109func renderPost(slug string) string {
110	v := posts.Get(slug)
111	if v == nil {
112		return "# Not found\n\nNo post at " + strconv.Quote(ui.Inline(slug)) +
113			".\n\n[← all posts](" + indexLink + ")\n"
114	}
115	p := v.(*post)
116
117	var b strings.Builder
118	b.WriteString("# ")
119	b.WriteString(ui.Inline(p.title))
120	b.WriteString("\n\n")
121	b.WriteString(byline(p))
122	b.WriteString("\n\n")
123	// The body is markdown written by `author`, who is also the only address
124	// that can call Set. It is the post, so it renders verbatim: escaping it
125	// would turn every heading and link in it into literal text.
126	b.WriteString(strings.TrimRight(p.body, "\n"))
127	b.WriteString("\n\n---\n\n[← all posts](" + indexLink + ")\n")
128	return b.String()
129}
130
131// entry is one row of a listing: linked title, byline, excerpt.
132func entry(p *post) string {
133	var b strings.Builder
134	b.WriteString("## [")
135	b.WriteString(ui.Inline(p.title))
136	b.WriteString("](")
137	b.WriteString(linkPrefix)
138	b.WriteString(p.slug)
139	b.WriteString(")\n\n")
140	b.WriteString(byline(p))
141	b.WriteString("\n\n")
142	b.WriteString(ui.Excerpt(flattenLinks(firstLine(p.body)), excerptLen))
143	b.WriteString("\n")
144	return b.String()
145}
146
147// byline is the date plus the post's tags as links, on one line.
148func byline(p *post) string {
149	var b strings.Builder
150	b.WriteString(p.date)
151	for _, t := range splitTags(p.tags) {
152		b.WriteString(" · [#")
153		b.WriteString(ui.Inline(t))
154		b.WriteString("](")
155		b.WriteString(linkPrefix)
156		b.WriteString("t/")
157		b.WriteString(t)
158		b.WriteString(")")
159	}
160	return b.String()
161}
162
163func hasTag(p *post, tag string) bool {
164	for _, t := range splitTags(p.tags) {
165		if t == tag {
166			return true
167		}
168	}
169	return false
170}
171
172// flattenLinks rewrites "[text](url)" to "text".
173//
174// An excerpt is plain text: ui.Excerpt escapes what it is given, so a markdown
175// link survives into the index as its own source, backslashes and all, and the
176// reader sees `[mygnoscan](https://...)` where a sentence should be. Shipped
177// exactly that way on 2026-09-23 and it was the first thing on the page.
178//
179// Dropping the URL rather than keeping it bare is deliberate: the title beside
180// it is already a link to the post, and a second URL in a two-line preview is
181// noise. Unbalanced brackets are left alone and escaped as before, because a
182// half-parsed link is worse than an unparsed one.
183func flattenLinks(s string) string {
184	var b strings.Builder
185	for {
186		open := strings.Index(s, "[")
187		if open < 0 {
188			break
189		}
190		close := strings.Index(s[open:], "](")
191		if close < 0 {
192			break
193		}
194		close += open
195		end := strings.Index(s[close:], ")")
196		if end < 0 {
197			break
198		}
199		end += close
200		b.WriteString(s[:open])
201		b.WriteString(s[open+1 : close])
202		s = s[end+1:]
203	}
204	b.WriteString(s)
205	return b.String()
206}
207
208// firstLine returns the first non-empty, non-heading line of a body, which is
209// what an excerpt should show: a post that opens with "## Something" would
210// otherwise preview its own heading.
211func firstLine(body string) string {
212	for _, line := range strings.Split(body, "\n") {
213		line = strings.TrimSpace(line)
214		if line == "" || strings.HasPrefix(line, "#") {
215			continue
216		}
217		return line
218	}
219	return ""
220}