Search Apps Documentation Source Content File Folder Download Copy

blog.gno

7.97 Kb ยท 398 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 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}