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