summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-27 08:59:39 +0300
committerPaul Buetow <paul@buetow.org>2026-04-27 08:59:39 +0300
commit30e63df03544b94ebc5fcb2a004d18a0d32a4247 (patch)
tree2933b6a790f01c215806b3efcb644f533c0c60cd /internal
parentc6e0b5cc48dedb52477cb0060e6ebc8ca4f088f2 (diff)
processor: introduce PostBuilder registry to replace hardcoded switch
Replace the large, duplicate switch statements in planPost and commitPlan with a PostBuilder interface registered per file extension. - PostBuilder interface: Plan(srcPath, ext) (postPlan, error) and Commit(plan, postDir, id, now) (*post.Post, []string, error) - Per-type builders: txtBuilder, mdBuilder, imageBuilder, audioBuilder. - Each builder self-registers via init() into a map[string]PostBuilder. - Core processor loops are now extension-agnostic, satisfying OCP/DIP. - All existing tests pass.
Diffstat (limited to 'internal')
-rw-r--r--internal/processor/audio.go39
-rw-r--r--internal/processor/image.go37
-rw-r--r--internal/processor/markdown.go48
-rw-r--r--internal/processor/processor.go142
-rw-r--r--internal/processor/txt.go28
5 files changed, 189 insertions, 105 deletions
diff --git a/internal/processor/audio.go b/internal/processor/audio.go
index 68938cf..821b747 100644
--- a/internal/processor/audio.go
+++ b/internal/processor/audio.go
@@ -4,8 +4,47 @@ import (
"fmt"
"io"
"os"
+ "path/filepath"
+ "time"
+
+ "codeberg.org/snonux/snonux/internal/post"
)
+type audioBuilder struct{}
+
+func (audioBuilder) Plan(srcPath string, ext string) (postPlan, error) {
+ plan := postPlan{srcPath: srcPath, ext: ext}
+ if err := validateAudio(srcPath); err != nil {
+ return postPlan{}, err
+ }
+ return plan, nil
+}
+
+func (audioBuilder) Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error) {
+ outName := filepath.Base(plan.srcPath)
+ dst := filepath.Join(postDir, outName)
+ if err := copyFile(plan.srcPath, dst); err != nil {
+ return nil, nil, err
+ }
+ src := fmt.Sprintf("posts/%s/%s", id, outName)
+ html := fmt.Sprintf(
+ `<audio controls class="post-audio"><source src="%s" type="audio/mpeg">Your browser does not support audio.</audio>`,
+ src,
+ )
+ p := &post.Post{
+ ID: id,
+ Timestamp: now,
+ PostType: post.TypeAudio,
+ Content: html,
+ Assets: []string{outName},
+ }
+ return p, nil, nil
+}
+
+func init() {
+ register(".mp3", audioBuilder{})
+}
+
// validateAudio confirms the audio source file exists and is readable.
func validateAudio(srcPath string) error {
f, err := os.Open(srcPath)
diff --git a/internal/processor/image.go b/internal/processor/image.go
index a981e85..f68f533 100644
--- a/internal/processor/image.go
+++ b/internal/processor/image.go
@@ -8,7 +8,9 @@ import (
"image/png"
"os"
"path/filepath"
+ "time"
+ "codeberg.org/snonux/snonux/internal/post"
"golang.org/x/image/draw"
)
@@ -17,6 +19,41 @@ const (
jpegQuality = 80
)
+type imageBuilder struct{}
+
+func (imageBuilder) Plan(srcPath string, ext string) (postPlan, error) {
+ plan := postPlan{srcPath: srcPath, ext: ext}
+ img, err := validateImage(srcPath)
+ if err != nil {
+ return postPlan{}, err
+ }
+ plan.validatedImage = img
+ return plan, nil
+}
+
+func (imageBuilder) Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error) {
+ if err := writeImageAsset(plan.validatedImage, postDir); err != nil {
+ return nil, nil, err
+ }
+ src := fmt.Sprintf("posts/%s/image.jpg", id)
+ html := fmt.Sprintf(`<img src="%s" alt="" class="post-image">`, src)
+ p := &post.Post{
+ ID: id,
+ Timestamp: now,
+ PostType: post.TypeImage,
+ Content: html,
+ Assets: []string{"image.jpg"},
+ }
+ return p, nil, nil
+}
+
+func init() {
+ register(".png", imageBuilder{})
+ register(".jpg", imageBuilder{})
+ register(".jpeg", imageBuilder{})
+ register(".gif", imageBuilder{})
+}
+
// validateImage reads and decodes the source image, resizing if necessary.
// It performs only read validation; the caller is responsible for writing assets.
func validateImage(srcPath string) (image.Image, error) {
diff --git a/internal/processor/markdown.go b/internal/processor/markdown.go
index c258e4c..ee38bf7 100644
--- a/internal/processor/markdown.go
+++ b/internal/processor/markdown.go
@@ -7,12 +7,60 @@ import (
"path/filepath"
"regexp"
"strings"
+ "time"
+ "codeberg.org/snonux/snonux/internal/post"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
+type mdBuilder struct{}
+
+func (mdBuilder) Plan(srcPath string, ext string) (postPlan, error) {
+ plan := postPlan{srcPath: srcPath, ext: ext}
+ html, locals, err := processMd(srcPath)
+ if err != nil {
+ return postPlan{}, err
+ }
+ plan.mdHTML = html
+ plan.localImages = locals
+ return plan, nil
+}
+
+func (mdBuilder) Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error) {
+ html := plan.mdHTML
+ for _, name := range plan.localImages {
+ html = strings.ReplaceAll(html,
+ fmt.Sprintf(`src="%s"`, name),
+ fmt.Sprintf(`src="posts/%s/%s"`, id, name))
+ }
+
+ var inboxExtras []string
+ sourceDir := filepath.Dir(plan.srcPath)
+ for _, name := range plan.localImages {
+ src := filepath.Join(sourceDir, name)
+ dst := filepath.Join(postDir, name)
+ if err := copyFile(src, dst); err != nil {
+ return nil, nil, fmt.Errorf("copy markdown asset %s: %w", name, err)
+ }
+ inboxExtras = append(inboxExtras, src)
+ }
+
+ p := &post.Post{
+ ID: id,
+ Timestamp: now,
+ PostType: post.TypeMarkdown,
+ Content: html,
+ Assets: plan.localImages,
+ }
+ return p, inboxExtras, nil
+}
+
+func init() {
+ register(".md", mdBuilder{})
+}
+
// isSimpleImageRef returns true for a filename-only reference (e.g.
// "img.png") that is safe to treat as a flat local file in the same
// directory as the markdown source. It rejects subdirectories, absolute
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 0c03d86..bb3a84d 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -32,6 +32,31 @@ import (
"codeberg.org/snonux/snonux/internal/post"
)
+// PostBuilder is the abstraction used to validate and commit a single post type.
+// Each concrete builder handles one file extension (e.g. .txt, .png, .mp3).
+// Registering a new builder is enough to add support for a new type — no changes
+// to the core planning or commit loops are required.
+type PostBuilder interface {
+ // Plan validates the source file and returns everything needed to commit it later.
+ Plan(srcPath string, ext string) (postPlan, error)
+ // Commit performs the mutations for this post type and returns the populated Post,
+ // plus any extra inbox files that should be cleaned up after a successful save.
+ Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error)
+}
+
+// builders maps a lower-case file extension to its PostBuilder.
+var builders = make(map[string]PostBuilder)
+
+// register adds a PostBuilder for the given extension. Panics on duplicates so
+// misconfiguration is caught at start-up.
+func register(ext string, b PostBuilder) {
+ ext = strings.ToLower(ext)
+ if _, exists := builders[ext]; exists {
+ panic(fmt.Sprintf("duplicate PostBuilder for extension %q", ext))
+ }
+ builders[ext] = b
+}
+
// Run scans cfg.InputDir and processes every eligible file into a post directory
// under cfg.OutputDir/posts/. It uses a two-phase commit pattern:
//
@@ -97,46 +122,22 @@ type postPlan struct {
mdHTML string
localImages []string
validatedImage image.Image
+ builder PostBuilder
}
// planPost validates a single source file and returns a plan containing
// everything needed to commit it later. It performs no mutations.
func planPost(srcPath string) (postPlan, error) {
ext := strings.ToLower(filepath.Ext(srcPath))
- plan := postPlan{srcPath: srcPath, ext: ext}
-
- switch ext {
- case ".txt":
- html, err := processTxt(srcPath)
- if err != nil {
- return postPlan{}, err
- }
- plan.textHTML = html
-
- case ".md":
- html, locals, err := processMd(srcPath)
- if err != nil {
- return postPlan{}, err
- }
- plan.mdHTML = html
- plan.localImages = locals
-
- case ".png", ".jpg", ".jpeg", ".gif":
- img, err := validateImage(srcPath)
- if err != nil {
- return postPlan{}, err
- }
- plan.validatedImage = img
-
- case ".mp3":
- if err := validateAudio(srcPath); err != nil {
- return postPlan{}, err
- }
-
- default:
+ b, ok := builders[ext]
+ if !ok {
return postPlan{}, fmt.Errorf("unsupported file type: %s", ext)
}
-
+ plan, err := b.Plan(srcPath, ext)
+ if err != nil {
+ return postPlan{}, err
+ }
+ plan.builder = b
return plan, nil
}
@@ -153,79 +154,10 @@ func commitPlan(plan postPlan, postsDir string, now time.Time) error {
return fmt.Errorf("create post dir %s: %w", id, err)
}
- var p *post.Post
- var inboxExtras []string
-
- switch plan.ext {
- case ".txt":
- p = &post.Post{
- ID: id,
- Timestamp: now,
- PostType: post.TypeText,
- Content: plan.textHTML,
- }
-
- case ".md":
- html := plan.mdHTML
- for _, name := range plan.localImages {
- html = strings.ReplaceAll(html,
- fmt.Sprintf(`src="%s"`, name),
- fmt.Sprintf(`src="posts/%s/%s"`, id, name))
- }
-
- sourceDir := filepath.Dir(plan.srcPath)
- for _, name := range plan.localImages {
- src := filepath.Join(sourceDir, name)
- dst := filepath.Join(postDir, name)
- if err := copyFile(src, dst); err != nil {
- _ = os.RemoveAll(postDir)
- return fmt.Errorf("copy markdown asset %s: %w", name, err)
- }
- inboxExtras = append(inboxExtras, src)
- }
-
- p = &post.Post{
- ID: id,
- Timestamp: now,
- PostType: post.TypeMarkdown,
- Content: html,
- Assets: plan.localImages,
- }
-
- case ".png", ".jpg", ".jpeg", ".gif":
- if err := writeImageAsset(plan.validatedImage, postDir); err != nil {
- _ = os.RemoveAll(postDir)
- return err
- }
- src := fmt.Sprintf("posts/%s/image.jpg", id)
- html := fmt.Sprintf(`<img src="%s" alt="" class="post-image">`, src)
- p = &post.Post{
- ID: id,
- Timestamp: now,
- PostType: post.TypeImage,
- Content: html,
- Assets: []string{"image.jpg"},
- }
-
- case ".mp3":
- outName := filepath.Base(plan.srcPath)
- dst := filepath.Join(postDir, outName)
- if err := copyFile(plan.srcPath, dst); err != nil {
- _ = os.RemoveAll(postDir)
- return err
- }
- src := fmt.Sprintf("posts/%s/%s", id, outName)
- html := fmt.Sprintf(
- `<audio controls class="post-audio"><source src="%s" type="audio/mpeg">Your browser does not support audio.</audio>`,
- src,
- )
- p = &post.Post{
- ID: id,
- Timestamp: now,
- PostType: post.TypeAudio,
- Content: html,
- Assets: []string{outName},
- }
+ p, inboxExtras, err := plan.builder.Commit(plan, postDir, id, now)
+ if err != nil {
+ _ = os.RemoveAll(postDir)
+ return err
}
if err := p.Save(postDir); err != nil {
diff --git a/internal/processor/txt.go b/internal/processor/txt.go
index 8381271..f230d37 100644
--- a/internal/processor/txt.go
+++ b/internal/processor/txt.go
@@ -6,12 +6,40 @@ import (
"os"
"regexp"
"strings"
+ "time"
+
+ "codeberg.org/snonux/snonux/internal/post"
)
// urlPattern matches http/https URLs in plain text.
// Trailing sentence punctuation is stripped separately by stripURLTrailing.
var urlPattern = regexp.MustCompile(`https?://\S+`)
+type txtBuilder struct{}
+
+func (txtBuilder) Plan(srcPath string, ext string) (postPlan, error) {
+ plan := postPlan{srcPath: srcPath, ext: ext}
+ html, err := processTxt(srcPath)
+ if err != nil {
+ return postPlan{}, err
+ }
+ plan.textHTML = html
+ return plan, nil
+}
+
+func (txtBuilder) Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error) {
+ return &post.Post{
+ ID: id,
+ Timestamp: now,
+ PostType: post.TypeText,
+ Content: plan.textHTML,
+ }, nil, nil
+}
+
+func init() {
+ register(".txt", txtBuilder{})
+}
+
// processTxt reads a plain-text file and wraps each non-empty paragraph in <p> tags.
// URLs are automatically converted to clickable <a> links.
// Non-URL text is HTML-escaped to prevent XSS.