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}