From f9978ed16476ca6d233a89669c62c798cdf9db9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sun, 18 Aug 2019 11:21:27 +0200 Subject: [PATCH] Image resource refactor This commit pulls most of the image related logic into its own package, to make it easier to reason about and extend. This is also a rewrite of the transformation logic used in Hugo Pipes, mostly to allow constructs like the one below: {{ ($myimg | fingerprint ).Width }} Fixes #5903 Fixes #6234 Fixes #6266 --- common/herrors/errors.go | 1 + htesting/test_helpers.go | 19 + hugolib/assets/images/sunset.jpg | Bin 0 -> 90587 bytes hugolib/pagebundler_test.go | 10 +- hugolib/resource_chain_test.go | 67 +- hugolib/testhelpers_test.go | 1 + resources/image.go | 514 ++-------- resources/image_cache.go | 63 +- resources/image_test.go | 191 ++-- resources/images/config.go | 276 ++++++ resources/images/config_test.go | 125 +++ resources/images/image.go | 170 ++++ resources/{ => images}/smartcrop.go | 16 +- resources/internal/key.go | 61 ++ resources/internal/key_test.go | 36 + resources/resource.go | 937 ++++++++---------- resources/resource/resourcetypes.go | 20 +- resources/resource_cache.go | 2 +- resources/resource_metadata.go | 20 +- resources/resource_metadata_test.go | 2 +- resources/resource_spec.go | 304 ++++++ resources/resource_test.go | 25 +- .../htesting/testhelpers.go | 80 ++ .../integrity/integrity.go | 25 +- .../integrity/integrity_test.go | 24 + .../resource_transformers/minifier/minify.go | 18 +- .../minifier/minify_test.go | 43 + .../resource_transformers/postcss/postcss.go | 12 +- .../templates/execute_as_template.go | 22 +- .../tocss/scss/client.go | 12 +- resources/testhelpers_test.go | 44 +- resources/transform.go | 640 ++++++------ resources/transform_test.go | 428 +++++++- tpl/resources/resources.go | 22 +- 34 files changed, 2674 insertions(+), 1556 deletions(-) create mode 100644 hugolib/assets/images/sunset.jpg create mode 100644 resources/images/config.go create mode 100644 resources/images/config_test.go create mode 100644 resources/images/image.go rename resources/{ => images}/smartcrop.go (96%) create mode 100644 resources/internal/key.go create mode 100644 resources/internal/key_test.go create mode 100644 resources/resource_spec.go create mode 100644 resources/resource_transformers/htesting/testhelpers.go create mode 100644 resources/resource_transformers/minifier/minify_test.go diff --git a/common/herrors/errors.go b/common/herrors/errors.go index e484ecb80..ff8eab116 100644 --- a/common/herrors/errors.go +++ b/common/herrors/errors.go @@ -52,6 +52,7 @@ func FprintStackTrace(w io.Writer, err error) { // defer herrors.Recover() func Recover(args ...interface{}) { if r := recover(); r != nil { + fmt.Println("ERR:", r) args = append(args, "stacktrace from panic: \n"+string(debug.Stack()), "\n") fmt.Println(args...) } diff --git a/htesting/test_helpers.go b/htesting/test_helpers.go index dc303b2e5..660c76a44 100644 --- a/htesting/test_helpers.go +++ b/htesting/test_helpers.go @@ -14,8 +14,10 @@ package htesting import ( + "math/rand" "runtime" "strings" + "time" "github.com/spf13/afero" ) @@ -37,3 +39,20 @@ func CreateTempDir(fs afero.Fs, prefix string) (string, func(), error) { } return tempDir, func() { fs.RemoveAll(tempDir) }, nil } + +// BailOut panics with a stack trace after the given duration. Useful for +// hanging tests. +func BailOut(after time.Duration) { + time.AfterFunc(after, func() { + buf := make([]byte, 1<<16) + runtime.Stack(buf, true) + panic(string(buf)) + }) + +} + +var rnd = rand.New(rand.NewSource(time.Now().UnixNano())) + +func RandIntn(n int) int { + return rnd.Intn(n) +} diff --git a/hugolib/assets/images/sunset.jpg b/hugolib/assets/images/sunset.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7d7307bed36efb65bf0443b62ebec6be6d7d3420 GIT binary patch literal 90587 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 V6Yqu8|FQfEE;s&{==tyK{{g?L5tsk~ literal 0 HcmV?d00001 diff --git a/hugolib/pagebundler_test.go b/hugolib/pagebundler_test.go index 4b3eef512..1f7addb28 100644 --- a/hugolib/pagebundler_test.go +++ b/hugolib/pagebundler_test.go @@ -42,8 +42,7 @@ import ( ) func TestPageBundlerSiteRegular(t *testing.T) { - t.Parallel() - + c := qt.New(t) baseBaseURL := "https://example.com" for _, baseURLPath := range []string{"", "/hugo"} { @@ -55,15 +54,14 @@ func TestPageBundlerSiteRegular(t *testing.T) { } ugly := ugly canonify := canonify - t.Run(fmt.Sprintf("ugly=%t,canonify=%t,path=%s", ugly, canonify, baseURLPathId), - func(t *testing.T) { - t.Parallel() + c.Run(fmt.Sprintf("ugly=%t,canonify=%t,path=%s", ugly, canonify, baseURLPathId), + func(c *qt.C) { + c.Parallel() baseURL := baseBaseURL + baseURLPath relURLBase := baseURLPath if canonify { relURLBase = "" } - c := qt.New(t) fs, cfg := newTestBundleSources(t) cfg.Set("baseURL", baseURL) cfg.Set("canonifyURLs", canonify) diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go index 84c871e4d..2b32587eb 100644 --- a/hugolib/resource_chain_test.go +++ b/hugolib/resource_chain_test.go @@ -14,6 +14,7 @@ package hugolib import ( + "io" "os" "path/filepath" "testing" @@ -167,6 +168,64 @@ T1: {{ $r.Content }} } +func TestResourceChainBasic(t *testing.T) { + t.Parallel() + + b := newTestSitesBuilder(t) + b.WithTemplatesAdded("index.html", ` +{{ $hello := "

Hello World!

" | resources.FromString "hello.html" | fingerprint "sha512" | minify | fingerprint }} + +HELLO: {{ $hello.Name }}|{{ $hello.RelPermalink }}|{{ $hello.Content | safeHTML }} + +{{ $img := resources.Get "images/sunset.jpg" }} +{{ $fit := $img.Fit "200x200" }} +{{ $fit2 := $fit.Fit "100x200" }} +{{ $img = $img | fingerprint }} +SUNSET: {{ $img.Name }}|{{ $img.RelPermalink }}|{{ $img.Width }}|{{ len $img.Content }} +FIT: {{ $fit.Name }}|{{ $fit.RelPermalink }}|{{ $fit.Width }} +`) + + fs := b.Fs.Source + + imageDir := filepath.Join("assets", "images") + b.Assert(os.MkdirAll(imageDir, 0777), qt.IsNil) + src, err := os.Open("testdata/sunset.jpg") + b.Assert(err, qt.IsNil) + out, err := fs.Create(filepath.Join(imageDir, "sunset.jpg")) + b.Assert(err, qt.IsNil) + _, err = io.Copy(out, src) + b.Assert(err, qt.IsNil) + out.Close() + + b.Running() + + for i := 0; i < 2; i++ { + + b.Build(BuildCfg{}) + + b.AssertFileContent("public/index.html", + ` +SUNSET: images/sunset.jpg|/images/sunset.a9bf1d944e19c0f382e0d8f51de690f7d0bc8fa97390c4242a86c3e5c0737e71.jpg|900|90587 +FIT: images/sunset.jpg|/images/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_fit_q75_box.jpg|200 + +`) + + b.EditFiles("page1.md", ` +--- +title: "Page 1 edit" +summary: "Edited summary" +--- + +Edited content. + +`) + + b.Assert(b.Fs.Destination.Remove("public"), qt.IsNil) + b.H.ResourceSpec.ClearCaches() + + } +} + func TestResourceChain(t *testing.T) { t.Parallel() @@ -353,9 +412,11 @@ Publish 2: {{ $cssPublish2.Permalink }} "Publish 1: body{color:blue} /external1.min.css", "Publish 2: http://example.com/external2.min.css", ) - c.Assert(b.CheckExists("public/external2.min.css"), qt.Equals, true) - c.Assert(b.CheckExists("public/external1.min.css"), qt.Equals, true) - c.Assert(b.CheckExists("public/inline.min.css"), qt.Equals, false) + b.Assert(b.CheckExists("public/external2.css"), qt.Equals, false) + b.Assert(b.CheckExists("public/external1.css"), qt.Equals, false) + b.Assert(b.CheckExists("public/external2.min.css"), qt.Equals, true) + b.Assert(b.CheckExists("public/external1.min.css"), qt.Equals, true) + b.Assert(b.CheckExists("public/inline.min.css"), qt.Equals, false) }}, {"unmarshal", func() bool { return true }, func(b *sitesBuilder) { diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index e7d3b99fb..f1c19366d 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -536,6 +536,7 @@ func (s *sitesBuilder) changeEvents() []fsnotify.Event { } func (s *sitesBuilder) build(cfg BuildCfg, shouldFail bool) *sitesBuilder { + s.Helper() defer func() { s.changedFiles = nil }() diff --git a/resources/image.go b/resources/image.go index f1aae2996..e1a816942 100644 --- a/resources/image.go +++ b/resources/image.go @@ -14,198 +14,98 @@ package resources import ( - "errors" "fmt" "image" "image/color" "image/draw" - "image/jpeg" - "io" + _ "image/gif" + _ "image/png" "os" - "strconv" "strings" - "sync" "github.com/gohugoio/hugo/resources/resource" _errors "github.com/pkg/errors" "github.com/disintegration/imaging" - "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/helpers" - "github.com/mitchellh/mapstructure" + "github.com/gohugoio/hugo/resources/images" // Blind import for image.Decode - _ "image/gif" - _ "image/png" // Blind import for image.Decode _ "golang.org/x/image/webp" ) var ( - _ resource.Resource = (*Image)(nil) - _ resource.Source = (*Image)(nil) - _ resource.Cloner = (*Image)(nil) + _ resource.Image = (*imageResource)(nil) + _ resource.Source = (*imageResource)(nil) + _ resource.Cloner = (*imageResource)(nil) ) -// Imaging contains default image processing configuration. This will be fetched -// from site (or language) config. -type Imaging struct { - // Default image quality setting (1-100). Only used for JPEG images. - Quality int +// ImageResource represents an image resource. +type imageResource struct { + *images.Image - // Resample filter used. See https://github.com/disintegration/imaging - ResampleFilter string - - // The anchor used in Fill. Default is "smart", i.e. Smart Crop. - Anchor string + baseResource } -const ( - defaultJPEGQuality = 75 - defaultResampleFilter = "box" -) +func (i *imageResource) Clone() resource.Resource { + gr := i.baseResource.Clone().(baseResource) + return &imageResource{ + Image: i.WithSpec(gr), + baseResource: gr, + } +} -var ( - imageFormats = map[string]imaging.Format{ - ".jpg": imaging.JPEG, - ".jpeg": imaging.JPEG, - ".png": imaging.PNG, - ".tif": imaging.TIFF, - ".tiff": imaging.TIFF, - ".bmp": imaging.BMP, - ".gif": imaging.GIF, +func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) { + base, err := i.baseResource.cloneWithUpdates(u) + if err != nil { + return nil, err } - // Add or increment if changes to an image format's processing requires - // re-generation. - imageFormatsVersions = map[imaging.Format]int{ - imaging.PNG: 2, // Floyd Steinberg dithering + var img *images.Image + + if u.isContenChanged() { + img = i.WithSpec(base) + } else { + img = i.Image } - // Increment to mark all processed images as stale. Only use when absolutely needed. - // See the finer grained smartCropVersionNumber and imageFormatsVersions. - mainImageVersionNumber = 0 -) - -var anchorPositions = map[string]imaging.Anchor{ - strings.ToLower("Center"): imaging.Center, - strings.ToLower("TopLeft"): imaging.TopLeft, - strings.ToLower("Top"): imaging.Top, - strings.ToLower("TopRight"): imaging.TopRight, - strings.ToLower("Left"): imaging.Left, - strings.ToLower("Right"): imaging.Right, - strings.ToLower("BottomLeft"): imaging.BottomLeft, - strings.ToLower("Bottom"): imaging.Bottom, - strings.ToLower("BottomRight"): imaging.BottomRight, -} - -var imageFilters = map[string]imaging.ResampleFilter{ - strings.ToLower("NearestNeighbor"): imaging.NearestNeighbor, - strings.ToLower("Box"): imaging.Box, - strings.ToLower("Linear"): imaging.Linear, - strings.ToLower("Hermite"): imaging.Hermite, - strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali, - strings.ToLower("CatmullRom"): imaging.CatmullRom, - strings.ToLower("BSpline"): imaging.BSpline, - strings.ToLower("Gaussian"): imaging.Gaussian, - strings.ToLower("Lanczos"): imaging.Lanczos, - strings.ToLower("Hann"): imaging.Hann, - strings.ToLower("Hamming"): imaging.Hamming, - strings.ToLower("Blackman"): imaging.Blackman, - strings.ToLower("Bartlett"): imaging.Bartlett, - strings.ToLower("Welch"): imaging.Welch, - strings.ToLower("Cosine"): imaging.Cosine, -} - -// Image represents an image resource. -type Image struct { - config image.Config - configInit sync.Once - configLoaded bool - - imaging *Imaging - - format imaging.Format - - *genericResource -} - -// Width returns i's width. -func (i *Image) Width() int { - i.initConfig() - return i.config.Width -} - -// Height returns i's height. -func (i *Image) Height() int { - i.initConfig() - return i.config.Height -} - -// WithNewBase implements the Cloner interface. -func (i *Image) WithNewBase(base string) resource.Resource { - return &Image{ - imaging: i.imaging, - format: i.format, - genericResource: i.genericResource.WithNewBase(base).(*genericResource)} + return &imageResource{ + Image: img, + baseResource: base, + }, nil } // Resize resizes the image to the specified width and height using the specified resampling // filter and returns the transformed image. If one of width or height is 0, the image aspect // ratio is preserved. -func (i *Image) Resize(spec string) (*Image, error) { - return i.doWithImageConfig("resize", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil +func (i *imageResource) Resize(spec string) (resource.Image, error) { + return i.doWithImageConfig("resize", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Resize(src, conf) }) } // Fit scales down the image using the specified resample filter to fit the specified // maximum width and height. -func (i *Image) Fit(spec string) (*Image, error) { - return i.doWithImageConfig("fit", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil +func (i *imageResource) Fit(spec string) (resource.Image, error) { + return i.doWithImageConfig("fit", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Fit(src, conf) }) } // Fill scales the image to the smallest possible size that will cover the specified dimensions, // crops the resized image to the specified dimensions using the given anchor point. // Space delimited config: 200x300 TopLeft -func (i *Image) Fill(spec string) (*Image, error) { - return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) { - if conf.AnchorStr == smartCropIdentifier { - return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter) - } - return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil +func (i *imageResource) Fill(spec string) (resource.Image, error) { + return i.doWithImageConfig("fill", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) { + return i.Proc.Fill(src, conf) }) } -// Holds configuration to create a new image from an existing one, resize etc. -type imageConfig struct { - Action string - - // Quality ranges from 1 to 100 inclusive, higher is better. - // This is only relevant for JPEG images. - // Default is 75. - Quality int - - // Rotate rotates an image by the given angle counter-clockwise. - // The rotation will be performed first. - Rotate int - - Width int - Height int - - Filter imaging.ResampleFilter - FilterStr string - - Anchor imaging.Anchor - AnchorStr string -} - -func (i *Image) isJPEG() bool { - name := strings.ToLower(i.relTargetDirFile.file) +func (i *imageResource) isJPEG() bool { + name := strings.ToLower(i.getResourcePaths().relTargetDirFile.file) return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg") } @@ -218,42 +118,20 @@ const imageProcWorkers = 1 var imageProcSem = make(chan bool, imageProcWorkers) -func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, conf imageConfig) (image.Image, error)) (*Image, error) { - conf, err := parseImageConfig(spec) +func (i *imageResource) doWithImageConfig(action, spec string, f func(src image.Image, conf images.ImageConfig) (image.Image, error)) (resource.Image, error) { + conf, err := i.decodeImageConfig(action, spec) if err != nil { return nil, err } - conf.Action = action - if conf.Quality <= 0 && i.isJPEG() { - // We need a quality setting for all JPEGs - conf.Quality = i.imaging.Quality - } - - if conf.FilterStr == "" { - conf.FilterStr = i.imaging.ResampleFilter - conf.Filter = imageFilters[conf.FilterStr] - } - - if conf.AnchorStr == "" { - conf.AnchorStr = i.imaging.Anchor - if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) { - conf.Anchor = anchorPositions[conf.AnchorStr] - } - } - - return i.spec.imageCache.getOrCreate(i, conf, func() (*Image, image.Image, error) { + return i.getSpec().imageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) { imageProcSem <- true defer func() { <-imageProcSem }() - ci := i.clone() - errOp := action - errPath := i.sourceFilename - - ci.setBasePath(conf) + errPath := i.getSourceFilename() src, err := i.decodeSource() if err != nil { @@ -267,10 +145,10 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c converted, err := f(src, conf) if err != nil { - return ci, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} + return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err} } - if i.format == imaging.PNG { + if i.Format == imaging.PNG { // Apply the colour palette from the source if paletted, ok := src.(*image.Paletted); ok { tmp := image.NewPaletted(converted.Bounds(), paletted.Palette) @@ -279,177 +157,30 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c } } - b := converted.Bounds() - ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y} - ci.configLoaded = true + ci := i.clone(converted) + ci.setBasePath(conf) return ci, converted, nil }) - } -func (i imageConfig) key(format imaging.Format) string { - k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) - if i.Action != "" { - k += "_" + i.Action - } - if i.Quality > 0 { - k += "_q" + strconv.Itoa(i.Quality) - } - if i.Rotate != 0 { - k += "_r" + strconv.Itoa(i.Rotate) - } - anchor := i.AnchorStr - if anchor == smartCropIdentifier { - anchor = anchor + strconv.Itoa(smartCropVersionNumber) - } - - k += "_" + i.FilterStr - - if strings.EqualFold(i.Action, "fill") { - k += "_" + anchor - } - - if v, ok := imageFormatsVersions[format]; ok { - k += "_" + strconv.Itoa(v) - } - - if mainImageVersionNumber > 0 { - k += "_" + strconv.Itoa(mainImageVersionNumber) - } - - return k -} - -func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig { - var c imageConfig - - c.Width = width - c.Height = height - c.Quality = quality - c.Rotate = rotate - - if filter != "" { - filter = strings.ToLower(filter) - if v, ok := imageFilters[filter]; ok { - c.Filter = v - c.FilterStr = filter - } - } - - if anchor != "" { - anchor = strings.ToLower(anchor) - if v, ok := anchorPositions[anchor]; ok { - c.Anchor = v - c.AnchorStr = anchor - } - } - - return c -} - -func parseImageConfig(config string) (imageConfig, error) { - var ( - c imageConfig - err error - ) - - if config == "" { - return c, errors.New("image config cannot be empty") - } - - parts := strings.Fields(config) - for _, part := range parts { - part = strings.ToLower(part) - - if part == smartCropIdentifier { - c.AnchorStr = smartCropIdentifier - } else if pos, ok := anchorPositions[part]; ok { - c.Anchor = pos - c.AnchorStr = part - } else if filter, ok := imageFilters[part]; ok { - c.Filter = filter - c.FilterStr = part - } else if part[0] == 'q' { - c.Quality, err = strconv.Atoi(part[1:]) - if err != nil { - return c, err - } - if c.Quality < 1 || c.Quality > 100 { - return c, errors.New("quality ranges from 1 to 100 inclusive") - } - } else if part[0] == 'r' { - c.Rotate, err = strconv.Atoi(part[1:]) - if err != nil { - return c, err - } - } else if strings.Contains(part, "x") { - widthHeight := strings.Split(part, "x") - if len(widthHeight) <= 2 { - first := widthHeight[0] - if first != "" { - c.Width, err = strconv.Atoi(first) - if err != nil { - return c, err - } - } - - if len(widthHeight) == 2 { - second := widthHeight[1] - if second != "" { - c.Height, err = strconv.Atoi(second) - if err != nil { - return c, err - } - } - } - } else { - return c, errors.New("invalid image dimensions") - } - - } - } - - if c.Width == 0 && c.Height == 0 { - return c, errors.New("must provide Width or Height") - } - - return c, nil -} - -func (i *Image) initConfig() error { - var err error - i.configInit.Do(func() { - if i.configLoaded { - return - } - - var ( - f hugio.ReadSeekCloser - config image.Config - ) - - f, err = i.ReadSeekCloser() - if err != nil { - return - } - defer f.Close() - - config, _, err = image.DecodeConfig(f) - if err != nil { - return - } - i.config = config - }) - +func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) { + conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg) if err != nil { - return _errors.Wrap(err, "failed to load image config") + return conf, err } - return nil + iconf := i.Proc.Cfg + + if conf.Quality <= 0 && i.isJPEG() { + // We need a quality setting for all JPEGs + conf.Quality = iconf.Quality + } + + return conf, nil } -func (i *Image) decodeSource() (image.Image, error) { +func (i *imageResource) decodeSource() (image.Image, error) { f, err := i.ReadSeekCloser() if err != nil { return nil, _errors.Wrap(err, "failed to open image for decode") @@ -459,80 +190,39 @@ func (i *Image) decodeSource() (image.Image, error) { return img, err } -// returns an opened file or nil if nothing to write. -func (i *Image) openDestinationsForWriting() (io.WriteCloser, error) { - targetFilenames := i.targetFilenames() - var changedFilenames []string +func (i *imageResource) clone(img image.Image) *imageResource { + spec := i.baseResource.Clone().(baseResource) - // Fast path: - // This is a processed version of the original; - // check if it already existis at the destination. - for _, targetFilename := range targetFilenames { - if _, err := i.spec.BaseFs.PublishFs.Stat(targetFilename); err == nil { - continue - } - changedFilenames = append(changedFilenames, targetFilename) + var image *images.Image + if img != nil { + image = i.WithImage(img) + } else { + image = i.WithSpec(spec) } - if len(changedFilenames) == 0 { - return nil, nil - } - - return helpers.OpenFilesForWriting(i.spec.BaseFs.PublishFs, changedFilenames...) - -} - -func (i *Image) encodeTo(conf imageConfig, img image.Image, w io.Writer) error { - switch i.format { - case imaging.JPEG: - - var rgba *image.RGBA - quality := conf.Quality - - if nrgba, ok := img.(*image.NRGBA); ok { - if nrgba.Opaque() { - rgba = &image.RGBA{ - Pix: nrgba.Pix, - Stride: nrgba.Stride, - Rect: nrgba.Rect, - } - } - } - if rgba != nil { - return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality}) - } - return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) - default: - return imaging.Encode(w, img, i.format) + return &imageResource{ + Image: image, + baseResource: spec, } } -func (i *Image) clone() *Image { - g := *i.genericResource - g.resourceContent = &resourceContent{} - if g.publishOnce != nil { - g.publishOnce = &publishOnce{logger: g.publishOnce.logger} +func (i *imageResource) setBasePath(conf images.ImageConfig) { + i.getResourcePaths().relTargetDirFile = i.relTargetPathFromConfig(conf) +} + +func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile { + p1, p2 := helpers.FileAndExt(i.getResourcePaths().relTargetDirFile.file) + if conf.Action == "trace" { + p2 = ".svg" } - return &Image{ - imaging: i.imaging, - format: i.format, - genericResource: &g} -} - -func (i *Image) setBasePath(conf imageConfig) { - i.relTargetDirFile = i.relTargetPathFromConfig(conf) -} - -func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { - p1, p2 := helpers.FileAndExt(i.relTargetDirFile.file) - - idStr := fmt.Sprintf("_hu%s_%d", i.hash, i.osFileInfo.Size()) + h, _ := i.hash() + idStr := fmt.Sprintf("_hu%s_%d", h, i.size()) // Do not change for no good reason. const md5Threshold = 100 - key := conf.key(i.format) + key := conf.Key(i.Format) // It is useful to have the key in clear text, but when nesting transforms, it // can easily be too long to read, and maybe even too long @@ -554,43 +244,7 @@ func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { } return dirFile{ - dir: i.relTargetDirFile.dir, + dir: i.getResourcePaths().relTargetDirFile.dir, file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2), } - -} - -func decodeImaging(m map[string]interface{}) (Imaging, error) { - var i Imaging - if err := mapstructure.WeakDecode(m, &i); err != nil { - return i, err - } - - if i.Quality == 0 { - i.Quality = defaultJPEGQuality - } else if i.Quality < 0 || i.Quality > 100 { - return i, errors.New("JPEG quality must be a number between 1 and 100") - } - - if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) { - i.Anchor = smartCropIdentifier - } else { - i.Anchor = strings.ToLower(i.Anchor) - if _, found := anchorPositions[i.Anchor]; !found { - return i, errors.New("invalid anchor value in imaging config") - } - } - - if i.ResampleFilter == "" { - i.ResampleFilter = defaultResampleFilter - } else { - filter := strings.ToLower(i.ResampleFilter) - _, found := imageFilters[filter] - if !found { - return i, fmt.Errorf("%q is not a valid resample filter", filter) - } - i.ResampleFilter = filter - } - - return i, nil } diff --git a/resources/image_cache.go b/resources/image_cache.go index 3324e442e..3a9e3c2c5 100644 --- a/resources/image_cache.go +++ b/resources/image_cache.go @@ -20,7 +20,7 @@ import ( "strings" "sync" - "github.com/gohugoio/hugo/common/hugio" + "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/cache/filecache" "github.com/gohugoio/hugo/helpers" @@ -32,7 +32,7 @@ type imageCache struct { fileCache *filecache.Cache mu sync.RWMutex - store map[string]*Image + store map[string]*resourceAdapter } func (c *imageCache) isInCache(key string) bool { @@ -66,33 +66,34 @@ func (c *imageCache) normalizeKey(key string) string { func (c *imageCache) clear() { c.mu.Lock() defer c.mu.Unlock() - c.store = make(map[string]*Image) + c.store = make(map[string]*resourceAdapter) } func (c *imageCache) getOrCreate( - parent *Image, conf imageConfig, createImage func() (*Image, image.Image, error)) (*Image, error) { - + parent *imageResource, conf images.ImageConfig, + createImage func() (*imageResource, image.Image, error)) (*resourceAdapter, error) { relTarget := parent.relTargetPathFromConfig(conf) key := parent.relTargetPathForRel(relTarget.path(), false, false, false) // First check the in-memory store, then the disk. c.mu.RLock() - img, found := c.store[key] + cachedImage, found := c.store[key] c.mu.RUnlock() if found { - return img, nil + return cachedImage, nil } + var img *imageResource + // These funcs are protected by a named lock. // read clones the parent to its new name and copies // the content to the destinations. read := func(info filecache.ItemInfo, r io.Reader) error { - img = parent.clone() - img.relTargetDirFile.file = relTarget.file - img.sourceFilename = info.Name - // Make sure it's always loaded by sourceFilename. - img.openReadSeekerCloser = nil + img = parent.clone(nil) + rp := img.getResourcePaths() + rp.relTargetDirFile.file = relTarget.file + img.setSourceFilename(info.Name) w, err := img.openDestinationsForWriting() if err != nil { @@ -109,29 +110,20 @@ func (c *imageCache) getOrCreate( return err } - // create creates the image and encodes it to w (cache) and to its destinations. + // create creates the image and encodes it to the cache (w). create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) { + defer w.Close() + var conv image.Image img, conv, err = createImage() if err != nil { - w.Close() return } - img.relTargetDirFile.file = relTarget.file - img.sourceFilename = info.Name + rp := img.getResourcePaths() + rp.relTargetDirFile.file = relTarget.file + img.setSourceFilename(info.Name) - destinations, err := img.openDestinationsForWriting() - if err != nil { - w.Close() - return err - } - - if destinations != nil { - w = hugio.NewMultiWriteCloser(w, destinations) - } - defer w.Close() - - return img.encodeTo(conf, conv, w) + return img.EncodeTo(conf, conv, w) } // Now look in the file cache. @@ -147,20 +139,21 @@ func (c *imageCache) getOrCreate( } // The file is now stored in this cache. - img.sourceFs = c.fileCache.Fs + img.setSourceFs(c.fileCache.Fs) c.mu.Lock() - if img2, found := c.store[key]; found { + if cachedImage, found = c.store[key]; found { c.mu.Unlock() - return img2, nil + return cachedImage, nil } - c.store[key] = img + + imgAdapter := newResourceAdapter(parent.getSpec(), true, img) + c.store[key] = imgAdapter c.mu.Unlock() - return img, nil - + return imgAdapter, nil } func newImageCache(fileCache *filecache.Cache, ps *helpers.PathSpec) *imageCache { - return &imageCache{fileCache: fileCache, pathSpec: ps, store: make(map[string]*Image)} + return &imageCache{fileCache: fileCache, pathSpec: ps, store: make(map[string]*resourceAdapter)} } diff --git a/resources/image_test.go b/resources/image_test.go index 96a66d999..31169444d 100644 --- a/resources/image_test.go +++ b/resources/image_test.go @@ -18,121 +18,101 @@ import ( "math/rand" "path/filepath" "strconv" + "sync" "testing" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/resource" + + "github.com/google/go-cmp/cmp" + "github.com/gohugoio/hugo/htesting/hqt" - "github.com/disintegration/imaging" - - "sync" - qt "github.com/frankban/quicktest" ) -func TestParseImageConfig(t *testing.T) { - for i, this := range []struct { - in string - expect interface{} - }{ - {"300x400", newImageConfig(300, 400, 0, 0, "", "")}, - {"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight")}, - {"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft")}, - {"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left")}, - {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right")}, - - {"", false}, - {"foo", false}, - } { - result, err := parseImageConfig(this.in) - if b, ok := this.expect.(bool); ok && !b { - if err == nil { - t.Errorf("[%d] parseImageConfig didn't return an expected error", i) - } - } else { - if err != nil { - t.Fatalf("[%d] err: %s", i, err) - } - if fmt.Sprint(result) != fmt.Sprint(this.expect) { - t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect) - } - } - } -} +var eq = qt.CmpEquals( + cmp.Comparer(func(p1, p2 *resourceAdapter) bool { + return p1.resourceAdapterInner == p2.resourceAdapterInner + }), + cmp.Comparer(func(p1, p2 *genericResource) bool { return p1 == p2 }), + cmp.Comparer(func(m1, m2 media.Type) bool { + return m1.Type() == m2.Type() + }), +) func TestImageTransformBasic(t *testing.T) { - c := qt.New(t) image := fetchSunset(c) - fileCache := image.spec.FileCaches.ImageCache().Fs + + fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs + + assertWidthHeight := func(img resource.Image, w, h int) { + c.Helper() + c.Assert(img, qt.Not(qt.IsNil)) + c.Assert(img.Width(), qt.Equals, w) + c.Assert(img.Height(), qt.Equals, h) + } c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg") c.Assert(image.ResourceType(), qt.Equals, "image") + assertWidthHeight(image, 900, 562) resized, err := image.Resize("300x200") c.Assert(err, qt.IsNil) c.Assert(image != resized, qt.Equals, true) - c.Assert(image.genericResource != resized.genericResource, qt.Equals, true) - c.Assert(image.sourceFilename != resized.sourceFilename, qt.Equals, true) + c.Assert(image, qt.Not(eq), resized) + assertWidthHeight(resized, 300, 200) + assertWidthHeight(image, 900, 562) resized0x, err := image.Resize("x200") c.Assert(err, qt.IsNil) - c.Assert(resized0x.Width(), qt.Equals, 320) - c.Assert(resized0x.Height(), qt.Equals, 200) - + assertWidthHeight(resized0x, 320, 200) assertFileCache(c, fileCache, resized0x.RelPermalink(), 320, 200) resizedx0, err := image.Resize("200x") c.Assert(err, qt.IsNil) - c.Assert(resizedx0.Width(), qt.Equals, 200) - c.Assert(resizedx0.Height(), qt.Equals, 125) + assertWidthHeight(resizedx0, 200, 125) assertFileCache(c, fileCache, resizedx0.RelPermalink(), 200, 125) resizedAndRotated, err := image.Resize("x200 r90") c.Assert(err, qt.IsNil) - c.Assert(resizedAndRotated.Width(), qt.Equals, 125) - c.Assert(resizedAndRotated.Height(), qt.Equals, 200) + assertWidthHeight(resizedAndRotated, 125, 200) assertFileCache(c, fileCache, resizedAndRotated.RelPermalink(), 125, 200) + assertWidthHeight(resized, 300, 200) c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q68_linear.jpg") - c.Assert(resized.Width(), qt.Equals, 300) - c.Assert(resized.Height(), qt.Equals, 200) fitted, err := resized.Fit("50x50") c.Assert(err, qt.IsNil) c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_625708021e2bb281c9f1002f88e4753f.jpg") - c.Assert(fitted.Width(), qt.Equals, 50) - c.Assert(fitted.Height(), qt.Equals, 33) + assertWidthHeight(fitted, 50, 33) // Check the MD5 key threshold fittedAgain, _ := fitted.Fit("10x20") fittedAgain, err = fittedAgain.Fit("10x20") c.Assert(err, qt.IsNil) c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg") - c.Assert(fittedAgain.Width(), qt.Equals, 10) - c.Assert(fittedAgain.Height(), qt.Equals, 6) + assertWidthHeight(fittedAgain, 10, 6) filled, err := image.Fill("200x100 bottomLeft") c.Assert(err, qt.IsNil) c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_bottomleft.jpg") - c.Assert(filled.Width(), qt.Equals, 200) - c.Assert(filled.Height(), qt.Equals, 100) + assertWidthHeight(filled, 200, 100) assertFileCache(c, fileCache, filled.RelPermalink(), 200, 100) smart, err := image.Fill("200x100 smart") c.Assert(err, qt.IsNil) - c.Assert(smart.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", smartCropVersionNumber)) - c.Assert(smart.Width(), qt.Equals, 200) - c.Assert(smart.Height(), qt.Equals, 100) + c.Assert(smart.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", 1)) + assertWidthHeight(smart, 200, 100) assertFileCache(c, fileCache, smart.RelPermalink(), 200, 100) // Check cache filledAgain, err := image.Fill("200x100 bottomLeft") c.Assert(err, qt.IsNil) - c.Assert(filled == filledAgain, qt.Equals, true) - c.Assert(filled.sourceFilename == filledAgain.sourceFilename, qt.Equals, true) + c.Assert(filled, eq, filledAgain) assertFileCache(c, fileCache, filledAgain.RelPermalink(), 200, 100) - } // https://github.com/gohugoio/hugo/issues/4261 @@ -158,6 +138,7 @@ func TestImageTransformLongFilename(t *testing.T) { func TestImageTransformUppercaseExt(t *testing.T) { c := qt.New(t) image := fetchImage(c, "sunrise.JPG") + resized, err := image.Resize("200x") c.Assert(err, qt.IsNil) c.Assert(resized, qt.Not(qt.IsNil)) @@ -173,17 +154,16 @@ func TestImagePermalinkPublishOrder(t *testing.T) { } t.Run(name, func(t *testing.T) { - c := qt.New(t) spec := newTestResourceOsFs(c) - check1 := func(img *Image) { + check1 := func(img resource.Image) { resizedLink := "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x50_resize_q75_box.jpg" c.Assert(img.RelPermalink(), qt.Equals, resizedLink) assertImageFile(c, spec.PublishFs, resizedLink, 100, 50) } - check2 := func(img *Image) { + check2 := func(img resource.Image) { c.Assert(img.RelPermalink(), qt.Equals, "/a/sunset.jpg") assertImageFile(c, spec.PublishFs, "a/sunset.jpg", 900, 562) } @@ -198,18 +178,16 @@ func TestImagePermalinkPublishOrder(t *testing.T) { resized, err := orignal.Resize("100x50") c.Assert(err, qt.IsNil) - check1(resized) + check1(resized.(resource.Image)) if !checkOriginalFirst { check2(orignal) } }) } - } func TestImageTransformConcurrent(t *testing.T) { - var wg sync.WaitGroup c := qt.New(t) @@ -239,12 +217,7 @@ func TestImageTransformConcurrent(t *testing.T) { t.Error(err) } - _, err = r2.decodeSource() - if err != nil { - t.Error("Err decode:", err) - } - - img = r1 + img = r2 } } }(i + 20) @@ -253,58 +226,12 @@ func TestImageTransformConcurrent(t *testing.T) { wg.Wait() } -func TestDecodeImaging(t *testing.T) { - c := qt.New(t) - m := map[string]interface{}{ - "quality": 42, - "resampleFilter": "NearestNeighbor", - "anchor": "topLeft", - } - - imaging, err := decodeImaging(m) - - c.Assert(err, qt.IsNil) - c.Assert(imaging.Quality, qt.Equals, 42) - c.Assert(imaging.ResampleFilter, qt.Equals, "nearestneighbor") - c.Assert(imaging.Anchor, qt.Equals, "topleft") - - m = map[string]interface{}{} - - imaging, err = decodeImaging(m) - c.Assert(err, qt.IsNil) - c.Assert(imaging.Quality, qt.Equals, defaultJPEGQuality) - c.Assert(imaging.ResampleFilter, qt.Equals, "box") - c.Assert(imaging.Anchor, qt.Equals, "smart") - - _, err = decodeImaging(map[string]interface{}{ - "quality": 123, - }) - c.Assert(err, qt.Not(qt.IsNil)) - - _, err = decodeImaging(map[string]interface{}{ - "resampleFilter": "asdf", - }) - c.Assert(err, qt.Not(qt.IsNil)) - - _, err = decodeImaging(map[string]interface{}{ - "anchor": "asdf", - }) - c.Assert(err, qt.Not(qt.IsNil)) - - imaging, err = decodeImaging(map[string]interface{}{ - "anchor": "Smart", - }) - c.Assert(err, qt.IsNil) - c.Assert(imaging.Anchor, qt.Equals, "smart") - -} - func TestImageWithMetadata(t *testing.T) { c := qt.New(t) image := fetchSunset(c) - var meta = []map[string]interface{}{ + meta := []map[string]interface{}{ { "title": "My Sunset", "name": "Sunset #:counter", @@ -318,71 +245,69 @@ func TestImageWithMetadata(t *testing.T) { resized, err := image.Resize("200x") c.Assert(err, qt.IsNil) c.Assert(resized.Name(), qt.Equals, "Sunset #1") - } func TestImageResize8BitPNG(t *testing.T) { - c := qt.New(t) image := fetchImage(c, "gohugoio.png") - c.Assert(image.format, qt.Equals, imaging.PNG) + c.Assert(image.MediaType().Type(), qt.Equals, "image/png") c.Assert(image.RelPermalink(), qt.Equals, "/a/gohugoio.png") c.Assert(image.ResourceType(), qt.Equals, "image") resized, err := image.Resize("800x") c.Assert(err, qt.IsNil) - c.Assert(resized.format, qt.Equals, imaging.PNG) + c.Assert(resized.MediaType().Type(), qt.Equals, "image/png") c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_800x0_resize_linear_2.png") c.Assert(resized.Width(), qt.Equals, 800) - } func TestImageResizeInSubPath(t *testing.T) { - c := qt.New(t) image := fetchImage(c, "sub/gohugoio2.png") - fileCache := image.spec.FileCaches.ImageCache().Fs + fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs - c.Assert(image.format, qt.Equals, imaging.PNG) + c.Assert(image.MediaType(), eq, media.PNGType) c.Assert(image.RelPermalink(), qt.Equals, "/a/sub/gohugoio2.png") c.Assert(image.ResourceType(), qt.Equals, "image") resized, err := image.Resize("101x101") c.Assert(err, qt.IsNil) - c.Assert(resized.format, qt.Equals, imaging.PNG) + c.Assert(resized.MediaType().Type(), qt.Equals, "image/png") c.Assert(resized.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_2.png") c.Assert(resized.Width(), qt.Equals, 101) assertFileCache(c, fileCache, resized.RelPermalink(), 101, 101) publishedImageFilename := filepath.Clean(resized.RelPermalink()) - assertImageFile(c, image.spec.BaseFs.PublishFs, publishedImageFilename, 101, 101) - c.Assert(image.spec.BaseFs.PublishFs.Remove(publishedImageFilename), qt.IsNil) + + spec := image.(specProvider).getSpec() + + assertImageFile(c, spec.BaseFs.PublishFs, publishedImageFilename, 101, 101) + c.Assert(spec.BaseFs.PublishFs.Remove(publishedImageFilename), qt.IsNil) // Cleare mem cache to simulate reading from the file cache. - resized.spec.imageCache.clear() + spec.imageCache.clear() resizedAgain, err := image.Resize("101x101") c.Assert(err, qt.IsNil) c.Assert(resizedAgain.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_2.png") c.Assert(resizedAgain.Width(), qt.Equals, 101) assertFileCache(c, fileCache, resizedAgain.RelPermalink(), 101, 101) - assertImageFile(c, image.spec.BaseFs.PublishFs, publishedImageFilename, 101, 101) - + assertImageFile(c, image.(specProvider).getSpec().BaseFs.PublishFs, publishedImageFilename, 101, 101) } func TestSVGImage(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) svg := fetchResourceForSpec(spec, c, "circle.svg") c.Assert(svg, qt.Not(qt.IsNil)) } func TestSVGImageContent(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) svg := fetchResourceForSpec(spec, c, "circle.svg") c.Assert(svg, qt.Not(qt.IsNil)) diff --git a/resources/images/config.go b/resources/images/config.go new file mode 100644 index 000000000..c4605c9cf --- /dev/null +++ b/resources/images/config.go @@ -0,0 +1,276 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package images + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/disintegration/imaging" + "github.com/mitchellh/mapstructure" +) + +const ( + defaultJPEGQuality = 75 + defaultResampleFilter = "box" +) + +var ( + imageFormats = map[string]imaging.Format{ + ".jpg": imaging.JPEG, + ".jpeg": imaging.JPEG, + ".png": imaging.PNG, + ".tif": imaging.TIFF, + ".tiff": imaging.TIFF, + ".bmp": imaging.BMP, + ".gif": imaging.GIF, + } + + // Add or increment if changes to an image format's processing requires + // re-generation. + imageFormatsVersions = map[imaging.Format]int{ + imaging.PNG: 2, // Floyd Steinberg dithering + } + + // Increment to mark all processed images as stale. Only use when absolutely needed. + // See the finer grained smartCropVersionNumber and imageFormatsVersions. + mainImageVersionNumber = 0 + + // Increment to mark all traced SVGs as stale. + traceVersionNumber = 0 +) + +var anchorPositions = map[string]imaging.Anchor{ + strings.ToLower("Center"): imaging.Center, + strings.ToLower("TopLeft"): imaging.TopLeft, + strings.ToLower("Top"): imaging.Top, + strings.ToLower("TopRight"): imaging.TopRight, + strings.ToLower("Left"): imaging.Left, + strings.ToLower("Right"): imaging.Right, + strings.ToLower("BottomLeft"): imaging.BottomLeft, + strings.ToLower("Bottom"): imaging.Bottom, + strings.ToLower("BottomRight"): imaging.BottomRight, +} + +var imageFilters = map[string]imaging.ResampleFilter{ + strings.ToLower("NearestNeighbor"): imaging.NearestNeighbor, + strings.ToLower("Box"): imaging.Box, + strings.ToLower("Linear"): imaging.Linear, + strings.ToLower("Hermite"): imaging.Hermite, + strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali, + strings.ToLower("CatmullRom"): imaging.CatmullRom, + strings.ToLower("BSpline"): imaging.BSpline, + strings.ToLower("Gaussian"): imaging.Gaussian, + strings.ToLower("Lanczos"): imaging.Lanczos, + strings.ToLower("Hann"): imaging.Hann, + strings.ToLower("Hamming"): imaging.Hamming, + strings.ToLower("Blackman"): imaging.Blackman, + strings.ToLower("Bartlett"): imaging.Bartlett, + strings.ToLower("Welch"): imaging.Welch, + strings.ToLower("Cosine"): imaging.Cosine, +} + +func ImageFormatFromExt(ext string) (imaging.Format, bool) { + f, found := imageFormats[ext] + return f, found +} + +func DecodeConfig(m map[string]interface{}) (Imaging, error) { + var i Imaging + if err := mapstructure.WeakDecode(m, &i); err != nil { + return i, err + } + + if i.Quality == 0 { + i.Quality = defaultJPEGQuality + } else if i.Quality < 0 || i.Quality > 100 { + return i, errors.New("JPEG quality must be a number between 1 and 100") + } + + if i.Anchor == "" || strings.EqualFold(i.Anchor, SmartCropIdentifier) { + i.Anchor = SmartCropIdentifier + } else { + i.Anchor = strings.ToLower(i.Anchor) + if _, found := anchorPositions[i.Anchor]; !found { + return i, errors.New("invalid anchor value in imaging config") + } + } + + if i.ResampleFilter == "" { + i.ResampleFilter = defaultResampleFilter + } else { + filter := strings.ToLower(i.ResampleFilter) + _, found := imageFilters[filter] + if !found { + return i, fmt.Errorf("%q is not a valid resample filter", filter) + } + i.ResampleFilter = filter + } + + return i, nil +} + +func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, error) { + var ( + c ImageConfig + err error + ) + + c.Action = action + + if config == "" { + return c, errors.New("image config cannot be empty") + } + + parts := strings.Fields(config) + for _, part := range parts { + part = strings.ToLower(part) + + if part == SmartCropIdentifier { + c.AnchorStr = SmartCropIdentifier + } else if pos, ok := anchorPositions[part]; ok { + c.Anchor = pos + c.AnchorStr = part + } else if filter, ok := imageFilters[part]; ok { + c.Filter = filter + c.FilterStr = part + } else if part[0] == 'q' { + c.Quality, err = strconv.Atoi(part[1:]) + if err != nil { + return c, err + } + if c.Quality < 1 || c.Quality > 100 { + return c, errors.New("quality ranges from 1 to 100 inclusive") + } + } else if part[0] == 'r' { + c.Rotate, err = strconv.Atoi(part[1:]) + if err != nil { + return c, err + } + } else if strings.Contains(part, "x") { + widthHeight := strings.Split(part, "x") + if len(widthHeight) <= 2 { + first := widthHeight[0] + if first != "" { + c.Width, err = strconv.Atoi(first) + if err != nil { + return c, err + } + } + + if len(widthHeight) == 2 { + second := widthHeight[1] + if second != "" { + c.Height, err = strconv.Atoi(second) + if err != nil { + return c, err + } + } + } + } else { + return c, errors.New("invalid image dimensions") + } + + } + } + + if c.Width == 0 && c.Height == 0 { + return c, errors.New("must provide Width or Height") + } + + if c.FilterStr == "" { + c.FilterStr = defaults.ResampleFilter + c.Filter = imageFilters[c.FilterStr] + } + + if c.AnchorStr == "" { + c.AnchorStr = defaults.Anchor + if !strings.EqualFold(c.AnchorStr, SmartCropIdentifier) { + c.Anchor = anchorPositions[c.AnchorStr] + } + } + + return c, nil +} + +// ImageConfig holds configuration to create a new image from an existing one, resize etc. +type ImageConfig struct { + Action string + + // Quality ranges from 1 to 100 inclusive, higher is better. + // This is only relevant for JPEG images. + // Default is 75. + Quality int + + // Rotate rotates an image by the given angle counter-clockwise. + // The rotation will be performed first. + Rotate int + + Width int + Height int + + Filter imaging.ResampleFilter + FilterStr string + + Anchor imaging.Anchor + AnchorStr string +} + +func (i ImageConfig) Key(format imaging.Format) string { + k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) + if i.Action != "" { + k += "_" + i.Action + } + if i.Quality > 0 { + k += "_q" + strconv.Itoa(i.Quality) + } + if i.Rotate != 0 { + k += "_r" + strconv.Itoa(i.Rotate) + } + anchor := i.AnchorStr + if anchor == SmartCropIdentifier { + anchor = anchor + strconv.Itoa(smartCropVersionNumber) + } + + k += "_" + i.FilterStr + + if strings.EqualFold(i.Action, "fill") { + k += "_" + anchor + } + + if v, ok := imageFormatsVersions[format]; ok { + k += "_" + strconv.Itoa(v) + } + + if mainImageVersionNumber > 0 { + k += "_" + strconv.Itoa(mainImageVersionNumber) + } + + return k +} + +// Imaging contains default image processing configuration. This will be fetched +// from site (or language) config. +type Imaging struct { + // Default image quality setting (1-100). Only used for JPEG images. + Quality int + + // Resample filter used. See https://github.com/disintegration/imaging + ResampleFilter string + + // The anchor used in Fill. Default is "smart", i.e. Smart Crop. + Anchor string +} diff --git a/resources/images/config_test.go b/resources/images/config_test.go new file mode 100644 index 000000000..91f4b663a --- /dev/null +++ b/resources/images/config_test.go @@ -0,0 +1,125 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package images + +import ( + "fmt" + "strings" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestDecodeConfig(t *testing.T) { + c := qt.New(t) + m := map[string]interface{}{ + "quality": 42, + "resampleFilter": "NearestNeighbor", + "anchor": "topLeft", + } + + imaging, err := DecodeConfig(m) + + c.Assert(err, qt.IsNil) + c.Assert(imaging.Quality, qt.Equals, 42) + c.Assert(imaging.ResampleFilter, qt.Equals, "nearestneighbor") + c.Assert(imaging.Anchor, qt.Equals, "topleft") + + m = map[string]interface{}{} + + imaging, err = DecodeConfig(m) + c.Assert(err, qt.IsNil) + c.Assert(imaging.Quality, qt.Equals, defaultJPEGQuality) + c.Assert(imaging.ResampleFilter, qt.Equals, "box") + c.Assert(imaging.Anchor, qt.Equals, "smart") + + _, err = DecodeConfig(map[string]interface{}{ + "quality": 123, + }) + c.Assert(err, qt.Not(qt.IsNil)) + + _, err = DecodeConfig(map[string]interface{}{ + "resampleFilter": "asdf", + }) + c.Assert(err, qt.Not(qt.IsNil)) + + _, err = DecodeConfig(map[string]interface{}{ + "anchor": "asdf", + }) + c.Assert(err, qt.Not(qt.IsNil)) + + imaging, err = DecodeConfig(map[string]interface{}{ + "anchor": "Smart", + }) + c.Assert(err, qt.IsNil) + c.Assert(imaging.Anchor, qt.Equals, "smart") +} + +func TestDecodeImageConfig(t *testing.T) { + for i, this := range []struct { + in string + expect interface{} + }{ + {"300x400", newImageConfig(300, 400, 0, 0, "", "")}, + {"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight")}, + {"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft")}, + {"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left")}, + {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right")}, + + {"", false}, + {"foo", false}, + } { + + result, err := DecodeImageConfig("resize", this.in, Imaging{}) + if b, ok := this.expect.(bool); ok && !b { + if err == nil { + t.Errorf("[%d] parseImageConfig didn't return an expected error", i) + } + } else { + if err != nil { + t.Fatalf("[%d] err: %s", i, err) + } + if fmt.Sprint(result) != fmt.Sprint(this.expect) { + t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect) + } + } + } +} + +func newImageConfig(width, height, quality, rotate int, filter, anchor string) ImageConfig { + var c ImageConfig + c.Action = "resize" + c.Width = width + c.Height = height + c.Quality = quality + c.Rotate = rotate + + if filter != "" { + filter = strings.ToLower(filter) + if v, ok := imageFilters[filter]; ok { + c.Filter = v + c.FilterStr = filter + } + } + + if anchor != "" { + anchor = strings.ToLower(anchor) + if v, ok := anchorPositions[anchor]; ok { + c.Anchor = v + c.AnchorStr = anchor + } + } + + return c +} diff --git a/resources/images/image.go b/resources/images/image.go new file mode 100644 index 000000000..b39e84972 --- /dev/null +++ b/resources/images/image.go @@ -0,0 +1,170 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package images + +import ( + "image" + "image/jpeg" + "io" + "sync" + + "github.com/disintegration/imaging" + "github.com/gohugoio/hugo/common/hugio" + "github.com/pkg/errors" +) + +func NewImage(f imaging.Format, proc *ImageProcessor, img image.Image, s Spec) *Image { + if img != nil { + return &Image{ + Format: f, + Proc: proc, + Spec: s, + imageConfig: &imageConfig{ + config: imageConfigFromImage(img), + configLoaded: true, + }, + } + } + return &Image{Format: f, Proc: proc, Spec: s, imageConfig: &imageConfig{}} +} + +type Image struct { + Format imaging.Format + + Proc *ImageProcessor + + Spec Spec + + *imageConfig +} + +func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error { + switch i.Format { + case imaging.JPEG: + + var rgba *image.RGBA + quality := conf.Quality + + if nrgba, ok := img.(*image.NRGBA); ok { + if nrgba.Opaque() { + rgba = &image.RGBA{ + Pix: nrgba.Pix, + Stride: nrgba.Stride, + Rect: nrgba.Rect, + } + } + } + if rgba != nil { + return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality}) + } + return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) + default: + return imaging.Encode(w, img, i.Format) + } +} + +// Height returns i's height. +func (i *Image) Height() int { + i.initConfig() + return i.config.Height +} + +// Width returns i's width. +func (i *Image) Width() int { + i.initConfig() + return i.config.Width +} + +func (i Image) WithImage(img image.Image) *Image { + i.Spec = nil + i.imageConfig = &imageConfig{ + config: imageConfigFromImage(img), + configLoaded: true, + } + + return &i +} + +func (i Image) WithSpec(s Spec) *Image { + i.Spec = s + i.imageConfig = &imageConfig{} + return &i +} + +func (i *Image) initConfig() error { + var err error + i.configInit.Do(func() { + if i.configLoaded { + return + } + + var ( + f hugio.ReadSeekCloser + config image.Config + ) + + f, err = i.Spec.ReadSeekCloser() + if err != nil { + return + } + defer f.Close() + + config, _, err = image.DecodeConfig(f) + if err != nil { + return + } + i.config = config + }) + + if err != nil { + return errors.Wrap(err, "failed to load image config") + } + + return nil +} + +type ImageProcessor struct { + Cfg Imaging +} + +func (p *ImageProcessor) Fill(src image.Image, conf ImageConfig) (image.Image, error) { + if conf.AnchorStr == SmartCropIdentifier { + return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter) + } + return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil +} + +func (p *ImageProcessor) Fit(src image.Image, conf ImageConfig) (image.Image, error) { + return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil +} + +func (p *ImageProcessor) Resize(src image.Image, conf ImageConfig) (image.Image, error) { + return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil +} + +type Spec interface { + // Loads the image source. + ReadSeekCloser() (hugio.ReadSeekCloser, error) +} + +type imageConfig struct { + config image.Config + configInit sync.Once + configLoaded bool +} + +func imageConfigFromImage(img image.Image) image.Config { + b := img.Bounds() + return image.Config{Width: b.Max.X, Height: b.Max.Y} +} diff --git a/resources/smartcrop.go b/resources/images/smartcrop.go similarity index 96% rename from resources/smartcrop.go rename to resources/images/smartcrop.go index d28a8dd03..0b35b8280 100644 --- a/resources/smartcrop.go +++ b/resources/images/smartcrop.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resources +package images import ( "image" @@ -22,13 +22,18 @@ import ( const ( // Do not change. - smartCropIdentifier = "smart" + // TODO(bep) image unexport + SmartCropIdentifier = "smart" // This is just a increment, starting on 1. If Smart Crop improves its cropping, we // need a way to trigger a re-generation of the crops in the wild, so increment this. smartCropVersionNumber = 1 ) +func newSmartCropAnalyzer(filter imaging.ResampleFilter) smartcrop.Analyzer { + return smartcrop.NewAnalyzer(imagingResizer{filter: filter}) +} + // Needed by smartcrop type imagingResizer struct { filter imaging.ResampleFilter @@ -38,12 +43,7 @@ func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image return imaging.Resize(img, int(width), int(height), r.filter) } -func newSmartCropAnalyzer(filter imaging.ResampleFilter) smartcrop.Analyzer { - return smartcrop.NewAnalyzer(imagingResizer{filter: filter}) -} - func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter imaging.ResampleFilter) (*image.NRGBA, error) { - if width <= 0 || height <= 0 { return &image.NRGBA{}, nil } @@ -63,7 +63,6 @@ func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter smart := newSmartCropAnalyzer(filter) rect, err := smart.FindBestCrop(img, width, height) - if err != nil { return nil, err } @@ -73,5 +72,4 @@ func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter cropped := imaging.Crop(img, b) return imaging.Resize(cropped, width, height, filter), nil - } diff --git a/resources/internal/key.go b/resources/internal/key.go new file mode 100644 index 000000000..3dce8b350 --- /dev/null +++ b/resources/internal/key.go @@ -0,0 +1,61 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "strconv" + + bp "github.com/gohugoio/hugo/bufferpool" + + "github.com/mitchellh/hashstructure" +) + +// ResourceTransformationKey are provided by the different transformation implementations. +// It identifies the transformation (name) and its configuration (elements). +// We combine this in a chain with the rest of the transformations +// with the target filename and a content hash of the origin to use as cache key. +type ResourceTransformationKey struct { + Name string + elements []interface{} +} + +// NewResourceTransformationKey creates a new ResourceTransformationKey from the transformation +// name and elements. We will create a 64 bit FNV hash from the elements, which when combined +// with the other key elements should be unique for all practical applications. +func NewResourceTransformationKey(name string, elements ...interface{}) ResourceTransformationKey { + return ResourceTransformationKey{Name: name, elements: elements} +} + +// Value returns the Key as a string. +// Do not change this without good reasons. +func (k ResourceTransformationKey) Value() string { + if len(k.elements) == 0 { + return k.Name + } + + sb := bp.GetBuffer() + defer bp.PutBuffer(sb) + + sb.WriteString(k.Name) + for _, element := range k.elements { + hash, err := hashstructure.Hash(element, nil) + if err != nil { + panic(err) + } + sb.WriteString("_") + sb.WriteString(strconv.FormatUint(hash, 10)) + } + + return sb.String() +} diff --git a/resources/internal/key_test.go b/resources/internal/key_test.go new file mode 100644 index 000000000..9b6a23d87 --- /dev/null +++ b/resources/internal/key_test.go @@ -0,0 +1,36 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +type testStruct struct { + Name string + V1 int64 + V2 int32 + V3 int + V4 uint64 +} + +func TestResourceTransformationKey(t *testing.T) { + // We really need this key to be portable across OSes. + key := NewResourceTransformationKey("testing", + testStruct{Name: "test", V1: int64(10), V2: int32(20), V3: 30, V4: uint64(40)}) + c := qt.New(t) + c.Assert("testing_518996646957295636", qt.Equals, key.Value()) +} diff --git a/resources/resource.go b/resources/resource.go index 92bcbd0fc..3859e6044 100644 --- a/resources/resource.go +++ b/resources/resource.go @@ -17,30 +17,23 @@ import ( "fmt" "io" "io/ioutil" - "mime" "os" "path" "path/filepath" - "strings" "sync" "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/source" - "github.com/gohugoio/hugo/output" - "github.com/gohugoio/hugo/tpl" "github.com/pkg/errors" - "github.com/gohugoio/hugo/cache/filecache" - "github.com/gohugoio/hugo/common/collections" "github.com/gohugoio/hugo/common/hugio" - "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/afero" "github.com/gohugoio/hugo/helpers" - "github.com/gohugoio/hugo/source" ) var ( @@ -51,80 +44,10 @@ var ( _ resource.Cloner = (*genericResource)(nil) _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) _ permalinker = (*genericResource)(nil) - _ collections.Slicer = (*genericResource)(nil) _ resource.Identifier = (*genericResource)(nil) + _ fileInfo = (*genericResource)(nil) ) -var noData = make(map[string]interface{}) - -type permalinker interface { - relPermalinkFor(target string) string - permalinkFor(target string) string - relTargetPathsFor(target string) []string - relTargetPaths() []string - TargetPath() string -} - -type Spec struct { - *helpers.PathSpec - - MediaTypes media.Types - OutputFormats output.Formats - - Logger *loggers.Logger - - TextTemplates tpl.TemplateParseFinder - - Permalinks page.PermalinkExpander - - // Holds default filter settings etc. - imaging *Imaging - - imageCache *imageCache - ResourceCache *ResourceCache - FileCaches filecache.Caches -} - -func NewSpec( - s *helpers.PathSpec, - fileCaches filecache.Caches, - logger *loggers.Logger, - outputFormats output.Formats, - mimeTypes media.Types) (*Spec, error) { - - imaging, err := decodeImaging(s.Cfg.GetStringMap("imaging")) - if err != nil { - return nil, err - } - - if logger == nil { - logger = loggers.NewErrorLogger() - } - - permalinks, err := page.NewPermalinkExpander(s) - if err != nil { - return nil, err - } - - rs := &Spec{PathSpec: s, - Logger: logger, - imaging: &imaging, - MediaTypes: mimeTypes, - OutputFormats: outputFormats, - Permalinks: permalinks, - FileCaches: fileCaches, - imageCache: newImageCache( - fileCaches.ImageCache(), - - s, - )} - - rs.ResourceCache = newResourceCache(rs) - - return rs, nil - -} - type ResourceSourceDescriptor struct { // TargetPaths is a callback to fetch paths's relative to its owner. TargetPaths func() page.TargetPaths @@ -161,268 +84,56 @@ func (r ResourceSourceDescriptor) Filename() string { return r.SourceFilename } -func (r *Spec) New(fd ResourceSourceDescriptor) (resource.Resource, error) { - return r.newResourceFor(fd) +type ResourceTransformer interface { + resource.Resource + Transformer } -func (r *Spec) newResourceFor(fd ResourceSourceDescriptor) (resource.Resource, error) { - if fd.OpenReadSeekCloser == nil { - if fd.SourceFile != nil && fd.SourceFilename != "" { - return nil, errors.New("both SourceFile and AbsSourceFilename provided") - } else if fd.SourceFile == nil && fd.SourceFilename == "" { - return nil, errors.New("either SourceFile or AbsSourceFilename must be provided") - } - } - - if fd.RelTargetFilename == "" { - fd.RelTargetFilename = fd.Filename() - } - - if len(fd.TargetBasePaths) == 0 { - // If not set, we publish the same resource to all hosts. - fd.TargetBasePaths = r.MultihostTargetBasePaths - } - - return r.newResource(fd.Fs, fd) +type Transformer interface { + Transform(...ResourceTransformation) (ResourceTransformer, error) } -func (r *Spec) newResource(sourceFs afero.Fs, fd ResourceSourceDescriptor) (resource.Resource, error) { - fi := fd.FileInfo - var sourceFilename string - - if fd.OpenReadSeekCloser != nil { - } else if fd.SourceFilename != "" { - var err error - fi, err = sourceFs.Stat(fd.SourceFilename) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - sourceFilename = fd.SourceFilename - } else { - sourceFilename = fd.SourceFile.Filename() - } - - if fd.RelTargetFilename == "" { - fd.RelTargetFilename = sourceFilename - } - - ext := strings.ToLower(filepath.Ext(fd.RelTargetFilename)) - mimeType, found := r.MediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")) - // TODO(bep) we need to handle these ambigous types better, but in this context - // we most likely want the application/xml type. - if mimeType.Suffix() == "xml" && mimeType.SubType == "rss" { - mimeType, found = r.MediaTypes.GetByType("application/xml") - } - - if !found { - // A fallback. Note that mime.TypeByExtension is slow by Hugo standards, - // so we should configure media types to avoid this lookup for most - // situations. - mimeStr := mime.TypeByExtension(ext) - if mimeStr != "" { - mimeType, _ = media.FromStringAndExt(mimeStr, ext) - } - } - - gr := r.newGenericResourceWithBase( - sourceFs, - fd.LazyPublish, - fd.OpenReadSeekCloser, - fd.TargetBasePaths, - fd.TargetPaths, - fi, - sourceFilename, - fd.RelTargetFilename, - mimeType) - - if mimeType.MainType == "image" { - imgFormat, ok := imageFormats[ext] - if !ok { - // This allows SVG etc. to be used as resources. They will not have the methods of the Image, but - // that would not (currently) have worked. - return gr, nil - } - - if err := gr.initHash(); err != nil { - return nil, err - } - - return &Image{ - format: imgFormat, - imaging: r.imaging, - genericResource: gr}, nil - } - return gr, nil - +type baseResourceResource interface { + resource.Cloner + resource.ContentProvider + resource.Resource + resource.Identifier } -// TODO(bep) unify -func (r *Spec) IsInImageCache(key string) bool { - // This is used for cache pruning. We currently only have images, but we could - // imagine expanding on this. - return r.imageCache.isInCache(key) +type baseResourceInternal interface { + resource.Source + + fileInfo + metaAssigner + targetPather + + ReadSeekCloser() (hugio.ReadSeekCloser, error) + + // Internal + cloneWithUpdates(*transformationUpdate) (baseResource, error) + tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser + + specProvider + getResourcePaths() *resourcePathDescriptor + getTargetFilenames() []string + openDestinationsForWriting() (io.WriteCloser, error) + openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) + + relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string } -func (r *Spec) DeleteCacheByPrefix(prefix string) { - r.imageCache.deleteByPrefix(prefix) +type specProvider interface { + getSpec() *Spec } -func (r *Spec) ClearCaches() { - r.imageCache.clear() - r.ResourceCache.clear() -} - -func (r *Spec) CacheStats() string { - r.imageCache.mu.RLock() - defer r.imageCache.mu.RUnlock() - - s := fmt.Sprintf("Cache entries: %d", len(r.imageCache.store)) - - count := 0 - for k := range r.imageCache.store { - if count > 5 { - break - } - s += "\n" + k - count++ - } - - return s -} - -type dirFile struct { - // This is the directory component with Unix-style slashes. - dir string - // This is the file component. - file string -} - -func (d dirFile) path() string { - return path.Join(d.dir, d.file) -} - -type resourcePathDescriptor struct { - // The relative target directory and filename. - relTargetDirFile dirFile - - // Callback used to construct a target path relative to its owner. - targetPathBuilder func() page.TargetPaths - - // This will normally be the same as above, but this will only apply to publishing - // of resources. It may be mulltiple values when in multihost mode. - baseTargetPathDirs []string - - // baseOffset is set when the output format's path has a offset, e.g. for AMP. - baseOffset string -} - -type resourceContent struct { - content string - contentInit sync.Once -} - -type resourceHash struct { - hash string - hashInit sync.Once -} - -type publishOnce struct { - publisherInit sync.Once - publisherErr error - logger *loggers.Logger -} - -func (l *publishOnce) publish(s resource.Source) error { - l.publisherInit.Do(func() { - l.publisherErr = s.Publish() - if l.publisherErr != nil { - l.logger.ERROR.Printf("failed to publish Resource: %s", l.publisherErr) - } - }) - return l.publisherErr -} - -// genericResource represents a generic linkable resource. -type genericResource struct { - commonResource - resourcePathDescriptor - - title string - name string - params map[string]interface{} - - // Absolute filename to the source, including any content folder path. - // Note that this is absolute in relation to the filesystem it is stored in. - // It can be a base path filesystem, and then this filename will not match - // the path to the file on the real filesystem. - sourceFilename string - - // Will be set if this resource is backed by something other than a file. - openReadSeekerCloser resource.OpenReadSeekCloser - - // A hash of the source content. Is only calculated in caching situations. - *resourceHash - - // This may be set to tell us to look in another filesystem for this resource. - // We, by default, use the sourceFs filesystem in the spec below. - sourceFs afero.Fs - - spec *Spec - - resourceType string - mediaType media.Type - - osFileInfo os.FileInfo - - // We create copies of this struct, so this needs to be a pointer. - *resourceContent - - // May be set to signal lazy/delayed publishing. - *publishOnce +type baseResource interface { + baseResourceResource + baseResourceInternal } type commonResource struct { } -func (l *genericResource) Data() interface{} { - return noData -} - -func (l *genericResource) Content() (interface{}, error) { - if err := l.initContent(); err != nil { - return nil, err - } - - return l.content, nil -} - -func (l *genericResource) ReadSeekCloser() (hugio.ReadSeekCloser, error) { - if l.openReadSeekerCloser != nil { - return l.openReadSeekerCloser() - } - - f, err := l.getSourceFs().Open(l.sourceFilename) - if err != nil { - return nil, err - } - return f, nil - -} - -func (l *genericResource) MediaType() media.Type { - return l.mediaType -} - -// Implement the Cloner interface. -func (l genericResource) WithNewBase(base string) resource.Resource { - l.baseOffset = base - l.resourceContent = &resourceContent{} - return &l -} - // Slice is not meant to be used externally. It's a bridge function // for the template functions. See collections.Slice. func (commonResource) Slice(in interface{}) (interface{}, error) { @@ -437,6 +148,8 @@ func (commonResource) Slice(in interface{}) (interface{}, error) { return nil, fmt.Errorf("type %T is not a Resource", v) } groups[i] = g + { + } } return groups, nil default: @@ -444,29 +157,130 @@ func (commonResource) Slice(in interface{}) (interface{}, error) { } } -func (l *genericResource) initHash() error { - var err error - l.hashInit.Do(func() { - var hash string - var f hugio.ReadSeekCloser - f, err = l.ReadSeekCloser() - if err != nil { - err = errors.Wrap(err, "failed to open source file") - return - } - defer f.Close() +type dirFile struct { + // This is the directory component with Unix-style slashes. + dir string + // This is the file component. + file string +} - hash, err = helpers.MD5FromFileFast(f) - if err != nil { - return - } - l.hash = hash +func (d dirFile) path() string { + return path.Join(d.dir, d.file) +} - }) +type fileInfo interface { + getSourceFilename() string + setSourceFilename(string) + setSourceFs(afero.Fs) + hash() (string, error) + size() int +} +// genericResource represents a generic linkable resource. +type genericResource struct { + *resourcePathDescriptor + *resourceFileInfo + *resourceContent + + spec *Spec + + title string + name string + params map[string]interface{} + data map[string]interface{} + + resourceType string + mediaType media.Type +} + +func (l *genericResource) Clone() resource.Resource { + return l.clone() +} + +func (l *genericResource) Content() (interface{}, error) { + if err := l.initContent(); err != nil { + return nil, err + } + + return l.content, nil +} + +func (l *genericResource) Data() interface{} { + return l.data +} + +func (l *genericResource) Key() string { + return l.relTargetDirFile.path() +} + +func (l *genericResource) MediaType() media.Type { + return l.mediaType +} + +func (l *genericResource) Name() string { + return l.name +} + +func (l *genericResource) Params() map[string]interface{} { + return l.params +} + +func (l *genericResource) Permalink() string { + return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(l.relTargetDirFile.path(), true), l.spec.BaseURL.HostURL()) +} + +func (l *genericResource) Publish() error { + fr, err := l.ReadSeekCloser() + if err != nil { + return err + } + defer fr.Close() + + fw, err := helpers.OpenFilesForWriting(l.spec.BaseFs.PublishFs, l.getTargetFilenames()...) + if err != nil { + return err + } + defer fw.Close() + + _, err = io.Copy(fw, fr) return err } +func (l *genericResource) RelPermalink() string { + return l.relPermalinkFor(l.relTargetDirFile.path()) +} + +func (l *genericResource) ResourceType() string { + return l.resourceType +} + +func (l *genericResource) String() string { + return fmt.Sprintf("Resource(%s: %s)", l.resourceType, l.name) +} + +// Path is stored with Unix style slashes. +func (l *genericResource) TargetPath() string { + return l.relTargetDirFile.path() +} + +func (l *genericResource) Title() string { + return l.title +} + +func (l *genericResource) createBasePath(rel string, isURL bool) string { + if l.targetPathBuilder == nil { + return rel + } + tp := l.targetPathBuilder() + + if isURL { + return path.Join(tp.SubResourceBaseLink, rel) + } + + // TODO(bep) path + return path.Join(filepath.ToSlash(tp.SubResourceBaseTarget), rel) +} + func (l *genericResource) initContent() error { var err error l.contentInit.Do(func() { @@ -484,100 +298,141 @@ func (l *genericResource) initContent() error { } l.content = string(b) - }) return err } -func (l *genericResource) getSourceFs() afero.Fs { - return l.sourceFs -} - -func (l *genericResource) publishIfNeeded() { - if l.publishOnce != nil { - l.publishOnce.publish(l) - } -} - -func (l *genericResource) Permalink() string { - l.publishIfNeeded() - return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(l.relTargetDirFile.path(), true), l.spec.BaseURL.HostURL()) -} - -func (l *genericResource) RelPermalink() string { - l.publishIfNeeded() - return l.relPermalinkFor(l.relTargetDirFile.path()) -} - -func (l *genericResource) Key() string { - return l.relTargetDirFile.path() -} - -func (l *genericResource) relPermalinkFor(target string) string { - return l.relPermalinkForRel(target, false) - -} -func (l *genericResource) permalinkFor(target string) string { - return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(target, true), l.spec.BaseURL.HostURL()) - -} -func (l *genericResource) relTargetPathsFor(target string) []string { - return l.relTargetPathsForRel(target) -} - -func (l *genericResource) relTargetPaths() []string { - return l.relTargetPathsForRel(l.TargetPath()) -} - -func (l *genericResource) Name() string { - return l.name -} - -func (l *genericResource) Title() string { - return l.title -} - -func (l *genericResource) Params() map[string]interface{} { - return l.params -} - -func (l *genericResource) setTitle(title string) { - l.title = title -} - func (l *genericResource) setName(name string) { l.name = name } -func (l *genericResource) updateParams(params map[string]interface{}) { - if l.params == nil { - l.params = params +func (l *genericResource) getResourcePaths() *resourcePathDescriptor { + return l.resourcePathDescriptor +} + +func (l *genericResource) getSpec() *Spec { + return l.spec +} + +func (l *genericResource) getTargetFilenames() []string { + paths := l.relTargetPaths() + for i, p := range paths { + paths[i] = filepath.Clean(p) + } + return paths +} + +func (l *genericResource) setTitle(title string) { + l.title = title +} + +func (r *genericResource) tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser { + fi, f, meta, found := r.spec.ResourceCache.getFromFile(key) + if !found { + return nil + } + u.sourceFilename = &fi.Name + mt, _ := r.spec.MediaTypes.GetByType(meta.MediaTypeV) + u.mediaType = mt + u.data = meta.MetaData + u.targetPath = meta.Target + return f +} + +func (r *genericResource) mergeData(in map[string]interface{}) { + if len(in) == 0 { return } - - // Sets the params not already set - for k, v := range params { - if _, found := l.params[k]; !found { - l.params[k] = v + if r.data == nil { + r.data = make(map[string]interface{}) + } + for k, v := range in { + if _, found := r.data[k]; !found { + r.data[k] = v } } } -func (l *genericResource) relPermalinkForRel(rel string, isAbs bool) string { - return l.spec.PathSpec.URLizeFilename(l.relTargetPathForRel(rel, false, isAbs, true)) +func (rc *genericResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) { + r := rc.clone() + + if u.content != nil { + r.contentInit.Do(func() { + r.content = *u.content + r.openReadSeekerCloser = func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromString(r.content), nil + } + }) + } + + r.mediaType = u.mediaType + + if u.sourceFilename != nil { + r.setSourceFilename(*u.sourceFilename) + } + + if u.sourceFs != nil { + r.setSourceFs(u.sourceFs) + } + + if u.targetPath == "" { + return nil, errors.New("missing targetPath") + } + + fpath, fname := path.Split(u.targetPath) + r.resourcePathDescriptor.relTargetDirFile = dirFile{dir: fpath, file: fname} + + r.mergeData(u.data) + + return r, nil } -func (l *genericResource) relTargetPathsForRel(rel string) []string { - if len(l.baseTargetPathDirs) == 0 { - return []string{l.relTargetPathForRelAndBasePath(rel, "", false, false)} +func (l genericResource) clone() *genericResource { + gi := *l.resourceFileInfo + rp := *l.resourcePathDescriptor + l.resourceFileInfo = &gi + l.resourcePathDescriptor = &rp + l.resourceContent = &resourceContent{} + return &l +} + +// returns an opened file or nil if nothing to write. +func (l *genericResource) openDestinationsForWriting() (io.WriteCloser, error) { + targetFilenames := l.getTargetFilenames() + var changedFilenames []string + + // Fast path: + // This is a processed version of the original; + // check if it already existis at the destination. + for _, targetFilename := range targetFilenames { + if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil { + continue + } + changedFilenames = append(changedFilenames, targetFilename) } - var targetPaths = make([]string, len(l.baseTargetPathDirs)) - for i, dir := range l.baseTargetPathDirs { - targetPaths[i] = l.relTargetPathForRelAndBasePath(rel, dir, false, false) + if len(changedFilenames) == 0 { + return nil, nil } - return targetPaths + + return helpers.OpenFilesForWriting(l.getSpec().BaseFs.PublishFs, changedFilenames...) +} + +func (r *genericResource) openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) { + return helpers.OpenFilesForWriting(r.spec.BaseFs.PublishFs, r.relTargetPathsFor(relTargetPath)...) +} + +func (l *genericResource) permalinkFor(target string) string { + return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(target, true), l.spec.BaseURL.HostURL()) +} + +func (l *genericResource) relPermalinkFor(target string) string { + return l.relPermalinkForRel(target, false) +} + +func (l *genericResource) relPermalinkForRel(rel string, isAbs bool) string { + return l.spec.PathSpec.URLizeFilename(l.relTargetPathForRel(rel, false, isAbs, true)) } func (l *genericResource) relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string { @@ -592,20 +447,6 @@ func (l *genericResource) relTargetPathForRel(rel string, addBaseTargetPath, isA return l.relTargetPathForRelAndBasePath(rel, basePath, isAbs, isURL) } -func (l *genericResource) createBasePath(rel string, isURL bool) string { - if l.targetPathBuilder == nil { - return rel - } - tp := l.targetPathBuilder() - - if isURL { - return path.Join(tp.SubResourceBaseLink, rel) - } - - // TODO(bep) path - return path.Join(filepath.ToSlash(tp.SubResourceBaseTarget), rel) -} - func (l *genericResource) relTargetPathForRelAndBasePath(rel, basePath string, isAbs, isURL bool) string { rel = l.createBasePath(rel, isURL) @@ -631,117 +472,153 @@ func (l *genericResource) relTargetPathForRelAndBasePath(rel, basePath string, i return rel } -func (l *genericResource) ResourceType() string { - return l.resourceType +func (l *genericResource) relTargetPaths() []string { + return l.relTargetPathsForRel(l.TargetPath()) } -func (l *genericResource) String() string { - return fmt.Sprintf("Resource(%s: %s)", l.resourceType, l.name) +func (l *genericResource) relTargetPathsFor(target string) []string { + return l.relTargetPathsForRel(target) } -func (l *genericResource) Publish() error { - fr, err := l.ReadSeekCloser() +func (l *genericResource) relTargetPathsForRel(rel string) []string { + if len(l.baseTargetPathDirs) == 0 { + return []string{l.relTargetPathForRelAndBasePath(rel, "", false, false)} + } + + targetPaths := make([]string, len(l.baseTargetPathDirs)) + for i, dir := range l.baseTargetPathDirs { + targetPaths[i] = l.relTargetPathForRelAndBasePath(rel, dir, false, false) + } + return targetPaths +} + +func (l *genericResource) updateParams(params map[string]interface{}) { + if l.params == nil { + l.params = params + return + } + + // Sets the params not already set + for k, v := range params { + if _, found := l.params[k]; !found { + l.params[k] = v + } + } +} + +type targetPather interface { + TargetPath() string +} + +type permalinker interface { + targetPather + permalinkFor(target string) string + relPermalinkFor(target string) string + relTargetPaths() []string + relTargetPathsFor(target string) []string +} + +type resourceContent struct { + content string + contentInit sync.Once +} + +type resourceFileInfo struct { + // Will be set if this resource is backed by something other than a file. + openReadSeekerCloser resource.OpenReadSeekCloser + + // This may be set to tell us to look in another filesystem for this resource. + // We, by default, use the sourceFs filesystem in the spec below. + sourceFs afero.Fs + + // Absolute filename to the source, including any content folder path. + // Note that this is absolute in relation to the filesystem it is stored in. + // It can be a base path filesystem, and then this filename will not match + // the path to the file on the real filesystem. + sourceFilename string + + fi os.FileInfo + + // A hash of the source content. Is only calculated in caching situations. + h *resourceHash +} + +func (fi *resourceFileInfo) ReadSeekCloser() (hugio.ReadSeekCloser, error) { + if fi.openReadSeekerCloser != nil { + return fi.openReadSeekerCloser() + } + + f, err := fi.getSourceFs().Open(fi.getSourceFilename()) if err != nil { - return err + return nil, err } - defer fr.Close() - - fw, err := helpers.OpenFilesForWriting(l.spec.BaseFs.PublishFs, l.targetFilenames()...) - if err != nil { - return err - } - defer fw.Close() - - _, err = io.Copy(fw, fr) - return err + return f, nil } -// Path is stored with Unix style slashes. -func (l *genericResource) TargetPath() string { - return l.relTargetDirFile.path() +func (fi *resourceFileInfo) getSourceFilename() string { + return fi.sourceFilename } -func (l *genericResource) targetFilenames() []string { - paths := l.relTargetPaths() - for i, p := range paths { - paths[i] = filepath.Clean(p) - } - return paths +func (fi *resourceFileInfo) setSourceFilename(s string) { + // Make sure it's always loaded by sourceFilename. + fi.openReadSeekerCloser = nil + fi.sourceFilename = s } -// TODO(bep) clean up below -func (r *Spec) newGenericResource(sourceFs afero.Fs, - targetPathBuilder func() page.TargetPaths, - osFileInfo os.FileInfo, - sourceFilename, - baseFilename string, - mediaType media.Type) *genericResource { - return r.newGenericResourceWithBase( - sourceFs, - false, - nil, - nil, - targetPathBuilder, - osFileInfo, - sourceFilename, - baseFilename, - mediaType, - ) - +func (fi *resourceFileInfo) getSourceFs() afero.Fs { + return fi.sourceFs } -func (r *Spec) newGenericResourceWithBase( - sourceFs afero.Fs, - lazyPublish bool, - openReadSeekerCloser resource.OpenReadSeekCloser, - targetPathBaseDirs []string, - targetPathBuilder func() page.TargetPaths, - osFileInfo os.FileInfo, - sourceFilename, - baseFilename string, - mediaType media.Type) *genericResource { - - if osFileInfo != nil && osFileInfo.IsDir() { - panic(fmt.Sprintf("dirs not supported resource types: %v", osFileInfo)) - } - - // This value is used both to construct URLs and file paths, but start - // with a Unix-styled path. - baseFilename = helpers.ToSlashTrimLeading(baseFilename) - fpath, fname := path.Split(baseFilename) - - var resourceType string - if mediaType.MainType == "image" { - resourceType = mediaType.MainType - } else { - resourceType = mediaType.SubType - } - - pathDescriptor := resourcePathDescriptor{ - baseTargetPathDirs: helpers.UniqueStringsReuse(targetPathBaseDirs), - targetPathBuilder: targetPathBuilder, - relTargetDirFile: dirFile{dir: fpath, file: fname}, - } - - var po *publishOnce - if lazyPublish { - po = &publishOnce{logger: r.Logger} - } - - return &genericResource{ - openReadSeekerCloser: openReadSeekerCloser, - publishOnce: po, - resourcePathDescriptor: pathDescriptor, - sourceFs: sourceFs, - osFileInfo: osFileInfo, - sourceFilename: sourceFilename, - mediaType: mediaType, - resourceType: resourceType, - spec: r, - params: make(map[string]interface{}), - name: baseFilename, - title: baseFilename, - resourceContent: &resourceContent{}, - resourceHash: &resourceHash{}, - } +func (fi *resourceFileInfo) setSourceFs(fs afero.Fs) { + fi.sourceFs = fs +} + +func (fi *resourceFileInfo) hash() (string, error) { + var err error + fi.h.init.Do(func() { + var hash string + var f hugio.ReadSeekCloser + f, err = fi.ReadSeekCloser() + if err != nil { + err = errors.Wrap(err, "failed to open source file") + return + } + defer f.Close() + + hash, err = helpers.MD5FromFileFast(f) + if err != nil { + return + } + fi.h.value = hash + }) + + return fi.h.value, err +} + +func (fi *resourceFileInfo) size() int { + if fi.fi == nil { + return 0 + } + + return int(fi.fi.Size()) +} + +type resourceHash struct { + value string + init sync.Once +} + +type resourcePathDescriptor struct { + // The relative target directory and filename. + relTargetDirFile dirFile + + // Callback used to construct a target path relative to its owner. + targetPathBuilder func() page.TargetPaths + + // This will normally be the same as above, but this will only apply to publishing + // of resources. It may be mulltiple values when in multihost mode. + baseTargetPathDirs []string + + // baseOffset is set when the output format's path has a offset, e.g. for AMP. + baseOffset string } diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go index 5a5839735..32c76fc83 100644 --- a/resources/resource/resourcetypes.go +++ b/resources/resource/resourcetypes.go @@ -23,7 +23,7 @@ import ( // Cloner is an internal template and not meant for use in the templates. It // may change without notice. type Cloner interface { - WithNewBase(base string) Resource + Clone() Resource } // Resource represents a linkable resource, i.e. a content page, image etc. @@ -35,6 +35,20 @@ type Resource interface { ResourceDataProvider } +// Image represents an image resource. +type Image interface { + Resource + ImageOps +} + +type ImageOps interface { + Height() int + Width() int + Fill(spec string) (Image, error) + Fit(spec string) (Image, error) + Resize(spec string) (Image, error) +} + type ResourceTypesProvider interface { // MediaType is this resource's MIME type. MediaType() media.Type @@ -117,6 +131,10 @@ type OpenReadSeekCloser func() (hugio.ReadSeekCloser, error) // ReadSeekCloserResource is a Resource that supports loading its content. type ReadSeekCloserResource interface { MediaType() media.Type + ReadSeekCloserProvider +} + +type ReadSeekCloserProvider interface { ReadSeekCloser() (hugio.ReadSeekCloser, error) } diff --git a/resources/resource_cache.go b/resources/resource_cache.go index 8f6fcbc0f..47822a7f5 100644 --- a/resources/resource_cache.go +++ b/resources/resource_cache.go @@ -281,7 +281,7 @@ func (c *ResourceCache) DeletePartitions(partitions ...string) { for k := range c.cache { clear := false - for p, _ := range partitionsSet { + for p := range partitionsSet { if strings.Contains(k, p) { // There will be some false positive, but that's fine. clear = true diff --git a/resources/resource_metadata.go b/resources/resource_metadata.go index adb9d6867..79e61e1a0 100644 --- a/resources/resource_metadata.go +++ b/resources/resource_metadata.go @@ -29,9 +29,15 @@ import ( ) var ( - _ metaAssigner = (*genericResource)(nil) + _ metaAssigner = (*genericResource)(nil) + _ metaAssigner = (*imageResource)(nil) + _ metaAssignerProvider = (*resourceAdapter)(nil) ) +type metaAssignerProvider interface { + getMetaAssigner() metaAssigner +} + // metaAssigner allows updating metadata in resources that supports it. type metaAssigner interface { setTitle(title string) @@ -50,8 +56,15 @@ func AssignMetadata(metadata []map[string]interface{}, resources ...resource.Res counters := make(map[string]int) for _, r := range resources { - if _, ok := r.(metaAssigner); !ok { - continue + var ma metaAssigner + mp, ok := r.(metaAssignerProvider) + if ok { + ma = mp.getMetaAssigner() + } else { + ma, ok = r.(metaAssigner) + if !ok { + continue + } } var ( @@ -61,7 +74,6 @@ func AssignMetadata(metadata []map[string]interface{}, resources ...resource.Res resourceSrcKey = strings.ToLower(r.Name()) ) - ma := r.(metaAssigner) for _, meta := range metadata { src, found := meta["src"] if !found { diff --git a/resources/resource_metadata_test.go b/resources/resource_metadata_test.go index bededcd1b..c79a50021 100644 --- a/resources/resource_metadata_test.go +++ b/resources/resource_metadata_test.go @@ -24,7 +24,7 @@ import ( func TestAssignMetadata(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) var foo1, foo2, foo3, logo1, logo2, logo3 resource.Resource var resources resource.Resources diff --git a/resources/resource_spec.go b/resources/resource_spec.go new file mode 100644 index 000000000..528a2bd58 --- /dev/null +++ b/resources/resource_spec.go @@ -0,0 +1,304 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resources + +import ( + "errors" + "fmt" + "mime" + "os" + "path" + "path/filepath" + "strings" + + "github.com/gohugoio/hugo/helpers" + + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/resources/page" + "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/tpl" + "github.com/spf13/afero" +) + +func NewSpec( + s *helpers.PathSpec, + fileCaches filecache.Caches, + logger *loggers.Logger, + outputFormats output.Formats, + mimeTypes media.Types) (*Spec, error) { + + imgConfig, err := images.DecodeConfig(s.Cfg.GetStringMap("imaging")) + if err != nil { + return nil, err + } + + imaging := &images.ImageProcessor{Cfg: imgConfig} + + if logger == nil { + logger = loggers.NewErrorLogger() + } + + permalinks, err := page.NewPermalinkExpander(s) + if err != nil { + return nil, err + } + + rs := &Spec{PathSpec: s, + Logger: logger, + imaging: imaging, + MediaTypes: mimeTypes, + OutputFormats: outputFormats, + Permalinks: permalinks, + FileCaches: fileCaches, + imageCache: newImageCache( + fileCaches.ImageCache(), + + s, + )} + + rs.ResourceCache = newResourceCache(rs) + + return rs, nil + +} + +type Spec struct { + *helpers.PathSpec + + MediaTypes media.Types + OutputFormats output.Formats + + Logger *loggers.Logger + + TextTemplates tpl.TemplateParseFinder + + Permalinks page.PermalinkExpander + + // Holds default filter settings etc. + imaging *images.ImageProcessor + + imageCache *imageCache + ResourceCache *ResourceCache + FileCaches filecache.Caches +} + +func (r *Spec) New(fd ResourceSourceDescriptor) (resource.Resource, error) { + return r.newResourceFor(fd) +} + +func (r *Spec) CacheStats() string { + r.imageCache.mu.RLock() + defer r.imageCache.mu.RUnlock() + + s := fmt.Sprintf("Cache entries: %d", len(r.imageCache.store)) + + count := 0 + for k := range r.imageCache.store { + if count > 5 { + break + } + s += "\n" + k + count++ + } + + return s +} + +func (r *Spec) ClearCaches() { + r.imageCache.clear() + r.ResourceCache.clear() +} + +func (r *Spec) DeleteCacheByPrefix(prefix string) { + r.imageCache.deleteByPrefix(prefix) +} + +// TODO(bep) unify +func (r *Spec) IsInImageCache(key string) bool { + // This is used for cache pruning. We currently only have images, but we could + // imagine expanding on this. + return r.imageCache.isInCache(key) +} + +func (s *Spec) String() string { + return "spec" +} + +// TODO(bep) clean up below +func (r *Spec) newGenericResource(sourceFs afero.Fs, + targetPathBuilder func() page.TargetPaths, + osFileInfo os.FileInfo, + sourceFilename, + baseFilename string, + mediaType media.Type) *genericResource { + return r.newGenericResourceWithBase( + sourceFs, + nil, + nil, + targetPathBuilder, + osFileInfo, + sourceFilename, + baseFilename, + mediaType, + ) + +} + +func (r *Spec) newGenericResourceWithBase( + sourceFs afero.Fs, + openReadSeekerCloser resource.OpenReadSeekCloser, + targetPathBaseDirs []string, + targetPathBuilder func() page.TargetPaths, + osFileInfo os.FileInfo, + sourceFilename, + baseFilename string, + mediaType media.Type) *genericResource { + + if osFileInfo != nil && osFileInfo.IsDir() { + panic(fmt.Sprintf("dirs not supported resource types: %v", osFileInfo)) + } + + // This value is used both to construct URLs and file paths, but start + // with a Unix-styled path. + baseFilename = helpers.ToSlashTrimLeading(baseFilename) + fpath, fname := path.Split(baseFilename) + + var resourceType string + if mediaType.MainType == "image" { + resourceType = mediaType.MainType + } else { + resourceType = mediaType.SubType + } + + pathDescriptor := &resourcePathDescriptor{ + baseTargetPathDirs: helpers.UniqueStringsReuse(targetPathBaseDirs), + targetPathBuilder: targetPathBuilder, + relTargetDirFile: dirFile{dir: fpath, file: fname}, + } + + gfi := &resourceFileInfo{ + fi: osFileInfo, + openReadSeekerCloser: openReadSeekerCloser, + sourceFs: sourceFs, + sourceFilename: sourceFilename, + h: &resourceHash{}, + } + + g := &genericResource{ + resourceFileInfo: gfi, + resourcePathDescriptor: pathDescriptor, + mediaType: mediaType, + resourceType: resourceType, + spec: r, + params: make(map[string]interface{}), + name: baseFilename, + title: baseFilename, + resourceContent: &resourceContent{}, + } + + return g + +} + +func (r *Spec) newResource(sourceFs afero.Fs, fd ResourceSourceDescriptor) (resource.Resource, error) { + fi := fd.FileInfo + var sourceFilename string + + if fd.OpenReadSeekCloser != nil { + } else if fd.SourceFilename != "" { + var err error + fi, err = sourceFs.Stat(fd.SourceFilename) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + sourceFilename = fd.SourceFilename + } else { + sourceFilename = fd.SourceFile.Filename() + } + + if fd.RelTargetFilename == "" { + fd.RelTargetFilename = sourceFilename + } + + ext := strings.ToLower(filepath.Ext(fd.RelTargetFilename)) + mimeType, found := r.MediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")) + // TODO(bep) we need to handle these ambigous types better, but in this context + // we most likely want the application/xml type. + if mimeType.Suffix() == "xml" && mimeType.SubType == "rss" { + mimeType, found = r.MediaTypes.GetByType("application/xml") + } + + if !found { + // A fallback. Note that mime.TypeByExtension is slow by Hugo standards, + // so we should configure media types to avoid this lookup for most + // situations. + mimeStr := mime.TypeByExtension(ext) + if mimeStr != "" { + mimeType, _ = media.FromStringAndExt(mimeStr, ext) + } + } + + gr := r.newGenericResourceWithBase( + sourceFs, + fd.OpenReadSeekCloser, + fd.TargetBasePaths, + fd.TargetPaths, + fi, + sourceFilename, + fd.RelTargetFilename, + mimeType) + + if mimeType.MainType == "image" { + imgFormat, ok := images.ImageFormatFromExt(ext) + if ok { + ir := &imageResource{ + Image: images.NewImage(imgFormat, r.imaging, nil, gr), + baseResource: gr, + } + return newResourceAdapter(gr.spec, fd.LazyPublish, ir), nil + } + + } + + return newResourceAdapter(gr.spec, fd.LazyPublish, gr), nil + +} + +func (r *Spec) newResourceFor(fd ResourceSourceDescriptor) (resource.Resource, error) { + if fd.OpenReadSeekCloser == nil { + if fd.SourceFile != nil && fd.SourceFilename != "" { + return nil, errors.New("both SourceFile and AbsSourceFilename provided") + } else if fd.SourceFile == nil && fd.SourceFilename == "" { + return nil, errors.New("either SourceFile or AbsSourceFilename must be provided") + } + } + + if fd.RelTargetFilename == "" { + fd.RelTargetFilename = fd.Filename() + } + + if len(fd.TargetBasePaths) == 0 { + // If not set, we publish the same resource to all hosts. + fd.TargetBasePaths = r.MultihostTargetBasePaths + } + + return r.newResource(fd.Fs, fd) +} diff --git a/resources/resource_test.go b/resources/resource_test.go index b6d93c9a6..46391527d 100644 --- a/resources/resource_test.go +++ b/resources/resource_test.go @@ -32,7 +32,7 @@ import ( func TestGenericResource(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) r := spec.newGenericResource(nil, nil, nil, "/a/foo.css", "foo.css", media.CSSType) @@ -44,7 +44,7 @@ func TestGenericResource(t *testing.T) { func TestGenericResourceWithLinkFacory(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) factory := newTargetPaths("/foo") @@ -58,7 +58,7 @@ func TestGenericResourceWithLinkFacory(t *testing.T) { func TestNewResourceFromFilename(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) writeSource(t, spec.Fs, "content/a/b/logo.png", "image") writeSource(t, spec.Fs, "content/a/b/data.json", "json") @@ -79,14 +79,11 @@ func TestNewResourceFromFilename(t *testing.T) { c.Assert(r, qt.Not(qt.IsNil)) c.Assert(r.ResourceType(), qt.Equals, "json") - cloned := r.(resource.Cloner).WithNewBase("aceof") - c.Assert(cloned.ResourceType(), qt.Equals, r.ResourceType()) - c.Assert(cloned.RelPermalink(), qt.Equals, "/aceof/a/b/data.json") } func TestNewResourceFromFilenameSubPathInBaseURL(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpecForBaseURL(c, "https://example.com/docs") + spec := newTestResourceSpec(specDescriptor{c: c, baseURL: "https://example.com/docs"}) writeSource(t, spec.Fs, "content/a/b/logo.png", "image") bfs := afero.NewBasePathFs(spec.Fs.Source, "content") @@ -99,8 +96,6 @@ func TestNewResourceFromFilenameSubPathInBaseURL(t *testing.T) { c.Assert(r.ResourceType(), qt.Equals, "image") c.Assert(r.RelPermalink(), qt.Equals, "/docs/a/b/logo.png") c.Assert(r.Permalink(), qt.Equals, "https://example.com/docs/a/b/logo.png") - img := r.(*Image) - c.Assert(img.targetFilenames()[0], qt.Equals, filepath.FromSlash("/a/b/logo.png")) } @@ -108,7 +103,7 @@ var pngType, _ = media.FromStringAndExt("image/png", "png") func TestResourcesByType(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) resources := resource.Resources{ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), spec.newGenericResource(nil, nil, nil, "/a/logo.png", "logo.css", pngType), @@ -122,7 +117,7 @@ func TestResourcesByType(t *testing.T) { func TestResourcesGetByPrefix(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) resources := resource.Resources{ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType), @@ -151,7 +146,7 @@ func TestResourcesGetByPrefix(t *testing.T) { func TestResourcesGetMatch(t *testing.T) { c := qt.New(t) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) resources := resource.Resources{ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType), spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType), @@ -213,7 +208,7 @@ func BenchmarkResourcesMatch(b *testing.B) { // my own curiosity. func BenchmarkResourcesMatchA100(b *testing.B) { c := qt.New(b) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) a100 := strings.Repeat("a", 100) pattern := "a*a*a*a*a*a*a*a*b" @@ -228,7 +223,7 @@ func BenchmarkResourcesMatchA100(b *testing.B) { func benchResources(b *testing.B) resource.Resources { c := qt.New(b) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) var resources resource.Resources for i := 0; i < 30; i++ { @@ -252,7 +247,7 @@ func benchResources(b *testing.B) resource.Resources { func BenchmarkAssignMetadata(b *testing.B) { c := qt.New(b) - spec := newTestResourceSpec(c) + spec := newTestResourceSpec(specDescriptor{c: c}) for i := 0; i < b.N; i++ { b.StopTimer() diff --git a/resources/resource_transformers/htesting/testhelpers.go b/resources/resource_transformers/htesting/testhelpers.go new file mode 100644 index 000000000..eb664ed3a --- /dev/null +++ b/resources/resource_transformers/htesting/testhelpers.go @@ -0,0 +1,80 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package htesting + +import ( + "path/filepath" + + "github.com/gohugoio/hugo/cache/filecache" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/output" + "github.com/gohugoio/hugo/resources" + "github.com/spf13/afero" + "github.com/spf13/viper" +) + +func NewTestResourceSpec() (*resources.Spec, error) { + cfg := viper.New() + cfg.Set("baseURL", "https://example.org") + cfg.Set("publishDir", "public") + + imagingCfg := map[string]interface{}{ + "resampleFilter": "linear", + "quality": 68, + "anchor": "left", + } + + cfg.Set("imaging", imagingCfg) + + fs := hugofs.NewMem(cfg) + + s, err := helpers.NewPathSpec(fs, cfg, nil) + if err != nil { + return nil, err + } + + filecaches, err := filecache.NewCaches(s) + if err != nil { + return nil, err + } + + spec, err := resources.NewSpec(s, filecaches, nil, output.DefaultFormats, media.DefaultTypes) + return spec, err +} + +func NewResourceTransformer(filename, content string) (resources.ResourceTransformer, error) { + spec, err := NewTestResourceSpec() + if err != nil { + return nil, err + } + return NewResourceTransformerForSpec(spec, filename, content) +} + +func NewResourceTransformerForSpec(spec *resources.Spec, filename, content string) (resources.ResourceTransformer, error) { + filename = filepath.FromSlash(filename) + + fs := spec.Fs.Source + if err := afero.WriteFile(fs, filename, []byte(content), 0777); err != nil { + return nil, err + } + + r, err := spec.New(resources.ResourceSourceDescriptor{Fs: fs, SourceFilename: filename}) + if err != nil { + return nil, err + } + + return r.(resources.ResourceTransformer), nil +} diff --git a/resources/resource_transformers/integrity/integrity.go b/resources/resource_transformers/integrity/integrity.go index 95065603d..1b74de7eb 100644 --- a/resources/resource_transformers/integrity/integrity.go +++ b/resources/resource_transformers/integrity/integrity.go @@ -23,6 +23,8 @@ import ( "html/template" "io" + "github.com/gohugoio/hugo/resources/internal" + "github.com/pkg/errors" "github.com/gohugoio/hugo/resources" @@ -46,8 +48,8 @@ type fingerprintTransformation struct { algo string } -func (t *fingerprintTransformation) Key() resources.ResourceTransformationKey { - return resources.NewResourceTransformationKey("fingerprint", t.algo) +func (t *fingerprintTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("fingerprint", t.algo) } // Transform creates a MD5 hash of the Resource content and inserts that hash before @@ -59,7 +61,17 @@ func (t *fingerprintTransformation) Transform(ctx *resources.ResourceTransformat return err } - io.Copy(io.MultiWriter(h, ctx.To), ctx.From) + var w io.Writer + if rc, ok := ctx.From.(io.ReadSeeker); ok { + // This transformation does not change the content, so try to + // avoid writing to To if we can. + defer rc.Seek(0, 0) + w = h + } else { + w = io.MultiWriter(h, ctx.To) + } + + io.Copy(w, ctx.From) d, err := digest(h) if err != nil { return err @@ -91,15 +103,12 @@ func newHash(algo string) (hash.Hash, error) { // the base64-encoded Subresource Integrity hash, so you will have to stay away from // md5 if you plan to use both. // See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity -func (c *Client) Fingerprint(res resource.Resource, algo string) (resource.Resource, error) { +func (c *Client) Fingerprint(res resources.ResourceTransformer, algo string) (resource.Resource, error) { if algo == "" { algo = defaultHashAlgo } - return c.rs.Transform( - res, - &fingerprintTransformation{algo: algo}, - ) + return res.Transform(&fingerprintTransformation{algo: algo}) } func integrity(algo string, sum []byte) template.HTMLAttr { diff --git a/resources/resource_transformers/integrity/integrity_test.go b/resources/resource_transformers/integrity/integrity_test.go index cb1caa006..3759e6313 100644 --- a/resources/resource_transformers/integrity/integrity_test.go +++ b/resources/resource_transformers/integrity/integrity_test.go @@ -14,9 +14,13 @@ package integrity import ( + "html/template" "testing" + "github.com/gohugoio/hugo/resources/resource" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/resources/resource_transformers/htesting" ) func TestHashFromAlgo(t *testing.T) { @@ -46,3 +50,23 @@ func TestHashFromAlgo(t *testing.T) { }) } } + +func TestTransform(t *testing.T) { + c := qt.New(t) + + spec, err := htesting.NewTestResourceSpec() + c.Assert(err, qt.IsNil) + client := New(spec) + + r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.txt", "Hugo Rocks!") + c.Assert(err, qt.IsNil) + + transformed, err := client.Fingerprint(r, "") + + c.Assert(err, qt.IsNil) + c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.a5ad1c6961214a55de53c1ce6e60d27b6b761f54851fa65e33066460dfa6a0db.txt") + c.Assert(transformed.Data(), qt.DeepEquals, map[string]interface{}{"Integrity": template.HTMLAttr("sha256-pa0caWEhSlXeU8HObmDSe2t2H1SFH6ZeMwZkYN+moNs=")}) + content, err := transformed.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + c.Assert(content, qt.Equals, "Hugo Rocks!") +} diff --git a/resources/resource_transformers/minifier/minify.go b/resources/resource_transformers/minifier/minify.go index 952c6a99c..38e3fc93a 100644 --- a/resources/resource_transformers/minifier/minify.go +++ b/resources/resource_transformers/minifier/minify.go @@ -16,6 +16,7 @@ package minifier import ( "github.com/gohugoio/hugo/minifiers" "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" ) @@ -37,8 +38,8 @@ type minifyTransformation struct { m minifiers.Client } -func (t *minifyTransformation) Key() resources.ResourceTransformationKey { - return resources.NewResourceTransformationKey("minify") +func (t *minifyTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("minify") } func (t *minifyTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { @@ -49,11 +50,10 @@ func (t *minifyTransformation) Transform(ctx *resources.ResourceTransformationCt return nil } -func (c *Client) Minify(res resource.Resource) (resource.Resource, error) { - return c.rs.Transform( - res, - &minifyTransformation{ - rs: c.rs, - m: c.m}, - ) +func (c *Client) Minify(res resources.ResourceTransformer) (resource.Resource, error) { + return res.Transform(&minifyTransformation{ + rs: c.rs, + m: c.m, + }) + } diff --git a/resources/resource_transformers/minifier/minify_test.go b/resources/resource_transformers/minifier/minify_test.go new file mode 100644 index 000000000..3f8853520 --- /dev/null +++ b/resources/resource_transformers/minifier/minify_test.go @@ -0,0 +1,43 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package minifier + +import ( + "testing" + + "github.com/gohugoio/hugo/resources/resource" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/resources/resource_transformers/htesting" +) + +func TestTransform(t *testing.T) { + c := qt.New(t) + + spec, err := htesting.NewTestResourceSpec() + c.Assert(err, qt.IsNil) + client := New(spec) + + r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.html", "

Hugo Rocks!

") + c.Assert(err, qt.IsNil) + + transformed, err := client.Minify(r) + c.Assert(err, qt.IsNil) + + c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.min.html") + content, err := transformed.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + c.Assert(content, qt.Equals, "

Hugo Rocks!

") + +} diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go index 452627e65..f262a5c91 100644 --- a/resources/resource_transformers/postcss/postcss.go +++ b/resources/resource_transformers/postcss/postcss.go @@ -17,6 +17,7 @@ import ( "io" "path/filepath" + "github.com/gohugoio/hugo/resources/internal" "github.com/spf13/cast" "github.com/gohugoio/hugo/hugofs" @@ -98,8 +99,8 @@ type postcssTransformation struct { rs *resources.Spec } -func (t *postcssTransformation) Key() resources.ResourceTransformationKey { - return resources.NewResourceTransformationKey("postcss", t.options) +func (t *postcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("postcss", t.options) } // Transform shells out to postcss-cli to do the heavy lifting. @@ -187,9 +188,6 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC } // Process transforms the given Resource with the PostCSS processor. -func (c *Client) Process(res resource.Resource, options Options) (resource.Resource, error) { - return c.rs.Transform( - res, - &postcssTransformation{rs: c.rs, options: options}, - ) +func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { + return res.Transform(&postcssTransformation{rs: c.rs, options: options}) } diff --git a/resources/resource_transformers/templates/execute_as_template.go b/resources/resource_transformers/templates/execute_as_template.go index b3ec3cf43..422f1bbe1 100644 --- a/resources/resource_transformers/templates/execute_as_template.go +++ b/resources/resource_transformers/templates/execute_as_template.go @@ -17,6 +17,7 @@ package templates import ( "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/tpl" "github.com/pkg/errors" @@ -47,8 +48,8 @@ type executeAsTemplateTransform struct { data interface{} } -func (t *executeAsTemplateTransform) Key() resources.ResourceTransformationKey { - return resources.NewResourceTransformationKey("execute-as-template", t.targetPath) +func (t *executeAsTemplateTransform) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("execute-as-template", t.targetPath) } func (t *executeAsTemplateTransform) Transform(ctx *resources.ResourceTransformationCtx) error { @@ -63,14 +64,11 @@ func (t *executeAsTemplateTransform) Transform(ctx *resources.ResourceTransforma return templ.Execute(ctx.To, t.data) } -func (c *Client) ExecuteAsTemplate(res resource.Resource, targetPath string, data interface{}) (resource.Resource, error) { - return c.rs.Transform( - res, - &executeAsTemplateTransform{ - rs: c.rs, - targetPath: helpers.ToSlashTrimLeading(targetPath), - textTemplate: c.textTemplate, - data: data, - }, - ) +func (c *Client) ExecuteAsTemplate(res resources.ResourceTransformer, targetPath string, data interface{}) (resource.Resource, error) { + return res.Transform(&executeAsTemplateTransform{ + rs: c.rs, + targetPath: helpers.ToSlashTrimLeading(targetPath), + textTemplate: c.textTemplate, + data: data, + }) } diff --git a/resources/resource_transformers/tocss/scss/client.go b/resources/resource_transformers/tocss/scss/client.go index e69af2f74..ddf51f7fe 100644 --- a/resources/resource_transformers/tocss/scss/client.go +++ b/resources/resource_transformers/tocss/scss/client.go @@ -18,6 +18,7 @@ import ( "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/hugolib/filesystems" "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/afero" @@ -68,7 +69,7 @@ type options struct { to scss.Options } -func (c *Client) ToCSS(res resource.Resource, opts Options) (resource.Resource, error) { +func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) { internalOptions := options{ from: opts, } @@ -83,10 +84,7 @@ func (c *Client) ToCSS(res resource.Resource, opts Options) (resource.Resource, internalOptions.to.Precision = 8 } - return c.rs.Transform( - res, - &toCSSTransformation{c: c, options: internalOptions}, - ) + return res.Transform(&toCSSTransformation{c: c, options: internalOptions}) } type toCSSTransformation struct { @@ -94,8 +92,8 @@ type toCSSTransformation struct { options options } -func (t *toCSSTransformation) Key() resources.ResourceTransformationKey { - return resources.NewResourceTransformationKey("tocss", t.options.from) +func (t *toCSSTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("tocss", t.options.from) } func DecodeOptions(m map[string]interface{}) (opts Options, err error) { diff --git a/resources/testhelpers_test.go b/resources/testhelpers_test.go index 55664535c..adf752ecc 100644 --- a/resources/testhelpers_test.go +++ b/resources/testhelpers_test.go @@ -4,8 +4,6 @@ import ( "path/filepath" "testing" - "github.com/gohugoio/hugo/htesting/hqt" - "image" "io" "io/ioutil" @@ -28,8 +26,10 @@ import ( "github.com/spf13/viper" ) -func newTestResourceSpec(c *qt.C) *Spec { - return newTestResourceSpecForBaseURL(c, "https://example.com/") +type specDescriptor struct { + baseURL string + c *qt.C + fs afero.Fs } func createTestCfg() *viper.Viper { @@ -54,7 +54,20 @@ func createTestCfg() *viper.Viper { } -func newTestResourceSpecForBaseURL(c *qt.C, baseURL string) *Spec { +func newTestResourceSpec(desc specDescriptor) *Spec { + + baseURL := desc.baseURL + if baseURL == "" { + baseURL = "https://example.com/" + } + + afs := desc.fs + if afs == nil { + afs = afero.NewMemMapFs() + } + + c := desc.c + cfg := createTestCfg() cfg.Set("baseURL", baseURL) @@ -66,7 +79,8 @@ func newTestResourceSpecForBaseURL(c *qt.C, baseURL string) *Spec { cfg.Set("imaging", imagingCfg) - fs := hugofs.NewMem(cfg) + fs := hugofs.NewFrom(afs, cfg) + fs.Destination = hugofs.NewCreateCountingFs(fs.Destination) s, err := helpers.NewPathSpec(fs, cfg, nil) c.Assert(err, qt.IsNil) @@ -117,19 +131,23 @@ func newTestResourceOsFs(c *qt.C) *Spec { } -func fetchSunset(c *qt.C) *Image { +func fetchSunset(c *qt.C) resource.Image { return fetchImage(c, "sunset.jpg") } -func fetchImage(c *qt.C, name string) *Image { - spec := newTestResourceSpec(c) +func fetchImage(c *qt.C, name string) resource.Image { + spec := newTestResourceSpec(specDescriptor{c: c}) return fetchImageForSpec(spec, c, name) } - -func fetchImageForSpec(spec *Spec, c *qt.C, name string) *Image { +func fetchImageForSpec(spec *Spec, c *qt.C, name string) resource.Image { r := fetchResourceForSpec(spec, c, name) - c.Assert(r, hqt.IsSameType, &Image{}) - return r.(*Image) + + img := r.(resource.Image) + + c.Assert(img, qt.Not(qt.IsNil)) + c.Assert(img.(specProvider).getSpec(), qt.Not(qt.IsNil)) + + return img } func fetchResourceForSpec(spec *Spec, c *qt.C, name string) resource.ContentResource { diff --git a/resources/transform.go b/resources/transform.go index 379452bb7..72b9479df 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -15,45 +15,63 @@ package resources import ( "bytes" + "fmt" + "io" "path" - "strconv" "strings" + "sync" - "github.com/pkg/errors" + "github.com/spf13/afero" + + bp "github.com/gohugoio/hugo/bufferpool" + + "github.com/gohugoio/hugo/resources/internal" - "github.com/gohugoio/hugo/common/collections" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/resources/resource" - "github.com/mitchellh/hashstructure" - - "fmt" - "io" - "sync" "github.com/gohugoio/hugo/media" - - bp "github.com/gohugoio/hugo/bufferpool" ) var ( - _ resource.ContentResource = (*transformedResource)(nil) - _ resource.ReadSeekCloserResource = (*transformedResource)(nil) - _ collections.Slicer = (*transformedResource)(nil) - _ resource.Identifier = (*transformedResource)(nil) + _ resource.ContentResource = (*resourceAdapter)(nil) + _ resource.ReadSeekCloserResource = (*resourceAdapter)(nil) + _ resource.Resource = (*resourceAdapter)(nil) + _ resource.Source = (*resourceAdapter)(nil) + _ resource.Identifier = (*resourceAdapter)(nil) + _ resource.ResourceMetaProvider = (*resourceAdapter)(nil) ) -func (s *Spec) Transform(r resource.Resource, t ResourceTransformation) (resource.Resource, error) { - if r == nil { - return nil, errors.New("got nil Resource in transformation. Make sure you check with 'with' or 'if' when you get a resource, e.g. with resources.Get.") - } +// These are transformations that need special support in Hugo that may not +// be available when building the theme/site so we write the transformation +// result to disk and reuse if needed for these, +var transformationsToCacheOnDisk = map[string]bool{ + "postcss": true, + "tocss": true, +} - return &transformedResource{ - Resource: r, - transformation: t, - transformedResourceMetadata: transformedResourceMetadata{MetaData: make(map[string]interface{})}, - cache: s.ResourceCache}, nil +func newResourceAdapter(spec *Spec, lazyPublish bool, target transformableResource) *resourceAdapter { + var po *publishOnce + if lazyPublish { + po = &publishOnce{} + } + return &resourceAdapter{ + resourceTransformations: &resourceTransformations{}, + resourceAdapterInner: &resourceAdapterInner{ + spec: spec, + publishOnce: po, + target: target, + }, + } +} + +// ResourceTransformation is the interface that a resource transformation step +// needs to implement. +type ResourceTransformation interface { + Key() internal.ResourceTransformationKey + Transform(ctx *ResourceTransformationCtx) error } type ResourceTransformationCtx struct { @@ -95,20 +113,6 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) { ctx.OutPath = ctx.addPathIdentifier(ctx.InPath, identifier) } -func (ctx *ResourceTransformationCtx) addPathIdentifier(inPath, identifier string) string { - dir, file := path.Split(inPath) - base, ext := helpers.PathAndExt(file) - return path.Join(dir, (base + identifier + ext)) -} - -// ReplaceOutPathExtension transforming InPath to OutPath replacing the file -// extension, e.g. ".scss" -func (ctx *ResourceTransformationCtx) ReplaceOutPathExtension(newExt string) { - dir, file := path.Split(ctx.InPath) - base, _ := helpers.PathAndExt(file) - ctx.OutPath = path.Join(dir, (base + newExt)) -} - // PublishSourceMap writes the content to the target folder of the main resource // with the ".map" extension added. func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error { @@ -122,240 +126,198 @@ func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error { return err } -// ResourceTransformationKey are provided by the different transformation implementations. -// It identifies the transformation (name) and its configuration (elements). -// We combine this in a chain with the rest of the transformations -// with the target filename and a content hash of the origin to use as cache key. -type ResourceTransformationKey struct { - name string - elements []interface{} +// ReplaceOutPathExtension transforming InPath to OutPath replacing the file +// extension, e.g. ".scss" +func (ctx *ResourceTransformationCtx) ReplaceOutPathExtension(newExt string) { + dir, file := path.Split(ctx.InPath) + base, _ := helpers.PathAndExt(file) + ctx.OutPath = path.Join(dir, (base + newExt)) } -// NewResourceTransformationKey creates a new ResourceTransformationKey from the transformation -// name and elements. We will create a 64 bit FNV hash from the elements, which when combined -// with the other key elements should be unique for all practical applications. -func NewResourceTransformationKey(name string, elements ...interface{}) ResourceTransformationKey { - return ResourceTransformationKey{name: name, elements: elements} +func (ctx *ResourceTransformationCtx) addPathIdentifier(inPath, identifier string) string { + dir, file := path.Split(inPath) + base, ext := helpers.PathAndExt(file) + return path.Join(dir, (base + identifier + ext)) } -// Do not change this without good reasons. -func (k ResourceTransformationKey) key() string { - if len(k.elements) == 0 { - return k.name - } - - sb := bp.GetBuffer() - defer bp.PutBuffer(sb) - - sb.WriteString(k.name) - for _, element := range k.elements { - hash, err := hashstructure.Hash(element, nil) - if err != nil { - panic(err) - } - sb.WriteString("_") - sb.WriteString(strconv.FormatUint(hash, 10)) - } - - return sb.String() +type publishOnce struct { + publisherInit sync.Once + publisherErr error } -// ResourceTransformation is the interface that a resource transformation step -// needs to implement. -type ResourceTransformation interface { - Key() ResourceTransformationKey - Transform(ctx *ResourceTransformationCtx) error -} - -// We will persist this information to disk. -type transformedResourceMetadata struct { - Target string `json:"Target"` - MediaTypeV string `json:"MediaType"` - MetaData map[string]interface{} `json:"Data"` -} - -type transformedResource struct { +type resourceAdapter struct { commonResource - - cache *ResourceCache - - // This is the filename inside resources/_gen/assets - sourceFilename string - - linker permalinker - - // The transformation to apply. - transformation ResourceTransformation - - // We apply the tranformations lazily. - transformInit sync.Once - transformErr error - - // We delay publishing until either .RelPermalink or .Permalink - // is invoked. - publishInit sync.Once - published bool - - // The transformed values - content string - contentInit sync.Once - transformedResourceMetadata - - // The source - resource.Resource + *resourceTransformations + *resourceAdapterInner } -func (r *transformedResource) ReadSeekCloser() (hugio.ReadSeekCloser, error) { - if err := r.initContent(); err != nil { - return nil, err +func (r *resourceAdapter) Content() (interface{}, error) { + r.init(false, true) + if r.transformationsErr != nil { + return nil, r.transformationsErr } - return hugio.NewReadSeekerNoOpCloserFromString(r.content), nil + return r.target.Content() } -func (r *transformedResource) transferTransformedValues(another *transformedResource) { - if another.content != "" { - r.contentInit.Do(func() { - r.content = another.content - }) - } - r.transformedResourceMetadata = another.transformedResourceMetadata +func (r *resourceAdapter) Data() interface{} { + r.init(false, false) + return r.target.Data() } -func (r *transformedResource) tryTransformedFileCache(key string) io.ReadCloser { - fi, f, meta, found := r.cache.getFromFile(key) - if !found { - return nil - } - r.transformedResourceMetadata = meta - r.sourceFilename = fi.Name - - return f +func (r *resourceAdapter) Fill(spec string) (resource.Image, error) { + return r.getImageOps().Fill(spec) } -func (r *transformedResource) Content() (interface{}, error) { - if err := r.initTransform(true, false); err != nil { - return nil, err - } - if err := r.initContent(); err != nil { - return "", err - } - return r.content, nil +func (r *resourceAdapter) Fit(spec string) (resource.Image, error) { + return r.getImageOps().Fit(spec) } -func (r *transformedResource) Data() interface{} { - if err := r.initTransform(false, false); err != nil { - return noData - } - return r.MetaData +func (r *resourceAdapter) Height() int { + return r.getImageOps().Height() } -func (r *transformedResource) MediaType() media.Type { - if err := r.initTransform(false, false); err != nil { - return media.Type{} - } - m, _ := r.cache.rs.MediaTypes.GetByType(r.MediaTypeV) - return m +func (r *resourceAdapter) Key() string { + r.init(false, false) + return r.target.(resource.Identifier).Key() } -func (r *transformedResource) Key() string { - if err := r.initTransform(false, false); err != nil { - return "" - } - return r.linker.relPermalinkFor(r.Target) +func (r *resourceAdapter) MediaType() media.Type { + r.init(false, false) + return r.target.MediaType() } -func (r *transformedResource) Permalink() string { - if err := r.initTransform(false, true); err != nil { - return "" - } - return r.linker.permalinkFor(r.Target) +func (r *resourceAdapter) Name() string { + r.init(false, false) + return r.target.Name() } -func (r *transformedResource) RelPermalink() string { - if err := r.initTransform(false, true); err != nil { - return "" - } - return r.linker.relPermalinkFor(r.Target) +func (r *resourceAdapter) Params() map[string]interface{} { + r.init(false, false) + return r.target.Params() } -func (r *transformedResource) initContent() error { - var err error - r.contentInit.Do(func() { - var b []byte - _, b, err = r.cache.fileCache.GetBytes(r.sourceFilename) - if err != nil { - return +func (r *resourceAdapter) Permalink() string { + r.init(true, false) + return r.target.Permalink() +} + +func (r *resourceAdapter) Publish() error { + r.init(false, false) + + return r.target.Publish() +} + +func (r *resourceAdapter) ReadSeekCloser() (hugio.ReadSeekCloser, error) { + r.init(false, false) + return r.target.ReadSeekCloser() +} + +func (r *resourceAdapter) RelPermalink() string { + r.init(true, false) + return r.target.RelPermalink() +} + +func (r *resourceAdapter) Resize(spec string) (resource.Image, error) { + return r.getImageOps().Resize(spec) +} + +func (r *resourceAdapter) ResourceType() string { + r.init(false, false) + return r.target.ResourceType() +} + +func (r *resourceAdapter) String() string { + return r.Name() +} + +func (r *resourceAdapter) Title() string { + r.init(false, false) + return r.target.Title() +} + +func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransformer, error) { + r.resourceTransformations = &resourceTransformations{ + transformations: append(r.transformations, t...), + } + + r.resourceAdapterInner = &resourceAdapterInner{ + spec: r.spec, + publishOnce: &publishOnce{}, + target: r.target, + } + + return &r, nil +} + +func (r *resourceAdapter) Width() int { + return r.getImageOps().Width() +} + +func (r *resourceAdapter) getImageOps() resource.ImageOps { + img, ok := r.target.(resource.ImageOps) + if !ok { + panic(fmt.Sprintf("%T is not an image", r.target)) + } + r.init(false, false) + return img +} + +func (r *resourceAdapter) getMetaAssigner() metaAssigner { + return r.target +} + +func (r *resourceAdapter) getSpec() *Spec { + return r.spec +} + +func (r *resourceAdapter) publish() { + if r.publishOnce == nil { + return + } + + r.publisherInit.Do(func() { + r.publisherErr = r.target.Publish() + + if r.publisherErr != nil { + r.spec.Logger.ERROR.Printf("Failed to publish Resource: %s", r.publisherErr) } - r.content = string(b) }) - return err + } -func (r *transformedResource) openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) { - return helpers.OpenFilesForWriting(r.cache.rs.PublishFs, r.linker.relTargetPathsFor(relTargetPath)...) -} - -func (r *transformedResource) transform(setContent, publish bool) (err error) { - - // This can be the last resource in a chain. - // Rewind and create a processing chain. - var chain []resource.Resource - current := r - for { - rr := current.Resource - chain = append(chain[:0], append([]resource.Resource{rr}, chain[0:]...)...) - if tr, ok := rr.(*transformedResource); ok { - current = tr - } else { - break - } - } - - // Append the current transformer at the end - chain = append(chain, r) - - first := chain[0] +func (r *resourceAdapter) transform(publish, setContent bool) error { + cache := r.spec.ResourceCache // Files with a suffix will be stored in cache (both on disk and in memory) - // partitioned by their suffix. There will be other files below /other. - // This partition is also how we determine what to delete on server reloads. - var key, base string - for _, element := range chain { - switch v := element.(type) { - case *transformedResource: - key = key + "_" + v.transformation.Key().key() - case permalinker: - r.linker = v - p := v.TargetPath() - if p == "" { - panic("target path needed for key creation") - } - base = ResourceCacheKey(p) - default: - return fmt.Errorf("transformation not supported for type %T", element) - } + // partitioned by their suffix. + var key string + for _, tr := range r.transformations { + key = key + "_" + tr.Key().Value() } - key = r.cache.cleanKey(base) + "_" + helpers.MD5String(key) + base := ResourceCacheKey(r.target.TargetPath()) + + key = cache.cleanKey(base) + "_" + helpers.MD5String(key) + + cached, found := cache.get(key) - cached, found := r.cache.get(key) if found { - r.transferTransformedValues(cached.(*transformedResource)) - return + r.resourceAdapterInner = cached.(*resourceAdapterInner) + return nil } // Acquire a write lock for the named transformation. - r.cache.nlocker.Lock(key) + cache.nlocker.Lock(key) // Check the cache again. - cached, found = r.cache.get(key) + cached, found = cache.get(key) if found { - r.transferTransformedValues(cached.(*transformedResource)) - r.cache.nlocker.Unlock(key) - return + r.resourceAdapterInner = cached.(*resourceAdapterInner) + cache.nlocker.Unlock(key) + return nil } - defer r.cache.nlocker.Unlock(key) - defer r.cache.set(key, r) + defer cache.nlocker.Unlock(key) + defer cache.set(key, r.resourceAdapterInner) b1 := bp.GetBuffer() b2 := bp.GetBuffer() @@ -363,68 +325,77 @@ func (r *transformedResource) transform(setContent, publish bool) (err error) { defer bp.PutBuffer(b2) tctx := &ResourceTransformationCtx{ - Data: r.transformedResourceMetadata.MetaData, - OpenResourcePublisher: r.openPublishFileForWriting, + Data: make(map[string]interface{}), + OpenResourcePublisher: r.target.openPublishFileForWriting, } - tctx.InMediaType = first.MediaType() - tctx.OutMediaType = first.MediaType() + tctx.InMediaType = r.target.MediaType() + tctx.OutMediaType = r.target.MediaType() - contentrc, err := contentReadSeekerCloser(first) + startCtx := *tctx + updates := &transformationUpdate{startCtx: startCtx} + + var contentrc hugio.ReadSeekCloser + + contentrc, err := contentReadSeekerCloser(r.target) if err != nil { return err } + defer contentrc.Close() tctx.From = contentrc tctx.To = b1 - if r.linker != nil { - tctx.InPath = r.linker.TargetPath() - tctx.SourcePath = tctx.InPath - } + tctx.InPath = r.target.TargetPath() + tctx.SourcePath = tctx.InPath counter := 0 + writeToFileCache := false var transformedContentr io.Reader - for _, element := range chain { - tr, ok := element.(*transformedResource) - if !ok { - continue - } - counter++ - if counter != 1 { + for i, tr := range r.transformations { + if i != 0 { tctx.InMediaType = tctx.OutMediaType } - if counter%2 == 0 { - tctx.From = b1 - b2.Reset() - tctx.To = b2 - } else { - if counter != 1 { - // The first reader is the file. - tctx.From = b2 - } - b1.Reset() - tctx.To = b1 + + if !writeToFileCache { + writeToFileCache = transformationsToCacheOnDisk[tr.Key().Name] } - if err := tr.transformation.Transform(tctx); err != nil { + if i > 0 { + hasWrites := tctx.To.(*bytes.Buffer).Len() > 0 + if hasWrites { + counter++ + // Switch the buffers + if counter%2 == 0 { + tctx.From = b2 + b1.Reset() + tctx.To = b1 + } else { + tctx.From = b1 + b2.Reset() + tctx.To = b2 + } + } + } - if err == herrors.ErrFeatureNotAvailable { + if err = tr.Transform(tctx); err != nil { + if writeToFileCache && err == herrors.ErrFeatureNotAvailable { // This transformation is not available in this // Hugo installation (scss not compiled in, PostCSS not available etc.) // If a prepared bundle for this transformation chain is available, use that. - f := r.tryTransformedFileCache(key) + f := r.target.tryTransformedFileCache(key, updates) if f == nil { errMsg := err.Error() - if tr.transformation.Key().name == "postcss" { + if tr.Key().Name == "postcss" { errMsg = "PostCSS not found; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/" } - return fmt.Errorf("%s: failed to transform %q (%s): %s", strings.ToUpper(tr.transformation.Key().name), tctx.InPath, tctx.InMediaType.Type(), errMsg) + return fmt.Errorf("%s: failed to transform %q (%s): %s", strings.ToUpper(tr.Key().Name), tctx.InPath, tctx.InMediaType.Type(), errMsg) } transformedContentr = f + updates.sourceFs = cache.fileCache.Fs defer f.Close() // The reader above is all we need. @@ -442,34 +413,35 @@ func (r *transformedResource) transform(setContent, publish bool) (err error) { } if transformedContentr == nil { - r.Target = tctx.InPath - r.MediaTypeV = tctx.OutMediaType.Type() + updates.updateFromCtx(tctx) } var publishwriters []io.WriteCloser if publish { - publicw, err := r.openPublishFileForWriting(r.Target) + publicw, err := r.target.openPublishFileForWriting(updates.targetPath) if err != nil { - r.transformErr = err return err } - defer publicw.Close() - publishwriters = append(publishwriters, publicw) } if transformedContentr == nil { - // Also write it to the cache - fi, metaw, err := r.cache.writeMeta(key, r.transformedResourceMetadata) - if err != nil { - return err + if writeToFileCache { + // Also write it to the cache + fi, metaw, err := cache.writeMeta(key, updates.toTransformedResourceMetadata()) + if err != nil { + return err + } + updates.sourceFilename = &fi.Name + updates.sourceFs = cache.fileCache.Fs + publishwriters = append(publishwriters, metaw) } - r.sourceFilename = fi.Name - publishwriters = append(publishwriters, metaw) - - if counter > 0 { + // Any transofrmations reading from From must also write to To. + // This means that if the target buffer is empty, we can just reuse + // the original reader. + if b, ok := tctx.To.(*bytes.Buffer); ok && b.Len() > 0 { transformedContentr = tctx.To.(*bytes.Buffer) } else { transformedContentr = contentrc @@ -479,6 +451,8 @@ func (r *transformedResource) transform(setContent, publish bool) (err error) { // Also write it to memory var contentmemw *bytes.Buffer + setContent = setContent || !writeToFileCache + if setContent { contentmemw = bp.GetBuffer() defer bp.PutBuffer(contentmemw) @@ -486,65 +460,111 @@ func (r *transformedResource) transform(setContent, publish bool) (err error) { } publishw := hugio.NewMultiWriteCloser(publishwriters...) - _, r.transformErr = io.Copy(publishw, transformedContentr) + _, err = io.Copy(publishw, transformedContentr) + if err != nil { + return err + } publishw.Close() if setContent { - r.contentInit.Do(func() { - r.content = contentmemw.String() - }) + s := contentmemw.String() + updates.content = &s } + newTarget, err := r.target.cloneWithUpdates(updates) + if err != nil { + return err + } + r.target = newTarget + return nil } -func (r *transformedResource) initTransform(setContent, publish bool) error { - r.transformInit.Do(func() { - r.published = publish - if err := r.transform(setContent, publish); err != nil { - r.transformErr = err - r.cache.rs.Logger.ERROR.Println("error: failed to transform resource:", err) +func (r *resourceAdapter) init(publish, setContent bool) { + r.initTransform(publish, setContent) +} + +func (r *resourceAdapter) initTransform(publish, setContent bool) { + r.transformationsInit.Do(func() { + if len(r.transformations) == 0 { + // Nothing to do. + return } + if publish { + // The transformation will write the content directly to + // the destination. + r.publishOnce = nil + } + + r.transformationsErr = r.transform(publish, setContent) + if r.transformationsErr != nil { + r.spec.Logger.ERROR.Printf("Transformation failed: %s", r.transformationsErr) + } }) - if !publish { - return r.transformErr + if publish && r.publishOnce != nil { + r.publish() } +} - r.publishInit.Do(func() { - if r.published { - return - } +type resourceAdapterInner struct { + target transformableResource - r.published = true + spec *Spec - // Copy the file from cache to /public - _, src, err := r.cache.fileCache.Get(r.sourceFilename) - if src == nil { - panic(fmt.Sprintf("[BUG] resource cache file not found: %q", r.sourceFilename)) - } + // Handles publishing (to /public) if needed. + *publishOnce +} - if err == nil { - defer src.Close() +type resourceTransformations struct { + transformationsInit sync.Once + transformationsErr error + transformations []ResourceTransformation +} - var dst io.WriteCloser - dst, err = r.openPublishFileForWriting(r.Target) - if err == nil { - defer dst.Close() - io.Copy(dst, src) - } - } +type transformableResource interface { + baseResourceInternal - if err != nil { - r.transformErr = err - r.cache.rs.Logger.ERROR.Println("error: failed to publish resource:", err) - return - } + resource.ContentProvider + resource.Resource +} - }) +type transformationUpdate struct { + content *string + sourceFilename *string + sourceFs afero.Fs + targetPath string + mediaType media.Type + data map[string]interface{} - return r.transformErr + startCtx ResourceTransformationCtx +} + +func (u *transformationUpdate) isContenChanged() bool { + return u.content != nil || u.sourceFilename != nil +} + +func (u *transformationUpdate) toTransformedResourceMetadata() transformedResourceMetadata { + return transformedResourceMetadata{ + MediaTypeV: u.mediaType.Type(), + Target: u.targetPath, + MetaData: u.data, + } +} + +func (u *transformationUpdate) updateFromCtx(ctx *ResourceTransformationCtx) { + u.targetPath = ctx.OutPath + u.mediaType = ctx.OutMediaType + u.data = ctx.Data + u.targetPath = ctx.InPath +} + +// We will persist this information to disk. +type transformedResourceMetadata struct { + Target string `json:"Target"` + MediaTypeV string `json:"MediaType"` + MetaData map[string]interface{} `json:"Data"` } // contentReadSeekerCloser returns a ReadSeekerCloser if possible for a given Resource. diff --git a/resources/transform_test.go b/resources/transform_test.go index 2019bda39..e7235bc8c 100644 --- a/resources/transform_test.go +++ b/resources/transform_test.go @@ -14,23 +14,427 @@ package resources import ( + "encoding/base64" + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + "sync" "testing" + "github.com/gohugoio/hugo/htesting" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/hugofs" + + "github.com/gohugoio/hugo/media" + "github.com/gohugoio/hugo/resources/internal" + + "github.com/gohugoio/hugo/helpers" + + "github.com/gohugoio/hugo/resources/resource" + "github.com/spf13/afero" + qt "github.com/frankban/quicktest" ) -type testStruct struct { - Name string - V1 int64 - V2 int32 - V3 int - V4 uint64 +const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDsJkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAAILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36IMRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZmrSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhktrRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEjyy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5feA+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fUyLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2SnssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==` + +func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) } + +func TestTransform(t *testing.T) { + c := qt.New(t) + + createTransformer := func(spec *Spec, filename, content string) Transformer { + filename = filepath.FromSlash(filename) + fs := spec.Fs.Source + afero.WriteFile(fs, filename, []byte(content), 0777) + r, _ := spec.New(ResourceSourceDescriptor{Fs: fs, SourceFilename: filename}) + return r.(Transformer) + } + + createContentReplacer := func(name, old, new string) ResourceTransformation { + return &testTransformation{ + name: name, + transform: func(ctx *ResourceTransformationCtx) error { + in := helpers.ReaderToString(ctx.From) + in = strings.Replace(in, old, new, 1) + ctx.AddOutPathIdentifier("." + name) + fmt.Fprint(ctx.To, in) + return nil + }, + } + } + + // Verify that we publish the same file once only. + assertNoDuplicateWrites := func(c *qt.C, spec *Spec) { + c.Helper() + d := spec.Fs.Destination.(hugofs.DuplicatesReporter) + c.Assert(d.ReportDuplicates(), qt.Equals, "") + } + + assertShouldExist := func(c *qt.C, spec *Spec, filename string, should bool) { + c.Helper() + exists, _ := helpers.Exists(filepath.FromSlash(filename), spec.Fs.Destination) + c.Assert(exists, qt.Equals, should) + } + + c.Run("All values", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + transformation := &testTransformation{ + name: "test", + transform: func(ctx *ResourceTransformationCtx) error { + // Content + in := helpers.ReaderToString(ctx.From) + in = strings.Replace(in, "blue", "green", 1) + fmt.Fprint(ctx.To, in) + + // Media type + ctx.OutMediaType = media.CSVType + + // Change target + ctx.ReplaceOutPathExtension(".csv") + + // Add some data to context + ctx.Data["mydata"] = "Hugo Rocks!" + + return nil + }, + } + + r := createTransformer(spec, "f1.txt", "color is blue") + + tr, err := r.Transform(transformation) + c.Assert(err, qt.IsNil) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(content, qt.Equals, "color is green") + c.Assert(tr.MediaType(), eq, media.CSVType) + c.Assert(tr.RelPermalink(), qt.Equals, "/f1.csv") + assertShouldExist(c, spec, "public/f1.csv", true) + + data := tr.Data().(map[string]interface{}) + c.Assert(data["mydata"], qt.Equals, "Hugo Rocks!") + + assertNoDuplicateWrites(c, spec) + }) + + c.Run("Meta only", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + transformation := &testTransformation{ + name: "test", + transform: func(ctx *ResourceTransformationCtx) error { + // Change media type only + ctx.OutMediaType = media.CSVType + ctx.ReplaceOutPathExtension(".csv") + + return nil + }, + } + + r := createTransformer(spec, "f1.txt", "color is blue") + + tr, err := r.Transform(transformation) + c.Assert(err, qt.IsNil) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(content, qt.Equals, "color is blue") + c.Assert(tr.MediaType(), eq, media.CSVType) + + // The transformed file should only be published if RelPermalink + // or Permalink is called. + n := htesting.RandIntn(3) + shouldExist := true + switch n { + case 0: + tr.RelPermalink() + case 1: + tr.Permalink() + default: + shouldExist = false + } + + assertShouldExist(c, spec, "public/f1.csv", shouldExist) + assertNoDuplicateWrites(c, spec) + }) + + c.Run("Memory-cached transformation", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + // Two transformations with same id, different behaviour. + t1 := createContentReplacer("t1", "blue", "green") + t2 := createContentReplacer("t1", "color", "car") + + for i, transformation := range []ResourceTransformation{t1, t2} { + r := createTransformer(spec, "f1.txt", "color is blue") + tr, _ := r.Transform(transformation) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + c.Assert(content, qt.Equals, "color is green", qt.Commentf("i=%d", i)) + + assertShouldExist(c, spec, "public/f1.t1.txt", false) + } + + assertNoDuplicateWrites(c, spec) + }) + + c.Run("File-cached transformation", func(c *qt.C) { + c.Parallel() + + fs := afero.NewMemMapFs() + + for i := 0; i < 2; i++ { + spec := newTestResourceSpec(specDescriptor{c: c, fs: fs}) + + r := createTransformer(spec, "f1.txt", "color is blue") + + var transformation ResourceTransformation + + if i == 0 { + // There is currently a hardcoded list of transformations that we + // persist to disk (tocss, postcss). + transformation = &testTransformation{ + name: "tocss", + transform: func(ctx *ResourceTransformationCtx) error { + in := helpers.ReaderToString(ctx.From) + in = strings.Replace(in, "blue", "green", 1) + ctx.AddOutPathIdentifier("." + "cached") + ctx.OutMediaType = media.CSVType + ctx.Data = map[string]interface{}{ + "Hugo": "Rocks!", + } + fmt.Fprint(ctx.To, in) + return nil + }, + } + } else { + // Force read from file cache. + transformation = &testTransformation{ + name: "tocss", + transform: func(ctx *ResourceTransformationCtx) error { + return herrors.ErrFeatureNotAvailable + }, + } + } + + msg := qt.Commentf("i=%d", i) + + tr, _ := r.Transform(transformation) + c.Assert(tr.RelPermalink(), qt.Equals, "/f1.cached.txt", msg) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + c.Assert(content, qt.Equals, "color is green", msg) + c.Assert(tr.MediaType(), eq, media.CSVType) + c.Assert(tr.Data(), qt.DeepEquals, map[string]interface{}{ + "Hugo": "Rocks!", + }) + + assertNoDuplicateWrites(c, spec) + assertShouldExist(c, spec, "public/f1.cached.txt", true) + + } + }) + + c.Run("Access RelPermalink first", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + t1 := createContentReplacer("t1", "blue", "green") + + r := createTransformer(spec, "f1.txt", "color is blue") + + tr, _ := r.Transform(t1) + + relPermalink := tr.RelPermalink() + + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(relPermalink, qt.Equals, "/f1.t1.txt") + c.Assert(content, qt.Equals, "color is green") + c.Assert(tr.MediaType(), eq, media.TextType) + + assertNoDuplicateWrites(c, spec) + assertShouldExist(c, spec, "public/f1.t1.txt", true) + }) + + c.Run("Content two", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + t1 := createContentReplacer("t1", "blue", "green") + t2 := createContentReplacer("t1", "color", "car") + + r := createTransformer(spec, "f1.txt", "color is blue") + + tr, _ := r.Transform(t1, t2) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(content, qt.Equals, "car is green") + c.Assert(tr.MediaType(), eq, media.TextType) + + assertNoDuplicateWrites(c, spec) + }) + + c.Run("Content two chained", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + t1 := createContentReplacer("t1", "blue", "green") + t2 := createContentReplacer("t2", "color", "car") + + r := createTransformer(spec, "f1.txt", "color is blue") + + tr1, _ := r.Transform(t1) + tr2, _ := tr1.Transform(t2) + + content1, err := tr1.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + content2, err := tr2.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(content1, qt.Equals, "color is green") + c.Assert(content2, qt.Equals, "car is green") + + assertNoDuplicateWrites(c, spec) + }) + + c.Run("Content many", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + const count = 26 // A-Z + + transformations := make([]ResourceTransformation, count) + for i := 0; i < count; i++ { + transformations[i] = createContentReplacer(fmt.Sprintf("t%d", i), fmt.Sprint(i), string(i+65)) + } + + var countstr strings.Builder + for i := 0; i < count; i++ { + countstr.WriteString(fmt.Sprint(i)) + } + + r := createTransformer(spec, "f1.txt", countstr.String()) + + tr, _ := r.Transform(transformations...) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + + c.Assert(content, qt.Equals, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + + assertNoDuplicateWrites(c, spec) + }) + + c.Run("Image", func(c *qt.C) { + c.Parallel() + + spec := newTestResourceSpec(specDescriptor{c: c}) + + transformation := &testTransformation{ + name: "test", + transform: func(ctx *ResourceTransformationCtx) error { + ctx.AddOutPathIdentifier(".changed") + return nil + }, + } + + r := createTransformer(spec, "gopher.png", helpers.ReaderToString(gopherPNG())) + + tr, err := r.Transform(transformation) + c.Assert(err, qt.IsNil) + c.Assert(tr.MediaType(), eq, media.PNGType) + + img, ok := tr.(resource.Image) + c.Assert(ok, qt.Equals, true) + + c.Assert(img.Width(), qt.Equals, 75) + c.Assert(img.Height(), qt.Equals, 60) + + // RelPermalink called. + resizedPublished1, err := img.Resize("40x40") + c.Assert(err, qt.IsNil) + c.Assert(resizedPublished1.Height(), qt.Equals, 40) + c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_2.png") + assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_2.png", true) + + // Permalink called. + resizedPublished2, err := img.Resize("30x30") + c.Assert(err, qt.IsNil) + c.Assert(resizedPublished2.Height(), qt.Equals, 30) + c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_2.png") + assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_2.png", true) + + // Not published because none of RelPermalink or Permalink was called. + resizedNotPublished, err := img.Resize("50x50") + c.Assert(err, qt.IsNil) + c.Assert(resizedNotPublished.Height(), qt.Equals, 50) + //c.Assert(resized.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png") + assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png", false) + + assertNoDuplicateWrites(c, spec) + + }) + + c.Run("Concurrent", func(c *qt.C) { + spec := newTestResourceSpec(specDescriptor{c: c}) + + transformers := make([]Transformer, 10) + transformations := make([]ResourceTransformation, 10) + + for i := 0; i < 10; i++ { + transformers[i] = createTransformer(spec, fmt.Sprintf("f%d.txt", i), fmt.Sprintf("color is %d", i)) + transformations[i] = createContentReplacer("test", strconv.Itoa(i), "blue") + } + + var wg sync.WaitGroup + + for i := 0; i < 13; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 23; j++ { + id := (i + j) % 10 + tr, err := transformers[id].Transform(transformations[id]) + c.Assert(err, qt.IsNil) + content, err := tr.(resource.ContentProvider).Content() + c.Assert(err, qt.IsNil) + c.Assert(content, qt.Equals, "color is blue") + c.Assert(tr.RelPermalink(), qt.Equals, fmt.Sprintf("/f%d.test.txt", id)) + } + }(i) + } + wg.Wait() + + assertNoDuplicateWrites(c, spec) + }) } -func TestResourceTransformationKey(t *testing.T) { - // We really need this key to be portable across OSes. - key := NewResourceTransformationKey("testing", - testStruct{Name: "test", V1: int64(10), V2: int32(20), V3: 30, V4: uint64(40)}) - c := qt.New(t) - c.Assert("testing_518996646957295636", qt.Equals, key.key()) +type testTransformation struct { + name string + transform func(ctx *ResourceTransformationCtx) error +} + +func (t *testTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey(t.name) +} + +func (t *testTransformation) Transform(ctx *ResourceTransformationCtx) error { + return t.transform(ctx) } diff --git a/tpl/resources/resources.go b/tpl/resources/resources.go index 3d688e21c..e676a3412 100644 --- a/tpl/resources/resources.go +++ b/tpl/resources/resources.go @@ -22,7 +22,9 @@ import ( _errors "github.com/pkg/errors" "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" + "github.com/gohugoio/hugo/resources/resource_factories/bundler" "github.com/gohugoio/hugo/resources/resource_factories/create" "github.com/gohugoio/hugo/resources/resource_transformers/integrity" @@ -174,7 +176,7 @@ func (ns *Namespace) ExecuteAsTemplate(args ...interface{}) (resource.Resource, } data := args[1] - r, ok := args[2].(resource.Resource) + r, ok := args[2].(resources.ResourceTransformer) if !ok { return nil, fmt.Errorf("type %T not supported in Resource transformations", args[2]) } @@ -201,9 +203,9 @@ func (ns *Namespace) Fingerprint(args ...interface{}) (resource.Resource, error) } } - r, ok := args[resIdx].(resource.Resource) + r, ok := args[resIdx].(resources.ResourceTransformer) if !ok { - return nil, fmt.Errorf("%T is not a Resource", args[resIdx]) + return nil, fmt.Errorf("%T can not be transformed", args[resIdx]) } return ns.integrityClient.Fingerprint(r, algo) @@ -211,7 +213,7 @@ func (ns *Namespace) Fingerprint(args ...interface{}) (resource.Resource, error) // Minify minifies the given Resource using the MediaType to pick the correct // minifier. -func (ns *Namespace) Minify(r resource.Resource) (resource.Resource, error) { +func (ns *Namespace) Minify(r resources.ResourceTransformer) (resource.Resource, error) { return ns.minifyClient.Minify(r) } @@ -219,7 +221,7 @@ func (ns *Namespace) Minify(r resource.Resource) (resource.Resource, error) { // object or a target path (string) as first argument. func (ns *Namespace) ToCSS(args ...interface{}) (resource.Resource, error) { var ( - r resource.Resource + r resources.ResourceTransformer m map[string]interface{} targetPath string err error @@ -266,7 +268,7 @@ func (ns *Namespace) PostCSS(args ...interface{}) (resource.Resource, error) { } // We allow string or a map as the first argument in some cases. -func (ns *Namespace) resolveIfFirstArgIsString(args []interface{}) (resource.Resource, string, bool) { +func (ns *Namespace) resolveIfFirstArgIsString(args []interface{}) (resources.ResourceTransformer, string, bool) { if len(args) != 2 { return nil, "", false } @@ -275,26 +277,26 @@ func (ns *Namespace) resolveIfFirstArgIsString(args []interface{}) (resource.Res if !ok1 { return nil, "", false } - v2, ok2 := args[1].(resource.Resource) + v2, ok2 := args[1].(resources.ResourceTransformer) return v2, v1, ok2 } // This roundabout way of doing it is needed to get both pipeline behaviour and options as arguments. -func (ns *Namespace) resolveArgs(args []interface{}) (resource.Resource, map[string]interface{}, error) { +func (ns *Namespace) resolveArgs(args []interface{}) (resources.ResourceTransformer, map[string]interface{}, error) { if len(args) == 0 { return nil, nil, errors.New("no Resource provided in transformation") } if len(args) == 1 { - r, ok := args[0].(resource.Resource) + r, ok := args[0].(resources.ResourceTransformer) if !ok { return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) } return r, nil, nil } - r, ok := args[1].(resource.Resource) + r, ok := args[1].(resources.ResourceTransformer) if !ok { return nil, nil, fmt.Errorf("type %T not supported in Resource transformations", args[0]) }