// 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, " ") + "..."
}