render.gno
4.09 Kb · 165 lines
1package blog
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/moul/kit/ui/v0"
8)
9
10// linkPrefix is what a permalink starts with. gnoweb addresses a realm's
11// render path with ':' after the package path, so this is realmPath with the
12// leading "gno.land" dropped and the separator appended.
13const linkPrefix = "/r/moul/blog:"
14
15// defaultIntro is the header shown before SetIntro has ever been called, i.e.
16// right after the first deploy or after a redeploy wiped the state.
17const defaultIntro = "# moul's blog\n\nNotes on gno.land, from the person building on it.\n"
18
19// Render serves three views:
20//
21// "" the index, newest first
22// "<slug>" one post
23// "t/<tag>" every post carrying a tag
24func Render(path string) string {
25 path = strings.TrimSpace(path)
26 switch {
27 case path == "":
28 return renderIndex()
29 case strings.HasPrefix(path, "t/"):
30 return renderTag(strings.TrimPrefix(path, "t/"))
31 default:
32 return renderPost(path)
33 }
34}
35
36func header() string {
37 if intro == "" {
38 return defaultIntro
39 }
40 return strings.TrimRight(intro, "\n") + "\n"
41}
42
43func renderIndex() string {
44 var b strings.Builder
45 b.WriteString(header())
46
47 if posts.Size() == 0 {
48 b.WriteString("\n")
49 b.WriteString(ui.Empty("Nothing published yet."))
50 return b.String()
51 }
52
53 // order is keyed by "<date>/<slug>", so walking it backwards is
54 // newest-first without a sort the language does not have.
55 order.ReverseIterate("", "", func(_ string, value any) bool {
56 b.WriteString("\n")
57 b.WriteString(entry(value.(*post)))
58 return false
59 })
60 return b.String()
61}
62
63func renderTag(tag string) string {
64 tag = strings.ToLower(strings.TrimSpace(tag))
65
66 var b strings.Builder
67 b.WriteString("# Tagged ")
68 b.WriteString(ui.Inline(tag))
69 b.WriteString("\n")
70
71 found := 0
72 order.ReverseIterate("", "", func(_ string, value any) bool {
73 p := value.(*post)
74 if !hasTag(p, tag) {
75 return false
76 }
77 found++
78 b.WriteString("\n")
79 b.WriteString(entry(p))
80 return false
81 })
82 if found == 0 {
83 b.WriteString("\n")
84 b.WriteString(ui.Empty("No post carries this tag."))
85 }
86 b.WriteString("\n[← all posts](" + linkPrefix + ")\n")
87 return b.String()
88}
89
90func renderPost(slug string) string {
91 v := posts.Get(slug)
92 if v == nil {
93 return "# Not found\n\nNo post at " + strconv.Quote(ui.Inline(slug)) +
94 ".\n\n[← all posts](" + linkPrefix + ")\n"
95 }
96 p := v.(*post)
97
98 var b strings.Builder
99 b.WriteString("# ")
100 b.WriteString(ui.Inline(p.title))
101 b.WriteString("\n\n")
102 b.WriteString(byline(p))
103 b.WriteString("\n\n")
104 // The body is markdown written by `author`, who is also the only address
105 // that can call Set. It is the post, so it renders verbatim: escaping it
106 // would turn every heading and link in it into literal text.
107 b.WriteString(strings.TrimRight(p.body, "\n"))
108 b.WriteString("\n\n---\n\n[← all posts](" + linkPrefix + ")\n")
109 return b.String()
110}
111
112// entry is one row of a listing: linked title, byline, excerpt.
113func entry(p *post) string {
114 var b strings.Builder
115 b.WriteString("## [")
116 b.WriteString(ui.Inline(p.title))
117 b.WriteString("](")
118 b.WriteString(linkPrefix)
119 b.WriteString(p.slug)
120 b.WriteString(")\n\n")
121 b.WriteString(byline(p))
122 b.WriteString("\n\n")
123 b.WriteString(ui.Excerpt(firstLine(p.body), excerptLen))
124 b.WriteString("\n")
125 return b.String()
126}
127
128// byline is the date plus the post's tags as links, on one line.
129func byline(p *post) string {
130 var b strings.Builder
131 b.WriteString(p.date)
132 for _, t := range splitTags(p.tags) {
133 b.WriteString(" · [#")
134 b.WriteString(ui.Inline(t))
135 b.WriteString("](")
136 b.WriteString(linkPrefix)
137 b.WriteString("t/")
138 b.WriteString(t)
139 b.WriteString(")")
140 }
141 return b.String()
142}
143
144func hasTag(p *post, tag string) bool {
145 for _, t := range splitTags(p.tags) {
146 if t == tag {
147 return true
148 }
149 }
150 return false
151}
152
153// firstLine returns the first non-empty, non-heading line of a body, which is
154// what an excerpt should show: a post that opens with "## Something" would
155// otherwise preview its own heading.
156func firstLine(body string) string {
157 for _, line := range strings.Split(body, "\n") {
158 line = strings.TrimSpace(line)
159 if line == "" || strings.HasPrefix(line, "#") {
160 continue
161 }
162 return line
163 }
164 return ""
165}