316 lines
7.1 KiB
Go
316 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aymerick/raymond"
|
|
"github.com/fogleman/gg"
|
|
)
|
|
|
|
const (
|
|
blogTitle = "Prose"
|
|
blogURL = "https://prose.nsood.in"
|
|
blogSummary = "Where I infodump in Markdown and nobody can stop me."
|
|
)
|
|
|
|
type server struct {
|
|
staticHandler http.Handler
|
|
|
|
mu sync.RWMutex
|
|
templates map[string]*raymond.Template
|
|
postList
|
|
styles map[string]string
|
|
homeImage []byte
|
|
}
|
|
|
|
func newServer() (*server, error) {
|
|
s := &server{
|
|
staticHandler: http.FileServer(http.Dir("static/")),
|
|
}
|
|
|
|
var imgBuffer bytes.Buffer
|
|
err := createImage(blogTitle, blogSummary, blogURL, &imgBuffer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.homeImage, err = io.ReadAll(&imgBuffer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
posts, err := newPostList()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.postList = posts
|
|
s.mu.Unlock()
|
|
|
|
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
|
|
}
|
|
s.mu.Lock()
|
|
s.templates = tpls
|
|
s.mu.Unlock()
|
|
|
|
styles, err := newStylesMap()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.styles = styles
|
|
s.mu.Unlock()
|
|
|
|
postsLn := newPostListener(func(updateFn func(postList) postList) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.postList = updateFn(s.postList)
|
|
})
|
|
go postsLn.listen()
|
|
|
|
templatesLn := newTemplateListener(func(updateFn func(map[string]*raymond.Template)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
updateFn(s.templates)
|
|
})
|
|
go templatesLn.listen()
|
|
|
|
stylesLn := newStylesListener(func(updateFn func(map[string]string)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
updateFn(s.styles)
|
|
})
|
|
go stylesLn.listen()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *server) logRequest(req *http.Request) {
|
|
log.Printf("%s %s from %s", req.Method, req.URL.Path, req.RemoteAddr)
|
|
}
|
|
|
|
func (s *server) router(res http.ResponseWriter, req *http.Request) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
s.logRequest(req)
|
|
res = &errorCatcher{
|
|
res: res,
|
|
req: req,
|
|
errorTpl: s.templates["error.html"],
|
|
notFoundTpl: s.templates["notfound.html"],
|
|
handledError: false,
|
|
}
|
|
slug := req.URL.Path[1:]
|
|
|
|
if slug == "" {
|
|
s.homePage(res, req)
|
|
return
|
|
}
|
|
if slug == "about.png" {
|
|
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 {
|
|
s.postPage(p, res, req)
|
|
return
|
|
} else if slug == p.Slug+"/about.png" {
|
|
s.renderImage(res, req, p.Image)
|
|
return
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(slug, "css/") {
|
|
filename := strings.TrimPrefix(slug, "css/")
|
|
ok := s.loadStylesheet(res, req, filename)
|
|
if ok {
|
|
return
|
|
}
|
|
}
|
|
|
|
s.staticHandler.ServeHTTP(res, req)
|
|
}
|
|
|
|
func (s *server) errorInRequest(res http.ResponseWriter, req *http.Request, err error) {
|
|
res.WriteHeader(http.StatusInternalServerError)
|
|
res.Write([]byte("oh no"))
|
|
log.Printf("ERR %s: %s", req.URL.Path, err)
|
|
}
|
|
|
|
func (s *server) createWebPage(title, subtitle, contents, path string) (string, error) {
|
|
ctx := map[string]interface{}{
|
|
"title": title,
|
|
"subtitle": subtitle,
|
|
"contents": contents,
|
|
"path": blogURL + path,
|
|
}
|
|
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.html"])
|
|
if err != nil {
|
|
s.errorInRequest(res, req, err)
|
|
}
|
|
page, err := s.createWebPage(p.Metadata.Title, p.Metadata.Summary, contents, req.URL.Path)
|
|
if err != nil {
|
|
s.errorInRequest(res, req, err)
|
|
}
|
|
res.Write([]byte(page))
|
|
}
|
|
|
|
func (s *server) homePage(res http.ResponseWriter, req *http.Request) {
|
|
res.Header().Add("content-type", "text/html; charset=utf-8")
|
|
|
|
var posts string
|
|
|
|
for _, p := range s.postList {
|
|
summary, err := p.render(s.templates["summary.html"])
|
|
if err != nil {
|
|
log.Printf("could not render post summary for %s", p.Slug)
|
|
}
|
|
posts = posts + summary
|
|
}
|
|
|
|
page, err := s.createWebPage("Home", blogSummary, posts, "")
|
|
|
|
if err != nil {
|
|
s.errorInRequest(res, req, err)
|
|
}
|
|
|
|
res.Write([]byte(page))
|
|
}
|
|
|
|
func (s *server) renderImage(res http.ResponseWriter, req *http.Request, img []byte) {
|
|
res.Header().Add("content-type", "image/png")
|
|
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 {
|
|
return false
|
|
}
|
|
res.Header().Add("content-type", "text/css")
|
|
res.Write([]byte(contents))
|
|
return ok
|
|
}
|
|
|
|
func rssDatetime(timestamp int64) string {
|
|
return time.Unix(timestamp, 0).Format("Mon, 02 Jan 2006 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
|
|
titleSize, summarySize, urlSize := 63.0, 42.0, 27.0
|
|
lineHeight := 1.05
|
|
textWidth := float64(imgWidth - 2*imgPaddingX)
|
|
|
|
draw := gg.NewContext(imgWidth, imgHeight)
|
|
|
|
titleFont, err := gg.LoadFontFace("fonts/Nunito-Bold.ttf", titleSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
summaryFont, err := gg.LoadFontFace("fonts/Nunito-LightItalic.ttf", summarySize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
urlFont, err := gg.LoadFontFace("fonts/JetBrainsMono-ExtraLight.ttf", urlSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
draw.SetFontFace(titleFont)
|
|
wrappedTitle := draw.WordWrap(title, textWidth)
|
|
draw.SetFontFace(summaryFont)
|
|
wrappedSummary := draw.WordWrap(summary, textWidth)
|
|
|
|
draw.SetHexColor("#fff")
|
|
draw.DrawRectangle(0, 0, float64(imgWidth), float64(imgHeight))
|
|
draw.Fill()
|
|
draw.SetHexColor("#3498db")
|
|
draw.DrawRectangle(0, float64(imgHeight)-accentHeight, float64(imgWidth), accentHeight)
|
|
draw.Fill()
|
|
|
|
offset := float64(imgPaddingY)
|
|
|
|
draw.SetFontFace(titleFont)
|
|
draw.SetHexColor("#333")
|
|
for _, line := range wrappedTitle {
|
|
draw.DrawString(line, float64(imgPaddingX), offset)
|
|
offset += lineHeight * titleSize
|
|
}
|
|
offset += spacerHeight
|
|
|
|
draw.SetFontFace(summaryFont)
|
|
draw.SetHexColor("#999")
|
|
for _, line := range wrappedSummary {
|
|
draw.DrawString(line, float64(imgPaddingX), offset)
|
|
offset += lineHeight * summarySize
|
|
}
|
|
|
|
draw.SetHexColor("#333")
|
|
draw.SetFontFace(urlFont)
|
|
urlY := float64(imgHeight - imgPaddingY)
|
|
draw.DrawStringWrapped(url, float64(imgPaddingX), urlY, 0, 0, textWidth, lineHeight, gg.AlignRight)
|
|
|
|
return draw.EncodePNG(out)
|
|
}
|