// 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. // 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 ( "context" "fmt" "io" "os" "path" "path/filepath" "strings" "sync" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/hugofs" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/source" "errors" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/resource" "github.com/spf13/afero" "github.com/gohugoio/hugo/helpers" ) var ( _ resource.ContentResource = (*genericResource)(nil) _ resource.ReadSeekCloserResource = (*genericResource)(nil) _ resource.Resource = (*genericResource)(nil) _ resource.Source = (*genericResource)(nil) _ resource.Cloner = (*genericResource)(nil) _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil) _ permalinker = (*genericResource)(nil) _ resource.Identifier = (*genericResource)(nil) _ fileInfo = (*genericResource)(nil) ) type ResourceSourceDescriptor struct { // TargetPaths is a callback to fetch paths's relative to its owner. TargetPaths func() page.TargetPaths // Need one of these to load the resource content. SourceFile source.File OpenReadSeekCloser resource.OpenReadSeekCloser FileInfo os.FileInfo // If OpenReadSeekerCloser is not set, we use this to open the file. SourceFilename string Fs afero.Fs Data map[string]any // Set when its known up front, else it's resolved from the target filename. MediaType media.Type // The relative target filename without any language code. RelTargetFilename string // Any base paths prepended to the target path. This will also typically be the // language code, but setting it here means that it should not have any effect on // the permalink. // This may be several values. In multihost mode we may publish the same resources to // multiple targets. TargetBasePaths []string // Delay publishing until either Permalink or RelPermalink is called. Maybe never. LazyPublish bool } func (r ResourceSourceDescriptor) Filename() string { if r.SourceFile != nil { return r.SourceFile.Filename() } return r.SourceFilename } type ResourceTransformer interface { resource.Resource Transformer } type Transformer interface { Transform(...ResourceTransformation) (ResourceTransformer, error) TransformWithContext(context.Context, ...ResourceTransformation) (ResourceTransformer, error) } func NewFeatureNotAvailableTransformer(key string, elements ...any) ResourceTransformation { return transformerNotAvailable{ key: internal.NewResourceTransformationKey(key, elements...), } } type transformerNotAvailable struct { key internal.ResourceTransformationKey } func (t transformerNotAvailable) Transform(ctx *ResourceTransformationCtx) error { return herrors.ErrFeatureNotAvailable } 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 { if r.Err() != nil { panic(fmt.Sprintf("Resource has an .Err: %s", r.Err())) } return r.(resourceCopier).cloneTo(targetPath) } type baseResourceResource interface { resource.Cloner resourceCopier resource.ContentProvider resource.Resource resource.Identifier } 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 } type specProvider interface { getSpec() *Spec } type baseResource interface { baseResourceResource baseResourceInternal } type commonResource struct { } // Slice is for internal use. // for the template functions. See collections.Slice. func (commonResource) Slice(in any) (any, error) { switch items := in.(type) { case resource.Resources: return items, nil case []any: groups := make(resource.Resources, len(items)) for i, v := range items { g, ok := v.(resource.Resource) if !ok { return nil, fmt.Errorf("type %T is not a Resource", v) } groups[i] = g { } } return groups, nil default: return nil, fmt.Errorf("invalid slice type %T", items) } } 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 fileInfo interface { getSourceFilename() string setSourceFilename(string) setSourceFs(afero.Fs) getFileInfo() hugofs.FileMetaInfo 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]any data map[string]any resourceType string mediaType media.Type } 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(context.Context) (any, error) { if err := l.initContent(); err != nil { return nil, err } return l.content, nil } func (r *genericResource) Err() resource.ResourceError { return nil } func (l *genericResource) Data() any { return l.data } func (l *genericResource) Key() string { if l.spec.BasePath == "" { return l.RelPermalink() } return strings.TrimPrefix(l.RelPermalink(), l.spec.BasePath) } func (l *genericResource) MediaType() media.Type { return l.mediaType } func (l *genericResource) setMediaType(mediaType media.Type) { l.mediaType = mediaType } func (l *genericResource) Name() string { return l.name } func (l *genericResource) Params() maps.Params { 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 { var err error l.publishInit.Do(func() { var fr hugio.ReadSeekCloser fr, err = l.ReadSeekCloser() if err != nil { return } defer fr.Close() var fw io.WriteCloser fw, err = helpers.OpenFilesForWriting(l.spec.BaseFs.PublishFs, l.getTargetFilenames()...) if err != nil { return } 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() { var r hugio.ReadSeekCloser r, err = l.ReadSeekCloser() if err != nil { return } defer r.Close() var b []byte b, err = io.ReadAll(r) if err != nil { return } l.content = string(b) }) return err } func (l *genericResource) setName(name string) { l.name = name } 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]any) { if len(in) == 0 { return } if r.data == nil { r.data = make(map[string]any) } for k, v := range in { if _, found := r.data[k]; !found { r.data[k] = v } } } 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) 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 (it may already be published). func (l *genericResource) openDestinationsForWriting() (w io.WriteCloser, err error) { l.publishInit.Do(func() { targetFilenames := l.getTargetFilenames() var changedFilenames []string // Fast path: // This is a processed version of the original; // check if it already exists at the destination. for _, targetFilename := range targetFilenames { if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil { continue } changedFilenames = append(changedFilenames, targetFilename) } if len(changedFilenames) == 0 { return } w, err = helpers.OpenFilesForWriting(l.getSpec().BaseFs.PublishFs, changedFilenames...) }) return } 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 { if addBaseTargetPath && len(l.baseTargetPathDirs) > 1 { panic("multiple baseTargetPathDirs") } var basePath string if addBaseTargetPath && len(l.baseTargetPathDirs) > 0 { basePath = l.baseTargetPathDirs[0] } return l.relTargetPathForRelAndBasePath(rel, basePath, isAbs, isURL) } func (l *genericResource) relTargetPathForRelAndBasePath(rel, basePath string, isAbs, isURL bool) string { rel = l.createBasePath(rel, isURL) if basePath != "" { rel = path.Join(basePath, rel) } if l.baseOffset != "" { rel = path.Join(l.baseOffset, rel) } if isURL { bp := l.spec.PathSpec.GetBasePath(!isAbs) if bp != "" { rel = path.Join(bp, rel) } } if len(rel) == 0 || rel[0] != '/' { rel = "/" + rel } return rel } func (l *genericResource) relTargetPaths() []string { return l.relTargetPathsForRel(l.TargetPath()) } func (l *genericResource) relTargetPathsFor(target string) []string { return l.relTargetPathsForRel(target) } 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]any) { 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 publishInit 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 hugofs.FileMetaInfo // 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 nil, err } return f, nil } func (fi *resourceFileInfo) getFileInfo() hugofs.FileMetaInfo { return fi.fi } func (fi *resourceFileInfo) getSourceFilename() string { return fi.sourceFilename } func (fi *resourceFileInfo) setSourceFilename(s string) { // Make sure it's always loaded by sourceFilename. fi.openReadSeekerCloser = nil fi.sourceFilename = s } func (fi *resourceFileInfo) getSourceFs() afero.Fs { return fi.sourceFs } 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 = fmt.Errorf("failed to open source file: %w", err) 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 multiple 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 }