package render import ( "bytes" "context" "fmt" "html/template" "io" "net/url" "path" "path/filepath" "strconv" "strings" txttpl "text/template" "dev.mediocregopher.com/mediocre-blog.git/src/gmi/gemtext" "dev.mediocregopher.com/mediocre-blog.git/src/post" "dev.mediocregopher.com/mediocre-blog.git/src/post/asset" "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 } // GetDraftPostsRes are the fields returned from the GetDraftPosts method. type GetDraftPostsRes struct { Posts []post.Post 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 httpURL *url.URL geminiURL *url.URL geminiGatewayURL *url.URL postStore post.Store postAssetStore asset.Store postDraftStore post.DraftStore preprocessFuncs post.PreprocessFunctions thisPost *post.StoredPost // cache thisDraftPost *post.Post // cache } // NewMethods initializes a Methods using its required dependencies. func NewMethods( ctx context.Context, url *url.URL, publicURL *url.URL, httpURL *url.URL, geminiURL *url.URL, geminiGatewayURL *url.URL, postStore post.Store, postAssetStore asset.Store, postDraftStore post.DraftStore, preprocessFuncs post.PreprocessFunctions, ) *Methods { return &Methods{ ctx, url, publicURL, httpURL, geminiURL, geminiGatewayURL, postStore, postAssetStore, postDraftStore, preprocessFuncs, nil, // thisPost nil, // thisDraftPost } } func (m *Methods) RootURL() URLBuilder { return NewURLBuilder(m.publicURL, m.httpURL, m.geminiURL) } func (m *Methods) GetTags() ([]string, error) { return m.postStore.GetTags() } func (m *Methods) GetPostAssetIDs() ([]string, error) { return m.postAssetStore.List() } 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) GetDraftPosts(page, count int) (GetDraftPostsRes, error) { posts, hasMore, err := m.postDraftStore.Get(page, count) return GetDraftPostsRes{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) GetThisDraftPost() (p post.Post, err error) { if m.thisPost != nil { return *m.thisDraftPost, nil } defer func() { m.thisDraftPost = &p }() id := path.Base(m.url.Path) if id == "/" { // An empty draft is fine, in the context of editing return } id = strings.TrimSuffix(id, path.Ext(id)) return m.postDraftStore.GetByID(id) } 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 } type preprocessPostPayload struct { RootURL URLBuilder image func(args ...string) (string, error) } func (p preprocessPostPayload) Image(args ...string) (string, error) { return p.image(args...) } // preprocessPostBody interprets the Post's Body as a text template which may // use any of the functions found in PreprocessFunctions (all must be set). It // executes the template and writes the result to the given writer. func (m *Methods) preprocessPostBody(into io.Writer, p post.Post) error { tpl := txttpl.New("") tpl, err := tpl.Parse(p.Body) if err != nil { return fmt.Errorf("parsing post body as template: %w", err) } err = tpl.Execute(into, preprocessPostPayload{ RootURL: m.RootURL(), image: m.preprocessFuncs.Image, }) if err != nil { return fmt.Errorf("executing post body as template: %w", err) } return nil } func (m *Methods) PostGemtextBody(p post.StoredPost) (string, error) { buf := new(bytes.Buffer) if err := m.preprocessPostBody(buf, p.Post); 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 := m.preprocessPostBody(bodyBuf, p.Post); 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 // `` 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 }