diff options
-rw-r--r-- | flake.nix | 2 | ||||
-rw-r--r-- | src/cmd/mediocre-blog/main.go | 1 | ||||
-rw-r--r-- | src/gmi/gemtext/gemtext.go (renamed from src/gmi/gemtext.go) | 8 | ||||
-rw-r--r-- | src/gmi/gemtext/gemtext_test.go (renamed from src/gmi/gemtext_test.go) | 6 | ||||
-rw-r--r-- | src/gmi/gmi.go | 14 | ||||
-rw-r--r-- | src/gmi/tpl.go | 150 | ||||
-rw-r--r-- | src/gmi/tpl/posts/post.gmi | 4 | ||||
-rw-r--r-- | src/http/assets.go | 2 | ||||
-rw-r--r-- | src/http/drafts.go | 2 | ||||
-rw-r--r-- | src/http/http.go | 2 | ||||
-rw-r--r-- | src/http/index.go | 2 | ||||
-rw-r--r-- | src/http/posts.go | 129 | ||||
-rw-r--r-- | src/http/tpl.go | 84 | ||||
-rw-r--r-- | src/http/tpl/post.html | 30 | ||||
-rw-r--r-- | src/render/methods.go | 226 | ||||
-rw-r--r-- | src/render/render.go | 2 |
16 files changed, 352 insertions, 312 deletions
@@ -43,7 +43,7 @@ export MEDIOCRE_BLOG_HTTP_PUBLIC_URL="$MEDIOCRE_BLOG_ML_PUBLIC_URL" export MEDIOCRE_BLOG_HTTP_LISTEN_PROTO="tcp" export MEDIOCRE_BLOG_HTTP_LISTEN_ADDR=":4000" - export MEDIOCRE_BLOG_HTTP_GEMINI_GATEWAY_URL="https://nightfall.city/x/" + export MEDIOCRE_BLOG_HTTP_GEMINI_GATEWAY_URL="https://gemini.tildeverse.org/?gemini://" # http auth # (password is "bar". This should definitely be changed for prod.) diff --git a/src/cmd/mediocre-blog/main.go b/src/cmd/mediocre-blog/main.go index 835b2d0..996b769 100644 --- a/src/cmd/mediocre-blog/main.go +++ b/src/cmd/mediocre-blog/main.go @@ -94,6 +94,7 @@ func main() { gmiParams.PostStore = postStore gmiParams.PostAssetLoader = postAssetLoader gmiParams.HTTPPublicURL = httpParams.PublicURL + gmiParams.HTTPGeminiGatewayURL = httpParams.GeminiGatewayURL logger.Info(ctx, "starting gmi api") gmiAPI, err := gmi.New(gmiParams) diff --git a/src/gmi/gemtext.go b/src/gmi/gemtext/gemtext.go index 884635c..5c8f594 100644 --- a/src/gmi/gemtext.go +++ b/src/gmi/gemtext/gemtext.go @@ -1,4 +1,6 @@ -package gmi +// Package gemtext contains code related to processing and producing gemtext +// documents. +package gemtext import ( "bufio" @@ -23,12 +25,12 @@ func hasImgExt(p string) bool { // matches `=> dstURL [optional description]` var linkRegexp = regexp.MustCompile(`^=>\s+(\S+)\s*(.*?)\s*$`) -// GemtextToMarkdown reads a gemtext formatted body from the Reader and writes +// ToMarkdown reads a gemtext formatted body from the Reader and writes // the markdown version of that body to the Writer. // // gmiGateway, if given, is used for all `gemini://` links. The `gemini://` // prefix will be stripped, and replaced with the given URL. -func GemtextToMarkdown(dst io.Writer, src io.Reader, gmiGateway *url.URL) error { +func ToMarkdown(dst io.Writer, src io.Reader, gmiGateway *url.URL) error { bufSrc := bufio.NewReader(src) diff --git a/src/gmi/gemtext_test.go b/src/gmi/gemtext/gemtext_test.go index 75da9df..fe58a64 100644 --- a/src/gmi/gemtext_test.go +++ b/src/gmi/gemtext/gemtext_test.go @@ -1,4 +1,4 @@ -package gmi +package gemtext import ( "bytes" @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGemtextToMarkdown(t *testing.T) { +func TestToMarkdown(t *testing.T) { gmiGateway, _ := url.Parse("https://gateway.com/x/") @@ -58,7 +58,7 @@ func TestGemtextToMarkdown(t *testing.T) { t.Run(strconv.Itoa(i), func(t *testing.T) { got := new(bytes.Buffer) - err := GemtextToMarkdown(got, bytes.NewBufferString(test.in), gmiGateway) + err := ToMarkdown(got, bytes.NewBufferString(test.in), gmiGateway) assert.NoError(t, err) assert.Equal(t, test.exp, got.String()) }) diff --git a/src/gmi/gmi.go b/src/gmi/gmi.go index 467ab5a..e37ca74 100644 --- a/src/gmi/gmi.go +++ b/src/gmi/gmi.go @@ -14,14 +14,14 @@ import ( "path/filepath" "strings" - "git.sr.ht/~adnano/go-gemini" - "git.sr.ht/~adnano/go-gemini/certificate" "dev.mediocregopher.com/mediocre-blog.git/src/cache" "dev.mediocregopher.com/mediocre-blog.git/src/cfg" "dev.mediocregopher.com/mediocre-blog.git/src/post" "dev.mediocregopher.com/mediocre-blog.git/src/post/asset" "dev.mediocregopher.com/mediocre-go-lib.git/mctx" "dev.mediocregopher.com/mediocre-go-lib.git/mlog" + "git.sr.ht/~adnano/go-gemini" + "git.sr.ht/~adnano/go-gemini/certificate" ) // Params are used to instantiate a new API instance. All fields are required @@ -37,7 +37,8 @@ type Params struct { ListenAddr string CertificatesPath string - HTTPPublicURL *url.URL + HTTPPublicURL *url.URL + HTTPGeminiGatewayURL *url.URL } // SetupCfg implement the cfg.Cfger interface. @@ -193,12 +194,7 @@ func postsMiddleware(tplHandler gemini.Handler) gemini.Handler { return } - query := r.URL.Query() - query.Set("id", id) - r.URL.RawQuery = query.Encode() - - r.URL.Path = "/posts/post.gmi" - + ctx = withTplPath(ctx, "/posts/post.gmi") tplHandler.ServeGemini(ctx, rw, r) }) } diff --git a/src/gmi/tpl.go b/src/gmi/tpl.go index 03d9819..8ffa6bc 100644 --- a/src/gmi/tpl.go +++ b/src/gmi/tpl.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "embed" + "errors" "fmt" "io" "io/fs" @@ -11,126 +12,27 @@ import ( "net/url" "path" "path/filepath" - "strconv" "strings" "text/template" - "git.sr.ht/~adnano/go-gemini" "dev.mediocregopher.com/mediocre-blog.git/src/post" + "dev.mediocregopher.com/mediocre-blog.git/src/render" "dev.mediocregopher.com/mediocre-go-lib.git/mctx" - gmnhg "github.com/tdemin/gmnhg" + "git.sr.ht/~adnano/go-gemini" ) -//go:embed tpl -var tplFS embed.FS - -type rendererGetPostsRes struct { - Posts []post.StoredPost - HasMore bool -} - -type rendererGetPostSeriesNextPreviousRes struct { - Next *post.StoredPost - Previous *post.StoredPost -} - -type renderer struct { - url *url.URL - publicURL *url.URL - postStore post.Store - preprocessFuncs post.PreprocessFunctions -} - -func (r renderer) GetPosts(page, count int) (rendererGetPostsRes, error) { - posts, hasMore, err := r.postStore.Get(page, count) - return rendererGetPostsRes{posts, hasMore}, err -} - -func (r renderer) GetPostByID(id string) (post.StoredPost, error) { - p, err := r.postStore.GetByID(id) - if err != nil { - return post.StoredPost{}, fmt.Errorf("fetching post %q: %w", id, err) - } - return p, nil -} - -func (r renderer) GetPostSeriesNextPrevious(p post.StoredPost) (rendererGetPostSeriesNextPreviousRes, error) { - - seriesPosts, err := r.postStore.GetBySeries(p.Series) - if err != nil { - return rendererGetPostSeriesNextPreviousRes{}, fmt.Errorf( - "fetching posts for series %q: %w", p.Series, err, - ) - } - - var ( - res rendererGetPostSeriesNextPreviousRes - foundThis bool - ) - - for i := range seriesPosts { - - seriesPost := seriesPosts[i] - - if seriesPost.ID == p.ID { - foundThis = true - continue - } - - if !foundThis { - res.Next = &seriesPost - continue - } - - res.Previous = &seriesPost - break - } - - return res, nil -} - -func (r renderer) PostBody(p post.StoredPost) (string, error) { - - buf := new(bytes.Buffer) - - if err := p.PreprocessBody(buf, r.preprocessFuncs); err != nil { - return "", fmt.Errorf("preprocessing post body: %w", err) - } - - bodyBytes := buf.Bytes() - - if p.Format == post.FormatMarkdown { - - gemtextBodyBytes, err := gmnhg.RenderMarkdown(bodyBytes, 0) - if err != nil { - return "", fmt.Errorf("converting from markdown: %w", err) - } - - bodyBytes = gemtextBodyBytes - } - - return string(bodyBytes), nil -} +type ctxKey string -func (r renderer) GetQueryValue(key, def string) string { - v := r.url.Query().Get(key) - if v == "" { - v = def - } - return v -} - -func (r renderer) GetQueryIntValue(key string, def int) (int, error) { - vStr := r.GetQueryValue(key, strconv.Itoa(def)) - return strconv.Atoi(vStr) -} +const ( + ctxKeyTplPath ctxKey = "tplPath" +) -func (r renderer) GetPath() (string, error) { - basePath := filepath.Join("/", r.publicURL.Path) // in case it's empty - return filepath.Rel(basePath, r.url.Path) +func withTplPath(ctx context.Context, path string) context.Context { + return context.WithValue(ctx, ctxKeyTplPath, path) } -func (r renderer) Add(a, b int) int { return a + b } +//go:embed tpl +var tplFS embed.FS func (a *api) tplHandler() (gemini.Handler, error) { @@ -177,7 +79,6 @@ func (a *api) tplHandler() (gemini.Handler, error) { return blogURL(a.params.HTTPPublicURL, path, true) }, Image: func(args ...string) (string, error) { - var ( id = args[0] descr = "Image" @@ -243,9 +144,13 @@ func (a *api) tplHandler() (gemini.Handler, error) { rw gemini.ResponseWriter, r *gemini.Request, ) { + tplPath, _ := ctx.Value(ctxKeyTplPath).(string) + if tplPath == "" { + tplPath = r.URL.Path + } + tplPath = strings.TrimPrefix(tplPath, "/") - tplPath := strings.TrimPrefix(r.URL.Path, "/") - mimeType := mime.TypeByExtension(path.Ext(r.URL.Path)) + mimeType := mime.TypeByExtension(path.Ext(tplPath)) ctx = mctx.Annotate(ctx, "url", r.URL, @@ -266,14 +171,19 @@ func (a *api) tplHandler() (gemini.Handler, error) { buf := new(bytes.Buffer) - err := tpl.Execute(buf, renderer{ - url: r.URL, - publicURL: a.params.PublicURL, - postStore: a.params.PostStore, - preprocessFuncs: preprocessFuncs, - }) - - if err != nil { + err := tpl.Execute(buf, render.NewMethods( + ctx, + r.URL, + a.params.PublicURL, + a.params.HTTPGeminiGatewayURL, + a.params.PostStore, + preprocessFuncs, + )) + + if errors.Is(err, post.ErrPostNotFound) { + a.params.Logger.Warn(ctx, "post not found", err) + rw.WriteHeader(gemini.StatusNotFound, "Post not found") + } else if err != nil { a.params.Logger.Error(ctx, "rendering error", err) rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) return diff --git a/src/gmi/tpl/posts/post.gmi b/src/gmi/tpl/posts/post.gmi index 0234395..b568044 100644 --- a/src/gmi/tpl/posts/post.gmi +++ b/src/gmi/tpl/posts/post.gmi @@ -1,4 +1,4 @@ -{{ $post := .GetPostByID (.GetQueryValue "id" "") -}} +{{ $post := .GetThisPost -}} {{ if eq $post.Format "md" -}} This post has been translated from it's original markdown format, if it seems busted it might appear better over HTTP: @@ -14,7 +14,7 @@ This post has been translated from it's original markdown format, if it seems bu {{ end -}} -{{ .PostBody $post }} +{{ .PostGemtextBody $post }} ======================================== diff --git a/src/http/assets.go b/src/http/assets.go index 79ab98c..5a47152 100644 --- a/src/http/assets.go +++ b/src/http/assets.go @@ -34,7 +34,7 @@ func (a *api) managePostAssetsHandler() http.Handler { IDs: ids, } - executeTemplate(rw, r, tpl, tplPayload) + a.executeTemplate(rw, r, tpl, tplPayload) }) } diff --git a/src/http/drafts.go b/src/http/drafts.go index f8e4c8a..b0550ce 100644 --- a/src/http/drafts.go +++ b/src/http/drafts.go @@ -48,7 +48,7 @@ func (a *api) manageDraftPostsHandler() http.Handler { tplPayload.NextPage = page + 1 } - executeTemplate(rw, r, tpl, tplPayload) + a.executeTemplate(rw, r, tpl, tplPayload) }) } diff --git a/src/http/http.go b/src/http/http.go index fd0ea16..9bfed59 100644 --- a/src/http/http.go +++ b/src/http/http.go @@ -146,7 +146,7 @@ func New(params Params) (API, error) { auther: NewAuther(params.AuthUsers, params.AuthRatelimit), } - a.redirectTpl = a.mustParseTpl("redirect.html") + a.redirectTpl = mustParseTpl(a.emptyTpl(), "redirect.html") a.srv = &http.Server{Handler: a.handler()} diff --git a/src/http/index.go b/src/http/index.go index 21c6c16..48d0d33 100644 --- a/src/http/index.go +++ b/src/http/index.go @@ -31,6 +31,6 @@ func (a *api) renderIndexHandler() http.Handler { return } - executeTemplate(rw, r, tpl, nil) + a.executeTemplate(rw, r, tpl, nil) }) } diff --git a/src/http/posts.go b/src/http/posts.go index ab3a18a..939b811 100644 --- a/src/http/posts.go +++ b/src/http/posts.go @@ -12,13 +12,10 @@ import ( txttpl "text/template" "time" - "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" - "dev.mediocregopher.com/mediocre-blog.git/src/gmi" "dev.mediocregopher.com/mediocre-blog.git/src/http/apiutil" "dev.mediocregopher.com/mediocre-blog.git/src/post" "dev.mediocregopher.com/mediocre-blog.git/src/post/asset" + "dev.mediocregopher.com/mediocre-blog.git/src/render" "dev.mediocregopher.com/mediocre-go-lib.git/mctx" ) @@ -92,80 +89,6 @@ func (a *api) postPreprocessFuncs() post.PreprocessFunctions { } } -func (a *api) postToPostTplPayload(storedPost post.StoredPost) (postTplPayload, error) { - - preprocessFuncs := a.postPreprocessFuncs() - - bodyBuf := new(bytes.Buffer) - - if err := storedPost.PreprocessBody(bodyBuf, preprocessFuncs); err != nil { - return postTplPayload{}, fmt.Errorf("preprocessing post body: %w", err) - } - - if storedPost.Format == post.FormatGemtext { - - prevBodyBuf := bodyBuf - bodyBuf = new(bytes.Buffer) - - err := gmi.GemtextToMarkdown( - bodyBuf, prevBodyBuf, a.params.GeminiGatewayURL, - ) - - if err != nil { - return postTplPayload{}, fmt.Errorf("converting gemtext to markdown: %w", err) - } - } - - // this helps the markdown renderer properly parse pages which end in a - // `</script>` tag... I don't know why. - _, _ = bodyBuf.WriteString("\n") - - parserExt := parser.CommonExtensions | parser.AutoHeadingIDs - parser := parser.NewWithExtensions(parserExt) - - htmlFlags := html.HrefTargetBlank - htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags}) - - renderedBody := markdown.ToHTML(bodyBuf.Bytes(), parser, htmlRenderer) - - tplPayload := postTplPayload{ - StoredPost: storedPost, - Body: template.HTML(renderedBody), - } - - if series := storedPost.Series; series != "" { - - seriesPosts, err := a.params.PostStore.GetBySeries(series) - if err != nil { - return postTplPayload{}, fmt.Errorf( - "fetching posts for series %q: %w", series, err, - ) - } - - var foundThis bool - - for i := range seriesPosts { - - seriesPost := seriesPosts[i] - - if seriesPost.ID == storedPost.ID { - foundThis = true - continue - } - - if !foundThis { - tplPayload.SeriesNext = &seriesPost - continue - } - - tplPayload.SeriesPrevious = &seriesPost - break - } - } - - return tplPayload, nil -} - func (a *api) getPostsHandler() http.Handler { tpl := a.mustParseBasedTpl("posts.html") @@ -236,7 +159,7 @@ func (a *api) getPostsHandler() http.Handler { tplPayload.NextPage = page + 1 } - executeTemplate(rw, r, tpl, tplPayload) + a.executeTemplate(rw, r, tpl, tplPayload) }) } @@ -245,37 +168,7 @@ func (a *api) getPostHandler() http.Handler { tpl := a.mustParseBasedTpl("post.html") return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - - id := strings.TrimSuffix(filepath.Base(r.URL.Path), ".html") - - storedPost, err := a.params.PostStore.GetByID(id) - - if errors.Is(err, post.ErrPostNotFound) { - http.Error(rw, "Post not found", 404) - return - } else if err != nil { - apiutil.InternalServerError( - rw, r, fmt.Errorf("fetching post with id %q: %w", id, err), - ) - return - } - - tplPayload, err := a.postToPostTplPayload(storedPost) - - if err != nil { - apiutil.InternalServerError( - rw, r, fmt.Errorf( - "generating template payload for post with id %q: %w", - id, err, - ), - ) - return - } - - executeTemplate( - rw, r, tpl, tplPayload, - executeTemplateWithTitlePrefix(storedPost.Title), - ) + a.executeTemplate(rw, r, tpl, nil) }) } @@ -319,7 +212,7 @@ func (a *api) managePostsHandler() http.Handler { tplPayload.NextPage = page + 1 } - executeTemplate(rw, r, tpl, tplPayload) + a.executeTemplate(rw, r, tpl, tplPayload) }) } @@ -378,7 +271,7 @@ func (a *api) editPostHandler(isDraft bool) http.Handler { Formats: post.Formats, } - executeTemplate(rw, r, tpl, tplPayload) + a.executeTemplate(rw, r, tpl, tplPayload) }) } @@ -516,15 +409,7 @@ func (a *api) previewPostHandler() http.Handler { PublishedAt: time.Now(), } - tplPayload, err := a.postToPostTplPayload(storedPost) - - if err != nil { - apiutil.InternalServerError( - rw, r, fmt.Errorf("generating template payload: %w", err), - ) - return - } - - executeTemplate(rw, r, tpl, tplPayload) + r = r.WithContext(render.WithPost(r.Context(), storedPost)) + a.executeTemplate(rw, r, tpl, nil) }) } diff --git a/src/http/tpl.go b/src/http/tpl.go index 24f2453..c623f2e 100644 --- a/src/http/tpl.go +++ b/src/http/tpl.go @@ -1,9 +1,12 @@ package http import ( + "bytes" "embed" + "errors" "fmt" "html/template" + "io" "io/fs" "net/http" "net/url" @@ -12,6 +15,8 @@ import ( "time" "dev.mediocregopher.com/mediocre-blog.git/src/http/apiutil" + "dev.mediocregopher.com/mediocre-blog.git/src/post" + "dev.mediocregopher.com/mediocre-blog.git/src/render" ) //go:embed tpl @@ -105,77 +110,84 @@ func (a *api) tplFuncs() template.FuncMap { } } -func (a *api) parseTpl(name, tplBody string) (*template.Template, error) { - - tpl := template.New(name) +func (a *api) emptyTpl() *template.Template { + tpl := template.New("") tpl = tpl.Funcs(a.tplFuncs()) tpl = tpl.Funcs(template.FuncMap(a.postPreprocessFuncs().ToFuncMap())) - - var err error - - if tpl, err = tpl.Parse(tplBody); err != nil { - return nil, err - } - - return tpl, nil + return tpl } -func (a *api) mustParseTpl(name string) *template.Template { - return template.Must(a.parseTpl(name, mustReadTplFile(name))) +func mustParseTpl(tpl *template.Template, name string) *template.Template { + return template.Must(tpl.New(name).Parse(mustReadTplFile(name))) } func (a *api) mustParseBasedTpl(name string) *template.Template { - tpl := a.mustParseTpl(name) - tpl = template.Must(tpl.New("gemini-cta.html").Parse(mustReadTplFile("gemini-cta.html"))) - tpl = template.Must(tpl.New("base.html").Parse(mustReadTplFile("base.html"))) + tpl := a.emptyTpl() + tpl = mustParseTpl(tpl, "gemini-cta.html") + tpl = mustParseTpl(tpl, "base.html") + tpl = mustParseTpl(tpl, name) return tpl } type tplData struct { + *render.Methods Payload interface{} Title string } -func newTPLData(r *http.Request, payload interface{}) tplData { +// WithTitlePrefix returns a copy of tplData but with the given string prefixed +// to the page title. This is intended for use within templates, when nesting +// the base template. +func (d tplData) WithTitlePrefix(prefix string) tplData { + d.Title = prefix + " - " + d.Title + return d +} + +func (a *api) newTPLData(r *http.Request, payload interface{}) tplData { return tplData{ + Methods: render.NewMethods( + r.Context(), + r.URL, + a.params.PublicURL, + a.params.GeminiGatewayURL, + a.params.PostStore, + a.postPreprocessFuncs(), + ), Payload: payload, Title: "mediocregopher's lil web corner", } } -type executeTemplateOpt func(*tplData) - -func executeTemplateWithTitlePrefix(prefix string) executeTemplateOpt { - return func(d *tplData) { - d.Title = prefix + " - " + d.Title - } -} - // executeTemplate expects to be the final action in an http.Handler -func executeTemplate( - rw http.ResponseWriter, r *http.Request, - tpl *template.Template, payload interface{}, - opts ...executeTemplateOpt, +func (a *api) executeTemplate( + rw http.ResponseWriter, + r *http.Request, + tpl *template.Template, + payload interface{}, ) { - tplData := newTPLData(r, payload) + tplData := a.newTPLData(r, payload) - for _, opt := range opts { - opt(&tplData) - } + buf := new(bytes.Buffer) - if err := tpl.Execute(rw, tplData); err != nil { + err := tpl.Execute(buf, tplData) + if errors.Is(err, post.ErrPostNotFound) { + http.Error(rw, "Post not found", 404) + return + } else if err != nil { apiutil.InternalServerError( rw, r, fmt.Errorf("rendering template: %w", err), ) return } + + io.Copy(rw, buf) } func (a *api) executeRedirectTpl( rw http.ResponseWriter, r *http.Request, url string, ) { - executeTemplate(rw, r, a.redirectTpl, struct { + a.executeTemplate(rw, r, a.redirectTpl, struct { URL string }{ URL: url, @@ -188,7 +200,7 @@ func (a *api) renderDumbTplHandler(tplName string) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - tplData := newTPLData(r, nil) + tplData := a.newTPLData(r, nil) if err := tpl.Execute(rw, tplData); err != nil { apiutil.InternalServerError( diff --git a/src/http/tpl/post.html b/src/http/tpl/post.html index db69302..0cf3622 100644 --- a/src/http/tpl/post.html +++ b/src/http/tpl/post.html @@ -1,45 +1,51 @@ {{ define "body" }} +{{ $post := .GetThisPost -}} <h1 id="post-headline"> - {{ .Payload.Title }} + {{ $post.Title }} </h1> -{{ if ne .Payload.Description "" }} +{{ if ne $post.Description "" }} <p> - <em>- {{ .Payload.Description }}</em> + <em>- {{ $post.Description }}</em> </p> {{ end }} <hr/> -{{ .Payload.Body }} +{{ .PostHTMLBody $post }} <p><em> - Published {{ DateTimeFormat .Payload.PublishedAt }} + Published {{ DateTimeFormat $post.PublishedAt }} </em></p> -{{ if (or .Payload.SeriesPrevious .Payload.SeriesNext) }} +{{- if $post.Series }} +{{ $seriesNextPrev := .GetPostSeriesNextPrevious $post -}} +{{ if or $seriesNextPrev.Next $seriesNextPrev.Previous }} <hr/> <p><em> This post is part of a series.<br/> - {{ if .Payload.SeriesPrevious }} - Previously: <a href="{{ PostURL .Payload.SeriesPrevious.ID }}">{{ .Payload.SeriesPrevious.Title }}</a> + {{ if $seriesNextPrev.Next }} + Next: <a href="{{ PostURL $seriesNextPrev.Next.ID }}">{{ $seriesNextPrev.Next.Title }}</a> {{ end }} - {{ if (and .Payload.SeriesNext .Payload.SeriesPrevious) }} + {{ if and $seriesNextPrev.Next $seriesNextPrev.Previous }} </br> {{ end }} - {{ if .Payload.SeriesNext }} - Next: <a href="{{ PostURL .Payload.SeriesNext.ID }}">{{ .Payload.SeriesNext.Title }}</a></br> + {{ if $seriesNextPrev.Previous }} + Previously: <a href="{{ PostURL $seriesNextPrev.Previous.ID }}">{{ $seriesNextPrev.Previous.Title }}</a> + <br/> {{ end }} </em></p> {{ end }} +{{ end }} {{ template "gemini-cta.html" . }} {{ end }} -{{ template "base.html" . }} +{{ $post := .GetThisPost -}} +{{ template "base.html" (.WithTitlePrefix $post.Title) }} diff --git a/src/render/methods.go b/src/render/methods.go new file mode 100644 index 0000000..6dd9332 --- /dev/null +++ b/src/render/methods.go @@ -0,0 +1,226 @@ +package render + +import ( + "bytes" + "context" + "fmt" + "html/template" + "net/url" + "path" + "path/filepath" + "strconv" + "strings" + + "dev.mediocregopher.com/mediocre-blog.git/src/gmi/gemtext" + "dev.mediocregopher.com/mediocre-blog.git/src/post" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + gmnhg "github.com/tdemin/gmnhg" +) + +type ctxKey string + +const ( + ctxKeyPost ctxKey = "post" +) + +// WithPost sets the post on a Context, such that it can be retrieved using +// the GetThisPost method. +func WithPost(ctx context.Context, p post.StoredPost) context.Context { + return context.WithValue(ctx, ctxKeyPost, p) +} + +// GetPostsRes are the fields returned from the GetPosts method. +type GetPostsRes struct { + Posts []post.StoredPost + HasMore bool +} + +// GetPostSeriesNextPreviousRes are the fields returned from the +// GetPostSeriesNextPreviousRes method. +type GetPostSeriesNextPreviousRes struct { + Next *post.StoredPost + Previous *post.StoredPost +} + +// Methods carries methods which are designed to be accessible from a template. +type Methods struct { + ctx context.Context + url *url.URL + publicURL *url.URL + geminiGatewayURL *url.URL + postStore post.Store + preprocessFuncs post.PreprocessFunctions + + thisPost *post.StoredPost // cache +} + +// NewMethods initializes a Methods using its required dependencies. +func NewMethods( + ctx context.Context, + url *url.URL, + publicURL *url.URL, + geminiGatewayURL *url.URL, + postStore post.Store, + preprocessFuncs post.PreprocessFunctions, +) *Methods { + return &Methods{ + ctx, + url, + publicURL, + geminiGatewayURL, + postStore, + preprocessFuncs, + nil, // thisPost + } +} + +func (m *Methods) GetPosts(page, count int) (GetPostsRes, error) { + posts, hasMore, err := m.postStore.Get(page, count) + return GetPostsRes{posts, hasMore}, err +} + +func (m *Methods) GetThisPost() (p post.StoredPost, err error) { + if m.thisPost != nil { + return *m.thisPost, nil + } + + defer func() { + m.thisPost = &p + }() + + if p, ok := m.ctx.Value(ctxKeyPost).(post.StoredPost); ok { + return p, nil + } + + id := path.Base(m.url.Path) + id = strings.TrimSuffix(id, path.Ext(id)) + + return m.postStore.GetByID(id) +} + +func (m *Methods) GetPostByID(id string) (post.StoredPost, error) { + p, err := m.postStore.GetByID(id) + if err != nil { + return post.StoredPost{}, fmt.Errorf("fetching post %q: %w", id, err) + } + return p, nil +} + +func (m *Methods) GetPostSeriesNextPrevious( + p post.StoredPost, +) ( + GetPostSeriesNextPreviousRes, error, +) { + + seriesPosts, err := m.postStore.GetBySeries(p.Series) + if err != nil { + return GetPostSeriesNextPreviousRes{}, fmt.Errorf( + "fetching posts for series %q: %w", p.Series, err, + ) + } + + var ( + res GetPostSeriesNextPreviousRes + foundThis bool + ) + + for i := range seriesPosts { + + seriesPost := seriesPosts[i] + + if seriesPost.ID == p.ID { + foundThis = true + continue + } + + if !foundThis { + res.Next = &seriesPost + continue + } + + res.Previous = &seriesPost + break + } + + return res, nil +} + +func (m *Methods) PostGemtextBody(p post.StoredPost) (string, error) { + + buf := new(bytes.Buffer) + + if err := p.PreprocessBody(buf, m.preprocessFuncs); err != nil { + return "", fmt.Errorf("preprocessing post body: %w", err) + } + + bodyBytes := buf.Bytes() + + if p.Format == post.FormatMarkdown { + + gemtextBodyBytes, err := gmnhg.RenderMarkdown(bodyBytes, 0) + if err != nil { + return "", fmt.Errorf("converting from markdown: %w", err) + } + + bodyBytes = gemtextBodyBytes + } + + return string(bodyBytes), nil +} + +func (m *Methods) PostHTMLBody(p post.StoredPost) (template.HTML, error) { + bodyBuf := new(bytes.Buffer) + + if err := p.PreprocessBody(bodyBuf, m.preprocessFuncs); err != nil { + return "", fmt.Errorf("preprocessing post body: %w", err) + } + + if p.Format == post.FormatGemtext { + + prevBodyBuf := bodyBuf + bodyBuf = new(bytes.Buffer) + + err := gemtext.ToMarkdown( + bodyBuf, prevBodyBuf, m.geminiGatewayURL, + ) + + if err != nil { + return "", fmt.Errorf("converting gemtext to markdown: %w", err) + } + } + + // this helps the markdown renderer properly parse pages which end in a + // `</script>` tag... I don't know why. + _, _ = bodyBuf.WriteString("\n") + + parserExt := parser.CommonExtensions | parser.AutoHeadingIDs + parser := parser.NewWithExtensions(parserExt) + + htmlFlags := html.HrefTargetBlank + htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags}) + + renderedBody := markdown.ToHTML(bodyBuf.Bytes(), parser, htmlRenderer) + return template.HTML(renderedBody), nil +} + +func (m *Methods) GetQueryValue(key, def string) string { + v := m.url.Query().Get(key) + if v == "" { + v = def + } + return v +} + +func (m *Methods) GetQueryIntValue(key string, def int) (int, error) { + vStr := m.GetQueryValue(key, strconv.Itoa(def)) + return strconv.Atoi(vStr) +} + +func (m *Methods) GetPath() (string, error) { + basePath := filepath.Join("/", m.publicURL.Path) // in case it's empty + return filepath.Rel(basePath, m.url.Path) +} + +func (m *Methods) Add(a, b int) int { return a + b } diff --git a/src/render/render.go b/src/render/render.go new file mode 100644 index 0000000..28e60df --- /dev/null +++ b/src/render/render.go @@ -0,0 +1,2 @@ +// Package render implements shared utilities used when rendering templates. +package render |