From 1981a291aa6acb935569a16ac68f91654507f813 Mon Sep 17 00:00:00 2001 From: Naman Sood Date: Sat, 23 Nov 2024 02:53:25 -0500 Subject: [PATCH] rewrite code a bit Signed-off-by: Naman Sood --- cmd/prose/errorCatcher.go | 74 -------- cmd/prose/listener.go | 75 --------- cmd/prose/post.go | 191 --------------------- cmd/prose/prose.go | 5 +- cmd/prose/server.go | 316 ----------------------------------- cmd/prose/style.go | 99 ----------- cmd/prose/template.go | 78 --------- common/common.go | 77 +++++++++ errorcatcher/errorcatcher.go | 84 ++++++++++ go.mod | 13 +- go.sum | 11 +- postmap/postmap.go | 213 +++++++++++++++++++++++ server/server.go | 201 ++++++++++++++++++++++ stylemap/stylemap.go | 117 +++++++++++++ templates/page.html | 2 +- tplmap/tplmap.go | 90 ++++++++++ watcher/watcher.go | 119 +++++++++++++ 17 files changed, 916 insertions(+), 849 deletions(-) delete mode 100644 cmd/prose/errorCatcher.go delete mode 100644 cmd/prose/listener.go delete mode 100644 cmd/prose/post.go delete mode 100644 cmd/prose/server.go delete mode 100644 cmd/prose/style.go delete mode 100644 cmd/prose/template.go create mode 100644 common/common.go create mode 100644 errorcatcher/errorcatcher.go create mode 100644 postmap/postmap.go create mode 100644 server/server.go create mode 100644 stylemap/stylemap.go create mode 100644 tplmap/tplmap.go create mode 100644 watcher/watcher.go diff --git a/cmd/prose/errorCatcher.go b/cmd/prose/errorCatcher.go deleted file mode 100644 index fda356b..0000000 --- a/cmd/prose/errorCatcher.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "net/http" - "strconv" - - "github.com/aymerick/raymond" -) - -// errorCatcher is a wrapper for http.ResponseWriter that -// captures 4xx and 5xx status codes and handles them in -// a custom manner -type errorCatcher struct { - req *http.Request - res http.ResponseWriter - errorTpl *raymond.Template - notFoundTpl *raymond.Template - handledError bool -} - -func (ec *errorCatcher) Header() http.Header { - return ec.res.Header() -} - -func (ec *errorCatcher) Write(buf []byte) (int, error) { - // if we have already sent a response, pretend that this was successful - if ec.handledError { - return len(buf), nil - } - return ec.res.Write(buf) -} - -func (ec *errorCatcher) WriteHeader(statusCode int) { - if ec.handledError { - return - } - if statusCode == 404 { - ctx := map[string]string{ - "path": ec.req.URL.Path, - } - page, err := ec.notFoundTpl.Exec(ctx) - // if we don't have a page to write, return before - // we toggle the flag so we fall back to the original - // error page - if err != nil { - return - } - ec.res.Header().Set("Content-Type", "text/html; charset=utf-8") - ec.res.WriteHeader(statusCode) - ec.res.Write([]byte(page)) - ec.handledError = true - return - } - - if statusCode >= 400 && statusCode < 600 { - ctx := map[string]string{ - "code": strconv.Itoa(statusCode), - } - page, err := ec.errorTpl.Exec(ctx) - // if we don't have a page to write, return before - // we toggle the flag so we fall back to the original - // error page - if err != nil { - return - } - ec.res.Header().Set("Content-Type", "text/html; charset=utf-8") - ec.res.WriteHeader(statusCode) - ec.res.Write([]byte(page)) - ec.handledError = true - return - } - - ec.res.WriteHeader(statusCode) -} diff --git a/cmd/prose/listener.go b/cmd/prose/listener.go deleted file mode 100644 index 5dc0e4c..0000000 --- a/cmd/prose/listener.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "log" - "os" - "runtime" - "strings" - - "github.com/rjeczalik/notify" -) - -type listener struct { - folder string - update func(string) error - clean func(string) error -} - -func (l *listener) listen() { - cwd, err := os.Getwd() - if err != nil { - log.Fatal("could not get current working directory for listener!") - } - cwd = cwd + "/" - - c := make(chan notify.EventInfo, 1) - - var events []notify.Event - - // inotify events prevent double-firing of - // certain events in Linux. - if runtime.GOOS == "linux" { - events = []notify.Event{ - notify.InCloseWrite, - notify.InMovedFrom, - notify.InMovedTo, - notify.InDelete, - } - } else { - events = []notify.Event{ - notify.Create, - notify.Remove, - notify.Rename, - notify.Write, - } - } - - err = notify.Watch(l.folder, c, events...) - - if err != nil { - log.Fatalf("Could not setup watcher for folder %s: %s", l.folder, err) - } - - defer notify.Stop(c) - - for { - ei := <-c - log.Printf("event: %s", ei.Event()) - switch ei.Event() { - case notify.InCloseWrite, notify.InMovedTo, notify.Create, notify.Rename, notify.Write: - filePath := strings.TrimPrefix(ei.Path(), cwd) - log.Printf("updating file %s", filePath) - err := l.update(strings.TrimPrefix(filePath, l.folder)) - if err != nil { - log.Printf("watcher update action on %s failed: %v", filePath, err) - } - case notify.InMovedFrom, notify.InDelete, notify.Remove: - filePath := strings.TrimPrefix(ei.Path(), cwd) - log.Printf("cleaning file %s", filePath) - err := l.clean(strings.TrimPrefix(filePath, l.folder)) - if err != nil { - log.Printf("watcher clean action on %s failed: %v", filePath, err) - } - } - } -} diff --git a/cmd/prose/post.go b/cmd/prose/post.go deleted file mode 100644 index f4d5aec..0000000 --- a/cmd/prose/post.go +++ /dev/null @@ -1,191 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - "log" - "os" - "sort" - "strings" - - "github.com/aymerick/raymond" - "github.com/mitchellh/mapstructure" - "github.com/yuin/goldmark" - emoji "github.com/yuin/goldmark-emoji" - highlighting "github.com/yuin/goldmark-highlighting" - meta "github.com/yuin/goldmark-meta" - "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer/html" -) - -// Metadata stores the data about a post that needs to be visible -// at the home page. -type Metadata struct { - Title string - Summary string - Time int64 // unix timestamp -} - -// Post stores the contents of a blog post. -type Post struct { - Slug string - Metadata Metadata - Contents string - Image []byte -} - -func newPost(slug string) (*Post, error) { - data, err := os.ReadFile("posts/" + slug + ".md") - if err != nil { - return nil, fmt.Errorf("could not read file: %s", err) - } - - md := goldmark.New( - goldmark.WithExtensions( - extension.Linkify, - extension.Strikethrough, - extension.Typographer, - extension.Footnote, - meta.Meta, - highlighting.Highlighting, - emoji.New(emoji.WithRenderingMethod(emoji.Unicode)), - ), - goldmark.WithRendererOptions( - html.WithUnsafe(), - ), - ) - var converted bytes.Buffer - ctx := parser.NewContext() - err = md.Convert(data, &converted, parser.WithContext(ctx)) - if err != nil { - return nil, fmt.Errorf("could not parse markdown: %s", err) - } - mdMap, err := meta.TryGet(ctx) - if err != nil { - return nil, fmt.Errorf("could not parse metadata: %s", err) - } - var metadata Metadata - err = mapstructure.Decode(mdMap, &metadata) - if err != nil { - return nil, fmt.Errorf("could not destructure metadata: %s", err) - } - - post := &Post{ - Slug: slug, - Metadata: metadata, - Contents: converted.String(), - } - - url := blogURL + "/" + slug - var buf bytes.Buffer - err = createImage(post.Metadata.Title, post.Metadata.Summary, url, &buf) - if err != nil { - return nil, fmt.Errorf("could not create post image: %v", err) - } - post.Image, err = io.ReadAll(&buf) - if err != nil { - return nil, err - } - - return post, nil -} - -func (p *Post) render(tpl *raymond.Template) (string, error) { - return tpl.Exec(p) -} - -func (p *Post) String() string { - return p.Slug -} - -type postList []*Post - -func newPostList() (postList, error) { - files, err := os.ReadDir("posts/") - if err != nil { - return nil, err - } - - pl := make(postList, 0, len(files)) - for _, f := range files { - filename := f.Name() - - if strings.HasSuffix(filename, ".md") { - post, err := newPost(strings.TrimSuffix(filename, ".md")) - if err != nil { - return nil, fmt.Errorf("could not render %s: %s", filename, err) - } - pl = append(pl, post) - log.Printf("Loaded post %s", filename) - } - } - sort.Sort(pl) - - return pl, nil -} - -func insertOrUpdatePost(pl postList, p *Post) postList { - for i, post := range pl { - if post.Slug == p.Slug { - pl[i] = p - sort.Sort(pl) - return pl - } - } - pl = append(pl, p) - sort.Sort(pl) - return pl -} - -func removePost(pl postList, slug string) postList { - for i, post := range pl { - if post.Slug == slug { - pl = append(pl[:i], pl[i+1:]...) - break - } - } - fmt.Println(pl) - return pl -} - -// Len implements sort.Interface -func (pl postList) Len() int { - return len(pl) -} - -// Less implements sort.Interface -func (pl postList) Less(i, j int) bool { - return pl[i].Metadata.Time > pl[j].Metadata.Time -} - -// Swap implements sort.Interface -func (pl postList) Swap(i, j int) { - temp := pl[i] - pl[i] = pl[j] - pl[j] = temp -} - -func newPostListener(update func(func(postList) postList)) *listener { - ln := &listener{ - folder: "posts/", - update: func(file string) error { - post, err := newPost(strings.TrimSuffix(file, ".md")) - if err != nil { - return err - } - update(func(oldList postList) postList { - return insertOrUpdatePost(oldList, post) - }) - return nil - }, - clean: func(file string) error { - update(func(oldList postList) postList { - return removePost(oldList, strings.TrimSuffix(file, ".md")) - }) - return nil - }, - } - return ln -} diff --git a/cmd/prose/prose.go b/cmd/prose/prose.go index 94d736b..21302ae 100644 --- a/cmd/prose/prose.go +++ b/cmd/prose/prose.go @@ -3,18 +3,17 @@ package main import ( "log" "net/http" + "prose/server" ) func main() { log.Printf("Hello, world! This is Prose.") - s, err := newServer() + err := server.New() if err != nil { log.Fatal(err) } - http.HandleFunc("/", s.router) - log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/cmd/prose/server.go b/cmd/prose/server.go deleted file mode 100644 index d5480e7..0000000 --- a/cmd/prose/server.go +++ /dev/null @@ -1,316 +0,0 @@ -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("static/fonts/Nunito-Bold.ttf", titleSize) - if err != nil { - return err - } - summaryFont, err := gg.LoadFontFace("static/fonts/Nunito-LightItalic.ttf", summarySize) - if err != nil { - return err - } - urlFont, err := gg.LoadFontFace("static/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) -} diff --git a/cmd/prose/style.go b/cmd/prose/style.go deleted file mode 100644 index 35f3fd2..0000000 --- a/cmd/prose/style.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "fmt" - "io" - "log" - "os" - "strings" - - "github.com/bep/godartsass/v2" -) - -var sassTranspiler *godartsass.Transpiler - -func newStylesMap() (map[string]string, error) { - folder, err := os.ReadDir("styles/") - if err != nil { - return nil, fmt.Errorf("could not load styles directory: %s", err) - } - - styles := make(map[string]string) - for _, s := range folder { - contents, filename, err := loadStylesheet(s.Name()) - if err != nil { - return nil, fmt.Errorf("could not generate styles for %s: %v", s.Name(), err) - } - styles[filename] = contents - log.Printf("Loaded stylesheet %s", filename) - } - - return styles, nil -} - -func newStylesListener(updateMap func(func(map[string]string))) *listener { - ln := &listener{ - folder: "styles/", - update: func(file string) error { - contents, filename, err := loadStylesheet(file) - if err != nil { - return err - } - updateMap(func(styles map[string]string) { - styles[filename] = contents - }) - return nil - }, - clean: func(file string) error { - updateMap(func(styles map[string]string) { - delete(styles, file+".css") - }) - return nil - }, - } - return ln -} - -func loadStylesheet(filename string) (string, string, error) { - if strings.HasSuffix(filename, ".scss") { - return loadSCSS(filename) - } - return loadCSS(filename) -} - -func loadSCSS(filename string) (string, string, error) { - in, err := os.Open("styles/" + filename) - if err != nil { - return "", "", fmt.Errorf("could not open stylesheet %s: %w", filename, err) - } - stylesheet, err := io.ReadAll(in) - if err != nil { - return "", "", fmt.Errorf("could not read stylesheet %s: %w", filename, err) - } - if sassTranspiler == nil { - sassTranspiler, err = godartsass.Start(godartsass.Options{}) - if err != nil { - return "", "", fmt.Errorf("could not start sass transpiler: %w", err) - } - } - res, err := sassTranspiler.Execute(godartsass.Args{ - Source: string(stylesheet), - }) - if err != nil { - return "", "", fmt.Errorf("could not generate stylesheet %s: %w", filename, err) - } - return res.CSS, strings.TrimSuffix(filename, ".scss") + ".css", nil -} - -func loadCSS(filename string) (string, string, error) { - in, err := os.Open("styles/" + filename) - if err != nil { - return "", "", fmt.Errorf("could not open style infile %s: %w", filename, err) - } - var buf strings.Builder - _, err = io.Copy(&buf, in) - if err != nil { - return "", "", fmt.Errorf("could not copy stylesheet %s: %s", filename, err) - } - return buf.String(), filename, nil -} diff --git a/cmd/prose/template.go b/cmd/prose/template.go deleted file mode 100644 index 5e27568..0000000 --- a/cmd/prose/template.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strconv" - "time" - - "github.com/aymerick/raymond" -) - -func loadTemplate(file string) (*raymond.Template, error) { - tpl, err := raymond.ParseFile("templates/" + file) - if err != nil { - 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) - if err != nil { - log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr) - timestamp = time.Now().Unix() - } - 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` -// 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) { - templates := make(map[string]*raymond.Template) - for _, f := range files { - tpl, err := loadTemplate(f) - if err != nil { - return nil, err - } - templates[f] = tpl - } - log.Printf("Loaded templates: %s", files) - return templates, nil -} - -func newTemplateListener(update func(func(map[string]*raymond.Template))) *listener { - return &listener{ - folder: "templates/", - update: func(file string) error { - newTpl, err := loadTemplate(file) - if err != nil { - return err - } - update(func(oldMap map[string]*raymond.Template) { - oldMap[file] = newTpl - }) - return nil - }, - clean: func(file string) error { - update(func(oldMap map[string]*raymond.Template) { - delete(oldMap, file) - }) - log.Printf("Unloaded template: %s", file) - return nil - }, - } -} diff --git a/common/common.go b/common/common.go new file mode 100644 index 0000000..bef2a3c --- /dev/null +++ b/common/common.go @@ -0,0 +1,77 @@ +package common + +import ( + "io" + "time" + + "github.com/fogleman/gg" +) + +const ( + BlogTitle = "Prose" + BlogURL = "https://prose.nsood.in" + BlogSummary = "Where I infodump in Markdown and nobody can stop me." +) + +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("static/fonts/Nunito-Bold.ttf", titleSize) + if err != nil { + return err + } + summaryFont, err := gg.LoadFontFace("static/fonts/Nunito-LightItalic.ttf", summarySize) + if err != nil { + return err + } + urlFont, err := gg.LoadFontFace("static/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) +} diff --git a/errorcatcher/errorcatcher.go b/errorcatcher/errorcatcher.go new file mode 100644 index 0000000..6d077db --- /dev/null +++ b/errorcatcher/errorcatcher.go @@ -0,0 +1,84 @@ +package errorcatcher + +import ( + "net/http" + "prose/watcher" + "strconv" + + "github.com/aymerick/raymond" +) + +// errorCatcher is a wrapper for http.ResponseWriter that +// captures 4xx and 5xx status codes and handles them in +// a custom manner +type errorCatcher struct { + r *http.Request + w http.ResponseWriter + templates watcher.AutoMap[string, *raymond.Template] + handled bool +} + +func New(w http.ResponseWriter, r *http.Request, templates watcher.AutoMap[string, *raymond.Template]) *errorCatcher { + return &errorCatcher{ + r: r, + w: w, + templates: templates, + } +} + +func (ec *errorCatcher) Header() http.Header { + return ec.w.Header() +} + +func (ec *errorCatcher) Write(buf []byte) (int, error) { + // if we have already sent a response, pretend that this was successful + if ec.handled { + return len(buf), nil + } + return ec.w.Write(buf) +} + +func (ec *errorCatcher) WriteHeader(statusCode int) { + if ec.handled { + return + } + + if statusCode == http.StatusNotFound { + tpl, _ := ec.templates.Get("notfound.html") + page, err := tpl.Exec(map[string]string{ + "path": ec.r.URL.Path, + }) + // if we don't have a page to write, return before + // we toggle the flag so we fall back to the original + // error page + if err != nil { + return + } + ec.w.Header().Set("Content-Type", "text/html; charset=utf-8") + ec.w.WriteHeader(statusCode) + ec.w.Write([]byte(page)) + ec.handled = true + return + } + + if statusCode >= 400 && statusCode < 600 { + ctx := map[string]string{ + "code": strconv.Itoa(statusCode), + } + tpl, _ := ec.templates.Get("error.html") + page, err := tpl.Exec(ctx) + // if we don't have a page to write, return before + // we toggle the flag so we fall back to the original + // error page + if err != nil { + return + } + ec.w.Header().Set("Content-Type", "text/html; charset=utf-8") + ec.w.WriteHeader(statusCode) + ec.w.Write([]byte(page)) + ec.handled = true + return + } + + ec.w.WriteHeader(statusCode) +} diff --git a/go.mod b/go.mod index 1fe6838..e7407e2 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,27 @@ module prose -go 1.15 +go 1.23 require ( github.com/aymerick/raymond v2.0.2+incompatible github.com/bep/godartsass/v2 v2.0.0 github.com/fogleman/gg v1.3.0 - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/mitchellh/mapstructure v1.4.1 github.com/rjeczalik/notify v0.9.2 github.com/yuin/goldmark v1.3.1 github.com/yuin/goldmark-emoji v1.0.1 github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691 github.com/yuin/goldmark-meta v1.0.0 +) + +require ( + github.com/alecthomas/chroma v0.7.2-0.20200305040604-4f3623dce67a // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect + github.com/dlclark/regexp2 v1.2.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index dbf7c1c..b2867e5 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,10 @@ github.com/bep/godartsass/v2 v2.0.0 h1:Ruht+BpBWkpmW+yAM2dkp7RSSeN0VLaTobyW0CiSP github.com/bep/godartsass/v2 v2.0.0/go.mod h1:AcP8QgC+OwOXEq6im0WgDRYK7scDsmZCEW62o1prQLo= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= @@ -44,11 +42,8 @@ github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -60,7 +55,6 @@ github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8= github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= @@ -70,7 +64,6 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= @@ -97,9 +90,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/postmap/postmap.go b/postmap/postmap.go new file mode 100644 index 0000000..0657e06 --- /dev/null +++ b/postmap/postmap.go @@ -0,0 +1,213 @@ +package postmap + +import ( + "bytes" + "fmt" + "io" + "iter" + "log" + "os" + "prose/common" + "prose/watcher" + "slices" + "strings" + "sync" + + "github.com/aymerick/raymond" + "github.com/mitchellh/mapstructure" + "github.com/yuin/goldmark" + emoji "github.com/yuin/goldmark-emoji" + highlighting "github.com/yuin/goldmark-highlighting" + meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +// Metadata stores the data about a post that needs to be visible +// at the home page. +type Metadata struct { + Title string + Summary string + Time int64 // unix timestamp +} + +// Post stores the contents of a blog post. +type Post struct { + Slug string + Metadata Metadata + Contents string + Image []byte +} + +func (p *Post) Render(tpl *raymond.Template) (string, error) { + return tpl.Exec(p) +} + +func (p *Post) String() string { + return p.Slug +} + +func postLess(a, b *Post) int { + return int(b.Metadata.Time - a.Metadata.Time) +} + +// postList stores Posts in reverse order of Post.Metadata.Time. +type postList struct { + mu sync.RWMutex + posts []*Post + md goldmark.Markdown +} + +// New returns a new postList. +func New() (watcher.OrderedAutoMap[string, *Post], error) { + files, err := os.ReadDir("posts/") + if err != nil { + return nil, err + } + + pl := &postList{ + posts: make([]*Post, 0, len(files)), + md: goldmark.New( + goldmark.WithExtensions( + extension.Linkify, + extension.Strikethrough, + extension.Typographer, + extension.Footnote, + meta.Meta, + highlighting.Highlighting, + emoji.New(emoji.WithRenderingMethod(emoji.Unicode)), + ), + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + ), + } + for _, f := range files { + filename := f.Name() + + if filename == ".gitignore" { + continue + } + + err := pl.fetchLocked(filename) + if err != nil { + log.Printf("could not render %q, skipping: %v", filename, err) + } else { + log.Printf("loaded post: %q", filename) + } + } + + go watcher.Watch("posts/", pl) + + return pl, nil +} + +func (pl *postList) newPost(filename string) (*Post, error) { + slug, ok := strings.CutSuffix(filename, ".md") + if !ok { + return nil, fmt.Errorf("unknown file extension in posts directory: %q", filename) + } + data, err := os.ReadFile("posts/" + filename) + if err != nil { + return nil, fmt.Errorf("could not read file: %w", err) + } + + var converted bytes.Buffer + ctx := parser.NewContext() + err = pl.md.Convert(data, &converted, parser.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("could not parse markdown: %w", err) + } + mdMap, err := meta.TryGet(ctx) + if err != nil { + return nil, fmt.Errorf("could not parse metadata: %w", err) + } + var metadata Metadata + err = mapstructure.Decode(mdMap, &metadata) + if err != nil { + return nil, fmt.Errorf("could not destructure metadata: %w", err) + } + + post := &Post{ + Slug: slug, + Metadata: metadata, + Contents: converted.String(), + } + + url := common.BlogURL + "/" + slug + var buf bytes.Buffer + err = common.CreateImage(post.Metadata.Title, post.Metadata.Summary, url, &buf) + if err != nil { + return nil, fmt.Errorf("could not create post image: %w", err) + } + post.Image, err = io.ReadAll(&buf) + if err != nil { + return nil, err + } + + return post, nil +} + +func (pl *postList) Get(slug string) (*Post, bool) { + pl.mu.RLock() + defer pl.mu.RUnlock() + for _, p := range pl.posts { + if p.Slug == slug { + return p, true + } + } + return nil, false +} + +func (pl *postList) Fetch(filename string) error { + pl.mu.Lock() + defer pl.mu.Unlock() + return pl.fetchLocked(filename) +} + +func (pl *postList) fetchLocked(filename string) error { + p, err := pl.newPost(filename) + if err != nil { + return err + } + defer slices.SortFunc(pl.posts, postLess) + for i, post := range pl.posts { + if post.Slug == p.Slug { + pl.posts[i] = p + return nil + } + } + pl.posts = append(pl.posts, p) + return nil +} + +func (pl *postList) Delete(filename string) error { + pl.mu.Lock() + defer pl.mu.Unlock() + slug, ok := strings.CutSuffix(filename, ".md") + if !ok { + return fmt.Errorf("unknown file extension in posts directory: %q", filename) + } + for i, post := range pl.posts { + if post.Slug == slug { + pl.posts = append(pl.posts[:i], pl.posts[i+1:]...) + break + } + } + return nil +} + +// All implements watcher.OrderedCompMap[string, *Post], providing Posts in +// order of most recent first. +func (pl *postList) All() iter.Seq2[string, *Post] { + return func(yield func(string, *Post) bool) { + pl.mu.RLock() + defer pl.mu.RUnlock() + for _, p := range pl.posts { + if !yield(p.Slug, p) { + break + } + } + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..03ccfa0 --- /dev/null +++ b/server/server.go @@ -0,0 +1,201 @@ +package server + +import ( + "bytes" + "log" + "net/http" + "prose/common" + "prose/errorcatcher" + "prose/postmap" + "prose/stylemap" + "prose/tplmap" + "prose/watcher" + "strings" + + "github.com/aymerick/raymond" +) + +type server struct { + staticHandler http.Handler + + templates watcher.AutoMap[string, *raymond.Template] + posts watcher.OrderedAutoMap[string, *postmap.Post] + styles watcher.AutoMap[string, string] + homeImage []byte +} + +func New() error { + s := &server{ + staticHandler: http.FileServer(http.Dir("static/")), + } + + var imgBuffer bytes.Buffer + err := common.CreateImage(common.BlogTitle, common.BlogSummary, common.BlogURL, &imgBuffer) + if err != nil { + return err + } + s.homeImage = imgBuffer.Bytes() + + s.posts, err = postmap.New() + if err != nil { + return err + } + + s.templates, err = tplmap.New() + if err != nil { + return err + } + + s.styles, err = stylemap.New() + if err != nil { + return err + } + + handlers := map[string]http.HandlerFunc{ + "GET /{$}": s.serveGetHome, + "GET /banner.png": s.serveGetImage(s.homeImage), + "GET /rss.xml": s.serveGetRSS, + "GET /{slug}": s.serveGetPost, + "GET /img/banner/{img}": s.serveGetPostImage, + "GET /css/{filename}": s.serveGetStylesheet, + "GET /": s.staticHandler.ServeHTTP, + } + + for pattern, handler := range handlers { + http.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w = errorcatcher.New(w, r, s.templates) + handler(w, r) + })) + } + + return nil +} + +func (s *server) errorInRequest(res http.ResponseWriter, req *http.Request, err error) { + res.WriteHeader(http.StatusInternalServerError) + log.Printf("ERR %s: %s", req.URL.Path, err) +} + +func (s *server) createWebPage(title, subtitle, contents, banner string) (string, error) { + ctx := map[string]interface{}{ + "title": title, + "subtitle": subtitle, + "contents": contents, + "banner": banner, + } + tpl, _ := s.templates.Get("page.html") + return tpl.Exec(ctx) +} + +func (s *server) serveGetPost(w http.ResponseWriter, r *http.Request) { + p, ok := s.posts.Get(r.PathValue("slug")) + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Add("content-type", "text/html; charset=utf-8") + tpl, _ := s.templates.Get("fullpost.html") + contents, err := p.Render(tpl) + if err != nil { + s.errorInRequest(w, r, err) + return + } + page, err := s.createWebPage(p.Metadata.Title, p.Metadata.Summary, contents, "/img/banner/"+p.Slug+".png") + if err != nil { + s.errorInRequest(w, r, err) + return + } + w.Write([]byte(page)) +} + +func (s *server) serveGetHome(w http.ResponseWriter, r *http.Request) { + w.Header().Add("content-type", "text/html; charset=utf-8") + + var posts string + + summaryTpl, _ := s.templates.Get("summary.html") + for _, p := range s.posts.All() { + summary, err := p.Render(summaryTpl) + if err != nil { + log.Printf("could not render post summary for %s", p.Slug) + } + posts = posts + summary + } + + page, err := s.createWebPage("Home", common.BlogSummary, posts, "/banner.png") + + if err != nil { + s.errorInRequest(w, r, err) + return + } + + w.Write([]byte(page)) +} + +func (s *server) serveGetPostImage(w http.ResponseWriter, r *http.Request) { + slug, ok := strings.CutSuffix(r.PathValue("img"), ".png") + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + post, ok := s.posts.Get(slug) + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + s.serveGetImage(post.Image)(w, r) +} + +func (s *server) serveGetImage(img []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("content-type", "image/png") + w.Write(img) + } +} + +func (s *server) serveGetRSS(w http.ResponseWriter, r *http.Request) { + w.Header().Add("content-type", "text/xml; charset=utf-8") + + var posts, pubDate string + + rssItemTpl, _ := s.templates.Get("rss-item.xml") + for _, p := range s.posts.All() { + if pubDate != "" { + pubDate = common.RSSDatetime(p.Metadata.Time) + } + summary, err := p.Render(rssItemTpl) + if err != nil { + log.Printf("could not render post summary for %s", p.Slug) + } + posts = posts + summary + } + if pubDate == "" { + pubDate = common.RSSDatetime(0) + } + + rssPageTpl, _ := s.templates.Get("rss-channel.xml") + page, err := rssPageTpl.Exec(map[string]string{ + "title": common.BlogTitle, + "description": common.BlogSummary, + "link": common.BlogURL, + "pubDate": pubDate, + "items": posts, + }) + + if err != nil { + s.errorInRequest(w, r, err) + return + } + + w.Write([]byte(page)) +} + +func (s *server) serveGetStylesheet(w http.ResponseWriter, r *http.Request) { + contents, ok := s.styles.Get(r.PathValue("filename")) + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Add("content-type", "text/css") + w.Write([]byte(contents)) +} diff --git a/stylemap/stylemap.go b/stylemap/stylemap.go new file mode 100644 index 0000000..70c8377 --- /dev/null +++ b/stylemap/stylemap.go @@ -0,0 +1,117 @@ +package stylemap + +import ( + "fmt" + "io" + "log" + "os" + "prose/watcher" + "strings" + "sync" + + "github.com/bep/godartsass/v2" +) + +type styleMap struct { + mu sync.RWMutex + stylesheets map[string]string + transpiler *godartsass.Transpiler +} + +// New returns a new styleMap. +func New() (watcher.AutoMap[string, string], error) { + folder, err := os.ReadDir("styles/") + if err != nil { + return nil, fmt.Errorf("could not load styles directory: %w", err) + } + + sassTranspiler, err := godartsass.Start(godartsass.Options{}) + if err != nil { + return nil, fmt.Errorf("could not start sass transpiler: %w", err) + } + + sm := &styleMap{ + stylesheets: make(map[string]string), + transpiler: sassTranspiler, + } + + for _, s := range folder { + err := sm.fetchLocked(s.Name()) + if err != nil { + return nil, fmt.Errorf("could not generate styles for %s: %w", s.Name(), err) + } + log.Printf("loaded stylesheet %q", s.Name()) + } + + go watcher.Watch("styles/", sm) + + return sm, nil +} + +func (sm *styleMap) Get(filename string) (string, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + contents, ok := sm.stylesheets[filename] + return contents, ok +} + +func (sm *styleMap) Delete(filename string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + if s, ok := strings.CutSuffix(filename, ".scss"); ok { + filename = s + ".css" + } + delete(sm.stylesheets, filename) + return nil +} + +func (sm *styleMap) Fetch(filename string) (err error) { + sm.mu.Lock() + defer sm.mu.Unlock() + return sm.fetchLocked(filename) +} + +func (sm *styleMap) fetchLocked(filename string) (err error) { + var outfile, contents string + if strings.HasSuffix(filename, ".scss") { + outfile, contents, err = sm.loadSCSS(filename) + } else { + outfile, contents, err = sm.loadCSS(filename) + } + if err != nil { + return + } + sm.stylesheets[outfile] = contents + return +} + +func (sm *styleMap) loadSCSS(filename string) (string, string, error) { + in, err := os.Open("styles/" + filename) + if err != nil { + return "", "", fmt.Errorf("could not open stylesheet %s: %w", filename, err) + } + stylesheet, err := io.ReadAll(in) + if err != nil { + return "", "", fmt.Errorf("could not read stylesheet %s: %w", filename, err) + } + res, err := sm.transpiler.Execute(godartsass.Args{ + Source: string(stylesheet), + }) + if err != nil { + return "", "", fmt.Errorf("could not generate stylesheet %s: %w", filename, err) + } + return strings.TrimSuffix(filename, ".scss") + ".css", res.CSS, nil +} + +func (sm *styleMap) loadCSS(filename string) (string, string, error) { + in, err := os.Open("styles/" + filename) + if err != nil { + return "", "", fmt.Errorf("could not open style infile %s: %w", filename, err) + } + var buf strings.Builder + _, err = io.Copy(&buf, in) + if err != nil { + return "", "", fmt.Errorf("could not copy stylesheet %s: %w", filename, err) + } + return filename, buf.String(), nil +} diff --git a/templates/page.html b/templates/page.html index 383264f..c199dda 100644 --- a/templates/page.html +++ b/templates/page.html @@ -8,7 +8,7 @@ - + diff --git a/tplmap/tplmap.go b/tplmap/tplmap.go new file mode 100644 index 0000000..ea5ab81 --- /dev/null +++ b/tplmap/tplmap.go @@ -0,0 +1,90 @@ +package tplmap + +import ( + "fmt" + "log" + "os" + "prose/common" + "prose/watcher" + "strconv" + "sync" + "time" + + "github.com/aymerick/raymond" +) + +type tplMap struct { + mu sync.RWMutex + templates map[string]*raymond.Template +} + +func New() (watcher.AutoMap[string, *raymond.Template], error) { + folder, err := os.ReadDir("templates/") + if err != nil { + return nil, fmt.Errorf("could not load templates directory: %w", err) + } + + tm := &tplMap{ + templates: make(map[string]*raymond.Template), + } + + for _, s := range folder { + err := tm.fetchLocked(s.Name()) + if err != nil { + return nil, fmt.Errorf("could not load template %q: %w", s.Name(), err) + } + log.Printf("loaded template %q", s.Name()) + } + + go watcher.Watch("templates/", tm) + + return tm, nil +} + +func (tm *tplMap) Get(filename string) (*raymond.Template, bool) { + tm.mu.RLock() + defer tm.mu.RUnlock() + got, ok := tm.templates[filename] + return got, ok +} + +func (tm *tplMap) Delete(filename string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + delete(tm.templates, filename) + return nil +} + +func (tm *tplMap) Fetch(filename string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.fetchLocked(filename) +} + +func (tm *tplMap) fetchLocked(filename string) error { + tpl, err := raymond.ParseFile("templates/" + filename) + if err != nil { + return fmt.Errorf("could not parse template %q: %w", filename, err) + } + tpl.RegisterHelper("datetime", 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 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 common.RSSDatetime(timestamp) + }) + tpl.RegisterHelper("getFullUrl", func(slug string) string { + return common.BlogURL + "/" + slug + }) + tm.templates[filename] = tpl + return nil +} diff --git a/watcher/watcher.go b/watcher/watcher.go new file mode 100644 index 0000000..cb73808 --- /dev/null +++ b/watcher/watcher.go @@ -0,0 +1,119 @@ +package watcher + +import ( + "iter" + "log" + "os" + "runtime" + "strings" + + "github.com/rjeczalik/notify" +) + +type WatchEventKind int + +const ( + Update WatchEventKind = iota + Clean +) + +// WatchEvent represents a change to a watched folder. It notes which file +// changed and what change happened to it. +type WatchEvent struct { + File string + Kind WatchEventKind +} + +func updated(f string) *WatchEvent { + return &WatchEvent{ + File: f, + Kind: Update, + } +} + +func cleaned(f string) *WatchEvent { + return &WatchEvent{ + File: f, + Kind: Clean, + } +} + +// Watcher classifies notify.Events into updates and deletes, and calls the +// respective functions for a file when those events happen to that file. +func Watch(folder string, up Updater[string]) { + cwd, err := os.Getwd() + if err != nil { + log.Fatal("could not get current working directory for listener!") + } + cwd = cwd + "/" + + c := make(chan notify.EventInfo, 1) + + var events []notify.Event + + // inotify events prevent double-firing of + // certain events in Linux. + if runtime.GOOS == "linux" { + events = []notify.Event{ + notify.InCloseWrite, + notify.InMovedFrom, + notify.InMovedTo, + notify.InDelete, + } + } else { + events = []notify.Event{ + notify.Create, + notify.Remove, + notify.Rename, + notify.Write, + } + } + + err = notify.Watch(folder, c, events...) + + if err != nil { + log.Fatalf("could not setup watcher for folder %s: %s", folder, err) + } + + defer notify.Stop(c) + for { + ei := <-c + log.Printf("event: %s", ei.Event()) + switch ei.Event() { + case notify.InCloseWrite, notify.InMovedTo, notify.Create, notify.Rename, notify.Write: + filePath := strings.TrimPrefix(ei.Path(), cwd) + log.Printf("updating file %s", filePath) + err := up.Fetch(strings.TrimPrefix(filePath, folder)) + if err != nil { + log.Printf("up.Fetch(%q): %v", filePath, err) + } + case notify.InMovedFrom, notify.InDelete, notify.Remove: + filePath := strings.TrimPrefix(ei.Path(), cwd) + log.Printf("cleaning file %s", filePath) + err := up.Delete(strings.TrimPrefix(filePath, folder)) + if err != nil { + log.Printf("up.Delete(%q): %v", filePath, err) + } + } + } +} + +// Updater is a key-value store which can be informed when to recompute values +// for a particular key. Updaters are normally also AutoMaps. +type Updater[K any] interface { + Fetch(K) error + Delete(K) error +} + +// AutoMap is a key-value store where the values are automatically computed by +// the store itself, based on the key. +type AutoMap[K, V any] interface { + Get(K) (V, bool) +} + +// OrderedAutoMap is an AutoMap that provides an iterator over its +// currently-existing keys in a known order. +type OrderedAutoMap[K, V any] interface { + AutoMap[K, V] + All() iter.Seq2[K, V] +}