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 }