LabCodeHub öffentliche Ansicht
Anmelden
Küpper / Quildrop öffentlich
Branch: main
Quildrop / internal / content / parser.go
Verlauf Rohdaten
R Rüdiger Küpper fix: new blog post
f3faa1b1 vor 6 Monaten
internal/content/parser.go 151 Zeilen · 3.8 KB · Go
1
package content
2
3
import (
4
	"bytes"
5
	"fmt"
6
	"html/template"
7
	"log"
8
	"os"
9
	"path/filepath"
10
	"regexp"
11
	"sort"
12
	"strings"
13
14
	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
15
	"github.com/yuin/goldmark"
16
	emoji "github.com/yuin/goldmark-emoji"
17
	highlighting "github.com/yuin/goldmark-highlighting/v2"
18
	"github.com/yuin/goldmark/extension"
19
	"github.com/yuin/goldmark/parser"
20
	"github.com/yuin/goldmark/renderer/html"
21
	"gopkg.in/yaml.v3"
22
)
23
24
var md goldmark.Markdown
25
26
// yamlUnmarshal is a package-level wrapper for yaml.Unmarshal.
27
var yamlUnmarshal = yaml.Unmarshal
28
29
func init() {
30
	md = goldmark.New(
31
		goldmark.WithExtensions(
32
			extension.GFM,
33
			extension.Footnote,
34
			highlighting.NewHighlighting(
35
				highlighting.WithStyle("dracula"),
36
				highlighting.WithFormatOptions(
37
					chromahtml.WithClasses(true),
38
				),
39
			),
40
			emoji.Emoji,
41
		),
42
		goldmark.WithParserOptions(
43
			parser.WithAutoHeadingID(),
44
		),
45
		goldmark.WithRendererOptions(
46
			html.WithUnsafe(),
47
		),
48
	)
49
}
50
51
var hugoShortcodeRe = regexp.MustCompile(`\{\{<\s*/?[a-zA-Z][^>]*>\}\}`)
52
53
// mdConvert renders markdown to the given writer using the shared goldmark instance.
54
func mdConvert(source []byte, w *strings.Builder) error {
55
	var buf bytes.Buffer
56
	if err := md.Convert(source, &buf); err != nil {
57
		return err
58
	}
59
	w.WriteString(buf.String())
60
	return nil
61
}
62
63
// preprocessContent strips Hugo shortcode delimiters while preserving raw HTML content between them.
64
func preprocessContent(raw string) string {
65
	// Remove {{< rawhtml >}} and {{< /rawhtml >}} delimiters
66
	raw = strings.ReplaceAll(raw, "{{< rawhtml >}}", "")
67
	raw = strings.ReplaceAll(raw, "{{< /rawhtml >}}", "")
68
	// Remove any remaining Hugo shortcodes
69
	raw = hugoShortcodeRe.ReplaceAllString(raw, "")
70
	return raw
71
}
72
73
// ParseFile reads a markdown file and returns a Post with parsed frontmatter and rendered HTML.
74
func ParseFile(path string) (*Post, error) {
75
	data, err := os.ReadFile(path)
76
	if err != nil {
77
		return nil, err
78
	}
79
80
	// Split on --- frontmatter delimiters
81
	// Trim leading whitespace/newlines (some files have a newline before ---)
82
	content := strings.TrimLeft(string(data), " \t\n\r")
83
	if !strings.HasPrefix(content, "---") {
84
		return nil, fmt.Errorf("no frontmatter found in %s", path)
85
	}
86
87
	// Find the closing ---
88
	rest := content[3:]
89
	idx := strings.Index(rest, "\n---")
90
	if idx < 0 {
91
		return nil, fmt.Errorf("unclosed frontmatter in %s", path)
92
	}
93
94
	frontmatter := rest[:idx]
95
	body := rest[idx+4:] // skip past \n---
96
97
	post := &Post{}
98
	if err := yaml.Unmarshal([]byte(frontmatter), post); err != nil {
99
		return nil, fmt.Errorf("parse frontmatter %s: %w", path, err)
100
	}
101
102
	// Derive slug from filename
103
	base := filepath.Base(path)
104
	post.Filename = base
105
	slug := strings.TrimSuffix(base, filepath.Ext(base))
106
	// Handle double .md extension
107
	slug = strings.TrimSuffix(slug, ".md")
108
	post.Slug = slug
109
110
	// Preprocess and render markdown
111
	rawContent := preprocessContent(body)
112
	post.Content = rawContent
113
114
	var buf bytes.Buffer
115
	if err := md.Convert([]byte(rawContent), &buf); err != nil {
116
		return nil, fmt.Errorf("render markdown %s: %w", path, err)
117
	}
118
	post.HTMLContent = template.HTML(buf.String())
119
120
	return post, nil
121
}
122
123
// LoadAll reads all markdown files from a directory, filters out drafts/hidden, and sorts by date descending.
124
func LoadAll(contentDir string) ([]*Post, error) {
125
	entries, err := os.ReadDir(contentDir)
126
	if err != nil {
127
		return nil, err
128
	}
129
130
	var posts []*Post
131
	for _, entry := range entries {
132
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
133
			continue
134
		}
135
		post, err := ParseFile(filepath.Join(contentDir, entry.Name()))
136
		if err != nil {
137
			log.Printf("Warning: skipping %s: %v", entry.Name(), err)
138
			continue
139
		}
140
		if post.Draft || post.Hide {
141
			continue
142
		}
143
		posts = append(posts, post)
144
	}
145
146
	sort.Slice(posts, func(i, j int) bool {
147
		return posts[i].Date.After(posts[j].Date.Time)
148
	})
149
150
	return posts, nil
151
}