371 lines
6.2 KiB
Go
371 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"embed"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.valxntine.dev/valxntine/blog/models"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
//go:embed posts/*.md
|
|
var postsFS embed.FS
|
|
|
|
var (
|
|
postCache []models.Post
|
|
postCacheTime time.Time
|
|
cacheValid = false
|
|
)
|
|
|
|
type PostMeta struct {
|
|
Title string
|
|
Date string
|
|
Tags []string
|
|
Excerpt string
|
|
WordCount int
|
|
Draft bool
|
|
}
|
|
|
|
var md = goldmark.New(
|
|
goldmark.WithExtensions(
|
|
extension.GFM,
|
|
extension.Table,
|
|
extension.Strikethrough,
|
|
extension.Linkify,
|
|
extension.TaskList,
|
|
),
|
|
goldmark.WithParserOptions(
|
|
parser.WithAutoHeadingID(),
|
|
),
|
|
goldmark.WithRendererOptions(
|
|
html.WithHardWraps(),
|
|
html.WithXHTML(),
|
|
html.WithUnsafe(),
|
|
),
|
|
)
|
|
|
|
func parseFrontmatter(content string) (PostMeta, string, error) {
|
|
lines := strings.Split(content, "\n")
|
|
|
|
if len(lines) < 2 || lines[0] != "---" {
|
|
return PostMeta{}, content, fmt.Errorf("no frontmatter found")
|
|
}
|
|
|
|
meta := PostMeta{}
|
|
var endID int
|
|
|
|
for i := 1; i < len(lines); i++ {
|
|
if lines[i] == "---" {
|
|
endID = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if endID == 0 {
|
|
return meta, content, fmt.Errorf("frontmatter not closed")
|
|
}
|
|
|
|
for i := 1; i < endID; i++ {
|
|
line := strings.TrimSpace(lines[i])
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
k := strings.TrimSpace(parts[0])
|
|
v := strings.TrimSpace(parts[1])
|
|
|
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
|
|
switch strings.ToLower(k) {
|
|
case "title":
|
|
meta.Title = v
|
|
case "date":
|
|
meta.Date = v
|
|
case "excerpt":
|
|
meta.Excerpt = v
|
|
case "tags":
|
|
if v != "" {
|
|
tags := strings.Split(v, ",")
|
|
for _, t := range tags {
|
|
meta.Tags = append(meta.Tags, strings.TrimSpace(t))
|
|
}
|
|
}
|
|
case "draft":
|
|
meta.Draft = strings.ToLower(v) == "true"
|
|
}
|
|
}
|
|
|
|
body := strings.Join(lines[endID+1:], "\n")
|
|
return meta, body, nil
|
|
}
|
|
|
|
func generateSlug(fn string) string {
|
|
name := filepath.Base(fn)
|
|
name = strings.TrimSuffix(name, filepath.Ext(name))
|
|
|
|
if len(name) > 11 && name[4] == '-' && name[7] == '-' && name[10] == '-' {
|
|
name = name[11:]
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
func countWords(c string) int {
|
|
sc := bufio.NewScanner(strings.NewReader(c))
|
|
sc.Split(bufio.ScanWords)
|
|
count := 0
|
|
for sc.Scan() {
|
|
count++
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func load() ([]models.Post, error) {
|
|
if cacheValid && time.Since(postCacheTime) < 10*time.Minute {
|
|
return postCache, nil
|
|
}
|
|
|
|
var posts []models.Post
|
|
|
|
entries, err := postsFS.ReadDir("posts")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read posts dir: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
|
continue
|
|
}
|
|
c, err := postsFS.ReadFile(filepath.Join("posts", entry.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
meta, body, err := parseFrontmatter(string(c))
|
|
if err != nil {
|
|
meta = PostMeta{
|
|
Title: strings.TrimSuffix(entry.Name(), ".md"),
|
|
Date: time.Now().Format("2006-01-02"),
|
|
}
|
|
body = string(c)
|
|
}
|
|
|
|
if meta.Draft {
|
|
continue
|
|
}
|
|
|
|
published, err := time.Parse("2006-01-02", meta.Date)
|
|
if err != nil {
|
|
published = time.Now()
|
|
}
|
|
|
|
var htmlC strings.Builder
|
|
|
|
if err := md.Convert([]byte(body), &htmlC); err != nil {
|
|
continue
|
|
}
|
|
|
|
excerpt := meta.Excerpt
|
|
if excerpt == "" {
|
|
plain := stripHTML(htmlC.String())
|
|
words := strings.Fields(plain)
|
|
if len(words) > 30 {
|
|
excerpt = strings.Join(words[:30], " ") + "..."
|
|
} else {
|
|
excerpt = plain
|
|
}
|
|
}
|
|
|
|
count := meta.WordCount
|
|
if count == 0 {
|
|
count = countWords(body)
|
|
}
|
|
|
|
slug := generateSlug(entry.Name())
|
|
|
|
post := models.Post{
|
|
ID: slug,
|
|
Title: meta.Title,
|
|
Slug: slug,
|
|
Excerpt: excerpt,
|
|
Content: htmlC.String(),
|
|
Tags: meta.Tags,
|
|
PublishedAt: published,
|
|
WordCount: count,
|
|
}
|
|
|
|
posts = append(posts, post)
|
|
}
|
|
|
|
sort.Slice(posts, func(i, j int) bool {
|
|
return posts[i].PublishedAt.After(posts[j].PublishedAt)
|
|
})
|
|
|
|
postCache = posts
|
|
postCacheTime = time.Now()
|
|
cacheValid = true
|
|
|
|
return posts, nil
|
|
|
|
}
|
|
|
|
func stripHTML(s string) string {
|
|
in := false
|
|
var sb strings.Builder
|
|
|
|
for _, r := range s {
|
|
if r == '<' {
|
|
in = true
|
|
continue
|
|
}
|
|
|
|
if r == '>' {
|
|
in = false
|
|
continue
|
|
}
|
|
if !in {
|
|
sb.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func GetPaginatedPosts(page, perPage int) []models.Post {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return []models.Post{}
|
|
}
|
|
|
|
start := (page - 1) * perPage
|
|
end := start + perPage
|
|
|
|
if start >= len(posts) {
|
|
return []models.Post{}
|
|
}
|
|
if end > len(posts) {
|
|
end = len(posts)
|
|
}
|
|
|
|
return posts[start:end]
|
|
}
|
|
|
|
func GetTotalPages(per int) int {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
|
|
return (len(posts) + per - 1) / per
|
|
}
|
|
|
|
func GetPostBySlug(slug string) *models.Post {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
for _, post := range posts {
|
|
if post.Slug == slug {
|
|
return &post
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func GetPostsByTag(tag string, page, per int) []models.Post {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return []models.Post{}
|
|
}
|
|
|
|
var filtered []models.Post
|
|
for _, post := range posts {
|
|
for _, t := range post.Tags {
|
|
if t == tag {
|
|
filtered = append(filtered, post)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
start := (page - 1) * per
|
|
end := start + per
|
|
|
|
if start >= len(filtered) {
|
|
return []models.Post{}
|
|
}
|
|
if end > len(filtered) {
|
|
end = len(filtered)
|
|
}
|
|
|
|
return filtered[start:end]
|
|
}
|
|
|
|
func GetTotalPagesByTag(tag string, per int) int {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
|
|
count := 0
|
|
for _, post := range posts {
|
|
for _, t := range post.Tags {
|
|
if t == tag {
|
|
count++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return (count + per - 1) / per
|
|
}
|
|
|
|
func GetAllPosts() []models.Post {
|
|
posts, err := load()
|
|
if err != nil {
|
|
return []models.Post{}
|
|
}
|
|
|
|
return posts
|
|
}
|
|
|
|
func GetAllTags() []string {
|
|
posts := GetAllPosts()
|
|
if len(posts) == 0 {
|
|
return []string{}
|
|
}
|
|
|
|
set := make(map[string]bool)
|
|
for _, post := range posts {
|
|
for _, tag := range post.Tags {
|
|
set[tag] = true
|
|
}
|
|
}
|
|
|
|
var tags []string
|
|
for tag := range set {
|
|
tags = append(tags, tag)
|
|
}
|
|
|
|
sort.Strings(tags)
|
|
return tags
|
|
}
|