diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-27 08:59:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-27 08:59:39 +0300 |
| commit | 30e63df03544b94ebc5fcb2a004d18a0d32a4247 (patch) | |
| tree | 2933b6a790f01c215806b3efcb644f533c0c60cd /internal | |
| parent | c6e0b5cc48dedb52477cb0060e6ebc8ca4f088f2 (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.go | 39 | ||||
| -rw-r--r-- | internal/processor/image.go | 37 | ||||
| -rw-r--r-- | internal/processor/markdown.go | 48 | ||||
| -rw-r--r-- | internal/processor/processor.go | 142 | ||||
| -rw-r--r-- | internal/processor/txt.go | 28 |
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. |
