package stage
import (
"bytes"
"context"
"fmt"
"strings"
"mercemay.top/src/tilstream/internal/pipeline"
"mercemay.top/src/tilstream/internal/render/markdown"
htmlsan "mercemay.top/src/tilstream/internal/render/markdown/html"
)
// Render turns each post's markdown body into HTML and a short plain-text
// summary. Rendering is done via the tilstream markdown package and then
// filtered through a strict bluemonday policy.
type Render struct {
parser *markdown.Parser
renderer *markdown.Renderer
sanitizer *htmlsan.Policy
}
// NewRender constructs a fully configured Render stage.
func NewRender() *Render {
return &Render{
parser: markdown.NewParser(),
renderer: markdown.NewRenderer(),
sanitizer: htmlsan.Strict(),
}
}
// Name returns the stage name.
func (*Render) Name() string { return "render" }
// Run fills Post.HTML and Post.Summary for every post in the state.
func (r *Render) Run(ctx context.Context, st *pipeline.State) error {
for i := range st.Posts {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := r.renderOne(&st.Posts[i]); err != nil {
return fmt.Errorf("render %s: %w", st.Posts[i].Path, err)
}
}
return nil
}
func (r *Render) renderOne(p *pipeline.Post) error {
doc, err := r.parser.Parse(p.Raw)
if err != nil {
return err
}
var buf bytes.Buffer
if err := r.renderer.Render(&buf, doc); err != nil {
return err
}
p.HTML = r.sanitizer.Sanitize(buf.String())
p.Summary = summarize(htmlsan.ToPlainText(p.HTML), 40)
return nil
}
func summarize(text string, maxWords int) string {
words := strings.Fields(text)
if len(words) <= maxWords {
return strings.Join(words, " ")
}
return strings.Join(words[:maxWords], " ") + "…"
}
// StripSectionMarkers removes the wrapper tags our theme injects so that
// feed descriptions don't leak layout classes. I found this simpler than
// teaching the renderer to emit two variants of each block.
func StripSectionMarkers(html string) string {
replacements := [][2]string{
{`<section class="til">`, ""},
{"</section>", ""},
}
for _, r := range replacements {
html = strings.ReplaceAll(html, r[0], r[1])
}
return html
}
// RenderString is a test/debug helper that renders a byte slice directly.
func (r *Render) RenderString(raw []byte) (string, error) {
p := &pipeline.Post{Raw: raw}
if err := r.renderOne(p); err != nil {
return "", err
}
return p.HTML, nil
}