From 3e61d09873065f5342efc414ee3ea0d5fdc4c767 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 9 Apr 2026 20:44:58 +0300 Subject: add snonux static microblog generator Full Go implementation with: - txt/md/image/audio input processing, URL auto-linking in .txt files - Paginated HTML output with Atom feed - 11 visual themes: neon, terminal, synthwave, minimal, brutalist, paper, aurora, matrix, ocean, retro, glass (selectable via --theme flag) - Keyboard navigation (j/k/arrows, Enter modal, h/l page nav) - Shared nav templates (navhints, navmodal, navscript) across all themes - Magefile build automation; integration test suite covering all themes Co-Authored-By: Claude Sonnet 4.6 --- internal/config/config.go | 26 ++++ internal/generator/atom.go | 104 +++++++++++++++ internal/generator/generator.go | 188 +++++++++++++++++++++++++++ internal/generator/shared.go | 100 +++++++++++++++ internal/generator/templates.go | 5 + internal/generator/theme_aurora.go | 114 +++++++++++++++++ internal/generator/theme_brutalist.go | 97 ++++++++++++++ internal/generator/theme_glass.go | 123 ++++++++++++++++++ internal/generator/theme_matrix.go | 102 +++++++++++++++ internal/generator/theme_minimal.go | 96 ++++++++++++++ internal/generator/theme_neon.go | 224 ++++++++++++++++++++++++++++++++ internal/generator/theme_ocean.go | 105 +++++++++++++++ internal/generator/theme_paper.go | 98 ++++++++++++++ internal/generator/theme_retro.go | 105 +++++++++++++++ internal/generator/theme_synthwave.go | 111 ++++++++++++++++ internal/generator/theme_terminal.go | 101 +++++++++++++++ internal/generator/themes.go | 44 +++++++ internal/post/post.go | 84 ++++++++++++ internal/processor/audio.go | 49 +++++++ internal/processor/image.go | 116 +++++++++++++++++ internal/processor/markdown.go | 68 ++++++++++ internal/processor/processor.go | 234 ++++++++++++++++++++++++++++++++++ internal/processor/txt.go | 103 +++++++++++++++ 23 files changed, 2397 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/generator/atom.go create mode 100644 internal/generator/generator.go create mode 100644 internal/generator/shared.go create mode 100644 internal/generator/templates.go create mode 100644 internal/generator/theme_aurora.go create mode 100644 internal/generator/theme_brutalist.go create mode 100644 internal/generator/theme_glass.go create mode 100644 internal/generator/theme_matrix.go create mode 100644 internal/generator/theme_minimal.go create mode 100644 internal/generator/theme_neon.go create mode 100644 internal/generator/theme_ocean.go create mode 100644 internal/generator/theme_paper.go create mode 100644 internal/generator/theme_retro.go create mode 100644 internal/generator/theme_synthwave.go create mode 100644 internal/generator/theme_terminal.go create mode 100644 internal/generator/themes.go create mode 100644 internal/post/post.go create mode 100644 internal/processor/audio.go create mode 100644 internal/processor/image.go create mode 100644 internal/processor/markdown.go create mode 100644 internal/processor/processor.go create mode 100644 internal/processor/txt.go (limited to 'internal') diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fd6e560 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,26 @@ +// Package config holds the shared configuration for the snonux generator. +// All values are derived from CLI flags with sensible defaults. +package config + +const ( + // PostsPerPage is the maximum number of blog posts rendered on a single HTML page. + PostsPerPage = 42 +) + +// Config carries the runtime configuration for the generator pipeline. +type Config struct { + // InputDir is where new source files (txt, md, images, audio) are read from. + InputDir string + + // OutputDir is the root of the static site: index.html, pageN.html, atom.xml, + // and the posts/ subdirectory all live here. + OutputDir string + + // BaseURL is the canonical site URL, used in the Atom feed links. + // Example: "https://snonux.foo" + BaseURL string + + // Theme selects the visual style for generated HTML pages. + // Defaults to "neon". Run with --help to see all available themes. + Theme string +} diff --git a/internal/generator/atom.go b/internal/generator/atom.go new file mode 100644 index 0000000..259301c --- /dev/null +++ b/internal/generator/atom.go @@ -0,0 +1,104 @@ +package generator + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// atomFeed is the root element of an Atom 1.0 feed document. +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + XMLNS string `xml:"xmlns,attr"` + Title string `xml:"title"` + Link atomLink `xml:"link"` + Updated string `xml:"updated"` + ID string `xml:"id"` + Entries []atomEntry `xml:"entry"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type atomEntry struct { + Title string `xml:"title"` + Link atomLink `xml:"link"` + ID string `xml:"id"` + Updated string `xml:"updated"` + Content atomContent `xml:"content"` +} + +type atomContent struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +// generateAtom writes atom.xml to cfg.OutputDir containing the most recent +// min(len(posts), config.PostsPerPage) entries. +func generateAtom(posts []*post.Post, cfg *config.Config) error { + limit := config.PostsPerPage + if len(posts) < limit { + limit = len(posts) + } + + recent := posts[:limit] + entries := buildAtomEntries(recent, cfg.BaseURL) + + updated := time.Now().UTC().Format(time.RFC3339) + if len(recent) > 0 { + updated = recent[0].Timestamp.UTC().Format(time.RFC3339) + } + + feed := atomFeed{ + XMLNS: "http://www.w3.org/2005/Atom", + Title: "snonux.foo", + Link: atomLink{Href: cfg.BaseURL + "/"}, + Updated: updated, + ID: cfg.BaseURL + "/", + Entries: entries, + } + + return writeAtomFile(feed, filepath.Join(cfg.OutputDir, "atom.xml")) +} + +// buildAtomEntries converts a slice of posts into Atom entry elements. +func buildAtomEntries(posts []*post.Post, baseURL string) []atomEntry { + entries := make([]atomEntry, 0, len(posts)) + + for _, p := range posts { + entryURL := fmt.Sprintf("%s/posts/%s/", baseURL, p.ID) + entry := atomEntry{ + Title: fmt.Sprintf("Post %s", p.ID), + Link: atomLink{Href: entryURL, Rel: "alternate"}, + ID: entryURL, + Updated: p.Timestamp.UTC().Format(time.RFC3339), + Content: atomContent{Type: "html", Value: p.Content}, + } + entries = append(entries, entry) + } + + return entries +} + +// writeAtomFile marshals feed to XML and writes it to path with XML declaration. +func writeAtomFile(feed atomFeed, path string) error { + data, err := xml.MarshalIndent(feed, "", " ") + 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 +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 0000000..595bb62 --- /dev/null +++ b/internal/generator/generator.go @@ -0,0 +1,188 @@ +// Package generator reads all post directories from outdir/posts/, sorts them by +// timestamp descending, paginates them into HTML pages, and writes atom.xml. +package generator + +import ( + "encoding/json" + "fmt" + "html/template" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// pageData holds the template variables for a single HTML page. +type pageData struct { + Posts []postView + PrevPage string // URL of the newer page, empty if none + NextPage string // URL of the older page, empty if none + PrevPageJSON template.JS + NextPageJSON template.JS +} + +// postView is a render-friendly representation of a post for the HTML template. +type postView struct { + FormattedTime string + ContentHTML template.HTML // pre-rendered; trusted — generated by this tool +} + +// Run loads all posts, generates all HTML pages, and writes atom.xml. +func Run(cfg *config.Config) error { + posts, err := loadAllPosts(cfg.OutputDir) + if err != nil { + return err + } + + // Sort newest-first so page 1 (index.html) has the latest content. + sort.Slice(posts, func(i, j int) bool { + return posts[i].Timestamp.After(posts[j].Timestamp) + }) + + pages := paginate(posts, config.PostsPerPage) + + // Combine the theme HTML (which uses {{template "navhints"}} etc.) with the + // shared navDefs sub-templates so a single parse call resolves all references. + combined := getTheme(cfg.Theme) + "\n" + navDefs + tmpl, err := template.New("page").Parse(combined) + if err != nil { + return fmt.Errorf("parse page template: %w", err) + } + + for i, page := range pages { + if err := writePage(tmpl, page, i, len(pages), cfg); err != nil { + return err + } + } + + return generateAtom(posts, cfg) +} + +// loadAllPosts walks outdir/posts/ and deserialises every post.json found. +func loadAllPosts(outputDir string) ([]*post.Post, error) { + postsDir := filepath.Join(outputDir, "posts") + + entries, err := os.ReadDir(postsDir) + if os.IsNotExist(err) { + return nil, nil // no posts yet — normal on first run + } + if err != nil { + return nil, fmt.Errorf("read posts dir: %w", err) + } + + var posts []*post.Post + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + p, err := post.Load(filepath.Join(postsDir, entry.Name())) + if err != nil { + return nil, err + } + + posts = append(posts, p) + } + + return posts, nil +} + +// paginate splits posts into chunks of size pageSize. +func paginate(posts []*post.Post, pageSize int) [][]*post.Post { + var pages [][]*post.Post + + for i := 0; i < len(posts); i += pageSize { + end := i + pageSize + if end > len(posts) { + end = len(posts) + } + pages = append(pages, posts[i:end]) + } + + return pages +} + +// pageFilename returns "index.html" for page 0 and "pageN.html" for page N>0. +func pageFilename(index int) string { + if index == 0 { + return "index.html" + } + return fmt.Sprintf("page%d.html", index+1) +} + +// writePage renders one HTML page and writes it to cfg.OutputDir. +func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config) error { + data := buildPageData(posts, pageIndex, totalPages) + + filename := pageFilename(pageIndex) + path := filepath.Join(cfg.OutputDir, filename) + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", filename, err) + } + defer f.Close() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("render %s: %w", filename, err) + } + + return nil +} + +// buildPageData constructs the template data for a single page. +func buildPageData(posts []*post.Post, pageIndex, totalPages int) pageData { + views := make([]postView, len(posts)) + for i, p := range posts { + views[i] = postView{ + FormattedTime: formatPostTime(p.Timestamp), + ContentHTML: template.HTML(p.Content), //nolint:gosec // content is tool-generated HTML + } + } + + var prevPage, nextPage string + + // "Prev" means newer — page index decreases. + if pageIndex > 0 { + prevPage = pageFilename(pageIndex - 1) + } + + // "Next" means older — page index increases. + if pageIndex < totalPages-1 { + nextPage = pageFilename(pageIndex + 1) + } + + return pageData{ + Posts: views, + PrevPage: prevPage, + NextPage: nextPage, + PrevPageJSON: jsonStringOrNull(prevPage), + NextPageJSON: jsonStringOrNull(nextPage), + } +} + +// formatPostTime formats a UTC timestamp in the style used on posts: "09.04.26 • 14:30 UTC". +func formatPostTime(t time.Time) string { + utc := t.UTC() + return fmt.Sprintf("%02d.%02d.%02d • %02d:%02d UTC", + utc.Day(), int(utc.Month()), utc.Year()%100, + utc.Hour(), utc.Minute(), + ) +} + +// jsonStringOrNull returns a JS-safe JSON string literal for s, or "null" if empty. +// The result is safe to embed directly in a +{{end}} +` diff --git a/internal/generator/templates.go b/internal/generator/templates.go new file mode 100644 index 0000000..5186794 --- /dev/null +++ b/internal/generator/templates.go @@ -0,0 +1,5 @@ +package generator + +// HTML templates have moved to per-theme files (theme_*.go). +// Shared sub-templates (navhints, navmodal, navscript) are in shared.go. +// The theme registry and selection logic are in themes.go. diff --git a/internal/generator/theme_aurora.go b/internal/generator/theme_aurora.go new file mode 100644 index 0000000..9475320 --- /dev/null +++ b/internal/generator/theme_aurora.go @@ -0,0 +1,114 @@ +package generator + +// auroraTemplate is a dark navy theme with a CSS-animated aurora borealis +// effect — shifting green/purple/teal gradients across the background sky. +const auroraTemplate = ` + + + + + snonux.foo ✦ AURORA + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_brutalist.go b/internal/generator/theme_brutalist.go new file mode 100644 index 0000000..214c103 --- /dev/null +++ b/internal/generator/theme_brutalist.go @@ -0,0 +1,97 @@ +package generator + +// brutalistTemplate is a raw brutalist theme — pure black, thick white borders, +// Impact font, red as the only accent. No rounded corners anywhere. +const brutalistTemplate = ` + + + + + SNONUX.FOO + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@SNONUX
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_glass.go b/internal/generator/theme_glass.go new file mode 100644 index 0000000..520f9b0 --- /dev/null +++ b/internal/generator/theme_glass.go @@ -0,0 +1,123 @@ +package generator + +// glassTemplate is a glassmorphism theme — semi-transparent frosted panels +// using backdrop-filter:blur over a blurred gradient background. +// Light mode with subtle purple/blue gradient blobs and white glass cards. +const glassTemplate = ` + + + + + snonux.foo · glass + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_matrix.go b/internal/generator/theme_matrix.go new file mode 100644 index 0000000..6d8b531 --- /dev/null +++ b/internal/generator/theme_matrix.go @@ -0,0 +1,102 @@ +package generator + +// matrixTemplate is a hacker-style theme inspired by The Matrix — black +// background, bright matrix-green (#00ff41) text, monospace throughout, +// no decorations beyond a faint scanline overlay. +const matrixTemplate = ` + + + + + snonux.foo // MATRIX + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_minimal.go b/internal/generator/theme_minimal.go new file mode 100644 index 0000000..ebad091 --- /dev/null +++ b/internal/generator/theme_minimal.go @@ -0,0 +1,96 @@ +package generator + +// minimalTemplate is a clean white theme — system font, subtle borders, +// no animations or decorations. Maximum readability. +const minimalTemplate = ` + + + + + snonux.foo + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_neon.go b/internal/generator/theme_neon.go new file mode 100644 index 0000000..3197d6f --- /dev/null +++ b/internal/generator/theme_neon.go @@ -0,0 +1,224 @@ +package generator + +// neonTemplate is the cyberpunk neon theme — dark background, Three.js 3D orb +// and rings, cyan/magenta/yellow palette, Orbitron font. +// Keyboard nav and modal HTML are injected via the shared navDefs sub-templates. +const neonTemplate = ` + + + + + snonux.foo • NEON NEXUS + + + + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}} + + {{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}} + + {{end}} +
+
+ {{template "navmodal" .}} + + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_ocean.go b/internal/generator/theme_ocean.go new file mode 100644 index 0000000..422cf0b --- /dev/null +++ b/internal/generator/theme_ocean.go @@ -0,0 +1,105 @@ +package generator + +// oceanTemplate is a deep-ocean theme — dark navy/midnight blue background, +// teal/aqua/seafoam accents, subtle wave gradient at the bottom. +const oceanTemplate = ` + + + + + snonux.foo ~ OCEAN + + + +
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_paper.go b/internal/generator/theme_paper.go new file mode 100644 index 0000000..551e224 --- /dev/null +++ b/internal/generator/theme_paper.go @@ -0,0 +1,98 @@ +package generator + +// paperTemplate is a warm vintage newspaper theme — Georgia serif, sepia tones, +// subtle texture simulation via CSS, no animations. +const paperTemplate = ` + + + + + snonux.foo — the microblog + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_retro.go b/internal/generator/theme_retro.go new file mode 100644 index 0000000..82dc605 --- /dev/null +++ b/internal/generator/theme_retro.go @@ -0,0 +1,105 @@ +package generator + +// retroTemplate is an amber DOS terminal theme — black background, amber +// phosphor (#ffb000) text, monospace throughout, no decorations. +// Distinct from terminal.go (green) — this one evokes vintage PC monitors. +const retroTemplate = ` + + + + + SNONUX.FOO // RETRO + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@SNONUX
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_synthwave.go b/internal/generator/theme_synthwave.go new file mode 100644 index 0000000..8798d18 --- /dev/null +++ b/internal/generator/theme_synthwave.go @@ -0,0 +1,111 @@ +package generator + +// synthwaveTemplate is the 80s retrowave theme — dark purple sky, CSS perspective +// grid floor, hot pink/orange accents, Russo One font. +const synthwaveTemplate = ` + + + + + snonux.foo ⊕ SYNTHWAVE + + + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_terminal.go b/internal/generator/theme_terminal.go new file mode 100644 index 0000000..075ec25 --- /dev/null +++ b/internal/generator/theme_terminal.go @@ -0,0 +1,101 @@ +package generator + +// terminalTemplate is the green phosphor CRT terminal theme. +// Monospace throughout, scanline overlay via CSS, no external dependencies. +const terminalTemplate = ` + + + + + snonux.foo // TERMINAL + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/themes.go b/internal/generator/themes.go new file mode 100644 index 0000000..8de6193 --- /dev/null +++ b/internal/generator/themes.go @@ -0,0 +1,44 @@ +package generator + +// themeRegistry maps theme names to their HTML template strings. +// Each template must use {{template "navhints" .}}, {{template "navmodal" .}}, +// and {{template "navscript" .}} — these are defined in shared.go (navDefs). +var themeRegistry = map[string]string{ + "neon": neonTemplate, + "terminal": terminalTemplate, + "synthwave": synthwaveTemplate, + "minimal": minimalTemplate, + "brutalist": brutalistTemplate, + "paper": paperTemplate, + "aurora": auroraTemplate, + "matrix": matrixTemplate, + "ocean": oceanTemplate, + "retro": retroTemplate, + "glass": glassTemplate, +} + +// getTheme returns the HTML template string for the given theme name. +// Falls back to the neon theme if the name is unknown. +func getTheme(name string) string { + if t, ok := themeRegistry[name]; ok { + return t + } + return neonTemplate +} + +// ListThemes returns a sorted list of all available theme names. +func ListThemes() []string { + names := make([]string, 0, len(themeRegistry)) + for k := range themeRegistry { + names = append(names, k) + } + // Sort for deterministic output in --help text. + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[i] > names[j] { + names[i], names[j] = names[j], names[i] + } + } + } + return names +} diff --git a/internal/post/post.go b/internal/post/post.go new file mode 100644 index 0000000..cdef546 --- /dev/null +++ b/internal/post/post.go @@ -0,0 +1,84 @@ +// Package post defines the Post data model and its JSON serialisation format. +// Each post is stored as post.json inside its own directory under outdir/posts/. +// This allows pages to be re-generated without re-processing the original inputs. +package post + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Type enumerates the supported input content types. +type Type string + +const ( + TypeText Type = "text" + TypeMarkdown Type = "markdown" + TypeImage Type = "image" + TypeAudio Type = "audio" +) + +// Post represents a single microblog entry. +// It is persisted as post.json in outdir/posts//. +type Post struct { + // ID is the unique directory-name-safe timestamp, e.g. "2026-04-09-143022". + ID string `json:"id"` + + // Timestamp is the moment the post was processed (UTC). + Timestamp time.Time `json:"timestamp"` + + // PostType determines how the content was generated and how it should be rendered. + PostType Type `json:"type"` + + // Content is the pre-rendered HTML snippet for this post (without outer post-card wrapper). + Content string `json:"content"` + + // Assets lists filenames (not paths) of any asset files stored alongside post.json. + Assets []string `json:"assets,omitempty"` +} + +// Save writes the post as post.json into dir. +func (p *Post) Save(dir string) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("marshal post %s: %w", p.ID, err) + } + + path := filepath.Join(dir, "post.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write post.json for %s: %w", p.ID, err) + } + + return nil +} + +// Load reads and parses post.json from dir. +func Load(dir string) (*Post, error) { + path := filepath.Join(dir, "post.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read post.json in %s: %w", dir, err) + } + + var p Post + if err := json.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("unmarshal post.json in %s: %w", dir, err) + } + + return &p, nil +} + +// NewID generates a unique post ID from the given time. +// Format: YYYY-MM-DD-HHmmss, optionally suffixed with -N for collisions. +func NewID(t time.Time, suffix int) string { + base := t.UTC().Format("2006-01-02-150405") + if suffix == 0 { + return base + } + + return fmt.Sprintf("%s-%d", base, suffix) +} diff --git a/internal/processor/audio.go b/internal/processor/audio.go new file mode 100644 index 0000000..98aedcf --- /dev/null +++ b/internal/processor/audio.go @@ -0,0 +1,49 @@ +package processor + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// processAudio copies an .mp3 file into destDir and returns an HTML