// 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 ( "bytes" "context" "fmt" "image" "io" "path" "strings" "sync" "github.com/gohugoio/hugo/common/constants" "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/resources/images" "github.com/gohugoio/hugo/resources/images/exif" "github.com/spf13/afero" bp "github.com/gohugoio/hugo/bufferpool" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/resources/internal" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/media" ) var ( _ resource.ContentResource = (*resourceAdapter)(nil) _ resourceCopier = (*resourceAdapter)(nil) _ resource.ReadSeekCloserResource = (*resourceAdapter)(nil) _ resource.Resource = (*resourceAdapter)(nil) _ resource.Staler = (*resourceAdapterInner)(nil) _ identity.IdentityGroupProvider = (*resourceAdapterInner)(nil) _ resource.Source = (*resourceAdapter)(nil) _ resource.Identifier = (*resourceAdapter)(nil) _ targetPathProvider = (*resourceAdapter)(nil) _ sourcePathProvider = (*resourceAdapter)(nil) _ resource.ResourceNameTitleProvider = (*resourceAdapter)(nil) _ resource.WithResourceMetaProvider = (*resourceAdapter)(nil) _ identity.DependencyManagerProvider = (*resourceAdapter)(nil) _ identity.IdentityGroupProvider = (*resourceAdapter)(nil) _ resource.NameNormalizedProvider = (*resourceAdapter)(nil) ) // 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, // TODO(bep) it's a little fragile having these constants redefined here. var transformationsToCacheOnDisk = map[string]bool{ "postcss": true, "tocss": true, "tocss-dart": true, } func newResourceAdapter(spec *Spec, lazyPublish bool, target transformableResource) *resourceAdapter { var po *publishOnce if lazyPublish { po = &publishOnce{} } return &resourceAdapter{ resourceTransformations: &resourceTransformations{}, metaProvider: target, resourceAdapterInner: &resourceAdapterInner{ ctx: context.Background(), spec: spec, publishOnce: po, target: target, Staler: &AtomicStaler{}, }, } } // 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 { // The context that started the transformation. Ctx context.Context // The dependency manager to use for dependency tracking. DependencyManager identity.Manager // The content to transform. From io.Reader // The target of content transformation. // The current implementation requires that r is written to w // even if no transformation is performed. To io.Writer // This is the relative path to the original source. Unix styled slashes. SourcePath string // This is the relative target path to the resource. Unix styled slashes. InPath string // The relative target path to the transformed resource. Unix styled slashes. OutPath string // The input media type InMediaType media.Type // The media type of the transformed resource. OutMediaType media.Type // Data data can be set on the transformed Resource. Not that this need // to be simple types, as it needs to be serialized to JSON and back. Data map[string]any // This is used to publish additional artifacts, e.g. source maps. // We may improve this. OpenResourcePublisher func(relTargetPath string) (io.WriteCloser, error) } // AddOutPathIdentifier transforming InPath to OutPath adding an identifier, // eg '.min' before any extension. func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) { ctx.OutPath = ctx.addPathIdentifier(ctx.InPath, identifier) } // PublishSourceMap writes the content to the target folder of the main resource // with the ".map" extension added. func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error { target := ctx.OutPath + ".map" f, err := ctx.OpenResourcePublisher(target) if err != nil { return err } defer f.Close() _, err = f.Write([]byte(content)) return err } // 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, _ := paths.PathAndExt(file) ctx.OutPath = path.Join(dir, (base + newExt)) } func (ctx *ResourceTransformationCtx) addPathIdentifier(inPath, identifier string) string { dir, file := path.Split(inPath) base, ext := paths.PathAndExt(file) return path.Join(dir, (base + identifier + ext)) } type publishOnce struct { publisherInit sync.Once publisherErr error } type resourceAdapter struct { commonResource *resourceTransformations *resourceAdapterInner metaProvider resource.ResourceMetaProvider } var _ identity.ForEeachIdentityByNameProvider = (*resourceAdapter)(nil) func (r *resourceAdapter) Content(ctx context.Context) (any, error) { r.init(false, true) if r.transformationsErr != nil { return nil, r.transformationsErr } return r.target.Content(ctx) } func (r *resourceAdapter) Err() resource.ResourceError { return nil } func (r *resourceAdapter) GetIdentity() identity.Identity { return identity.FirstIdentity(r.target) } func (r *resourceAdapter) Data() any { r.init(false, false) return r.target.Data() } func (r *resourceAdapter) ForEeachIdentityByName(name string, f func(identity.Identity) bool) { if constants.IsFieldRelOrPermalink(name) && !r.resourceTransformations.hasTransformationPermalinkHash() { // Special case for links without any content hash in the URL. // We don't need to rebuild all pages that use this resource, // but we want to make sure that the resource is accessed at least once. f(identity.NewFindFirstManagerIdentityProvider(r.target.GetDependencyManager(), r.target.GetIdentityGroup())) return } f(r.target.GetIdentityGroup()) f(r.target.GetDependencyManager()) } func (r *resourceAdapter) GetIdentityGroup() identity.Identity { return r.target.GetIdentityGroup() } func (r *resourceAdapter) GetDependencyManager() identity.Manager { return r.target.GetDependencyManager() } func (r resourceAdapter) cloneTo(targetPath string) resource.Resource { newtTarget := r.target.cloneTo(targetPath) newInner := &resourceAdapterInner{ ctx: r.ctx, spec: r.spec, Staler: r.Staler, target: newtTarget.(transformableResource), } if r.resourceAdapterInner.publishOnce != nil { newInner.publishOnce = &publishOnce{} } r.resourceAdapterInner = newInner return &r } func (r *resourceAdapter) Process(spec string) (images.ImageResource, error) { return r.getImageOps().Process(spec) } func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) { return r.getImageOps().Crop(spec) } func (r *resourceAdapter) Fill(spec string) (images.ImageResource, error) { return r.getImageOps().Fill(spec) } func (r *resourceAdapter) Fit(spec string) (images.ImageResource, error) { return r.getImageOps().Fit(spec) } func (r *resourceAdapter) Filter(filters ...any) (images.ImageResource, error) { return r.getImageOps().Filter(filters...) } func (r *resourceAdapter) Resize(spec string) (images.ImageResource, error) { return r.getImageOps().Resize(spec) } func (r *resourceAdapter) Height() int { return r.getImageOps().Height() } func (r *resourceAdapter) Exif() *exif.ExifInfo { return r.getImageOps().Exif() } func (r *resourceAdapter) Colors() ([]images.Color, error) { return r.getImageOps().Colors() } func (r *resourceAdapter) Key() string { r.init(false, false) return r.target.(resource.Identifier).Key() } func (r *resourceAdapter) targetPath() string { r.init(false, false) return r.target.(targetPathProvider).targetPath() } func (r *resourceAdapter) sourcePath() string { r.init(false, false) if sp, ok := r.target.(sourcePathProvider); ok { return sp.sourcePath() } return "" } func (r *resourceAdapter) MediaType() media.Type { r.init(false, false) return r.target.MediaType() } func (r *resourceAdapter) Name() string { r.init(false, false) return r.metaProvider.Name() } func (r *resourceAdapter) NameNormalized() string { r.init(false, false) return r.target.(resource.NameNormalizedProvider).NameNormalized() } func (r *resourceAdapter) Params() maps.Params { r.init(false, false) return r.metaProvider.Params() } 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) 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.metaProvider.Title() } func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransformer, error) { return r.TransformWithContext(context.Background(), t...) } func (r resourceAdapter) TransformWithContext(ctx context.Context, t ...ResourceTransformation) (ResourceTransformer, error) { r.resourceTransformations = &resourceTransformations{ transformations: append(r.transformations, t...), } r.resourceAdapterInner = &resourceAdapterInner{ ctx: ctx, spec: r.spec, Staler: r.Staler, publishOnce: &publishOnce{}, target: r.target, } return &r, nil } func (r *resourceAdapter) Width() int { return r.getImageOps().Width() } func (r *resourceAdapter) DecodeImage() (image.Image, error) { return r.getImageOps().DecodeImage() } func (r resourceAdapter) WithResourceMeta(mp resource.ResourceMetaProvider) resource.Resource { r.metaProvider = mp return &r } func (r *resourceAdapter) getImageOps() images.ImageResourceOps { img, ok := r.target.(images.ImageResourceOps) if !ok { if r.MediaType().SubType == "svg" { panic("this method is only available for raster images. To determine if an image is SVG, you can do {{ if eq .MediaType.SubType \"svg\" }}{{ end }}") } fmt.Println(r.MediaType().SubType) panic("this method is only available for image resources") } r.init(false, false) return img } 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.Errorf("Failed to publish Resource: %s", r.publisherErr) } }) } func (r *resourceAdapter) TransformationKey() string { var key string for _, tr := range r.transformations { key = key + "_" + tr.Key().Value() } return r.spec.ResourceCache.cleanKey(r.target.Key()) + "_" + hashing.MD5FromStringHexEncoded(key) } func (r *resourceAdapter) getOrTransform(publish, setContent bool) error { key := r.TransformationKey() res, err := r.spec.ResourceCache.cacheResourceTransformation.GetOrCreate(key, func(string) (*resourceAdapterInner, error) { return r.transform(key, publish, setContent) }) if err != nil { return err } r.resourceAdapterInner = res return nil } func (r *resourceAdapter) transform(key string, publish, setContent bool) (*resourceAdapterInner, error) { cache := r.spec.ResourceCache b1 := bp.GetBuffer() b2 := bp.GetBuffer() defer bp.PutBuffer(b1) defer bp.PutBuffer(b2) tctx := &ResourceTransformationCtx{ Ctx: r.ctx, Data: make(map[string]any), OpenResourcePublisher: r.target.openPublishFileForWriting, DependencyManager: r.target.GetDependencyManager(), } tctx.InMediaType = r.target.MediaType() tctx.OutMediaType = r.target.MediaType() startCtx := *tctx updates := &transformationUpdate{startCtx: startCtx} var contentrc hugio.ReadSeekCloser contentrc, err := contentReadSeekerCloser(r.target) if err != nil { return nil, err } defer contentrc.Close() tctx.From = contentrc tctx.To = b1 tctx.InPath = r.target.TargetPath() tctx.SourcePath = strings.TrimPrefix(tctx.InPath, "/") counter := 0 writeToFileCache := false var transformedContentr io.Reader for i, tr := range r.transformations { if i != 0 { tctx.InMediaType = tctx.OutMediaType } mayBeCachedOnDisk := transformationsToCacheOnDisk[tr.Key().Name] if !writeToFileCache { writeToFileCache = mayBeCachedOnDisk } 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 } } } newErr := func(err error) error { msg := fmt.Sprintf("%s: failed to transform %q (%s)", strings.ToUpper(tr.Key().Name), tctx.InPath, tctx.InMediaType.Type) if herrors.IsFeatureNotAvailableError(err) { var errMsg string switch strings.ToLower(tr.Key().Name) { case "postcss": // This transformation is not available in this // Most likely because PostCSS is not installed. errMsg = ". You need to install PostCSS. See https://gohugo.io/functions/css/postcss/" case "tailwindcss": errMsg = ". You need to install TailwindCSS CLI. See https://gohugo.io/functions/css/tailwindcss/" case "tocss": errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'." case "tocss-dart": errMsg = ". You need to install Dart Sass, see https://gohugo.io//functions/css/sass/#dart-sass" case "babel": errMsg = ". You need to install Babel, see https://gohugo.io/functions/js/babel/" } return fmt.Errorf(msg+errMsg+": %w", err) } return fmt.Errorf(msg+": %w", err) } bcfg := r.spec.BuildConfig() var tryFileCache bool if mayBeCachedOnDisk && bcfg.UseResourceCache(nil) { tryFileCache = true } else { err = tr.Transform(tctx) if err != nil && err != herrors.ErrFeatureNotAvailable { return nil, newErr(err) } if mayBeCachedOnDisk { tryFileCache = bcfg.UseResourceCache(err) } if err != nil && !tryFileCache { return nil, newErr(err) } } if tryFileCache { f := r.target.tryTransformedFileCache(key, updates) if f == nil { if err != nil { return nil, newErr(err) } return nil, newErr(fmt.Errorf("resource %q not found in file cache", key)) } transformedContentr = f updates.sourceFs = cache.fileCache.Fs defer f.Close() // The reader above is all we need. break } if tctx.OutPath != "" { tctx.InPath = tctx.OutPath tctx.OutPath = "" } } if transformedContentr == nil { updates.updateFromCtx(tctx) } var publishwriters []io.WriteCloser if publish { publicw, err := r.target.openPublishFileForWriting(updates.targetPath) if err != nil { return nil, err } publishwriters = append(publishwriters, publicw) } if transformedContentr == nil { if writeToFileCache { // Also write it to the cache fi, metaw, err := cache.writeMeta(key, updates.toTransformedResourceMetadata()) if err != nil { return nil, err } updates.sourceFilename = &fi.Name updates.sourceFs = cache.fileCache.Fs publishwriters = append(publishwriters, metaw) } // Any transformations 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 } } // Also write it to memory var contentmemw *bytes.Buffer setContent = setContent || !writeToFileCache if setContent { contentmemw = bp.GetBuffer() defer bp.PutBuffer(contentmemw) publishwriters = append(publishwriters, hugio.ToWriteCloser(contentmemw)) } publishw := hugio.NewMultiWriteCloser(publishwriters...) _, err = io.Copy(publishw, transformedContentr) if err != nil { return nil, err } publishw.Close() if setContent { s := contentmemw.String() updates.content = &s } newTarget, err := r.target.cloneWithUpdates(updates) if err != nil { return nil, err } r.target = newTarget return r.resourceAdapterInner, nil } 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.getOrTransform(publish, setContent) if r.transformationsErr != nil { if r.spec.ErrorSender != nil { r.spec.ErrorSender.SendError(r.transformationsErr) } else { r.spec.Logger.Errorf("Transformation failed: %s", r.transformationsErr) } } }) if publish && r.publishOnce != nil { r.publish() } } type resourceAdapterInner struct { // The context that started this transformation. ctx context.Context target transformableResource resource.Staler spec *Spec // Handles publishing (to /public) if needed. *publishOnce } func (r *resourceAdapterInner) GetIdentityGroup() identity.Identity { return r.target.GetIdentityGroup() } func (r *resourceAdapterInner) StaleVersion() uint32 { // Both of these are incremented on change. return r.Staler.StaleVersion() + r.target.StaleVersion() } type resourceTransformations struct { transformationsInit sync.Once transformationsErr error transformations []ResourceTransformation } // hasTransformationPermalinkHash reports whether any of the transformations // in the chain creates a permalink that's based on the content, e.g. fingerprint. func (r *resourceTransformations) hasTransformationPermalinkHash() bool { for _, t := range r.transformations { if constants.IsResourceTransformationPermalinkHash(t.Key().Name) { return true } } return false } type transformableResource interface { baseResourceInternal resource.ContentProvider resource.Resource resource.Identifier resource.Staler resourceCopier } type transformationUpdate struct { content *string sourceFilename *string sourceFs afero.Fs targetPath string mediaType media.Type data map[string]any startCtx ResourceTransformationCtx } func (u *transformationUpdate) isContentChanged() 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]any `json:"Data"` } // contentReadSeekerCloser returns a ReadSeekerCloser if possible for a given Resource. func contentReadSeekerCloser(r resource.Resource) (hugio.ReadSeekCloser, error) { switch rr := r.(type) { case resource.ReadSeekCloserResource: rc, err := rr.ReadSeekCloser() if err != nil { return nil, err } return rc, nil default: return nil, fmt.Errorf("cannot transform content of Resource of type %T", r) } }