internal/render/render.go

// Package render walks a source directory of markdown TIL notes and writes
// one HTML file per note into the output directory. It also returns a slice
// of Post records that the feed and index packages consume.
package render

import (
	"bytes"
	"fmt"
	"html/template"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
	"gopkg.in/yaml.v3"
)

// Post is one rendered TIL note.
type Post struct {
	Title    string
	Slug     string
	Date     time.Time
	Tags     []string
	Body     string // plain text for search
	HTML     template.HTML
	Draft    bool
	Filename string
}

type frontMatter struct {
	Title string    `yaml:"title"`
	Date  time.Time `yaml:"date"`
	Tags  []string  `yaml:"tags"`
	Draft bool      `yaml:"draft"`
}

var md = goldmark.New(
	goldmark.WithExtensions(
		extension.GFM,
		extension.Footnote,
		extension.DefinitionList,
	),
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
	goldmark.WithRendererOptions(html.WithUnsafe()),
)

// RenderTree reads every *.md under src, writes HTML into out, and returns
// a reverse-chronological slice of posts.
func RenderTree(src, out, tmplPath string, drafts bool) ([]Post, error) {
	tmpl, err := template.ParseFiles(tmplPath)
	if err != nil {
		return nil, fmt.Errorf("parse template: %w", err)
	}
	var posts []Post
	err = filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() || !strings.HasSuffix(p, ".md") {
			return err
		}
		post, err := parseFile(p)
		if err != nil {
			return fmt.Errorf("%s: %w", p, err)
		}
		if post.Draft && !drafts {
			return nil
		}
		posts = append(posts, post)
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Slice(posts, func(i, j int) bool {
		return posts[i].Date.After(posts[j].Date)
	})
	for _, p := range posts {
		if err := writePost(out, tmpl, p); err != nil {
			return nil, err
		}
	}
	if err := writeIndex(out, tmpl, posts); err != nil {
		return nil, err
	}
	return posts, nil
}

func parseFile(path string) (Post, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return Post{}, err
	}
	body := raw
	var fm frontMatter
	if bytes.HasPrefix(raw, []byte("---\n")) {
		end := bytes.Index(raw[4:], []byte("\n---\n"))
		if end < 0 {
			return Post{}, fmt.Errorf("unterminated front matter")
		}
		if err := yaml.Unmarshal(raw[4:4+end], &fm); err != nil {
			return Post{}, fmt.Errorf("yaml: %w", err)
		}
		body = raw[4+end+5:]
	}
	var buf bytes.Buffer
	if err := md.Convert(body, &buf); err != nil {
		return Post{}, err
	}
	slug := strings.TrimSuffix(filepath.Base(path), ".md")
	return Post{
		Title:    fm.Title,
		Slug:     slug,
		Date:     fm.Date,
		Tags:     fm.Tags,
		Draft:    fm.Draft,
		Body:     string(body),
		HTML:     template.HTML(buf.String()),
		Filename: path,
	}, nil
}

func writePost(out string, tmpl *template.Template, p Post) error {
	dest := filepath.Join(out, p.Slug+".html")
	f, err := os.Create(dest)
	if err != nil {
		return err
	}
	defer f.Close()
	return tmpl.ExecuteTemplate(f, "post", p)
}

func writeIndex(out string, tmpl *template.Template, posts []Post) error {
	type view struct {
		Title string
		Posts []Post
	}
	f, err := os.Create(filepath.Join(out, "index.html"))
	if err != nil {
		return err
	}
	defer f.Close()
	return tmpl.ExecuteTemplate(f, "index", view{
		Title: "TIL",
		Posts: posts,
	})
}

// Summary returns the first ~40 words of body as plain text, suitable for
// RSS descriptions. Drops leading blank lines and code fences.
func Summary(body string) string {
	lines := strings.Split(body, "\n")
	var out []string
	inFence := false
	for _, l := range lines {
		if strings.HasPrefix(l, "```") {
			inFence = !inFence
			continue
		}
		if inFence || strings.TrimSpace(l) == "" {
			continue
		}
		out = append(out, l)
		if len(strings.Fields(strings.Join(out, " "))) > 40 {
			break
		}
	}
	words := strings.Fields(strings.Join(out, " "))
	if len(words) > 40 {
		words = words[:40]
	}
	return strings.Join(words, " ") + "..."
}