diff --git a/go.mod b/go.mod index 58ef1463..1fb9e74b 100644 --- a/go.mod +++ b/go.mod @@ -78,6 +78,7 @@ require ( go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/image v0.31.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/go.sum b/go.sum index 0332d015..d11bdab8 100644 --- a/go.sum +++ b/go.sum @@ -181,6 +181,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/metal/cli/seo/client_test.go b/metal/cli/seo/client_test.go index 577a1efa..cfaee9f3 100644 --- a/metal/cli/seo/client_test.go +++ b/metal/cli/seo/client_test.go @@ -2,6 +2,7 @@ package seo import ( "net/http" + "path/filepath" "strings" "testing" @@ -54,6 +55,9 @@ func TestFetchReturnsJSONDecodeError(t *testing.T) { func TestClientLoadsFixtures(t *testing.T) { withRepoRoot(t) + spaDir := t.TempDir() + imagesDir := filepath.Join(spaDir, "posts", "images") + e := &env.Environment{ App: env.AppEnvironment{ Name: "SEO Test Fixtures", @@ -61,7 +65,7 @@ func TestClientLoadsFixtures(t *testing.T) { Type: "local", MasterKey: strings.Repeat("k", 32), }, - Seo: env.SeoEnvironment{SpaDir: t.TempDir()}, + Seo: env.SeoEnvironment{SpaDir: spaDir, SpaImagesDir: imagesDir}, } routes := router.NewWebsiteRoutes(e) diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go index 1cd26be7..c12b0b74 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -38,7 +38,7 @@ type TemplateData struct { } type TagOgData struct { - Type string `validate:"required,oneof=website"` + Type string `validate:"required,oneof=website article"` Image string `validate:"required,url"` ImageAlt string `validate:"required,min=10"` ImageWidth string `validate:"required"` diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go index dfe18dc1..e88c4c40 100644 --- a/metal/cli/seo/defaults.go +++ b/metal/cli/seo/defaults.go @@ -18,6 +18,7 @@ const WebAboutUrl = "/about" const WebPostsName = "Posts" const WebPostsUrl = "/posts" +const WebPostDetailUrl = "/post" const WebResumeName = "Resume" const WebResumeUrl = "/resume" diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 359f3864..35c3de64 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -3,6 +3,7 @@ package seo import ( "bytes" "embed" + "errors" "fmt" "html/template" "net/url" @@ -310,6 +311,10 @@ func (g *Generator) Export(origin string, data TemplateData) error { return fmt.Errorf("%s: creating directory for %s: %w", fileName, filepath.Dir(out), err) } + if err = os.Remove(out); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s: removing stale export %s: %w", fileName, out, err) + } + cli.Blueln(fmt.Sprintf("Writing file on: %s", out)) if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { return fmt.Errorf("%s: writing %s: %w", fileName, out, err) @@ -439,9 +444,19 @@ func (g *Generator) BuildForPost(post payload.PostResponse, body []template.HTML imageAlt := g.SanitizeAltText(post.Title, g.Page.SiteName) description := g.SanitizeMetaDescription(post.Excerpt, Description) image := g.PreferredImageURL(post.CoverImageURL, g.Page.AboutPhotoUrl) + imageType := "image/png" + + if prepared, err := g.preparePostImage(post); err == nil && prepared.URL != "" { + image = prepared.URL + imageType = prepared.Mime + } else if err != nil { + cli.Errorln(fmt.Sprintf("failed to prepare post image for %s: %v", post.Slug, err)) + } return g.buildForPage(post.Title, path, body, func(data *TemplateData) { data.OGTagOg.Image = image + data.OGTagOg.Type = "article" + data.OGTagOg.ImageType = imageType data.Twitter.Image = image data.Description = description data.OGTagOg.ImageAlt = imageAlt @@ -454,10 +469,10 @@ func (g *Generator) CanonicalPostPath(slug string) string { cleaned = strings.Trim(cleaned, "/") if cleaned == "" { - return WebPostsUrl + return WebPostDetailUrl } - return WebPostsUrl + "/" + cleaned + return WebPostDetailUrl + "/" + cleaned } func (g *Generator) SanitizeMetaDescription(raw, fallback string) string { diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index c320c049..fc73b3dd 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -3,14 +3,24 @@ package seo import ( "encoding/json" "html/template" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" "os" + "path" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/go-playground/validator/v10" "github.com/oullin/database" + "github.com/oullin/handler/payload" + "github.com/oullin/metal/env" "github.com/oullin/pkg/portal" ) @@ -68,11 +78,16 @@ func TestGeneratorBuildAndExport(t *testing.T) { t.Fatalf("unexpected manifest short name: %v", manifest["short_name"]) } + output := filepath.Join(page.OutputDir, "index.seo.html") + + if err := os.WriteFile(output, []byte("stale"), 0o444); err != nil { + t.Fatalf("seed stale export: %v", err) + } + if err := gen.Export("index", data); err != nil { t.Fatalf("export err: %v", err) } - output := filepath.Join(page.OutputDir, "index.seo.html") raw, err := os.ReadFile(output) if err != nil { t.Fatalf("read output: %v", err) @@ -204,3 +219,176 @@ func TestGeneratorGenerateAllPages(t *testing.T) { t.Fatalf("expected post body content in seo output: %q", postContent) } } + +func TestGeneratorPreparePostImage(t *testing.T) { + withRepoRoot(t) + + outputDir := t.TempDir() + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "source.png") + + img := image.NewRGBA(image.Rect(0, 0, 300, 300)) + for y := 0; y < 300; y++ { + for x := 0; x < 300; x++ { + img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255}) + } + } + + fh, err := os.Create(srcPath) + if err != nil { + t.Fatalf("create source image: %v", err) + } + + if err := png.Encode(fh, img); err != nil { + t.Fatalf("encode image: %v", err) + } + + if err := fh.Close(); err != nil { + t.Fatalf("close image: %v", err) + } + + fileURL := url.URL{Scheme: "file", Path: srcPath} + + imagesDir := filepath.Join(outputDir, "posts", "images") + + gen := &Generator{ + Page: Page{ + SiteName: "SEO Test Suite", + SiteURL: "https://seo.example.test", + OutputDir: outputDir, + }, + Env: &env.Environment{Seo: env.SeoEnvironment{SpaDir: outputDir, SpaImagesDir: imagesDir}}, + } + + post := payload.PostResponse{Slug: "awesome-post", CoverImageURL: fileURL.String()} + + prepared, err := gen.preparePostImage(post) + if err != nil { + t.Fatalf("prepare post image: %v", err) + } + + if prepared.URL == "" { + t.Fatalf("expected prepared image url") + } + + expectedSuffix := path.Join("posts", "images", "awesome-post.png") + if !strings.HasSuffix(prepared.URL, expectedSuffix) { + t.Fatalf("unexpected image url: %s", prepared.URL) + } + + destPath := filepath.Join(imagesDir, "awesome-post.png") + info, err := os.Stat(destPath) + if err != nil { + t.Fatalf("stat destination image: %v", err) + } + + if info.Size() == 0 { + t.Fatalf("expected destination image to have content") + } + + fh, err = os.Open(destPath) + if err != nil { + t.Fatalf("open destination image: %v", err) + } + defer fh.Close() + + resized, _, err := image.Decode(fh) + if err != nil { + t.Fatalf("decode destination image: %v", err) + } + + bounds := resized.Bounds() + if bounds.Dx() != seoImageWidth || bounds.Dy() != seoImageHeight { + t.Fatalf("unexpected resized dimensions: got %dx%d", bounds.Dx(), bounds.Dy()) + } + + if prepared.Mime != "image/png" { + t.Fatalf("unexpected mime type: %s", prepared.Mime) + } + +} + +func TestGeneratorPreparePostImageRemote(t *testing.T) { + withRepoRoot(t) + + outputDir := t.TempDir() + + var requests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + + img := image.NewRGBA(image.Rect(0, 0, 400, 400)) + for y := 0; y < 400; y++ { + for x := 0; x < 400; x++ { + img.Set(x, y, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + } + } + + if err := png.Encode(w, img); err != nil { + t.Fatalf("encode remote image: %v", err) + } + })) + defer server.Close() + + imagesDir := filepath.Join(outputDir, "posts", "images") + + gen := &Generator{ + Page: Page{ + SiteName: "SEO Test Suite", + SiteURL: "https://seo.example.test", + OutputDir: outputDir, + }, + Env: &env.Environment{Seo: env.SeoEnvironment{SpaDir: outputDir, SpaImagesDir: imagesDir}}, + } + + post := payload.PostResponse{Slug: "remote-post", CoverImageURL: server.URL + "/cover.png"} + + prepared, err := gen.preparePostImage(post) + if err != nil { + t.Fatalf("prepare post image: %v", err) + } + + if prepared.URL == "" { + t.Fatalf("expected prepared image url") + } + + expectedSuffix := path.Join("posts", "images", "remote-post.png") + if !strings.HasSuffix(prepared.URL, expectedSuffix) { + t.Fatalf("unexpected image url: %s", prepared.URL) + } + + destPath := filepath.Join(imagesDir, "remote-post.png") + info, err := os.Stat(destPath) + if err != nil { + t.Fatalf("stat destination image: %v", err) + } + + if info.Size() == 0 { + t.Fatalf("expected destination image to have content") + } + + fh, err := os.Open(destPath) + if err != nil { + t.Fatalf("open destination image: %v", err) + } + defer fh.Close() + + resized, _, err := image.Decode(fh) + if err != nil { + t.Fatalf("decode destination image: %v", err) + } + + bounds := resized.Bounds() + if bounds.Dx() != seoImageWidth || bounds.Dy() != seoImageHeight { + t.Fatalf("unexpected resized dimensions: got %dx%d", bounds.Dx(), bounds.Dy()) + } + + if prepared.Mime != "image/png" { + t.Fatalf("unexpected mime type: %s", prepared.Mime) + } + + if got := atomic.LoadInt32(&requests); got == 0 { + t.Fatalf("expected remote image to be requested at least once") + } + +} diff --git a/metal/cli/seo/images.go b/metal/cli/seo/images.go new file mode 100644 index 00000000..b72d8e7a --- /dev/null +++ b/metal/cli/seo/images.go @@ -0,0 +1,94 @@ +package seo + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/oullin/handler/payload" + pkgimages "github.com/oullin/pkg/images" + "github.com/oullin/pkg/portal" +) + +type preparedImage struct { + URL string + Mime string +} + +const ( + seoImageWidth = 1200 + seoImageHeight = 630 +) + +func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, error) { + source := strings.TrimSpace(post.CoverImageURL) + if source == "" { + return preparedImage{}, errors.New("post has no cover image url") + } + + spaImagesDir, err := g.spaImagesDir() + if err != nil { + return preparedImage{}, err + } + + img, format, err := pkgimages.Fetch(source) + if err != nil { + return preparedImage{}, err + } + + resized := pkgimages.Resize(img, seoImageWidth, seoImageHeight) + + ext := pkgimages.DetermineExtension(source, format) + fileName := pkgimages.BuildFileName(post.Slug, ext, "post-image") + + if err := os.MkdirAll(spaImagesDir, 0o755); err != nil { + return preparedImage{}, fmt.Errorf("create destination dir: %w", err) + } + + destPath := filepath.Join(spaImagesDir, fileName) + if err := pkgimages.Save(destPath, resized, ext, pkgimages.DefaultJPEGQuality); err != nil { + return preparedImage{}, fmt.Errorf("write resized image: %w", err) + } + + relativeDir, err := filepath.Rel(g.Page.OutputDir, spaImagesDir) + if err != nil { + return preparedImage{}, fmt.Errorf("determine relative image path: %w", err) + } + + relativeDir = filepath.ToSlash(relativeDir) + relativeDir = strings.Trim(relativeDir, "/") + + relative := path.Join(relativeDir, fileName) + relative = strings.TrimPrefix(relative, "/") + + return preparedImage{ + URL: g.siteURLFor(relative), + Mime: pkgimages.MIMEFromExtension(ext), + }, nil +} + +func (g *Generator) spaImagesDir() (string, error) { + if g.Env == nil { + return "", errors.New("generator environment not configured") + } + + if g.Env.Seo.SpaImagesDir == "" { + return "", errors.New("spa images directory is not configured") + } + + return g.Env.Seo.SpaImagesDir, nil +} + +func (g *Generator) siteURLFor(rel string) string { + base := strings.TrimSuffix(g.Page.SiteURL, "/") + rel = strings.TrimPrefix(rel, "/") + + if base == "" { + return rel + } + + return portal.SanitiseURL(base + "/" + rel) +} diff --git a/metal/cli/seo/images_test.go b/metal/cli/seo/images_test.go new file mode 100644 index 00000000..2ebf53cb --- /dev/null +++ b/metal/cli/seo/images_test.go @@ -0,0 +1,61 @@ +package seo + +import ( + "strings" + "testing" + + "github.com/oullin/handler/payload" +) + +func TestGeneratorPreparePostImageMissingURL(t *testing.T) { + gen := &Generator{Page: Page{}} + + _, err := gen.preparePostImage(payload.PostResponse{Slug: "test-post"}) + if err == nil { + t.Fatalf("expected error for missing cover image url") + } + + if !strings.Contains(err.Error(), "post has no cover image url") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestGeneratorSiteURLFor(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + base string + rel string + want string + }{ + "with base and leading slash": { + base: "https://example.test/", + rel: "/posts/images/pic.png", + want: "https://example.test/posts/images/pic.png", + }, + "without trailing slash": { + base: "https://example.test", + rel: "posts/images/pic.png", + want: "https://example.test/posts/images/pic.png", + }, + "no base url": { + base: "", + rel: "/posts/images/pic.png", + want: "posts/images/pic.png", + }, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + gen := &Generator{Page: Page{SiteURL: tc.base}} + got := gen.siteURLFor(tc.rel) + + if got != tc.want { + t.Fatalf("siteURLFor(%q, %q) = %q, want %q", tc.base, tc.rel, got, tc.want) + } + }) + } +} diff --git a/metal/cli/seo/testhelpers_test.go b/metal/cli/seo/testhelpers_test.go index 6baf8846..7f488688 100644 --- a/metal/cli/seo/testhelpers_test.go +++ b/metal/cli/seo/testhelpers_test.go @@ -97,7 +97,8 @@ func newPostgresConnection(t *testing.T, models ...interface{}) (*database.Conne Password: strings.Repeat("s", 16), }, Seo: env.SeoEnvironment{ - SpaDir: spaDir, + SpaDir: spaDir, + SpaImagesDir: filepath.Join(spaDir, "posts", "images"), }, } diff --git a/metal/env/seo.go b/metal/env/seo.go index c8b0368e..c408e305 100644 --- a/metal/env/seo.go +++ b/metal/env/seo.go @@ -1,5 +1,6 @@ package env type SeoEnvironment struct { - SpaDir string `validate:"required"` + SpaDir string `validate:"required"` + SpaImagesDir string `validate:"required"` } diff --git a/metal/kernel/factory.go b/metal/kernel/factory.go index 7c57f30c..438af707 100644 --- a/metal/kernel/factory.go +++ b/metal/kernel/factory.go @@ -113,7 +113,8 @@ func MakeEnv(validate *portal.Validator) *env.Environment { } seoEnv := env.SeoEnvironment{ - SpaDir: env.GetEnvVar("ENV_SPA_DIR"), + SpaDir: env.GetEnvVar("ENV_SPA_DIR"), + SpaImagesDir: env.GetEnvVar("ENV_SPA_IMAGES_DIR"), } if _, err := validate.Rejects(app); err != nil { diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index 7d069403..c87d3443 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -45,6 +45,7 @@ func validEnvVars(t *testing.T) { t.Setenv("ENV_PING_PASSWORD", "abcdef1234567890") t.Setenv("ENV_APP_URL", "http://localhost:8080") t.Setenv("ENV_SPA_DIR", "/Users/gus/Sites/oullin/web/public/seo") + t.Setenv("ENV_SPA_IMAGES_DIR", "/Users/gus/Sites/oullin/web/public/seo/posts/images") } func TestMakeEnv(t *testing.T) { @@ -96,6 +97,7 @@ func TestIgnite(t *testing.T) { "ENV_SENTRY_DSN=dsn\n" + "ENV_SENTRY_CSP=csp\n" + "ENV_SPA_DIR=/tmp\n" + + "ENV_SPA_IMAGES_DIR=/tmp/posts/images\n" + "ENV_PING_USERNAME=1234567890abcdef\n" + "ENV_PING_PASSWORD=abcdef1234567890\n" diff --git a/pkg/images/image.go b/pkg/images/image.go new file mode 100644 index 00000000..a9b4211d --- /dev/null +++ b/pkg/images/image.go @@ -0,0 +1,183 @@ +package images + +import ( + "fmt" + stdimage "image" + _ "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/image/draw" +) + +const DefaultJPEGQuality = 85 + +func Fetch(source string) (stdimage.Image, string, error) { + parsed, err := url.Parse(source) + if err != nil { + return nil, "", fmt.Errorf("parse url: %w", err) + } + + reader, err := openSource(parsed) + if err != nil { + return nil, "", err + } + defer reader.Close() + + img, format, err := stdimage.Decode(reader) + if err != nil { + return nil, "", fmt.Errorf("decode image: %w", err) + } + + return img, format, nil +} + +func Resize(src stdimage.Image, width, height int) stdimage.Image { + dst := stdimage.NewRGBA(stdimage.Rect(0, 0, width, height)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + + return dst +} + +func DetermineExtension(source, format string) string { + ext := strings.ToLower(filepath.Ext(source)) + if ext == "" { + ext = "." + strings.ToLower(format) + } + + switch ext { + case ".jpeg": + return ".jpg" + case ".jpg", ".png": + return ext + default: + if format == "png" { + return ".png" + } + + return ".jpg" + } +} + +func BuildFileName(slug, ext, fallback string) string { + trimmed := strings.TrimSpace(slug) + cleaned := strings.Trim(trimmed, "/") + if cleaned == "" { + cleaned = fallback + } + + cleaned = strings.ReplaceAll(cleaned, " ", "-") + + return cleaned + ext +} + +func Save(path string, img stdimage.Image, ext string, quality int) error { + fh, err := os.Create(path) + if err != nil { + return err + } + defer fh.Close() + + switch ext { + case ".png": + encoder := &png.Encoder{CompressionLevel: png.DefaultCompression} + return encoder.Encode(fh, img) + default: + options := &jpeg.Options{Quality: quality} + return jpeg.Encode(fh, img, options) + } +} + +func Move(src, dst string) error { + if err := os.RemoveAll(dst); err != nil { + return err + } + + if err := os.Rename(src, dst); err == nil { + return nil + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer func() { + _ = out.Close() + }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + if err := out.Sync(); err != nil { + return err + } + + return os.Remove(src) +} + +func MIMEFromExtension(ext string) string { + switch strings.ToLower(ext) { + case ".png": + return "image/png" + case ".jpg": + return "image/jpeg" + default: + return "image/png" + } +} + +func openSource(parsed *url.URL) (io.ReadCloser, error) { + switch parsed.Scheme { + case "http", "https": + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(parsed.String()) + if err != nil { + return nil, fmt.Errorf("download image: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + return nil, fmt.Errorf("download image: unexpected status %s", resp.Status) + } + + return resp.Body, nil + case "file": + return openLocal(parsed) + case "": + return os.Open(parsed.Path) + default: + return nil, fmt.Errorf("unsupported image scheme: %s", parsed.Scheme) + } +} + +func openLocal(parsed *url.URL) (io.ReadCloser, error) { + pathValue := parsed.Path + if pathValue == "" { + pathValue = parsed.Opaque + } + + if parsed.Host != "" { + pathValue = "//" + parsed.Host + pathValue + } + + unescaped, err := url.PathUnescape(pathValue) + if err != nil { + return nil, fmt.Errorf("decode file path: %w", err) + } + + return os.Open(unescaped) +} diff --git a/pkg/images/image_test.go b/pkg/images/image_test.go new file mode 100644 index 00000000..3446baf9 --- /dev/null +++ b/pkg/images/image_test.go @@ -0,0 +1,260 @@ +package images + +import ( + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + _ "image/jpeg" +) + +func createTestImage(width, height int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 200, A: 255}) + } + } + + return img +} + +func writePNG(t *testing.T, path string, img image.Image) { + t.Helper() + + fh, err := os.Create(path) + if err != nil { + t.Fatalf("create image: %v", err) + } + defer fh.Close() + + if err := png.Encode(fh, img); err != nil { + t.Fatalf("encode png: %v", err) + } +} + +func TestFetchLocal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := filepath.Join(dir, "local.png") + writePNG(t, src, createTestImage(100, 80)) + + fileURL := url.URL{Scheme: "file", Path: src} + + img, format, err := Fetch(fileURL.String()) + if err != nil { + t.Fatalf("fetch local image: %v", err) + } + + if format != "png" { + t.Fatalf("expected png format, got %q", format) + } + + bounds := img.Bounds() + if bounds.Dx() != 100 || bounds.Dy() != 80 { + t.Fatalf("unexpected dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestFetchRemote(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cover.png" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + + if err := png.Encode(w, createTestImage(50, 40)); err != nil { + t.Fatalf("encode remote png: %v", err) + } + })) + defer server.Close() + + img, format, err := Fetch(server.URL + "/cover.png") + if err != nil { + t.Fatalf("fetch remote image: %v", err) + } + + if format != "png" { + t.Fatalf("expected png format, got %q", format) + } + + bounds := img.Bounds() + if bounds.Dx() != 50 || bounds.Dy() != 40 { + t.Fatalf("unexpected dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestResize(t *testing.T) { + t.Parallel() + + src := createTestImage(20, 10) + resized := Resize(src, 200, 100) + + bounds := resized.Bounds() + if bounds.Dx() != 200 || bounds.Dy() != 100 { + t.Fatalf("unexpected dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestDetermineExtension(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + format string + want string + }{ + {"explicit png", "example.png", "jpeg", ".png"}, + {"explicit jpg", "example.jpg", "png", ".jpg"}, + {"explicit jpeg", "example.jpeg", "png", ".jpg"}, + {"missing ext png format", "example", "png", ".png"}, + {"missing ext jpeg format", "example", "jpeg", ".jpg"}, + {"unknown ext falls back to jpg", "example.gif", "jpeg", ".jpg"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := DetermineExtension(tc.source, tc.format); got != tc.want { + t.Fatalf("DetermineExtension(%q, %q) = %q, want %q", tc.source, tc.format, got, tc.want) + } + }) + } +} + +func TestBuildFileName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + slug string + ext string + fallback string + want string + }{ + {"uses slug", "my-post", ".png", "fallback", "my-post.png"}, + {"trims whitespace", " my post ", ".jpg", "fallback", "my-post.jpg"}, + {"removes leading slash", "/post/slug/", ".png", "fallback", "post/slug.png"}, + {"uses fallback", " ", ".jpg", "fallback", "fallback.jpg"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := BuildFileName(tc.slug, tc.ext, tc.fallback); got != tc.want { + t.Fatalf("BuildFileName(%q, %q, %q) = %q, want %q", tc.slug, tc.ext, tc.fallback, got, tc.want) + } + }) + } +} + +func TestMIMEFromExtension(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + ".png": "image/png", + ".PNG": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/png", + ".gif": "image/png", + } + + for ext, want := range tests { + ext := ext + want := want + t.Run(ext, func(t *testing.T) { + t.Parallel() + + if got := MIMEFromExtension(ext); got != want { + t.Fatalf("MIMEFromExtension(%q) = %q, want %q", ext, got, want) + } + }) + } +} + +func TestSaveAndMove(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := filepath.Join(dir, "source.png") + dst := filepath.Join(dir, "dest.png") + + // Create an existing destination to ensure Move removes it first. + writePNG(t, dst, createTestImage(10, 10)) + + if err := Save(src, createTestImage(60, 30), ".png", DefaultJPEGQuality); err != nil { + t.Fatalf("save png: %v", err) + } + + if err := Move(src, dst); err != nil { + t.Fatalf("move file: %v", err) + } + + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("expected source to be removed, got %v", err) + } + + fh, err := os.Open(dst) + if err != nil { + t.Fatalf("open dest: %v", err) + } + defer fh.Close() + + img, format, err := image.Decode(fh) + if err != nil { + t.Fatalf("decode moved image: %v", err) + } + + if format != "png" { + t.Fatalf("expected png format, got %q", format) + } + + bounds := img.Bounds() + if bounds.Dx() != 60 || bounds.Dy() != 30 { + t.Fatalf("unexpected dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestSaveJPEG(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "photo.jpg") + + if err := Save(path, createTestImage(25, 25), ".jpg", 70); err != nil { + t.Fatalf("save jpg: %v", err) + } + + fh, err := os.Open(path) + if err != nil { + t.Fatalf("open jpeg: %v", err) + } + defer fh.Close() + + img, format, err := image.Decode(fh) + if err != nil { + t.Fatalf("decode jpeg: %v", err) + } + + if format != "jpeg" { + t.Fatalf("expected jpeg format, got %q", format) + } + + bounds := img.Bounds() + if bounds.Dx() != 25 || bounds.Dy() != 25 { + t.Fatalf("unexpected dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } +}