summaryrefslogtreecommitdiff
path: root/internal/generator/atom/atom.go
blob: 03475d867b787f4045711f52aa4a00999546320a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// Package atom serializes the site Atom 1.0 feed (atom.xml). It depends only on
// internal/config and internal/post. It does not import HTML themes, shared nav
// templates, or the html/template page pipeline — those live in the parent
// generator package.
package atom

import (
	"context"
	"encoding/xml"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"codeberg.org/snonux/snonux/internal/config"
	"codeberg.org/snonux/snonux/internal/post"
)

// feed is the root element of an Atom 1.0 feed document.
type feed struct {
	XMLName xml.Name `xml:"feed"`
	XMLNS   string   `xml:"xmlns,attr"`
	Title   string   `xml:"title"`
	Link    link     `xml:"link"`
	Updated string   `xml:"updated"`
	ID      string   `xml:"id"`
	Entries []entry  `xml:"entry"`
}

type link struct {
	Href string `xml:"href,attr"`
	Rel  string `xml:"rel,attr,omitempty"`
}

type entry struct {
	Title   string  `xml:"title"`
	Link    link    `xml:"link"`
	ID      string  `xml:"id"`
	Updated string  `xml:"updated"`
	Content content `xml:"content"`
}

type content struct {
	Type  string `xml:"type,attr"`
	Value string `xml:",chardata"`
}

// Generate writes atom.xml to cfg.OutputDir containing the most recent
// min(len(posts), config.PostsPerPage) entries. Posts must already be sorted
// newest-first (as produced by generator.Run).
// The context is currently accepted for API consistency and future
// cancellation propagation; no blocking I/O operations currently observe it.
func Generate(ctx context.Context, posts []*post.Post, cfg *config.Config) error {
	limit := config.PostsPerPage
	if len(posts) < limit {
		limit = len(posts)
	}

	recent := posts[:limit]
	entries := buildEntries(recent, cfg.BaseURL)

	updated := time.Now().UTC().Format(time.RFC3339)
	if len(recent) > 0 {
		updated = recent[0].Timestamp.UTC().Format(time.RFC3339)
	}

	f := feed{
		XMLNS:   "http://www.w3.org/2005/Atom",
		Title:   "snonux.foo",
		Link:    link{Href: cfg.BaseURL + "/"},
		Updated: updated,
		ID:      cfg.BaseURL + "/",
		Entries: entries,
	}

	return writeFile(f, filepath.Join(cfg.OutputDir, "atom.xml"))
}

func buildEntries(posts []*post.Post, baseURL string) []entry {
	entries := make([]entry, 0, len(posts))

	base := strings.TrimSuffix(baseURL, "/")
	for _, p := range posts {
		// Link to the main HTML feed page, not /posts/<id>/ (no per-post HTML there;
		// asset URLs in content are root-relative, e.g. posts/<id>/image.jpg).
		entryURL := fmt.Sprintf("%s/#post-%s", base, p.ID)
		entries = append(entries, entry{
			Title:   fmt.Sprintf("Post %s", p.ID),
			Link:    link{Href: entryURL, Rel: "alternate"},
			ID:      entryURL,
			Updated: p.Timestamp.UTC().Format(time.RFC3339),
			Content: content{Type: "html", Value: p.Content},
		})
	}

	return entries
}

func writeFile(f feed, path string) error {
	data, err := xml.MarshalIndent(f, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal atom feed: %w", err)
	}

	content := append([]byte(xml.Header), data...)

	if err := os.WriteFile(path, content, 0o644); err != nil {
		return fmt.Errorf("write atom.xml: %w", err)
	}

	return nil
}