204 lines
4.7 KiB
Go
204 lines
4.7 KiB
Go
package article
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
md "github.com/gomarkdown/markdown"
|
|
"github.com/gomarkdown/markdown/html"
|
|
)
|
|
|
|
type LoaderOptions struct {
|
|
// TrustedHTML erlaubt rohe .html-Inhalte aus deinem Content-Repository.
|
|
// Achtung: Nur aktivieren, wenn du dem Repository vertraust.
|
|
TrustedHTML bool
|
|
}
|
|
|
|
type frontMatter struct {
|
|
Title string `json:"title"`
|
|
Date string `json:"date"`
|
|
Slug string `json:"slug"`
|
|
Cover string `json:"cover"`
|
|
Description string `json:"description"`
|
|
Format string `json:"format"`
|
|
}
|
|
|
|
func LoadDir(root string, opts LoaderOptions) ([]Article, error) {
|
|
var out []Article
|
|
seen := make(map[string]bool)
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
|
|
return nil
|
|
}
|
|
|
|
meta, body, format, err := readContentFile(path, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if meta.Slug == "" {
|
|
meta.Slug = strings.TrimSuffix(filepath.Base(path), ext)
|
|
}
|
|
if seen[meta.Slug] {
|
|
return fmt.Errorf("%s: duplicate slug %q", path, meta.Slug)
|
|
}
|
|
seen[meta.Slug] = true
|
|
|
|
date, err := time.Parse("2006-01-02", meta.Date)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: parse date: %w", path, err)
|
|
}
|
|
|
|
out = append(out, Article{
|
|
Title: meta.Title,
|
|
Slug: meta.Slug,
|
|
Date: date,
|
|
Cover: meta.Cover,
|
|
Description: meta.Description,
|
|
Body: body,
|
|
Format: format,
|
|
SourcePath: path,
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) })
|
|
return out, nil
|
|
}
|
|
|
|
func LoadStatic(dir string, opts LoaderOptions) (map[string]StaticPage, error) {
|
|
pages := make(map[string]StaticPage)
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(dir, e.Name())
|
|
meta, body, format, err := readContentFile(path, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if meta.Slug == "" {
|
|
meta.Slug = strings.TrimSuffix(e.Name(), ext)
|
|
}
|
|
if _, dup := pages[meta.Slug]; dup {
|
|
return nil, fmt.Errorf("%s: duplicate slug %q", path, meta.Slug)
|
|
}
|
|
|
|
pages[meta.Slug] = StaticPage{
|
|
Title: meta.Title,
|
|
Slug: meta.Slug,
|
|
Description: meta.Description,
|
|
Body: body,
|
|
Format: format,
|
|
SourcePath: path,
|
|
}
|
|
}
|
|
|
|
return pages, nil
|
|
}
|
|
|
|
func readContentFile(path string, opts LoaderOptions) (frontMatter, template.HTML, Format, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return frontMatter{}, "", "", fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
parts := bytes.SplitN(raw, []byte("\n"), 2)
|
|
if len(parts) < 2 {
|
|
return frontMatter{}, "", "", fmt.Errorf("%s: missing front-matter", path)
|
|
}
|
|
|
|
headerJSON, err := extractJSONHeader(parts[0])
|
|
if err != nil {
|
|
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
|
|
var meta frontMatter
|
|
if err := json.Unmarshal(headerJSON, &meta); err != nil {
|
|
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
format := detectFormat(ext, meta.Format)
|
|
body := renderBody(parts[1], format, opts)
|
|
|
|
return meta, body, format, nil
|
|
}
|
|
|
|
func detectFormat(ext, explicit string) Format {
|
|
switch strings.ToLower(strings.TrimSpace(explicit)) {
|
|
case "html":
|
|
return FormatHTML
|
|
case "md", "markdown":
|
|
return FormatMarkdown
|
|
}
|
|
|
|
switch ext {
|
|
case ".html", ".htm":
|
|
return FormatHTML
|
|
default:
|
|
return FormatMarkdown
|
|
}
|
|
}
|
|
|
|
func renderBody(body []byte, format Format, opts LoaderOptions) template.HTML {
|
|
if format == FormatMarkdown {
|
|
renderer := html.NewRenderer(html.RendererOptions{
|
|
Flags: html.CommonFlags | html.HrefTargetBlank,
|
|
})
|
|
return template.HTML(md.ToHTML(body, nil, renderer))
|
|
}
|
|
|
|
if opts.TrustedHTML {
|
|
return template.HTML(body)
|
|
}
|
|
|
|
// Sicherheits-Fallback: HTML-Dateien werden escaped, solange BLOG_TRUSTED_HTML=false ist.
|
|
return template.HTML(template.HTMLEscapeString(string(body)))
|
|
}
|
|
|
|
func extractJSONHeader(line []byte) ([]byte, error) {
|
|
s := strings.TrimSpace(string(line))
|
|
s = strings.TrimPrefix(s, "<!--")
|
|
if idx := strings.Index(s, "-->"); idx != -1 {
|
|
s = s[:idx]
|
|
}
|
|
s = strings.TrimSpace(s)
|
|
if !strings.HasPrefix(s, "{") {
|
|
return nil, fmt.Errorf("no JSON front-matter found")
|
|
}
|
|
return []byte(s), nil
|
|
}
|