blog.gno
8.20 Kb ยท 407 lines
1package blog
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/nt/avl"
9 "gno.land/p/nt/mux"
10 "gno.land/p/nt/ufmt"
11)
12
13type Blog struct {
14 Title string
15 Prefix string // i.e. r/gnoland/blog:
16 Posts avl.Tree // slug -> *Post
17 PostsPublished avl.Tree // published-date -> *Post
18 PostsAlphabetical avl.Tree // title -> *Post
19 NoBreadcrumb bool
20}
21
22func (b Blog) RenderLastPostsWidget(limit int) string {
23 if b.PostsPublished.Size() == 0 {
24 return "No posts."
25 }
26
27 output := ""
28 i := 0
29 b.PostsPublished.ReverseIterate("", "", func(key string, value any) bool {
30 p := value.(*Post)
31 output += ufmt.Sprintf("- [%s](%s)\n", p.Title, p.URL())
32 i++
33 return i >= limit
34 })
35 return output
36}
37
38func (b Blog) RenderHome(res *mux.ResponseWriter, _ *mux.Request) {
39 if !b.NoBreadcrumb {
40 res.Write(breadcrumb([]string{b.Title}))
41 }
42
43 if b.Posts.Size() == 0 {
44 res.Write("No posts.")
45 return
46 }
47
48 const maxCol = 3
49 var rowItems []string
50
51 b.PostsPublished.ReverseIterate("", "", func(key string, value any) bool {
52 post := value.(*Post)
53 rowItems = append(rowItems, post.RenderListItem())
54
55 if len(rowItems) == maxCol {
56 res.Write("<gno-columns>" + strings.Join(rowItems, "|||") + "</gno-columns>\n")
57 rowItems = []string{}
58 }
59 return false
60 })
61
62 // Pad and flush any remaining items
63 if len(rowItems) > 0 {
64 for len(rowItems) < maxCol {
65 rowItems = append(rowItems, "")
66 }
67 res.Write("<gno-columns>" + strings.Join(rowItems, "\n|||\n") + "</gno-columns>\n")
68 }
69}
70
71func (b Blog) RenderPost(res *mux.ResponseWriter, req *mux.Request) {
72 slug := req.GetVar("slug")
73
74 post, found := b.Posts.Get(slug)
75 if !found {
76 res.Write("404")
77 return
78 }
79 p := post.(*Post)
80
81 res.Write("<main class='gno-tmpl-page'>" + "\n\n")
82
83 res.Write("# " + p.Title + "\n\n")
84 res.Write(p.Body + "\n\n")
85 res.Write("---\n\n")
86
87 res.Write(p.RenderTagList() + "\n\n")
88 res.Write(p.RenderAuthorList() + "\n\n")
89 res.Write(p.RenderPublishData() + "\n\n")
90
91 res.Write("---\n")
92 res.Write("<details><summary>Comment section</summary>\n\n")
93
94 // comments
95 p.Comments.ReverseIterate("", "", func(key string, value any) bool {
96 comment := value.(*Comment)
97 res.Write(comment.RenderListItem())
98 return false
99 })
100
101 res.Write("</details>\n")
102 res.Write("</main>")
103}
104
105func (b Blog) RenderTag(res *mux.ResponseWriter, req *mux.Request) {
106 slug := req.GetVar("slug")
107
108 if slug == "" {
109 res.Write("404")
110 return
111 }
112
113 if !b.NoBreadcrumb {
114 breadStr := breadcrumb([]string{
115 ufmt.Sprintf("[%s](%s)", b.Title, b.Prefix),
116 "t",
117 slug,
118 })
119 res.Write(breadStr)
120 }
121
122 nb := 0
123 b.Posts.Iterate("", "", func(key string, value any) bool {
124 post := value.(*Post)
125 if !post.HasTag(slug) {
126 return false
127 }
128 res.Write(post.RenderListItem())
129 nb++
130 return false
131 })
132 if nb == 0 {
133 res.Write("No posts.")
134 }
135}
136
137func (b Blog) Render(path string) string {
138 router := mux.NewRouter()
139 router.HandleFunc("", b.RenderHome)
140 router.HandleFunc("p/{slug}", b.RenderPost)
141 router.HandleFunc("t/{slug}", b.RenderTag)
142 return router.Render(path)
143}
144
145func (b *Blog) NewPost(publisher address, slug, title, body, pubDate string, authors, tags []string) error {
146 if _, found := b.Posts.Get(slug); found {
147 return ErrPostSlugExists
148 }
149
150 var parsedTime time.Time
151 var err error
152 if pubDate != "" {
153 parsedTime, err = time.Parse(time.RFC3339, pubDate)
154 if err != nil {
155 return err
156 }
157 } else {
158 // If no publication date was passed in by caller, take current block time
159 parsedTime = time.Now()
160 }
161
162 post := &Post{
163 Publisher: publisher,
164 Authors: authors,
165 Slug: slug,
166 Title: title,
167 Body: body,
168 Tags: tags,
169 CreatedAt: parsedTime,
170 }
171
172 return b.prepareAndSetPost(post, false)
173}
174
175func (b *Blog) prepareAndSetPost(post *Post, edit bool) error {
176 post.Title = strings.TrimSpace(post.Title)
177 post.Body = strings.TrimSpace(post.Body)
178
179 if post.Title == "" {
180 return ErrPostTitleMissing
181 }
182 if post.Body == "" {
183 return ErrPostBodyMissing
184 }
185 if post.Slug == "" {
186 return ErrPostSlugMissing
187 }
188
189 post.Blog = b
190 post.UpdatedAt = time.Now()
191
192 trimmedTitleKey := getTitleKey(post.Title)
193 pubDateKey := getPublishedKey(post.CreatedAt)
194
195 if !edit {
196 // Cannot have two posts with same title key
197 if _, found := b.PostsAlphabetical.Get(trimmedTitleKey); found {
198 return ErrPostTitleExists
199 }
200 // Cannot have two posts with *exact* same timestamp
201 if _, found := b.PostsPublished.Get(pubDateKey); found {
202 return ErrPostPubDateExists
203 }
204 }
205
206 // Store post under keys
207 b.PostsAlphabetical.Set(trimmedTitleKey, post)
208 b.PostsPublished.Set(pubDateKey, post)
209 b.Posts.Set(post.Slug, post)
210
211 return nil
212}
213
214func (b *Blog) RemovePost(slug string) {
215 p, exists := b.Posts.Get(slug)
216 if !exists {
217 panic("post with specified slug doesn't exist")
218 }
219
220 post := p.(*Post)
221
222 titleKey := getTitleKey(post.Title)
223 publishedKey := getPublishedKey(post.CreatedAt)
224
225 _, _ = b.Posts.Remove(slug)
226 _, _ = b.PostsAlphabetical.Remove(titleKey)
227 _, _ = b.PostsPublished.Remove(publishedKey)
228}
229
230func (b *Blog) GetPost(slug string) *Post {
231 post, found := b.Posts.Get(slug)
232 if !found {
233 return nil
234 }
235 return post.(*Post)
236}
237
238type Post struct {
239 Blog *Blog
240 Slug string // FIXME: save space?
241 Title string
242 Body string
243 CreatedAt time.Time
244 UpdatedAt time.Time
245 Comments avl.Tree
246 Authors []string
247 Publisher address
248 Tags []string
249 CommentIndex int
250}
251
252func (p *Post) Update(title, body, publicationDate string, authors, tags []string) error {
253 p.Title = title
254 p.Body = body
255 p.Tags = tags
256 p.Authors = authors
257
258 parsedTime, err := time.Parse(time.RFC3339, publicationDate)
259 if err != nil {
260 return err
261 }
262
263 p.CreatedAt = parsedTime
264 return p.Blog.prepareAndSetPost(p, true)
265}
266
267func (p *Post) AddComment(author address, comment string) error {
268 if p == nil {
269 return ErrNoSuchPost
270 }
271 p.CommentIndex++
272 commentKey := strconv.Itoa(p.CommentIndex)
273 comment = strings.TrimSpace(comment)
274 p.Comments.Set(commentKey, &Comment{
275 Post: p,
276 CreatedAt: time.Now(),
277 Author: author,
278 Comment: comment,
279 })
280
281 return nil
282}
283
284func (p *Post) DeleteComment(index int) error {
285 if p == nil {
286 return ErrNoSuchPost
287 }
288 commentKey := strconv.Itoa(index)
289 p.Comments.Remove(commentKey)
290 return nil
291}
292
293func (p *Post) HasTag(tag string) bool {
294 if p == nil {
295 return false
296 }
297 for _, t := range p.Tags {
298 if t == tag {
299 return true
300 }
301 }
302 return false
303}
304
305func (p *Post) RenderListItem() string {
306 if p == nil {
307 return "error: no such post\n"
308 }
309 output := ufmt.Sprintf("\n### [%s](%s)\n", p.Title, p.URL())
310 // output += ufmt.Sprintf("**[Learn More](%s)**\n\n", p.URL())
311
312 output += p.CreatedAt.Format("02 Jan 2006")
313 // output += p.Summary() + "\n\n"
314 // output += p.RenderTagList() + "\n\n"
315 output += "\n"
316 return output
317}
318
319// Render post tags
320func (p *Post) RenderTagList() string {
321 if p == nil {
322 return "error: no such post\n"
323 }
324 if len(p.Tags) == 0 {
325 return ""
326 }
327
328 output := "Tags: "
329 for idx, tag := range p.Tags {
330 if idx > 0 {
331 output += " "
332 }
333 tagURL := p.Blog.Prefix + "t/" + tag
334 output += ufmt.Sprintf("[#%s](%s)", tag, tagURL)
335
336 }
337 return output
338}
339
340// Render authors if there are any
341func (p *Post) RenderAuthorList() string {
342 out := "Written"
343 if len(p.Authors) != 0 {
344 out += " by "
345
346 for idx, author := range p.Authors {
347 out += author
348 if idx < len(p.Authors)-1 {
349 out += ", "
350 }
351 }
352 }
353 out += " on " + p.CreatedAt.Format("02 Jan 2006")
354
355 return out
356}
357
358func (p *Post) RenderPublishData() string {
359 out := "Published "
360 if p.Publisher != "" {
361 out += "by " + p.Publisher.String() + " "
362 }
363 out += "to " + p.Blog.Title
364
365 return out
366}
367
368func (p *Post) URL() string {
369 if p == nil {
370 return p.Blog.Prefix + "404"
371 }
372 return p.Blog.Prefix + "p/" + p.Slug
373}
374
375func (p *Post) Summary() string {
376 if p == nil {
377 return "error: no such post\n"
378 }
379
380 // FIXME: better summary.
381 lines := strings.Split(p.Body, "\n")
382 if len(lines) <= 3 {
383 return p.Body
384 }
385 return strings.Join(lines[0:3], "\n") + "..."
386}
387
388type Comment struct {
389 Post *Post
390 CreatedAt time.Time
391 Author address
392 Comment string
393}
394
395func (c Comment) RenderListItem() string {
396 output := "<h5>"
397 output += c.Comment + "\n\n"
398 output += "</h5>"
399
400 output += "<h6>"
401 output += ufmt.Sprintf("by %s on %s", c.Author, c.CreatedAt.Format(time.RFC822))
402 output += "</h6>\n\n"
403
404 output += "---\n\n"
405
406 return output
407}