From a1ad26124c23d930fbbc4ca9e374e6ee9390aaa0 Mon Sep 17 00:00:00 2001
From: Naman Sood <mail@nsood.in>
Date: Wed, 22 Jun 2022 23:25:25 -0500
Subject: [PATCH] server: add RSS feed support

Signed-off-by: Naman Sood <mail@nsood.in>
---
 README.md                 |  4 +--
 server.go                 | 64 +++++++++++++++++++++++++++++++++++----
 template.go               | 30 +++++++++++-------
 templates/rss-channel.xml | 10 ++++++
 templates/rss-item.xml    |  7 +++++
 5 files changed, 96 insertions(+), 19 deletions(-)
 create mode 100644 templates/rss-channel.xml
 create mode 100644 templates/rss-item.xml

diff --git a/README.md b/README.md
index 21a643d..6dd1f9e 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,10 @@ Prose is a blogging platform written in Go, which I am building to serve my own
 
 Blog posts should be created in the format `title-slug.md`. Work in progress posts should be stored as `WIP-title-slug.md`. Static content should be stored in the `static/` folder, appropriately arranged.
 
-Posts will be served as `/title-slug`, and files like `static/random/file/structure.txt` will be served as `/random/file/structure.txt`. When title slugs and static files conflict, slugs will have higher precdence.
+Posts will be served as `/title-slug`, and files like `static/random/file/structure.txt` will be served as `/random/file/structure.txt`. When title slugs and static files conflict, slugs will have higher precdence. An RSS feed of the blog is available at `/rss.xml`.
 
 To start the server:
 
 	go run .
 
-Server will be live on port 8080.
\ No newline at end of file
+Server will be live on port 8080.
diff --git a/server.go b/server.go
index d999c87..2df5270 100644
--- a/server.go
+++ b/server.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"strings"
 	"sync"
+	"time"
 
 	"github.com/aymerick/raymond"
 	"github.com/fogleman/gg"
@@ -51,7 +52,15 @@ func newServer() (*server, error) {
 	s.postList = posts
 	s.mu.Unlock()
 
-	tpls, err := loadTemplates([]string{"page", "fullpost", "summary", "notfound", "error"})
+	tpls, err := loadTemplates([]string{
+		"page.html",
+		"fullpost.html",
+		"summary.html",
+		"notfound.html",
+		"error.html",
+		"rss-channel.xml",
+		"rss-item.xml",
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -102,8 +111,8 @@ func (s *server) router(res http.ResponseWriter, req *http.Request) {
 	res = &errorCatcher{
 		res:          res,
 		req:          req,
-		errorTpl:     s.templates["error"],
-		notFoundTpl:  s.templates["notfound"],
+		errorTpl:     s.templates["error.html"],
+		notFoundTpl:  s.templates["notfound.html"],
 		handledError: false,
 	}
 	slug := req.URL.Path[1:]
@@ -116,6 +125,10 @@ func (s *server) router(res http.ResponseWriter, req *http.Request) {
 		s.renderImage(res, req, s.homeImage)
 		return
 	}
+	if slug == "rss.xml" {
+		s.renderRSS(res, req)
+		return
+	}
 
 	for _, p := range s.postList {
 		if slug == p.Slug {
@@ -151,12 +164,12 @@ func (s *server) createWebPage(title, subtitle, contents, path string) (string,
 		"contents": contents,
 		"path":     blogURL + path,
 	}
-	return s.templates["page"].Exec(ctx)
+	return s.templates["page.html"].Exec(ctx)
 }
 
 func (s *server) postPage(p *Post, res http.ResponseWriter, req *http.Request) {
 	res.Header().Add("content-type", "text/html; charset=utf-8")
-	contents, err := p.render(s.templates["fullpost"])
+	contents, err := p.render(s.templates["fullpost.html"])
 	if err != nil {
 		s.errorInRequest(res, req, err)
 	}
@@ -173,7 +186,7 @@ func (s *server) homePage(res http.ResponseWriter, req *http.Request) {
 	var posts string
 
 	for _, p := range s.postList {
-		summary, err := p.render(s.templates["summary"])
+		summary, err := p.render(s.templates["summary.html"])
 		if err != nil {
 			log.Printf("could not render post summary for %s", p.Slug)
 		}
@@ -194,6 +207,41 @@ func (s *server) renderImage(res http.ResponseWriter, req *http.Request, img []b
 	res.Write(img)
 }
 
+func (s *server) renderRSS(res http.ResponseWriter, req *http.Request) {
+	res.Header().Add("content-type", "text/xml; charset=utf-8")
+
+	var posts string
+
+	for _, p := range s.postList {
+		summary, err := p.render(s.templates["rss-item.xml"])
+		if err != nil {
+			log.Printf("could not render post summary for %s", p.Slug)
+		}
+		posts = posts + summary
+	}
+
+	var pubDate string
+	if len(posts) > 0 {
+		pubDate = rssDatetime(s.postList[0].Metadata.Time)
+	} else {
+		pubDate = rssDatetime(0)
+	}
+
+	page, err := s.templates["rss-channel.xml"].Exec(map[string]string{
+		"title":       blogTitle,
+		"description": blogSummary,
+		"link":        blogURL,
+		"pubDate":     pubDate,
+		"items":       posts,
+	})
+
+	if err != nil {
+		s.errorInRequest(res, req, err)
+	}
+
+	res.Write([]byte(page))
+}
+
 func (s *server) loadStylesheet(res http.ResponseWriter, req *http.Request, filename string) (ok bool) {
 	contents, ok := s.styles[filename]
 	if !ok {
@@ -204,6 +252,10 @@ func (s *server) loadStylesheet(res http.ResponseWriter, req *http.Request, file
 	return ok
 }
 
+func rssDatetime(timestamp int64) string {
+	return time.Unix(timestamp, 0).Format("Mon, 02 Jan 06 15:04:05 MST")
+}
+
 func createImage(title, summary, url string, out io.Writer) error {
 	imgWidth, imgHeight, imgPaddingX, imgPaddingY := 1200, 600, 50, 100
 	accentHeight, spacerHeight := 12.5, 20.0
diff --git a/template.go b/template.go
index f2e7f48..5e27568 100644
--- a/template.go
+++ b/template.go
@@ -4,16 +4,15 @@ import (
 	"fmt"
 	"log"
 	"strconv"
-	"strings"
 	"time"
 
 	"github.com/aymerick/raymond"
 )
 
 func loadTemplate(file string) (*raymond.Template, error) {
-	tpl, err := raymond.ParseFile("templates/" + file + ".html")
+	tpl, err := raymond.ParseFile("templates/" + file)
 	if err != nil {
-		return nil, fmt.Errorf("Could not parse %s template: %w", file, err)
+		return nil, fmt.Errorf("could not parse %s template: %w", file, err)
 	}
 	tpl.RegisterHelper("datetime", func(timeStr string) string {
 		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
@@ -23,12 +22,23 @@ func loadTemplate(file string) (*raymond.Template, error) {
 		}
 		return time.Unix(timestamp, 0).Format("Jan 2 2006, 3:04 PM")
 	})
+	tpl.RegisterHelper("rssDatetime", func(timeStr string) string {
+		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
+		if err != nil {
+			log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr)
+			timestamp = time.Now().Unix()
+		}
+		return rssDatetime(timestamp)
+	})
+	tpl.RegisterHelper("getFullUrl", func(slug string) string {
+		return blogURL + "/" + slug
+	})
 	log.Printf("Loaded template: %s", file)
 	return tpl, nil
 }
 
-// loadTemplates, for each f in files, loads `templates/$f.html`
-// as a handlebars HTML template. If any single template fails to
+// loadTemplates, for each f in files, loads `templates/$f`
+// as a handlebars HTML/XML template. If any single template fails to
 // load, only an error is returned. Conversely, if there is no error,
 // every template name passed is guaranteed to have loaded successfully.
 func loadTemplates(files []string) (map[string]*raymond.Template, error) {
@@ -48,22 +58,20 @@ func newTemplateListener(update func(func(map[string]*raymond.Template))) *liste
 	return &listener{
 		folder: "templates/",
 		update: func(file string) error {
-			tplName := strings.TrimSuffix(file, ".html")
-			newTpl, err := loadTemplate(tplName)
+			newTpl, err := loadTemplate(file)
 			if err != nil {
 				return err
 			}
 			update(func(oldMap map[string]*raymond.Template) {
-				oldMap[tplName] = newTpl
+				oldMap[file] = newTpl
 			})
 			return nil
 		},
 		clean: func(file string) error {
-			tplName := strings.TrimSuffix(file, ".html")
 			update(func(oldMap map[string]*raymond.Template) {
-				delete(oldMap, tplName)
+				delete(oldMap, file)
 			})
-			log.Printf("Unloaded template: %s", tplName)
+			log.Printf("Unloaded template: %s", file)
 			return nil
 		},
 	}
diff --git a/templates/rss-channel.xml b/templates/rss-channel.xml
new file mode 100644
index 0000000..c70fdd6
--- /dev/null
+++ b/templates/rss-channel.xml
@@ -0,0 +1,10 @@
+<rss version="2.0">
+    <channel>
+        <title>{{title}}</title>
+        <link>{{link}}</link>
+        <description>{{description}}</description>
+        <language>en-US</language>
+        <pubDate>{{pubDate}}</pubDate>
+        {{{items}}}
+    </channel>
+</rss>
diff --git a/templates/rss-item.xml b/templates/rss-item.xml
new file mode 100644
index 0000000..2cf4242
--- /dev/null
+++ b/templates/rss-item.xml
@@ -0,0 +1,7 @@
+<item>
+    <title>{{metadata.title}}</title>
+    <link>{{getFullUrl slug}}</link>
+    <description>{{metadata.summary}}</description>
+    <author>mail@nsood.in</author>
+    <pubDate>{{rssDatetime metadata.time}}</pubDate>
+</item>