From 20c9b6ec81171d1c586ea31d5d08b40b0edaffc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 15 Jan 2018 20:40:39 +0100 Subject: resource: Add front matter metadata to Resource This commit expands the Resource interface with 3 new methods: * Name * Title * Params All of these can be set in the Page front matter. `Name` will get its default value from the base filename, and is the value used in the ByPrefix and GetByPrefix lookup methods. Fixes #4244 --- resource/image.go | 2 +- resource/image_cache.go | 18 ++--- resource/image_test.go | 22 ++++++ resource/resource.go | 171 +++++++++++++++++++++++++++++++++++++---- resource/resource_test.go | 192 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 23 deletions(-) (limited to 'resource') diff --git a/resource/image.go b/resource/image.go index e9a617f97..7ec65f3bc 100644 --- a/resource/image.go +++ b/resource/image.go @@ -208,7 +208,7 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c key := i.relTargetPathForRel(i.filenameFromConfig(conf), false) - return i.spec.imageCache.getOrCreate(i.spec, key, func(resourceCacheFilename string) (*Image, error) { + return i.spec.imageCache.getOrCreate(i, key, func(resourceCacheFilename string) (*Image, error) { ci := i.clone() ci.setBasePath(conf) diff --git a/resource/image_cache.go b/resource/image_cache.go index c2d5d0ad5..5720fb623 100644 --- a/resource/image_cache.go +++ b/resource/image_cache.go @@ -15,7 +15,6 @@ package resource import ( "fmt" - "os" "path/filepath" "strings" "sync" @@ -50,7 +49,7 @@ func (c *imageCache) deleteByPrefix(prefix string) { } func (c *imageCache) getOrCreate( - spec *Spec, key string, create func(resourceCacheFilename string) (*Image, error)) (*Image, error) { + parent *Image, key string, create func(resourceCacheFilename string) (*Image, error)) (*Image, error) { relTargetFilename := key @@ -77,19 +76,20 @@ func (c *imageCache) getOrCreate( // but the count of processed image variations for this site. c.pathSpec.ProcessingStats.Incr(&c.pathSpec.ProcessingStats.ProcessedImages) - r, err := spec.NewResourceFromFilename(nil, c.absPublishDir, cacheFilename, relTargetFilename) - notFound := err != nil && os.IsNotExist(err) - if err != nil && !os.IsNotExist(err) { + exists, err := helpers.Exists(cacheFilename, c.pathSpec.Fs.Source) + if err != nil { return nil, err } - if notFound { + if exists { + img = parent.clone() + img.relTargetPath = relTargetFilename + img.absSourceFilename = cacheFilename + } else { img, err = create(cacheFilename) if err != nil { return nil, err } - } else { - img = r.(*Image) } c.mu.Lock() @@ -102,7 +102,7 @@ func (c *imageCache) getOrCreate( c.mu.Unlock() - if notFound { + if !exists { // File already written to destination return img, nil } diff --git a/resource/image_test.go b/resource/image_test.go index 28f68a46c..bf097b319 100644 --- a/resource/image_test.go +++ b/resource/image_test.go @@ -147,3 +147,25 @@ func TestDecodeImaging(t *testing.T) { assert.Equal(42, imaging.Quality) assert.Equal("nearestneighbor", imaging.ResampleFilter) } + +func TestImageWithMetadata(t *testing.T) { + assert := require.New(t) + + image := fetchSunset(assert) + + var meta = []map[string]interface{}{ + map[string]interface{}{ + "title": "My Sunset", + "name": "Sunset #:counter", + "src": "*.jpg", + }, + } + + assert.NoError(AssignMetadata(meta, image)) + assert.Equal("Sunset #1", image.Name()) + + resized, err := image.Resize("200x") + assert.NoError(err) + assert.Equal("Sunset #1", resized.Name()) + +} diff --git a/resource/resource.go b/resource/resource.go index bea53856e..951f1d9a7 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -19,8 +19,11 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" + "github.com/spf13/cast" + "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/source" @@ -28,9 +31,10 @@ import ( ) var ( - _ Resource = (*genericResource)(nil) - _ Source = (*genericResource)(nil) - _ Cloner = (*genericResource)(nil) + _ Resource = (*genericResource)(nil) + _ metaAssigner = (*genericResource)(nil) + _ Source = (*genericResource)(nil) + _ Cloner = (*genericResource)(nil) ) const DefaultResourceType = "unknown" @@ -48,11 +52,38 @@ type Cloner interface { WithNewBase(base string) Resource } +type metaAssigner interface { + setTitle(title string) + setName(name string) + setParams(params map[string]interface{}) +} + // Resource represents a linkable resource, i.e. a content page, image etc. type Resource interface { + // Permalink represents the absolute link to this resource. Permalink() string + + // RelPermalink represents the host relative link to this resource. RelPermalink() string + + // ResourceType is the resource type. For most file types, this is the main + // part of the MIME type, e.g. "image", "application", "text" etc. + // For content pages, this value is "page". ResourceType() string + + // Name is the logical name of this resource. This can be set in the front matter + // metadata for this resource. If not set, Hugo will assign a value. + // This will in most cases be the base filename. + // So, for the image "/some/path/sunset.jpg" this will be "sunset.jpg". + // The value returned by this method will be used in the GetByPrefix and ByPrefix methods + // on Resources. + Name() string + + // Title returns the title if set in front matter. For content pages, this will be the expected value. + Title() string + + // Params set in front matter for this resource. + Params() map[string]interface{} } // Resources represents a slice of resources, which can be a mix of different types. @@ -97,16 +128,7 @@ func (r Resources) ByPrefix(prefix string) Resources { } func matchesPrefix(r Resource, prefix string) bool { - var name string - f, ok := r.(source.File) - if ok { - name = f.BaseFileName() - } else { - _, name = filepath.Split(r.RelPermalink()) - } - name = strings.ToLower(name) - - return strings.HasPrefix(name, prefix) + return strings.HasPrefix(strings.ToLower(r.Name()), prefix) } type Spec struct { @@ -238,6 +260,10 @@ type genericResource struct { // Base is set when the output format's path has a offset, e.g. for AMP. base string + title string + name string + params map[string]interface{} + // Absolute filename to the source, including any content folder path. absSourceFilename string absPublishDir string @@ -256,6 +282,30 @@ func (l *genericResource) RelPermalink() string { return l.relPermalinkForRel(l.relTargetPath, true) } +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) setParams(params map[string]interface{}) { + l.params = params +} + // Implement the Cloner interface. func (l genericResource) WithNewBase(base string) Resource { l.base = base @@ -306,6 +356,98 @@ func (l *genericResource) Publish() error { return helpers.WriteToDisk(target, f, l.spec.Fs.Destination) } +// AssignMetadata assigns the given metadata to those resources that supports updates +// and matching by wildcard given in `src` using `filepath.Match` with lower cased values. +// This assignment is additive, but the most specific match needs to be first. +// The `name` and `title` metadata field support shell-matched collection it got a match in. +// See https://golang.org/pkg/path/filepath/#Match +func AssignMetadata(metadata []map[string]interface{}, resources ...Resource) error { + + counters := make(map[string]int) + + for _, r := range resources { + if _, ok := r.(metaAssigner); !ok { + continue + } + + var ( + nameSet, titleSet, paramsSet bool + currentCounter = 0 + resourceSrcKey = strings.ToLower(r.Name()) + ) + + ma := r.(metaAssigner) + for _, meta := range metadata { + if nameSet && titleSet && paramsSet { + // No need to look further + break + } + + src, found := meta["src"] + if !found { + return fmt.Errorf("missing 'src' in metadata for resource") + } + + srcKey := strings.ToLower(cast.ToString(src)) + + match, err := filepath.Match(srcKey, resourceSrcKey) + if err != nil { + return fmt.Errorf("failed to match resource with metadata: %s", err) + } + + if match { + if !nameSet { + name, found := meta["name"] + if found { + if currentCounter == 0 { + currentCounter = counters[srcKey] + 1 + counters[srcKey] = currentCounter + } + + ma.setName(replaceResourcePlaceholders(cast.ToString(name), currentCounter)) + nameSet = true + } + } + + if !titleSet { + title, found := meta["title"] + if found { + if currentCounter == 0 { + currentCounter = counters[srcKey] + 1 + counters[srcKey] = currentCounter + } + ma.setTitle((replaceResourcePlaceholders(cast.ToString(title), currentCounter))) + titleSet = true + } + } + + if !paramsSet { + params, found := meta["params"] + if found { + m := cast.ToStringMap(params) + // Needed for case insensitive fetching of params values + helpers.ToLowerMap(m) + ma.setParams(m) + + if currentCounter == 0 { + currentCounter = counters[srcKey] + 1 + counters[srcKey] = currentCounter + } + + paramsSet = true + } + } + } + } + } + + return nil +} + +func replaceResourcePlaceholders(in string, counter int) string { + return strings.Replace(in, ":counter", strconv.Itoa(counter), -1) +} + func (l *genericResource) target() string { target := l.relTargetPathForRel(l.relTargetPath, false) if l.spec.PathSpec.Languages.IsMultihost() { @@ -330,5 +472,8 @@ func (r *Spec) newGenericResource( relTargetPath: baseFilename, resourceType: resourceType, spec: r, + params: make(map[string]interface{}), + name: baseFilename, + title: baseFilename, } } diff --git a/resource/resource_test.go b/resource/resource_test.go index 73d98d62a..4670ef632 100644 --- a/resource/resource_test.go +++ b/resource/resource_test.go @@ -14,6 +14,7 @@ package resource import ( + "fmt" "path" "path/filepath" "testing" @@ -129,4 +130,195 @@ func TestResourcesGetByPrefix(t *testing.T) { assert.Equal(2, len(resources.ByPrefix("logo"))) assert.Equal(1, len(resources.ByPrefix("logo2"))) + logo := resources.GetByPrefix("logo") + assert.NotNil(logo.Params()) + assert.Equal("logo1.png", logo.Name()) + assert.Equal("logo1.png", logo.Title()) + +} + +func TestAssignMetadata(t *testing.T) { + assert := require.New(t) + spec := newTestResourceSpec(assert) + + var foo1, foo2, foo3, logo1, logo2, logo3 Resource + var resources Resources + + for _, this := range []struct { + metaData []map[string]interface{} + assertFunc func(err error) + }{ + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "My Resource", + "name": "My Name", + "src": "*", + }, + }, func(err error) { + assert.Equal("My Resource", logo1.Title()) + assert.Equal("My Name", logo1.Name()) + assert.Equal("My Name", foo2.Name()) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "My Logo", + "src": "*loGo*", + }, + map[string]interface{}{ + "title": "My Resource", + "name": "My Name", + "src": "*", + }, + }, func(err error) { + assert.Equal("My Logo", logo1.Title()) + assert.Equal("My Logo", logo2.Title()) + assert.Equal("My Name", logo1.Name()) + assert.Equal("My Name", foo2.Name()) + assert.Equal("My Name", foo3.Name()) + assert.Equal("My Resource", foo3.Title()) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "My Logo", + "src": "*loGo*", + "params": map[string]interface{}{ + "Param1": true, + }, + }, + map[string]interface{}{ + "title": "My Resource", + "src": "*", + "params": map[string]interface{}{ + "Param2": true, + }, + }, + }, func(err error) { + assert.NoError(err) + assert.Equal("My Logo", logo1.Title()) + assert.Equal("My Resource", foo3.Title()) + _, p1 := logo2.Params()["param1"] + _, p2 := foo2.Params()["param2"] + assert.True(p1) + assert.True(p2) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "name": "Logo Name #:counter", + "src": "*logo*", + }, + map[string]interface{}{ + "title": "Resource #:counter", + "name": "Name #:counter", + "src": "*", + }, + }, func(err error) { + assert.NoError(err) + assert.Equal("Resource #1", logo2.Title()) + assert.Equal("Logo Name #1", logo2.Name()) + assert.Equal("Resource #2", logo1.Title()) + assert.Equal("Logo Name #2", logo1.Name()) + assert.Equal("Resource #1", foo2.Title()) + assert.Equal("Resource #2", foo1.Title()) + assert.Equal("Name #2", foo1.Name()) + assert.Equal("Resource #3", foo3.Title()) + + assert.Equal(logo2, resources.GetByPrefix("logo name #1")) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "Third Logo #:counter", + "src": "logo3.png", + }, + map[string]interface{}{ + "title": "Other Logo #:counter", + "name": "Name #:counter", + "src": "logo*", + }, + }, func(err error) { + assert.NoError(err) + assert.Equal("Third Logo #1", logo3.Title()) + assert.Equal("Name #1", logo3.Name()) + assert.Equal("Other Logo #1", logo2.Title()) + assert.Equal("Name #1", logo2.Name()) + assert.Equal("Other Logo #2", logo1.Title()) + assert.Equal("Name #2", logo1.Name()) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "Third Logo #:counter", + }, + }, func(err error) { + // Missing src + assert.Error(err) + + }}, + {[]map[string]interface{}{ + map[string]interface{}{ + "title": "Title", + "src": "[]", + }, + }, func(err error) { + // Invalid pattern + assert.Error(err) + + }}, + } { + + foo2 = spec.newGenericResource(nil, nil, "/public", "/b/foo2.css", "foo2.css", "css") + logo2 = spec.newGenericResource(nil, nil, "/public", "/b/Logo2.png", "Logo2.png", "image") + foo1 = spec.newGenericResource(nil, nil, "/public", "/a/foo1.css", "foo1.css", "css") + logo1 = spec.newGenericResource(nil, nil, "/public", "/a/logo1.png", "logo1.png", "image") + foo3 = spec.newGenericResource(nil, nil, "/public", "/b/foo3.css", "foo3.css", "css") + logo3 = spec.newGenericResource(nil, nil, "/public", "/b/logo3.png", "logo3.png", "image") + + resources = Resources{ + foo2, + logo2, + foo1, + logo1, + foo3, + logo3, + } + + this.assertFunc(AssignMetadata(this.metaData, resources...)) + } + +} + +func BenchmarkAssignMetadata(b *testing.B) { + assert := require.New(b) + spec := newTestResourceSpec(assert) + + for i := 0; i < b.N; i++ { + b.StopTimer() + var resources Resources + var meta = []map[string]interface{}{ + map[string]interface{}{ + "title": "Foo #:counter", + "name": "Foo Name #:counter", + "src": "foo1*", + }, + map[string]interface{}{ + "title": "Rest #:counter", + "name": "Rest Name #:counter", + "src": "*", + }, + } + for i := 0; i < 20; i++ { + name := fmt.Sprintf("foo%d_%d.css", i%5, i) + resources = append(resources, spec.newGenericResource(nil, nil, "/public", "/a/"+name, name, "css")) + } + b.StartTimer() + + if err := AssignMetadata(meta, resources...); err != nil { + b.Fatal(err) + } + + } + } -- cgit v1.2.3