// Copyright 2024 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" "errors" "fmt" "io" "mime" "strings" "sync" "sync/atomic" "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/resources/resource" "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) _ resource.Identifier = (*genericResource)(nil) _ identity.IdentityGroupProvider = (*genericResource)(nil) _ identity.DependencyManagerProvider = (*genericResource)(nil) _ identity.Identity = (*genericResource)(nil) _ fileInfo = (*genericResource)(nil) ) type ResourceSourceDescriptor struct { // The source content. OpenReadSeekCloser hugio.OpenReadSeekCloser // The canonical source path. Path *paths.Path // The normalized name of the resource. NameNormalized string // The name of the resource as it was read from the source. NameOriginal 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 TargetPath string BasePathRelPermalink string BasePathTargetPath string // The Data to associate with this resource. Data map[string]any // Delay publishing until either Permalink or RelPermalink is called. Maybe never. LazyPublish bool // Set when its known up front, else it's resolved from the target filename. MediaType media.Type // Used to track dependencies (e.g. imports). May be nil if that's of no concern. DependencyManager identity.Manager // A shared identity for this resource and all its clones. // If this is not set, an Identity is created. GroupIdentity identity.Identity } func (fd *ResourceSourceDescriptor) init(r *Spec) error { if len(fd.TargetBasePaths) == 0 { // If not set, we publish the same resource to all hosts. fd.TargetBasePaths = r.MultihostTargetBasePaths } if fd.OpenReadSeekCloser == nil { panic(errors.New("OpenReadSeekCloser is nil")) } if fd.TargetPath == "" { panic(errors.New("RelPath is empty")) } if fd.Path == nil { fd.Path = paths.Parse("", fd.TargetPath) } if fd.TargetPath == "" { fd.TargetPath = fd.Path.Path() } else { fd.TargetPath = paths.ToSlashPreserveLeading(fd.TargetPath) } fd.BasePathRelPermalink = paths.ToSlashPreserveLeading(fd.BasePathRelPermalink) if fd.BasePathRelPermalink == "/" { fd.BasePathRelPermalink = "" } fd.BasePathTargetPath = paths.ToSlashPreserveLeading(fd.BasePathTargetPath) if fd.BasePathTargetPath == "/" { fd.BasePathTargetPath = "" } fd.TargetPath = paths.ToSlashPreserveLeading(fd.TargetPath) for i, base := range fd.TargetBasePaths { dir := paths.ToSlashPreserveLeading(base) if dir == "/" { dir = "" } fd.TargetBasePaths[i] = dir } if fd.NameNormalized == "" { fd.NameNormalized = fd.TargetPath } if fd.NameOriginal == "" { fd.NameOriginal = fd.NameNormalized } mediaType := fd.MediaType if mediaType.IsZero() { ext := fd.Path.Ext() var ( found bool suffixInfo media.SuffixInfo ) mediaType, suffixInfo, found = r.MediaTypes().GetFirstBySuffix(ext) // TODO(bep) we need to handle these ambiguous types better, but in this context // we most likely want the application/xml type. if suffixInfo.Suffix == "xml" && mediaType.SubType == "rss" { mediaType, 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 != "" { mediaType, _ = media.FromStringAndExt(mimeStr, ext) } } } fd.MediaType = mediaType if fd.DependencyManager == nil { fd.DependencyManager = r.Cfg.NewIdentityManager("resource") } return nil } 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 resource.NameNormalizedProvider fileInfo mediaTypeAssigner targetPather ReadSeekCloser() (hugio.ReadSeekCloser, error) identity.IdentityGroupProvider identity.DependencyManagerProvider // For internal use. cloneWithUpdates(*transformationUpdate) (baseResource, error) tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser getResourcePaths() internal.ResourcePaths specProvider openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) } type specProvider interface { getSpec() *Spec } type baseResource interface { baseResourceResource baseResourceInternal resource.Staler } 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 fileInfo interface { setOpenSource(hugio.OpenReadSeekCloser) setSourceFilenameIsHash(bool) setTargetPath(internal.ResourcePaths) size() int64 hashProvider } type hashProvider interface { hash() string } type StaleValue[V any] struct { // The value. Value V // IsStaleFunc reports whether the value is stale. IsStaleFunc func() bool } func (s *StaleValue[V]) IsStale() bool { return s.IsStaleFunc() } type AtomicStaler struct { stale uint32 } func (s *AtomicStaler) MarkStale() { atomic.StoreUint32(&s.stale, 1) } func (s *AtomicStaler) IsStale() bool { return atomic.LoadUint32(&(s.stale)) > 0 } // For internal use. type GenericResourceTestInfo struct { Paths internal.ResourcePaths } // For internal use. func GetTestInfoForResource(r resource.Resource) GenericResourceTestInfo { var gr *genericResource switch v := r.(type) { case *genericResource: gr = v case *resourceAdapter: gr = v.target.(*genericResource) default: panic(fmt.Sprintf("unknown resource type: %T", r)) } return GenericResourceTestInfo{ Paths: gr.paths, } } // genericResource represents a generic linkable resource. type genericResource struct { publishInit *sync.Once sd ResourceSourceDescriptor paths internal.ResourcePaths sourceFilenameIsHash bool h *resourceHash // A hash of the source content. Is only calculated in caching situations. resource.Staler title string name string params map[string]any spec *Spec } func (l *genericResource) IdentifierBase() string { return l.sd.Path.IdentifierBase() } func (l *genericResource) GetIdentityGroup() identity.Identity { return l.sd.GroupIdentity } func (l *genericResource) GetDependencyManager() identity.Manager { return l.sd.DependencyManager } func (l *genericResource) ReadSeekCloser() (hugio.ReadSeekCloser, error) { return l.sd.OpenReadSeekCloser() } func (l *genericResource) Clone() resource.Resource { return l.clone() } func (l *genericResource) size() int64 { l.hash() return l.h.size } func (l *genericResource) hash() string { if err := l.h.init(l); err != nil { panic(err) } return l.h.value } func (l *genericResource) setOpenSource(openSource hugio.OpenReadSeekCloser) { l.sd.OpenReadSeekCloser = openSource } func (l *genericResource) setSourceFilenameIsHash(b bool) { l.sourceFilenameIsHash = b } func (l *genericResource) setTargetPath(d internal.ResourcePaths) { l.paths = d } func (l *genericResource) cloneTo(targetPath string) resource.Resource { c := l.clone() c.paths = c.paths.FromTargetPath(targetPath) return c } func (l *genericResource) Content(context.Context) (any, error) { r, err := l.ReadSeekCloser() if err != nil { return "", err } defer r.Close() return hugio.ReadString(r) } func (r *genericResource) Err() resource.ResourceError { return nil } func (l *genericResource) Data() any { return l.sd.Data } func (l *genericResource) Key() string { basePath := l.spec.Cfg.BaseURL().BasePathNoTrailingSlash var key string if basePath == "" { key = l.RelPermalink() } else { key = strings.TrimPrefix(l.RelPermalink(), basePath) } if l.spec.Cfg.IsMultihost() { key = l.spec.Lang() + key } return key } func (l *genericResource) MediaType() media.Type { return l.sd.MediaType } func (l *genericResource) setMediaType(mediaType media.Type) { l.sd.MediaType = mediaType } func (l *genericResource) Name() string { return l.name } func (l *genericResource) NameNormalized() string { return l.sd.NameNormalized } func (l *genericResource) Params() maps.Params { return l.params } func (l *genericResource) Publish() error { var err error l.publishInit.Do(func() { targetFilenames := l.getResourcePaths().TargetFilenames() if l.sourceFilenameIsHash { // This is a processed image. We want to avoid copying it if it hasn't changed. var changedFilenames []string for _, targetFilename := range targetFilenames { if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil { continue } changedFilenames = append(changedFilenames, targetFilename) } if len(changedFilenames) == 0 { return } targetFilenames = changedFilenames } 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, targetFilenames...) if err != nil { return } defer fw.Close() _, err = io.Copy(fw, fr) }) return err } func (l *genericResource) RelPermalink() string { return l.spec.PathSpec.GetBasePath(false) + paths.PathEscape(l.paths.TargetLink()) } func (l *genericResource) Permalink() string { return l.spec.Cfg.BaseURL().WithPathNoTrailingSlash + paths.PathEscape(l.paths.TargetPath()) } func (l *genericResource) ResourceType() string { return l.MediaType().MainType } 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.paths.TargetPath() } func (l *genericResource) Title() string { return l.title } func (l *genericResource) getSpec() *Spec { return l.spec } func (l *genericResource) getResourcePaths() internal.ResourcePaths { return l.paths } 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.sd.Data == nil { r.sd.Data = make(map[string]any) } for k, v := range in { if _, found := r.sd.Data[k]; !found { r.sd.Data[k] = v } } } func (rc *genericResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) { r := rc.clone() if u.content != nil { r.sd.OpenReadSeekCloser = func() (hugio.ReadSeekCloser, error) { return hugio.NewReadSeekerNoOpCloserFromString(*u.content), nil } } r.sd.MediaType = u.mediaType if u.sourceFilename != nil { if u.sourceFs == nil { return nil, errors.New("sourceFs is nil") } r.setOpenSource(func() (hugio.ReadSeekCloser, error) { return u.sourceFs.Open(*u.sourceFilename) }) } else if u.sourceFs != nil { return nil, errors.New("sourceFs is set without sourceFilename") } if u.targetPath == "" { return nil, errors.New("missing targetPath") } r.setTargetPath(r.paths.FromTargetPath(u.targetPath)) r.mergeData(u.data) return r, nil } func (l genericResource) clone() *genericResource { l.publishInit = &sync.Once{} return &l } func (r *genericResource) openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) { filenames := r.paths.FromTargetPath(relTargetPath).TargetFilenames() return helpers.OpenFilesForWriting(r.spec.BaseFs.PublishFs, filenames...) } type targetPather interface { TargetPath() string } type resourceHash struct { value string size int64 initOnce sync.Once } func (r *resourceHash) init(l hugio.ReadSeekCloserProvider) error { var initErr error r.initOnce.Do(func() { var hash string var size int64 f, err := l.ReadSeekCloser() if err != nil { initErr = fmt.Errorf("failed to open source: %w", err) return } defer f.Close() hash, size, err = helpers.MD5FromReaderFast(f) if err != nil { initErr = fmt.Errorf("failed to calculate hash: %w", err) return } r.value = hash r.size = size }) return initErr }