Skip to content

Commit

Permalink
feat(blog): add sorting, better rendering (gnolang#1541)
Browse files Browse the repository at this point in the history
## Description

This PR adds two new AVL trees that enable sorting of the blog posts by
publication date and title.
This approach utilizes the fact that the AVL tree iteration order is
lexicographic by key. We introduce two new AVL trees into the `Blog`
struct - which store a key to sort by (title or publication date), and
store the pointers to posts, meaning the memory overhead is small.

This PR also modifies the rendering of the blog:

Old blog home page:
![Screenshot 2024-01-23 at 11 34
10](https://github.com/gnolang/gno/assets/33522493/78e197d3-a477-49a9-88d8-06b4f2c3d51d)

New blog home page (sorted newest-top-left):
![Screenshot 2024-01-23 at 11 33
38](https://github.com/gnolang/gno/assets/33522493/16712e37-c971-4cf5-9962-9dbf639a6088)

Old post rendering - header & footer:
![Screenshot 2024-01-23 at 11 34
46](https://github.com/gnolang/gno/assets/33522493/d6a713bb-379c-44b7-9ea7-38997e6b5c5a)

New post rendering - header & footer:
![Screenshot 2024-01-23 at 11 35
03](https://github.com/gnolang/gno/assets/33522493/352ea40d-c9ac-41a0-a362-fb3c5ca62968)
----
![Screenshot 2024-01-23 at 11 35
11](https://github.com/gnolang/gno/assets/33522493/6f21416c-8945-4eca-9bee-b67a9e56a568)


The API changes are the following:
- Each post now stores the publisher (ie caller of ModAddPost), and the
authors.
- It is envisioned that the author list will be a list of GitHub
usernames, while the publisher is simply the address of the caller. A PR
in the Blog repo will be added to accommodate this change.
- If the author list in the frontmatter of the blog post is empty, just
the publisher will be displayed
- The timestamp for when the blog was written will primarily be taken in
from the outside world, ie from the frontmatter, in the `RFC3339`
format. This timestamp allows us to still keep the ordering of the blogs
even when the block height/timestamp is unreliable. In case the
timestamps are not provided, time.Now() in the realm will be used
instead.

Publishing flow - two options:
- Pass in data directly to the blog realm via `ModAddPost`,
- Use the `gnoblog-cli` or similar parsing & publishing tool.
Either way, it is expected that any timestamps passed will be used in
the `time.RFC3339` format.

This PR is being published in pair with a
[refactor](gnolang/blog#63) of the gnoblog-cli
tool.

Related: gnolang/blog#20

<details><summary>Contributors' checklist...</summary>

- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
</details>

---------

Co-authored-by: Manfred Touron <94029+moul@users.noreply.github.com>
  • Loading branch information
leohhhn and moul committed Feb 29, 2024
1 parent dbf86ab commit 496e114
Show file tree
Hide file tree
Showing 19 changed files with 253 additions and 157 deletions.
162 changes: 117 additions & 45 deletions examples/gno.land/p/demo/blog/blog.gno
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package blog

import (
"errors"
"std"
"strconv"
"strings"
Expand All @@ -13,10 +12,12 @@ import (
)

type Blog struct {
Title string
Prefix string // i.e. r/gnoland/blog:
Posts avl.Tree // slug -> Post
NoBreadcrumb bool
Title string
Prefix string // i.e. r/gnoland/blog:
Posts avl.Tree // slug -> *Post
PostsPublished avl.Tree // published-date -> *Post
PostsAlphabetical avl.Tree // title -> *Post
NoBreadcrumb bool
}

func (b Blog) RenderLastPostsWidget(limit int) string {
Expand All @@ -42,7 +43,7 @@ func (b Blog) RenderHome(res *mux.ResponseWriter, req *mux.Request) {
}

res.Write("<div class='columns-3'>")
b.Posts.Iterate("", "", func(key string, value interface{}) bool {
b.PostsPublished.ReverseIterate("", "", func(key string, value interface{}) bool {
post := value.(*Post)
res.Write(post.RenderListItem())
return false
Expand All @@ -62,26 +63,25 @@ func (b Blog) RenderPost(res *mux.ResponseWriter, req *mux.Request) {
}
p := post.(*Post)

if !b.NoBreadcrumb {
breadStr := breadcrumb([]string{
ufmt.Sprintf("[%s](%s)", b.Title, b.Prefix),
"p",
p.Title,
})
res.Write(breadStr)
}

// output += ufmt.Sprintf("## [%s](%s)\n", p.Title, p.URL())
res.Write("# " + p.Title + "\n\n")
res.Write(p.Body + "\n\n")
res.Write("---\n\n")

res.Write(p.RenderTagList() + "\n\n")
res.Write(formatAuthorAndDate(p.Author, p.CreatedAt) + "\n\n")
res.Write(p.RenderAuthorList() + "\n\n")
res.Write(p.RenderPublishData() + "\n\n")

res.Write("---\n")
res.Write("<details><summary>Comment section</summary>\n\n")

// comments
p.Comments.ReverseIterate("", "", func(key string, value interface{}) bool {
comment := value.(*Comment)
res.Write(comment.RenderListItem())
return false
})

res.Write("</details>\n")
}

func (b Blog) RenderTag(res *mux.ResponseWriter, req *mux.Request) {
Expand Down Expand Up @@ -124,42 +124,70 @@ func (b Blog) Render(path string) string {
return router.Render(path)
}

func (b *Blog) NewPost(author std.Address, slug, title, body string, tags []string) error {
_, found := b.Posts.Get(slug)
if found {
return errors.New("slug already exists.")
func (b *Blog) NewPost(publisher std.Address, slug, title, body, pubDate string, authors, tags []string) error {
if _, found := b.Posts.Get(slug); found {
return ErrPostSlugExists
}

post := Post{
Author: author,
var parsedTime time.Time
var err error
if pubDate != "" {
parsedTime, err = time.Parse(time.RFC3339, pubDate)
if err != nil {
return err
}
} else {
// If no publication date was passed in by caller, take current block time
parsedTime = time.Now()
}

post := &Post{
Publisher: publisher,
Authors: authors,
Slug: slug,
Title: title,
Body: body,
Tags: tags,
CreatedAt: time.Now(),
CreatedAt: parsedTime,
}
return b.prepareAndSetPost(&post)

return b.prepareAndSetPost(post)
}

func (b *Blog) prepareAndSetPost(post *Post) error {
post.Title = strings.TrimSpace(post.Title)
post.Body = strings.TrimSpace(post.Body)

if post.Title == "" {
return errors.New("title is missing.")
return ErrPostTitleMissing
}
if post.Body == "" {
return errors.New("body is missing.")
return ErrPostBodyMissing
}
if post.Slug == "" {
return errors.New("slug is missing.")
return ErrPostSlugMissing
}
// more input sanitization?

post.Blog = b
post.UpdatedAt = time.Now()

trimmedTitleKey := strings.Replace(post.Title, " ", "", -1)
pubDateKey := post.CreatedAt.Format(time.RFC3339)

// Cannot have two posts with same title key
if _, found := b.PostsAlphabetical.Get(trimmedTitleKey); found {
return ErrPostTitleExists
}
// Cannot have two posts with *exact* same timestamp
if _, found := b.PostsPublished.Get(pubDateKey); found {
return ErrPostPubDateExists
}

// Store post under keys
b.PostsAlphabetical.Set(trimmedTitleKey, post)
b.PostsPublished.Set(pubDateKey, post)
b.Posts.Set(post.Slug, post)

return nil
}

Expand All @@ -179,15 +207,24 @@ type Post struct {
CreatedAt time.Time
UpdatedAt time.Time
Comments avl.Tree
Author std.Address
Authors []string
Publisher std.Address
Tags []string
CommentIndex int
}

func (p *Post) Update(title, body string, tags []string) error {
func (p *Post) Update(title, body, publicationDate string, authors, tags []string) error {
p.Title = title
p.Body = body
p.Tags = tags
p.Authors = authors

parsedTime, err := time.Parse(time.RFC3339, publicationDate)
if err != nil {
return err
}

p.CreatedAt = parsedTime
return p.Blog.prepareAndSetPost(p)
}

Expand Down Expand Up @@ -234,31 +271,66 @@ func (p *Post) RenderListItem() string {
return "error: no such post\n"
}
output := "<div>\n\n"
output += ufmt.Sprintf("## [%s](%s)\n", p.Title, p.URL())
output += ufmt.Sprintf("**[Learn More](%s)**\n", p.URL())
output += ufmt.Sprintf("### [%s](%s)\n", p.Title, p.URL())
// output += ufmt.Sprintf("**[Learn More](%s)**\n\n", p.URL())

output += " " + p.CreatedAt.Format("02 Jan 2006")
// output += p.Summary() + "\n\n"
// output += p.RenderTagList() + "\n\n"
// output += formatAuthorAndDate(p.Author, p.CreatedAt) + "\n"
output += "\n"
output += "</div>"
return output
}

// Render post tags
func (p *Post) RenderTagList() string {
if p == nil {
return "error: no such post\n"
}
output := ""
if len(p.Tags) == 0 {
return ""
}

output := "Tags: "
for idx, tag := range p.Tags {
if idx > 0 {
output += " "
}
tagURL := p.Blog.Prefix + "t/" + tag
output += ufmt.Sprintf("[#%s](%s)", tag, tagURL)

}
return output
}

// Render authors if there are any
func (p *Post) RenderAuthorList() string {
out := "Written"
if len(p.Authors) != 0 {
out += " by "

for idx, author := range p.Authors {
out += author
if idx < len(p.Authors)-1 {
out += ", "
}
}
}
out += " on " + p.CreatedAt.Format("02 Jan 2006")

return out
}

func (p *Post) RenderPublishData() string {
out := "Published "
if p.Publisher != "" {
out += "by " + p.Publisher.String() + " "
}
out += "to " + p.Blog.Title

return out
}

func (p *Post) URL() string {
if p == nil {
return p.Blog.Prefix + "404"
Expand Down Expand Up @@ -287,15 +359,15 @@ type Comment struct {
}

func (c Comment) RenderListItem() string {
output := ""
output += ufmt.Sprintf("#### %s\n", formatAuthorAndDate(c.Author, c.CreatedAt))
output += c.Comment + "\n"
output += "\n"
return output
}
output := "<h5>"
output += c.Comment + "\n\n"
output += "</h5>"

output += "<h6>"
output += ufmt.Sprintf("by %s on %s", c.Author, c.CreatedAt.Format(time.RFC822))
output += "</h6>\n\n"

func formatAuthorAndDate(author std.Address, createdAt time.Time) string {
authorString := author.String() // FIXME: username.
createdAtString := createdAt.Format("2006-01-02 3:04pm MST")
return ufmt.Sprintf("by %s on %s", authorString, createdAtString)
output += "---\n\n"

return output
}
10 changes: 9 additions & 1 deletion examples/gno.land/p/demo/blog/errors.gno
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@ package blog

import "errors"

var ErrNoSuchPost = errors.New("no such post")
var (
ErrPostTitleMissing = errors.New("post title is missing")
ErrPostSlugMissing = errors.New("post slug is missing")
ErrPostBodyMissing = errors.New("post body is missing")
ErrPostSlugExists = errors.New("post with specified slug already exists")
ErrPostPubDateExists = errors.New("post with specified publication date exists")
ErrPostTitleExists = errors.New("post with specified title already exists")
ErrNoSuchPost = errors.New("no such post")
)
22 changes: 17 additions & 5 deletions examples/gno.land/r/gnoland/blog/admin.gno
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,32 @@ func AdminRemoveModerator(addr std.Address) {
moderatorList.Set(addr.String(), false) // FIXME: delete instead?
}

func ModAddPost(slug, title, body, tags string) {
func ModAddPost(slug, title, body, publicationDate, authors, tags string) {
assertIsModerator()

caller := std.GetOrigCaller()
tagList := strings.Split(tags, ",")
err := b.NewPost(caller, slug, title, body, tagList)

var tagList []string
if tags != "" {
tagList = strings.Split(tags, ",")
}
var authorList []string
if authors != "" {
authorList = strings.Split(authors, ",")
}

err := b.NewPost(caller, slug, title, body, publicationDate, authorList, tagList)

checkErr(err)
}

func ModEditPost(slug, title, body, tags string) {
func ModEditPost(slug, title, body, publicationDate, authors, tags string) {
assertIsModerator()

tagList := strings.Split(tags, ",")
err := b.GetPost(slug).Update(title, body, tagList)
authorList := strings.Split(authors, ",")

err := b.GetPost(slug).Update(title, body, publicationDate, authorList, tagList)
checkErr(err)
}

Expand Down
7 changes: 7 additions & 0 deletions examples/gno.land/r/gnoland/blog/gnoblog.gno
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ func Render(path string) string {
func RenderLastPostsWidget(limit int) string {
return b.RenderLastPostsWidget(limit)
}

func PostExists(slug string) bool {
if b.GetPost(slug) == nil {
return false
}
return true
}
Loading

0 comments on commit 496e114

Please sign in to comment.