Repository: gohugoio/hugo (language: go)

Issue:
Add resource.Copy
Currently only some resource methods can take a "destination" argument. 

But many don't, including image transformation. 

It might also be useful to rename an asset without doing anything to it, especially when using `.GetRemote`:

```
{{ with resources.GetRemote "https://image.com/3093i49039i403i4903i403.jpg" }}
  {{ $img := resources.Copy . "directory/good-looking-name.jpg" }}
  <img src="{{ $img.RelPermalink }}" />
{{ end }}
```


Discussion / hints:
I suspect that we have discussed this before; my conclusion then would be that `resources.Copy` would be better (the old will still exist under its original name).
Yes I could not find the conversation though.
But then once you've copied it, are you suppose to "Get" it again from its new location? Or the new asset is returned by the `resources.Copy` method...
>Or the new asset is returned by the resources.Copy method...

Yes, you can start using it right away:

```
{{ $bar := resources.GetRemote "https://example.org/foo.jpg" | resources.Copy "bar.jpg" }}
<img src="{{ $bar.RelPermalink }}">
```
Damn! How did I miss that! Thanks!
What we have now:

```
{{ $options := dict "targetPath" "css/foo.css" }}
{{ $css := resources.Get "sass/style.scss" | resources.ToCSS $options }}

{{ $options := dict "targetPath" "js/foo.js" }}
{{ $js := resources.Get "js/script.js" | js.Build $options }}
````

To be somewhat consistent, we could just do the same for `.GetRemote`:

```
{{ $options := dict "targetPath" "img/foo.jpg" }}
{{ $img := resources.GetRemote "https://example.org/image.jpg" $options }}
```

There's an undocumented method to publish a resource, having the same effect as invoking `.Permalink` or `.RelPermalink`:

```
{{ $img := resources.GetRemote "https://example.org/image.jpg" }}
{{ $img.Publish }}
```

We could also create a `resources.Publish` method [#9303](https://github.com/gohugoio/hugo/issues/9303) which would take an optional dictionary:

```
{{ $options := dict "targetPath" "img/foo.jpg" }}
{{ $img := resources.GetRemote "https://example.org/image.jpg" | resources.Publish $options  }}
```

I'm not advocating any of these, but it's worth thinking about. Whatever we do I hope it's mostly consistent between global and page resources, regardless of media type (images, css, js, etc.).
>To be somewhat consistent, we could just do the same for .GetRemote:

Yes, I have thought about that, and maybe we should do that, but there's a non-trivial implementation cost to add the same set of options to every `FooResource` function we have/add in the future. And I like the Unixy simplicity of this simple line:

```
{{ $bar := resources.GetRemote "https://example.org/foo.jpg" | resources.Copy "bar.jpg" }}
```

But I'm not totally 100% sure ... There may be other considerations as to why we maybe should add `targetPath` to at least the core funcs, e.g. `GetRemote/Get`.
>{{ $img := resources.GetRemote "https://example.org/image.jpg" | resources.Publish $options  }}

Yea, that could maybe be a better idea; this would allow us to put more options in the future if needed.
Related: <https://discourse.gohugo.io/t/using-getremote-on-image-puts-the-resource-in-root/36397>
Duplicate of https://github.com/gohugoio/hugo/issues/7406
> Yes, you can start using it right away:

Are you sure? I get this when I try your suggestion: 
```
at <resources>: can't evaluate field Copy in type interface {}
```
Lost in translation...

regis asked: 

> But then once you've copied it, are you suppose to "Get" it again from its new location? Or the new asset is returned by the resources.Copy method...

bep answered:

> Yes, you can start using it right away:

bep didn't mean that the feature is available in the current release.
> bep didn't mean that the feature is available in the current release.

I see, on `master` then?
No. This is still in the proposal stage. Nothing has been implemented.
This would help a lot! Any progress?

Fix patch:
diff --git a/helpers/general.go b/helpers/general.go
index e31bbfc9daf..462ec773da0 100644
--- a/helpers/general.go
+++ b/helpers/general.go
@@ -33,8 +33,6 @@ import (
 
 	"github.com/mitchellh/hashstructure"
 
-	"github.com/gohugoio/hugo/hugofs"
-
 	"github.com/gohugoio/hugo/common/hugo"
 
 	"github.com/spf13/afero"
@@ -521,13 +519,7 @@ func PrintFs(fs afero.Fs, path string, w io.Writer) {
 	}
 
 	afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error {
-		var filename string
-		var meta any
-		if fim, ok := info.(hugofs.FileMetaInfo); ok {
-			filename = fim.Meta().Filename
-			meta = fim.Meta()
-		}
-		fmt.Fprintf(w, "    %q %q\t\t%v\n", path, filename, meta)
+		fmt.Println(path)
 		return nil
 	})
 }
diff --git a/resources/image.go b/resources/image.go
index f431784b4a7..3eda709d9b1 100644
--- a/resources/image.go
+++ b/resources/image.go
@@ -134,7 +134,7 @@ func (i *imageResource) getExif() *exif.ExifInfo {
 	return i.meta.Exif
 }
 
-// Cloneis for internal use.
+// Clone is for internal use.
 func (i *imageResource) Clone() resource.Resource {
 	gr := i.baseResource.Clone().(baseResource)
 	return &imageResource{
@@ -144,6 +144,15 @@ func (i *imageResource) Clone() resource.Resource {
 	}
 }
 
+func (i *imageResource) cloneTo(targetPath string) resource.Resource {
+	gr := i.baseResource.cloneTo(targetPath).(baseResource)
+	return &imageResource{
+		root:         i.root,
+		Image:        i.WithSpec(gr),
+		baseResource: gr,
+	}
+}
+
 func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) {
 	base, err := i.baseResource.cloneWithUpdates(u)
 	if err != nil {
diff --git a/resources/resource.go b/resources/resource.go
index ead483d65a1..e38090dbe73 100644
--- a/resources/resource.go
+++ b/resources/resource.go
@@ -1,4 +1,4 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
+// Copyright 2022 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.
@@ -120,8 +120,19 @@ func (t transformerNotAvailable) Key() internal.ResourceTransformationKey {
 	return t.key
 }
 
+// resourceCopier is for internal use.
+type resourceCopier interface {
+	cloneTo(targetPath string) resource.Resource
+}
+
+// Copy copies r to the targetPath given.
+func Copy(r resource.Resource, targetPath string) resource.Resource {
+	return r.(resourceCopier).cloneTo(targetPath)
+}
+
 type baseResourceResource interface {
 	resource.Cloner
+	resourceCopier
 	resource.ContentProvider
 	resource.Resource
 	resource.Identifier
@@ -225,6 +236,20 @@ func (l *genericResource) Clone() resource.Resource {
 	return l.clone()
 }
 
+func (l *genericResource) cloneTo(targetPath string) resource.Resource {
+	c := l.clone()
+
+	targetPath = helpers.ToSlashTrimLeading(targetPath)
+	dir, file := path.Split(targetPath)
+
+	c.resourcePathDescriptor = &resourcePathDescriptor{
+		relTargetDirFile: dirFile{dir: dir, file: file},
+	}
+
+	return c
+
+}
+
 func (l *genericResource) Content() (any, error) {
 	if err := l.initContent(); err != nil {
 		return nil, err
diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go
index e3251aabe22..4ba95c17053 100644
--- a/resources/resource/resourcetypes.go
+++ b/resources/resource/resourcetypes.go
@@ -26,8 +26,7 @@ var (
 	_ ResourceError        = (*resourceError)(nil)
 )
 
-// Cloner is an internal template and not meant for use in the templates. It
-// may change without notice.
+// Cloner is for internal use.
 type Cloner interface {
 	Clone() Resource
 }
diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go
index 3827411a9f9..075d257362b 100644
--- a/resources/resource_factories/create/create.go
+++ b/resources/resource_factories/create/create.go
@@ -51,6 +51,13 @@ func New(rs *resources.Spec) *Client {
 	}
 }
 
+// Copy copies r to the new targetPath.
+func (c *Client) Copy(r resource.Resource, targetPath string) (resource.Resource, error) {
+	return c.rs.ResourceCache.GetOrCreate(resources.ResourceCacheKey(targetPath), func() (resource.Resource, error) {
+		return resources.Copy(r, targetPath), nil
+	})
+}
+
 // Get creates a new Resource by opening the given filename in the assets filesystem.
 func (c *Client) Get(filename string) (resource.Resource, error) {
 	filename = filepath.Clean(filename)
diff --git a/resources/transform.go b/resources/transform.go
index a470c94daf4..bb1608cbd54 100644
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -42,6 +42,7 @@ import (
 
 var (
 	_ resource.ContentResource        = (*resourceAdapter)(nil)
+	_ resourceCopier                  = (*resourceAdapter)(nil)
 	_ resource.ReadSeekCloserResource = (*resourceAdapter)(nil)
 	_ resource.Resource               = (*resourceAdapter)(nil)
 	_ resource.Source                 = (*resourceAdapter)(nil)
@@ -175,6 +176,19 @@ func (r *resourceAdapter) Data() any {
 	return r.target.Data()
 }
 
+func (r resourceAdapter) cloneTo(targetPath string) resource.Resource {
+	newtTarget := r.target.cloneTo(targetPath)
+	newInner := &resourceAdapterInner{
+		spec:   r.spec,
+		target: newtTarget.(transformableResource),
+	}
+	if r.resourceAdapterInner.publishOnce != nil {
+		newInner.publishOnce = &publishOnce{}
+	}
+	r.resourceAdapterInner = newInner
+	return &r
+}
+
 func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) {
 	return r.getImageOps().Crop(spec)
 }
@@ -596,6 +610,7 @@ type transformableResource interface {
 	resource.ContentProvider
 	resource.Resource
 	resource.Identifier
+	resourceCopier
 }
 
 type transformationUpdate struct {
diff --git a/tpl/resources/resources.go b/tpl/resources/resources.go
index 2adec358ce8..165152c78a9 100644
--- a/tpl/resources/resources.go
+++ b/tpl/resources/resources.go
@@ -111,6 +111,15 @@ func (ns *Namespace) getscssClientDartSass() (*dartsass.Client, error) {
 	return ns.scssClientDartSass, err
 }
 
+// Copy copies r to the new targetPath in s.
+func (ns *Namespace) Copy(s any, r resource.Resource) (resource.Resource, error) {
+	targetPath, err := cast.ToStringE(s)
+	if err != nil {
+		panic(err)
+	}
+	return ns.createClient.Copy(r, targetPath)
+}
+
 // Get locates the filename given in Hugo's assets filesystem
 // and creates a Resource object that can be used for further transformations.
 func (ns *Namespace) Get(filename any) resource.Resource {


Test patch:
diff --git a/tpl/resources/integration_test.go b/tpl/resources/integration_test.go
new file mode 100644
index 00000000000..06f98eeee9f
--- /dev/null
+++ b/tpl/resources/integration_test.go
@@ -0,0 +1,100 @@
+// Copyright 2022s 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_test
+
+import (
+	"testing"
+
+	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestCopy(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- config.toml --
+baseURL = "http://example.com/blog"
+-- assets/images/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- layouts/index.html --
+{{/* Image resources */}}
+{{ $img := resources.Get "images/pixel.png" }}
+{{ $imgCopy1 := $img | resources.Copy "images/copy.png"  }}
+{{ $imgCopy1 = $imgCopy1.Resize "3x4"}}
+{{ $imgCopy2 := $imgCopy1 | resources.Copy "images/copy2.png" }}
+{{ $imgCopy3 := $imgCopy1 | resources.Copy "images/copy3.png" }}
+Image Orig:  {{ $img.RelPermalink}}|{{ $img.MediaType }}|{{ $img.Width }}|{{ $img.Height }}|
+Image Copy1:  {{ $imgCopy1.RelPermalink}}|{{ $imgCopy1.MediaType }}|{{ $imgCopy1.Width }}|{{ $imgCopy1.Height }}|
+Image Copy2:  {{ $imgCopy2.RelPermalink}}|{{ $imgCopy2.MediaType }}|{{ $imgCopy2.Width }}|{{ $imgCopy2.Height }}|
+Image Copy3:  {{ $imgCopy3.MediaType }}|{{ $imgCopy3.Width }}|{{ $imgCopy3.Height }}|
+
+{{/* Generic resources */}}
+{{ $targetPath := "js/vars.js" }}
+{{ $orig := "let foo;" | resources.FromString "js/foo.js" }}
+{{ $copy1 := $orig | resources.Copy "js/copies/bar.js" }}
+{{ $copy2 := $orig | resources.Copy "js/copies/baz.js" | fingerprint "md5" }}
+{{ $copy3 := $copy2 | resources.Copy "js/copies/moo.js" | minify }}
+
+Orig: {{ $orig.RelPermalink}}|{{ $orig.MediaType }}|{{ $orig.Content | safeJS }}|
+Copy1: {{ $copy1.RelPermalink}}|{{ $copy1.MediaType }}|{{ $copy1.Content | safeJS }}|
+Copy2: {{ $copy2.RelPermalink}}|{{ $copy2.MediaType }}|{{ $copy2.Content | safeJS }}|
+Copy3: {{ $copy3.RelPermalink}}|{{ $copy3.MediaType }}|{{ $copy3.Content | safeJS }}|
+
+	`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", `
+Image Orig:  /blog/images/pixel.png|image/png|1|1|
+Image Copy1:  /blog/images/copy_hu8aa3346827e49d756ff4e630147c42b5_70_3x4_resize_box_3.png|image/png|3|4|
+Image Copy2:  /blog/images/copy2.png|image/png|3|4
+Image Copy3:  image/png|3|4|
+Orig: /blog/js/foo.js|application/javascript|let foo;|
+Copy1: /blog/js/copies/bar.js|application/javascript|let foo;|
+Copy2: /blog/js/copies/baz.a677329fc6c4ad947e0c7116d91f37a2.js|application/javascript|let foo;|
+Copy3: /blog/js/copies/moo.a677329fc6c4ad947e0c7116d91f37a2.min.js|application/javascript|let foo|
+
+		`)
+
+	b.AssertDestinationExists("images/copy2.png", true)
+	// No permalink used.
+	b.AssertDestinationExists("images/copy3.png", false)
+
+}
+
+func TestCopyPageShouldFail(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- config.toml --
+-- layouts/index.html --
+{{/* This is currently not supported. */}}
+{{ $copy := .Copy "copy.md" }}
+
+	`
+
+	b, err := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+		}).BuildE()
+
+	b.Assert(err, qt.IsNotNil)
+
+}