internal/pipeline/stage/render.go

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
}