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}