1package blog23import (4 "strconv"5 "strings"6 "time"78 "gno.land/p/nt/avl/v0"9 "gno.land/p/nt/mux/v0"10 "gno.land/p/nt/ufmt/v0"11)1213type Blog struct {14 Title string15 Prefix string // i.e. r/gnoland/blog:16 Posts avl.Tree // slug -> *Post17 PostsPublished avl.Tree // published-date -> *Post18 PostsAlphabetical avl.Tree // title -> *Post19 NoBreadcrumb bool20}2122func (b Blog) RenderLastPostsWidget(limit int) string {23 if b.PostsPublished.Size() == 0 {24 return "No posts."25 }2627 output := ""28 i := 029 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 >= limit34 })35 return output36}3738func (b Blog) RenderHome(res *mux.ResponseWriter, _ *mux.Request) {39 if !b.NoBreadcrumb {40 res.Write(breadcrumb([]string{b.Title}))41 }4243 if b.Posts.Size() == 0 {44 res.Write("No posts.")45 return46 }4748 const maxCol = 349 var rowItems []string5051 b.PostsPublished.ReverseIterate("", "", func(key string, value any) bool {52 post := value.(*Post)53 rowItems = append(rowItems, post.RenderListItem())5455 if len(rowItems) == maxCol {56 res.Write("<gno-columns>" + strings.Join(rowItems, "<gno-columns-sep>") + "</gno-columns>\n")57 rowItems = []string{}58 }59 return false60 })6162 // Pad and flush any remaining items63 if len(rowItems) > 0 {64 for len(rowItems) < maxCol {65 rowItems = append(rowItems, "")66 }67 res.Write("<gno-columns>" + strings.Join(rowItems, "\n<gno-columns-sep>\n") + "</gno-columns>\n")68 }69}7071func (b Blog) RenderPost(res *mux.ResponseWriter, req *mux.Request) {72 slug := req.GetVar("slug")7374 post := b.Posts.Get(slug)75 if post == nil {76 res.Write("404")77 return78 }79 p := post.(*Post)8081 res.Write("<main class='gno-tmpl-page'>" + "\n\n")8283 res.Write("# " + p.Title + "\n\n")84 res.Write(p.Body + "\n\n")85 res.Write("---\n\n")8687 res.Write(p.RenderTagList() + "\n\n")88 res.Write(p.RenderAuthorList() + "\n\n")89 res.Write(p.RenderPublishData() + "\n\n")9091 res.Write("---\n")92 res.Write("<details><summary>Comment section</summary>\n\n")9394 // comments95 p.Comments.ReverseIterate("", "", func(key string, value any) bool {96 comment := value.(*Comment)97 res.Write(comment.RenderListItem())98 return false99 })100101 res.Write("</details>\n")102 res.Write("</main>")103}104105func (b Blog) RenderTag(res *mux.ResponseWriter, req *mux.Request) {106 slug := req.GetVar("slug")107108 if slug == "" {109 res.Write("404")110 return111 }112113 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 }121122 nb := 0123 b.Posts.Iterate("", "", func(key string, value any) bool {124 post := value.(*Post)125 if !post.HasTag(slug) {126 return false127 }128 res.Write(post.RenderListItem())129 nb++130 return false131 })132 if nb == 0 {133 res.Write("No posts.")134 }135}136137func (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}144145func (b *Blog) NewPost(publisher address, slug, title, body, pubDate string, authors, tags []string) error {146 if b.Posts.Has(slug) {147 return ErrPostSlugExists148 }149150 var parsedTime time.Time151 var err error152 if pubDate != "" {153 parsedTime, err = time.Parse(time.RFC3339, pubDate)154 if err != nil {155 return err156 }157 } else {158 // If no publication date was passed in by caller, take current block time159 parsedTime = time.Now()160 }161162 post := &Post{163 Publisher: publisher,164 Authors: authors,165 Slug: slug,166 Title: title,167 Body: body,168 Tags: tags,169 CreatedAt: parsedTime,170 }171172 return b.prepareAndSetPost(post, false)173}174175func (b *Blog) prepareAndSetPost(post *Post, edit bool) error {176 post.Title = strings.TrimSpace(post.Title)177 post.Body = strings.TrimSpace(post.Body)178179 if post.Title == "" {180 return ErrPostTitleMissing181 }182 if post.Body == "" {183 return ErrPostBodyMissing184 }185 if post.Slug == "" {186 return ErrPostSlugMissing187 }188189 post.Blog = b190 post.UpdatedAt = time.Now()191192 trimmedTitleKey := getTitleKey(post.Title)193 pubDateKey := getPublishedKey(post.CreatedAt)194195 if !edit {196 // Cannot have two posts with same title key197 if b.PostsAlphabetical.Has(trimmedTitleKey) {198 return ErrPostTitleExists199 }200 // Cannot have two posts with *exact* same timestamp201 if b.PostsPublished.Has(pubDateKey) {202 return ErrPostPubDateExists203 }204 }205206 // Store post under keys207 b.PostsAlphabetical.Set(trimmedTitleKey, post)208 b.PostsPublished.Set(pubDateKey, post)209 b.Posts.Set(post.Slug, post)210211 return nil212}213214func (b *Blog) RemovePost(slug string) {215 p := b.Posts.Get(slug)216 if p == nil {217 panic("post with specified slug doesn't exist")218 }219220 post := p.(*Post)221222 titleKey := getTitleKey(post.Title)223 publishedKey := getPublishedKey(post.CreatedAt)224225 _, _ = b.Posts.Remove(slug)226 _, _ = b.PostsAlphabetical.Remove(titleKey)227 _, _ = b.PostsPublished.Remove(publishedKey)228}229230func (b *Blog) GetPost(slug string) *Post {231 post := b.Posts.Get(slug)232 if post == nil {233 return nil234 }235 return post.(*Post)236}237238type Post struct {239 Blog *Blog240 Slug string // FIXME: save space?241 Title string242 Body string243 CreatedAt time.Time244 UpdatedAt time.Time245 Comments avl.Tree246 Authors []string247 Publisher address248 Tags []string249 CommentIndex int250}251252func (p *Post) Update(title, body, publicationDate string, authors, tags []string) error {253 p.Title = title254 p.Body = body255 p.Tags = tags256 p.Authors = authors257258 parsedTime, err := time.Parse(time.RFC3339, publicationDate)259 if err != nil {260 return err261 }262263 p.CreatedAt = parsedTime264 return p.Blog.prepareAndSetPost(p, true)265}266267func (p *Post) AddComment(author address, comment string) error {268 if p == nil {269 return ErrNoSuchPost270 }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 })280281 return nil282}283284func (p *Post) DeleteComment(index int) error {285 if p == nil {286 return ErrNoSuchPost287 }288 commentKey := strconv.Itoa(index)289 p.Comments.Remove(commentKey)290 return nil291}292293func (p *Post) HasTag(tag string) bool {294 if p == nil {295 return false296 }297 for _, t := range p.Tags {298 if t == tag {299 return true300 }301 }302 return false303}304305func (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())311312 output += p.CreatedAt.Format("02 Jan 2006")313 // output += p.Summary() + "\n\n"314 // output += p.RenderTagList() + "\n\n"315 output += "\n"316 return output317}318319// Render post tags320func (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 }327328 output := "Tags: "329 for idx, tag := range p.Tags {330 if idx > 0 {331 output += " "332 }333 tagURL := p.Blog.Prefix + "t/" + tag334 output += ufmt.Sprintf("[#%s](%s)", tag, tagURL)335336 }337 return output338}339340// Render authors if there are any341func (p *Post) RenderAuthorList() string {342 out := "Written"343 if len(p.Authors) != 0 {344 out += " by "345346 for idx, author := range p.Authors {347 out += author348 if idx < len(p.Authors)-1 {349 out += ", "350 }351 }352 }353 out += " on " + p.CreatedAt.Format("02 Jan 2006")354355 return out356}357358func (p *Post) RenderPublishData() string {359 out := "Published "360 if p.Publisher != "" {361 out += "by " + p.Publisher.String() + " "362 }363 out += "to " + p.Blog.Title364365 return out366}367368func (p *Post) URL() string {369 if p == nil {370 return p.Blog.Prefix + "404"371 }372 return p.Blog.Prefix + "p/" + p.Slug373}374375func (p *Post) Summary() string {376 if p == nil {377 return "error: no such post\n"378 }379380 // FIXME: better summary.381 lines := strings.Split(p.Body, "\n")382 if len(lines) <= 3 {383 return p.Body384 }385 return strings.Join(lines[0:3], "\n") + "..."386}387388type Comment struct {389 Post *Post390 CreatedAt time.Time391 Author address392 Comment string393}394395func (c Comment) RenderListItem() string {396 output := "<h5>"397 output += c.Comment + "\n\n"398 output += "</h5>"399400 output += "<h6>"401 output += ufmt.Sprintf("by %s on %s", c.Author, c.CreatedAt.Format(time.RFC822))402 output += "</h6>\n\n"403404 output += "---\n\n"405406 return output407}408Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.