From 2929b4279c7a8128bd305290cc4187b6afb11cde Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Fri, 13 May 2022 11:47:29 -0600 Subject: [PATCH] Implement rendering Posts to html --- srv/default.nix | 2 +- srv/src/go.mod | 11 ++-- srv/src/go.sum | 11 ++-- srv/src/post/post.go | 20 +++----- srv/src/post/renderer.go | 96 +++++++++++++++++++++++++++++++++++ srv/src/post/renderer_test.go | 92 +++++++++++++++++++++++++++++++++ srv/src/tpl/html/base.html | 65 ++++++++++++++++++++++++ srv/src/tpl/html/post.html | 48 ++++++++++++++++++ srv/src/tpl/tpl.go | 12 +++++ 9 files changed, 333 insertions(+), 24 deletions(-) create mode 100644 srv/src/post/renderer.go create mode 100644 srv/src/post/renderer_test.go create mode 100644 srv/src/tpl/html/base.html create mode 100644 srv/src/tpl/html/post.html create mode 100644 srv/src/tpl/tpl.go diff --git a/srv/default.nix b/srv/default.nix index 42018ce..a5aa6af 100644 --- a/srv/default.nix +++ b/srv/default.nix @@ -38,7 +38,7 @@ pname = "mediocre-blog-srv"; version = "dev"; src = ./src; - vendorSha256 = "sha256-MdjPrNSAAiqkAnJRIhMFTVQDKIPuDCHqRQFEtnoe1Cc="; + vendorSha256 = "1s5jhis1a2y7m50k29ap7kd0h4bgc3dzy1f9dqf5jrz8n27f3i87"; # disable tests checkPhase = ''''; diff --git a/srv/src/go.mod b/srv/src/go.mod index 48ca311..2f9bf4b 100644 --- a/srv/src/go.mod +++ b/srv/src/go.mod @@ -3,17 +3,20 @@ module github.com/mediocregopher/blog.mediocregopher.com/srv go 1.16 require ( - github.com/adrg/frontmatter v0.2.0 // indirect + github.com/adrg/frontmatter v0.2.0 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.15.0 + github.com/gomarkdown/markdown v0.0.0-20220510115730-2372b9aa33e5 github.com/google/uuid v1.3.0 - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.4.2 github.com/mattn/go-sqlite3 v1.14.8 github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0.0.20220506011745-cbeee71cb1ee - github.com/mediocregopher/radix/v4 v4.0.0-beta.1.0.20210726230805-d62fa1b2e3cb // indirect + github.com/mediocregopher/radix/v4 v4.0.0-beta.1.0.20210726230805-d62fa1b2e3cb github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc github.com/stretchr/testify v1.7.0 github.com/tilinna/clock v1.1.0 github.com/ziutek/mymysql v1.5.4 // indirect - golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect ) diff --git a/srv/src/go.sum b/srv/src/go.sum index 79b2bf6..358c3d6 100644 --- a/srv/src/go.sum +++ b/srv/src/go.sum @@ -57,6 +57,8 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomarkdown/markdown v0.0.0-20220510115730-2372b9aa33e5 h1:mjLaNMRojfgo2qgSdEX1CzmG7rHTeOWZdO1T1sgjEb0= +github.com/gomarkdown/markdown v0.0.0-20220510115730-2372b9aa33e5/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -109,8 +111,6 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0 h1:i9FBkcCaWXxteJ8458AD8dBL2YqSxVlpsHOMWg5N9Dc= -github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0/go.mod h1:wOZVlnKYvIbkzyCJ3dxy1k40XkirvCd1pisX2O91qoQ= github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0.0.20220506011745-cbeee71cb1ee h1:AWRuhgn7iumyhPuxKwed1F1Ri2dXMwxKfp5YIdpnQIY= github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0.0.20220506011745-cbeee71cb1ee/go.mod h1:wOZVlnKYvIbkzyCJ3dxy1k40XkirvCd1pisX2O91qoQ= github.com/mediocregopher/radix/v4 v4.0.0-beta.1.0.20210726230805-d62fa1b2e3cb h1:7Y2vAC5q44VJzbBUdxRUEqfz88ySJ/6yXXkpQ+sxke4= @@ -181,7 +181,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -209,12 +208,14 @@ golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/srv/src/post/post.go b/srv/src/post/post.go index bdc48af..5835995 100644 --- a/srv/src/post/post.go +++ b/srv/src/post/post.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "path" "regexp" "strings" "time" @@ -37,6 +36,12 @@ type Post struct { Body string } +// HTTPPath returns the relative URL path of the StoredPost, when querying it +// over HTTP. +func (p Post) HTTPPath() string { + return fmt.Sprintf("%s.html", p.ID) +} + // StoredPost is a Post which has been stored in a Store, and has been given // some extra fields as a result. type StoredPost struct { @@ -46,19 +51,6 @@ type StoredPost struct { LastUpdatedAt time.Time } -// URL returns the relative URL of the StoredPost. -func (p StoredPost) URL() string { - return path.Join( - fmt.Sprintf( - "%d/%0d/%0d", - p.PublishedAt.Year(), - p.PublishedAt.Month(), - p.PublishedAt.Day(), - ), - p.ID+".html", - ) -} - // Store is used for storing posts to a persistent storage. type Store interface { diff --git a/srv/src/post/renderer.go b/srv/src/post/renderer.go new file mode 100644 index 0000000..74acc25 --- /dev/null +++ b/srv/src/post/renderer.go @@ -0,0 +1,96 @@ +package post + +import ( + _ "embed" + "fmt" + "html/template" + "io" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/mediocregopher/blog.mediocregopher.com/srv/tpl" +) + +// RenderablePost is a Post wrapped with extra information necessary for +// rendering. +type RenderablePost struct { + StoredPost + SeriesPrevious, SeriesNext *StoredPost +} + +// NewRenderablePost wraps an existing Post such that it can be rendered. +func NewRenderablePost(store Store, post StoredPost) (RenderablePost, error) { + + renderablePost := RenderablePost{ + StoredPost: post, + } + + if post.Series != "" { + + seriesPosts, err := store.GetBySeries(post.Series) + if err != nil { + return RenderablePost{}, fmt.Errorf( + "fetching posts for series %q: %w", + post.Series, err, + ) + } + + var foundThis bool + + for i := range seriesPosts { + + seriesPost := seriesPosts[i] + + if seriesPost.ID == post.ID { + foundThis = true + continue + } + + if !foundThis { + renderablePost.SeriesPrevious = &seriesPost + continue + } + + renderablePost.SeriesNext = &seriesPost + break + } + } + + return renderablePost, nil +} + +// Renderer takes a Post and renders it to some encoding. +type Renderer interface { + Render(io.Writer, RenderablePost) error +} + +func mdBodyToHTML(body []byte) []byte { + parserExt := parser.CommonExtensions | parser.AutoHeadingIDs + parser := parser.NewWithExtensions(parserExt) + + htmlFlags := html.CommonFlags | html.HrefTargetBlank + htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags}) + + return markdown.ToHTML(body, parser, htmlRenderer) +} + +type mdHTMLRenderer struct{} + +// NewMarkdownToHTMLRenderer renders Posts from markdown to HTML. +func NewMarkdownToHTMLRenderer() Renderer { + return mdHTMLRenderer{} +} + +func (r mdHTMLRenderer) Render(into io.Writer, post RenderablePost) error { + + data := struct { + RenderablePost + Body template.HTML + }{ + RenderablePost: post, + Body: template.HTML(mdBodyToHTML([]byte(post.Body))), + } + + return tpl.HTML.ExecuteTemplate(into, "post.html", data) +} diff --git a/srv/src/post/renderer_test.go b/srv/src/post/renderer_test.go new file mode 100644 index 0000000..5c01cd2 --- /dev/null +++ b/srv/src/post/renderer_test.go @@ -0,0 +1,92 @@ +package post + +import ( + "bytes" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMarkdownBodyToHTML(t *testing.T) { + + tests := []struct { + body string + exp string + }{ + { + body: ` +# Foo +`, + exp: `

Foo

`, + }, + { + body: ` +this is a body + +this is another +`, + exp: ` +

this is a body

+ +

this is another

`, + }, + { + body: `this is a [link](somewhere.html)`, + exp: `

this is a link

`, + }, + } + + for i, test := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + + outB := mdBodyToHTML([]byte(test.body)) + out := string(outB) + + // just to make the tests nicer + out = strings.TrimSpace(out) + test.exp = strings.TrimSpace(test.exp) + + assert.Equal(t, test.exp, out) + }) + } +} + +func TestMarkdownToHTMLRenderer(t *testing.T) { + + r := NewMarkdownToHTMLRenderer() + + post := RenderablePost{ + StoredPost: StoredPost{ + Post: Post{ + ID: "foo", + Title: "Foo", + Description: "Bar.", + Body: "This is the body.", + Series: "baz", + }, + PublishedAt: time.Now(), + }, + + SeriesPrevious: &StoredPost{ + Post: Post{ + ID: "foo-prev", + Title: "Foo Prev", + }, + }, + + SeriesNext: &StoredPost{ + Post: Post{ + ID: "foo-next", + Title: "Foo Next", + }, + }, + } + + buf := new(bytes.Buffer) + err := r.Render(buf, post) + assert.NoError(t, err) + t.Log(buf.String()) +} diff --git a/srv/src/tpl/html/base.html b/srv/src/tpl/html/base.html new file mode 100644 index 0000000..bf81032 --- /dev/null +++ b/srv/src/tpl/html/base.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + +
+ + + + {{ template "body" . }} + +
+

+ Unless otherwised specified, all works are licensed under the + WTFPL. +

+
+ +
+ + + + + diff --git a/srv/src/tpl/html/post.html b/srv/src/tpl/html/post.html new file mode 100644 index 0000000..22a5b97 --- /dev/null +++ b/srv/src/tpl/html/post.html @@ -0,0 +1,48 @@ +{{ define "body" }} + +
+

+ {{ .Title }} +

+
+ {{ .PublishedAt.Format "2006-01-02" }} +  •  + {{ if not .LastUpdatedAt.IsZero }} + (Updated {{ .LastUpdatedAt.Format "2006-01-02" }}) +  •  + {{ end }} + {{ .Description }} +
+
+ +{{ if (or .SeriesPrevious .SeriesNext) }} +

+ This post is part of a series:
+ {{ if .SeriesPrevious }} + Previously: {{ .SeriesPrevious.Title }}
+ {{ end }} + {{ if .SeriesNext }} + Next: {{ .SeriesNext.Title }}
+ {{ end }} +

+{{ end }} + +
+ {{ .Body }} +
+ +{{ if (or .SeriesPrevious .SeriesNext) }} +

+ If you liked this post, consider checking out other posts in the series:
+ {{ if .SeriesPrevious }} + Previously: {{ .SeriesPrevious.Title }}
+ {{ end }} + {{ if .SeriesNext }} + Next: {{ .SeriesNext.Title }}
+ {{ end }} +

+{{ end }} + +{{ end }} + +{{ template "base.html" . }} diff --git a/srv/src/tpl/tpl.go b/srv/src/tpl/tpl.go new file mode 100644 index 0000000..1dd98ba --- /dev/null +++ b/srv/src/tpl/tpl.go @@ -0,0 +1,12 @@ +// Package tpl contains template files which are used to render the blog. +package tpl + +import ( + "embed" + html_tpl "html/template" +) + +//go:embed * +var fs embed.FS + +var HTML = html_tpl.Must(html_tpl.ParseFS(fs, "html/*"))