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

render.gno

6.09 Kb · 220 lines
  1package blog
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"gno.land/p/moul/kit/ui/v0"
  8	"gno.land/r/moul/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/moul/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/moul/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/moul/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}