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}