package blog import ( "bytes" "html/template" "strings" "time" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "gopkg.in/yaml.v3" ) // FrontMatter is the YAML block at the top of a post file. type FrontMatter struct { Title string `yaml:"title"` Date string `yaml:"date"` Tags []string `yaml:"tags"` Slug string `yaml:"slug"` Draft bool `yaml:"draft"` Description string `yaml:"description"` } // Post is a parsed blog post with rendered HTML content. type Post struct { FrontMatter ParsedDate time.Time Content template.HTML Slug string } // RenderMarkdown converts a markdown string to HTML. func RenderMarkdown(src string) (template.HTML, error) { md := goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Footnote, extension.DefinitionList, extension.Strikethrough, extension.Table, highlighting.NewHighlighting( highlighting.WithFormatOptions( chromahtml.WithClasses(true), ), ), ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithXHTML(), ), ) var buf bytes.Buffer if err := md.Convert([]byte(src), &buf); err != nil { return "", err } return template.HTML(buf.String()), nil } // ParsePost parses raw markdown bytes (with optional YAML frontmatter) into a Post. // filename is the bare filename (e.g. "my-post.md") used to derive the slug if not set. func ParsePost(raw []byte, filename string) (*Post, error) { content := string(raw) var fm FrontMatter var markdownContent string if strings.HasPrefix(content, "---") { // Find closing --- rest := content[3:] idx := strings.Index(rest, "\n---") if idx >= 0 { fmRaw := rest[:idx] if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil { return nil, err } markdownContent = strings.TrimSpace(rest[idx+4:]) } else { markdownContent = content } } else { markdownContent = content } htmlContent, err := RenderMarkdown(markdownContent) if err != nil { return nil, err } slug := fm.Slug if slug == "" { slug = strings.TrimSuffix(filename, ".md") } var parsedDate time.Time if fm.Date != "" { for _, layout := range []string{"2006-01-02", "2006-01-02T15:04:05Z07:00"} { if t, err := time.Parse(layout, fm.Date); err == nil { parsedDate = t break } } } return &Post{ FrontMatter: fm, ParsedDate: parsedDate, Content: htmlContent, Slug: slug, }, nil }