From 4c04177c05355ddb92d3d31a4c5cfbaa86555a13 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 14 May 2022 16:14:11 -0600 Subject: Move template rendering logic into api package --- srv/src/api/api.go | 12 +++-- srv/src/api/apiutil/apiutil.go | 112 +++++++++++++++++++++++++++++++++++++++ srv/src/api/apiutils/apiutils.go | 112 --------------------------------------- srv/src/api/chat.go | 36 ++++++------- srv/src/api/csrf.go | 14 ++--- srv/src/api/mailinglist.go | 24 ++++----- srv/src/api/middleware.go | 6 +-- srv/src/api/posts.go | 65 +++++++++++++++++++---- srv/src/api/pow.go | 10 ++-- srv/src/api/tpl/base.html | 65 +++++++++++++++++++++++ srv/src/api/tpl/post.html | 48 +++++++++++++++++ 11 files changed, 334 insertions(+), 170 deletions(-) create mode 100644 srv/src/api/apiutil/apiutil.go delete mode 100644 srv/src/api/apiutils/apiutils.go create mode 100644 srv/src/api/tpl/base.html create mode 100644 srv/src/api/tpl/post.html (limited to 'srv/src/api') diff --git a/srv/src/api/api.go b/srv/src/api/api.go index 75147d5..92771a1 100644 --- a/srv/src/api/api.go +++ b/srv/src/api/api.go @@ -3,8 +3,10 @@ package api import ( "context" + "embed" "errors" "fmt" + "html/template" "net" "net/http" "net/http/httputil" @@ -20,14 +22,18 @@ import ( "github.com/mediocregopher/mediocre-go-lib/v2/mlog" ) +//go:embed tpl +var fs embed.FS + +var tpls = template.Must(template.ParseFS(fs, "tpl/*")) + // Params are used to instantiate a new API instance. All fields are required // unless otherwise noted. type Params struct { Logger *mlog.Logger PowManager pow.Manager - PostStore post.Store - PostHTTPRenderer post.Renderer + PostStore post.Store MailingList mailinglist.MailingList @@ -190,7 +196,7 @@ func (a *api) handler() http.Handler { mux.Handle("/api/", http.StripPrefix("/api", apiHandler)) - mux.Handle("/posts/", a.postHandler()) + mux.Handle("/v2/posts/", a.postHandler()) return mux } diff --git a/srv/src/api/apiutil/apiutil.go b/srv/src/api/apiutil/apiutil.go new file mode 100644 index 0000000..c9f8795 --- /dev/null +++ b/srv/src/api/apiutil/apiutil.go @@ -0,0 +1,112 @@ +// Package apiutil contains utilities which are useful for implementing api +// endpoints. +package apiutil + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/mediocregopher/mediocre-go-lib/v2/mlog" +) + +type loggerCtxKey int + +// SetRequestLogger sets the given Logger onto the given Request's Context, +// returning a copy. +func SetRequestLogger(r *http.Request, logger *mlog.Logger) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, loggerCtxKey(0), logger) + return r.WithContext(ctx) +} + +// GetRequestLogger returns the Logger which was set by SetRequestLogger onto +// this Request, or nil. +func GetRequestLogger(r *http.Request) *mlog.Logger { + ctx := r.Context() + logger, _ := ctx.Value(loggerCtxKey(0)).(*mlog.Logger) + if logger == nil { + logger = mlog.Null + } + return logger +} + +// JSONResult writes the JSON encoding of the given value as the response body. +func JSONResult(rw http.ResponseWriter, r *http.Request, v interface{}) { + b, err := json.Marshal(v) + if err != nil { + InternalServerError(rw, r, err) + return + } + b = append(b, '\n') + + rw.Header().Set("Content-Type", "application/json") + rw.Write(b) +} + +// BadRequest writes a 400 status and a JSON encoded error struct containing the +// given error as the response body. +func BadRequest(rw http.ResponseWriter, r *http.Request, err error) { + GetRequestLogger(r).Warn(r.Context(), "bad request", err) + + rw.WriteHeader(400) + JSONResult(rw, r, struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) +} + +// InternalServerError writes a 500 status and a JSON encoded error struct +// containing a generic error as the response body (though it will log the given +// one). +func InternalServerError(rw http.ResponseWriter, r *http.Request, err error) { + GetRequestLogger(r).Error(r.Context(), "internal server error", err) + + rw.WriteHeader(500) + JSONResult(rw, r, struct { + Error string `json:"error"` + }{ + Error: "internal server error", + }) +} + +// StrToInt parses the given string as an integer, or returns the given default +// integer if the string is empty. +func StrToInt(str string, defaultVal int) (int, error) { + if str == "" { + return defaultVal, nil + } + return strconv.Atoi(str) +} + +// GetCookie returns the namd cookie's value, or the given default value if the +// cookie is not set. +// +// This will only return an error if there was an unexpected error parsing the +// Request's cookies. +func GetCookie(r *http.Request, cookieName, defaultVal string) (string, error) { + c, err := r.Cookie(cookieName) + if errors.Is(err, http.ErrNoCookie) { + return defaultVal, nil + } else if err != nil { + return "", fmt.Errorf("reading cookie %q: %w", cookieName, err) + } + + return c.Value, nil +} + +// RandStr returns a human-readable random string with the given number of bytes +// of randomness. +func RandStr(numBytes int) string { + b := make([]byte, numBytes) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/srv/src/api/apiutils/apiutils.go b/srv/src/api/apiutils/apiutils.go deleted file mode 100644 index 223c2b9..0000000 --- a/srv/src/api/apiutils/apiutils.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package apiutils contains utilities which are useful for implementing api -// endpoints. -package apiutils - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/mediocregopher/mediocre-go-lib/v2/mlog" -) - -type loggerCtxKey int - -// SetRequestLogger sets the given Logger onto the given Request's Context, -// returning a copy. -func SetRequestLogger(r *http.Request, logger *mlog.Logger) *http.Request { - ctx := r.Context() - ctx = context.WithValue(ctx, loggerCtxKey(0), logger) - return r.WithContext(ctx) -} - -// GetRequestLogger returns the Logger which was set by SetRequestLogger onto -// this Request, or nil. -func GetRequestLogger(r *http.Request) *mlog.Logger { - ctx := r.Context() - logger, _ := ctx.Value(loggerCtxKey(0)).(*mlog.Logger) - if logger == nil { - logger = mlog.Null - } - return logger -} - -// JSONResult writes the JSON encoding of the given value as the response body. -func JSONResult(rw http.ResponseWriter, r *http.Request, v interface{}) { - b, err := json.Marshal(v) - if err != nil { - InternalServerError(rw, r, err) - return - } - b = append(b, '\n') - - rw.Header().Set("Content-Type", "application/json") - rw.Write(b) -} - -// BadRequest writes a 400 status and a JSON encoded error struct containing the -// given error as the response body. -func BadRequest(rw http.ResponseWriter, r *http.Request, err error) { - GetRequestLogger(r).Warn(r.Context(), "bad request", err) - - rw.WriteHeader(400) - JSONResult(rw, r, struct { - Error string `json:"error"` - }{ - Error: err.Error(), - }) -} - -// InternalServerError writes a 500 status and a JSON encoded error struct -// containing a generic error as the response body (though it will log the given -// one). -func InternalServerError(rw http.ResponseWriter, r *http.Request, err error) { - GetRequestLogger(r).Error(r.Context(), "internal server error", err) - - rw.WriteHeader(500) - JSONResult(rw, r, struct { - Error string `json:"error"` - }{ - Error: "internal server error", - }) -} - -// StrToInt parses the given string as an integer, or returns the given default -// integer if the string is empty. -func StrToInt(str string, defaultVal int) (int, error) { - if str == "" { - return defaultVal, nil - } - return strconv.Atoi(str) -} - -// GetCookie returns the namd cookie's value, or the given default value if the -// cookie is not set. -// -// This will only return an error if there was an unexpected error parsing the -// Request's cookies. -func GetCookie(r *http.Request, cookieName, defaultVal string) (string, error) { - c, err := r.Cookie(cookieName) - if errors.Is(err, http.ErrNoCookie) { - return defaultVal, nil - } else if err != nil { - return "", fmt.Errorf("reading cookie %q: %w", cookieName, err) - } - - return c.Value, nil -} - -// RandStr returns a human-readable random string with the given number of bytes -// of randomness. -func RandStr(numBytes int) string { - b := make([]byte, numBytes) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} diff --git a/srv/src/api/chat.go b/srv/src/api/chat.go index a1acc5a..f4b90ef 100644 --- a/srv/src/api/chat.go +++ b/srv/src/api/chat.go @@ -9,7 +9,7 @@ import ( "unicode" "github.com/gorilla/websocket" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/chat" ) @@ -44,9 +44,9 @@ func newChatHandler( func (c *chatHandler) historyHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - limit, err := apiutils.StrToInt(r.PostFormValue("limit"), 0) + limit, err := apiutil.StrToInt(r.PostFormValue("limit"), 0) if err != nil { - apiutils.BadRequest(rw, r, fmt.Errorf("invalid limit parameter: %w", err)) + apiutil.BadRequest(rw, r, fmt.Errorf("invalid limit parameter: %w", err)) return } @@ -58,13 +58,13 @@ func (c *chatHandler) historyHandler() http.Handler { }) if argErr := (chat.ErrInvalidArg{}); errors.As(err, &argErr) { - apiutils.BadRequest(rw, r, argErr.Err) + apiutil.BadRequest(rw, r, argErr.Err) return } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) } - apiutils.JSONResult(rw, r, struct { + apiutil.JSONResult(rw, r, struct { Cursor string `json:"cursor"` Messages []chat.Message `json:"messages"` }{ @@ -107,11 +107,11 @@ func (c *chatHandler) userIDHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { userID, err := c.userID(r) if err != nil { - apiutils.BadRequest(rw, r, err) + apiutil.BadRequest(rw, r, err) return } - apiutils.JSONResult(rw, r, struct { + apiutil.JSONResult(rw, r, struct { UserID chat.UserID `json:"userID"` }{ UserID: userID, @@ -123,18 +123,18 @@ func (c *chatHandler) appendHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { userID, err := c.userID(r) if err != nil { - apiutils.BadRequest(rw, r, err) + apiutil.BadRequest(rw, r, err) return } body := r.PostFormValue("body") if l := len(body); l == 0 { - apiutils.BadRequest(rw, r, errors.New("body is required")) + apiutil.BadRequest(rw, r, errors.New("body is required")) return } else if l > 300 { - apiutils.BadRequest(rw, r, errors.New("body too long")) + apiutil.BadRequest(rw, r, errors.New("body too long")) return } @@ -144,11 +144,11 @@ func (c *chatHandler) appendHandler() http.Handler { }) if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } - apiutils.JSONResult(rw, r, struct { + apiutil.JSONResult(rw, r, struct { MessageID string `json:"messageID"` }{ MessageID: msg.ID, @@ -164,7 +164,7 @@ func (c *chatHandler) listenHandler() http.Handler { conn, err := c.wsUpgrader.Upgrade(rw, r, nil) if err != nil { - apiutils.BadRequest(rw, r, err) + apiutil.BadRequest(rw, r, err) return } defer conn.Close() @@ -172,14 +172,14 @@ func (c *chatHandler) listenHandler() http.Handler { it, err := c.room.Listen(ctx, sinceID) if errors.As(err, new(chat.ErrInvalidArg)) { - apiutils.BadRequest(rw, r, err) + apiutil.BadRequest(rw, r, err) return } else if errors.Is(err, context.Canceled) { return } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } @@ -192,7 +192,7 @@ func (c *chatHandler) listenHandler() http.Handler { return } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } @@ -203,7 +203,7 @@ func (c *chatHandler) listenHandler() http.Handler { }) if err != nil { - apiutils.GetRequestLogger(r).Error(ctx, "couldn't write message", err) + apiutil.GetRequestLogger(r).Error(ctx, "couldn't write message", err) return } } diff --git a/srv/src/api/csrf.go b/srv/src/api/csrf.go index 13b6ec6..9717030 100644 --- a/srv/src/api/csrf.go +++ b/srv/src/api/csrf.go @@ -4,7 +4,7 @@ import ( "errors" "net/http" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" ) const ( @@ -15,16 +15,16 @@ const ( func setCSRFMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - csrfTok, err := apiutils.GetCookie(r, csrfTokenCookieName, "") + csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "") if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } else if csrfTok == "" { http.SetCookie(rw, &http.Cookie{ Name: csrfTokenCookieName, - Value: apiutils.RandStr(32), + Value: apiutil.RandStr(32), Secure: true, }) } @@ -36,10 +36,10 @@ func setCSRFMiddleware(h http.Handler) http.Handler { func checkCSRFMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - csrfTok, err := apiutils.GetCookie(r, csrfTokenCookieName, "") + csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "") if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } @@ -49,7 +49,7 @@ func checkCSRFMiddleware(h http.Handler) http.Handler { } if csrfTok == "" || givenCSRFTok != csrfTok { - apiutils.BadRequest(rw, r, errors.New("invalid CSRF token")) + apiutil.BadRequest(rw, r, errors.New("invalid CSRF token")) return } diff --git a/srv/src/api/mailinglist.go b/srv/src/api/mailinglist.go index d89fe2a..c12e75d 100644 --- a/srv/src/api/mailinglist.go +++ b/srv/src/api/mailinglist.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" ) @@ -16,7 +16,7 @@ func (a *api) mailingListSubscribeHandler() http.Handler { parts[0] == "" || parts[1] == "" || len(email) >= 512 { - apiutils.BadRequest(rw, r, errors.New("invalid email")) + apiutil.BadRequest(rw, r, errors.New("invalid email")) return } @@ -26,11 +26,11 @@ func (a *api) mailingListSubscribeHandler() http.Handler { // just eat the error, make it look to the user like the // verification email was sent. } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } - apiutils.JSONResult(rw, r, struct{}{}) + apiutil.JSONResult(rw, r, struct{}{}) }) } @@ -40,25 +40,25 @@ func (a *api) mailingListFinalizeHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { subToken := r.PostFormValue("subToken") if l := len(subToken); l == 0 || l > 128 { - apiutils.BadRequest(rw, r, errInvalidSubToken) + apiutil.BadRequest(rw, r, errInvalidSubToken) return } err := a.params.MailingList.FinalizeSubscription(subToken) if errors.Is(err, mailinglist.ErrNotFound) { - apiutils.BadRequest(rw, r, errInvalidSubToken) + apiutil.BadRequest(rw, r, errInvalidSubToken) return } else if errors.Is(err, mailinglist.ErrAlreadyVerified) { // no problem } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } - apiutils.JSONResult(rw, r, struct{}{}) + apiutil.JSONResult(rw, r, struct{}{}) }) } @@ -68,21 +68,21 @@ func (a *api) mailingListUnsubscribeHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { unsubToken := r.PostFormValue("unsubToken") if l := len(unsubToken); l == 0 || l > 128 { - apiutils.BadRequest(rw, r, errInvalidUnsubToken) + apiutil.BadRequest(rw, r, errInvalidUnsubToken) return } err := a.params.MailingList.Unsubscribe(unsubToken) if errors.Is(err, mailinglist.ErrNotFound) { - apiutils.BadRequest(rw, r, errInvalidUnsubToken) + apiutil.BadRequest(rw, r, errInvalidUnsubToken) return } else if err != nil { - apiutils.InternalServerError(rw, r, err) + apiutil.InternalServerError(rw, r, err) return } - apiutils.JSONResult(rw, r, struct{}{}) + apiutil.JSONResult(rw, r, struct{}{}) }) } diff --git a/srv/src/api/middleware.go b/srv/src/api/middleware.go index 6ea0d13..0b3eec7 100644 --- a/srv/src/api/middleware.go +++ b/srv/src/api/middleware.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mlog" ) @@ -61,7 +61,7 @@ func (lrw *logResponseWriter) WriteHeader(statusCode int) { func logMiddleware(logger *mlog.Logger, h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - r = apiutils.SetRequestLogger(r, logger) + r = apiutil.SetRequestLogger(r, logger) lrw := newLogResponseWriter(rw) @@ -90,7 +90,7 @@ func postOnlyMiddleware(h http.Handler) http.Handler { return } - apiutils.GetRequestLogger(r).WarnString(r.Context(), "method not allowed") + apiutil.GetRequestLogger(r).WarnString(r.Context(), "method not allowed") rw.WriteHeader(405) }) } diff --git a/srv/src/api/posts.go b/srv/src/api/posts.go index 995f2fb..cc7a176 100644 --- a/srv/src/api/posts.go +++ b/srv/src/api/posts.go @@ -3,11 +3,15 @@ package api import ( "errors" "fmt" + "html/template" "net/http" "path/filepath" "strings" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/post" ) @@ -22,22 +26,63 @@ func (a *api) postHandler() http.Handler { http.Error(rw, "Post not found", 404) return } else if err != nil { - apiutils.InternalServerError( + apiutil.InternalServerError( rw, r, fmt.Errorf("fetching post with id %q: %w", id, err), ) return } - renderablePost, err := post.NewRenderablePost(a.params.PostStore, storedPost) - if err != nil { - apiutils.InternalServerError( - rw, r, fmt.Errorf("constructing renderable post with id %q: %w", id, err), - ) - return + parserExt := parser.CommonExtensions | parser.AutoHeadingIDs + parser := parser.NewWithExtensions(parserExt) + + htmlFlags := html.CommonFlags | html.HrefTargetBlank + htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags}) + + renderedBody := markdown.ToHTML([]byte(storedPost.Body), parser, htmlRenderer) + + tplData := struct { + post.StoredPost + SeriesPrevious, SeriesNext *post.StoredPost + Body template.HTML + }{ + StoredPost: storedPost, + Body: template.HTML(renderedBody), + } + + if series := storedPost.Series; series != "" { + + seriesPosts, err := a.params.PostStore.GetBySeries(series) + if err != nil { + apiutil.InternalServerError( + rw, r, + fmt.Errorf("fetching posts for series %q: %w", series, err), + ) + return + } + + var foundThis bool + + for i := range seriesPosts { + + seriesPost := seriesPosts[i] + + if seriesPost.ID == storedPost.ID { + foundThis = true + continue + } + + if !foundThis { + tplData.SeriesPrevious = &seriesPost + continue + } + + tplData.SeriesNext = &seriesPost + break + } } - if err := a.params.PostHTTPRenderer.Render(rw, renderablePost); err != nil { - apiutils.InternalServerError( + if err := tpls.ExecuteTemplate(rw, "post.html", tplData); err != nil { + apiutil.InternalServerError( rw, r, fmt.Errorf("rendering post with id %q: %w", id, err), ) return diff --git a/srv/src/api/pow.go b/srv/src/api/pow.go index 1b232b1..ae2d2f1 100644 --- a/srv/src/api/pow.go +++ b/srv/src/api/pow.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" - "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil" ) func (a *api) newPowChallengeHandler() http.Handler { @@ -14,7 +14,7 @@ func (a *api) newPowChallengeHandler() http.Handler { challenge := a.params.PowManager.NewChallenge() - apiutils.JSONResult(rw, r, struct { + apiutil.JSONResult(rw, r, struct { Seed string `json:"seed"` Target uint32 `json:"target"` }{ @@ -30,21 +30,21 @@ func (a *api) requirePowMiddleware(h http.Handler) http.Handler { seedHex := r.FormValue("powSeed") seed, err := hex.DecodeString(seedHex) if err != nil || len(seed) == 0 { - apiutils.BadRequest(rw, r, errors.New("invalid powSeed")) + apiutil.BadRequest(rw, r, errors.New("invalid powSeed")) return } solutionHex := r.FormValue("powSolution") solution, err := hex.DecodeString(solutionHex) if err != nil || len(seed) == 0 { - apiutils.BadRequest(rw, r, errors.New("invalid powSolution")) + apiutil.BadRequest(rw, r, errors.New("invalid powSolution")) return } err = a.params.PowManager.CheckSolution(seed, solution) if err != nil { - apiutils.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err)) + apiutil.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err)) return } diff --git a/srv/src/api/tpl/base.html b/srv/src/api/tpl/base.html new file mode 100644 index 0000000..bf81032 --- /dev/null +++ b/srv/src/api/tpl/base.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + +
+ + + + {{ template "body" . }} + +
+

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

+
+ +
+ + + + + diff --git a/srv/src/api/tpl/post.html b/srv/src/api/tpl/post.html new file mode 100644 index 0000000..22a5b97 --- /dev/null +++ b/srv/src/api/tpl/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" . }} -- cgit v1.2.3