dockerify
Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
parent
969291afcd
commit
7cc0d4132c
41 changed files with 34 additions and 5 deletions
74
cmd/prose/errorCatcher.go
Normal file
74
cmd/prose/errorCatcher.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
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)
|
||||
}
|
75
cmd/prose/listener.go
Normal file
75
cmd/prose/listener.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
191
cmd/prose/post.go
Normal file
191
cmd/prose/post.go
Normal file
|
@ -0,0 +1,191 @@
|
|||
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
|
||||
}
|
20
cmd/prose/prose.go
Normal file
20
cmd/prose/prose.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Printf("Hello, world! This is Prose.")
|
||||
|
||||
s, err := newServer()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
http.HandleFunc("/", s.router)
|
||||
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
316
cmd/prose/server.go
Normal file
316
cmd/prose/server.go
Normal file
|
@ -0,0 +1,316 @@
|
|||
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)
|
||||
}
|
99
cmd/prose/style.go
Normal file
99
cmd/prose/style.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
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
|
||||
}
|
78
cmd/prose/template.go
Normal file
78
cmd/prose/template.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
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
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue