From e07406097076f16deaca23a7adc013afed60655e Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:00:45 +0800 Subject: [PATCH 1/9] Add local SEO image processing for posts --- go.mod | 1 + go.sum | 2 + metal/cli/seo/data.go | 2 +- metal/cli/seo/defaults.go | 1 + metal/cli/seo/generator.go | 15 +- metal/cli/seo/generator_test.go | 90 +++++++++++ metal/cli/seo/images.go | 266 ++++++++++++++++++++++++++++++++ 7 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 metal/cli/seo/images.go 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/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..63c23b04 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -5,6 +5,7 @@ import ( "embed" "fmt" "html/template" + "log/slog" "net/url" "os" "path/filepath" @@ -439,9 +440,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 { + slog.Warn("failed to prepare post image", "slug", post.Slug, "err", 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 +465,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..23f0eaf9 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -3,7 +3,12 @@ package seo import ( "encoding/json" "html/template" + "image" + "image/color" + "image/png" + "net/url" "os" + "path" "path/filepath" "strings" "testing" @@ -11,6 +16,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/oullin/database" + "github.com/oullin/handler/payload" "github.com/oullin/pkg/portal" ) @@ -204,3 +210,87 @@ 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} + + gen := &Generator{ + Page: Page{ + SiteName: "SEO Test Suite", + SiteURL: "https://seo.example.test", + OutputDir: outputDir, + }, + } + + 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(outputDir, "posts", "images", "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) + } +} diff --git a/metal/cli/seo/images.go b/metal/cli/seo/images.go new file mode 100644 index 00000000..3498cf44 --- /dev/null +++ b/metal/cli/seo/images.go @@ -0,0 +1,266 @@ +package seo + +import ( + "errors" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/oullin/handler/payload" + "github.com/oullin/pkg/portal" + "golang.org/x/image/draw" +) + +type preparedImage struct { + URL string + Mime string +} + +const ( + seoStorageDir = "storage/seo" + postImagesDir = "posts" + postImagesFolder = "images" + seoImageWidth = 1200 + seoImageHeight = 630 + defaultImageQuality = 85 +) + +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") + } + + img, format, err := fetchImage(source) + if err != nil { + return preparedImage{}, err + } + + resized := resizeImage(img) + + ext := determineExtension(source, format) + fileName := buildImageFileName(post.Slug, ext) + + if err := os.MkdirAll(seoStorageDir, 0o755); err != nil { + return preparedImage{}, fmt.Errorf("create storage dir: %w", err) + } + + tempPath := filepath.Join(seoStorageDir, fileName) + if err := saveImage(tempPath, resized, ext); err != nil { + return preparedImage{}, fmt.Errorf("write resized image: %w", err) + } + + defer func() { + _ = os.Remove(tempPath) + }() + + destDir := filepath.Join(g.Page.OutputDir, postImagesDir, postImagesFolder) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return preparedImage{}, fmt.Errorf("create destination dir: %w", err) + } + + destPath := filepath.Join(destDir, fileName) + if err := moveFile(tempPath, destPath); err != nil { + return preparedImage{}, fmt.Errorf("move resized image: %w", err) + } + + relative := path.Join(postImagesDir, postImagesFolder, fileName) + + return preparedImage{ + URL: g.siteURLFor(relative), + Mime: mimeFromExtension(ext), + }, nil +} + +func fetchImage(source string) (image.Image, string, error) { + parsed, err := url.Parse(source) + if err != nil { + return nil, "", fmt.Errorf("parse url: %w", err) + } + + reader, err := openImageSource(parsed) + if err != nil { + return nil, "", err + } + defer reader.Close() + + img, format, err := image.Decode(reader) + if err != nil { + return nil, "", fmt.Errorf("decode image: %w", err) + } + + return img, format, nil +} + +func openImageSource(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 openLocalFile(parsed) + case "": + // Treat empty scheme as local file path + return os.Open(parsed.Path) + default: + return nil, fmt.Errorf("unsupported image scheme: %s", parsed.Scheme) + } +} + +func openLocalFile(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) +} + +func resizeImage(img image.Image) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, seoImageWidth, seoImageHeight)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.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 buildImageFileName(slug, ext string) string { + trimmed := strings.TrimSpace(slug) + cleaned := strings.Trim(trimmed, "/") + if cleaned == "" { + cleaned = "post-image" + } + + cleaned = strings.ReplaceAll(cleaned, " ", "-") + + return cleaned + ext +} + +func saveImage(path string, img image.Image, ext string) error { + fh, err := os.Create(path) + if err != nil { + return err + } + defer fh.Close() + + switch ext { + case ".png": + return pngEncode(fh, img) + default: + return jpegEncode(fh, img) + } +} + +func pngEncode(w io.Writer, img image.Image) error { + encoder := &png.Encoder{CompressionLevel: png.DefaultCompression} + return encoder.Encode(w, img) +} + +func jpegEncode(w io.Writer, img image.Image) error { + options := &jpeg.Options{Quality: defaultImageQuality} + return jpeg.Encode(w, img, options) +} + +func moveFile(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 (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) +} + +func mimeFromExtension(ext string) string { + switch strings.ToLower(ext) { + case ".png": + return "image/png" + case ".jpg": + return "image/jpeg" + default: + return "image/png" + } +} From 7a933d446ebda613b406816660f4e1f8800f6a08 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:15:33 +0800 Subject: [PATCH 2/9] Extract SEO image utilities into shared package --- metal/cli/seo/images.go | 207 +++------------------------------------- pkg/image/image.go | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 194 deletions(-) create mode 100644 pkg/image/image.go diff --git a/metal/cli/seo/images.go b/metal/cli/seo/images.go index 3498cf44..09fdf90d 100644 --- a/metal/cli/seo/images.go +++ b/metal/cli/seo/images.go @@ -3,22 +3,14 @@ package seo import ( "errors" "fmt" - "image" - _ "image/gif" - "image/jpeg" - "image/png" - "io" - "net/http" - "net/url" "os" "path" "path/filepath" "strings" - "time" "github.com/oullin/handler/payload" + pkgimage "github.com/oullin/pkg/image" "github.com/oullin/pkg/portal" - "golang.org/x/image/draw" ) type preparedImage struct { @@ -27,12 +19,11 @@ type preparedImage struct { } const ( - seoStorageDir = "storage/seo" - postImagesDir = "posts" - postImagesFolder = "images" - seoImageWidth = 1200 - seoImageHeight = 630 - defaultImageQuality = 85 + seoStorageDir = "storage/seo" + postImagesDir = "posts" + postImagesFolder = "images" + seoImageWidth = 1200 + seoImageHeight = 630 ) func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, error) { @@ -41,22 +32,22 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, return preparedImage{}, errors.New("post has no cover image url") } - img, format, err := fetchImage(source) + img, format, err := pkgimage.Fetch(source) if err != nil { return preparedImage{}, err } - resized := resizeImage(img) + resized := pkgimage.Resize(img, seoImageWidth, seoImageHeight) - ext := determineExtension(source, format) - fileName := buildImageFileName(post.Slug, ext) + ext := pkgimage.DetermineExtension(source, format) + fileName := pkgimage.BuildFileName(post.Slug, ext, "post-image") if err := os.MkdirAll(seoStorageDir, 0o755); err != nil { return preparedImage{}, fmt.Errorf("create storage dir: %w", err) } tempPath := filepath.Join(seoStorageDir, fileName) - if err := saveImage(tempPath, resized, ext); err != nil { + if err := pkgimage.Save(tempPath, resized, ext, pkgimage.DefaultJPEGQuality); err != nil { return preparedImage{}, fmt.Errorf("write resized image: %w", err) } @@ -70,7 +61,7 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, } destPath := filepath.Join(destDir, fileName) - if err := moveFile(tempPath, destPath); err != nil { + if err := pkgimage.Move(tempPath, destPath); err != nil { return preparedImage{}, fmt.Errorf("move resized image: %w", err) } @@ -78,171 +69,10 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, return preparedImage{ URL: g.siteURLFor(relative), - Mime: mimeFromExtension(ext), + Mime: pkgimage.MIMEFromExtension(ext), }, nil } -func fetchImage(source string) (image.Image, string, error) { - parsed, err := url.Parse(source) - if err != nil { - return nil, "", fmt.Errorf("parse url: %w", err) - } - - reader, err := openImageSource(parsed) - if err != nil { - return nil, "", err - } - defer reader.Close() - - img, format, err := image.Decode(reader) - if err != nil { - return nil, "", fmt.Errorf("decode image: %w", err) - } - - return img, format, nil -} - -func openImageSource(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 openLocalFile(parsed) - case "": - // Treat empty scheme as local file path - return os.Open(parsed.Path) - default: - return nil, fmt.Errorf("unsupported image scheme: %s", parsed.Scheme) - } -} - -func openLocalFile(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) -} - -func resizeImage(img image.Image) image.Image { - dst := image.NewRGBA(image.Rect(0, 0, seoImageWidth, seoImageHeight)) - draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.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 buildImageFileName(slug, ext string) string { - trimmed := strings.TrimSpace(slug) - cleaned := strings.Trim(trimmed, "/") - if cleaned == "" { - cleaned = "post-image" - } - - cleaned = strings.ReplaceAll(cleaned, " ", "-") - - return cleaned + ext -} - -func saveImage(path string, img image.Image, ext string) error { - fh, err := os.Create(path) - if err != nil { - return err - } - defer fh.Close() - - switch ext { - case ".png": - return pngEncode(fh, img) - default: - return jpegEncode(fh, img) - } -} - -func pngEncode(w io.Writer, img image.Image) error { - encoder := &png.Encoder{CompressionLevel: png.DefaultCompression} - return encoder.Encode(w, img) -} - -func jpegEncode(w io.Writer, img image.Image) error { - options := &jpeg.Options{Quality: defaultImageQuality} - return jpeg.Encode(w, img, options) -} - -func moveFile(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 (g *Generator) siteURLFor(rel string) string { base := strings.TrimSuffix(g.Page.SiteURL, "/") rel = strings.TrimPrefix(rel, "/") @@ -253,14 +83,3 @@ func (g *Generator) siteURLFor(rel string) string { return portal.SanitiseURL(base + "/" + rel) } - -func mimeFromExtension(ext string) string { - switch strings.ToLower(ext) { - case ".png": - return "image/png" - case ".jpg": - return "image/jpeg" - default: - return "image/png" - } -} diff --git a/pkg/image/image.go b/pkg/image/image.go new file mode 100644 index 00000000..06f53e93 --- /dev/null +++ b/pkg/image/image.go @@ -0,0 +1,183 @@ +package image + +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) +} From c49a2a71c18dbd5058eb15f1f1e1a5f69af0ab86 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:22:02 +0800 Subject: [PATCH 3/9] test: cover seo image fetch over http --- metal/cli/seo/generator_test.go | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 23f0eaf9..e21d98c4 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -6,11 +6,14 @@ import ( "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" @@ -294,3 +297,84 @@ func TestGeneratorPreparePostImage(t *testing.T) { 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() + + gen := &Generator{ + Page: Page{ + SiteName: "SEO Test Suite", + SiteURL: "https://seo.example.test", + OutputDir: outputDir, + }, + } + + 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(outputDir, "posts", "images", "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") + } +} From d96a8d7af5390004b9bc8615ca452b1e25fc9eac Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:28:22 +0800 Subject: [PATCH 4/9] Rename SEO image helpers package to images --- metal/cli/seo/images.go | 16 ++++++++-------- pkg/{image => images}/image.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) rename pkg/{image => images}/image.go (99%) diff --git a/metal/cli/seo/images.go b/metal/cli/seo/images.go index 09fdf90d..c6880e35 100644 --- a/metal/cli/seo/images.go +++ b/metal/cli/seo/images.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/oullin/handler/payload" - pkgimage "github.com/oullin/pkg/image" + pkgimages "github.com/oullin/pkg/images" "github.com/oullin/pkg/portal" ) @@ -32,22 +32,22 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, return preparedImage{}, errors.New("post has no cover image url") } - img, format, err := pkgimage.Fetch(source) + img, format, err := pkgimages.Fetch(source) if err != nil { return preparedImage{}, err } - resized := pkgimage.Resize(img, seoImageWidth, seoImageHeight) + resized := pkgimages.Resize(img, seoImageWidth, seoImageHeight) - ext := pkgimage.DetermineExtension(source, format) - fileName := pkgimage.BuildFileName(post.Slug, ext, "post-image") + ext := pkgimages.DetermineExtension(source, format) + fileName := pkgimages.BuildFileName(post.Slug, ext, "post-image") if err := os.MkdirAll(seoStorageDir, 0o755); err != nil { return preparedImage{}, fmt.Errorf("create storage dir: %w", err) } tempPath := filepath.Join(seoStorageDir, fileName) - if err := pkgimage.Save(tempPath, resized, ext, pkgimage.DefaultJPEGQuality); err != nil { + if err := pkgimages.Save(tempPath, resized, ext, pkgimages.DefaultJPEGQuality); err != nil { return preparedImage{}, fmt.Errorf("write resized image: %w", err) } @@ -61,7 +61,7 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, } destPath := filepath.Join(destDir, fileName) - if err := pkgimage.Move(tempPath, destPath); err != nil { + if err := pkgimages.Move(tempPath, destPath); err != nil { return preparedImage{}, fmt.Errorf("move resized image: %w", err) } @@ -69,7 +69,7 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, return preparedImage{ URL: g.siteURLFor(relative), - Mime: pkgimage.MIMEFromExtension(ext), + Mime: pkgimages.MIMEFromExtension(ext), }, nil } diff --git a/pkg/image/image.go b/pkg/images/image.go similarity index 99% rename from pkg/image/image.go rename to pkg/images/image.go index 06f53e93..a9b4211d 100644 --- a/pkg/image/image.go +++ b/pkg/images/image.go @@ -1,4 +1,4 @@ -package image +package images import ( "fmt" From 9de7d1354d9423ad24fff5e1929608eed4a464e3 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:36:21 +0800 Subject: [PATCH 5/9] Ensure SEO image tests verify storage cleanup --- metal/cli/seo/generator_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index e21d98c4..cbd72156 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -296,6 +296,19 @@ func TestGeneratorPreparePostImage(t *testing.T) { if prepared.Mime != "image/png" { t.Fatalf("unexpected mime type: %s", prepared.Mime) } + + entries, err := os.ReadDir(seoStorageDir) + if err != nil { + t.Fatalf("read storage dir: %v", err) + } + + for _, entry := range entries { + if entry.Name() == ".gitkeep" { + continue + } + + t.Fatalf("unexpected leftover file in storage: %s", entry.Name()) + } } func TestGeneratorPreparePostImageRemote(t *testing.T) { @@ -377,4 +390,17 @@ func TestGeneratorPreparePostImageRemote(t *testing.T) { if got := atomic.LoadInt32(&requests); got == 0 { t.Fatalf("expected remote image to be requested at least once") } + + entries, err := os.ReadDir(seoStorageDir) + if err != nil { + t.Fatalf("read storage dir: %v", err) + } + + for _, entry := range entries { + if entry.Name() == ".gitkeep" { + continue + } + + t.Fatalf("unexpected leftover file in storage: %s", entry.Name()) + } } From 28fe52f6df28aa638a353b097a8aaf10641ac5d9 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 16:45:15 +0800 Subject: [PATCH 6/9] Add coverage for SEO image helpers --- metal/cli/seo/images_test.go | 61 ++++++++ pkg/images/image_test.go | 260 +++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 metal/cli/seo/images_test.go create mode 100644 pkg/images/image_test.go 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/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()) + } +} From 80d6b3c5123c60944ed4814e67eb98f93043a0dc Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 17:06:32 +0800 Subject: [PATCH 7/9] Store SEO images directly in SPA output --- metal/cli/seo/client_test.go | 6 +++- metal/cli/seo/generator_test.go | 35 ++++++--------------- metal/cli/seo/images.go | 51 ++++++++++++++++++------------- metal/cli/seo/testhelpers_test.go | 3 +- metal/env/seo.go | 3 +- metal/kernel/factory.go | 3 +- metal/kernel/kernel_test.go | 2 ++ 7 files changed, 52 insertions(+), 51 deletions(-) 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/generator_test.go b/metal/cli/seo/generator_test.go index cbd72156..cf65762c 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -20,6 +20,7 @@ import ( "github.com/oullin/database" "github.com/oullin/handler/payload" + "github.com/oullin/metal/env" "github.com/oullin/pkg/portal" ) @@ -243,12 +244,15 @@ func TestGeneratorPreparePostImage(t *testing.T) { 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()} @@ -267,7 +271,7 @@ func TestGeneratorPreparePostImage(t *testing.T) { t.Fatalf("unexpected image url: %s", prepared.URL) } - destPath := filepath.Join(outputDir, "posts", "images", "awesome-post.png") + destPath := filepath.Join(imagesDir, "awesome-post.png") info, err := os.Stat(destPath) if err != nil { t.Fatalf("stat destination image: %v", err) @@ -297,18 +301,6 @@ func TestGeneratorPreparePostImage(t *testing.T) { t.Fatalf("unexpected mime type: %s", prepared.Mime) } - entries, err := os.ReadDir(seoStorageDir) - if err != nil { - t.Fatalf("read storage dir: %v", err) - } - - for _, entry := range entries { - if entry.Name() == ".gitkeep" { - continue - } - - t.Fatalf("unexpected leftover file in storage: %s", entry.Name()) - } } func TestGeneratorPreparePostImageRemote(t *testing.T) { @@ -333,12 +325,15 @@ func TestGeneratorPreparePostImageRemote(t *testing.T) { })) 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"} @@ -357,7 +352,7 @@ func TestGeneratorPreparePostImageRemote(t *testing.T) { t.Fatalf("unexpected image url: %s", prepared.URL) } - destPath := filepath.Join(outputDir, "posts", "images", "remote-post.png") + destPath := filepath.Join(imagesDir, "remote-post.png") info, err := os.Stat(destPath) if err != nil { t.Fatalf("stat destination image: %v", err) @@ -391,16 +386,4 @@ func TestGeneratorPreparePostImageRemote(t *testing.T) { t.Fatalf("expected remote image to be requested at least once") } - entries, err := os.ReadDir(seoStorageDir) - if err != nil { - t.Fatalf("read storage dir: %v", err) - } - - for _, entry := range entries { - if entry.Name() == ".gitkeep" { - continue - } - - t.Fatalf("unexpected leftover file in storage: %s", entry.Name()) - } } diff --git a/metal/cli/seo/images.go b/metal/cli/seo/images.go index c6880e35..b72d8e7a 100644 --- a/metal/cli/seo/images.go +++ b/metal/cli/seo/images.go @@ -19,11 +19,8 @@ type preparedImage struct { } const ( - seoStorageDir = "storage/seo" - postImagesDir = "posts" - postImagesFolder = "images" - seoImageWidth = 1200 - seoImageHeight = 630 + seoImageWidth = 1200 + seoImageHeight = 630 ) func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, error) { @@ -32,6 +29,11 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, 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 @@ -42,30 +44,25 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, ext := pkgimages.DetermineExtension(source, format) fileName := pkgimages.BuildFileName(post.Slug, ext, "post-image") - if err := os.MkdirAll(seoStorageDir, 0o755); err != nil { - return preparedImage{}, fmt.Errorf("create storage dir: %w", err) + if err := os.MkdirAll(spaImagesDir, 0o755); err != nil { + return preparedImage{}, fmt.Errorf("create destination dir: %w", err) } - tempPath := filepath.Join(seoStorageDir, fileName) - if err := pkgimages.Save(tempPath, resized, ext, pkgimages.DefaultJPEGQuality); err != nil { + 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) } - defer func() { - _ = os.Remove(tempPath) - }() - - destDir := filepath.Join(g.Page.OutputDir, postImagesDir, postImagesFolder) - if err := os.MkdirAll(destDir, 0o755); err != nil { - return preparedImage{}, fmt.Errorf("create destination dir: %w", err) + relativeDir, err := filepath.Rel(g.Page.OutputDir, spaImagesDir) + if err != nil { + return preparedImage{}, fmt.Errorf("determine relative image path: %w", err) } - destPath := filepath.Join(destDir, fileName) - if err := pkgimages.Move(tempPath, destPath); err != nil { - return preparedImage{}, fmt.Errorf("move resized image: %w", err) - } + relativeDir = filepath.ToSlash(relativeDir) + relativeDir = strings.Trim(relativeDir, "/") - relative := path.Join(postImagesDir, postImagesFolder, fileName) + relative := path.Join(relativeDir, fileName) + relative = strings.TrimPrefix(relative, "/") return preparedImage{ URL: g.siteURLFor(relative), @@ -73,6 +70,18 @@ func (g *Generator) preparePostImage(post payload.PostResponse) (preparedImage, }, 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, "/") 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" From 0a8d5323278cff4650728bf8a7e732f6598aabd7 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 2 Oct 2025 17:26:12 +0800 Subject: [PATCH 8/9] Use CLI error output for post image failures --- metal/cli/seo/generator.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 63c23b04..273f528f 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -5,7 +5,6 @@ import ( "embed" "fmt" "html/template" - "log/slog" "net/url" "os" "path/filepath" @@ -446,7 +445,7 @@ func (g *Generator) BuildForPost(post payload.PostResponse, body []template.HTML image = prepared.URL imageType = prepared.Mime } else if err != nil { - slog.Warn("failed to prepare post image", "slug", post.Slug, "err", err) + 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) { From 8ea6f199f5b1cab451e72b317a0b6530118fdc2c Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 3 Oct 2025 12:55:36 +0800 Subject: [PATCH 9/9] Remove stale SEO exports before regenerating --- metal/cli/seo/generator.go | 5 +++++ metal/cli/seo/generator_test.go | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 273f528f..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) diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index cf65762c..fc73b3dd 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -78,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)