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