Added atom feed and a few other modifications

This commit is contained in:
Asaf Gartner 2021-05-30 21:35:01 +03:00
parent 60b5d07d00
commit 5d9b628144
15 changed files with 327 additions and 74 deletions

View File

@ -18,6 +18,16 @@ type Q struct {
Value string
}
func QFromURL(u *url.URL) []Q {
var result []Q
for key, values := range u.Query() {
for _, v := range values {
result = append(result, Q{Name: key, Value: v})
}
}
return result
}
var baseUrlParsed url.URL
var cacheBust string
var isTest bool

View File

@ -50,6 +50,8 @@ func TestSiteMap(t *testing.T) {
func TestAtomFeed(t *testing.T) {
AssertRegexMatch(t, BuildAtomFeed(), RegexAtomFeed, nil)
AssertRegexMatch(t, BuildAtomFeedForProjects(), RegexAtomFeed, map[string]string{"feedtype": "projects"})
AssertRegexMatch(t, BuildAtomFeedForShowcase(), RegexAtomFeed, map[string]string{"feedtype": "showcase"})
}
func TestLoginAction(t *testing.T) {

View File

@ -57,13 +57,6 @@ func BuildSiteMap() string {
return Url("/sitemap", nil)
}
var RegexAtomFeed = regexp.MustCompile("^/atom$")
func BuildAtomFeed() string {
defer CatchPanic()
return Url("/atom", nil)
}
// QUESTION(ben): Can we change these routes?
var RegexLoginAction = regexp.MustCompile("^/login$")
@ -176,6 +169,23 @@ func BuildFeedWithPage(page int) string {
return Url("/feed/"+strconv.Itoa(page), nil)
}
var RegexAtomFeed = regexp.MustCompile("^/atom(/(?P<feedtype>.+))?$")
func BuildAtomFeed() string {
defer CatchPanic()
return Url("/atom", nil)
}
func BuildAtomFeedForProjects() string {
defer CatchPanic()
return Url("/atom/projects", nil)
}
func BuildAtomFeedForShowcase() string {
defer CatchPanic()
return Url("/atom/showcase", nil)
}
/*
* Podcast
*/

View File

@ -46,14 +46,16 @@ var ZerologStackMarshaler = func(err error) interface{} {
if asOops, ok := err.(*Error); ok {
return asOops.Stack
}
return nil
// NOTE(asaf): If we got here, it means zerolog is trying to output a non-oops error.
// We remove this call and the zerolog caller from the stack.
return Trace()[2:]
}
func New(wrapped error, format string, args ...interface{}) error {
return &Error{
Message: fmt.Sprintf(format, args...),
Wrapped: wrapped,
Stack: Trace(),
Stack: Trace()[1:], // NOTE(asaf): Remove the call to New from the stack
}
}

View File

@ -102,7 +102,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
Blurb: u.Blurb,
Signature: u.Signature,
AvatarUrl: avatar,
ProfileUrl: hmnurl.Url("m/"+u.Username, nil),
ProfileUrl: hmnurl.BuildMember(u.Username),
DarkTheme: u.DarkTheme,
Timezone: u.Timezone,

View File

@ -0,0 +1,34 @@
{{ noescape "<?xml version=\"1.0\" encoding=\"utf-8\"?>" }}
<feed xmlns="http://www.w3.org/2005/Atom">
<title type="text">{{ .Title }}</title>
<subtitle type="html">{{ .Subtitle }}</subtitle>
<link href="{{ .HomepageUrl }}"/>
<link rel="self" type="application/atom+xml" href="{{ .AtomFeedUrl }}"/>
<link rel="alternate" type="text/html" hreflang="en" href="{{ .FeedUrl }}"/>
<rights type="html">{{ .CopyrightStatement }}</rights>
<generator uri="{{ .HomepageUrl }}" version="{{ .SiteVersion }}">Handmade Network site engine v{{ .SiteVersion }}</generator>
<updated>{{ rfc3339 .Updated }}</updated>
<id>{{ .FeedID }}</id>
{{ if .Posts }}
{{ range .Posts }}
<entry>
<title>{{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }}</title>
<link rel="alternate" type="text/html" href="{{ .Url }}" />
<id>{{ .UUID }}</id>
<published>{{ rfc3339 .Date }}</published>
<updated>{{ rfc3339 .LastEditDate }}</updated>
<author>
<name>{{ .User.Name }}</name>
<uri>{{ .User.ProfileUrl }}</uri>
</author>
<summary type="html">{{ .Preview }}</summary>
</entry>
{{ end }}
{{ else if .Projects }}
{{ range .Projects }}
{{ end }}
{{ else if .Snippets }}
{{ range .Snippets }}
{{ end }}
{{ end }}
</feed>

View File

@ -20,7 +20,29 @@
</div>
</div>
{{ range .Posts }}
{{ template "post_list_item.html" . }}
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden">
<div class="breadcrumbs">
{{ range $i, $breadcrumb := .Breadcrumbs }}
{{ if gt $i 0 }} » {{ end }}
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
{{ end }}
</div>
<div class="title nowrap truncate"><a href="{{ .Url }}" title="{{ .Preview }}">{{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
{{ with .Preview }}
<div class="mt1">
{{ noescape . }}
</div>
{{ end }}
</div>
<div class="goto">
<a href="{{ .Url }}">&raquo;</a>
</div>
</div>
{{ end }}
<div class="optionbar bottom">
<div>

View File

@ -13,15 +13,10 @@ It should be called with PostListItem.
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
{{ end }}
</div>
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
<div class="title nowrap truncate"><a href="{{ .Url }}" title="{{ .Preview }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
{{ with .Content }}
<div class="mt2">
{{ . }}
</div>
{{ end }}
</div>
<div class="goto">
<a href="{{ .Url }}">&raquo;</a>

View File

@ -104,7 +104,7 @@
</div>
{{ end }}
{{/*
{{/* TODO(asaf): Delete this section once we're done with the landing page
{% block columns %}
{% include "showcase/js_templates.html" %}
{% include "timeline/js_templates.html" %}
@ -268,7 +268,7 @@
{{/* Call this template with a LandingPageFeaturedPost. */}}
<div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1">
<div class="flex-grow-1 overflow-hidden">
<div class="title mb1"><a href="{{ .Url }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
@ -277,7 +277,7 @@
<div>
{{ .Content }}
</div>
<div class="excerpt-fade absolute w-100 h4 bottom-0"></div>
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
</div>
<div class="mt2">
<a href="{{ .Url }}">Read More &rarr;</a>
@ -285,3 +285,25 @@
</div>
</div>
{{ end }}
{{ define "post_list_item" }}
{{/* Call this template with a PostListItem. */}}
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden">
<div class="breadcrumbs">
{{ range $i, $breadcrumb := .Breadcrumbs }}
{{ if gt $i 0 }} » {{ end }}
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
{{ end }}
</div>
<div class="title nowrap truncate"><a href="{{ .Url }}" title="{{ .Preview }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
</div>
<div class="goto">
<a href="{{ .Url }}">&raquo;</a>
</div>
</div>
{{ end }}

View File

@ -4,7 +4,6 @@ import (
"embed"
"fmt"
"html/template"
"net/url"
"strings"
"time"
@ -25,11 +24,7 @@ const (
var templateFs embed.FS
var Templates map[string]*template.Template
var cachebust string
func Init() {
cachebust = fmt.Sprint(time.Now().Unix())
Templates = make(map[string]*template.Template)
files, _ := templateFs.ReadDir("src")
@ -53,6 +48,16 @@ func Init() {
logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template")
}
Templates[f.Name()] = t
} else if strings.HasSuffix(f.Name(), ".xml") {
t := template.New(f.Name())
t = t.Funcs(sprig.FuncMap())
t = t.Funcs(HMNTemplateFuncs)
t, err := t.ParseFS(templateFs, "src/"+f.Name())
if err != nil {
logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template")
}
Templates[f.Name()] = t
}
}
@ -68,7 +73,10 @@ func names(ts []*template.Template) []string {
var HMNTemplateFuncs = template.FuncMap{
"absolutedate": func(t time.Time) string {
return t.Format("January 2, 2006, 3:04pm")
return t.UTC().Format("January 2, 2006, 3:04pm")
},
"rfc3339": func(t time.Time) string {
return t.UTC().Format(time.RFC3339)
},
"alpha": func(alpha float64, color noire.Color) noire.Color {
color.Alpha = alpha
@ -77,9 +85,6 @@ var HMNTemplateFuncs = template.FuncMap{
"brighten": func(amount float64, color noire.Color) noire.Color {
return color.Tint(amount)
},
"cachebust": func() string {
return cachebust
},
"color2css": func(color noire.Color) template.CSS {
return template.CSS(color.HTML())
},
@ -96,20 +101,6 @@ var HMNTemplateFuncs = template.FuncMap{
h, s, _, a := color.HSLA()
return noire.NewHSLA(h, s, lightness*100, a)
},
"projecturl": func(url string, proj interface{}) string {
return hmnurl.ProjectUrl(url, nil, getProjectSubdomain(proj))
},
"projecturlq": func(url string, proj interface{}, query string) string {
absUrl := hmnurl.ProjectUrl(url, nil, getProjectSubdomain(proj))
return fmt.Sprintf("%s?%s", absUrl, query)
},
"query": func(args ...string) string {
query := url.Values{}
for i := 0; i < len(args); i += 2 {
query.Set(args[i], args[i+1])
}
return query.Encode()
},
"relativedate": func(t time.Time) string {
// TODO: Support relative future dates
@ -161,9 +152,12 @@ var HMNTemplateFuncs = template.FuncMap{
return hmnurl.BuildTheme(filepath, theme, false)
},
"timehtml": func(formatted string, t time.Time) template.HTML {
iso := t.Format(time.RFC3339)
iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time datetime="%s">%s</time>`, iso, formatted))
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
}
type ErrInvalidHexColor struct {

View File

@ -135,18 +135,37 @@ type BackgroundImage struct {
Size string // A valid CSS background-size value
}
type PostType int
const (
PostTypeUnknown PostType = iota
PostTypeBlogPost
PostTypeBlogComment
PostTypeForumThread
PostTypeForumReply
PostTypeWikiCreate
PostTypeWikiTalk
PostTypeWikiEdit
PostTypeLibraryComment
)
// Data from post_list_item.html
type PostListItem struct {
Title string
Url string
UUID string
Breadcrumbs []Breadcrumb
PostType PostType
PostTypePrefix string
User User
Date time.Time
Unread bool
Classes string
Content string
Unread bool
Classes string
Preview string
LastEditDate time.Time
}
// Data from thread_list_item.html

View File

@ -1,11 +1,15 @@
package website
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
@ -76,9 +80,123 @@ func Feed(c *RequestContext) ResponseData {
currentUserId = &c.CurrentUser.ID
}
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts"))
}
baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "feed")
var res ResponseData
res.WriteTemplate("feed.html", FeedData{
BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildMarkRead(0),
Posts: posts,
Pagination: pagination,
}, c.Perf)
return res
}
type FeedType int
const (
FeedTypeAll = iota
FeedTypeProjects
FeedTypeShowcase
)
// NOTE(asaf): UUID values copied from old website
var (
FeedIDAll = "urn:uuid:1084fd28-993a-4961-9011-39ddeaeb3711"
FeedIDProjects = "urn:uuid:cfad0d50-cbcf-11e7-82d7-db1d52543cc7"
FeedIDShowcase = "urn:uuid:37d29027-2892-5a21-b521-951246c7aa46"
)
type AtomFeedData struct {
Title string
Subtitle string
HomepageUrl string
AtomFeedUrl string
FeedUrl string
CopyrightStatement string
SiteVersion string
Updated time.Time
FeedID string
FeedType FeedType
Posts []templates.PostListItem
Projects []int // TODO(asaf): Actually do this
Snippets []int // TODO(asaf): Actually do this
}
func AtomFeed(c *RequestContext) ResponseData {
itemsPerFeed := 25 // NOTE(asaf): Copied from old website
feedData := AtomFeedData{
HomepageUrl: hmnurl.BuildHomepage(),
CopyrightStatement: fmt.Sprintf("Copyright (C) 2014-%d Handmade.Network and its contributors", time.Now().Year()),
SiteVersion: "2.0",
}
feedType, hasType := c.PathParams["feedtype"]
if !hasType || len(feedType) == 0 {
feedData.Title = "New Threads, Blog Posts, Replies and Comments | Site-wide | Handmade.Network"
feedData.Subtitle = feedData.Title
feedData.FeedType = FeedTypeAll
feedData.FeedID = FeedIDAll
feedData.AtomFeedUrl = hmnurl.BuildAtomFeed()
feedData.FeedUrl = hmnurl.BuildFeed()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts"))
}
feedData.Posts = posts
updated := time.Now()
if len(posts) > 0 {
updated = posts[0].Date
}
feedData.Updated = updated
} else {
switch strings.ToLower(feedType) {
case "projects":
// TODO(asaf): Implement this
case "showcase":
// TODO(asaf): Implement this
default:
return FourOhFour(c)
}
}
var res ResponseData
res.WriteTemplate("atom.xml", feedData, c.Perf)
return res
}
func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) {
c.Perf.StartBlock("SQL", "Fetch posts")
type feedPostQuery struct {
Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"version"`
Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"`
Proj models.Project `db:"proj"`
@ -92,6 +210,7 @@ func Feed(c *RequestContext) ResponseData {
SELECT $columns
FROM
handmade_post AS post
JOIN handmade_postversion AS version ON version.id = post.current_id
JOIN handmade_thread AS thread ON thread.id = post.thread_id
JOIN handmade_category AS cat ON cat.id = post.category_id
JOIN handmade_project AS proj ON proj.id = post.project_id
@ -112,21 +231,16 @@ func Feed(c *RequestContext) ResponseData {
ORDER BY postdate DESC
LIMIT $3 OFFSET $4
`,
currentUserId,
currentUserID,
[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource},
postsPerPage,
howManyPostsToSkip,
limit,
offset,
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts"))
return nil, err
}
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
c.Perf.StartBlock("FEED", "Build post items")
var postItems []templates.PostListItem
for _, iPostResult := range posts.ToSlice() {
@ -139,7 +253,7 @@ func Feed(c *RequestContext) ResponseData {
hasRead = true
}
postItems = append(postItems, MakePostListItem(
postItem := MakePostListItem(
lineageBuilder,
&postResult.Proj,
&postResult.Thread,
@ -149,22 +263,14 @@ func Feed(c *RequestContext) ResponseData {
!hasRead,
true,
c.Theme,
))
)
postItem.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(postItem.Url)).URN()
postItem.LastEditDate = postResult.PostVersion.EditDate
postItems = append(postItems, postItem)
}
c.Perf.EndBlock()
baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "feed")
var res ResponseData
res.WriteTemplate("feed.html", FeedData{
BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildMarkRead(0),
Posts: postItems,
Pagination: pagination,
}, c.Perf)
return res
return postItems, nil
}

View File

@ -29,6 +29,24 @@ func UrlForGenericPost(post *models.Post, subforums []string, threadTitle string
return hmnurl.BuildProjectHomepage(projectSlug)
}
var PostTypeMap = map[models.CategoryKind][]templates.PostType{
models.CatKindBlog: []templates.PostType{templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.CatKindForum: []templates.PostType{templates.PostTypeForumThread, templates.PostTypeForumReply},
models.CatKindWiki: []templates.PostType{templates.PostTypeWikiCreate, templates.PostTypeWikiTalk},
models.CatKindLibraryResource: []templates.PostType{templates.PostTypeLibraryComment, templates.PostTypeLibraryComment},
}
var PostTypePrefix = map[templates.PostType]string{
templates.PostTypeBlogPost: "New blog post",
templates.PostTypeBlogComment: "Blog comment",
templates.PostTypeForumThread: "New forum thread",
templates.PostTypeForumReply: "Forum reply",
templates.PostTypeWikiCreate: "New wiki page",
templates.PostTypeWikiTalk: "Wiki comment",
templates.PostTypeWikiEdit: "Wiki edit",
templates.PostTypeLibraryComment: "Library comment",
}
// NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts.
func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem {
var result templates.PostListItem
@ -42,6 +60,19 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo
libraryResourceId = libraryResource.ID
}
result.Url = UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug)
result.Preview = post.Preview
postType := templates.PostTypeUnknown
postTypeOptions, found := PostTypeMap[post.CategoryKind]
if found {
var hasParent int
if post.ParentID != nil {
hasParent = 1
}
postType = postTypeOptions[hasParent]
}
result.PostType = postType
result.PostTypePrefix = PostTypePrefix[result.PostType]
if includeBreadcrumbs {
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{

View File

@ -14,6 +14,7 @@ import (
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4/pgxpool"
@ -249,7 +250,11 @@ func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.Re
rp.StartBlock("TEMPLATE", name)
defer rp.EndBlock()
}
return templates.Templates[name].Execute(rd, data)
template, hasTemplate := templates.Templates[name]
if !hasTemplate {
panic(oops.New(nil, "Template not found: %s", name))
}
return template.Execute(rd, data)
}
func ErrorResponse(status int, errs ...error) ResponseData {

View File

@ -73,7 +73,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
}
if !c.CurrentProject.IsHMN() {
return c.Redirect(hmnurl.Url(c.URL().String(), nil), http.StatusMovedPermanently)
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
}
return h(c)
@ -104,6 +104,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
// TODO(asaf): Trailing slashes break these
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)