parent
d284fe2d25
commit
2929b4279c
@ -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" }} |
||||
• |
||||
{{ 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" . }} |
@ -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