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}