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}