Files
chaosmith-site/blog/store.go
2026-06-28 12:15:44 +03:00

280 lines
6.3 KiB
Go

package blog
import (
"bytes"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
var md = goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()),
)
type Post struct {
Slug string
Title string
Date time.Time
HTML string
Description string
OGImage string
OGImageCustom bool
CodeLangs []string
HasCode bool
HasErgon bool
HasArchASM bool
}
type Store struct {
posts []Post
index map[string]int
}
func NewStore() *Store {
return &Store{index: make(map[string]int)}
}
func (s *Store) Load(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
s.posts = s.posts[:0]
s.index = make(map[string]int)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
path := filepath.Join(dir, e.Name())
raw, err := os.ReadFile(path)
if err != nil {
continue
}
slug := strings.TrimSuffix(e.Name(), ".md")
title, body, date, ogImage, ogImageCustom, description := parseFrontMatter(raw, slug, path)
var buf bytes.Buffer
if err := md.Convert(body, &buf); err != nil {
continue
}
ogImagePath := ogImage
if !ogImageCustom {
ogImagePath = "/blog/og/" + slug + ".png"
}
codeLangs := extractCodeLangs(raw)
var bundleLangs []string
hasErgon := false
hasArchASM := false
for _, l := range codeLangs {
switch l {
case "ergon":
hasErgon = true
case "archasm":
hasArchASM = true
default:
bundleLangs = append(bundleLangs, l)
}
}
s.index[slug] = len(s.posts)
s.posts = append(s.posts, Post{
Slug: slug,
Title: title,
Date: date,
HTML: buf.String(),
Description: description,
OGImage: ogImagePath,
OGImageCustom: ogImageCustom,
CodeLangs: bundleLangs,
HasCode: len(bundleLangs) > 0 || hasErgon || hasArchASM,
HasErgon: hasErgon,
HasArchASM: hasArchASM,
})
}
// sort newest first
sort.Slice(s.posts, func(i, j int) bool {
return s.posts[i].Date.After(s.posts[j].Date)
})
for i, p := range s.posts {
s.index[p.Slug] = i
}
return nil
}
func (s *Store) GenerateOGImages(dir string) {
for _, p := range s.posts {
if p.OGImageCustom {
continue
}
ensureGeneratedOGImage(dir, p.Slug, p.Title, p.Date.Format("January 2, 2006"), p.Description)
}
}
func (s *Store) All() []Post {
return s.posts
}
func (s *Store) Get(slug string) (Post, bool) {
i, ok := s.index[slug]
if !ok {
return Post{}, false
}
return s.posts[i], true
}
func (s *Store) Count() int {
return len(s.posts)
}
var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`)
// Date formats we recognize on the line after the title
var dateFormats = []string{
"2006-01-02",
"January 2, 2006",
"Jan 2, 2006",
"02/01/2006",
}
// parseFrontMatter extracts the title from the first `# heading`,
// strips that line from the body, and tries to parse a date from
// the line immediately after. Falls back to file mod time.
// Also extracts optional og-image: and og-description: lines.
func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte, date time.Time, ogImage string, ogImageCustom bool, description string) {
body = raw
// Extract title
m := h1Re.FindSubmatch(raw)
if len(m) >= 2 {
title = strings.TrimSpace(string(m[1]))
} else {
title = slug
}
// Strip the first `# title` line from body so it's not rendered twice
if loc := h1Re.FindIndex(raw); loc != nil {
// Find end of that line
lineEnd := loc[1]
// advance past newline
for lineEnd < len(raw) && (raw[lineEnd] == '\n' || raw[lineEnd] == '\r') {
lineEnd++
}
body = raw[lineEnd:]
}
// Try to parse date from first remaining line
date = fileModTime(path)
rest := strings.TrimSpace(string(body))
if rest != "" {
firstLine := rest
if idx := strings.IndexByte(rest, '\n'); idx >= 0 {
firstLine = strings.TrimSpace(rest[:idx])
}
for _, fmt := range dateFormats {
if t, err := time.Parse(fmt, firstLine); err == nil {
date = t
// strip the date line from body too
if idx := strings.IndexByte(string(body), '\n'); idx >= 0 {
body = body[idx+1:]
} else {
body = nil
}
break
}
}
}
// Strip og-image: and og-description: lines from body (can appear anywhere)
ogImageRe := regexp.MustCompile(`(?m)^og-image:\s*(.+)$`)
ogDescRe := regexp.MustCompile(`(?m)^og-description:\s*(.+)$`)
if m := ogImageRe.FindSubmatch(body); len(m) >= 2 {
ogImage = strings.TrimSpace(string(m[1]))
ogImageCustom = true
body = ogImageRe.ReplaceAll(body, nil)
}
if m := ogDescRe.FindSubmatch(body); len(m) >= 2 {
description = strings.TrimSpace(string(m[1]))
body = ogDescRe.ReplaceAll(body, nil)
}
// Auto-extract description from first paragraph if not set
if description == "" {
description = extractDescription(body)
}
return title, body, date, ogImage, ogImageCustom, description
}
var codeBlockRe = regexp.MustCompile("(?m)^```([a-zA-Z0-9_+-]+)?$")
func containsLang(langs []string, target string) bool {
for _, l := range langs {
if l == target {
return true
}
}
return false
}
func extractCodeLangs(raw []byte) []string {
seen := make(map[string]bool)
var langs []string
for _, m := range codeBlockRe.FindAllSubmatch(raw, -1) {
if len(m) >= 2 && len(m[1]) > 0 {
lang := strings.ToLower(string(m[1]))
if !seen[lang] {
seen[lang] = true
langs = append(langs, lang)
}
}
}
return langs
}
func fileModTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Now()
}
return info.ModTime()
}
func extractDescription(body []byte) string {
// Find first non-empty line that isn't a markdown heading or front matter
lines := strings.Split(string(body), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "---") {
continue
}
// Strip basic markdown formatting
desc := strings.ReplaceAll(line, "**", "")
desc = strings.ReplaceAll(desc, "*", "")
desc = strings.ReplaceAll(desc, "_", "")
// Truncate
if len(desc) > 160 {
desc = desc[:157] + "..."
}
return desc
}
return ""
}