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 "" }