diff options
author | Brian Picciano <mediocregopher@gmail.com> | 2022-05-14 16:14:11 -0600 |
---|---|---|
committer | Brian Picciano <mediocregopher@gmail.com> | 2022-05-14 17:02:16 -0600 |
commit | 4c04177c05355ddb92d3d31a4c5cfbaa86555a13 (patch) | |
tree | d40a4dcb70ef84ba9356751a9bd96fed99f7f5d2 | |
parent | dd354bc323cd3176c9676444f99b33b69d0a2062 (diff) |
Move template rendering logic into api package
-rw-r--r-- | srv/src/api/api.go | 12 | ||||
-rw-r--r-- | srv/src/api/apiutil/apiutil.go (renamed from srv/src/api/apiutils/apiutils.go) | 4 | ||||
-rw-r--r-- | srv/src/api/chat.go | 36 | ||||
-rw-r--r-- | srv/src/api/csrf.go | 14 | ||||
-rw-r--r-- | srv/src/api/mailinglist.go | 24 | ||||
-rw-r--r-- | srv/src/api/middleware.go | 6 | ||||
-rw-r--r-- | srv/src/api/posts.go | 65 | ||||
-rw-r--r-- | srv/src/api/pow.go | 10 | ||||
-rw-r--r-- | srv/src/api/tpl/base.html (renamed from srv/src/tpl/html/base.html) | 0 | ||||
-rw-r--r-- | srv/src/api/tpl/post.html (renamed from srv/src/tpl/html/post.html) | 0 | ||||
-rw-r--r-- | srv/src/cmd/mediocre-blog/main.go | 1 | ||||
-rw-r--r-- | srv/src/post/renderer.go | 96 | ||||
-rw-r--r-- | srv/src/post/renderer_test.go | 92 | ||||
-rw-r--r-- | srv/src/tpl/tpl.go | 12 |
14 files changed, 111 insertions, 261 deletions
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/apiutils/apiutils.go b/srv/src/api/apiutil/apiutil.go index 223c2b9..c9f8795 100644 --- a/srv/src/api/apiutils/apiutils.go +++ b/srv/src/api/apiutil/apiutil.go @@ -1,6 +1,6 @@ -// Package apiutils contains utilities which are useful for implementing api +// Package apiutil contains utilities which are useful for implementing api // endpoints. -package apiutils +package apiutil import ( "context" 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/tpl/html/base.html b/srv/src/api/tpl/base.html index bf81032..bf81032 100644 --- a/srv/src/tpl/html/base.html +++ b/srv/src/api/tpl/base.html diff --git a/srv/src/tpl/html/post.html b/srv/src/api/tpl/post.html index 22a5b97..22a5b97 100644 --- a/srv/src/tpl/html/post.html +++ b/srv/src/api/tpl/post.html diff --git a/srv/src/cmd/mediocre-blog/main.go b/srv/src/cmd/mediocre-blog/main.go index 28571a3..bdcd1b9 100644 --- a/srv/src/cmd/mediocre-blog/main.go +++ b/srv/src/cmd/mediocre-blog/main.go @@ -124,7 +124,6 @@ func main() { apiParams.Logger = logger.WithNamespace("api") apiParams.PowManager = powMgr apiParams.PostStore = postStore - apiParams.PostHTTPRenderer = post.NewMarkdownToHTMLRenderer() apiParams.MailingList = ml apiParams.GlobalRoom = chatGlobalRoom apiParams.UserIDCalculator = chatUserIDCalc diff --git a/srv/src/post/renderer.go b/srv/src/post/renderer.go deleted file mode 100644 index 74acc25..0000000 --- a/srv/src/post/renderer.go +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 5c01cd2..0000000 --- a/srv/src/post/renderer_test.go +++ /dev/null @@ -1,92 +0,0 @@ -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()) -} diff --git a/srv/src/tpl/tpl.go b/srv/src/tpl/tpl.go deleted file mode 100644 index 1dd98ba..0000000 --- a/srv/src/tpl/tpl.go +++ /dev/null @@ -1,12 +0,0 @@ -// 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/*")) |