Implement rendering Posts to html
This commit is contained in:
parent
d284fe2d25
commit
2929b4279c
@ -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 {
|
||||
|
||||
|
96
srv/src/post/renderer.go
Normal file
96
srv/src/post/renderer.go
Normal file
@ -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)
|
||||
}
|
92
srv/src/post/renderer_test.go
Normal file
92
srv/src/post/renderer_test.go
Normal file
@ -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())
|
||||
}
|
65
srv/src/tpl/html/base.html
Normal file
65
srv/src/tpl/html/base.html
Normal file
@ -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>
|
||||
|
48
srv/src/tpl/html/post.html
Normal file
48
srv/src/tpl/html/post.html
Normal file
@ -0,0 +1,48 @@
|
||||
{{ define "body" }}
|
||||
|
||||
<header id="post-header">
|
||||
<h1 id="post-headline">
|
||||
{{ .Title }}
|
||||
</h1>
|
||||
<div class="light">
|
||||
{{ .PublishedAt.Format "2006-01-02" }}
|
||||
•
|
||||
{{ if not .LastUpdatedAt.IsZero }}
|
||||
(Updated {{ .LastUpdatedAt.Format "2006-01-02" }})
|
||||
•
|
||||
{{ 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" . }}
|
12
srv/src/tpl/tpl.go
Normal file
12
srv/src/tpl/tpl.go
Normal file
@ -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…
Reference in New Issue
Block a user