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