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