Implement rendering Posts to html

pull/18/head
Brian Picciano 2 years ago
parent d284fe2d25
commit 2929b4279c
  1. 2
      srv/default.nix
  2. 11
      srv/src/go.mod
  3. 11
      srv/src/go.sum
  4. 20
      srv/src/post/post.go
  5. 96
      srv/src/post/renderer.go
  6. 92
      srv/src/post/renderer_test.go
  7. 65
      srv/src/tpl/html/base.html
  8. 48
      srv/src/tpl/html/post.html
  9. 12
      srv/src/tpl/tpl.go

@ -38,7 +38,7 @@
pname = "mediocre-blog-srv";
version = "dev";
src = ./src;
vendorSha256 = "sha256-MdjPrNSAAiqkAnJRIhMFTVQDKIPuDCHqRQFEtnoe1Cc=";
vendorSha256 = "1s5jhis1a2y7m50k29ap7kd0h4bgc3dzy1f9dqf5jrz8n27f3i87";
# disable tests
checkPhase = '''';

@ -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
)

@ -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=

@ -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 {

@ -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)
}

@ -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: `<h1 id="foo">Foo</h1>`,
},
{
body: `
this is a body
this is another
`,
exp: `
<p>this is a body</p>
<p>this is another</p>`,
},
{
body: `this is a [link](somewhere.html)`,
exp: `<p>this is a <a href="somewhere.html" target="_blank">link</a></p>`,
},
}
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())
}

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/assets/normalize.css">
<link rel="stylesheet" href="/assets/skeleton.css">
<link rel="stylesheet" href="/assets/friendly.css">
<link rel="stylesheet" href="/assets/main.css">
<link rel="stylesheet" href="/assets/fontawesome/css/all.css">
</head>
<body>
<div class="container">
<header id="title-header" role="banner">
<div class="row">
<div class="seven columns" style="margin-bottom: 3rem;">
<h1 class="title">
<a href="/">Mediocre Blog</a>
</h1>
<div class="light social">
<span>By Brian Picciano</span>
<span>
Even more @
<a href="https://mediocregopher.eth.link" target="_blank">https://mediocregopher.eth.link</a>
</span>
</div>
</div>
<div class="five columns light">
<span style="display:block; margin-bottom:0.5rem;">Get notified when new posts are published!</span>
<a href="/follow.html">
<button class="button-primary">
<i class="far fa-envelope"></i>
Follow
</button>
</a>
<a href="/feed.xml">
<button class="button">
<i class="fas fa-rss"></i>
RSS
</button>
</a>
</div>
</div>
</header>
{{ template "body" . }}
<footer>
<p class="license light">
Unless otherwised specified, all works are licensed under the
<a href="/assets/wtfpl.txt">WTFPL</a>.
</p>
</footer>
</div>
</body>
</html>

@ -0,0 +1,48 @@
{{ define "body" }}
<header id="post-header">
<h1 id="post-headline">
{{ .Title }}
</h1>
<div class="light">
{{ .PublishedAt.Format "2006-01-02" }}
&nbsp;&nbsp;
{{ if not .LastUpdatedAt.IsZero }}
(Updated {{ .LastUpdatedAt.Format "2006-01-02" }})
&nbsp;&nbsp;
{{ end }}
<em>{{ .Description }}</em>
</div>
</header>
{{ if (or .SeriesPrevious .SeriesNext) }}
<p class="light"><em>
This post is part of a series:<br/>
{{ if .SeriesPrevious }}
Previously: <a href="{{ .SeriesPrevious.HTTPPath }}">{{ .SeriesPrevious.Title }}</a></br>
{{ end }}
{{ if .SeriesNext }}
Next: <a href="{{ .SeriesNext.HTTPPath }}">{{ .SeriesNext.Title }}</a></br>
{{ end }}
</em></p>
{{ end }}
<div id="post-content">
{{ .Body }}
</div>
{{ if (or .SeriesPrevious .SeriesNext) }}
<p class="light"><em>
If you liked this post, consider checking out other posts in the series:<br/>
{{ if .SeriesPrevious }}
Previously: <a href="{{ .SeriesPrevious.HTTPPath }}">{{ .SeriesPrevious.Title }}</a></br>
{{ end }}
{{ if .SeriesNext }}
Next: <a href="{{ .SeriesNext.HTTPPath }}">{{ .SeriesNext.Title }}</a></br>
{{ end }}
</em></p>
{{ end }}
{{ end }}
{{ template "base.html" . }}

@ -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/*"))
Loading…
Cancel
Save