main
1package flux
2
3import (
4 "encoding/json"
5 "encoding/xml"
6 "fmt"
7 "html/template"
8 "os"
9 "path/filepath"
10 "sort"
11 "time"
12)
13
14// Config holds rendering configuration.
15type Config struct {
16 Title string
17 Description string
18 Author string
19 BaseURL string // e.g. https://vincent.demeester.fr/flux
20 SiteURL string // e.g. https://vincent.demeester.fr
21 OutputDir string
22}
23
24// --- JSON Feed (v1.1) ---
25
26type jsonFeed struct {
27 Version string `json:"version"`
28 Title string `json:"title"`
29 HomePageURL string `json:"home_page_url"`
30 FeedURL string `json:"feed_url"`
31 Description string `json:"description,omitempty"`
32 Authors []jsonAuthor `json:"authors,omitempty"`
33 Items []jsonFeedItem `json:"items"`
34}
35
36type jsonAuthor struct {
37 Name string `json:"name"`
38}
39
40type jsonFeedItem struct {
41 ID string `json:"id"`
42 Title string `json:"title"`
43 URL string `json:"url,omitempty"`
44 DatePublished string `json:"date_published"`
45 ContentText string `json:"content_text,omitempty"`
46 Tags []string `json:"tags,omitempty"`
47}
48
49// RenderJSONFeed writes feed.json to the output dir.
50func RenderJSONFeed(cfg Config, entries []Entry) error {
51 return RenderJSONFeedTo(cfg, entries, filepath.Join(cfg.OutputDir, "feed.json"))
52}
53
54// RenderJSONFeedTo writes a JSON Feed to the given path.
55func RenderJSONFeedTo(cfg Config, entries []Entry, path string) error {
56 feed := jsonFeed{
57 Version: "https://jsonfeed.org/version/1.1",
58 Title: cfg.Title,
59 HomePageURL: cfg.BaseURL + "/",
60 FeedURL: cfg.BaseURL + "/feed.json",
61 Description: cfg.Description,
62 Authors: []jsonAuthor{{Name: cfg.Author}},
63 }
64
65 for _, e := range entries {
66 body := e.Title
67 if e.Body != "" {
68 body = e.Body
69 }
70 feed.Items = append(feed.Items, jsonFeedItem{
71 ID: e.ID,
72 Title: e.Title,
73 URL: e.URL,
74 DatePublished: e.Date.Format(time.RFC3339),
75 ContentText: body,
76 Tags: e.Tags,
77 })
78 }
79
80 data, err := json.MarshalIndent(feed, "", " ")
81 if err != nil {
82 return fmt.Errorf("marshaling JSON feed: %w", err)
83 }
84 return os.WriteFile(path, data, 0644)
85}
86
87// --- Atom Feed ---
88
89type atomFeed struct {
90 XMLName xml.Name `xml:"feed"`
91 XMLNS string `xml:"xmlns,attr"`
92 Title string `xml:"title"`
93 Link []atomLink `xml:"link"`
94 Updated string `xml:"updated"`
95 Author atomAuthor `xml:"author"`
96 ID string `xml:"id"`
97 Entries []atomEntry `xml:"entry"`
98}
99
100type atomLink struct {
101 Href string `xml:"href,attr"`
102 Rel string `xml:"rel,attr,omitempty"`
103 Type string `xml:"type,attr,omitempty"`
104}
105
106type atomAuthor struct {
107 Name string `xml:"name"`
108}
109
110type atomEntry struct {
111 Title string `xml:"title"`
112 Link atomLink `xml:"link"`
113 ID string `xml:"id"`
114 Updated string `xml:"updated"`
115 Summary string `xml:"summary,omitempty"`
116}
117
118// RenderAtomFeed writes feed.xml to the output dir.
119func RenderAtomFeed(cfg Config, entries []Entry) error {
120 return RenderAtomFeedTo(cfg, entries, filepath.Join(cfg.OutputDir, "feed.xml"))
121}
122
123// RenderAtomFeedTo writes an Atom feed to the given path.
124func RenderAtomFeedTo(cfg Config, entries []Entry, path string) error {
125 updated := time.Now().Format(time.RFC3339)
126 if len(entries) > 0 {
127 updated = entries[0].Date.Format(time.RFC3339)
128 }
129
130 feed := atomFeed{
131 XMLNS: "http://www.w3.org/2005/Atom",
132 Title: cfg.Title,
133 Updated: updated,
134 Author: atomAuthor{Name: cfg.Author},
135 ID: cfg.BaseURL + "/",
136 Link: []atomLink{
137 {Href: cfg.BaseURL + "/", Rel: "alternate", Type: "text/html"},
138 {Href: cfg.BaseURL + "/feed.xml", Rel: "self", Type: "application/atom+xml"},
139 },
140 }
141
142 for _, e := range entries {
143 body := e.Title
144 if e.Body != "" {
145 body = e.Body
146 }
147 feed.Entries = append(feed.Entries, atomEntry{
148 Title: e.Title,
149 Link: atomLink{Href: e.URL},
150 ID: e.ID,
151 Updated: e.Date.Format(time.RFC3339),
152 Summary: body,
153 })
154 }
155
156 data, err := xml.MarshalIndent(feed, "", " ")
157 if err != nil {
158 return fmt.Errorf("marshaling Atom feed: %w", err)
159 }
160 header := []byte(xml.Header)
161 data = append(header, data...)
162 return os.WriteFile(path, data, 0644)
163}
164
165// --- HTML Rendering ---
166
167// RenderHomeSnippet writes a partial HTML snippet with the latest N entries for embedding in the homepage.
168func RenderHomeSnippet(cfg Config, entries []Entry, path string) error {
169 tmpl, err := template.New("snippet").Funcs(funcMap).Parse(homeSnippetTemplate)
170 if err != nil {
171 return fmt.Errorf("parsing snippet template: %w", err)
172 }
173
174 f, err := os.Create(path)
175 if err != nil {
176 return fmt.Errorf("creating %s: %w", path, err)
177 }
178 defer f.Close()
179
180 return tmpl.Execute(f, struct {
181 Entries []Entry
182 BaseURL string
183 }{Entries: entries, BaseURL: cfg.BaseURL})
184}
185
186const homeSnippetTemplate = `<!-- flux home snippet — auto-generated, do not edit -->
187{{range .Entries -}}
188{{if isNote . -}}
189<div class="stream-card" id="{{.ID}}">
190 <a class="card-anchor" href="#{{.ID}}">§</a>
191 <div class="card-date"><time datetime="{{isoDate .Date}}">{{formatDate .Date}}</time></div>
192 <div class="card-title"><a href="{{.URL}}">{{.Title}}</a></div>
193 {{- if .Body}}
194 <div class="card-body"><p>{{.Body}}</p></div>
195 {{- end}}
196 {{- if .Tags}}
197 <div class="tags">{{range .Tags}}<a class="tag" href="/flux/tags/{{.}}.html">{{.}}</a>{{end}}</div>
198 {{- end}}
199</div>
200{{else if isPR . -}}
201<div class="github-entry">
202 <span class="gh-icon pr">{{prIcon .}}</span>
203 <div class="gh-content">
204 <div class="gh-title"><a href="{{.URL}}">{{.Title}}</a></div>
205 <div class="gh-repo">{{meta . "repo"}}#{{meta . "number"}}</div>
206 </div>
207 <div class="gh-date">{{meta . "state"}} · {{shortDate .Date}}</div>
208</div>
209{{else if isIssue . -}}
210<div class="github-entry">
211 <span class="gh-icon issue">{{issueIcon .}}</span>
212 <div class="gh-content">
213 <div class="gh-title"><a href="{{.URL}}">{{.Title}}</a></div>
214 <div class="gh-repo">{{meta . "repo"}}#{{meta . "number"}}</div>
215 </div>
216 <div class="gh-date">{{meta . "state"}} · {{shortDate .Date}}</div>
217</div>
218{{else if isRelease . -}}
219<div class="release-card">
220 <span class="release-project">{{meta . "repo"}}</span>
221 <span class="release-version"><a href="{{.URL}}">{{.Title}}</a></span>
222 <span class="release-date"> · {{formatDate .Date}}</span>
223</div>
224{{else if isPage . -}}
225<a class="page-entry" href="{{.URL}}">
226 {{- if eq .Kind "page-new"}}
227 <span class="marker new">✚</span>
228 {{- else}}
229 <span class="marker updated">~</span>
230 {{- end}}
231 <span class="page-title">{{.Title}}</span>
232 <span class="page-date">{{shortDate .Date}}</span>
233 <span class="arrow">↗</span>
234</a>
235{{else if isBookmark . -}}
236<div class="bookmark-entry">
237 <div class="bm-header">
238 <span class="bm-icon">↬</span>
239 <span class="bm-title"><a href="{{.URL}}">{{.Title}}</a></span>
240 <span class="bm-date">{{shortDate .Date}}</span>
241 </div>
242 {{- if .Body}}
243 <div class="bm-note">{{.Body}}</div>
244 {{- end}}
245 {{- if meta . "author"}}
246 <div class="bm-author">— {{meta . "author"}}</div>
247 {{- end}}
248</div>
249{{end -}}
250{{end -}}
251<p class="flux-more"><a href="/flux/">See all in the flux stream →</a>
252 · <a href="/flux/feed.json" style="font-family:var(--font-mono);font-size:0.8rem">JSON</a>
253 · <a href="/flux/feed.xml" style="font-family:var(--font-mono);font-size:0.8rem">Atom</a></p>
254`
255
256// RenderHTML writes index.html with the latest entries, plus year archive pages.
257func RenderHTML(cfg Config, store *Store) error {
258 byYear := store.ByYear()
259 tags := store.AllTags()
260
261 // Collect sorted year list
262 var years []int
263 for y := range byYear {
264 years = append(years, y)
265 }
266 sort.Sort(sort.Reverse(sort.IntSlice(years)))
267
268 // index.html — latest 50
269 latest := store.Latest(50)
270 if err := renderHTMLPage(cfg, filepath.Join(cfg.OutputDir, "index.html"), "Flux", cfg.Description, latest, false, years, tags); err != nil {
271 return fmt.Errorf("rendering index: %w", err)
272 }
273
274 // Year archives
275 for year, entries := range byYear {
276 title := fmt.Sprintf("Flux — %d", year)
277 path := filepath.Join(cfg.OutputDir, fmt.Sprintf("%d.html", year))
278 if err := renderHTMLPage(cfg, path, title, "", entries, true, years, tags); err != nil {
279 return fmt.Errorf("rendering year %d: %w", year, err)
280 }
281 }
282
283 // Tag pages
284 if err := os.MkdirAll(filepath.Join(cfg.OutputDir, "tags"), 0755); err != nil {
285 return fmt.Errorf("creating tags dir: %w", err)
286 }
287 if err := renderTagIndex(cfg, tags); err != nil {
288 return fmt.Errorf("rendering tag index: %w", err)
289 }
290 for tag := range tags {
291 entries := store.ByTag(tag)
292 title := fmt.Sprintf("Flux — #%s", tag)
293 path := filepath.Join(cfg.OutputDir, "tags", tag+".html")
294 if err := renderHTMLPage(cfg, path, title, "", entries, true, years, tags); err != nil {
295 return fmt.Errorf("rendering tag %s: %w", tag, err)
296 }
297 }
298
299 return nil
300}
301
302type pageData struct {
303 Title string
304 Description string
305 BaseURL string
306 SiteURL string
307 Entries []Entry
308 IsArchive bool
309 Years []int
310 Tags map[string]int
311}
312
313var funcMap = template.FuncMap{
314 "formatDate": func(t time.Time) string {
315 return t.Format("January 2, 2006")
316 },
317 "shortDate": func(t time.Time) string {
318 return t.Format("Jan 2")
319 },
320 "isoDate": func(t time.Time) string {
321 return t.Format("2006-01-02")
322 },
323 "yearMonth": func(t time.Time) string {
324 return t.Format("January 2006")
325 },
326 "year": func(t time.Time) int {
327 return t.Year()
328 },
329 "isPR": func(e Entry) bool {
330 return e.Kind == KindGitHubPR
331 },
332 "isIssue": func(e Entry) bool {
333 return e.Kind == KindGitHubIssue
334 },
335 "isRelease": func(e Entry) bool {
336 return e.Kind == KindGitHubRelease
337 },
338 "isGitHub": func(e Entry) bool {
339 return e.Kind == KindGitHubPR || e.Kind == KindGitHubIssue || e.Kind == KindGitHubRelease
340 },
341 "isPage": func(e Entry) bool {
342 return e.Kind == KindPageNew || e.Kind == KindPageUpdated
343 },
344 "isBookmark": func(e Entry) bool {
345 return e.Kind == KindBookmark
346 },
347 "isNote": func(e Entry) bool {
348 return e.Kind == KindNote
349 },
350 "meta": func(e Entry, key string) string {
351 if e.Metadata == nil {
352 return ""
353 }
354 return e.Metadata[key]
355 },
356 "prIcon": func(e Entry) string {
357 return "⬢" // ⬢ filled hexagon (always merged)
358 },
359 "issueIcon": func(e Entry) string {
360 return "◇" // ◇ open diamond (tracked at creation)
361 },
362}
363
364const htmlTemplate = `<!DOCTYPE html>
365<html lang="en">
366<head>
367 <meta charset="utf-8">
368 <meta name="viewport" content="width=device-width, initial-scale=1">
369 <title>{{.Title}} — Vincent Demeester</title>
370 <link rel="stylesheet" href="/style.css" type="text/css">
371 <script src="/flux.js" defer></script>
372 <link rel="alternate" type="application/json" title="JSON Feed" href="{{.BaseURL}}/feed.json">
373 <link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{.BaseURL}}/feed.xml">
374 <meta name="fediverse:creator" content="@vdemeester@fosstodon.org">
375</head>
376<body>
377 <div class="site-controls"><span class="site-nav"><a href="/" title="index">⌘</a><a href="/flux/" title="flux">≋</a><a href="/about.html" title="about">☉</a></span><button class="theme-toggle" id="theme-toggle" title="Toggle dark/light mode">☀️</button></div>
378
379 <article class="content flux">
380 <header>
381 <h1 class="title">{{.Title}}</h1>
382 {{- if .Description}}
383 <p class="subtitle">{{.Description}}</p>
384 {{- end}}
385 {{- if not .IsArchive}}
386 <div class="flux-feed-links">
387 <a href="feed.json">JSON</a>
388 <a href="feed.xml">Atom</a>
389 <a href="content.json" title="Pages, TIL, bookmarks only">JSON (content)</a>
390 <a href="content.xml" title="Pages, TIL, bookmarks only">Atom (content)</a>
391 </div>
392 {{- end}}
393 </header>
394
395 <nav class="flux-years">
396 {{- range .Years}}
397 <a href="/flux/{{.}}.html">{{.}}</a>
398 {{- end}}
399 · <a href="/flux/tags/">tags</a>
400 </nav>
401
402 <div class="flux-filters">
403 <button class="flux-filter active" data-filter="all">all</button>
404 <button class="flux-filter" data-filter="github">github</button>
405 <button class="flux-filter" data-filter="gitlog">pages</button>
406 <button class="flux-filter" data-filter="til">til</button>
407 <button class="flux-filter" data-filter="bookmarks">bookmarks</button>
408 <button class="flux-filter" data-filter="mastodon">mastodon</button>
409 </div>
410
411 {{range .Entries -}}
412 {{if isNote . -}}
413 <div class="stream-card" data-source="{{.Source}}" data-kind="{{.Kind}}" id="{{.ID}}">
414 <a class="card-anchor" href="#{{.ID}}">§</a>
415 <div class="card-date"><time datetime="{{isoDate .Date}}">{{formatDate .Date}}</time></div>
416 <div class="card-title"><a href="{{.URL}}">{{.Title}}</a></div>
417 {{- if .Body}}
418 <div class="card-body"><p>{{.Body}}</p></div>
419 {{- end}}
420 {{- if .Tags}}
421 <div class="tags">{{range .Tags}}<a class="tag" href="/flux/tags/{{.}}.html">{{.}}</a>{{end}}</div>
422 {{- end}}
423 </div>
424 {{- else if isRelease . -}}
425 <div class="release-card" data-source="{{.Source}}" data-kind="{{.Kind}}">
426 <span class="release-project">{{meta . "repo"}}</span>
427 <span class="release-version"><a href="{{.URL}}">{{.Title}}</a></span>
428 <span class="release-date"> · {{formatDate .Date}}</span>
429 </div>
430 {{- else if isGitHub . -}}
431 <div class="github-entry" data-source="{{.Source}}" data-kind="{{.Kind}}">
432 {{- if isPR .}}
433 <span class="gh-icon pr">{{prIcon .}}</span>
434 {{- else if isIssue .}}
435 <span class="gh-icon issue">{{issueIcon .}}</span>
436 {{- end}}
437 <div class="gh-content">
438 <div class="gh-title"><a href="{{.URL}}">{{.Title}}</a></div>
439 <div class="gh-repo">{{meta . "repo"}}#{{meta . "number"}}</div>
440 </div>
441 <div class="gh-date">{{meta . "state"}} · {{shortDate .Date}}</div>
442 </div>
443 {{- else if isPage . -}}
444 <a class="page-entry" data-source="{{.Source}}" data-kind="{{.Kind}}" href="{{.URL}}">
445 {{- if eq .Kind "page-new"}}
446 <span class="marker new">✚</span>
447 {{- else}}
448 <span class="marker updated">~</span>
449 {{- end}}
450 <span class="page-title">{{.Title}}</span>
451 <span class="page-date">{{shortDate .Date}}</span>
452 <span class="arrow">↗</span>
453 </a>
454 {{- else if isBookmark . -}}
455 <div class="bookmark-entry" data-source="{{.Source}}" data-kind="{{.Kind}}">
456 <div class="bm-header">
457 <span class="bm-icon">↬</span>
458 <span class="bm-title"><a href="{{.URL}}">{{.Title}}</a></span>
459 <span class="bm-date">{{shortDate .Date}}</span>
460 </div>
461 {{- if .Body}}
462 <div class="bm-note">{{.Body}}</div>
463 {{- end}}
464 {{- if meta . "author"}}
465 <div class="bm-author">— {{meta . "author"}}</div>
466 {{- end}}
467 </div>
468 {{- end}}
469 {{end}}
470
471 <footer class="site-footer">
472 <p><a href="/">Vincent Demeester</a> · <a href="/flux/">flux</a> · <a href="/flux/feed.json">json</a> · <a href="/flux/feed.xml">atom</a> · <a href="/flux/content.xml" title="pages, TIL, bookmarks only">atom (content)</a></p>
473 </footer>
474 </article>
475</body>
476</html>`
477
478func renderHTMLPage(cfg Config, path, title, description string, entries []Entry, isArchive bool, years []int, tags map[string]int) error {
479 tmpl, err := template.New("page").Funcs(funcMap).Parse(htmlTemplate)
480 if err != nil {
481 return fmt.Errorf("parsing template: %w", err)
482 }
483
484 f, err := os.Create(path)
485 if err != nil {
486 return fmt.Errorf("creating %s: %w", path, err)
487 }
488 defer f.Close()
489
490 data := pageData{
491 Title: title,
492 Description: description,
493 BaseURL: cfg.BaseURL,
494 SiteURL: cfg.SiteURL,
495 Entries: entries,
496 IsArchive: isArchive,
497 Years: years,
498 Tags: tags,
499 }
500
501 return tmpl.Execute(f, data)
502}
503
504const tagIndexTemplate = `<!DOCTYPE html>
505<html lang="en">
506<head>
507 <meta charset="utf-8">
508 <meta name="viewport" content="width=device-width, initial-scale=1">
509 <title>Tags — Vincent Demeester</title>
510 <link rel="stylesheet" href="/style.css" type="text/css">
511 <script src="/flux.js" defer></script>
512</head>
513<body>
514 <div class="site-controls"><span class="site-nav"><a href="/" title="index">⌘</a><a href="/flux/" title="flux">≋</a><a href="/about.html" title="about">☉</a></span><button class="theme-toggle" id="theme-toggle" title="Toggle dark/light mode">☀️</button></div>
515
516 <article class="content flux">
517 <header>
518 <h1 class="title">Tags</h1>
519 </header>
520
521 <div class="tags">
522 {{range $tag, $count := .Tags -}}
523 <a class="tag" href="{{$tag}}.html">{{$tag}} ({{$count}})</a>
524 {{end}}
525 </div>
526
527 <footer class="site-footer">
528 <p><a href="/">Vincent Demeester</a> · <a href="/flux/">flux</a> · <a href="/flux/feed.json">json</a> · <a href="/flux/feed.xml">atom</a> · <a href="/flux/content.xml" title="pages, TIL, bookmarks only">atom (content)</a></p>
529 </footer>
530 </article>
531</body>
532</html>`
533
534func renderTagIndex(cfg Config, tags map[string]int) error {
535 tmpl, err := template.New("tags").Parse(tagIndexTemplate)
536 if err != nil {
537 return fmt.Errorf("parsing tag index template: %w", err)
538 }
539
540 path := filepath.Join(cfg.OutputDir, "tags", "index.html")
541 f, err := os.Create(path)
542 if err != nil {
543 return fmt.Errorf("creating %s: %w", path, err)
544 }
545 defer f.Close()
546
547 return tmpl.Execute(f, struct{ Tags map[string]int }{Tags: tags})
548}