github.com/linchen2chris/hugo@v0.0.0-20230307053224-cec209389705/resources/transform.go (about)

     1  // Copyright 2019 The Hugo Authors. All rights reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  package resources
    15  
    16  import (
    17  	"bytes"
    18  	"context"
    19  	"fmt"
    20  	"image"
    21  	"io"
    22  	"path"
    23  	"strings"
    24  	"sync"
    25  
    26  	"github.com/gohugoio/hugo/common/paths"
    27  
    28  	"github.com/gohugoio/hugo/resources/images"
    29  	"github.com/gohugoio/hugo/resources/images/exif"
    30  	"github.com/spf13/afero"
    31  
    32  	bp "github.com/gohugoio/hugo/bufferpool"
    33  
    34  	"github.com/gohugoio/hugo/common/herrors"
    35  	"github.com/gohugoio/hugo/common/hugio"
    36  	"github.com/gohugoio/hugo/common/maps"
    37  	"github.com/gohugoio/hugo/helpers"
    38  	"github.com/gohugoio/hugo/resources/internal"
    39  	"github.com/gohugoio/hugo/resources/resource"
    40  
    41  	"github.com/gohugoio/hugo/media"
    42  )
    43  
    44  var (
    45  	_ resource.ContentResource        = (*resourceAdapter)(nil)
    46  	_ resourceCopier                  = (*resourceAdapter)(nil)
    47  	_ resource.ReadSeekCloserResource = (*resourceAdapter)(nil)
    48  	_ resource.Resource               = (*resourceAdapter)(nil)
    49  	_ resource.Source                 = (*resourceAdapter)(nil)
    50  	_ resource.Identifier             = (*resourceAdapter)(nil)
    51  	_ resource.ResourceMetaProvider   = (*resourceAdapter)(nil)
    52  )
    53  
    54  // These are transformations that need special support in Hugo that may not
    55  // be available when building the theme/site so we write the transformation
    56  // result to disk and reuse if needed for these,
    57  // TODO(bep) it's a little fragile having these constants redefined here.
    58  var transformationsToCacheOnDisk = map[string]bool{
    59  	"postcss":    true,
    60  	"tocss":      true,
    61  	"tocss-dart": true,
    62  }
    63  
    64  func newResourceAdapter(spec *Spec, lazyPublish bool, target transformableResource) *resourceAdapter {
    65  	var po *publishOnce
    66  	if lazyPublish {
    67  		po = &publishOnce{}
    68  	}
    69  	return &resourceAdapter{
    70  		resourceTransformations: &resourceTransformations{},
    71  		resourceAdapterInner: &resourceAdapterInner{
    72  			ctx:         context.TODO(),
    73  			spec:        spec,
    74  			publishOnce: po,
    75  			target:      target,
    76  		},
    77  	}
    78  }
    79  
    80  // ResourceTransformation is the interface that a resource transformation step
    81  // needs to implement.
    82  type ResourceTransformation interface {
    83  	Key() internal.ResourceTransformationKey
    84  	Transform(ctx *ResourceTransformationCtx) error
    85  }
    86  
    87  type ResourceTransformationCtx struct {
    88  	// The context that started the transformation.
    89  	Ctx context.Context
    90  
    91  	// The content to transform.
    92  	From io.Reader
    93  
    94  	// The target of content transformation.
    95  	// The current implementation requires that r is written to w
    96  	// even if no transformation is performed.
    97  	To io.Writer
    98  
    99  	// This is the relative path to the original source. Unix styled slashes.
   100  	SourcePath string
   101  
   102  	// This is the relative target path to the resource. Unix styled slashes.
   103  	InPath string
   104  
   105  	// The relative target path to the transformed resource. Unix styled slashes.
   106  	OutPath string
   107  
   108  	// The input media type
   109  	InMediaType media.Type
   110  
   111  	// The media type of the transformed resource.
   112  	OutMediaType media.Type
   113  
   114  	// Data data can be set on the transformed Resource. Not that this need
   115  	// to be simple types, as it needs to be serialized to JSON and back.
   116  	Data map[string]any
   117  
   118  	// This is used to publish additional artifacts, e.g. source maps.
   119  	// We may improve this.
   120  	OpenResourcePublisher func(relTargetPath string) (io.WriteCloser, error)
   121  }
   122  
   123  // AddOutPathIdentifier transforming InPath to OutPath adding an identifier,
   124  // eg '.min' before any extension.
   125  func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
   126  	ctx.OutPath = ctx.addPathIdentifier(ctx.InPath, identifier)
   127  }
   128  
   129  // PublishSourceMap writes the content to the target folder of the main resource
   130  // with the ".map" extension added.
   131  func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
   132  	target := ctx.OutPath + ".map"
   133  	f, err := ctx.OpenResourcePublisher(target)
   134  	if err != nil {
   135  		return err
   136  	}
   137  	defer f.Close()
   138  	_, err = f.Write([]byte(content))
   139  	return err
   140  }
   141  
   142  // ReplaceOutPathExtension transforming InPath to OutPath replacing the file
   143  // extension, e.g. ".scss"
   144  func (ctx *ResourceTransformationCtx) ReplaceOutPathExtension(newExt string) {
   145  	dir, file := path.Split(ctx.InPath)
   146  	base, _ := paths.PathAndExt(file)
   147  	ctx.OutPath = path.Join(dir, (base + newExt))
   148  }
   149  
   150  func (ctx *ResourceTransformationCtx) addPathIdentifier(inPath, identifier string) string {
   151  	dir, file := path.Split(inPath)
   152  	base, ext := paths.PathAndExt(file)
   153  	return path.Join(dir, (base + identifier + ext))
   154  }
   155  
   156  type publishOnce struct {
   157  	publisherInit sync.Once
   158  	publisherErr  error
   159  }
   160  
   161  type resourceAdapter struct {
   162  	commonResource
   163  	*resourceTransformations
   164  	*resourceAdapterInner
   165  }
   166  
   167  func (r *resourceAdapter) Content(ctx context.Context) (any, error) {
   168  	r.init(false, true)
   169  	if r.transformationsErr != nil {
   170  		return nil, r.transformationsErr
   171  	}
   172  	return r.target.Content(ctx)
   173  }
   174  
   175  func (r *resourceAdapter) Err() resource.ResourceError {
   176  	return nil
   177  }
   178  
   179  func (r *resourceAdapter) Data() any {
   180  	r.init(false, false)
   181  	return r.target.Data()
   182  }
   183  
   184  func (r resourceAdapter) cloneTo(targetPath string) resource.Resource {
   185  	newtTarget := r.target.cloneTo(targetPath)
   186  	newInner := &resourceAdapterInner{
   187  		ctx:    r.ctx,
   188  		spec:   r.spec,
   189  		target: newtTarget.(transformableResource),
   190  	}
   191  	if r.resourceAdapterInner.publishOnce != nil {
   192  		newInner.publishOnce = &publishOnce{}
   193  	}
   194  	r.resourceAdapterInner = newInner
   195  	return &r
   196  }
   197  
   198  func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) {
   199  	return r.getImageOps().Crop(spec)
   200  }
   201  
   202  func (r *resourceAdapter) Fill(spec string) (images.ImageResource, error) {
   203  	return r.getImageOps().Fill(spec)
   204  }
   205  
   206  func (r *resourceAdapter) Fit(spec string) (images.ImageResource, error) {
   207  	return r.getImageOps().Fit(spec)
   208  }
   209  
   210  func (r *resourceAdapter) Filter(filters ...any) (images.ImageResource, error) {
   211  	return r.getImageOps().Filter(filters...)
   212  }
   213  
   214  func (r *resourceAdapter) Height() int {
   215  	return r.getImageOps().Height()
   216  }
   217  
   218  func (r *resourceAdapter) Exif() *exif.ExifInfo {
   219  	return r.getImageOps().Exif()
   220  }
   221  
   222  func (r *resourceAdapter) Colors() ([]string, error) {
   223  	return r.getImageOps().Colors()
   224  }
   225  
   226  func (r *resourceAdapter) Key() string {
   227  	r.init(false, false)
   228  	return r.target.(resource.Identifier).Key()
   229  }
   230  
   231  func (r *resourceAdapter) MediaType() media.Type {
   232  	r.init(false, false)
   233  	return r.target.MediaType()
   234  }
   235  
   236  func (r *resourceAdapter) Name() string {
   237  	r.init(false, false)
   238  	return r.target.Name()
   239  }
   240  
   241  func (r *resourceAdapter) Params() maps.Params {
   242  	r.init(false, false)
   243  	return r.target.Params()
   244  }
   245  
   246  func (r *resourceAdapter) Permalink() string {
   247  	r.init(true, false)
   248  	return r.target.Permalink()
   249  }
   250  
   251  func (r *resourceAdapter) Publish() error {
   252  	r.init(false, false)
   253  
   254  	return r.target.Publish()
   255  }
   256  
   257  func (r *resourceAdapter) ReadSeekCloser() (hugio.ReadSeekCloser, error) {
   258  	r.init(false, false)
   259  	return r.target.ReadSeekCloser()
   260  }
   261  
   262  func (r *resourceAdapter) RelPermalink() string {
   263  	r.init(true, false)
   264  	return r.target.RelPermalink()
   265  }
   266  
   267  func (r *resourceAdapter) Resize(spec string) (images.ImageResource, error) {
   268  	return r.getImageOps().Resize(spec)
   269  }
   270  
   271  func (r *resourceAdapter) ResourceType() string {
   272  	r.init(false, false)
   273  	return r.target.ResourceType()
   274  }
   275  
   276  func (r *resourceAdapter) String() string {
   277  	return r.Name()
   278  }
   279  
   280  func (r *resourceAdapter) Title() string {
   281  	r.init(false, false)
   282  	return r.target.Title()
   283  }
   284  
   285  func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransformer, error) {
   286  	return r.TransformWithContext(context.Background(), t...)
   287  }
   288  
   289  func (r resourceAdapter) TransformWithContext(ctx context.Context, t ...ResourceTransformation) (ResourceTransformer, error) {
   290  
   291  	r.resourceTransformations = &resourceTransformations{
   292  		transformations: append(r.transformations, t...),
   293  	}
   294  
   295  	r.resourceAdapterInner = &resourceAdapterInner{
   296  		ctx:         ctx,
   297  		spec:        r.spec,
   298  		publishOnce: &publishOnce{},
   299  		target:      r.target,
   300  	}
   301  
   302  	return &r, nil
   303  }
   304  
   305  func (r *resourceAdapter) Width() int {
   306  	return r.getImageOps().Width()
   307  }
   308  
   309  func (r *resourceAdapter) DecodeImage() (image.Image, error) {
   310  	return r.getImageOps().DecodeImage()
   311  }
   312  
   313  func (r *resourceAdapter) getImageOps() images.ImageResourceOps {
   314  	img, ok := r.target.(images.ImageResourceOps)
   315  	if !ok {
   316  		if r.MediaType().SubType == "svg" {
   317  			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 }}")
   318  		}
   319  		fmt.Println(r.MediaType().SubType)
   320  		panic("this method is only available for image resources")
   321  	}
   322  	r.init(false, false)
   323  	return img
   324  }
   325  
   326  func (r *resourceAdapter) getMetaAssigner() metaAssigner {
   327  	return r.target
   328  }
   329  
   330  func (r *resourceAdapter) getSpec() *Spec {
   331  	return r.spec
   332  }
   333  
   334  func (r *resourceAdapter) publish() {
   335  	if r.publishOnce == nil {
   336  		return
   337  	}
   338  
   339  	r.publisherInit.Do(func() {
   340  		r.publisherErr = r.target.Publish()
   341  
   342  		if r.publisherErr != nil {
   343  			r.spec.Logger.Errorf("Failed to publish Resource: %s", r.publisherErr)
   344  		}
   345  	})
   346  }
   347  
   348  func (r *resourceAdapter) TransformationKey() string {
   349  	// Files with a suffix will be stored in cache (both on disk and in memory)
   350  	// partitioned by their suffix.
   351  	var key string
   352  	for _, tr := range r.transformations {
   353  		key = key + "_" + tr.Key().Value()
   354  	}
   355  
   356  	base := ResourceCacheKey(r.target.Key())
   357  	return r.spec.ResourceCache.cleanKey(base) + "_" + helpers.MD5String(key)
   358  }
   359  
   360  func (r *resourceAdapter) transform(publish, setContent bool) error {
   361  	cache := r.spec.ResourceCache
   362  
   363  	key := r.TransformationKey()
   364  
   365  	cached, found := cache.get(key)
   366  
   367  	if found {
   368  		r.resourceAdapterInner = cached.(*resourceAdapterInner)
   369  		return nil
   370  	}
   371  
   372  	// Acquire a write lock for the named transformation.
   373  	cache.nlocker.Lock(key)
   374  	// Check the cache again.
   375  	cached, found = cache.get(key)
   376  	if found {
   377  		r.resourceAdapterInner = cached.(*resourceAdapterInner)
   378  		cache.nlocker.Unlock(key)
   379  		return nil
   380  	}
   381  
   382  	defer cache.nlocker.Unlock(key)
   383  	defer cache.set(key, r.resourceAdapterInner)
   384  
   385  	b1 := bp.GetBuffer()
   386  	b2 := bp.GetBuffer()
   387  	defer bp.PutBuffer(b1)
   388  	defer bp.PutBuffer(b2)
   389  
   390  	tctx := &ResourceTransformationCtx{
   391  		Ctx:                   r.ctx,
   392  		Data:                  make(map[string]any),
   393  		OpenResourcePublisher: r.target.openPublishFileForWriting,
   394  	}
   395  
   396  	tctx.InMediaType = r.target.MediaType()
   397  	tctx.OutMediaType = r.target.MediaType()
   398  
   399  	startCtx := *tctx
   400  	updates := &transformationUpdate{startCtx: startCtx}
   401  
   402  	var contentrc hugio.ReadSeekCloser
   403  
   404  	contentrc, err := contentReadSeekerCloser(r.target)
   405  	if err != nil {
   406  		return err
   407  	}
   408  
   409  	defer contentrc.Close()
   410  
   411  	tctx.From = contentrc
   412  	tctx.To = b1
   413  
   414  	tctx.InPath = r.target.TargetPath()
   415  	tctx.SourcePath = tctx.InPath
   416  
   417  	counter := 0
   418  	writeToFileCache := false
   419  
   420  	var transformedContentr io.Reader
   421  
   422  	for i, tr := range r.transformations {
   423  		if i != 0 {
   424  			tctx.InMediaType = tctx.OutMediaType
   425  		}
   426  
   427  		mayBeCachedOnDisk := transformationsToCacheOnDisk[tr.Key().Name]
   428  		if !writeToFileCache {
   429  			writeToFileCache = mayBeCachedOnDisk
   430  		}
   431  
   432  		if i > 0 {
   433  			hasWrites := tctx.To.(*bytes.Buffer).Len() > 0
   434  			if hasWrites {
   435  				counter++
   436  				// Switch the buffers
   437  				if counter%2 == 0 {
   438  					tctx.From = b2
   439  					b1.Reset()
   440  					tctx.To = b1
   441  				} else {
   442  					tctx.From = b1
   443  					b2.Reset()
   444  					tctx.To = b2
   445  				}
   446  			}
   447  		}
   448  
   449  		newErr := func(err error) error {
   450  			msg := fmt.Sprintf("%s: failed to transform %q (%s)", strings.ToUpper(tr.Key().Name), tctx.InPath, tctx.InMediaType.Type())
   451  
   452  			if err == herrors.ErrFeatureNotAvailable {
   453  				var errMsg string
   454  				if tr.Key().Name == "postcss" {
   455  					// This transformation is not available in this
   456  					// Most likely because PostCSS is not installed.
   457  					errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/"
   458  				} else if tr.Key().Name == "tocss" {
   459  					errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'."
   460  				} else if tr.Key().Name == "tocss-dart" {
   461  					errMsg = ". You need dart-sass-embedded in your system $PATH."
   462  
   463  				} else if tr.Key().Name == "babel" {
   464  					errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
   465  				}
   466  
   467  				return fmt.Errorf(msg+errMsg+": %w", err)
   468  			}
   469  
   470  			return fmt.Errorf(msg+": %w", err)
   471  		}
   472  
   473  		var tryFileCache bool
   474  
   475  		if mayBeCachedOnDisk && r.spec.BuildConfig.UseResourceCache(nil) {
   476  			tryFileCache = true
   477  		} else {
   478  			err = tr.Transform(tctx)
   479  			if err != nil && err != herrors.ErrFeatureNotAvailable {
   480  				return newErr(err)
   481  			}
   482  
   483  			if mayBeCachedOnDisk {
   484  				tryFileCache = r.spec.BuildConfig.UseResourceCache(err)
   485  			}
   486  			if err != nil && !tryFileCache {
   487  				return newErr(err)
   488  			}
   489  		}
   490  
   491  		if tryFileCache {
   492  			f := r.target.tryTransformedFileCache(key, updates)
   493  			if f == nil {
   494  				if err != nil {
   495  					return newErr(err)
   496  				}
   497  				return newErr(fmt.Errorf("resource %q not found in file cache", key))
   498  			}
   499  			transformedContentr = f
   500  			updates.sourceFs = cache.fileCache.Fs
   501  			defer f.Close()
   502  
   503  			// The reader above is all we need.
   504  			break
   505  		}
   506  
   507  		if tctx.OutPath != "" {
   508  			tctx.InPath = tctx.OutPath
   509  			tctx.OutPath = ""
   510  		}
   511  	}
   512  
   513  	if transformedContentr == nil {
   514  		updates.updateFromCtx(tctx)
   515  	}
   516  
   517  	var publishwriters []io.WriteCloser
   518  
   519  	if publish {
   520  		publicw, err := r.target.openPublishFileForWriting(updates.targetPath)
   521  		if err != nil {
   522  			return err
   523  		}
   524  		publishwriters = append(publishwriters, publicw)
   525  	}
   526  
   527  	if transformedContentr == nil {
   528  		if writeToFileCache {
   529  			// Also write it to the cache
   530  			fi, metaw, err := cache.writeMeta(key, updates.toTransformedResourceMetadata())
   531  			if err != nil {
   532  				return err
   533  			}
   534  			updates.sourceFilename = &fi.Name
   535  			updates.sourceFs = cache.fileCache.Fs
   536  			publishwriters = append(publishwriters, metaw)
   537  		}
   538  
   539  		// Any transformations reading from From must also write to To.
   540  		// This means that if the target buffer is empty, we can just reuse
   541  		// the original reader.
   542  		if b, ok := tctx.To.(*bytes.Buffer); ok && b.Len() > 0 {
   543  			transformedContentr = tctx.To.(*bytes.Buffer)
   544  		} else {
   545  			transformedContentr = contentrc
   546  		}
   547  	}
   548  
   549  	// Also write it to memory
   550  	var contentmemw *bytes.Buffer
   551  
   552  	setContent = setContent || !writeToFileCache
   553  
   554  	if setContent {
   555  		contentmemw = bp.GetBuffer()
   556  		defer bp.PutBuffer(contentmemw)
   557  		publishwriters = append(publishwriters, hugio.ToWriteCloser(contentmemw))
   558  	}
   559  
   560  	publishw := hugio.NewMultiWriteCloser(publishwriters...)
   561  	_, err = io.Copy(publishw, transformedContentr)
   562  	if err != nil {
   563  		return err
   564  	}
   565  	publishw.Close()
   566  
   567  	if setContent {
   568  		s := contentmemw.String()
   569  		updates.content = &s
   570  	}
   571  
   572  	newTarget, err := r.target.cloneWithUpdates(updates)
   573  	if err != nil {
   574  		return err
   575  	}
   576  	r.target = newTarget
   577  
   578  	return nil
   579  }
   580  
   581  func (r *resourceAdapter) init(publish, setContent bool) {
   582  	r.initTransform(publish, setContent)
   583  }
   584  
   585  func (r *resourceAdapter) initTransform(publish, setContent bool) {
   586  	r.transformationsInit.Do(func() {
   587  		if len(r.transformations) == 0 {
   588  			// Nothing to do.
   589  			return
   590  		}
   591  
   592  		if publish {
   593  			// The transformation will write the content directly to
   594  			// the destination.
   595  			r.publishOnce = nil
   596  		}
   597  
   598  		r.transformationsErr = r.transform(publish, setContent)
   599  		if r.transformationsErr != nil {
   600  			if r.spec.ErrorSender != nil {
   601  				r.spec.ErrorSender.SendError(r.transformationsErr)
   602  			} else {
   603  				r.spec.Logger.Errorf("Transformation failed: %s", r.transformationsErr)
   604  			}
   605  		}
   606  	})
   607  
   608  	if publish && r.publishOnce != nil {
   609  		r.publish()
   610  	}
   611  }
   612  
   613  type resourceAdapterInner struct {
   614  	// The context that started this transformation.
   615  	ctx context.Context
   616  
   617  	target transformableResource
   618  
   619  	spec *Spec
   620  
   621  	// Handles publishing (to /public) if needed.
   622  	*publishOnce
   623  }
   624  
   625  type resourceTransformations struct {
   626  	transformationsInit sync.Once
   627  	transformationsErr  error
   628  	transformations     []ResourceTransformation
   629  }
   630  
   631  type transformableResource interface {
   632  	baseResourceInternal
   633  
   634  	resource.ContentProvider
   635  	resource.Resource
   636  	resource.Identifier
   637  	resourceCopier
   638  }
   639  
   640  type transformationUpdate struct {
   641  	content        *string
   642  	sourceFilename *string
   643  	sourceFs       afero.Fs
   644  	targetPath     string
   645  	mediaType      media.Type
   646  	data           map[string]any
   647  
   648  	startCtx ResourceTransformationCtx
   649  }
   650  
   651  func (u *transformationUpdate) isContentChanged() bool {
   652  	return u.content != nil || u.sourceFilename != nil
   653  }
   654  
   655  func (u *transformationUpdate) toTransformedResourceMetadata() transformedResourceMetadata {
   656  	return transformedResourceMetadata{
   657  		MediaTypeV: u.mediaType.Type(),
   658  		Target:     u.targetPath,
   659  		MetaData:   u.data,
   660  	}
   661  }
   662  
   663  func (u *transformationUpdate) updateFromCtx(ctx *ResourceTransformationCtx) {
   664  	u.targetPath = ctx.OutPath
   665  	u.mediaType = ctx.OutMediaType
   666  	u.data = ctx.Data
   667  	u.targetPath = ctx.InPath
   668  }
   669  
   670  // We will persist this information to disk.
   671  type transformedResourceMetadata struct {
   672  	Target     string         `json:"Target"`
   673  	MediaTypeV string         `json:"MediaType"`
   674  	MetaData   map[string]any `json:"Data"`
   675  }
   676  
   677  // contentReadSeekerCloser returns a ReadSeekerCloser if possible for a given Resource.
   678  func contentReadSeekerCloser(r resource.Resource) (hugio.ReadSeekCloser, error) {
   679  	switch rr := r.(type) {
   680  	case resource.ReadSeekCloserResource:
   681  		rc, err := rr.ReadSeekCloser()
   682  		if err != nil {
   683  			return nil, err
   684  		}
   685  		return rc, nil
   686  	default:
   687  		return nil, fmt.Errorf("cannot transform content of Resource of type %T", r)
   688  
   689  	}
   690  }