Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/gohugoio/hugo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'resources')
-rw-r--r--resources/.gitattributes2
-rw-r--r--resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content1
-rw-r--r--resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json1
-rw-r--r--resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content18
-rw-r--r--resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.json1
-rw-r--r--resources/errorResource.go132
-rw-r--r--resources/image.go452
-rw-r--r--resources/image_cache.go168
-rw-r--r--resources/image_extended_test.go42
-rw-r--r--resources/image_test.go843
-rw-r--r--resources/images/color.go83
-rw-r--r--resources/images/color_test.go89
-rw-r--r--resources/images/config.go462
-rw-r--r--resources/images/config_test.go158
-rw-r--r--resources/images/exif/exif.go272
-rw-r--r--resources/images/exif/exif_test.go135
-rw-r--r--resources/images/filters.go236
-rw-r--r--resources/images/filters_test.go33
-rw-r--r--resources/images/image.go410
-rw-r--r--resources/images/image_resource.go53
-rw-r--r--resources/images/overlay.go43
-rw-r--r--resources/images/resampling.go214
-rw-r--r--resources/images/smartcrop.go104
-rw-r--r--resources/images/text.go108
-rw-r--r--resources/images/webp/webp.go36
-rw-r--r--resources/images/webp/webp_notavailable.go36
-rw-r--r--resources/integration_test.go96
-rw-r--r--resources/internal/key.go42
-rw-r--r--resources/internal/key_test.go36
-rw-r--r--resources/jsconfig/jsconfig.go92
-rw-r--r--resources/jsconfig/jsconfig_test.go35
-rw-r--r--resources/page/integration_test.go138
-rw-r--r--resources/page/page.go420
-rw-r--r--resources/page/page_author.go44
-rw-r--r--resources/page/page_data.go42
-rw-r--r--resources/page/page_data_test.go55
-rw-r--r--resources/page/page_generate/.gitignore1
-rw-r--r--resources/page/page_generate/generate_page_wrappers.go280
-rw-r--r--resources/page/page_kinds.go47
-rw-r--r--resources/page/page_kinds_test.go37
-rw-r--r--resources/page/page_lazy_contentprovider.go124
-rw-r--r--resources/page/page_marshaljson.autogen.go211
-rw-r--r--resources/page/page_matcher.go142
-rw-r--r--resources/page/page_matcher_test.go83
-rw-r--r--resources/page/page_nop.go515
-rw-r--r--resources/page/page_outputformat.go95
-rw-r--r--resources/page/page_paths.go342
-rw-r--r--resources/page/page_paths_test.go293
-rw-r--r--resources/page/page_wrappers.autogen.go25
-rw-r--r--resources/page/pagegroup.go460
-rw-r--r--resources/page/pagegroup_test.go466
-rw-r--r--resources/page/pagemeta/page_frontmatter.go427
-rw-r--r--resources/page/pagemeta/page_frontmatter_test.go257
-rw-r--r--resources/page/pagemeta/pagemeta.go110
-rw-r--r--resources/page/pagemeta/pagemeta_test.go92
-rw-r--r--resources/page/pages.go157
-rw-r--r--resources/page/pages_cache.go135
-rw-r--r--resources/page/pages_cache_test.go87
-rw-r--r--resources/page/pages_language_merge.go62
-rw-r--r--resources/page/pages_prev_next.go34
-rw-r--r--resources/page/pages_prev_next_test.go91
-rw-r--r--resources/page/pages_related.go195
-rw-r--r--resources/page/pages_related_test.go86
-rw-r--r--resources/page/pages_sort.go412
-rw-r--r--resources/page/pages_sort_search.go125
-rw-r--r--resources/page/pages_sort_search_test.go122
-rw-r--r--resources/page/pages_sort_test.go289
-rw-r--r--resources/page/pages_test.go72
-rw-r--r--resources/page/pagination.go396
-rw-r--r--resources/page/pagination_test.go310
-rw-r--r--resources/page/permalinks.go371
-rw-r--r--resources/page/permalinks_test.go241
-rw-r--r--resources/page/site.go167
-rw-r--r--resources/page/testhelpers_test.go622
-rw-r--r--resources/page/weighted.go138
-rw-r--r--resources/page/zero_file.autogen.go88
-rw-r--r--resources/post_publish.go51
-rw-r--r--resources/postpub/fields.go59
-rw-r--r--resources/postpub/fields_test.go45
-rw-r--r--resources/postpub/postpub.go181
-rw-r--r--resources/resource.go709
-rw-r--r--resources/resource/dates.go93
-rw-r--r--resources/resource/params.go33
-rw-r--r--resources/resource/resource_helpers.go70
-rw-r--r--resources/resource/resources.go198
-rw-r--r--resources/resource/resourcetypes.go224
-rw-r--r--resources/resource_cache.go305
-rw-r--r--resources/resource_cache_test.go58
-rw-r--r--resources/resource_factories/bundler/bundler.go148
-rw-r--r--resources/resource_factories/bundler/bundler_test.go40
-rw-r--r--resources/resource_factories/create/create.go151
-rw-r--r--resources/resource_factories/create/remote.go279
-rw-r--r--resources/resource_factories/create/remote_test.go96
-rw-r--r--resources/resource_metadata.go144
-rw-r--r--resources/resource_metadata_test.go221
-rw-r--r--resources/resource_spec.go345
-rw-r--r--resources/resource_test.go270
-rw-r--r--resources/resource_transformers/babel/babel.go239
-rw-r--r--resources/resource_transformers/babel/integration_test.go94
-rw-r--r--resources/resource_transformers/htesting/testhelpers.go78
-rw-r--r--resources/resource_transformers/integrity/integrity.go120
-rw-r--r--resources/resource_transformers/integrity/integrity_test.go69
-rw-r--r--resources/resource_transformers/js/build.go222
-rw-r--r--resources/resource_transformers/js/build_test.go14
-rw-r--r--resources/resource_transformers/js/integration_test.go261
-rw-r--r--resources/resource_transformers/js/options.go424
-rw-r--r--resources/resource_transformers/js/options_test.go184
-rw-r--r--resources/resource_transformers/minifier/integration_test.go47
-rw-r--r--resources/resource_transformers/minifier/minify.go59
-rw-r--r--resources/resource_transformers/minifier/minify_test.go42
-rw-r--r--resources/resource_transformers/postcss/integration_test.go244
-rw-r--r--resources/resource_transformers/postcss/postcss.go440
-rw-r--r--resources/resource_transformers/postcss/postcss_test.go166
-rw-r--r--resources/resource_transformers/templates/execute_as_template.go74
-rw-r--r--resources/resource_transformers/templates/integration_test.go77
-rw-r--r--resources/resource_transformers/tocss/dartsass/client.go143
-rw-r--r--resources/resource_transformers/tocss/dartsass/integration_test.go273
-rw-r--r--resources/resource_transformers/tocss/dartsass/transform.go182
-rw-r--r--resources/resource_transformers/tocss/scss/client.go90
-rw-r--r--resources/resource_transformers/tocss/scss/client_extended.go60
-rw-r--r--resources/resource_transformers/tocss/scss/client_notavailable.go31
-rw-r--r--resources/resource_transformers/tocss/scss/client_test.go49
-rw-r--r--resources/resource_transformers/tocss/scss/integration_test.go247
-rw-r--r--resources/resource_transformers/tocss/scss/tocss.go204
-rw-r--r--resources/testdata/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpgbin0 -> 90587 bytes
-rw-r--r--resources/testdata/circle.svg5
-rw-r--r--resources/testdata/fuzzy-cirlcle.pngbin0 -> 26792 bytes
-rw-r--r--resources/testdata/giphy.gifbin0 -> 52213 bytes
-rw-r--r--resources/testdata/gohugoio-card.gifbin0 -> 10820 bytes
-rw-r--r--resources/testdata/gohugoio.pngbin0 -> 73886 bytes
-rw-r--r--resources/testdata/gohugoio24.pngbin0 -> 267952 bytes
-rw-r--r--resources/testdata/gohugoio8.pngbin0 -> 73538 bytes
-rw-r--r--resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gifbin0 -> 73619 bytes
-rw-r--r--resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gifbin0 -> 310936 bytes
-rw-r--r--resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_100x0_resize_box.gifbin0 -> 3555 bytes
-rw-r--r--resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_220x0_resize_box.gifbin0 -> 12249 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_3.pngbin0 -> 11002 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_1a923841aa34545db29f46a8fc4c5b0d.pngbin0 -> 46054 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_3.pngbin0 -> 62018 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_3.pngbin0 -> 20979 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_3.pngbin0 -> 23035 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_3.pngbin0 -> 46395 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_3.pngbin0 -> 38597 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_324b4d42c8746a684068d123fad8b744.pngbin0 -> 78589 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_3.pngbin0 -> 60099 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_3.pngbin0 -> 60099 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_43055c40cb4a15bd8491bfc502799f43.pngbin0 -> 45378 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_4ea8f246299cc5fba9744bdf162bd57d.pngbin0 -> 8960 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_3.pngbin0 -> 112941 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_60c098f0ca6626668d9e3ad6bfb38b5b.pngbin0 -> 64612 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_6575f3a3c39a30cba9d76a6045c36de6.pngbin0 -> 61497 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_8166ccaf22bdabb94c9bb90bffe64133.pngbin0 -> 65067 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9165e5559db8ba31a401327b5617c098.pngbin0 -> 85767 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9a8d95423df65a9c230a4cc88056c13a.pngbin0 -> 58718 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a38a1924befb1721a09be7d432f5f70f.pngbin0 -> 60267 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a487ef4bea3dba1e1a84be5358cfef39.pngbin0 -> 60182 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a751b6cd969d7feab12540a8bb0ca927.pngbin0 -> 53835 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_abcdd770eaed9301cfff4bc2f96459ba.pngbin0 -> 62941 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_aeaaf23afe6fb4702bd3992426d0cad3.pngbin0 -> 62049 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_bdde5e36f15689c1451933f92fd357b3.pngbin0 -> 59041 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d111079da5d8d143b6cae10d6fedbc24.pngbin0 -> 44573 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d87fd348ad697a9b16399709441d9d56.pngbin0 -> 58776 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_e8ef2efdde4357a79694ea9c2be82f63.pngbin0 -> 34370 bytes
-rw-r--r--resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_ee57777f148caaa6993972d9709fdf2d.pngbin0 -> 62162 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_00cd4ff18b53ecbd78e42aefe5fbf522.pngbin0 -> 24546 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_3.pngbin0 -> 5969 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_3.pngbin0 -> 25346 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_3.pngbin0 -> 10198 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_276650b97daa7ae98e79b929d7f87c19.pngbin0 -> 34375 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_2e05d39f4cb329be10e8c515494cef76.pngbin0 -> 36145 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_3.pngbin0 -> 10210 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_3.pngbin0 -> 20658 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_3.pngbin0 -> 17575 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3980c5868e0b6f20ec95424dfdcb1d67.pngbin0 -> 24422 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_398ca764abfff83bb15318068105dcb9.pngbin0 -> 26511 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3ed273f49d1dc83891f5736e21fc5f44.pngbin0 -> 33095 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_3.pngbin0 -> 26281 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_3.pngbin0 -> 26281 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_428e769d14483c2fcdd6f5c5138e2066.pngbin0 -> 23863 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_3.pngbin0 -> 47492 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.pngbin0 -> 20199 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_871826faffc414ca3746f65fc9910eed.pngbin0 -> 21552 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0505112c99af88626ac9b9a16a27acb.pngbin0 -> 34281 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0ffc0f22f22e6920f3cad414d6db6ba.pngbin0 -> 24075 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.pngbin0 -> 34054 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_b34412412a1cf1658e516a335b0a8dd4.pngbin0 -> 27285 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c5140f11378ddb13843432a5b489594a.pngbin0 -> 26663 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d17d0184674fcf0a4d770c90bed503db.pngbin0 -> 20267 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.pngbin0 -> 28414 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_e4d38674b70d9ef559c5df72c9262790.pngbin0 -> 29412 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_eff9583d9b94ac79c60cb099846ce8f3.pngbin0 -> 18095 bytes
-rw-r--r--resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_f17bba59421e7a500387232295512fc0.pngbin0 -> 27034 bytes
-rw-r--r--resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_3.pngbin0 -> 5597 bytes
-rw-r--r--resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_3.jpgbin0 -> 7640 bytes
-rw-r--r--resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_30x0_resize_box_3.pngbin0 -> 1314 bytes
-rw-r--r--resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_3.pngbin0 -> 4220 bytes
-rw-r--r--resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_3.jpgbin0 -> 2909 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpgbin0 -> 6446 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpgbin0 -> 1805 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpgbin0 -> 7033 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpgbin0 -> 4222 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpgbin0 -> 2698 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpgbin0 -> 2065 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpgbin0 -> 4667 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpgbin0 -> 4919 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpgbin0 -> 7087 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpgbin0 -> 6435 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpgbin0 -> 4449 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpgbin0 -> 6941 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpgbin0 -> 7311 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpgbin0 -> 6448 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_510813cc53c37e2d489d2f9fdb13f749.jpgbin0 -> 20818 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpgbin0 -> 15636 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6673ece428cb7d523234ca0d7c299542.jpgbin0 -> 7088 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpgbin0 -> 6563 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpgbin0 -> 6580 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpgbin0 -> 6132 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpgbin0 -> 5908 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c2d24766b49f3147f5a4137a8db592ac.jpgbin0 -> 7252 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpgbin0 -> 6337 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c50a17db1e6d1bd0fe31a9a3444f1587.jpgbin0 -> 6850 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpgbin0 -> 4464 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpgbin0 -> 5858 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpgbin0 -> 5469 bytes
-rw-r--r--resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpgbin0 -> 6421 bytes
-rw-r--r--resources/testdata/golden_webp/fuzzy-cirlcle_hu525d1a6cf670e85f5e8f19890241399b_26792_200x0_resize_q75_h2_box_3.webpbin0 -> 7848 bytes
-rw-r--r--resources/testdata/gopher-hero8.pngbin0 -> 13327 bytes
-rw-r--r--resources/testdata/gradient-circle.pngbin0 -> 20069 bytes
-rw-r--r--resources/testdata/iss8079.jpgbin0 -> 116955 bytes
-rw-r--r--resources/testdata/sub/gohugoio2.pngbin0 -> 73886 bytes
-rw-r--r--resources/testdata/sunrise.JPGbin0 -> 90587 bytes
-rw-r--r--resources/testdata/sunset.jpgbin0 -> 90587 bytes
-rw-r--r--resources/testdata/sunset.webpbin0 -> 59826 bytes
-rw-r--r--resources/testhelpers_test.go205
-rw-r--r--resources/transform.go670
-rw-r--r--resources/transform_test.go440
236 files changed, 22489 insertions, 23 deletions
diff --git a/resources/.gitattributes b/resources/.gitattributes
deleted file mode 100644
index a205a8e9d..000000000
--- a/resources/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-*.* linguist-generated=true
-*.* -diff -merge \ No newline at end of file
diff --git a/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content b/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content
deleted file mode 100644
index 42d7140c5..000000000
--- a/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.content
+++ /dev/null
@@ -1 +0,0 @@
-@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:200;src:local('Muli Extra Light '),local('Muli-Extra Light'),url(/fonts/muli-latin-200.woff2) format('woff2'),url(/fonts/muli-latin-200.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:200;src:local('Muli Extra Light italic'),local('Muli-Extra Lightitalic'),url(/fonts/muli-latin-200italic.woff2) format('woff2'),url(/fonts/muli-latin-200italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:300;src:local('Muli Light '),local(Muli-Light),url(/fonts/muli-latin-300.woff2) format('woff2'),url(/fonts/muli-latin-300.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:300;src:local('Muli Light italic'),local(Muli-Lightitalic),url(/fonts/muli-latin-300italic.woff2) format('woff2'),url(/fonts/muli-latin-300italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:400;src:local('Muli Regular '),local(Muli-Regular),url(/fonts/muli-latin-400.woff2) format('woff2'),url(/fonts/muli-latin-400.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:400;src:local('Muli Regular italic'),local(Muli-Regularitalic),url(/fonts/muli-latin-400italic.woff2) format('woff2'),url(/fonts/muli-latin-400italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:600;src:local('Muli SemiBold '),local(Muli-SemiBold),url(/fonts/muli-latin-600.woff2) format('woff2'),url(/fonts/muli-latin-600.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:600;src:local('Muli SemiBold italic'),local(Muli-SemiBolditalic),url(/fonts/muli-latin-600italic.woff2) format('woff2'),url(/fonts/muli-latin-600italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:700;src:local('Muli Bold '),local(Muli-Bold),url(/fonts/muli-latin-700.woff2) format('woff2'),url(/fonts/muli-latin-700.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:700;src:local('Muli Bold italic'),local(Muli-Bolditalic),url(/fonts/muli-latin-700italic.woff2) format('woff2'),url(/fonts/muli-latin-700italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:800;src:local('Muli ExtraBold '),local(Muli-ExtraBold),url(/fonts/muli-latin-800.woff2) format('woff2'),url(/fonts/muli-latin-800.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:800;src:local('Muli ExtraBold italic'),local(Muli-ExtraBolditalic),url(/fonts/muli-latin-800italic.woff2) format('woff2'),url(/fonts/muli-latin-800italic.woff) format('woff')}@font-face{font-family:muli;font-style:normal;font-display:swap;font-weight:900;src:local('Muli Black '),local(Muli-Black),url(/fonts/muli-latin-900.woff2) format('woff2'),url(/fonts/muli-latin-900.woff) format('woff')}@font-face{font-family:muli;font-style:italic;font-display:swap;font-weight:900;src:local('Muli Black italic'),local(Muli-Blackitalic),url(/fonts/muli-latin-900italic.woff2) format('woff2'),url(/fonts/muli-latin-900italic.woff) format('woff')}/*!TACHYONS v4.7.0 | http://tachyons.io*//*!normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css*/html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html,body,div,article,aside,section,main,nav,footer,header,form,fieldset,legend,pre,code,a,h1,h2,h3,h4,h5,h6,p,ul,ol,li,dl,dt,dd,blockquote,figcaption,figure,textarea,table,td,th,tr,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],.border-box{-webkit-box-sizing:border-box;box-sizing:border-box}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}@media screen and (min-width:30em){.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}}@media screen and (min-width:30em) and (max-width:60em){.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}}@media screen and (min-width:60em){.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}}.bg-center{background-repeat:no-repeat;background-position:50%}.bg-top{background-repeat:no-repeat;background-position:50% 0}.bg-right{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom{background-repeat:no-repeat;background-position:50% 100%}.bg-left{background-repeat:no-repeat;background-position:50% 0}@media screen and (min-width:30em){.bg-center-ns{background-repeat:no-repeat;background-position:50%}.bg-top-ns{background-repeat:no-repeat;background-position:50% 0}.bg-right-ns{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-ns{background-repeat:no-repeat;background-position:50% 100%}.bg-left-ns{background-repeat:no-repeat;background-position:50% 0}}@media screen and (min-width:30em) and (max-width:60em){.bg-center-m{background-repeat:no-repeat;background-position:50%}.bg-top-m{background-repeat:no-repeat;background-position:50% 0}.bg-right-m{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-m{background-repeat:no-repeat;background-position:50% 100%}.bg-left-m{background-repeat:no-repeat;background-position:50% 0}}@media screen and (min-width:60em){.bg-center-l{background-repeat:no-repeat;background-position:50%}.bg-top-l{background-repeat:no-repeat;background-position:50% 0}.bg-right-l{background-repeat:no-repeat;background-position:50% 100%}.bg-bottom-l{background-repeat:no-repeat;background-position:50% 100%}.bg-left-l{background-repeat:no-repeat;background-position:50% 0}}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}@media screen and (min-width:30em){.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}}@media screen and (min-width:30em) and (max-width:60em){.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}}@media screen and (min-width:60em){.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:rgba(255,255,255,.9)}.b--white-80{border-color:rgba(255,255,255,.8)}.b--white-70{border-color:rgba(255,255,255,.7)}.b--white-60{border-color:rgba(255,255,255,.6)}.b--white-50{border-color:rgba(255,255,255,.5)}.b--white-40{border-color:rgba(255,255,255,.4)}.b--white-30{border-color:rgba(255,255,255,.3)}.b--white-20{border-color:rgba(255,255,255,.2)}.b--white-10{border-color:rgba(255,255,255,.1)}.b--white-05{border-color:rgba(255,255,255,.05)}.b--white-025{border-color:rgba(255,255,255,.025)}.b--white-0125{border-color:rgba(255,255,255,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#0594cb}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right{border-top-left-radius:0;border-bottom-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}@media screen and (min-width:30em){.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-ns{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}}@media screen and (min-width:30em) and (max-width:60em){.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-m{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}}@media screen and (min-width:60em){.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-left-radius:0;border-bottom-right-radius:0}.br--right-l{border-top-left-radius:0;border-bottom-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}@media screen and (min-width:30em){.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}}@media screen and (min-width:30em) and (max-width:60em){.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}}@media screen and (min-width:60em){.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}@media screen and (min-width:30em){.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}}@media screen and (min-width:30em) and (max-width:60em){.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}}@media screen and (min-width:60em){.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}}.shadow-1{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}@media screen and (min-width:30em){.shadow-1-ns{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:30em) and (max-width:60em){.shadow-1-m{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:60em){.shadow-1-l{-webkit-box-shadow:0 0 4px 2px rgba(0,0,0,.2);box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{-webkit-box-shadow:0 0 8px 2px rgba(0,0,0,.2);box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{-webkit-box-shadow:2px 2px 4px 2px rgba(0,0,0,.2);box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{-webkit-box-shadow:2px 2px 8px 0 rgba(0,0,0,.2);box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{-webkit-box-shadow:4px 4px 8px 0 rgba(0,0,0,.2);box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}@media screen and (min-width:30em){.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}}@media screen and (min-width:30em) and (max-width:60em){.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}}@media screen and (min-width:60em){.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}}.cf:before,.cf:after{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}@media screen and (min-width:30em){.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}}@media screen and (min-width:30em) and (max-width:60em){.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}}@media screen and (min-width:60em){.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}@media screen and (min-width:30em){.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}}@media screen and (min-width:30em) and (max-width:60em){.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}}@media screen and (min-width:60em){.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}}.flex{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start{-ms-flex-item-align:start;align-self:flex-start}.self-end{-ms-flex-item-align:end;align-self:flex-end}.self-center{-ms-flex-item-align:center;align-self:center}.self-baseline{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch{-ms-flex-item-align:stretch;align-self:stretch}.justify-start{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around{-ms-flex-pack:distribute;justify-content:space-around}.content-start{-ms-flex-line-pack:start;align-content:flex-start}.content-end{-ms-flex-line-pack:end;align-content:flex-end}.content-center{-ms-flex-line-pack:center;align-content:center}.content-between{-ms-flex-line-pack:justify;align-content:space-between}.content-around{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch{-ms-flex-line-pack:stretch;align-content:stretch}.order-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1{-ms-flex-negative:1;flex-shrink:1}@media screen and (min-width:30em){.flex-ns{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-ns{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-ns{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-ns{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-ns{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-ns{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-ns{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-ns{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-ns{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-ns{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-ns{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-ns{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-ns{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-ns{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-ns{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-ns{-ms-flex-item-align:start;align-self:flex-start}.self-end-ns{-ms-flex-item-align:end;align-self:flex-end}.self-center-ns{-ms-flex-item-align:center;align-self:center}.self-baseline-ns{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-ns{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-ns{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-ns{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-ns{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-ns{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-ns{-ms-flex-pack:distribute;justify-content:space-around}.content-start-ns{-ms-flex-line-pack:start;align-content:flex-start}.content-end-ns{-ms-flex-line-pack:end;align-content:flex-end}.content-center-ns{-ms-flex-line-pack:center;align-content:center}.content-between-ns{-ms-flex-line-pack:justify;align-content:space-between}.content-around-ns{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-ns{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-ns{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-ns{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-ns{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-ns{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-ns{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-ns{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-ns{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-ns{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-ns{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-ns{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-ns{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-ns{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-ns{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-ns{-ms-flex-negative:1;flex-shrink:1}}@media screen and (min-width:30em) and (max-width:60em){.flex-m{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-m{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-m{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-m{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-m{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-m{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-m{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-m{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-m{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-m{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-m{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-m{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-m{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-m{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-m{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-m{-ms-flex-item-align:start;align-self:flex-start}.self-end-m{-ms-flex-item-align:end;align-self:flex-end}.self-center-m{-ms-flex-item-align:center;align-self:center}.self-baseline-m{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-m{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-m{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-m{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-m{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-m{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-m{-ms-flex-pack:distribute;justify-content:space-around}.content-start-m{-ms-flex-line-pack:start;align-content:flex-start}.content-end-m{-ms-flex-line-pack:end;align-content:flex-end}.content-center-m{-ms-flex-line-pack:center;align-content:center}.content-between-m{-ms-flex-line-pack:justify;align-content:space-between}.content-around-m{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-m{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-m{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-m{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-m{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-m{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-m{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-m{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-m{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-m{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-m{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-m{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-m{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-m{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-m{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-m{-ms-flex-negative:1;flex-shrink:1}}@media screen and (min-width:60em){.flex-l{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex-l{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.flex-auto-l{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{-webkit-box-flex:0;-ms-flex:none;flex:none}.flex-column-l{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.flex-row-l{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.flex-wrap-l{-ms-flex-wrap:wrap;flex-wrap:wrap}.flex-nowrap-l{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.flex-wrap-reverse-l{-ms-flex-wrap:wrap-reverse;flex-wrap:wrap-reverse}.flex-column-reverse-l{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.flex-row-reverse-l{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.items-start-l{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.items-end-l{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.items-center-l{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.items-baseline-l{-webkit-box-align:baseline;-ms-flex-align:baseline;align-items:baseline}.items-stretch-l{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.self-start-l{-ms-flex-item-align:start;align-self:flex-start}.self-end-l{-ms-flex-item-align:end;align-self:flex-end}.self-center-l{-ms-flex-item-align:center;align-self:center}.self-baseline-l{-ms-flex-item-align:baseline;align-self:baseline}.self-stretch-l{-ms-flex-item-align:stretch;align-self:stretch}.justify-start-l{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.justify-end-l{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.justify-center-l{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.justify-between-l{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.justify-around-l{-ms-flex-pack:distribute;justify-content:space-around}.content-start-l{-ms-flex-line-pack:start;align-content:flex-start}.content-end-l{-ms-flex-line-pack:end;align-content:flex-end}.content-center-l{-ms-flex-line-pack:center;align-content:center}.content-between-l{-ms-flex-line-pack:justify;align-content:space-between}.content-around-l{-ms-flex-line-pack:distribute;align-content:space-around}.content-stretch-l{-ms-flex-line-pack:stretch;align-content:stretch}.order-0-l{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1-l{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2-l{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3-l{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4-l{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5-l{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6-l{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7-l{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8-l{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-last-l{-webkit-box-ordinal-group:100000;-ms-flex-order:99999;order:99999}.flex-grow-0-l{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0}.flex-grow-1-l{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.flex-shrink-0-l{-ms-flex-negative:0;flex-shrink:0}.flex-shrink-1-l{-ms-flex-negative:1;flex-shrink:1}}.fl{float:left;_display:inline}.fr{float:right;_display:inline}.fn{float:none}@media screen and (min-width:30em){.fl-ns{float:left;_display:inline}.fr-ns{float:right;_display:inline}.fn-ns{float:none}}@media screen and (min-width:30em) and (max-width:60em){.fl-m{float:left;_display:inline}.fr-m{float:right;_display:inline}.fn-m{float:none}}@media screen and (min-width:60em){.fl-l{float:left;_display:inline}.fr-l{float:right;_display:inline}.fn-l{float:none}}.i{font-style:italic}.fs-normal{font-style:normal}@media screen and (min-width:30em){.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}}@media screen and (min-width:30em) and (max-width:60em){.i-m{font-style:italic}.fs-normal-m{font-style:normal}}@media screen and (min-width:60em){.i-l{font-style:italic}.fs-normal-l{font-style:normal}}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}@media screen and (min-width:30em){.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}}@media screen and (min-width:30em) and (max-width:60em){.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}}@media screen and (min-width:60em){.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}@media screen and (min-width:30em){.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}}@media screen and (min-width:30em) and (max-width:60em){.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}}@media screen and (min-width:60em){.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}@media screen and (min-width:30em){.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}}@media screen and (min-width:30em) and (max-width:60em){.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}}@media screen and (min-width:60em){.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}@media screen and (min-width:30em){.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}}@media screen and (min-width:30em) and (max-width:60em){.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}}@media screen and (min-width:60em){.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}}.link{text-decoration:none;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:link,.link:visited{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:hover{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:active{-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.link:focus{-webkit-transition:color .15s ease-in;transition:color .15s ease-in;outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}@media screen and (min-width:30em){.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}}@media screen and (min-width:30em) and (max-width:60em){.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}}@media screen and (min-width:60em){.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}@media screen and (min-width:30em){.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}}@media screen and (min-width:30em) and (max-width:60em){.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}}@media screen and (min-width:60em){.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}@media screen and (min-width:30em){.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}}@media screen and (min-width:30em) and (max-width:60em){.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}}@media screen and (min-width:60em){.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}@media screen and (min-width:30em){.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}}@media screen and (min-width:30em) and (max-width:60em){.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}}@media screen and (min-width:60em){.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:rgba(255,255,255,.9)}.white-80{color:rgba(255,255,255,.8)}.white-70{color:rgba(255,255,255,.7)}.white-60{color:rgba(255,255,255,.6)}.white-50{color:rgba(255,255,255,.5)}.white-40{color:rgba(255,255,255,.4)}.white-30{color:rgba(255,255,255,.3)}.white-20{color:rgba(255,255,255,.2)}.white-10{color:rgba(255,255,255,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#0594cb}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:rgba(255,255,255,.9)}.bg-white-80{background-color:rgba(255,255,255,.8)}.bg-white-70{background-color:rgba(255,255,255,.7)}.bg-white-60{background-color:rgba(255,255,255,.6)}.bg-white-50{background-color:rgba(255,255,255,.5)}.bg-white-40{background-color:rgba(255,255,255,.4)}.bg-white-30{background-color:rgba(255,255,255,.3)}.bg-white-20{background-color:rgba(255,255,255,.2)}.bg-white-10{background-color:rgba(255,255,255,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#0594cb}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:hover,.hover-black:focus{color:#000}.hover-near-black:hover,.hover-near-black:focus{color:#111}.hover-dark-gray:hover,.hover-dark-gray:focus{color:#333}.hover-mid-gray:hover,.hover-mid-gray:focus{color:#555}.hover-gray:hover,.hover-gray:focus{color:#777}.hover-silver:hover,.hover-silver:focus{color:#999}.hover-light-silver:hover,.hover-light-silver:focus{color:#aaa}.hover-moon-gray:hover,.hover-moon-gray:focus{color:#ccc}.hover-light-gray:hover,.hover-light-gray:focus{color:#eee}.hover-near-white:hover,.hover-near-white:focus{color:#f4f4f4}.hover-white:hover,.hover-white:focus{color:#fff}.hover-black-90:hover,.hover-black-90:focus{color:rgba(0,0,0,.9)}.hover-black-80:hover,.hover-black-80:focus{color:rgba(0,0,0,.8)}.hover-black-70:hover,.hover-black-70:focus{color:rgba(0,0,0,.7)}.hover-black-60:hover,.hover-black-60:focus{color:rgba(0,0,0,.6)}.hover-black-50:hover,.hover-black-50:focus{color:rgba(0,0,0,.5)}.hover-black-40:hover,.hover-black-40:focus{color:rgba(0,0,0,.4)}.hover-black-30:hover,.hover-black-30:focus{color:rgba(0,0,0,.3)}.hover-black-20:hover,.hover-black-20:focus{color:rgba(0,0,0,.2)}.hover-black-10:hover,.hover-black-10:focus{color:rgba(0,0,0,.1)}.hover-white-90:hover,.hover-white-90:focus{color:rgba(255,255,255,.9)}.hover-white-80:hover,.hover-white-80:focus{color:rgba(255,255,255,.8)}.hover-white-70:hover,.hover-white-70:focus{color:rgba(255,255,255,.7)}.hover-white-60:hover,.hover-white-60:focus{color:rgba(255,255,255,.6)}.hover-white-50:hover,.hover-white-50:focus{color:rgba(255,255,255,.5)}.hover-white-40:hover,.hover-white-40:focus{color:rgba(255,255,255,.4)}.hover-white-30:hover,.hover-white-30:focus{color:rgba(255,255,255,.3)}.hover-white-20:hover,.hover-white-20:focus{color:rgba(255,255,255,.2)}.hover-white-10:hover,.hover-white-10:focus{color:rgba(255,255,255,.1)}.hover-inherit:hover,.hover-inherit:focus{color:inherit}.hover-bg-black:hover,.hover-bg-black:focus{background-color:#000}.hover-bg-near-black:hover,.hover-bg-near-black:focus{background-color:#111}.hover-bg-dark-gray:hover,.hover-bg-dark-gray:focus{background-color:#333}.hover-bg-mid-gray:hover,.hover-bg-mid-gray:focus{background-color:#555}.hover-bg-gray:hover,.hover-bg-gray:focus{background-color:#777}.hover-bg-silver:hover,.hover-bg-silver:focus{background-color:#999}.hover-bg-light-silver:hover,.hover-bg-light-silver:focus{background-color:#aaa}.hover-bg-moon-gray:hover,.hover-bg-moon-gray:focus{background-color:#ccc}.hover-bg-light-gray:hover,.hover-bg-light-gray:focus{background-color:#eee}.hover-bg-near-white:hover,.hover-bg-near-white:focus{background-color:#f4f4f4}.hover-bg-white:hover,.hover-bg-white:focus{background-color:#fff}.hover-bg-transparent:hover,.hover-bg-transparent:focus{background-color:transparent}.hover-bg-black-90:hover,.hover-bg-black-90:focus{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:hover,.hover-bg-black-80:focus{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:hover,.hover-bg-black-70:focus{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:hover,.hover-bg-black-60:focus{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:hover,.hover-bg-black-50:focus{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:hover,.hover-bg-black-40:focus{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:hover,.hover-bg-black-30:focus{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:hover,.hover-bg-black-20:focus{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:hover,.hover-bg-black-10:focus{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:hover,.hover-bg-white-90:focus{background-color:rgba(255,255,255,.9)}.hover-bg-white-80:hover,.hover-bg-white-80:focus{background-color:rgba(255,255,255,.8)}.hover-bg-white-70:hover,.hover-bg-white-70:focus{background-color:rgba(255,255,255,.7)}.hover-bg-white-60:hover,.hover-bg-white-60:focus{background-color:rgba(255,255,255,.6)}.hover-bg-white-50:hover,.hover-bg-white-50:focus{background-color:rgba(255,255,255,.5)}.hover-bg-white-40:hover,.hover-bg-white-40:focus{background-color:rgba(255,255,255,.4)}.hover-bg-white-30:hover,.hover-bg-white-30:focus{background-color:rgba(255,255,255,.3)}.hover-bg-white-20:hover,.hover-bg-white-20:focus{background-color:rgba(255,255,255,.2)}.hover-bg-white-10:hover,.hover-bg-white-10:focus{background-color:rgba(255,255,255,.1)}.hover-dark-red:hover,.hover-dark-red:focus{color:#e7040f}.hover-red:hover,.hover-red:focus{color:#ff4136}.hover-light-red:hover,.hover-light-red:focus{color:#ff725c}.hover-orange:hover,.hover-orange:focus{color:#ff6300}.hover-gold:hover,.hover-gold:focus{color:#ffb700}.hover-yellow:hover,.hover-yellow:focus{color:gold}.hover-light-yellow:hover,.hover-light-yellow:focus{color:#fbf1a9}.hover-purple:hover,.hover-purple:focus{color:#5e2ca5}.hover-light-purple:hover,.hover-light-purple:focus{color:#a463f2}.hover-dark-pink:hover,.hover-dark-pink:focus{color:#d5008f}.hover-hot-pink:hover,.hover-hot-pink:focus{color:#ff41b4}.hover-pink:hover,.hover-pink:focus{color:#ff80cc}.hover-light-pink:hover,.hover-light-pink:focus{color:#ffa3d7}.hover-dark-green:hover,.hover-dark-green:focus{color:#137752}.hover-green:hover,.hover-green:focus{color:#19a974}.hover-light-green:hover,.hover-light-green:focus{color:#9eebcf}.hover-navy:hover,.hover-navy:focus{color:#001b44}.hover-dark-blue:hover,.hover-dark-blue:focus{color:#00449e}.hover-blue:hover,.hover-blue:focus{color:#0594cb}.hover-light-blue:hover,.hover-light-blue:focus{color:#96ccff}.hover-lightest-blue:hover,.hover-lightest-blue:focus{color:#cdecff}.hover-washed-blue:hover,.hover-washed-blue:focus{color:#f6fffe}.hover-washed-green:hover,.hover-washed-green:focus{color:#e8fdf5}.hover-washed-yellow:hover,.hover-washed-yellow:focus{color:#fffceb}.hover-washed-red:hover,.hover-washed-red:focus{color:#ffdfdf}.hover-bg-dark-red:hover,.hover-bg-dark-red:focus{background-color:#e7040f}.hover-bg-red:hover,.hover-bg-red:focus{background-color:#ff4136}.hover-bg-light-red:hover,.hover-bg-light-red:focus{background-color:#ff725c}.hover-bg-orange:hover,.hover-bg-orange:focus{background-color:#ff6300}.hover-bg-gold:hover,.hover-bg-gold:focus{background-color:#ffb700}.hover-bg-yellow:hover,.hover-bg-yellow:focus{background-color:gold}.hover-bg-light-yellow:hover,.hover-bg-light-yellow:focus{background-color:#fbf1a9}.hover-bg-purple:hover,.hover-bg-purple:focus{background-color:#5e2ca5}.hover-bg-light-purple:hover,.hover-bg-light-purple:focus{background-color:#a463f2}.hover-bg-dark-pink:hover,.hover-bg-dark-pink:focus{background-color:#d5008f}.hover-bg-hot-pink:hover,.hover-bg-hot-pink:focus{background-color:#ff41b4}.hover-bg-pink:hover,.hover-bg-pink:focus{background-color:#ff80cc}.hover-bg-light-pink:hover,.hover-bg-light-pink:focus{background-color:#ffa3d7}.hover-bg-dark-green:hover,.hover-bg-dark-green:focus{background-color:#137752}.hover-bg-green:hover,.hover-bg-green:focus{background-color:#19a974}.hover-bg-light-green:hover,.hover-bg-light-green:focus{background-color:#9eebcf}.hover-bg-navy:hover,.hover-bg-navy:focus{background-color:#001b44}.hover-bg-dark-blue:hover,.hover-bg-dark-blue:focus{background-color:#00449e}.hover-bg-blue:hover,.hover-bg-blue:focus{background-color:#0594cb}.hover-bg-light-blue:hover,.hover-bg-light-blue:focus{background-color:#96ccff}.hover-bg-lightest-blue:hover,.hover-bg-lightest-blue:focus{background-color:#cdecff}.hover-bg-washed-blue:hover,.hover-bg-washed-blue:focus{background-color:#f6fffe}.hover-bg-washed-green:hover,.hover-bg-washed-green:focus{background-color:#e8fdf5}.hover-bg-washed-yellow:hover,.hover-bg-washed-yellow:focus{background-color:#fffceb}.hover-bg-washed-red:hover,.hover-bg-washed-red:focus{background-color:#ffdfdf}.hover-bg-inherit:hover,.hover-bg-inherit:focus{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}@media screen and (min-width:30em){.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:30em) and (max-width:60em){.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:60em){.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}@media screen and (min-width:30em){.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}}@media screen and (min-width:30em) and (max-width:60em){.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}}@media screen and (min-width:60em){.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:rgba(255,255,255,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}@media screen and (min-width:30em){.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}}@media screen and (min-width:30em) and (max-width:60em){.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}}@media screen and (min-width:60em){.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}@media screen and (min-width:30em){.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}}@media screen and (min-width:30em) and (max-width:60em){.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}}@media screen and (min-width:60em){.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}@media screen and (min-width:30em){.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}}@media screen and (min-width:30em) and (max-width:60em){.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}}@media screen and (min-width:60em){.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}@media screen and (min-width:30em){.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}}@media screen and (min-width:30em) and (max-width:60em){.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}}@media screen and (min-width:60em){.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}@media screen and (min-width:30em){.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}@media screen and (min-width:30em) and (max-width:60em){.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}@media screen and (min-width:60em){.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{-webkit-font-feature-settings:"c2sc";font-feature-settings:"c2sc";font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}.overflow-container{overflow-y:scroll}.center{margin-right:auto;margin-left:auto}.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}@media screen and (min-width:30em){.center-ns{margin-right:auto;margin-left:auto}.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}}@media screen and (min-width:30em) and (max-width:60em){.center-m{margin-right:auto;margin-left:auto}.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}}@media screen and (min-width:60em){.center-l{margin-right:auto;margin-left:auto}.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}@media screen and (min-width:30em){.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:30em) and (max-width:60em){.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:60em){.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}@media screen and (min-width:30em){.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}}@media screen and (min-width:30em) and (max-width:60em){.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}}@media screen and (min-width:60em){.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}@media screen and (min-width:30em){.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}}.dim{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.dim:hover,.dim:focus{opacity:.5;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.dim:active{opacity:.8;-webkit-transition:opacity .15s ease-out;transition:opacity .15s ease-out}.glow{-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.glow:hover,.glow:focus{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.hide-child .child{opacity:0;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.hide-child:hover .child,.hide-child:focus .child,.hide-child:active .child{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.underline-hover:hover,.underline-hover:focus{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);-webkit-transition:-webkit-transform .25s ease-out;transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:hover,.grow:focus{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.90);transform:scale(.90)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);-webkit-transition:-webkit-transform .25s ease-in-out;transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:hover,.grow-large:focus{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover{cursor:pointer}.shadow-hover{cursor:pointer;position:relative;-webkit-transition:all .5s cubic-bezier(0.165,0.84,0.44,1);transition:all .5s cubic-bezier(0.165,0.84,0.44,1)}.shadow-hover::after{content:'';-webkit-box-shadow:0 0 16px 2px rgba(0,0,0,.2);box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;-webkit-transition:opacity .5s cubic-bezier(0.165,0.84,0.44,1);transition:opacity .5s cubic-bezier(0.165,0.84,0.44,1)}.shadow-hover:hover::after,.shadow-hover:focus::after{opacity:1}.bg-animate,.bg-animate:hover,.bg-animate:focus{-webkit-transition:background-color .15s ease-in-out;transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto;z-index:initial}.z-unset{z-index:unset}.nested-copy-line-height p,.nested-copy-line-height ul,.nested-copy-line-height ol{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ul,.nested-list-reset ol{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#0594cb;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.nested-links a:hover,.nested-links a:focus{color:#96ccff;-webkit-transition:color .15s ease-in;transition:color .15s ease-in}.header-link:after{position:relative;left:.5em;opacity:0;font-size:.8em;-moz-transition:opacity .2s ease-in-out .1s;-ms-transition:opacity .2s ease-in-out .1s}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1}.animated{-webkit-animation-duration:.5s;animation-duration:.5s;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}.fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}.animated-delay-1{-webkit-animation-delay:.5s;animation-delay:.5s}.note,.warning{border-left-width:4px;border-left-style:solid;position:relative;border-color:#0594cb;display:block}.note #exclamation-icon,.warning #exclamation-icon{fill:#0594cb;position:absolute;top:35%;left:-12px}.admonition-content{display:block;margin:0;padding:.125em 1em;margin-top:2em;margin-bottom:2em;overflow-x:auto;background-color:rgba(0,0,0,.05)}.hide-child-menu .child-menu{display:none}.hide-child-menu:hover .child-menu,.hide-child-menu:focus .child-menu,.hide-child-menu:active .child-menu{display:block}.documentation-copy h2{margin-top:3em}.documentation-copy h2.minor{font-size:inherit;margin-top:inherit;border-bottom:none}.searchbox{display:inline-block;position:relative;width:200px;height:32px!important;white-space:nowrap;-webkit-box-sizing:border-box;box-sizing:border-box;visibility:visible!important}.searchbox .algolia-autocomplete{display:block;width:100%;height:100%}.searchbox__wrapper{width:100%;height:100%;z-index:999;position:relative}.searchbox__input{display:inline-block;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-transition:background .4s ease,-webkit-box-shadow .4s ease;transition:background .4s ease,-webkit-box-shadow .4s ease;transition:box-shadow .4s ease,background .4s ease;transition:box-shadow .4s ease,background .4s ease,-webkit-box-shadow .4s ease;border:0;border-radius:16px;-webkit-box-shadow:inset 0 0 0 1px #ccc;box-shadow:inset 0 0 0 1px #ccc;background:#fff!important;padding:0 26px 0 32px;width:100%;height:100%;vertical-align:middle;white-space:normal;font-size:12px;-webkit-appearance:none;-moz-appearance:none;appearance:none}.searchbox__input::-webkit-search-cancel-button,.searchbox__input::-webkit-search-decoration,.searchbox__input::-webkit-search-results-button,.searchbox__input::-webkit-search-results-decoration{display:none}.searchbox__input:hover{-webkit-box-shadow:inset 0 0 0 1px #b3b3b3;box-shadow:inset 0 0 0 1px #b3b3b3}.searchbox__input:active,.searchbox__input:focus{outline:0;-webkit-box-shadow:inset 0 0 0 1px #aaa;box-shadow:inset 0 0 0 1px #aaa;background:#fff}.searchbox__input::-webkit-input-placeholder{color:#aaa}.searchbox__input:-ms-input-placeholder{color:#aaa}.searchbox__input::-ms-input-placeholder{color:#aaa}.searchbox__input::placeholder{color:#aaa}.searchbox__submit{position:absolute;top:0;margin:0;border:0;border-radius:16px 0 0 16px;background-color:rgba(69,142,225,0);padding:0;width:32px;height:100%;vertical-align:middle;text-align:center;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;right:inherit;left:0}.searchbox__submit:before{display:inline-block;margin-right:-4px;height:100%;vertical-align:middle;content:""}.searchbox__submit:active,.searchbox__submit:hover{cursor:pointer}.searchbox__submit:focus{outline:0}.searchbox__submit svg{width:14px;height:14px;vertical-align:middle;fill:#6d7e96}.searchbox__reset{display:block;position:absolute;top:8px;right:8px;margin:0;border:0;background:0 0;cursor:pointer;padding:0;font-size:inherit;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;fill:rgba(0,0,0,.5)}.searchbox__reset.hide{display:none}.searchbox__reset:focus{outline:0}.searchbox__reset svg{display:block;margin:4px;width:8px;height:8px}.searchbox__input:valid~.searchbox__reset{display:block;-webkit-animation-name:sbx-reset-in;animation-name:sbx-reset-in;-webkit-animation-duration:.15s;animation-duration:.15s}@-webkit-keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}@keyframes sbx-reset-in{0%{-webkit-transform:translate3d(-20%,0,0);transform:translate3d(-20%,0,0);opacity:0}to{-webkit-transform:none;transform:none;opacity:1}}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu{right:0!important;left:inherit!important}.algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu:before{right:48px}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu{left:0!important;right:inherit!important}.algolia-autocomplete.algolia-autocomplete-left .ds-dropdown-menu:before{left:48px}.algolia-autocomplete .ds-dropdown-menu{top:-6px;border-radius:4px;margin:6px 0 0;padding:0;text-align:left;height:auto;position:relative;background:transparent;border:none;z-index:999;max-width:600px;min-width:500px;-webkit-box-shadow:0 1px 0 0 rgba(0,0,0,.2),0 2px 3px 0 rgba(0,0,0,.1);box-shadow:0 1px 0 0 rgba(0,0,0,.2),0 2px 3px 0 rgba(0,0,0,.1)}.algolia-autocomplete .ds-dropdown-menu:before{display:block;position:absolute;content:"";width:14px;height:14px;background:#fff;z-index:1000;top:-7px;border-top:1px solid #d9d9d9;border-right:1px solid #d9d9d9;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);border-radius:2px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions{position:relative;z-index:1000;margin-top:8px}.algolia-autocomplete .ds-dropdown-menu .ds-suggestions a:hover{text-decoration:none}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion{cursor:pointer}.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion.suggestion-layout-simple,.algolia-autocomplete .ds-dropdown-menu .ds-suggestion.ds-cursor .algolia-docsearch-suggestion:not(.suggestion-layout-simple) .algolia-docsearch-suggestion--content{background-color:rgba(69,142,225,.05)}.algolia-autocomplete .ds-dropdown-menu [class^=ds-dataset-]{position:relative;border:1px solid #d9d9d9;background:#fff;border-radius:4px;overflow:auto;padding:0 8px 8px}.algolia-autocomplete .ds-dropdown-menu *{-webkit-box-sizing:border-box;box-sizing:border-box}.algolia-autocomplete .algolia-docsearch-suggestion{display:block;position:relative;padding:0 8px;background:#fff;color:#02060c;overflow:hidden}.algolia-autocomplete .algolia-docsearch-suggestion--highlight{color:#174d8c;background:rgba(143,187,237,.1);padding:.1em .05em}.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl0 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--category-header-lvl1 .algolia-docsearch-suggestion--highlight,.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{padding:0 0 1px;background:inherit;-webkit-box-shadow:inset 0 -2px 0 0 rgba(69,142,225,.8);box-shadow:inset 0 -2px 0 0 rgba(69,142,225,.8);color:inherit}.algolia-autocomplete .algolia-docsearch-suggestion--content{display:block;float:right;width:70%;position:relative;padding:5.33333px 0 5.33333px 10.66667px;cursor:pointer}.algolia-autocomplete .algolia-docsearch-suggestion--content:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;left:-1px}.algolia-autocomplete .algolia-docsearch-suggestion--category-header{position:relative;border-bottom:1px solid #ddd;display:none;margin-top:8px;padding:4px 0;font-size:1em;color:#33363d}.algolia-autocomplete .algolia-docsearch-suggestion--wrapper{width:100%;float:left;padding:8px 0 0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column{float:left;width:30%;text-align:right;position:relative;padding:5.33333px 10.66667px;color:#a4a7ae;font-size:.9em;word-wrap:break-word}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column:before{content:"";position:absolute;display:block;top:0;height:100%;width:1px;background:#ddd;right:0}.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-inline{display:none}.algolia-autocomplete .algolia-docsearch-suggestion--title{margin-bottom:4px;color:#02060c;font-size:.9em;font-weight:700}.algolia-autocomplete .algolia-docsearch-suggestion--text{display:block;line-height:1.2em;font-size:.85em;color:#63676d}.algolia-autocomplete .algolia-docsearch-suggestion--no-results{width:100%;padding:8px 0;text-align:center;font-size:1.2em}.algolia-autocomplete .algolia-docsearch-suggestion--no-results:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion code{padding:1px 5px;font-size:90%;border:none;color:#222;background-color:#ebebeb;border-radius:3px;font-family:Menlo,Monaco,Consolas,Courier New,monospace}.algolia-autocomplete .algolia-docsearch-suggestion code .algolia-docsearch-suggestion--highlight{background:0 0}.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main .algolia-docsearch-suggestion--category-header,.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary{display:block}@media(min-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:block}}@media(max-width:768px){.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column{display:inline-block;width:auto;float:left;padding:0;color:#02060c;font-size:.9em;font-weight:700;text-align:left;opacity:.5}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:before{display:none}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after{content:"|"}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content{display:inline-block;width:auto;text-align:left;float:left;padding:0}.algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--content:before{display:none}}.algolia-autocomplete .suggestion-layout-simple.algolia-docsearch-suggestion{border-bottom:1px solid #eee;padding:8px;margin:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content{width:100%;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--content:before{display:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header{margin:0;padding:0;display:block;width:100%;border:none}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl0,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1{opacity:.6;font-size:.85em}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--category-header-lvl1:before{background-image:url(data:image/svg+xml;utf8;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCAyMCAzOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMS40OSA0LjMxbDE0IDE2LjEyNi4wMDItMi42MjQtMTQgMTYuMDc0LTEuMzE0IDEuNTEgMy4wMTcgMi42MjYgMS4zMTMtMS41MDggMTQtMTYuMDc1IDEuMTQyLTEuMzEzLTEuMTQtMS4zMTMtMTQtMTYuMTI1TDMuMi4xOC4xOCAyLjhsMS4zMSAxLjUxeiIgZmlsbC1ydWxlPSJldmVub2RkIiBmaWxsPSIjMWQzNjU3IiAvPjwvc3ZnPg==);content:"";width:10px;height:10px;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--wrapper{width:100%;float:left;margin:0;padding:0}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--duplicate-content,.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--subcategory-inline{display:none!important}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title{margin:0;color:#458ee1;font-size:.9em;font-weight:400}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--title:before{content:"#";font-weight:700;color:#458ee1;display:inline-block}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text{margin:4px 0 0;display:block;line-height:1.4em;padding:5.33333px 8px;background:#f8f8f8;font-size:.85em;opacity:.8}.algolia-autocomplete .suggestion-layout-simple .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight{color:#3f4145;font-weight:700;-webkit-box-shadow:none;box-shadow:none}.algolia-autocomplete .algolia-docsearch-footer{width:134px;height:20px;z-index:2000;margin-top:10.66667px;float:right;font-size:0;line-height:0}.algolia-autocomplete .algolia-docsearch-footer--logo{background-image:url(data:image/svg+xml;charset=utf-8;base64,PHN2ZyB3aWR0aD0iMTY4IiBoZWlnaHQ9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTc4Ljk4OC45MzhoMTYuNTk0YTIuOTY4IDIuOTY4LjAgMCAxIDIuOTY2IDIuOTY2VjIwLjVhMi45NjcgMi45NjcuMCAwIDEtMi45NjYgMi45NjRINzguOTg4YTIuOTY3IDIuOTY3LjAgMCAxLTIuOTY2LTIuOTY0VjMuODk3QTIuOTYxIDIuOTYxLjAgMCAxIDc4Ljk4OC45Mzh6bTQxLjkzNyAxNy44NjZjLTQuMzg2LjAyLTQuMzg2LTMuNTQtNC4zODYtNC4xMDZsLS4wMDctMTMuMzM2IDIuNjc1LS40MjR2MTMuMjU0YzAgLjMyMi4wIDIuMzU4IDEuNzE4IDIuMzY0djIuMjQ4em0tMTAuODQ2LTIuMThjLjgyMS4wIDEuNDMtLjA0NyAxLjg1NS0uMTI5di0yLjcxOWE2LjMzNCA2LjMzNC4wIDAgMC0xLjU3NC0uMTk5IDUuNyA1LjcuMCAwIDAtLjg5Ny4wNjkgMi42OTkgMi42OTkuMCAwIDAtLjgxNC4yNGMtLjI0LjExNi0uNDM5LjI4LS41ODIuNDkxLS4xNS4yMTItLjIxOS4zMzUtLjIxOS42NTYuMC42MjguMjE5Ljk5MS42MTYgMS4yM3MuOTM4LjM2MiAxLjYxNS4zNjJ6bS0uMjMzLTkuN2MuODgzLjAgMS42MjkuMTA5IDIuMjMxLjMyOC42MDIuMjE4IDEuMDg4LjUyNSAxLjQ0NC45MTUuMzYzLjM5Ni42MDkuOTIyLjc2IDEuNDgzLjE1Ny41Ni4yMzIgMS4xNzUuMjMyIDEuODV2Ni44NzRhMzIuNSAzMi41LjAgMCAxLTEuODY4LjMxNGMtLjgzNC4xMjMtMS43NzIuMTg1LTIuODEzLjE4NS0uNjkuMC0xLjMyNy0uMDY5LTEuODk1LS4xOThhNC4wMDEgNC4wMDEuMCAwIDEtMS40NzEtLjYzNiAzLjA4NSAzLjA4NS4wIDAgMS0uOTUxLTEuMTM0Yy0uMjI2LS40NjUtLjM0My0xLjEyLS4zNDMtMS44MDMuMC0uNjU2LjEzLTEuMDczLjM4NC0xLjUyNWEzLjI0IDMuMjQuMCAwIDEgMS4wNDctMS4xMDZjLjQ0NS0uMjg3Ljk1LS40OTIgMS41MzItLjYxNWE4LjggOC44LjAgMCAxIDEuODItLjE4NSA4LjQwNCA4LjQwNC4wIDAgMSAxLjk3Mi4yNHYtLjQzOGMwLS4zMDctLjAzNS0uNi0uMTEtLjg3NGExLjg4IDEuODguMCAwIDAtLjM4NC0uNzMgMS43ODQgMS43ODQuMCAwIDAtLjcyNC0uNDkzIDMuMTY0IDMuMTY0LjAgMCAwLTEuMTQzLS4yMDVjLS42MTYuMC0xLjE3Ny4wNzUtMS42OS4xNjRhNy43MzUgNy43MzUuMCAwIDAtMS4yNi4zMDdsLS4zMjEtMi4xOTJjLjMzNS0uMTE3LjgzNC0uMjMzIDEuNDc4LS4zNDlhMTAuOTggMTAuOTguMCAwIDEgMi4wNzMtLjE3OHptNTIuODQyIDkuNjI2Yy44MjIuMCAxLjQzLS4wNDggMS44NTQtLjEzVjEzLjdhNi4zNDcgNi4zNDcuMCAwIDAtMS41NzQtLjE5OWMtLjI5NC4wLS41OTUuMDIxLS44OTYuMDY5YTIuNyAyLjcuMCAwIDAtLjgxNC4yNCAxLjQ2IDEuNDYuMCAwIDAtLjU4Mi40OTFjLS4xNS4yMTItLjIxOC4zMzUtLjIxOC42NTYuMC42MjguMjE4Ljk5MS42MTUgMS4yMy40MDQuMjQ1LjkzOC4zNjIgMS42MTUuMzYyem0tLjIyNi05LjY5NGMuODgzLjAgMS42MjkuMTA4IDIuMjMxLjMyNy42MDIuMjE5IDEuMDg4LjUyNiAxLjQ0NC45MTUuMzU1LjM5LjYwOS45MjMuNzU5IDEuNDgzYTYuOCA2LjguMCAwIDEgLjIzMyAxLjg1MnY2Ljg3M2MtLjQxLjA4OC0xLjAzNC4xOS0xLjg2OC4zMTQtLjgzNC4xMjMtMS43NzIuMTg0LTIuODEzLjE4NC0uNjkuMC0xLjMyNy0uMDY4LTEuODk1LS4xOThhNC4wMDEgNC4wMDEuMCAwIDEtMS40NzEtLjYzNSAzLjA4NSAzLjA4NS4wIDAgMS0uOTUxLTEuMTM0Yy0uMjI2LS40NjUtLjM0My0xLjEyLS4zNDMtMS44MDQuMC0uNjU2LjEzLTEuMDczLjM4NC0xLjUyNC4yNi0uNDUuNjA4LS44MiAxLjA0Ny0xLjEwNy40NDUtLjI4Ni45NS0uNDkxIDEuNTMyLS42MTRhOC44MDMgOC44MDMuMCAwIDEgMi43NTEtLjEzYy4zMjkuMDM0LjY3MS4wOTYgMS4wNC4xODV2LS40MzdhMy4zIDMuMy4wIDAgMC0uMTA5LS44NzUgMS44NzMgMS44NzMuMCAwIDAtLjM4NC0uNzMxIDEuNzg0IDEuNzg0LjAgMCAwLS43MjQtLjQ5MiAzLjE2NSAzLjE2NS4wIDAgMC0xLjE0My0uMjA1Yy0uNjE2LjAtMS4xNzcuMDc1LTEuNjkuMTY0YTcuNzUgNy43NS4wIDAgMC0xLjI2LjMwN2wtLjMyMS0yLjE5M2MuMzM1LS4xMTYuODM0LS4yMzIgMS40NzgtLjM0OGExMS42MzMgMTEuNjMzLjAgMCAxIDIuMDczLS4xNzd6bS04LjAzNC0xLjI3MWExLjYyNiAxLjYyNi4wIDAgMS0xLjYyOC0xLjYyYzAtLjg5NS43MjUtMS42MiAxLjYyOC0xLjYyLjkwNC4wIDEuNjMuNzI1IDEuNjMgMS42Mi4wLjg5NS0uNzMzIDEuNjItMS42MyAxLjYyem0xLjM0OCAxMy4yMmgtMi42ODlWNy4yN2wyLjY5LS40MjN2MTEuOTU2em0tNC43MTQuMGMtNC4zODYuMDItNC4zODYtMy41NC00LjM4Ni00LjEwN2wtLjAwOC0xMy4zMzYgMi42NzYtLjQyNHYxMy4yNTRjMCAuMzIyLjAgMi4zNTggMS43MTggMi4zNjR2Mi4yNDh6bS04LjY5OC01LjkwM2MwLTEuMTU2LS4yNTMtMi4xMTktLjc0Ni0yLjc4OC0uNDkzLS42NzctMS4xODMtMS4wMS0yLjA2Ny0xLjAxLS44ODIuMC0xLjU3NC4zMzMtMi4wNjUgMS4wMS0uNDkzLjY3Ni0uNzMzIDEuNjMyLS43MzMgMi43ODguMCAxLjE2OC4yNDYgMS45NTMuNzQgMi42My40OTIuNjgzIDEuMTgzIDEuMDE4IDIuMDY2IDEuMDE4Ljg4Mi4wIDEuNTc0LS4zNDIgMi4wNjctMS4wMTkuNDkyLS42ODMuNzM4LTEuNDYuNzM4LTIuNjN6bTIuNzM3LS4wMDdjMCAuOTAyLS4xMyAxLjU4NC0uMzk3IDIuMzNhNS41MiA1LjUyLjAgMCAxLTEuMTI4IDEuOTA2IDQuOTg2IDQuOTg2LjAgMCAxLTEuNzUyIDEuMjIzYy0uNjg1LjI4Ni0xLjczOS40NS0yLjI2NS40NS0uNTI4LS4wMDYtMS41NzQtLjE1Ny0yLjI1Mi0uNDVhNS4wOTYgNS4wOTYuMCAwIDEtMS43NDQtMS4yMjNjLS40ODctLjUyNy0uODYzLTEuMTYyLTEuMTM3LTEuOTA2YTYuMzQ1IDYuMzQ1LjAgMCAxLS40MS0yLjMzYzAtLjkwMi4xMjMtMS43Ny4zOTctMi41MDhhNS41NTQgNS41NTQuMCAwIDEgMS4xNS0xLjg5MiA1LjEzMyA1LjEzMy4wIDAgMSAxLjc1LTEuMjE2Yy42NzktLjI4NyAxLjQyNS0uNDIzIDIuMjMyLS40MjMuODA4LjAgMS41NTMuMTQyIDIuMjM3LjQyM2E0Ljg4IDQuODguMCAwIDEgMS43NTMgMS4yMTYgNS42NDQgNS42NDQuMCAwIDEgMS4xMzUgMS44OTJjLjI4Ny43MzguNDMxIDEuNjA2LjQzMSAyLjUwOHptLTIwLjEzOC4wYzAgMS4xMi4yNDYgMi4zNjMuNzM4IDIuODgyLjQ5My41MiAxLjEzLjc4IDEuOTEuNzguNDI0LjAuODI4LS4wNjIgMS4yMDQtLjE3OC4zNzctLjExNi42NzctLjI1My45MTctLjQxN1Y5LjMzYTEwLjQ3NiAxMC40NzYuMCAwIDAtMS43NjYtLjIyNmMtLjk3MS0uMDI4LTEuNzEuMzctMi4yMyAxLjAwNC0uNTEzLjYzNi0uNzczIDEuNzUtLjc3MyAyLjc4OHptNy40MzggNS4yNzRjMCAxLjgyNC0uNDY2IDMuMTU2LTEuNDA0IDQuMDA0LS45MzYuODQ2LTIuMzY3IDEuMjctNC4yOTYgMS4yNy0uNzA1LjAtMi4xNy0uMTM3LTMuMzQtLjM5NmwuNDMxLTIuMTE4Yy45OC4yMDUgMi4yNzIuMjYgMi45NS4yNiAxLjA3NC4wIDEuODQtLjIxOSAyLjI5OS0uNjU2LjQ1OS0uNDM3LjY4NC0xLjA4Ni42ODQtMS45NDh2LS40MzdhOC4wNyA4LjA3LjAgMCAxLTEuMDQ3LjM5N2MtLjQzLjEzLS45My4xOTgtMS40OTIuMTk4LS43MzkuMC0xLjQxLS4xMTYtMi4wMTgtLjM0OWE0LjIwNiA0LjIwNi4wIDAgMS0xLjU2Ny0xLjAyNWMtLjQzMS0uNDUtLjc3NC0xLjAxNy0xLjAxMy0xLjY5NC0uMjQtLjY3Ny0uMzYzLTEuODg1LS4zNjMtMi43NzMuMC0uODM0LjEzLTEuODguMzg0LTIuNTc3LjI2LS42OTYuNjI5LTEuMjk4IDEuMTI5LTEuNzk2LjQ5My0uNDk4IDEuMDk1LS44ODEgMS44LTEuMTYyYTYuNjA1IDYuNjA1LjAgMCAxIDIuNDI4LS40NTdjLjg3LjAgMS42Ny4xMDkgMi40NS4yNC43OC4xMjkgMS40NDQuMjY1IDEuOTg1LjQxNVYxOC4xN3oiIGZpbGw9IiM1NDY4ZmYiLz48cGF0aCBkPSJNNi45NzIgNi42Nzd2MS42MjdjLS43MTItLjQ0Ni0xLjUyLS42Ny0yLjQyNS0uNjctLjU4NS4wLTEuMDQ1LjEzLTEuMzguMzkxYTEuMjQgMS4yNC4wIDAgMC0uNTAyIDEuMDNjMCAuNDI1LjE2NC43NjUuNDk0IDEuMDIuMzMuMjU2LjgzNS41MzIgMS41MTYuODMuNDQ3LjE5Mi43OTUuMzU2IDEuMDQ1LjQ5NS4yNS4xMzguNTM3LjMzMi44NjIuNTgyLjMyNC4yNS41NjMuNTQ4LjcxOC44OTQuMTU0LjM0NS4yMy43NDEuMjMgMS4xODguMC45NDctLjMzNCAxLjY5MS0xLjAwNCAyLjIzNC0uNjcuNTQyLTEuNTM3LjgxNC0yLjYwMS44MTQtMS4xOC4wLTIuMTYtLjIyOS0yLjkzNi0uNjg2di0xLjcwOGMuODQuNjI4IDEuODE0Ljk0MiAyLjkyLjk0Mi41ODUuMCAxLjA0OC0uMTM2IDEuMzg4LS40MDcuMzQtLjI3MS41MS0uNjQ2LjUxLTEuMTI1LjAtLjI4Ny0uMS0uNTUtLjMwMi0uNzktLjIwMy0uMjQtLjQyLS40Mi0uNjU1LS41NDItLjIzNC0uMTIzLS41ODUtLjI5LTEuMDUzLS41MDNhNjEuMjcgNjEuMjcuMCAwIDEtLjU4Mi0uMjcxIDEzLjY3IDEzLjY3LjAgMCAxLS41NS0uMjg3IDQuMjc1IDQuMjc1LjAgMCAxLS41NjctLjM1MSA2LjkyIDYuOTIuMCAwIDEtLjQ1NS0uNGMtLjE4LS4xNy0uMzEtLjM0LS4zOS0uNTEtLjA4LS4xNy0uMTU1LS4zNy0uMjI0LS41OThhMi41NTMgMi41NTMuMCAwIDEtLjEwNC0uNzQyYzAtLjkxNS4zMzMtMS42MzguOTk4LTIuMTcuNjY0LS41MzIgMS41MjMtLjc5OCAyLjU3Ni0uNzk4Ljk2OC4wIDEuNzkzLjE3IDIuNDczLjUxem03LjQ2OCA1LjY5NnYtLjI4N2MtLjAyMi0uNjA3LS4xODctMS4wODgtLjQ5NS0xLjQ0NC0uMzA5LS4zNTctLjc1LS41MzUtMS4zMjQtLjUzNS0uNTMyLjAtLjk5LjE5NC0xLjM3My41ODMtLjM4Mi4zODgtLjYyMi45NDktLjcxNyAxLjY4M2gzLjkwOXptMS4wMDUgMi43OTJ2MS40MDRjLS41OTYuMzQtMS4zODMuNTEtMi4zNjIuNTEtMS4yNTUuMC0yLjI1NS0uMzc3LTMtMS4xMzItLjc0NC0uNzU1LTEuMTE2LTEuNzQ0LTEuMTE2LTIuOTY4LjAtMS4yOTcuMzQtMi4zMTYgMS4wMjEtMy4wNTUuNjgtLjc0IDEuNTQ4LTEuMTEgMi42LTEuMTEgMS4wMzMuMCAxLjg1Mi4zMjMgMi40NTguOTY2LjYwNi42NDQuOTEgMS41NzIuOTEgMi43ODQuMC4zMy0uMDMzLjY3Ni0uMDk2IDEuMDM4aC01LjMxNGMuMTA3LjcwMi40MDUgMS4yMzkuODk0IDEuNjExLjQ5LjM3MiAxLjEwNi41NTggMS44NS41NTguODYyLjAgMS41OC0uMjAyIDIuMTU1LS42MDZ6bTYuNjA1LTEuNzdoLTEuMjEyYy0uNTk2LjAtMS4wNDUuMTE2LTEuMzQ5LjM1LS4zMDMuMjM0LS40NTQuNTMyLS40NTQuODk0LjAuMzcyLjExNy42NjQuMzUuODc3LjIzNS4yMTMuNTc1LjMyIDEuMDIyLjMyLjUxLjAuOTEyLS4xNDIgMS4yMDQtLjQyNC4yOTMtLjI4MS40NC0uNjUxLjQ0LTEuMTA4di0uOTF6bS00LjA2OC0yLjU1NFY5LjMyNWMuNjI3LS4zNjEgMS40NTctLjU0MiAyLjQ4OS0uNTQyIDIuMTE2LjAgMy4xNzUgMS4wMjYgMy4xNzUgMy4wOFYxN2gtMS41NDh2LS45NTdjLS40MTUuNjgtMS4xNDMgMS4wMi0yLjE4NiAxLjAyLS43NjYuMC0xLjM4LS4yMi0xLjg0My0uNjYxLS40NjItLjQ0Mi0uNjk0LTEuMDAzLS42OTQtMS42ODQuMC0uNzc2LjI5My0xLjM4Ljg3OC0xLjgxLjU4NS0uNDMxIDEuNDA0LS42NDcgMi40NTctLjY0N2gxLjM0VjExLjhjMC0uNTU0LS4xMzMtLjk3MS0uMzk5LTEuMjUzLS4yNjYtLjI4Mi0uNzA3LS40MjMtMS4zMjQtLjQyM2E0LjA3IDQuMDcuMCAwIDAtMi4zNDUuNzE4em05LjMzMy0xLjkzdjEuNDJjLjM5NC0xIDEuMTAxLTEuNSAyLjEyMy0xLjUuMTQ4LjAuMzEzLjAxNi40OTQuMDQ4djEuNTMxYTEuODg1IDEuODg1LjAgMCAwLS43NS0uMTQzYy0uNTQyLjAtLjk4OS4yNC0xLjM0LjcxOC0uMzUxLjQ3OS0uNTI3IDEuMDQ4LS41MjcgMS43MDdWMTdoLTEuNTYzVjguOTFoMS41NjN6bTUuMDEgNC4wODRjLjAyMi44Mi4yNzIgMS40OTIuNzUgMi4wMTkuNDc5LjUyNiAxLjE1Ljc5IDIuMDEuNzkuNjM5LjAgMS4yMzUtLjE3NiAxLjc4OC0uNTI3djEuNDA0Yy0uNTIxLjMxOS0xLjE4Ni40NzktMS45OTUuNDc5LTEuMjY1LjAtMi4yNzYtLjQtMy4wMzEtMS4xOTctLjc1NS0uNzk4LTEuMTMzLTEuNzkyLTEuMTMzLTIuOTg0LjAtMS4xNi4zOC0yLjE1MSAxLjE0LTIuOTc1Ljc2MS0uODI1IDEuNzktMS4yMzcgMy4wODgtMS4yMzcuNzAyLjAgMS4zNDYuMTQ5IDEuOTMuNDQ3djEuNDM2YTMuMjQyIDMuMjQyLjAgMCAwLTEuNzctLjQ5NWMtLjg0LjAtMS41MTMuMjY2LTIuMDE5Ljc5OC0uNTA1LjUzMi0uNzU4IDEuMjEzLS43NTggMi4wNDJ6TTQwLjI0IDUuNzJ2NC41NzljLjQ1OC0xIDEuMjkzLTEuNSAyLjUwNS0xLjUuNzg3LjAgMS40Mi4yNDUgMS44OTkuNzM0LjQ3OS40OS43MTggMS4xNy43MTggMi4wNDJWMTdoLTEuNTY0di01LjEwNmMwLS41NTMtLjE0LS45OC0uNDIyLTEuMjg0LS4yODItLjMwMy0uNjUyLS40NTUtMS4xMS0uNDU1LS41MzEuMC0xLjAwMi4yMDItMS40MTEuNjA2LS40MS40MDUtLjYxNSAxLjAyMi0uNjE1IDEuODUxVjE3aC0xLjU2M1Y1LjcyaDEuNTYzem0xNC45NjYgMTAuMDJjLjU5Ni4wIDEuMDk2LS4yNTMgMS41LS43NTguNDA0LS41MDYuNjA2LTEuMTU3LjYwNi0xLjk1NS4wLS45MTUtLjIwMi0xLjYyLS42MDYtMi4xMTQtLjQwNC0uNDk1LS45Mi0uNzQyLTEuNTQ4LS43NDItLjU1My4wLTEuMDUuMjI0LTEuNDkxLjY3LS40NDIuNDQ3LS42NjIgMS4xMzMtLjY2MiAyLjA1OC4wLjk1OC4yMTIgMS42Ny42MzggMi4xMzguNDI1LjQ2OS45NDYuNzAzIDEuNTYzLjcwM3pNNTMuMDA0IDUuNzJ2NC40MmMuNTc0LS44OTQgMS4zODgtMS4zNDEgMi40NC0xLjM0MSAxLjAyMi4wIDEuODU3LjM4MyAyLjUwNiAxLjE0OS42NDkuNzY2Ljk3MyAxLjc4MS45NzMgMy4wNDcuMCAxLjEzOC0uMzA5IDIuMTA5LS45MjUgMi45MTItLjYxNy44MDMtMS40NjMgMS4yMDUtMi41MzcgMS4yMDUtMS4wNzUuMC0xLjg5NC0uNDQ3LTIuNDU3LTEuMzRWMTdoLTEuNThWNS43MmgxLjU4em05LjkwOCAxMS4xMDQtMy4yMjMtNy45MTNoMS43MzlsMS4wMDUgMi42MzIgMS4yNiAzLjQxNWMuMDk2LS4zMi40OC0xLjQ1OCAxLjE1LTMuNDE1bC45MDktMi42MzJoMS42NmwtMi45MiA3Ljg2NmMtLjc3NyAyLjA3NC0xLjk2MyAzLjExLTMuNTU5IDMuMTFhMi45MiAyLjkyLjAgMCAxLS43MzQtLjA3OXYtMS4zNGMuMTcuMDQyLjM1MS4wNjQuNTQzLjA2NCAxLjAzMi4wIDEuNzU1LS41NyAyLjE3LTEuNzA4eiIgZmlsbD0iIzVkNjQ5NCIvPjxwYXRoIGQ9Ik04OS42MzIgNS45Njd2LS43NzJhLjk3OC45NzguMCAwIDAtLjk3OC0uOTc3aC0yLjI4YS45NzguOTc4LjAgMCAwLS45NzguOTc3di43OTNjMCAuMDg4LjA4Mi4xNS4xNzEuMTNhNy4xMjcgNy4xMjcuMCAwIDEgMS45ODQtLjI4Yy42NS4wIDEuMjk1LjA4OCAxLjkxNy4yNTkuMDgyLjAyLjE2NC0uMDQuMTY0LS4xM20tNi4yNDggMS4wMS0uMzktLjM4OWEuOTc3Ljk3Ny4wIDAgMC0xLjM4Mi4wbC0uNDY1LjQ2NWEuOTczLjk3My4wIDAgMCAwIDEuMzhsLjM4My4zODNjLjA2Mi4wNjEuMTUuMDQ3LjIwNS0uMDE0LjIyNi0uMzA3LjQ3Mi0uNjAxLjc0Ni0uODc0LjI4MS0uMjguNTY4LS41MjYuODgzLS43NTEuMDY4LS4wNDIuMDc1LS4xMzcuMDItLjJtNC4xNiAyLjQ1M3YzLjM0MWMwIC4wOTYuMTA0LjE2NS4xOTIuMTE3bDIuOTctMS41MzdjLjA2OC0uMDM0LjA4OS0uMTE3LjA1NS0uMTg0YTMuNjk1IDMuNjk1LjAgMCAwLTMuMDgtMS44NjZjLS4wNjguMC0uMTM2LjA1NC0uMTM2LjEzbTAgOC4wNDhhNC40ODkgNC40ODkuMCAwIDEtNC40OS00LjQ4MiA0LjQ4OCA0LjQ4OC4wIDAgMSA0LjQ5LTQuNDgyIDQuNDg4IDQuNDg4LjAgMCAxIDQuNDg5IDQuNDgyIDQuNDg0IDQuNDg0LjAgMCAxLTQuNDkgNC40ODJtMC0xMC44NWE2LjM2MyA2LjM2My4wIDEgMCAwIDEyLjcyOSA2LjM3IDYuMzcuMCAwIDAgNi4zNzItNi4zNjggNi4zNTggNi4zNTguMCAwIDAtNi4zNzEtNi4zNiIgZmlsbD0iI2ZmZiIvPjwvZz48L3N2Zz4=);background-repeat:no-repeat;background-position:50%;background-size:100%;overflow:hidden;text-indent:-9000px;padding:0!important;width:100%;height:100%;display:block}.overflow-x-scroll{-webkit-overflow-scrolling:touch}.row{-webkit-transition:450ms -webkit-transform;transition:450ms -webkit-transform;transition:450ms transform;transition:450ms transform,450ms -webkit-transform;font-size:0}.tile{-webkit-transition:450ms all;transition:450ms all}.details{background:-webkit-gradient(linear,left bottom,left top,from(rgba(0,0,0,.9)),to(rgba(0,0,0,0)));background:linear-gradient(to top,rgba(0,0,0,.9) 0%,rgba(0,0,0,0) 100%);-webkit-transition:450ms opacity;transition:450ms opacity}.tile:hover .details{opacity:1}.row:hover .tile{opacity:.3}.row:hover .tile:hover{opacity:1}.chroma .lntable pre{padding:0;margin:0;border:0}.chroma .lntable pre code{padding:0;margin:0}pre,.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}code{padding:.2em;margin:0;font-size:85%;background-color:rgba(27,31,35,.05);border-radius:3px}pre code{display:block;padding:1.5em;font-size:.875rem;line-height:2;overflow-x:auto}pre{background-color:#fff;color:#333;white-space:pre;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none;position:relative;border-width:1px;border-color:#ccc;border-style:solid}.highlight pre{background-color:inherit;color:inherit;padding:.5em;font-size:.875rem}.copy:after{content:"Copy"}.copied:after{content:"Copied"}@media screen and (min-width:60em){.full-width,pre.expand:hover{margin-right:-30vw;max-width:100vw}}.code-block .line-numbers-rows{background:#2f3a46;border:none;bottom:-50px;color:#98a4b3;left:-178px;padding:50px 0;top:-50px;width:138px}.code-block .line-numbers-rows>span:before{color:inherit;padding-right:30px}.tab-button{margin-bottom:1px;position:relative;z-index:1;color:#333;border-color:#ccc;outline:none;background-color:#fff}.tab-pane code{background:#f1f2f2;border-radius:0}.tab-pane .chroma{background:0 0;padding:0}.tab-button.active{border-bottom-color:#f1f2f2;background-color:#f1f2f2}.tab-content .tab-pane{display:none}.tab-content .tab-pane.active{display:block}.tab-content .copy,.tab-content .copied{display:none}.tab-content .tab-pane.active+.copy,.tab-content .tab-pane.active+.copied{display:block}.primary-color{color:#0594cb}.bg-primary-color{background-color:#0594cb}.hover-bg-primary-color:hover{background-color:#0594cb}.primary-color-dark{color:#0a1922}.bg-primary-color-dark{background-color:#0a1922}.hover-bg-primary-color-dark:hover{background-color:#0a1922}.primary-color-light{color:#f9f9f9}.bg-primary-color-light{background-color:#f9f9f9}.hover-bg-primary-color-light:hover{background-color:#f9f9f9}.accent-color{color:#ebb951}.bg-accent-color{background-color:#ebb951}.hover-bg-accent-color:hover{background-color:#ebb951}.accent-color-light{color:#ff4088}.hover-accent-color-light:hover{color:#ff4088}.bg-accent-color-light{background-color:#ff4088}.hover-bg-accent-color-light:hover{background-color:#ff4088}.accent-color-dark{color:#33ba91}.bg-accent-color-dark{background-color:#33ba91}.hover-bg-accent-color-dark:hover{background-color:#33ba91}.text-color-primary{color:#373737}.text-on-primary-color{color:#fff}.text-color-secondary{color:#ccc}.text-color-disabled{color:#f7f7f7}.divider-color{color:#f6f6f6}.warn-color{color:red}.nested-links a{color:#0594cb;text-decoration:none}.column-count-2{-webkit-column-count:1;column-count:1}.column-gap-1{-webkit-column-gap:0;column-gap:0}.break-inside-avoid{-webkit-column-break-inside:auto;break-inside:auto}@media screen and (min-width:60em){.column-count-3-l{-webkit-column-count:3;column-count:3}.column-count-2-l{-webkit-column-count:2;column-count:2}.column-gap-1-l{-webkit-column-gap:1;column-gap:1}.break-inside-avoid-l{-webkit-column-break-inside:avoid;break-inside:avoid}}.prose ul,.prose ol{margin-bottom:2em}.prose ul li,.prose ol li{margin-bottom:.5em}.prose li:hover{background-color:#eee}.prose ::selection{background:#0594cb;color:#fff}body{line-height:1.45}p{margin-bottom:1.3em}h1,h2,h3,h4{margin:1.414em 0 .5em;line-height:1.2}h1{margin-top:0;font-size:2.441em}h2{font-size:1.953em}h3{font-size:1.563em}h4{font-size:1.25em}small,.font_small{font-size:.8em}.prose table{width:100%;margin-bottom:3em;border-collapse:collapse;border-spacing:0;font-size:1em;border:1px solid #eee}.prose table th{background-color:#0594cb;border-bottom:1px solid #0594cb;color:#fff;font-weight:400;text-align:left;padding:.375em .5em}.prose table td,.prose table tc{padding:.75em .5em;text-align:left;border-right:1px solid #eee}.prose table tr:nth-child(even){background-color:#eee}dl dt{font-weight:700;font-size:1.125rem}dd{margin:.5em 0 2em;padding:0}.f2-fluid{font-size:2.25rem}@media screen and (min-width:60em){.f2-fluid{font-size:1.25rem;font-size:calc(0.70833rem + 0.83333vw)}}code,.code,pre code,.highlight pre{font-family:inconsolata,Menlo,Monaco,courier new,monospace}.sans-serif{font-family:muli,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:Palatino,palatino linotype,palatino lt std,book antiqua,Georgia,serif}.courier{font-family:courier next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:bodoni mt,serif}.calisto{font-family:calisto mt,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.pagination{margin:3rem 0}.pagination li{display:inline-block;margin-right:.375rem;font-size:.875rem;margin-bottom:2.5em}.pagination li a{padding:.5rem .625rem;background-color:#fff;color:#333;border:1px solid #ddd;border-radius:3px;text-decoration:none}.pagination li.disabled{display:none}.pagination li.active a:link,.pagination li.active a:active,.pagination li.active a:visited{background-color:#ddd}#TableOfContents ul li ul li ul li{display:none}#TableOfContents ul li{color:#000;display:block;margin-bottom:.375em;line-height:1.375}#TableOfContents ul li a{width:100%;padding:.25em .375em;margin-left:-.375em}#TableOfContents ul li a:hover{background-color:#999;color:#fff}.no-js .needs-js{opacity:0}.js .needs-js{opacity:1;-webkit-transition:opacity .15s ease-in;transition:opacity .15s ease-in}.facebook,.twitter,.instagram,.youtube{fill:#bababa}.facebook:hover{fill:#3b5998}.twitter{fill:#55acee}.twitter:hover{fill:#bababa}.instagram:hover{fill:#e95950}.youtube:hover{fill:#b00}@media(min-width:75em){[data-scrolldir=down] .sticky{position:fixed;top:100px;right:0}[data-scrolldir=up] .sticky{position:fixed;top:100px;right:0}}.fill-current{fill:currentColor}.chroma{background-color:#fff}.chroma .err{color:#a61717;background-color:#e3d2d2}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block}.chroma .hl{display:block;width:100%;background-color:#ffc}.chroma .lnt{margin-right:.4em;padding:0 .4em}.chroma .ln{margin-right:.4em;padding:0 .4em}.chroma .k{font-weight:700}.chroma .kc{font-weight:700}.chroma .kd{font-weight:700}.chroma .kn{font-weight:700}.chroma .kp{font-weight:700}.chroma .kr{font-weight:700}.chroma .kt{color:#458;font-weight:700}.chroma .na{color:teal}.chroma .nb{color:#999}.chroma .nc{color:#458;font-weight:700}.chroma .no{color:teal}.chroma .ni{color:purple}.chroma .ne{color:#900;font-weight:700}.chroma .nf{color:#900;font-weight:700}.chroma .nn{color:#555}.chroma .nt{color:navy}.chroma .nv{color:teal}.chroma .s{color:#b84}.chroma .sa{color:#b84}.chroma .sb{color:#b84}.chroma .sc{color:#b84}.chroma .dl{color:#b84}.chroma .sd{color:#b84}.chroma .s2{color:#b84}.chroma .se{color:#b84}.chroma .sh{color:#b84}.chroma .si{color:#b84}.chroma .sx{color:#b84}.chroma .sr{color:olive}.chroma .s1{color:#b84}.chroma .ss{color:#b84}.chroma .m{color:#099}.chroma .mb{color:#099}.chroma .mf{color:#099}.chroma .mh{color:#099}.chroma .mi{color:#099}.chroma .il{color:#099}.chroma .mo{color:#099}.chroma .o{font-weight:700}.chroma .ow{font-weight:700}.chroma .c{color:#998;font-style:italic}.chroma .ch{color:#998;font-style:italic}.chroma .cm{color:#998;font-style:italic}.chroma .c1{color:#998;font-style:italic}.chroma .cs{color:#999;font-weight:700;font-style:italic}.chroma .cp{color:#999;font-weight:700}.chroma .cpf{color:#999;font-weight:700}.chroma .gd{color:#000;background-color:#fdd}.chroma .ge{font-style:italic}.chroma .gr{color:#a00}.chroma .gh{color:#999}.chroma .gi{color:#000;background-color:#dfd}.chroma .go{color:#888}.chroma .gp{color:#555}.chroma .gs{font-weight:700}.chroma .gu{color:#aaa}.chroma .gt{color:#a00}.chroma .w{color:#bbb}.nested-blockquote blockquote{border-left:4px solid #0594cb;padding-left:1em}.mw-90{max-width:90%} \ No newline at end of file
diff --git a/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json b/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json
deleted file mode 100644
index 91f089a79..000000000
--- a/resources/_gen/assets/css/output/css/app.css_d11fe7b62c27961c87ecd0f2490357b9.json
+++ /dev/null
@@ -1 +0,0 @@
-{"Target":"output/css/app.min.2ac9b5935f7ff7709fe13c2b042a4a2d49fa96fb508e3e8870019ee9b72cf329.css","MediaType":"text/css","Data":{"Integrity":"sha256-Ksm1k19/93Cf4TwrBCpKLUn6lvtQjj6IcAGe6bcs8yk="}} \ No newline at end of file
diff --git a/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content b/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content
deleted file mode 100644
index 3097ec5a6..000000000
--- a/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.content
+++ /dev/null
@@ -1,18 +0,0 @@
-!function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(r,i,function(t){return e[t]}.bind(null,i));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(e,t,n){!function(t,n){var r=function(e,t){"use strict";if(!t.getElementsByClassName)return;var n,r,i=t.documentElement,s=e.Date,o=e.HTMLPictureElement,a=e.addEventListener,c=e.setTimeout,u=e.requestAnimationFrame||c,l=e.requestIdleCallback,h=/^picture$/i,d=["load","error","lazyincluded","_lazyloaded"],f={},p=Array.prototype.forEach,g=function(e,t){return f[t]||(f[t]=new RegExp("(\\s|^)"+t+"(\\s|$)")),f[t].test(e.getAttribute("class")||"")&&f[t]},m=function(e,t){g(e,t)||e.setAttribute("class",(e.getAttribute("class")||"").trim()+" "+t)},v=function(e,t){var n;(n=g(e,t))&&e.setAttribute("class",(e.getAttribute("class")||"").replace(n," "))},y=function(e,t,n){var r=n?"addEventListener":"removeEventListener";n&&y(e,t),d.forEach(function(n){e[r](n,t)})},b=function(e,r,i,s,o){var a=t.createEvent("Event");return i||(i={}),i.instance=n,a.initEvent(r,!s,!o),a.detail=i,e.dispatchEvent(a),a},w=function(t,n){var i;!o&&(i=e.picturefill||r.pf)?(n&&n.src&&!t.getAttribute("srcset")&&t.setAttribute("srcset",n.src),i({reevaluate:!0,elements:[t]})):n&&n.src&&(t.src=n.src)},_=function(e,t){return(getComputedStyle(e,null)||{})[t]},E=function(e,t,n){for(n=n||e.offsetWidth;n<r.minSize&&t&&!e._lazysizesWidth;)n=t.offsetWidth,t=t.parentNode;return n},x=function(){var e,n,r=[],i=[],s=r,o=function(){var t=s;for(s=r.length?i:r,e=!0,n=!1;t.length;)t.shift()();e=!1},a=function(r,i){e&&!i?r.apply(this,arguments):(s.push(r),n||(n=!0,(t.hidden?c:u)(o)))};return a._lsFlush=o,a}(),S=function(e,t){return t?function(){x(e)}:function(){var t=this,n=arguments;x(function(){e.apply(t,n)})}},C=function(e){var t,n,r=function(){t=null,e()},i=function(){var e=s.now()-n;e<99?c(i,99-e):(l||r)(r)};return function(){n=s.now(),t||(t=c(i,99))}};!function(){var t,n={lazyClass:"lazyload",loadedClass:"lazyloaded",loadingClass:"lazyloading",preloadClass:"lazypreload",errorClass:"lazyerror",autosizesClass:"lazyautosizes",srcAttr:"data-src",srcsetAttr:"data-srcset",sizesAttr:"data-sizes",minSize:40,customMedia:{},init:!0,expFactor:1.5,hFac:.8,loadMode:2,loadHidden:!0,ricTimeout:0,throttleDelay:125};for(t in r=e.lazySizesConfig||e.lazysizesConfig||{},n)t in r||(r[t]=n[t]);e.lazySizesConfig=r,c(function(){r.init&&O()})}();var A=function(){var o,u,d,f,E,A,O,T,k,R,M,L,I,D,P=/^img$/i,j=/^iframe$/i,$="onscroll"in e&&!/(gle|ing)bot/.test(navigator.userAgent),H=0,B=0,q=-1,z=function(e){B--,e&&e.target&&y(e.target,z),(!e||B<0||!e.target)&&(B=0)},F=function(e,n){var r,s=e,o="hidden"==_(t.body,"visibility")||"hidden"!=_(e.parentNode,"visibility")&&"hidden"!=_(e,"visibility");for(T-=n,M+=n,k-=n,R+=n;o&&(s=s.offsetParent)&&s!=t.body&&s!=i;)(o=(_(s,"opacity")||1)>0)&&"visible"!=_(s,"overflow")&&(r=s.getBoundingClientRect(),o=R>r.left&&k<r.right&&M>r.top-1&&T<r.bottom+1);return o},U=function(){var e,s,a,c,l,h,d,p,g,m=n.elements;if((f=r.loadMode)&&B<8&&(e=m.length)){s=0,q++,null==I&&("expand"in r||(r.expand=i.clientHeight>500&&i.clientWidth>500?500:370),L=r.expand,I=L*r.expFactor),H<I&&B<1&&q>2&&f>2&&!t.hidden?(H=I,q=0):H=f>1&&q>1&&B<6?L:0;for(;s<e;s++)if(m[s]&&!m[s]._lazyRace)if($)if((p=m[s].getAttribute("data-expand"))&&(h=1*p)||(h=H),g!==h&&(A=innerWidth+h*D,O=innerHeight+h,d=-1*h,g=h),a=m[s].getBoundingClientRect(),(M=a.bottom)>=d&&(T=a.top)<=O&&(R=a.right)>=d*D&&(k=a.left)<=A&&(M||R||k||T)&&(r.loadHidden||"hidden"!=_(m[s],"visibility"))&&(u&&B<3&&!p&&(f<3||q<4)||F(m[s],h))){if(X(m[s]),l=!0,B>9)break}else!l&&u&&!c&&B<4&&q<4&&f>2&&(o[0]||r.preloadAfterLoad)&&(o[0]||!p&&(M||R||k||T||"auto"!=m[s].getAttribute(r.sizesAttr)))&&(c=o[0]||m[s]);else X(m[s]);c&&!l&&X(c)}},K=function(e){var t,n=0,i=r.throttleDelay,o=r.ricTimeout,a=function(){t=!1,n=s.now(),e()},u=l&&o>49?function(){l(a,{timeout:o}),o!==r.ricTimeout&&(o=r.ricTimeout)}:S(function(){c(a)},!0);return function(e){var r;(e=!0===e)&&(o=33),t||(t=!0,(r=i-(s.now()-n))<0&&(r=0),e||r<9?u():c(u,r))}}(U),V=function(e){m(e.target,r.loadedClass),v(e.target,r.loadingClass),y(e.target,W),b(e.target,"lazyloaded")},J=S(V),W=function(e){J({target:e.target})},G=function(e){var t,n=e.getAttribute(r.srcsetAttr);(t=r.customMedia[e.getAttribute("data-media")||e.getAttribute("media")])&&e.setAttribute("media",t),n&&e.setAttribute("srcset",n)},Q=S(function(e,t,n,i,s){var o,a,u,l,f,g;(f=b(e,"lazybeforeunveil",t)).defaultPrevented||(i&&(n?m(e,r.autosizesClass):e.setAttribute("sizes",i)),a=e.getAttribute(r.srcsetAttr),o=e.getAttribute(r.srcAttr),s&&(u=e.parentNode,l=u&&h.test(u.nodeName||"")),g=t.firesLoad||"src"in e&&(a||o||l),f={target:e},g&&(y(e,z,!0),clearTimeout(d),d=c(z,2500),m(e,r.loadingClass),y(e,W,!0)),l&&p.call(u.getElementsByTagName("source"),G),a?e.setAttribute("srcset",a):o&&!l&&(j.test(e.nodeName)?function(e,t){try{e.contentWindow.location.replace(t)}catch(n){e.src=t}}(e,o):e.src=o),s&&(a||l)&&w(e,{src:o})),e._lazyRace&&delete e._lazyRace,v(e,r.lazyClass),x(function(){(!g||e.complete&&e.naturalWidth>1)&&(g?z(f):B--,V(f))},!0)}),X=function(e){var t,n=P.test(e.nodeName),i=n&&(e.getAttribute(r.sizesAttr)||e.getAttribute("sizes")),s="auto"==i;(!s&&u||!n||!e.getAttribute("src")&&!e.srcset||e.complete||g(e,r.errorClass)||!g(e,r.lazyClass))&&(t=b(e,"lazyunveilread").detail,s&&N.updateElem(e,!0,e.offsetWidth),e._lazyRace=!0,B++,Q(e,t,s,i,n))},Z=function(){if(!u)if(s.now()-E<999)c(Z,999);else{var e=C(function(){r.loadMode=3,K()});u=!0,r.loadMode=3,K(),a("scroll",function(){3==r.loadMode&&(r.loadMode=2),e()},!0)}};return{_:function(){E=s.now(),n.elements=t.getElementsByClassName(r.lazyClass),o=t.getElementsByClassName(r.lazyClass+" "+r.preloadClass),D=r.hFac,a("scroll",K,!0),a("resize",K,!0),e.MutationObserver?new MutationObserver(K).observe(i,{childList:!0,subtree:!0,attributes:!0}):(i.addEventListener("DOMNodeInserted",K,!0),i.addEventListener("DOMAttrModified",K,!0),setInterval(K,999)),a("hashchange",K,!0),["focus","mouseover","click","load","transitionend","animationend","webkitAnimationEnd"].forEach(function(e){t.addEventListener(e,K,!0)}),/d$|^c/.test(t.readyState)?Z():(a("load",Z),t.addEventListener("DOMContentLoaded",K),c(Z,2e4)),n.elements.length?(U(),x._lsFlush()):K()},checkElems:K,unveil:X}}(),N=function(){var e,n=S(function(e,t,n,r){var i,s,o;if(e._lazysizesWidth=r,r+="px",e.setAttribute("sizes",r),h.test(t.nodeName||""))for(i=t.getElementsByTagName("source"),s=0,o=i.length;s<o;s++)i[s].setAttribute("sizes",r);n.detail.dataAttr||w(e,n.detail)}),i=function(e,t,r){var i,s=e.parentNode;s&&(r=E(e,s,r),(i=b(e,"lazybeforesizes",{width:r,dataAttr:!!t})).defaultPrevented||(r=i.detail.width)&&r!==e._lazysizesWidth&&n(e,s,i,r))},s=C(function(){var t,n=e.length;if(n)for(t=0;t<n;t++)i(e[t])});return{_:function(){e=t.getElementsByClassName(r.autosizesClass),a("resize",s)},checkElems:s,updateElem:i}}(),O=function(){O.i||(O.i=!0,N._(),A._())};return n={cfg:r,autoSizer:N,loader:A,init:O,uP:w,aC:m,rC:v,hC:g,fire:b,gW:E,rAF:x}}(t,t.document);t.lazySizes=r,e.exports&&(e.exports=r)}(window)},function(e,t,n){"use strict";n.r(t);n(3),n(4),n(5),n(7),n(8),n(10),n(21),n(23),n(24),n(26),n(27),n(28);n(2)},function(e,t,n){},function(e,t,n){},function(e,t){var n=function(e){var t=document.createElement("a");return t.className="header-link",t.href="#"+e,t.innerHTML=' <svg class="fill-current o-60 hover-accent-color-light" height="22px" viewBox="0 0 24 24" width="22px" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>',t},r=function(e,t){for(var r=t.getElementsByTagName("h"+e),i=0;i<r.length;i++){var s=r[i];void 0!==s.id&&""!==s.id&&s.appendChild(n(s.id))}};document.onreadystatechange=function(){if("complete"===this.readyState){var e=document.getElementsByClassName("prose")[0];if(!e)return;for(var t=2;t<=4;t++)r(t,e)}}},function(e,t,n){function r(e,t){e.setAttribute("class","copied bg-primary-color-dark f6 absolute top-0 right-0 lh-solid hover-bg-primary-color-dark bn white ph3 pv2"),e.setAttribute("aria-label",t)}new(n(6))(".copy",{target:function(e){return e.classList.contains("copy-toggle")?e.previousElementSibling:e.nextElementSibling}}).on("success",function(e){r(e.trigger,"Copied!"),e.clearSelection()}).on("error",function(e){r(e.trigger,function(e,t){var n="",r="cut"===t?"X":"C";n=isMac?"Press ⌘-"+r:"Press Ctrl-"+r;return n}(e.action))})},function(e,t,n){
-/*!
- * clipboard.js v2.0.4
- * https://zenorocha.github.io/clipboard.js
- *
- * Licensed MIT © Zeno Rocha
- */
-!function(t,n){e.exports=n()}(0,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(r,i,function(t){return e[t]}.bind(null,i));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(){function e(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}return function(t,n,r){return n&&e(t.prototype,n),r&&e(t,r),t}}(),s=c(n(1)),o=c(n(3)),a=c(n(4));function c(e){return e&&e.__esModule?e:{default:e}}var u=function(e){function t(e,n){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,t);var r=function(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}(this,(t.__proto__||Object.getPrototypeOf(t)).call(this));return r.resolveOptions(n),r.listenClick(e),r}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}(t,o.default),i(t,[{key:"resolveOptions",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof e.action?e.action:this.defaultAction,this.target="function"==typeof e.target?e.target:this.defaultTarget,this.text="function"==typeof e.text?e.text:this.defaultText,this.container="object"===r(e.container)?e.container:document.body}},{key:"listenClick",value:function(e){var t=this;this.listener=(0,a.default)(e,"click",function(e){return t.onClick(e)})}},{key:"onClick",value:function(e){var t=e.delegateTarget||e.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new s.default({action:this.action(t),target:this.target(t),text:this.text(t),container:this.container,trigger:t,emitter:this})}},{key:"defaultAction",value:function(e){return l("action",e)}},{key:"defaultTarget",value:function(e){var t=l("target",e);if(t)return document.querySelector(t)}},{key:"defaultText",value:function(e){return l("text",e)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],t="string"==typeof e?[e]:e,n=!!document.queryCommandSupported;return t.forEach(function(e){n=n&&!!document.queryCommandSupported(e)}),n}}]),t}();function l(e,t){var n="data-clipboard-"+e;if(t.hasAttribute(n))return t.getAttribute(n)}e.exports=u},function(e,t,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(){function e(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}return function(t,n,r){return n&&e(t.prototype,n),r&&e(t,r),t}}(),s=function(e){return e&&e.__esModule?e:{default:e}}(n(2));var o=function(){function e(t){!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.resolveOptions(t),this.initSelection()}return i(e,[{key:"resolveOptions",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action=e.action,this.container=e.container,this.emitter=e.emitter,this.target=e.target,this.text=e.text,this.trigger=e.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var e=this,t="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return e.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[t?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,s.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,s.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var e=void 0;try{e=document.execCommand(this.action)}catch(t){e=!1}this.handleResult(e)}},{key:"handleResult",value:function(e){this.emitter.emit(e?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=e,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(e){if(void 0!==e){if(!e||"object"!==(void 0===e?"undefined":r(e))||1!==e.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&e.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(e.hasAttribute("readonly")||e.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=e}},get:function(){return this._target}}]),e}();e.exports=o},function(e,t){e.exports=function(e){var t;if("SELECT"===e.nodeName)e.focus(),t=e.value;else if("INPUT"===e.nodeName||"TEXTAREA"===e.nodeName){var n=e.hasAttribute("readonly");n||e.setAttribute("readonly",""),e.select(),e.setSelectionRange(0,e.value.length),n||e.removeAttribute("readonly"),t=e.value}else{e.hasAttribute("contenteditable")&&e.focus();var r=window.getSelection(),i=document.createRange();i.selectNodeContents(e),r.removeAllRanges(),r.addRange(i),t=r.toString()}return t}},function(e,t){function n(){}n.prototype={on:function(e,t,n){var r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this},once:function(e,t,n){var r=this;function i(){r.off(e,i),t.apply(n,arguments)}return i._=t,this.on(e,i,n)},emit:function(e){for(var t=[].slice.call(arguments,1),n=((this.e||(this.e={}))[e]||[]).slice(),r=0,i=n.length;r<i;r++)n[r].fn.apply(n[r].ctx,t);return this},off:function(e,t){var n=this.e||(this.e={}),r=n[e],i=[];if(r&&t)for(var s=0,o=r.length;s<o;s++)r[s].fn!==t&&r[s].fn._!==t&&i.push(r[s]);return i.length?n[e]=i:delete n[e],this}},e.exports=n},function(e,t,n){var r=n(5),i=n(6);e.exports=function(e,t,n){if(!e&&!t&&!n)throw new Error("Missing required arguments");if(!r.string(t))throw new TypeError("Second argument must be a String");if(!r.fn(n))throw new TypeError("Third argument must be a Function");if(r.node(e))return function(e,t,n){return e.addEventListener(t,n),{destroy:function(){e.removeEventListener(t,n)}}}(e,t,n);if(r.nodeList(e))return function(e,t,n){return Array.prototype.forEach.call(e,function(e){e.addEventListener(t,n)}),{destroy:function(){Array.prototype.forEach.call(e,function(e){e.removeEventListener(t,n)})}}}(e,t,n);if(r.string(e))return function(e,t,n){return i(document.body,e,t,n)}(e,t,n);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}},function(e,t){t.node=function(e){return void 0!==e&&e instanceof HTMLElement&&1===e.nodeType},t.nodeList=function(e){var n=Object.prototype.toString.call(e);return void 0!==e&&("[object NodeList]"===n||"[object HTMLCollection]"===n)&&"length"in e&&(0===e.length||t.node(e[0]))},t.string=function(e){return"string"==typeof e||e instanceof String},t.fn=function(e){return"[object Function]"===Object.prototype.toString.call(e)}},function(e,t,n){var r=n(7);function i(e,t,n,i,s){var o=function(e,t,n,i){return function(n){n.delegateTarget=r(n.target,t),n.delegateTarget&&i.call(e,n)}}.apply(this,arguments);return e.addEventListener(n,o,s),{destroy:function(){e.removeEventListener(n,o,s)}}}e.exports=function(e,t,n,r,s){return"function"==typeof e.addEventListener?i.apply(null,arguments):"function"==typeof n?i.bind(null,document).apply(null,arguments):("string"==typeof e&&(e=document.querySelectorAll(e)),Array.prototype.map.call(e,function(e){return i(e,t,n,r,s)}))}},function(e,t){var n=9;if("undefined"!=typeof Element&&!Element.prototype.matches){var r=Element.prototype;r.matches=r.matchesSelector||r.mozMatchesSelector||r.msMatchesSelector||r.oMatchesSelector||r.webkitMatchesSelector}e.exports=function(e,t){for(;e&&e.nodeType!==n;){if("function"==typeof e.matches&&e.matches(t))return e;e=e.parentNode}}}])})},function(e,t){let n=document.getElementById("prose");if(n){let e=n.getElementsByTagName("code");for(let[t,n]of Object.entries(e)){n.scrollWidth-n.clientWidth>0&&n.parentNode.classList.add("expand")}}},function(e,t,n){n(9)({apiKey:"167e7998590aebda7f9fedcf86bc4a55",indexName:"hugodocs",inputSelector:"#search-input",debug:!0})},function(e,t,n){
-/*! docsearch 2.6.1 | © Algolia | github.com/algolia/docsearch */
-!function(t,n){e.exports=n()}("undefined"!=typeof self&&self,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var i=t[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=22)}([function(e,t,n){"use strict";var r=n(1);function i(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}e.exports={isArray:null,isFunction:null,isObject:null,bind:null,each:null,map:null,mixin:null,isMsie:function(e){if(void 0===e&&(e=navigator.userAgent),/(msie|trident)/i.test(e)){var t=e.match(/(msie |rv:)(\d+(.\d+)?)/i);if(t)return t[2]}return!1},escapeRegExChars:function(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isNumber:function(e){return"number"==typeof e},toStr:function(e){return void 0===e||null===e?"":e+""},cloneDeep:function(e){var t=this.mixin({},e),n=this;return this.each(t,function(e,r){e&&(n.isArray(e)?t[r]=[].concat(e):n.isObject(e)&&(t[r]=n.cloneDeep(e)))}),t},error:function(e){throw new Error(e)},every:function(e,t){var n=!0;return e?(this.each(e,function(r,i){if(!(n=t.call(null,r,i,e)))return!1}),!!n):n},any:function(e,t){var n=!1;return e?(this.each(e,function(r,i){if(t.call(null,r,i,e))return n=!0,!1}),n):n},getUniqueId:function(){var e=0;return function(){return e++}}(),templatify:function(e){if(this.isFunction(e))return e;var t=r.element(e);return"SCRIPT"===t.prop("tagName")?function(){return t.text()}:function(){return String(e)}},defer:function(e){setTimeout(e,0)},noop:function(){},formatPrefix:function(e,t){return t?"":e+"-"},className:function(e,t,n){return(n?"":".")+e+t},escapeHighlightedString:function(e,t,n){t=t||"<em>";var r=document.createElement("div");r.appendChild(document.createTextNode(t)),n=n||"</em>";var s=document.createElement("div");s.appendChild(document.createTextNode(n));var o=document.createElement("div");return o.appendChild(document.createTextNode(e)),o.innerHTML.replace(RegExp(i(r.innerHTML),"g"),t).replace(RegExp(i(s.innerHTML),"g"),n)}}},function(e,t,n){"use strict";e.exports={element:null}},function(e,t){var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString;e.exports=function(e,t,i){if("[object Function]"!==r.call(t))throw new TypeError("iterator must be a function");var s=e.length;if(s===+s)for(var o=0;o<s;o++)t.call(i,e[o],o,e);else for(var a in e)n.call(e,a)&&t.call(i,e[a],a,e)}},function(e,t){e.exports=function(e){return JSON.parse(JSON.stringify(e))}},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";var r=n(12);function i(e,t){var r=n(2),i=this;"function"==typeof Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):i.stack=(new Error).stack||"Cannot get a stacktrace, browser is too old",this.name="AlgoliaSearchError",this.message=e||"Unknown error",t&&r(t,function(e,t){i[t]=e})}function s(e,t){function n(){var n=Array.prototype.slice.call(arguments,0);"string"!=typeof n[0]&&n.unshift(t),i.apply(this,n),this.name="AlgoliaSearch"+e+"Error"}return r(n,i),n}r(i,Error),e.exports={AlgoliaSearchError:i,UnparsableJSON:s("UnparsableJSON","Could not parse the incoming response as JSON, see err.more for details"),RequestTimeout:s("RequestTimeout","Request timedout before getting a response"),Network:s("Network","Network issue, see err.more for details"),JSONPScriptFail:s("JSONPScriptFail","<script> was loaded but did not call our provided callback"),JSONPScriptError:s("JSONPScriptError","<script> unable to load due to an `error` event on it"),Unknown:s("Unknown","Unknown error occured")}},function(e,t){var n={}.toString;e.exports=Array.isArray||function(e){return"[object Array]"==n.call(e)}},function(e,t,n){var r=n(2);e.exports=function(e,t){var n=[];return r(e,function(r,i){n.push(t(r,i,e))}),n}},function(e,t,n){(function(r){function i(){var e;try{e=t.storage.debug}catch(e){}return!e&&void 0!==r&&"env"in r&&(e=Object({NODE_ENV:"production"}).DEBUG),e}(t=e.exports=n(39)).log=function(){return"object"==typeof console&&console.log&&Function.prototype.apply.call(console.log,console,arguments)},t.formatArgs=function(e){var n=this.useColors;if(e[0]=(n?"%c":"")+this.namespace+(n?" %c":" ")+e[0]+(n?"%c ":" ")+"+"+t.humanize(this.diff),!n)return;var r="color: "+this.color;e.splice(1,0,r,"color: inherit");var i=0,s=0;e[0].replace(/%[a-zA-Z%]/g,function(e){"%%"!==e&&"%c"===e&&(s=++i)}),e.splice(s,0,r)},t.save=function(e){try{null==e?t.storage.removeItem("debug"):t.storage.debug=e}catch(e){}},t.load=i,t.useColors=function(){if("undefined"!=typeof window&&window.process&&"renderer"===window.process.type)return!0;return"undefined"!=typeof document&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||"undefined"!=typeof window&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/)&&parseInt(RegExp.$1,10)>=31||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)},t.storage="undefined"!=typeof chrome&&void 0!==chrome.storage?chrome.storage.local:function(){try{return window.localStorage}catch(e){}}(),t.colors=["lightseagreen","forestgreen","goldenrod","dodgerblue","darkorchid","crimson"],t.formatters.j=function(e){try{return JSON.stringify(e)}catch(e){return"[UnexpectedJSONParseError]: "+e.message}},t.enable(i())}).call(t,n(9))},function(e,t){var n,r,i=e.exports={};function s(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function a(e){if(n===setTimeout)return setTimeout(e,0);if((n===s||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:s}catch(e){n=s}try{r="function"==typeof clearTimeout?clearTimeout:o}catch(e){r=o}}();var c,u=[],l=!1,h=-1;function d(){l&&c&&(l=!1,c.length?u=c.concat(u):h=-1,u.length&&f())}function f(){if(!l){var e=a(d);l=!0;for(var t=u.length;t;){for(c=u,u=[];++h<t;)c&&c[h].run();h=-1,t=u.length}c=null,l=!1,function(e){if(r===clearTimeout)return clearTimeout(e);if((r===o||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(e);try{r(e)}catch(t){try{return r.call(null,e)}catch(t){return r.call(this,e)}}}(e)}}function p(e,t){this.fun=e,this.array=t}function g(){}i.nextTick=function(e){var t=new Array(arguments.length-1);if(arguments.length>1)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];u.push(new p(e,t)),1!==u.length||l||a(f)},p.prototype.run=function(){this.fun.apply(null,this.array)},i.title="browser",i.browser=!0,i.env={},i.argv=[],i.version="",i.versions={},i.on=g,i.addListener=g,i.once=g,i.off=g,i.removeListener=g,i.removeAllListeners=g,i.emit=g,i.prependListener=g,i.prependOnceListener=g,i.listeners=function(e){return[]},i.binding=function(e){throw new Error("process.binding is not supported")},i.cwd=function(){return"/"},i.chdir=function(e){throw new Error("process.chdir is not supported")},i.umask=function(){return 0}},function(e,t,n){"use strict";var r=n(53),i=/\s+/;function s(e,t,n,r){var s;if(!n)return this;for(t=t.split(i),n=r?function(e,t){return e.bind?e.bind(t):function(){e.apply(t,[].slice.call(arguments,0))}}(n,r):n,this._callbacks=this._callbacks||{};s=t.shift();)this._callbacks[s]=this._callbacks[s]||{sync:[],async:[]},this._callbacks[s][e].push(n);return this}function o(e,t,n){return function(){for(var r,i=0,s=e.length;!r&&i<s;i+=1)r=!1===e[i].apply(t,n);return!r}}e.exports={onSync:function(e,t,n){return s.call(this,"sync",e,t,n)},onAsync:function(e,t,n){return s.call(this,"async",e,t,n)},off:function(e){var t;if(!this._callbacks)return this;e=e.split(i);for(;t=e.shift();)delete this._callbacks[t];return this},trigger:function(e){var t,n,s,a,c;if(!this._callbacks)return this;e=e.split(i),s=[].slice.call(arguments,1);for(;(t=e.shift())&&(n=this._callbacks[t]);)a=o(n.sync,this,[t].concat(s)),c=o(n.async,this,[t].concat(s)),a()&&r(c);return this}}},function(e,t,n){"use strict";var r=n(0),i={wrapper:{position:"relative",display:"inline-block"},hint:{position:"absolute",top:"0",left:"0",borderColor:"transparent",boxShadow:"none",opacity:"1"},input:{position:"relative",verticalAlign:"top",backgroundColor:"transparent"},inputWithNoHint:{position:"relative",verticalAlign:"top"},dropdown:{position:"absolute",top:"100%",left:"0",zIndex:"100",display:"none"},suggestions:{display:"block"},suggestion:{whiteSpace:"nowrap",cursor:"pointer"},suggestionChild:{whiteSpace:"normal"},ltr:{left:"0",right:"auto"},rtl:{left:"auto",right:"0"},defaultClasses:{root:"algolia-autocomplete",prefix:"aa",noPrefix:!1,dropdownMenu:"dropdown-menu",input:"input",hint:"hint",suggestions:"suggestions",suggestion:"suggestion",cursor:"cursor",dataset:"dataset",empty:"empty"},appendTo:{wrapper:{position:"absolute",zIndex:"100",display:"none"},input:{},inputWithNoHint:{},dropdown:{display:"block"}}};r.isMsie()&&r.mixin(i.input,{backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"}),r.isMsie()&&r.isMsie()<=7&&r.mixin(i.input,{marginTop:"-1px"}),e.exports=i},function(e,t){"function"==typeof Object.create?e.exports=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})}:e.exports=function(e,t){e.super_=t;var n=function(){};n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e}},function(e,t,n){e.exports=function(e,t){return function(n,i,s){if("function"==typeof n&&"object"==typeof i||"object"==typeof s)throw new r.AlgoliaSearchError("index.search usage is index.search(query, params, cb)");0===arguments.length||"function"==typeof n?(s=n,n=""):1!==arguments.length&&"function"!=typeof i||(s=i,i=void 0),"object"==typeof n&&null!==n?(i=n,n=void 0):void 0!==n&&null!==n||(n="");var o,a="";return void 0!==n&&(a+=e+"="+encodeURIComponent(n)),void 0!==i&&(i.additionalUA&&(o=i.additionalUA,delete i.additionalUA),a=this.as._getSearchParams(i,a)),this._search(a,t,s,o)}};var r=n(5)},function(e,t,n){e.exports=function(e,t){var r=n(36),i={};return n(2)(r(e),function(n){!0!==t(n)&&(i[n]=e[n])}),i}},function(e,t){!function(t,n){e.exports=function(e){var t=function(){var t,n,r,i,s,o,a=[],c=a.concat,u=a.filter,l=a.slice,h=e.document,d={},f={},p={"column-count":1,columns:1,"font-weight":1,"line-height":1,opacity:1,"z-index":1,zoom:1},g=/^\s*<(\w+|!)[^>]*>/,m=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,y=/^(?:body|html)$/i,b=/([A-Z])/g,w=["val","css","html","text","data","width","height","offset"],_=h.createElement("table"),E=h.createElement("tr"),x={tr:h.createElement("tbody"),tbody:_,thead:_,tfoot:_,td:E,th:E,"*":h.createElement("div")},S=/complete|loaded|interactive/,C=/^[\w-]*$/,A={},N=A.toString,O={},T=h.createElement("div"),k={tabindex:"tabIndex",readonly:"readOnly",for:"htmlFor",class:"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},R=Array.isArray||function(e){return e instanceof Array};function M(e){return null==e?String(e):A[N.call(e)]||"object"}function L(e){return"function"==M(e)}function I(e){return null!=e&&e==e.window}function D(e){return null!=e&&e.nodeType==e.DOCUMENT_NODE}function P(e){return"object"==M(e)}function j(e){return P(e)&&!I(e)&&Object.getPrototypeOf(e)==Object.prototype}function $(e){var t=!!e&&"length"in e&&e.length,n=r.type(e);return"function"!=n&&!I(e)&&("array"==n||0===t||"number"==typeof t&&t>0&&t-1 in e)}function H(e){return e.replace(/::/g,"/").replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").replace(/_/g,"-").toLowerCase()}function B(e){return e in f?f[e]:f[e]=new RegExp("(^|\\s)"+e+"(\\s|$)")}function q(e,t){return"number"!=typeof t||p[H(e)]?t:t+"px"}function z(e){return"children"in e?l.call(e.children):r.map(e.childNodes,function(e){if(1==e.nodeType)return e})}function F(e,t){var n,r=e?e.length:0;for(n=0;n<r;n++)this[n]=e[n];this.length=r,this.selector=t||""}function U(e,t){return null==t?r(e):r(e).filter(t)}function K(e,t,n,r){return L(t)?t.call(e,n,r):t}function V(e,t,n){null==n?e.removeAttribute(t):e.setAttribute(t,n)}function J(e,n){var r=e.className||"",i=r&&r.baseVal!==t;if(n===t)return i?r.baseVal:r;i?r.baseVal=n:e.className=n}function W(e){try{return e?"true"==e||"false"!=e&&("null"==e?null:+e+""==e?+e:/^[\[\{]/.test(e)?r.parseJSON(e):e):e}catch(t){return e}}return O.matches=function(e,t){if(!t||!e||1!==e.nodeType)return!1;var n=e.matches||e.webkitMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.matchesSelector;if(n)return n.call(e,t);var r,i=e.parentNode,s=!i;return s&&(i=T).appendChild(e),r=~O.qsa(i,t).indexOf(e),s&&T.removeChild(e),r},s=function(e){return e.replace(/-+(.)?/g,function(e,t){return t?t.toUpperCase():""})},o=function(e){return u.call(e,function(t,n){return e.indexOf(t)==n})},O.fragment=function(e,n,i){var s,o,a;return m.test(e)&&(s=r(h.createElement(RegExp.$1))),s||(e.replace&&(e=e.replace(v,"<$1></$2>")),n===t&&(n=g.test(e)&&RegExp.$1),n in x||(n="*"),(a=x[n]).innerHTML=""+e,s=r.each(l.call(a.childNodes),function(){a.removeChild(this)})),j(i)&&(o=r(s),r.each(i,function(e,t){w.indexOf(e)>-1?o[e](t):o.attr(e,t)})),s},O.Z=function(e,t){return new F(e,t)},O.isZ=function(e){return e instanceof O.Z},O.init=function(e,n){var i;if(!e)return O.Z();if("string"==typeof e)if("<"==(e=e.trim())[0]&&g.test(e))i=O.fragment(e,RegExp.$1,n),e=null;else{if(n!==t)return r(n).find(e);i=O.qsa(h,e)}else{if(L(e))return r(h).ready(e);if(O.isZ(e))return e;if(R(e))i=function(e){return u.call(e,function(e){return null!=e})}(e);else if(P(e))i=[e],e=null;else if(g.test(e))i=O.fragment(e.trim(),RegExp.$1,n),e=null;else{if(n!==t)return r(n).find(e);i=O.qsa(h,e)}}return O.Z(i,e)},(r=function(e,t){return O.init(e,t)}).extend=function(e){var r,i=l.call(arguments,1);return"boolean"==typeof e&&(r=e,e=i.shift()),i.forEach(function(i){!function e(r,i,s){for(n in i)s&&(j(i[n])||R(i[n]))?(j(i[n])&&!j(r[n])&&(r[n]={}),R(i[n])&&!R(r[n])&&(r[n]=[]),e(r[n],i[n],s)):i[n]!==t&&(r[n]=i[n])}(e,i,r)}),e},O.qsa=function(e,t){var n,r="#"==t[0],i=!r&&"."==t[0],s=r||i?t.slice(1):t,o=C.test(s);return e.getElementById&&o&&r?(n=e.getElementById(s))?[n]:[]:1!==e.nodeType&&9!==e.nodeType&&11!==e.nodeType?[]:l.call(o&&!r&&e.getElementsByClassName?i?e.getElementsByClassName(s):e.getElementsByTagName(t):e.querySelectorAll(t))},r.contains=h.documentElement.contains?function(e,t){return e!==t&&e.contains(t)}:function(e,t){for(;t&&(t=t.parentNode);)if(t===e)return!0;return!1},r.type=M,r.isFunction=L,r.isWindow=I,r.isArray=R,r.isPlainObject=j,r.isEmptyObject=function(e){var t;for(t in e)return!1;return!0},r.isNumeric=function(e){var t=Number(e),n=typeof e;return null!=e&&"boolean"!=n&&("string"!=n||e.length)&&!isNaN(t)&&isFinite(t)||!1},r.inArray=function(e,t,n){return a.indexOf.call(t,e,n)},r.camelCase=s,r.trim=function(e){return null==e?"":String.prototype.trim.call(e)},r.uuid=0,r.support={},r.expr={},r.noop=function(){},r.map=function(e,t){var n,i,s,o=[];if($(e))for(i=0;i<e.length;i++)null!=(n=t(e[i],i))&&o.push(n);else for(s in e)null!=(n=t(e[s],s))&&o.push(n);return function(e){return e.length>0?r.fn.concat.apply([],e):e}(o)},r.each=function(e,t){var n,r;if($(e)){for(n=0;n<e.length;n++)if(!1===t.call(e[n],n,e[n]))return e}else for(r in e)if(!1===t.call(e[r],r,e[r]))return e;return e},r.grep=function(e,t){return u.call(e,t)},e.JSON&&(r.parseJSON=JSON.parse),r.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){A["[object "+t+"]"]=t.toLowerCase()}),r.fn={constructor:O.Z,length:0,forEach:a.forEach,reduce:a.reduce,push:a.push,sort:a.sort,splice:a.splice,indexOf:a.indexOf,concat:function(){var e,t,n=[];for(e=0;e<arguments.length;e++)t=arguments[e],n[e]=O.isZ(t)?t.toArray():t;return c.apply(O.isZ(this)?this.toArray():this,n)},map:function(e){return r(r.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return r(l.apply(this,arguments))},ready:function(e){return S.test(h.readyState)&&h.body?e(r):h.addEventListener("DOMContentLoaded",function(){e(r)},!1),this},get:function(e){return e===t?l.call(this):this[e>=0?e:e+this.length]},toArray:function(){return this.get()},size:function(){return this.length},remove:function(){return this.each(function(){null!=this.parentNode&&this.parentNode.removeChild(this)})},each:function(e){return a.every.call(this,function(t,n){return!1!==e.call(t,n,t)}),this},filter:function(e){return L(e)?this.not(this.not(e)):r(u.call(this,function(t){return O.matches(t,e)}))},add:function(e,t){return r(o(this.concat(r(e,t))))},is:function(e){return this.length>0&&O.matches(this[0],e)},not:function(e){var n=[];if(L(e)&&e.call!==t)this.each(function(t){e.call(this,t)||n.push(this)});else{var i="string"==typeof e?this.filter(e):$(e)&&L(e.item)?l.call(e):r(e);this.forEach(function(e){i.indexOf(e)<0&&n.push(e)})}return r(n)},has:function(e){return this.filter(function(){return P(e)?r.contains(this,e):r(this).find(e).size()})},eq:function(e){return-1===e?this.slice(e):this.slice(e,+e+1)},first:function(){var e=this[0];return e&&!P(e)?e:r(e)},last:function(){var e=this[this.length-1];return e&&!P(e)?e:r(e)},find:function(e){var t=this;return e?"object"==typeof e?r(e).filter(function(){var e=this;return a.some.call(t,function(t){return r.contains(t,e)})}):1==this.length?r(O.qsa(this[0],e)):this.map(function(){return O.qsa(this,e)}):r()},closest:function(e,t){var n=[],i="object"==typeof e&&r(e);return this.each(function(r,s){for(;s&&!(i?i.indexOf(s)>=0:O.matches(s,e));)s=s!==t&&!D(s)&&s.parentNode;s&&n.indexOf(s)<0&&n.push(s)}),r(n)},parents:function(e){for(var t=[],n=this;n.length>0;)n=r.map(n,function(e){if((e=e.parentNode)&&!D(e)&&t.indexOf(e)<0)return t.push(e),e});return U(t,e)},parent:function(e){return U(o(this.pluck("parentNode")),e)},children:function(e){return U(this.map(function(){return z(this)}),e)},contents:function(){return this.map(function(){return this.contentDocument||l.call(this.childNodes)})},siblings:function(e){return U(this.map(function(e,t){return u.call(z(t.parentNode),function(e){return e!==t})}),e)},empty:function(){return this.each(function(){this.innerHTML=""})},pluck:function(e){return r.map(this,function(t){return t[e]})},show:function(){return this.each(function(){"none"==this.style.display&&(this.style.display=""),"none"==getComputedStyle(this,"").getPropertyValue("display")&&(this.style.display=function(e){var t,n;d[e]||(t=h.createElement(e),h.body.appendChild(t),n=getComputedStyle(t,"").getPropertyValue("display"),t.parentNode.removeChild(t),"none"==n&&(n="block"),d[e]=n);return d[e]}(this.nodeName))})},replaceWith:function(e){return this.before(e).remove()},wrap:function(e){var t=L(e);if(this[0]&&!t)var n=r(e).get(0),i=n.parentNode||this.length>1;return this.each(function(s){r(this).wrapAll(t?e.call(this,s):i?n.cloneNode(!0):n)})},wrapAll:function(e){if(this[0]){var t;for(r(this[0]).before(e=r(e));(t=e.children()).length;)e=t.first();r(e).append(this)}return this},wrapInner:function(e){var t=L(e);return this.each(function(n){var i=r(this),s=i.contents(),o=t?e.call(this,n):e;s.length?s.wrapAll(o):i.append(o)})},unwrap:function(){return this.parent().each(function(){r(this).replaceWith(r(this).children())}),this},clone:function(){return this.map(function(){return this.cloneNode(!0)})},hide:function(){return this.css("display","none")},toggle:function(e){return this.each(function(){var n=r(this);(e===t?"none"==n.css("display"):e)?n.show():n.hide()})},prev:function(e){return r(this.pluck("previousElementSibling")).filter(e||"*")},next:function(e){return r(this.pluck("nextElementSibling")).filter(e||"*")},html:function(e){return 0 in arguments?this.each(function(t){var n=this.innerHTML;r(this).empty().append(K(this,e,t,n))}):0 in this?this[0].innerHTML:null},text:function(e){return 0 in arguments?this.each(function(t){var n=K(this,e,t,this.textContent);this.textContent=null==n?"":""+n}):0 in this?this.pluck("textContent").join(""):null},attr:function(e,r){var i;return"string"!=typeof e||1 in arguments?this.each(function(t){if(1===this.nodeType)if(P(e))for(n in e)V(this,n,e[n]);else V(this,e,K(this,r,t,this.getAttribute(e)))}):0 in this&&1==this[0].nodeType&&null!=(i=this[0].getAttribute(e))?i:t},removeAttr:function(e){return this.each(function(){1===this.nodeType&&e.split(" ").forEach(function(e){V(this,e)},this)})},prop:function(e,t){return e=k[e]||e,1 in arguments?this.each(function(n){this[e]=K(this,t,n,this[e])}):this[0]&&this[0][e]},removeProp:function(e){return e=k[e]||e,this.each(function(){delete this[e]})},data:function(e,n){var r="data-"+e.replace(b,"-$1").toLowerCase(),i=1 in arguments?this.attr(r,n):this.attr(r);return null!==i?W(i):t},val:function(e){return 0 in arguments?(null==e&&(e=""),this.each(function(t){this.value=K(this,e,t,this.value)})):this[0]&&(this[0].multiple?r(this[0]).find("option").filter(function(){return this.selected}).pluck("value"):this[0].value)},offset:function(t){if(t)return this.each(function(e){var n=r(this),i=K(this,t,e,n.offset()),s=n.offsetParent().offset(),o={top:i.top-s.top,left:i.left-s.left};"static"==n.css("position")&&(o.position="relative"),n.css(o)});if(!this.length)return null;if(h.documentElement!==this[0]&&!r.contains(h.documentElement,this[0]))return{top:0,left:0};var n=this[0].getBoundingClientRect();return{left:n.left+e.pageXOffset,top:n.top+e.pageYOffset,width:Math.round(n.width),height:Math.round(n.height)}},css:function(e,t){if(arguments.length<2){var i=this[0];if("string"==typeof e){if(!i)return;return i.style[s(e)]||getComputedStyle(i,"").getPropertyValue(e)}if(R(e)){if(!i)return;var o={},a=getComputedStyle(i,"");return r.each(e,function(e,t){o[t]=i.style[s(t)]||a.getPropertyValue(t)}),o}}var c="";if("string"==M(e))t||0===t?c=H(e)+":"+q(e,t):this.each(function(){this.style.removeProperty(H(e))});else for(n in e)e[n]||0===e[n]?c+=H(n)+":"+q(n,e[n])+";":this.each(function(){this.style.removeProperty(H(n))});return this.each(function(){this.style.cssText+=";"+c})},index:function(e){return e?this.indexOf(r(e)[0]):this.parent().children().indexOf(this[0])},hasClass:function(e){return!!e&&a.some.call(this,function(e){return this.test(J(e))},B(e))},addClass:function(e){return e?this.each(function(t){if("className"in this){i=[];var n=J(this),s=K(this,e,t,n);s.split(/\s+/g).forEach(function(e){r(this).hasClass(e)||i.push(e)},this),i.length&&J(this,n+(n?" ":"")+i.join(" "))}}):this},removeClass:function(e){return this.each(function(n){if("className"in this){if(e===t)return J(this,"");i=J(this),K(this,e,n,i).split(/\s+/g).forEach(function(e){i=i.replace(B(e)," ")}),J(this,i.trim())}})},toggleClass:function(e,n){return e?this.each(function(i){var s=r(this),o=K(this,e,i,J(this));o.split(/\s+/g).forEach(function(e){(n===t?!s.hasClass(e):n)?s.addClass(e):s.removeClass(e)})}):this},scrollTop:function(e){if(this.length){var n="scrollTop"in this[0];return e===t?n?this[0].scrollTop:this[0].pageYOffset:this.each(n?function(){this.scrollTop=e}:function(){this.scrollTo(this.scrollX,e)})}},scrollLeft:function(e){if(this.length){var n="scrollLeft"in this[0];return e===t?n?this[0].scrollLeft:this[0].pageXOffset:this.each(n?function(){this.scrollLeft=e}:function(){this.scrollTo(e,this.scrollY)})}},position:function(){if(this.length){var e=this[0],t=this.offsetParent(),n=this.offset(),i=y.test(t[0].nodeName)?{top:0,left:0}:t.offset();return n.top-=parseFloat(r(e).css("margin-top"))||0,n.left-=parseFloat(r(e).css("margin-left"))||0,i.top+=parseFloat(r(t[0]).css("border-top-width"))||0,i.left+=parseFloat(r(t[0]).css("border-left-width"))||0,{top:n.top-i.top,left:n.left-i.left}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent||h.body;e&&!y.test(e.nodeName)&&"static"==r(e).css("position");)e=e.offsetParent;return e})}},r.fn.detach=r.fn.remove,["width","height"].forEach(function(e){var n=e.replace(/./,function(e){return e[0].toUpperCase()});r.fn[e]=function(i){var s,o=this[0];return i===t?I(o)?o["inner"+n]:D(o)?o.documentElement["scroll"+n]:(s=this.offset())&&s[e]:this.each(function(t){(o=r(this)).css(e,K(this,i,t,o[e]()))})}}),["after","prepend","before","append"].forEach(function(n,i){var s=i%2;r.fn[n]=function(){var n,o,a=r.map(arguments,function(e){var i=[];return"array"==(n=M(e))?(e.forEach(function(e){return e.nodeType!==t?i.push(e):r.zepto.isZ(e)?i=i.concat(e.get()):void(i=i.concat(O.fragment(e)))}),i):"object"==n||null==e?e:O.fragment(e)}),c=this.length>1;return a.length<1?this:this.each(function(t,n){o=s?n:n.parentNode,n=0==i?n.nextSibling:1==i?n.firstChild:2==i?n:null;var u=r.contains(h.documentElement,o);a.forEach(function(t){if(c)t=t.cloneNode(!0);else if(!o)return r(t).remove();o.insertBefore(t,n),u&&function e(t,n){n(t);for(var r=0,i=t.childNodes.length;r<i;r++)e(t.childNodes[r],n)}(t,function(t){if(!(null==t.nodeName||"SCRIPT"!==t.nodeName.toUpperCase()||t.type&&"text/javascript"!==t.type||t.src)){var n=t.ownerDocument?t.ownerDocument.defaultView:e;n.eval.call(n,t.innerHTML)}})})})},r.fn[s?n+"To":"insert"+(i?"Before":"After")]=function(e){return r(e)[n](this),this}}),O.Z.prototype=F.prototype=r.fn,O.uniq=o,O.deserializeValue=W,r.zepto=O,r}();return function(t){var n,r=1,i=Array.prototype.slice,s=t.isFunction,o=function(e){return"string"==typeof e},a={},c={},u="onfocusin"in e,l={focus:"focusin",blur:"focusout"},h={mouseenter:"mouseover",mouseleave:"mouseout"};function d(e){return e._zid||(e._zid=r++)}function f(e,t,n,r){if((t=p(t)).ns)var i=function(e){return new RegExp("(?:^| )"+e.replace(" "," .* ?")+"(?: |$)")}(t.ns);return(a[d(e)]||[]).filter(function(e){return e&&(!t.e||e.e==t.e)&&(!t.ns||i.test(e.ns))&&(!n||d(e.fn)===d(n))&&(!r||e.sel==r)})}function p(e){var t=(""+e).split(".");return{e:t[0],ns:t.slice(1).sort().join(" ")}}function g(e,t){return e.del&&!u&&e.e in l||!!t}function m(e){return h[e]||u&&l[e]||e}function v(e,r,i,s,o,c,u){var l=d(e),f=a[l]||(a[l]=[]);r.split(/\s/).forEach(function(r){if("ready"==r)return t(document).ready(i);var a=p(r);a.fn=i,a.sel=o,a.e in h&&(i=function(e){var n=e.relatedTarget;if(!n||n!==this&&!t.contains(this,n))return a.fn.apply(this,arguments)}),a.del=c;var l=c||i;a.proxy=function(t){if(!(t=x(t)).isImmediatePropagationStopped()){try{var r=Object.getOwnPropertyDescriptor(t,"data");r&&!r.writable||(t.data=s)}catch(t){}var i=l.apply(e,t._args==n?[t]:[t].concat(t._args));return!1===i&&(t.preventDefault(),t.stopPropagation()),i}},a.i=f.length,f.push(a),"addEventListener"in e&&e.addEventListener(m(a.e),a.proxy,g(a,u))})}function y(e,t,n,r,i){var s=d(e);(t||"").split(/\s/).forEach(function(t){f(e,t,n,r).forEach(function(t){delete a[s][t.i],"removeEventListener"in e&&e.removeEventListener(m(t.e),t.proxy,g(t,i))})})}c.click=c.mousedown=c.mouseup=c.mousemove="MouseEvents",t.event={add:v,remove:y},t.proxy=function(e,n){var r=2 in arguments&&i.call(arguments,2);if(s(e)){var a=function(){return e.apply(n,r?r.concat(i.call(arguments)):arguments)};return a._zid=d(e),a}if(o(n))return r?(r.unshift(e[n],e),t.proxy.apply(null,r)):t.proxy(e[n],e);throw new TypeError("expected function")},t.fn.bind=function(e,t,n){return this.on(e,t,n)},t.fn.unbind=function(e,t){return this.off(e,t)},t.fn.one=function(e,t,n,r){return this.on(e,t,n,r,1)};var b=function(){return!0},w=function(){return!1},_=/^([A-Z]|returnValue$|layer[XY]$|webkitMovement[XY]$)/,E={preventDefault:"isDefaultPrevented",stopImmediatePropagation:"isImmediatePropagationStopped",stopPropagation:"isPropagationStopped"};function x(e,r){return!r&&e.isDefaultPrevented||(r||(r=e),t.each(E,function(t,n){var i=r[t];e[t]=function(){return this[n]=b,i&&i.apply(r,arguments)},e[n]=w}),e.timeStamp||(e.timeStamp=Date.now()),(r.defaultPrevented!==n?r.defaultPrevented:"returnValue"in r?!1===r.returnValue:r.getPreventDefault&&r.getPreventDefault())&&(e.isDefaultPrevented=b)),e}function S(e){var t,r={originalEvent:e};for(t in e)_.test(t)||e[t]===n||(r[t]=e[t]);return x(r,e)}t.fn.delegate=function(e,t,n){return this.on(t,e,n)},t.fn.undelegate=function(e,t,n){return this.off(t,e,n)},t.fn.live=function(e,n){return t(document.body).delegate(this.selector,e,n),this},t.fn.die=function(e,n){return t(document.body).undelegate(this.selector,e,n),this},t.fn.on=function(e,r,a,c,u){var l,h,d=this;return e&&!o(e)?(t.each(e,function(e,t){d.on(e,r,a,t,u)}),d):(o(r)||s(c)||!1===c||(c=a,a=r,r=n),c!==n&&!1!==a||(c=a,a=n),!1===c&&(c=w),d.each(function(n,s){u&&(l=function(e){return y(s,e.type,c),c.apply(this,arguments)}),r&&(h=function(e){var n,o=t(e.target).closest(r,s).get(0);if(o&&o!==s)return n=t.extend(S(e),{currentTarget:o,liveFired:s}),(l||c).apply(o,[n].concat(i.call(arguments,1)))}),v(s,e,c,a,r,h||l)}))},t.fn.off=function(e,r,i){var a=this;return e&&!o(e)?(t.each(e,function(e,t){a.off(e,r,t)}),a):(o(r)||s(i)||!1===i||(i=r,r=n),!1===i&&(i=w),a.each(function(){y(this,e,i,r)}))},t.fn.trigger=function(e,n){return(e=o(e)||t.isPlainObject(e)?t.Event(e):x(e))._args=n,this.each(function(){e.type in l&&"function"==typeof this[e.type]?this[e.type]():"dispatchEvent"in this?this.dispatchEvent(e):t(this).triggerHandler(e,n)})},t.fn.triggerHandler=function(e,n){var r,i;return this.each(function(s,a){(r=S(o(e)?t.Event(e):e))._args=n,r.target=a,t.each(f(a,e.type||e),function(e,t){if(i=t.proxy(r),r.isImmediatePropagationStopped())return!1})}),i},"focusin focusout focus blur load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select keydown keypress keyup error".split(" ").forEach(function(e){t.fn[e]=function(t){return 0 in arguments?this.bind(e,t):this.trigger(e)}}),t.Event=function(e,t){o(e)||(e=(t=e).type);var n=document.createEvent(c[e]||"Events"),r=!0;if(t)for(var i in t)"bubbles"==i?r=!!t[i]:n[i]=t[i];return n.initEvent(e,r,!0),x(n)}}(t),function(e){var n,r=[];t.fn.remove=function(){return this.each(function(){this.parentNode&&("IMG"===this.tagName&&(r.push(this),this.src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=",n&&clearTimeout(n),n=setTimeout(function(){r=[]},6e4)),this.parentNode.removeChild(this))})}}(),function(e){var t={},n=e.fn.data,r=e.camelCase,i=e.expando="Zepto"+ +new Date,s=[];function o(n,o,a){var c=n[i]||(n[i]=++e.uuid),u=t[c]||(t[c]=function(t){var n={};return e.each(t.attributes||s,function(t,i){0==i.name.indexOf("data-")&&(n[r(i.name.replace("data-",""))]=e.zepto.deserializeValue(i.value))}),n}(n));return void 0!==o&&(u[r(o)]=a),u}e.fn.data=function(s,a){return void 0===a?e.isPlainObject(s)?this.each(function(t,n){e.each(s,function(e,t){o(n,e,t)})}):0 in this?function(s,a){var c=s[i],u=c&&t[c];if(void 0===a)return u||o(s);if(u){if(a in u)return u[a];var l=r(a);if(l in u)return u[l]}return n.call(e(s),a)}(this[0],s):void 0:this.each(function(){o(this,s,a)})},e.data=function(t,n,r){return e(t).data(n,r)},e.hasData=function(n){var r=n[i],s=r&&t[r];return!!s&&!e.isEmptyObject(s)},e.fn.removeData=function(n){return"string"==typeof n&&(n=n.split(/\s+/)),this.each(function(){var s=this[i],o=s&&t[s];o&&e.each(n||o,function(e){delete o[n?r(this):e]})})},["remove","empty"].forEach(function(t){var n=e.fn[t];e.fn[t]=function(){var e=this.find("*");return"remove"===t&&(e=e.add(this)),e.removeData(),n.call(this)}})}(t),t}(t)}(window)},function(e,t,n){"use strict";var r=n(0),i=n(1);function s(e){e&&e.el||r.error("EventBus initialized without el"),this.$el=i.element(e.el)}r.mixin(s.prototype,{trigger:function(e){var t=[].slice.call(arguments,1),n=r.Event("autocomplete:"+e);return this.$el.trigger(n,t),n}}),e.exports=s},function(e,t,n){"use strict";e.exports={wrapper:'<span class="%ROOT%"></span>',dropdown:'<span class="%PREFIX%%DROPDOWN_MENU%"></span>',dataset:'<div class="%PREFIX%%DATASET%-%CLASS%"></div>',suggestions:'<span class="%PREFIX%%SUGGESTIONS%"></span>',suggestion:'<div class="%PREFIX%%SUGGESTION%"></div>'}},function(e,t){e.exports="0.32.0"},function(e,t,n){"use strict";e.exports=function(e){var t=e.match(/Algolia for vanilla JavaScript (\d+\.)(\d+\.)(\d+)/);if(t)return[t[1],t[2],t[3]]}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=function(e){return e&&e.__esModule?e:{default:e}}(n(15));t.default=r.default},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default="2.6.1"},function(e,t,n){"use strict";var r=function(e){return e&&e.__esModule?e:{default:e}}(n(23));e.exports=r.default},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=o(n(24)),i=o(n(25)),s=o(n(21));function o(e){return e&&e.__esModule?e:{default:e}}var a=(0,r.default)(i.default);a.version=s.default,t.default=a},function(e,t,n){"use strict";var r=Function.prototype.bind;e.exports=function(e){var t=function(){for(var t=arguments.length,n=Array(t),i=0;i<t;i++)n[i]=arguments[i];return new(r.apply(e,[null].concat(n)))};return t.__proto__=e,t.prototype=e.prototype,t}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},i=function(){function e(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}return function(t,n,r){return n&&e(t.prototype,n),r&&e(t,r),t}}(),s=d(n(26)),o=d(n(29)),a=d(n(49)),c=d(n(64)),u=d(n(65)),l=d(n(21)),h=d(n(20));function d(e){return e&&e.__esModule?e:{default:e}}var f=function(){function e(t){var n=t.apiKey,i=t.indexName,s=t.inputSelector,u=t.appId,d=void 0===u?"BH4D9OD16A":u,f=t.debug,p=void 0!==f&&f,g=t.algoliaOptions,m=void 0===g?{}:g,v=t.autocompleteOptions,y=void 0===v?{debug:!1,hint:!1,autoselect:!0}:v,b=t.transformData,w=void 0!==b&&b,_=t.queryHook,E=void 0!==_&&_,x=t.handleSelected,S=void 0!==x&&x,C=t.enhancedSearchInput,A=void 0!==C&&C,N=t.layout,O=void 0===N?"collumns":N;!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),e.checkArguments({apiKey:n,indexName:i,inputSelector:s,debug:p,algoliaOptions:m,autocompleteOptions:y,transformData:w,queryHook:E,handleSelected:S,enhancedSearchInput:A,layout:O}),this.apiKey=n,this.appId=d,this.indexName=i,this.input=e.getInputFromSelector(s),this.algoliaOptions=r({hitsPerPage:5},m);var T=!(!y||!y.debug)&&y.debug;y.debug=p||T,this.autocompleteOptions=y,this.autocompleteOptions.cssClasses=this.autocompleteOptions.cssClasses||{},this.autocompleteOptions.cssClasses.prefix=this.autocompleteOptions.cssClasses.prefix||"ds",S=S||this.handleSelected,this.isSimpleLayout="simple"===O,this.client=(0,o.default)(this.appId,this.apiKey),this.client.addAlgoliaAgent("docsearch.js "+l.default),A&&(this.input=e.injectSearchBox(this.input)),this.autocomplete=(0,a.default)(this.input,y,[{source:this.getAutocompleteSource(w,E),templates:{suggestion:e.getSuggestionTemplate(this.isSimpleLayout),footer:c.default.footer,empty:e.getEmptyTemplate()}}]),S&&(0,h.default)(".algolia-autocomplete").on("click",".ds-suggestions a",function(e){e.preventDefault()}),this.autocomplete.on("autocomplete:selected",S.bind(null,this.autocomplete.autocomplete)),this.autocomplete.on("autocomplete:shown",this.handleShown.bind(null,this.input)),A&&e.bindSearchBoxEvent()}return i(e,[{key:"getAutocompleteSource",value:function(t,n){var r=this;return function(i,s){n&&(i=n(i)||i),r.client.search([{indexName:r.indexName,query:i,params:r.algoliaOptions}]).then(function(n){var r=n.results[0].hits;t&&(r=t(r)||r),s(e.formatHits(r))})}}},{key:"handleSelected",value:function(e,t,n){e.setVal(""),window.location.assign(n.url)}},{key:"handleShown",value:function(e){var t=e.offset().left+e.width()/2,n=(0,h.default)(document).width()/2;isNaN(n)&&(n=900);var r=t-n>=0?"algolia-autocomplete-right":"algolia-autocomplete-left",i=t-n<0?"algolia-autocomplete-right":"algolia-autocomplete-left",s=(0,h.default)(".algolia-autocomplete");s.hasClass(r)||s.addClass(r),s.hasClass(i)&&s.removeClass(i)}}],[{key:"checkArguments",value:function(t){if(!t.apiKey||!t.indexName)throw new Error("Usage:\n documentationSearch({\n apiKey,\n indexName,\n inputSelector,\n [ appId ],\n [ algoliaOptions.{hitsPerPage} ]\n [ autocompleteOptions.{hint,debug} ]\n})");if("string"!=typeof t.inputSelector)throw new Error("Error: inputSelector:"+t.inputSelector+" must be a string. Each selector must match only one element and separated by ','");if(!e.getInputFromSelector(t.inputSelector))throw new Error("Error: No input element in the page matches "+t.inputSelector)}},{key:"injectSearchBox",value:function(e){e.before(c.default.searchBox);var t=e.prev().prev().find("input");return e.remove(),t}},{key:"bindSearchBoxEvent",value:function(){(0,h.default)('.searchbox [type="reset"]').on("click",function(){(0,h.default)("input#docsearch").focus(),(0,h.default)(this).addClass("hide"),a.default.autocomplete.setVal("")}),(0,h.default)("input#docsearch").on("keyup",function(){var e=document.querySelector("input#docsearch"),t=document.querySelector('.searchbox [type="reset"]');t.className="searchbox__reset",0===e.value.length&&(t.className+=" hide")})}},{key:"getInputFromSelector",value:function(e){var t=(0,h.default)(e).filter("input");return t.length?(0,h.default)(t[0]):null}},{key:"formatHits",value:function(t){var n=u.default.deepClone(t).map(function(e){return e._highlightResult&&(e._highlightResult=u.default.mergeKeyWithParent(e._highlightResult,"hierarchy")),u.default.mergeKeyWithParent(e,"hierarchy")}),r=u.default.groupBy(n,"lvl0");return h.default.each(r,function(e,t){var n=u.default.groupBy(t,"lvl1"),i=u.default.flattenAndFlagFirst(n,"isSubCategoryHeader");r[e]=i}),(r=u.default.flattenAndFlagFirst(r,"isCategoryHeader")).map(function(t){var n=e.formatURL(t),r=u.default.getHighlightedValue(t,"lvl0"),i=u.default.getHighlightedValue(t,"lvl1")||r,s=u.default.compact([u.default.getHighlightedValue(t,"lvl2")||i,u.default.getHighlightedValue(t,"lvl3"),u.default.getHighlightedValue(t,"lvl4"),u.default.getHighlightedValue(t,"lvl5"),u.default.getHighlightedValue(t,"lvl6")]).join('<span class="aa-suggestion-title-separator" aria-hidden="true"> › </span>'),o=u.default.getSnippetedValue(t,"content"),a=i&&""!==i||s&&""!==s,c=s&&""!==s&&s!==i,l=!c&&i&&""!==i&&i!==r;return{isLvl0:!l&&!c,isLvl1:l,isLvl2:c,isLvl1EmptyOrDuplicate:!i||""===i||i===r,isCategoryHeader:t.isCategoryHeader,isSubCategoryHeader:t.isSubCategoryHeader,isTextOrSubcategoryNonEmpty:a,category:r,subcategory:i,title:s,text:o,url:n}})}},{key:"formatURL",value:function(e){var t=e.url,n=e.anchor;return t?-1!==t.indexOf("#")?t:n?e.url+"#"+e.anchor:t:n?"#"+e.anchor:(console.warn("no anchor nor url for : ",JSON.stringify(e)),null)}},{key:"getEmptyTemplate",value:function(){return function(e){return s.default.compile(c.default.empty).render(e)}}},{key:"getSuggestionTemplate",value:function(e){var t=e?c.default.suggestionSimple:c.default.suggestion,n=s.default.compile(t);return function(e){return n.render(e)}}}]),e}();t.default=f},function(e,t,n){var r=n(27);r.Template=n(28).Template,r.template=r.Template,e.exports=r},function(e,t,n){!function(e){var t=/\S/,n=/\"/g,r=/\n/g,i=/\r/g,s=/\\/g,o=/\u2028/,a=/\u2029/;function c(e){"}"===e.n.substr(e.n.length-1)&&(e.n=e.n.substring(0,e.n.length-1))}function u(e){return e.trim?e.trim():e.replace(/^\s*|\s*$/g,"")}function l(e,t,n){if(t.charAt(n)!=e.charAt(0))return!1;for(var r=1,i=e.length;r<i;r++)if(t.charAt(n+r)!=e.charAt(r))return!1;return!0}e.tags={"#":1,"^":2,"<":3,$:4,"/":5,"!":6,">":7,"=":8,_v:9,"{":10,"&":11,_t:12},e.scan=function(n,r){var i=n.length,s=0,o=null,a=null,h="",d=[],f=!1,p=0,g=0,m="{{",v="}}";function y(){h.length>0&&(d.push({tag:"_t",text:new String(h)}),h="")}function b(n,r){if(y(),n&&function(){for(var n=!0,r=g;r<d.length;r++)if(!(n=e.tags[d[r].tag]<e.tags._v||"_t"==d[r].tag&&null===d[r].text.match(t)))return!1;return n}())for(var i,s=g;s<d.length;s++)d[s].text&&((i=d[s+1])&&">"==i.tag&&(i.indent=d[s].text.toString()),d.splice(s,1));else r||d.push({tag:"\n"});f=!1,g=d.length}function w(e,t){var n="="+v,r=e.indexOf(n,t),i=u(e.substring(e.indexOf("=",t)+1,r)).split(" ");return m=i[0],v=i[i.length-1],r+n.length-1}for(r&&(r=r.split(" "),m=r[0],v=r[1]),p=0;p<i;p++)0==s?l(m,n,p)?(--p,y(),s=1):"\n"==n.charAt(p)?b(f):h+=n.charAt(p):1==s?(p+=m.length-1,"="==(o=(a=e.tags[n.charAt(p+1)])?n.charAt(p+1):"_v")?(p=w(n,p),s=0):(a&&p++,s=2),f=p):l(v,n,p)?(d.push({tag:o,n:u(h),otag:m,ctag:v,i:"/"==o?f-m.length:p+v.length}),h="",p+=v.length-1,s=0,"{"==o&&("}}"==v?p++:c(d[d.length-1]))):h+=n.charAt(p);return b(f,!0),d};var h={_t:!0,"\n":!0,$:!0,"/":!0};function d(e,t){for(var n=0,r=t.length;n<r;n++)if(t[n].o==e.n)return e.tag="#",!0}function f(e,t,n){for(var r=0,i=n.length;r<i;r++)if(n[r].c==e&&n[r].o==t)return!0}function p(e){var t=[];for(var n in e.partials)t.push('"'+m(n)+'":{name:"'+m(e.partials[n].name)+'", '+p(e.partials[n])+"}");return"partials: {"+t.join(",")+"}, subs: "+function(e){var t=[];for(var n in e)t.push('"'+m(n)+'": function(c,p,t,i) {'+e[n]+"}");return"{ "+t.join(",")+" }"}(e.subs)}e.stringify=function(t,n,r){return"{code: function (c,p,i) { "+e.wrapMain(t.code)+" },"+p(t)+"}"};var g=0;function m(e){return e.replace(s,"\\\\").replace(n,'\\"').replace(r,"\\n").replace(i,"\\r").replace(o,"\\u2028").replace(a,"\\u2029")}function v(e){return~e.indexOf(".")?"d":"f"}function y(e,t){var n="<"+(t.prefix||"")+e.n+g++;return t.partials[n]={name:e.n,partials:{}},t.code+='t.b(t.rp("'+m(n)+'",c,p,"'+(e.indent||"")+'"));',n}function b(e,t){t.code+="t.b(t.t(t."+v(e.n)+'("'+m(e.n)+'",c,p,0)));'}function w(e){return"t.b("+e+");"}e.generate=function(t,n,r){g=0;var i={code:"",subs:{},partials:{}};return e.walk(t,i),r.asString?this.stringify(i,n,r):this.makeTemplate(i,n,r)},e.wrapMain=function(e){return'var t=this;t.b(i=i||"");'+e+"return t.fl();"},e.template=e.Template,e.makeTemplate=function(e,t,n){var r=this.makePartials(e);return r.code=new Function("c","p","i",this.wrapMain(e.code)),new this.template(r,t,this,n)},e.makePartials=function(e){var t,n={subs:{},partials:e.partials,name:e.name};for(t in n.partials)n.partials[t]=this.makePartials(n.partials[t]);for(t in e.subs)n.subs[t]=new Function("c","p","t","i",e.subs[t]);return n},e.codegen={"#":function(t,n){n.code+="if(t.s(t."+v(t.n)+'("'+m(t.n)+'",c,p,1),c,p,0,'+t.i+","+t.end+',"'+t.otag+" "+t.ctag+'")){t.rs(c,p,function(c,p,t){',e.walk(t.nodes,n),n.code+="});c.pop();}"},"^":function(t,n){n.code+="if(!t.s(t."+v(t.n)+'("'+m(t.n)+'",c,p,1),c,p,1,0,0,"")){',e.walk(t.nodes,n),n.code+="};"},">":y,"<":function(t,n){var r={partials:{},code:"",subs:{},inPartial:!0};e.walk(t.nodes,r);var i=n.partials[y(t,n)];i.subs=r.subs,i.partials=r.partials},$:function(t,n){var r={subs:{},code:"",partials:n.partials,prefix:t.n};e.walk(t.nodes,r),n.subs[t.n]=r.code,n.inPartial||(n.code+='t.sub("'+m(t.n)+'",c,p,i);')},"\n":function(e,t){t.code+=w('"\\n"'+(e.last?"":" + i"))},_v:function(e,t){t.code+="t.b(t.v(t."+v(e.n)+'("'+m(e.n)+'",c,p,0)));'},_t:function(e,t){t.code+=w('"'+m(e.text)+'"')},"{":b,"&":b},e.walk=function(t,n){for(var r,i=0,s=t.length;i<s;i++)(r=e.codegen[t[i].tag])&&r(t[i],n);return n},e.parse=function(t,n,r){return function t(n,r,i,s){var o,a=[],c=null,u=null;for(o=i[i.length-1];n.length>0;){if(u=n.shift(),o&&"<"==o.tag&&!(u.tag in h))throw new Error("Illegal content in < super tag.");if(e.tags[u.tag]<=e.tags.$||d(u,s))i.push(u),u.nodes=t(n,u.tag,i,s);else{if("/"==u.tag){if(0===i.length)throw new Error("Closing tag without opener: /"+u.n);if(c=i.pop(),u.n!=c.n&&!f(u.n,c.n,s))throw new Error("Nesting error: "+c.n+" vs. "+u.n);return c.end=u.i,a}"\n"==u.tag&&(u.last=0==n.length||"\n"==n[0].tag)}a.push(u)}if(i.length>0)throw new Error("missing closing tag: "+i.pop().n);return a}(t,0,[],(r=r||{}).sectionTags||[])},e.cache={},e.cacheKey=function(e,t){return[e,!!t.asString,!!t.disableLambda,t.delimiters,!!t.modelGet].join("||")},e.compile=function(t,n){n=n||{};var r=e.cacheKey(t,n),i=this.cache[r];if(i){var s=i.partials;for(var o in s)delete s[o].instance;return i}return i=this.generate(this.parse(this.scan(t,n.delimiters),t,n),t,n),this.cache[r]=i}}(t)},function(e,t,n){!function(e){function t(e,t,n){var r;return t&&"object"==typeof t&&(void 0!==t[e]?r=t[e]:n&&t.get&&"function"==typeof t.get&&(r=t.get(e))),r}e.Template=function(e,t,n,r){e=e||{},this.r=e.code||this.r,this.c=n,this.options=r||{},this.text=t||"",this.partials=e.partials||{},this.subs=e.subs||{},this.buf=""},e.Template.prototype={r:function(e,t,n){return""},v:function(e){return e=c(e),a.test(e)?e.replace(n,"&amp;").replace(r,"&lt;").replace(i,"&gt;").replace(s,"&#39;").replace(o,"&quot;"):e},t:c,render:function(e,t,n){return this.ri([e],t||{},n)},ri:function(e,t,n){return this.r(e,t,n)},ep:function(e,t){var n=this.partials[e],r=t[n.name];if(n.instance&&n.base==r)return n.instance;if("string"==typeof r){if(!this.c)throw new Error("No compiler available.");r=this.c.compile(r,this.options)}if(!r)return null;if(this.partials[e].base=r,n.subs){for(key in t.stackText||(t.stackText={}),n.subs)t.stackText[key]||(t.stackText[key]=void 0!==this.activeSub&&t.stackText[this.activeSub]?t.stackText[this.activeSub]:this.text);r=function(e,t,n,r,i,s){function o(){}function a(){}var c;o.prototype=e,a.prototype=e.subs;var u=new o;for(c in u.subs=new a,u.subsText={},u.buf="",r=r||{},u.stackSubs=r,u.subsText=s,t)r[c]||(r[c]=t[c]);for(c in r)u.subs[c]=r[c];for(c in i=i||{},u.stackPartials=i,n)i[c]||(i[c]=n[c]);for(c in i)u.partials[c]=i[c];return u}(r,n.subs,n.partials,this.stackSubs,this.stackPartials,t.stackText)}return this.partials[e].instance=r,r},rp:function(e,t,n,r){var i=this.ep(e,n);return i?i.ri(t,n,r):""},rs:function(e,t,n){var r=e[e.length-1];if(u(r))for(var i=0;i<r.length;i++)e.push(r[i]),n(e,t,this),e.pop();else n(e,t,this)},s:function(e,t,n,r,i,s,o){var a;return(!u(e)||0!==e.length)&&("function"==typeof e&&(e=this.ms(e,t,n,r,i,s,o)),a=!!e,!r&&a&&t&&t.push("object"==typeof e?e:t[t.length-1]),a)},d:function(e,n,r,i){var s,o=e.split("."),a=this.f(o[0],n,r,i),c=this.options.modelGet,l=null;if("."===e&&u(n[n.length-2]))a=n[n.length-1];else for(var h=1;h<o.length;h++)void 0!==(s=t(o[h],a,c))?(l=a,a=s):a="";return!(i&&!a)&&(i||"function"!=typeof a||(n.push(l),a=this.mv(a,n,r),n.pop()),a)},f:function(e,n,r,i){for(var s=!1,o=!1,a=this.options.modelGet,c=n.length-1;c>=0;c--)if(void 0!==(s=t(e,n[c],a))){o=!0;break}return o?(i||"function"!=typeof s||(s=this.mv(s,n,r)),s):!i&&""},ls:function(e,t,n,r,i){var s=this.options.delimiters;return this.options.delimiters=i,this.b(this.ct(c(e.call(t,r)),t,n)),this.options.delimiters=s,!1},ct:function(e,t,n){if(this.options.disableLambda)throw new Error("Lambda features disabled.");return this.c.compile(e,this.options).render(t,n)},b:function(e){this.buf+=e},fl:function(){var e=this.buf;return this.buf="",e},ms:function(e,t,n,r,i,s,o){var a,c=t[t.length-1],u=e.call(c);return"function"==typeof u?!!r||(a=this.activeSub&&this.subsText&&this.subsText[this.activeSub]?this.subsText[this.activeSub]:this.text,this.ls(u,c,n,a.substring(i,s),o)):u},mv:function(e,t,n){var r=t[t.length-1],i=e.call(r);return"function"==typeof i?this.ct(c(i.call(r)),r,n):i},sub:function(e,t,n,r){var i=this.subs[e];i&&(this.activeSub=e,i(t,n,this,r),this.activeSub=!1)}};var n=/&/g,r=/</g,i=/>/g,s=/\'/g,o=/\"/g,a=/[&<>\"\']/;function c(e){return String(null===e||void 0===e?"":e)}var u=Array.isArray||function(e){return"[object Array]"===Object.prototype.toString.call(e)}}(t)},function(e,t,n){"use strict";var r=n(30),i=n(41);e.exports=i(r,"(lite) ")},function(e,t,n){e.exports=c;var r=n(5),i=n(31),s=n(32),o=n(38),a=Object({NODE_ENV:"production"}).RESET_APP_DATA_TIMER&&parseInt(Object({NODE_ENV:"production"}).RESET_APP_DATA_TIMER,10)||12e4;function c(e,t,i){var s=n(8)("algoliasearch"),o=n(3),a=n(6),c=n(7),l="Usage: algoliasearch(applicationID, apiKey, opts)";if(!0!==i._allowEmptyCredentials&&!e)throw new r.AlgoliaSearchError("Please provide an application ID. "+l);if(!0!==i._allowEmptyCredentials&&!t)throw new r.AlgoliaSearchError("Please provide an API key. "+l);this.applicationID=e,this.apiKey=t,this.hosts={read:[],write:[]},i=i||{},this._timeouts=i.timeouts||{connect:1e3,read:2e3,write:3e4},i.timeout&&(this._timeouts.connect=this._timeouts.read=this._timeouts.write=i.timeout);var h=i.protocol||"https:";if(/:$/.test(h)||(h+=":"),"http:"!==h&&"https:"!==h)throw new r.AlgoliaSearchError("protocol must be `http:` or `https:` (was `"+i.protocol+"`)");if(this._checkAppIdData(),i.hosts)a(i.hosts)?(this.hosts.read=o(i.hosts),this.hosts.write=o(i.hosts)):(this.hosts.read=o(i.hosts.read),this.hosts.write=o(i.hosts.write));else{var d=c(this._shuffleResult,function(t){return e+"-"+t+".algolianet.com"}),f=(!1===i.dsn?"":"-dsn")+".algolia.net";this.hosts.read=[this.applicationID+f].concat(d),this.hosts.write=[this.applicationID+".algolia.net"].concat(d)}this.hosts.read=c(this.hosts.read,u(h)),this.hosts.write=c(this.hosts.write,u(h)),this.extraHeaders={},this.cache=i._cache||{},this._ua=i._ua,this._useCache=!(void 0!==i._useCache&&!i._cache)||i._useCache,this._useRequestCache=this._useCache&&i._useRequestCache,this._useFallback=void 0===i.useFallback||i.useFallback,this._setTimeout=i._setTimeout,s("init done, %j",this)}function u(e){return function(t){return e+"//"+t.toLowerCase()}}function l(e){if(void 0===Array.prototype.toJSON)return JSON.stringify(e);var t=Array.prototype.toJSON;delete Array.prototype.toJSON;var n=JSON.stringify(e);return Array.prototype.toJSON=t,n}function h(e){var t={};for(var n in e){var r;if(Object.prototype.hasOwnProperty.call(e,n))r="x-algolia-api-key"===n||"x-algolia-application-id"===n?"**hidden for security purposes**":e[n],t[n]=r}return t}c.prototype.initIndex=function(e){return new s(this,e)},c.prototype.setExtraHeader=function(e,t){this.extraHeaders[e.toLowerCase()]=t},c.prototype.getExtraHeader=function(e){return this.extraHeaders[e.toLowerCase()]},c.prototype.unsetExtraHeader=function(e){delete this.extraHeaders[e.toLowerCase()]},c.prototype.addAlgoliaAgent=function(e){-1===this._ua.indexOf(";"+e)&&(this._ua+=";"+e)},c.prototype._jsonRequest=function(e){this._checkAppIdData();var t,s,o,a=n(8)("algoliasearch:"+e.url),c=e.additionalUA||"",u=e.cache,d=this,f=0,p=!1,g=d._useFallback&&d._request.fallback&&e.fallback;this.apiKey.length>500&&void 0!==e.body&&(void 0!==e.body.params||void 0!==e.body.requests)?(e.body.apiKey=this.apiKey,o=this._computeRequestHeaders({additionalUA:c,withApiKey:!1,headers:e.headers})):o=this._computeRequestHeaders({additionalUA:c,headers:e.headers}),void 0!==e.body&&(t=l(e.body)),a("request start");var m=[];function v(e,t,n){return d._useCache&&e&&t&&void 0!==t[n]}function y(t,n){if(v(d._useRequestCache,u,s)&&t.catch(function(){delete u[s]}),"function"!=typeof e.callback)return t.then(n);t.then(function(t){i(function(){e.callback(null,n(t))},d._setTimeout||setTimeout)},function(t){i(function(){e.callback(t)},d._setTimeout||setTimeout)})}if(d._useCache&&d._useRequestCache&&(s=e.url),d._useCache&&d._useRequestCache&&t&&(s+="_body_"+t),v(d._useRequestCache,u,s)){a("serving request from cache");var b=u[s];return y("function"!=typeof b.then?d._promise.resolve({responseText:b}):b,function(e){return JSON.parse(e.responseText)})}var w=function n(i,y){d._checkAppIdData();var b=new Date;if(d._useCache&&!d._useRequestCache&&(s=e.url),d._useCache&&!d._useRequestCache&&t&&(s+="_body_"+y.body),v(!d._useRequestCache,u,s)){a("serving response from cache");var w=u[s];return d._promise.resolve({body:JSON.parse(w),responseText:w})}if(f>=d.hosts[e.hostType].length)return!g||p?(a("could not get any response"),d._promise.reject(new r.AlgoliaSearchError("Cannot connect to the AlgoliaSearch API. Send an email to support@algolia.com to report and resolve the issue. Application id was: "+d.applicationID,{debugData:m}))):(a("switching to fallback"),f=0,y.method=e.fallback.method,y.url=e.fallback.url,y.jsonBody=e.fallback.body,y.jsonBody&&(y.body=l(y.jsonBody)),o=d._computeRequestHeaders({additionalUA:c,headers:e.headers}),y.timeouts=d._getTimeoutsForRequest(e.hostType),d._setHostIndexByType(0,e.hostType),p=!0,n(d._request.fallback,y));var _=d._getHostByType(e.hostType),E=_+y.url,x={body:y.body,jsonBody:y.jsonBody,method:y.method,headers:o,timeouts:y.timeouts,debug:a,forceAuthHeaders:y.forceAuthHeaders};return a("method: %s, url: %s, headers: %j, timeouts: %d",x.method,E,x.headers,x.timeouts),i===d._request.fallback&&a("using fallback"),i.call(d,E,x).then(function(e){var n=e&&e.body&&e.body.message&&e.body.status||e.statusCode||e&&e.body&&200;a("received response: statusCode: %s, computed statusCode: %d, headers: %j",e.statusCode,n,e.headers);var i=2===Math.floor(n/100),c=new Date;if(m.push({currentHost:_,headers:h(o),content:t||null,contentLength:void 0!==t?t.length:null,method:y.method,timeouts:y.timeouts,url:y.url,startTime:b,endTime:c,duration:c-b,statusCode:n}),i)return d._useCache&&!d._useRequestCache&&u&&(u[s]=e.responseText),{responseText:e.responseText,body:e.body};if(4!==Math.floor(n/100))return f+=1,S();a("unrecoverable error");var l=new r.AlgoliaSearchError(e.body&&e.body.message,{debugData:m,statusCode:n});return d._promise.reject(l)},function(s){a("error: %s, stack: %s",s.message,s.stack);var c=new Date;return m.push({currentHost:_,headers:h(o),content:t||null,contentLength:void 0!==t?t.length:null,method:y.method,timeouts:y.timeouts,url:y.url,startTime:b,endTime:c,duration:c-b}),s instanceof r.AlgoliaSearchError||(s=new r.Unknown(s&&s.message,s)),f+=1,s instanceof r.Unknown||s instanceof r.UnparsableJSON||f>=d.hosts[e.hostType].length&&(p||!g)?(s.debugData=m,d._promise.reject(s)):s instanceof r.RequestTimeout?(a("retrying request with higher timeout"),d._incrementHostIndex(e.hostType),d._incrementTimeoutMultipler(),y.timeouts=d._getTimeoutsForRequest(e.hostType),n(i,y)):S()});function S(){return a("retrying request"),d._incrementHostIndex(e.hostType),n(i,y)}}(d._request,{url:e.url,method:e.method,body:t,jsonBody:e.body,timeouts:d._getTimeoutsForRequest(e.hostType),forceAuthHeaders:e.forceAuthHeaders});return d._useCache&&d._useRequestCache&&u&&(u[s]=w),y(w,function(e){return e.body})},c.prototype._getSearchParams=function(e,t){if(void 0===e||null===e)return t;for(var n in e)null!==n&&void 0!==e[n]&&e.hasOwnProperty(n)&&(t+=""===t?"":"&",t+=n+"="+encodeURIComponent("[object Array]"===Object.prototype.toString.call(e[n])?l(e[n]):e[n]));return t},c.prototype._computeRequestHeaders=function(e){var t=n(2),r={"x-algolia-agent":e.additionalUA?this._ua+";"+e.additionalUA:this._ua,"x-algolia-application-id":this.applicationID};return!1!==e.withApiKey&&(r["x-algolia-api-key"]=this.apiKey),this.userToken&&(r["x-algolia-usertoken"]=this.userToken),this.securityTags&&(r["x-algolia-tagfilters"]=this.securityTags),t(this.extraHeaders,function(e,t){r[t]=e}),e.headers&&t(e.headers,function(e,t){r[t]=e}),r},c.prototype.search=function(e,t,r){var i=n(6),s=n(7);if(!i(e))throw new Error("Usage: client.search(arrayOfQueries[, callback])");"function"==typeof t?(r=t,t={}):void 0===t&&(t={});var o=this,a={requests:s(e,function(e){var t="";return void 0!==e.query&&(t+="query="+encodeURIComponent(e.query)),{indexName:e.indexName,params:o._getSearchParams(e.params,t)}})},c=s(a.requests,function(e,t){return t+"="+encodeURIComponent("/1/indexes/"+encodeURIComponent(e.indexName)+"?"+e.params)}).join("&");return void 0!==t.strategy&&(a.strategy=t.strategy),this._jsonRequest({cache:this.cache,method:"POST",url:"/1/indexes/*/queries",body:a,hostType:"read",fallback:{method:"GET",url:"/1/indexes/*",body:{params:c}},callback:r})},c.prototype.searchForFacetValues=function(e){var t=n(6),r=n(7),i="Usage: client.searchForFacetValues([{indexName, params: {facetName, facetQuery, ...params}}, ...queries])";if(!t(e))throw new Error(i);var s=this;return s._promise.all(r(e,function(e){if(!e||void 0===e.indexName||void 0===e.params.facetName||void 0===e.params.facetQuery)throw new Error(i);var t=n(3),r=n(14),o=e.indexName,a=e.params,c=a.facetName,u=r(t(a),function(e){return"facetName"===e}),l=s._getSearchParams(u,"");return s._jsonRequest({cache:s.cache,method:"POST",url:"/1/indexes/"+encodeURIComponent(o)+"/facets/"+encodeURIComponent(c)+"/query",hostType:"read",body:{params:l}})}))},c.prototype.setSecurityTags=function(e){if("[object Array]"===Object.prototype.toString.call(e)){for(var t=[],n=0;n<e.length;++n)if("[object Array]"===Object.prototype.toString.call(e[n])){for(var r=[],i=0;i<e[n].length;++i)r.push(e[n][i]);t.push("("+r.join(",")+")")}else t.push(e[n]);e=t.join(",")}this.securityTags=e},c.prototype.setUserToken=function(e){this.userToken=e},c.prototype.clearCache=function(){this.cache={}},c.prototype.setRequestTimeout=function(e){e&&(this._timeouts.connect=this._timeouts.read=this._timeouts.write=e)},c.prototype.setTimeouts=function(e){this._timeouts=e},c.prototype.getTimeouts=function(){return this._timeouts},c.prototype._getAppIdData=function(){var e=o.get(this.applicationID);return null!==e&&this._cacheAppIdData(e),e},c.prototype._setAppIdData=function(e){return e.lastChange=(new Date).getTime(),this._cacheAppIdData(e),o.set(this.applicationID,e)},c.prototype._checkAppIdData=function(){var e=this._getAppIdData(),t=(new Date).getTime();return null===e||t-e.lastChange>a?this._resetInitialAppIdData(e):e},c.prototype._resetInitialAppIdData=function(e){var t=e||{};return t.hostIndexes={read:0,write:0},t.timeoutMultiplier=1,t.shuffleResult=t.shuffleResult||function(e){var t,n,r=e.length;for(;0!==r;)n=Math.floor(Math.random()*r),t=e[r-=1],e[r]=e[n],e[n]=t;return e}([1,2,3]),this._setAppIdData(t)},c.prototype._cacheAppIdData=function(e){this._hostIndexes=e.hostIndexes,this._timeoutMultiplier=e.timeoutMultiplier,this._shuffleResult=e.shuffleResult},c.prototype._partialAppIdDataUpdate=function(e){var t=n(2),r=this._getAppIdData();return t(e,function(e,t){r[t]=e}),this._setAppIdData(r)},c.prototype._getHostByType=function(e){return this.hosts[e][this._getHostIndexByType(e)]},c.prototype._getTimeoutMultiplier=function(){return this._timeoutMultiplier},c.prototype._getHostIndexByType=function(e){return this._hostIndexes[e]},c.prototype._setHostIndexByType=function(e,t){var r=n(3)(this._hostIndexes);return r[t]=e,this._partialAppIdDataUpdate({hostIndexes:r}),e},c.prototype._incrementHostIndex=function(e){return this._setHostIndexByType((this._getHostIndexByType(e)+1)%this.hosts[e].length,e)},c.prototype._incrementTimeoutMultipler=function(){var e=Math.max(this._timeoutMultiplier+1,4);return this._partialAppIdDataUpdate({timeoutMultiplier:e})},c.prototype._getTimeoutsForRequest=function(e){return{connect:this._timeouts.connect*this._timeoutMultiplier,complete:this._timeouts[e]*this._timeoutMultiplier}}},function(e,t){e.exports=function(e,t){t(e,0)}},function(e,t,n){var r=n(13),i=n(33),s=n(34);function o(e,t){this.indexName=t,this.as=e,this.typeAheadArgs=null,this.typeAheadValueOption=null,this.cache={}}e.exports=o,o.prototype.clearCache=function(){this.cache={}},o.prototype.search=r("query"),o.prototype.similarSearch=r("similarQuery"),o.prototype.browse=function(e,t,r){var i,s,o=n(35);0===arguments.length||1===arguments.length&&"function"==typeof arguments[0]?(i=0,r=arguments[0],e=void 0):"number"==typeof arguments[0]?(i=arguments[0],"number"==typeof arguments[1]?s=arguments[1]:"function"==typeof arguments[1]&&(r=arguments[1],s=void 0),e=void 0,t=void 0):"object"==typeof arguments[0]?("function"==typeof arguments[1]&&(r=arguments[1]),t=arguments[0],e=void 0):"string"==typeof arguments[0]&&"function"==typeof arguments[1]&&(r=arguments[1],t=void 0),t=o({},t||{},{page:i,hitsPerPage:s,query:e});var a=this.as._getSearchParams(t,"");return this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(this.indexName)+"/browse",body:{params:a},hostType:"read",callback:r})},o.prototype.browseFrom=function(e,t){return this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(this.indexName)+"/browse",body:{cursor:e},hostType:"read",callback:t})},o.prototype.searchForFacetValues=function(e,t){var r=n(3),i=n(14);if(void 0===e.facetName||void 0===e.facetQuery)throw new Error("Usage: index.searchForFacetValues({facetName, facetQuery, ...params}[, callback])");var s=e.facetName,o=i(r(e),function(e){return"facetName"===e}),a=this.as._getSearchParams(o,"");return this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(this.indexName)+"/facets/"+encodeURIComponent(s)+"/query",hostType:"read",body:{params:a},callback:t})},o.prototype.searchFacet=i(function(e,t){return this.searchForFacetValues(e,t)},s("index.searchFacet(params[, callback])","index.searchForFacetValues(params[, callback])")),o.prototype._search=function(e,t,n,r){return this.as._jsonRequest({cache:this.cache,method:"POST",url:t||"/1/indexes/"+encodeURIComponent(this.indexName)+"/query",body:{params:e},hostType:"read",fallback:{method:"GET",url:"/1/indexes/"+encodeURIComponent(this.indexName),body:{params:e}},callback:n,additionalUA:r})},o.prototype.getObject=function(e,t,n){1!==arguments.length&&"function"!=typeof t||(n=t,t=void 0);var r="";if(void 0!==t){r="?attributes=";for(var i=0;i<t.length;++i)0!==i&&(r+=","),r+=t[i]}return this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(this.indexName)+"/"+encodeURIComponent(e)+r,hostType:"read",callback:n})},o.prototype.getObjects=function(e,t,r){var i=n(6),s=n(7);if(!i(e))throw new Error("Usage: index.getObjects(arrayOfObjectIDs[, callback])");var o=this;1!==arguments.length&&"function"!=typeof t||(r=t,t=void 0);var a={requests:s(e,function(e){var n={indexName:o.indexName,objectID:e};return t&&(n.attributesToRetrieve=t.join(",")),n})};return this.as._jsonRequest({method:"POST",url:"/1/indexes/*/objects",hostType:"read",body:a,callback:r})},o.prototype.as=null,o.prototype.indexName=null,o.prototype.typeAheadArgs=null,o.prototype.typeAheadValueOption=null},function(e,t){e.exports=function(e,t){var n=!1;return function(){return n||(console.warn(t),n=!0),e.apply(this,arguments)}}},function(e,t){e.exports=function(e,t){return"algoliasearch: `"+e+"` was replaced by `"+t+"`. Please see https://github.com/algolia/algoliasearch-client-javascript/wiki/Deprecated#"+e.toLowerCase().replace(/[\.\(\)]/g,"")}},function(e,t,n){var r=n(2);e.exports=function e(t){var n=Array.prototype.slice.call(arguments);return r(n,function(n){for(var r in n)n.hasOwnProperty(r)&&("object"==typeof t[r]&&"object"==typeof n[r]?t[r]=e({},t[r],n[r]):void 0!==n[r]&&(t[r]=n[r]))}),t}},function(e,t,n){"use strict";var r=Object.prototype.hasOwnProperty,i=Object.prototype.toString,s=Array.prototype.slice,o=n(37),a=Object.prototype.propertyIsEnumerable,c=!a.call({toString:null},"toString"),u=a.call(function(){},"prototype"),l=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],h=function(e){var t=e.constructor;return t&&t.prototype===e},d={$applicationCache:!0,$console:!0,$external:!0,$frame:!0,$frameElement:!0,$frames:!0,$innerHeight:!0,$innerWidth:!0,$outerHeight:!0,$outerWidth:!0,$pageXOffset:!0,$pageYOffset:!0,$parent:!0,$scrollLeft:!0,$scrollTop:!0,$scrollX:!0,$scrollY:!0,$self:!0,$webkitIndexedDB:!0,$webkitStorageInfo:!0,$window:!0},f=function(){if("undefined"==typeof window)return!1;for(var e in window)try{if(!d["$"+e]&&r.call(window,e)&&null!==window[e]&&"object"==typeof window[e])try{h(window[e])}catch(e){return!0}}catch(e){return!0}return!1}(),p=function(e){var t=null!==e&&"object"==typeof e,n="[object Function]"===i.call(e),s=o(e),a=t&&"[object String]"===i.call(e),d=[];if(!t&&!n&&!s)throw new TypeError("Object.keys called on a non-object");var p=u&&n;if(a&&e.length>0&&!r.call(e,0))for(var g=0;g<e.length;++g)d.push(String(g));if(s&&e.length>0)for(var m=0;m<e.length;++m)d.push(String(m));else for(var v in e)p&&"prototype"===v||!r.call(e,v)||d.push(String(v));if(c)for(var y=function(e){if("undefined"==typeof window||!f)return h(e);try{return h(e)}catch(e){return!1}}(e),b=0;b<l.length;++b)y&&"constructor"===l[b]||!r.call(e,l[b])||d.push(l[b]);return d};p.shim=function(){if(Object.keys){if(!function(){return 2===(Object.keys(arguments)||"").length}(1,2)){var e=Object.keys;Object.keys=function(t){return o(t)?e(s.call(t)):e(t)}}}else Object.keys=p;return Object.keys||p},e.exports=p},function(e,t,n){"use strict";var r=Object.prototype.toString;e.exports=function(e){var t=r.call(e),n="[object Arguments]"===t;return n||(n="[object Array]"!==t&&null!==e&&"object"==typeof e&&"number"==typeof e.length&&e.length>=0&&"[object Function]"===r.call(e.callee)),n}},function(e,t,n){(function(t){var r,i=n(8)("algoliasearch:src/hostIndexState.js"),s="algoliasearch-client-js",o={state:{},set:function(e,t){return this.state[e]=t,this.state[e]},get:function(e){return this.state[e]||null}},a={set:function(e,n){o.set(e,n);try{var r=JSON.parse(t.localStorage[s]);return r[e]=n,t.localStorage[s]=JSON.stringify(r),r[e]}catch(t){return c(e,t)}},get:function(e){try{return JSON.parse(t.localStorage[s])[e]||null}catch(t){return c(e,t)}}};function c(e,n){return i("localStorage failed with",n),function(){try{t.localStorage.removeItem(s)}catch(e){}}(),(r=o).get(e)}function u(e,t){return 1===arguments.length?r.get(e):r.set(e,t)}function l(){try{return"localStorage"in t&&null!==t.localStorage&&(t.localStorage[s]||t.localStorage.setItem(s,JSON.stringify({})),!0)}catch(e){return!1}}r=l()?a:o,e.exports={get:u,set:u,supportsLocalStorage:l}}).call(t,n(4))},function(e,t,n){var r;function i(e){function n(){if(n.enabled){var e=n,i=+new Date,s=i-(r||i);e.diff=s,e.prev=r,e.curr=i,r=i;for(var o=new Array(arguments.length),a=0;a<o.length;a++)o[a]=arguments[a];o[0]=t.coerce(o[0]),"string"!=typeof o[0]&&o.unshift("%O");var c=0;o[0]=o[0].replace(/%([a-zA-Z%])/g,function(n,r){if("%%"===n)return n;c++;var i=t.formatters[r];if("function"==typeof i){var s=o[c];n=i.call(e,s),o.splice(c,1),c--}return n}),t.formatArgs.call(e,o),(n.log||t.log||console.log.bind(console)).apply(e,o)}}return n.namespace=e,n.enabled=t.enabled(e),n.useColors=t.useColors(),n.color=function(e){var n,r=0;for(n in e)r=(r<<5)-r+e.charCodeAt(n),r|=0;return t.colors[Math.abs(r)%t.colors.length]}(e),"function"==typeof t.init&&t.init(n),n}(t=e.exports=i.debug=i.default=i).coerce=function(e){return e instanceof Error?e.stack||e.message:e},t.disable=function(){t.enable("")},t.enable=function(e){t.save(e),t.names=[],t.skips=[];for(var n=("string"==typeof e?e:"").split(/[\s,]+/),r=n.length,i=0;i<r;i++)n[i]&&("-"===(e=n[i].replace(/\*/g,".*?"))[0]?t.skips.push(new RegExp("^"+e.substr(1)+"$")):t.names.push(new RegExp("^"+e+"$")))},t.enabled=function(e){var n,r;for(n=0,r=t.skips.length;n<r;n++)if(t.skips[n].test(e))return!1;for(n=0,r=t.names.length;n<r;n++)if(t.names[n].test(e))return!0;return!1},t.humanize=n(40),t.names=[],t.skips=[],t.formatters={}},function(e,t){var n=1e3,r=60*n,i=60*r,s=24*i,o=365.25*s;function a(e,t,n){if(!(e<t))return e<1.5*t?Math.floor(e/t)+" "+n:Math.ceil(e/t)+" "+n+"s"}e.exports=function(e,t){t=t||{};var c=typeof e;if("string"===c&&e.length>0)return function(e){if((e=String(e)).length>100)return;var t=/^((?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|years?|yrs?|y)?$/i.exec(e);if(!t)return;var a=parseFloat(t[1]);switch((t[2]||"ms").toLowerCase()){case"years":case"year":case"yrs":case"yr":case"y":return a*o;case"days":case"day":case"d":return a*s;case"hours":case"hour":case"hrs":case"hr":case"h":return a*i;case"minutes":case"minute":case"mins":case"min":case"m":return a*r;case"seconds":case"second":case"secs":case"sec":case"s":return a*n;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return a;default:return}}(e);if("number"===c&&!1===isNaN(e))return t.long?function(e){return a(e,s,"day")||a(e,i,"hour")||a(e,r,"minute")||a(e,n,"second")||e+" ms"}(e):function(e){if(e>=s)return Math.round(e/s)+"d";if(e>=i)return Math.round(e/i)+"h";if(e>=r)return Math.round(e/r)+"m";if(e>=n)return Math.round(e/n)+"s";return e+"ms"}(e);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(e))}},function(e,t,n){"use strict";var r=n(42),i=r.Promise||n(43).Promise;e.exports=function(e,t){var s=n(12),o=n(5),a=n(44),c=n(46),u=n(47);function l(e,t,r){return(r=n(3)(r||{}))._ua=r._ua||l.ua,new d(e,t,r)}t=t||"",l.version=n(48),l.ua="Algolia for vanilla JavaScript "+t+l.version,l.initPlaces=u(l),r.__algolia={debug:n(8),algoliasearch:l};var h={hasXMLHttpRequest:"XMLHttpRequest"in r,hasXDomainRequest:"XDomainRequest"in r};function d(){e.apply(this,arguments)}return h.hasXMLHttpRequest&&(h.cors="withCredentials"in new XMLHttpRequest),s(d,e),d.prototype._request=function(e,t){return new i(function(n,r){if(h.cors||h.hasXDomainRequest){e=a(e,t.headers);var i,s,c=t.body,u=h.cors?new XMLHttpRequest:new XDomainRequest,l=!1;i=setTimeout(d,t.timeouts.connect),u.onprogress=function(){l||f()},"onreadystatechange"in u&&(u.onreadystatechange=function(){!l&&u.readyState>1&&f()}),u.onload=function(){if(s)return;var e;clearTimeout(i);try{e={body:JSON.parse(u.responseText),responseText:u.responseText,statusCode:u.status,headers:u.getAllResponseHeaders&&u.getAllResponseHeaders()||{}}}catch(t){e=new o.UnparsableJSON({more:u.responseText})}e instanceof o.UnparsableJSON?r(e):n(e)},u.onerror=function(e){if(s)return;clearTimeout(i),r(new o.Network({more:e}))},u instanceof XMLHttpRequest?(u.open(t.method,e,!0),t.forceAuthHeaders&&(u.setRequestHeader("x-algolia-application-id",t.headers["x-algolia-application-id"]),u.setRequestHeader("x-algolia-api-key",t.headers["x-algolia-api-key"]))):u.open(t.method,e),h.cors&&(c&&("POST"===t.method?u.setRequestHeader("content-type","application/x-www-form-urlencoded"):u.setRequestHeader("content-type","application/json")),u.setRequestHeader("accept","application/json")),c?u.send(c):u.send()}else r(new o.Network("CORS not supported"));function d(){s=!0,u.abort(),r(new o.RequestTimeout)}function f(){l=!0,clearTimeout(i),i=setTimeout(d,t.timeouts.complete)}})},d.prototype._request.fallback=function(e,t){return e=a(e,t.headers),new i(function(n,r){c(e,t,function(e,t){e?r(e):n(t)})})},d.prototype._promise={reject:function(e){return i.reject(e)},resolve:function(e){return i.resolve(e)},delay:function(e){return new i(function(t){setTimeout(t,e)})},all:function(e){return i.all(e)}},l}},function(e,t,n){(function(t){var n;n="undefined"!=typeof window?window:void 0!==t?t:"undefined"!=typeof self?self:{},e.exports=n}).call(t,n(4))},function(e,t,n){(function(t,n){
-/*!
- * @overview es6-promise - a tiny implementation of Promises/A+.
- * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
- * @license Licensed under MIT license
- * See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
- * @version v4.2.4+314e4831
- */
-!function(t,n){e.exports=n()}(0,function(){"use strict";function e(e){return"function"==typeof e}var r=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},i=0,s=void 0,o=void 0,a=function(e,t){p[i]=e,p[i+1]=t,2===(i+=2)&&(o?o(g):m())};var c="undefined"!=typeof window?window:void 0,u=c||{},l=u.MutationObserver||u.WebKitMutationObserver,h="undefined"==typeof self&&void 0!==t&&"[object process]"==={}.toString.call(t),d="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function f(){var e=setTimeout;return function(){return e(g,1)}}var p=new Array(1e3);function g(){for(var e=0;e<i;e+=2){(0,p[e])(p[e+1]),p[e]=void 0,p[e+1]=void 0}i=0}var m=void 0;function v(e,t){var n=this,r=new this.constructor(w);void 0===r[b]&&D(r);var i=n._state;if(i){var s=arguments[i-1];a(function(){return L(i,r,s,n._result)})}else R(n,r,e,t);return r}function y(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(w);return N(t,e),t}m=h?function(){return t.nextTick(g)}:l?function(){var e=0,t=new l(g),n=document.createTextNode("");return t.observe(n,{characterData:!0}),function(){n.data=e=++e%2}}():d?function(){var e=new MessageChannel;return e.port1.onmessage=g,function(){return e.port2.postMessage(0)}}():void 0===c?function(){try{var e=Function("return this")().require("vertx");return void 0!==(s=e.runOnLoop||e.runOnContext)?function(){s(g)}:f()}catch(e){return f()}}():f();var b=Math.random().toString(36).substring(2);function w(){}var _=void 0,E=1,x=2,S={error:null};function C(e){try{return e.then}catch(e){return S.error=e,S}}function A(t,n,r){n.constructor===t.constructor&&r===v&&n.constructor.resolve===y?function(e,t){t._state===E?T(e,t._result):t._state===x?k(e,t._result):R(t,void 0,function(t){return N(e,t)},function(t){return k(e,t)})}(t,n):r===S?(k(t,S.error),S.error=null):void 0===r?T(t,n):e(r)?function(e,t,n){a(function(e){var r=!1,i=function(e,t,n,r){try{e.call(t,n,r)}catch(e){return e}}(n,t,function(n){r||(r=!0,t!==n?N(e,n):T(e,n))},function(t){r||(r=!0,k(e,t))},e._label);!r&&i&&(r=!0,k(e,i))},e)}(t,n,r):T(t,n)}function N(e,t){e===t?k(e,new TypeError("You cannot resolve a promise with itself")):!function(e){var t=typeof e;return null!==e&&("object"===t||"function"===t)}(t)?T(e,t):A(e,t,C(t))}function O(e){e._onerror&&e._onerror(e._result),M(e)}function T(e,t){e._state===_&&(e._result=t,e._state=E,0!==e._subscribers.length&&a(M,e))}function k(e,t){e._state===_&&(e._state=x,e._result=t,a(O,e))}function R(e,t,n,r){var i=e._subscribers,s=i.length;e._onerror=null,i[s]=t,i[s+E]=n,i[s+x]=r,0===s&&e._state&&a(M,e)}function M(e){var t=e._subscribers,n=e._state;if(0!==t.length){for(var r=void 0,i=void 0,s=e._result,o=0;o<t.length;o+=3)r=t[o],i=t[o+n],r?L(n,r,i,s):i(s);e._subscribers.length=0}}function L(t,n,r,i){var s=e(r),o=void 0,a=void 0,c=void 0,u=void 0;if(s){if((o=function(e,t){try{return e(t)}catch(e){return S.error=e,S}}(r,i))===S?(u=!0,a=o.error,o.error=null):c=!0,n===o)return void k(n,new TypeError("A promises callback cannot return that same promise."))}else o=i,c=!0;n._state!==_||(s&&c?N(n,o):u?k(n,a):t===E?T(n,o):t===x&&k(n,o))}var I=0;function D(e){e[b]=I++,e._state=void 0,e._result=void 0,e._subscribers=[]}var P=function(){function e(e,t){this._instanceConstructor=e,this.promise=new e(w),this.promise[b]||D(this.promise),r(t)?(this.length=t.length,this._remaining=t.length,this._result=new Array(this.length),0===this.length?T(this.promise,this._result):(this.length=this.length||0,this._enumerate(t),0===this._remaining&&T(this.promise,this._result))):k(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;this._state===_&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var n=this._instanceConstructor,r=n.resolve;if(r===y){var i=C(e);if(i===v&&e._state!==_)this._settledAt(e._state,t,e._result);else if("function"!=typeof i)this._remaining--,this._result[t]=e;else if(n===j){var s=new n(w);A(s,e,i),this._willSettleAt(s,t)}else this._willSettleAt(new n(function(t){return t(e)}),t)}else this._willSettleAt(r(e),t)},e.prototype._settledAt=function(e,t,n){var r=this.promise;r._state===_&&(this._remaining--,e===x?k(r,n):this._result[t]=n),0===this._remaining&&T(r,this._result)},e.prototype._willSettleAt=function(e,t){var n=this;R(e,void 0,function(e){return n._settledAt(E,t,e)},function(e){return n._settledAt(x,t,e)})},e}();var j=function(){function e(t){this[b]=I++,this._result=this._state=void 0,this._subscribers=[],w!==t&&("function"!=typeof t&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof e?function(e,t){try{t(function(t){N(e,t)},function(t){k(e,t)})}catch(t){k(e,t)}}(this,t):function(){throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.")}())}return e.prototype.catch=function(e){return this.then(null,e)},e.prototype.finally=function(e){var t=this.constructor;return this.then(function(n){return t.resolve(e()).then(function(){return n})},function(n){return t.resolve(e()).then(function(){throw n})})},e}();return j.prototype.then=v,j.all=function(e){return new P(this,e).promise},j.race=function(e){var t=this;return r(e)?new t(function(n,r){for(var i=e.length,s=0;s<i;s++)t.resolve(e[s]).then(n,r)}):new t(function(e,t){return t(new TypeError("You must pass an array to race."))})},j.resolve=y,j.reject=function(e){var t=new this(w);return k(t,e),t},j._setScheduler=function(e){o=e},j._setAsap=function(e){a=e},j._asap=a,j.polyfill=function(){var e=void 0;if(void 0!==n)e=n;else if("undefined"!=typeof self)e=self;else try{e=Function("return this")()}catch(e){throw new Error("polyfill failed because global object is unavailable in this environment")}var t=e.Promise;if(t){var r=null;try{r=Object.prototype.toString.call(t.resolve())}catch(e){}if("[object Promise]"===r&&!t.cast)return}e.Promise=j},j.Promise=j,j})}).call(t,n(9),n(4))},function(e,t,n){"use strict";e.exports=function(e,t){/\?/.test(e)?e+="&":e+="?";return e+r(t)};var r=n(45)},function(e,t,n){"use strict";var r=function(e){switch(typeof e){case"string":return e;case"boolean":return e?"true":"false";case"number":return isFinite(e)?e:"";default:return""}};e.exports=function(e,t,n,a){return t=t||"&",n=n||"=",null===e&&(e=void 0),"object"==typeof e?s(o(e),function(o){var a=encodeURIComponent(r(o))+n;return i(e[o])?s(e[o],function(e){return a+encodeURIComponent(r(e))}).join(t):a+encodeURIComponent(r(e[o]))}).join(t):a?encodeURIComponent(r(a))+n+encodeURIComponent(r(e)):""};var i=Array.isArray||function(e){return"[object Array]"===Object.prototype.toString.call(e)};function s(e,t){if(e.map)return e.map(t);for(var n=[],r=0;r<e.length;r++)n.push(t(e[r],r));return n}var o=Object.keys||function(e){var t=[];for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&t.push(n);return t}},function(e,t,n){"use strict";e.exports=function(e,t,n){if("GET"!==t.method)return void n(new Error("Method "+t.method+" "+e+" is not supported by JSONP."));t.debug("JSONP: start");var s=!1,o=!1;i+=1;var a=document.getElementsByTagName("head")[0],c=document.createElement("script"),u="algoliaJSONP_"+i,l=!1;window[u]=function(e){!function(){try{delete window[u],delete window[u+"_loaded"]}catch(e){window[u]=window[u+"_loaded"]=void 0}}(),o?t.debug("JSONP: Late answer, ignoring"):(s=!0,f(),n(null,{body:e,responseText:JSON.stringify(e)}))},e+="&callback="+u,t.jsonBody&&t.jsonBody.params&&(e+="&"+t.jsonBody.params);var h=setTimeout(function(){t.debug("JSONP: Script timeout"),o=!0,f(),n(new r.RequestTimeout)},t.timeouts.complete);function d(){t.debug("JSONP: success"),l||o||(l=!0,s||(t.debug("JSONP: Fail. Script loaded but did not call the callback"),f(),n(new r.JSONPScriptFail)))}function f(){clearTimeout(h),c.onload=null,c.onreadystatechange=null,c.onerror=null,a.removeChild(c)}c.onreadystatechange=function(){"loaded"!==this.readyState&&"complete"!==this.readyState||d()},c.onload=d,c.onerror=function(){if(t.debug("JSONP: Script error"),l||o)return;f(),n(new r.JSONPScriptError)},c.async=!0,c.defer=!0,c.src=e,a.appendChild(c)};var r=n(5),i=0},function(e,t,n){e.exports=function(e){return function(t,i,s){var o=n(3);(s=s&&o(s)||{}).hosts=s.hosts||["places-dsn.algolia.net","places-1.algolianet.com","places-2.algolianet.com","places-3.algolianet.com"],0!==arguments.length&&"object"!=typeof t&&void 0!==t||(t="",i="",s._allowEmptyCredentials=!0);var a=e(t,i,s),c=a.initIndex("places");return c.search=r("query","/1/places/query"),c.getObject=function(e,t){return this.as._jsonRequest({method:"GET",url:"/1/places/"+encodeURIComponent(e),hostType:"read",callback:t})},c}};var r=n(13)},function(e,t,n){"use strict";e.exports="3.30.0"},function(e,t,n){"use strict";e.exports=n(50)},function(e,t,n){"use strict";var r=n(15);n(1).element=r;var i=n(0);i.isArray=r.isArray,i.isFunction=r.isFunction,i.isObject=r.isPlainObject,i.bind=r.proxy,i.each=function(e,t){r.each(e,function(e,n){return t(n,e)})},i.map=r.map,i.mixin=r.extend,i.Event=r.Event;var s="aaAutocomplete",o=n(51),a=n(16);function c(e,t,n,c){n=i.isArray(n)?n:[].slice.call(arguments,2);var u=r(e).each(function(e,i){var u=r(i),l=new a({el:u}),h=c||new o({input:u,eventBus:l,dropdownMenuContainer:t.dropdownMenuContainer,hint:void 0===t.hint||!!t.hint,minLength:t.minLength,autoselect:t.autoselect,autoselectOnBlur:t.autoselectOnBlur,tabAutocomplete:t.tabAutocomplete,openOnFocus:t.openOnFocus,templates:t.templates,debug:t.debug,clearOnSelected:t.clearOnSelected,cssClasses:t.cssClasses,datasets:n,keyboardShortcuts:t.keyboardShortcuts,appendTo:t.appendTo,autoWidth:t.autoWidth});u.data(s,h)});return u.autocomplete={},i.each(["open","close","getVal","setVal","destroy","getWrapper"],function(e){u.autocomplete[e]=function(){var t,n=arguments;return u.each(function(i,o){var a=r(o).data(s);t=a[e].apply(a,n)}),t}}),u}c.sources=o.sources,c.escapeHighlightedString=i.escapeHighlightedString;var u="autocomplete"in window,l=window.autocomplete;c.noConflict=function(){return u?window.autocomplete=l:delete window.autocomplete,c},e.exports=c},function(e,t,n){"use strict";var r="aaAttrs",i=n(0),s=n(1),o=n(16),a=n(52),c=n(59),u=n(17),l=n(11);function h(e){var t,n;if((e=e||{}).input||i.error("missing input"),this.isActivated=!1,this.debug=!!e.debug,this.autoselect=!!e.autoselect,this.autoselectOnBlur=!!e.autoselectOnBlur,this.openOnFocus=!!e.openOnFocus,this.minLength=i.isNumber(e.minLength)?e.minLength:1,this.autoWidth=void 0===e.autoWidth||!!e.autoWidth,this.clearOnSelected=!!e.clearOnSelected,this.tabAutocomplete=void 0===e.tabAutocomplete||!!e.tabAutocomplete,e.hint=!!e.hint,e.hint&&e.appendTo)throw new Error("[autocomplete.js] hint and appendTo options can't be used at the same time");this.css=e.css=i.mixin({},l,e.appendTo?l.appendTo:{}),this.cssClasses=e.cssClasses=i.mixin({},l.defaultClasses,e.cssClasses||{}),this.cssClasses.prefix=e.cssClasses.formattedPrefix=i.formatPrefix(this.cssClasses.prefix,this.cssClasses.noPrefix),this.listboxId=e.listboxId=[this.cssClasses.root,"listbox",i.getUniqueId()].join("-");var a=function(e){var t,n,o,a;t=s.element(e.input),n=s.element(u.wrapper.replace("%ROOT%",e.cssClasses.root)).css(e.css.wrapper),e.appendTo||"block"!==t.css("display")||"table"!==t.parent().css("display")||n.css("display","table-cell");var c=u.dropdown.replace("%PREFIX%",e.cssClasses.prefix).replace("%DROPDOWN_MENU%",e.cssClasses.dropdownMenu);o=s.element(c).css(e.css.dropdown).attr({role:"listbox",id:e.listboxId}),e.templates&&e.templates.dropdownMenu&&o.html(i.templatify(e.templates.dropdownMenu)());(a=t.clone().css(e.css.hint).css(function(e){return{backgroundAttachment:e.css("background-attachment"),backgroundClip:e.css("background-clip"),backgroundColor:e.css("background-color"),backgroundImage:e.css("background-image"),backgroundOrigin:e.css("background-origin"),backgroundPosition:e.css("background-position"),backgroundRepeat:e.css("background-repeat"),backgroundSize:e.css("background-size")}}(t))).val("").addClass(i.className(e.cssClasses.prefix,e.cssClasses.hint,!0)).removeAttr("id name placeholder required").prop("readonly",!0).attr({"aria-hidden":"true",autocomplete:"off",spellcheck:"false",tabindex:-1}),a.removeData&&a.removeData();t.data(r,{"aria-autocomplete":t.attr("aria-autocomplete"),"aria-expanded":t.attr("aria-expanded"),"aria-owns":t.attr("aria-owns"),autocomplete:t.attr("autocomplete"),dir:t.attr("dir"),role:t.attr("role"),spellcheck:t.attr("spellcheck"),style:t.attr("style"),type:t.attr("type")}),t.addClass(i.className(e.cssClasses.prefix,e.cssClasses.input,!0)).attr({autocomplete:"off",spellcheck:!1,role:"combobox","aria-autocomplete":e.datasets&&e.datasets[0]&&e.datasets[0].displayKey?"both":"list","aria-expanded":"false","aria-label":e.ariaLabel,"aria-owns":e.listboxId}).css(e.hint?e.css.input:e.css.inputWithNoHint);try{t.attr("dir")||t.attr("dir","auto")}catch(e){}return(n=e.appendTo?n.appendTo(s.element(e.appendTo).eq(0)).eq(0):t.wrap(n).parent()).prepend(e.hint?a:null).append(o),{wrapper:n,input:t,hint:a,menu:o}}(e);this.$node=a.wrapper;var c=this.$input=a.input;t=a.menu,n=a.hint,e.dropdownMenuContainer&&s.element(e.dropdownMenuContainer).css("position","relative").append(t.css("top","0")),c.on("blur.aa",function(e){var n=document.activeElement;i.isMsie()&&(t[0]===n||t[0].contains(n))&&(e.preventDefault(),e.stopImmediatePropagation(),i.defer(function(){c.focus()}))}),t.on("mousedown.aa",function(e){e.preventDefault()}),this.eventBus=e.eventBus||new o({el:c}),this.dropdown=new h.Dropdown({appendTo:e.appendTo,wrapper:this.$node,menu:t,datasets:e.datasets,templates:e.templates,cssClasses:e.cssClasses,minLength:this.minLength}).onSync("suggestionClicked",this._onSuggestionClicked,this).onSync("cursorMoved",this._onCursorMoved,this).onSync("cursorRemoved",this._onCursorRemoved,this).onSync("opened",this._onOpened,this).onSync("closed",this._onClosed,this).onSync("shown",this._onShown,this).onSync("empty",this._onEmpty,this).onSync("redrawn",this._onRedrawn,this).onAsync("datasetRendered",this._onDatasetRendered,this),this.input=new h.Input({input:c,hint:n}).onSync("focused",this._onFocused,this).onSync("blurred",this._onBlurred,this).onSync("enterKeyed",this._onEnterKeyed,this).onSync("tabKeyed",this._onTabKeyed,this).onSync("escKeyed",this._onEscKeyed,this).onSync("upKeyed",this._onUpKeyed,this).onSync("downKeyed",this._onDownKeyed,this).onSync("leftKeyed",this._onLeftKeyed,this).onSync("rightKeyed",this._onRightKeyed,this).onSync("queryChanged",this._onQueryChanged,this).onSync("whitespaceChanged",this._onWhitespaceChanged,this),this._bindKeyboardShortcuts(e),this._setLanguageDirection()}i.mixin(h.prototype,{_bindKeyboardShortcuts:function(e){if(e.keyboardShortcuts){var t=this.$input,n=[];i.each(e.keyboardShortcuts,function(e){"string"==typeof e&&(e=e.toUpperCase().charCodeAt(0)),n.push(e)}),s.element(document).keydown(function(e){var r=e.target||e.srcElement,i=r.tagName;if(!r.isContentEditable&&"INPUT"!==i&&"SELECT"!==i&&"TEXTAREA"!==i){var s=e.which||e.keyCode;-1!==n.indexOf(s)&&(t.focus(),e.stopPropagation(),e.preventDefault())}})}},_onSuggestionClicked:function(e,t){var n;(n=this.dropdown.getDatumForSuggestion(t))&&this._select(n)},_onCursorMoved:function(e,t){var n=this.dropdown.getDatumForCursor(),r=this.dropdown.getCurrentCursor().attr("id");this.input.setActiveDescendant(r),n&&(t&&this.input.setInputValue(n.value,!0),this.eventBus.trigger("cursorchanged",n.raw,n.datasetName))},_onCursorRemoved:function(){this.input.resetInputValue(),this._updateHint(),this.eventBus.trigger("cursorremoved")},_onDatasetRendered:function(){this._updateHint(),this.eventBus.trigger("updated")},_onOpened:function(){this._updateHint(),this.input.expand(),this.eventBus.trigger("opened")},_onEmpty:function(){this.eventBus.trigger("empty")},_onRedrawn:function(){this.$node.css("top","0px"),this.$node.css("left","0px");var e=this.$input[0].getBoundingClientRect();this.autoWidth&&this.$node.css("width",e.width+"px");var t=this.$node[0].getBoundingClientRect(),n=e.bottom-t.top;this.$node.css("top",n+"px");var r=e.left-t.left;this.$node.css("left",r+"px"),this.eventBus.trigger("redrawn")},_onShown:function(){this.eventBus.trigger("shown"),this.autoselect&&this.dropdown.cursorTopSuggestion()},_onClosed:function(){this.input.clearHint(),this.input.removeActiveDescendant(),this.input.collapse(),this.eventBus.trigger("closed")},_onFocused:function(){if(this.isActivated=!0,this.openOnFocus){var e=this.input.getQuery();e.length>=this.minLength?this.dropdown.update(e):this.dropdown.empty(),this.dropdown.open()}},_onBlurred:function(){var e,t;e=this.dropdown.getDatumForCursor(),t=this.dropdown.getDatumForTopSuggestion(),this.debug||(this.autoselectOnBlur&&e?this._select(e):this.autoselectOnBlur&&t?this._select(t):(this.isActivated=!1,this.dropdown.empty(),this.dropdown.close()))},_onEnterKeyed:function(e,t){var n,r;n=this.dropdown.getDatumForCursor(),r=this.dropdown.getDatumForTopSuggestion(),n?(this._select(n),t.preventDefault()):this.autoselect&&r&&(this._select(r),t.preventDefault())},_onTabKeyed:function(e,t){var n;this.tabAutocomplete?(n=this.dropdown.getDatumForCursor())?(this._select(n),t.preventDefault()):this._autocomplete(!0):this.dropdown.close()},_onEscKeyed:function(){this.dropdown.close(),this.input.resetInputValue()},_onUpKeyed:function(){var e=this.input.getQuery();this.dropdown.isEmpty&&e.length>=this.minLength?this.dropdown.update(e):this.dropdown.moveCursorUp(),this.dropdown.open()},_onDownKeyed:function(){var e=this.input.getQuery();this.dropdown.isEmpty&&e.length>=this.minLength?this.dropdown.update(e):this.dropdown.moveCursorDown(),this.dropdown.open()},_onLeftKeyed:function(){"rtl"===this.dir&&this._autocomplete()},_onRightKeyed:function(){"ltr"===this.dir&&this._autocomplete()},_onQueryChanged:function(e,t){this.input.clearHintIfInvalid(),t.length>=this.minLength?this.dropdown.update(t):this.dropdown.empty(),this.dropdown.open(),this._setLanguageDirection()},_onWhitespaceChanged:function(){this._updateHint(),this.dropdown.open()},_setLanguageDirection:function(){var e=this.input.getLanguageDirection();this.dir!==e&&(this.dir=e,this.$node.css("direction",e),this.dropdown.setLanguageDirection(e))},_updateHint:function(){var e,t,n,r,s;(e=this.dropdown.getDatumForTopSuggestion())&&this.dropdown.isVisible()&&!this.input.hasOverflow()?(t=this.input.getInputValue(),n=a.normalizeQuery(t),r=i.escapeRegExChars(n),(s=new RegExp("^(?:"+r+")(.+$)","i").exec(e.value))?this.input.setHint(t+s[1]):this.input.clearHint()):this.input.clearHint()},_autocomplete:function(e){var t,n,r,i;t=this.input.getHint(),n=this.input.getQuery(),r=e||this.input.isCursorAtEnd(),t&&n!==t&&r&&((i=this.dropdown.getDatumForTopSuggestion())&&this.input.setInputValue(i.value),this.eventBus.trigger("autocompleted",i.raw,i.datasetName))},_select:function(e){void 0!==e.value&&this.input.setQuery(e.value),this.clearOnSelected?this.setVal(""):this.input.setInputValue(e.value,!0),this._setLanguageDirection(),!1===this.eventBus.trigger("selected",e.raw,e.datasetName).isDefaultPrevented()&&(this.dropdown.close(),i.defer(i.bind(this.dropdown.empty,this.dropdown)))},open:function(){if(!this.isActivated){var e=this.input.getInputValue();e.length>=this.minLength?this.dropdown.update(e):this.dropdown.empty()}this.dropdown.open()},close:function(){this.dropdown.close()},setVal:function(e){e=i.toStr(e),this.isActivated?this.input.setInputValue(e):(this.input.setQuery(e),this.input.setInputValue(e,!0)),this._setLanguageDirection()},getVal:function(){return this.input.getQuery()},destroy:function(){this.input.destroy(),this.dropdown.destroy(),function(e,t){var n=e.find(i.className(t.prefix,t.input));i.each(n.data(r),function(e,t){void 0===e?n.removeAttr(t):n.attr(t,e)}),n.detach().removeClass(i.className(t.prefix,t.input,!0)).insertAfter(e),n.removeData&&n.removeData(r);e.remove()}(this.$node,this.cssClasses),this.$node=null},getWrapper:function(){return this.dropdown.$container[0]}}),h.Dropdown=c,h.Input=a,h.sources=n(61),e.exports=h},function(e,t,n){"use strict";var r;r={9:"tab",27:"esc",37:"left",39:"right",13:"enter",38:"up",40:"down"};var i=n(0),s=n(1),o=n(10);function a(e){var t,n,o,a,c=this;(e=e||{}).input||i.error("input is missing"),t=i.bind(this._onBlur,this),n=i.bind(this._onFocus,this),o=i.bind(this._onKeydown,this),a=i.bind(this._onInput,this),this.$hint=s.element(e.hint),this.$input=s.element(e.input).on("blur.aa",t).on("focus.aa",n).on("keydown.aa",o),0===this.$hint.length&&(this.setHint=this.getHint=this.clearHint=this.clearHintIfInvalid=i.noop),i.isMsie()?this.$input.on("keydown.aa keypress.aa cut.aa paste.aa",function(e){r[e.which||e.keyCode]||i.defer(i.bind(c._onInput,c,e))}):this.$input.on("input.aa",a),this.query=this.$input.val(),this.$overflowHelper=function(e){return s.element('<pre aria-hidden="true"></pre>').css({position:"absolute",visibility:"hidden",whiteSpace:"pre",fontFamily:e.css("font-family"),fontSize:e.css("font-size"),fontStyle:e.css("font-style"),fontVariant:e.css("font-variant"),fontWeight:e.css("font-weight"),wordSpacing:e.css("word-spacing"),letterSpacing:e.css("letter-spacing"),textIndent:e.css("text-indent"),textRendering:e.css("text-rendering"),textTransform:e.css("text-transform")}).insertAfter(e)}(this.$input)}function c(e){return e.altKey||e.ctrlKey||e.metaKey||e.shiftKey}a.normalizeQuery=function(e){return(e||"").replace(/^\s*/g,"").replace(/\s{2,}/g," ")},i.mixin(a.prototype,o,{_onBlur:function(){this.resetInputValue(),this.$input.removeAttr("aria-activedescendant"),this.trigger("blurred")},_onFocus:function(){this.trigger("focused")},_onKeydown:function(e){var t=r[e.which||e.keyCode];this._managePreventDefault(t,e),t&&this._shouldTrigger(t,e)&&this.trigger(t+"Keyed",e)},_onInput:function(){this._checkInputValue()},_managePreventDefault:function(e,t){var n,r,i;switch(e){case"tab":r=this.getHint(),i=this.getInputValue(),n=r&&r!==i&&!c(t);break;case"up":case"down":n=!c(t);break;default:n=!1}n&&t.preventDefault()},_shouldTrigger:function(e,t){var n;switch(e){case"tab":n=!c(t);break;default:n=!0}return n},_checkInputValue:function(){var e,t,n;n=!(!(t=function(e,t){return a.normalizeQuery(e)===a.normalizeQuery(t)}(e=this.getInputValue(),this.query))||!this.query)&&this.query.length!==e.length,this.query=e,t?n&&this.trigger("whitespaceChanged",this.query):this.trigger("queryChanged",this.query)},focus:function(){this.$input.focus()},blur:function(){this.$input.blur()},getQuery:function(){return this.query},setQuery:function(e){this.query=e},getInputValue:function(){return this.$input.val()},setInputValue:function(e,t){void 0===e&&(e=this.query),this.$input.val(e),t?this.clearHint():this._checkInputValue()},expand:function(){this.$input.attr("aria-expanded","true")},collapse:function(){this.$input.attr("aria-expanded","false")},setActiveDescendant:function(e){this.$input.attr("aria-activedescendant",e)},removeActiveDescendant:function(){this.$input.removeAttr("aria-activedescendant")},resetInputValue:function(){this.setInputValue(this.query,!0)},getHint:function(){return this.$hint.val()},setHint:function(e){this.$hint.val(e)},clearHint:function(){this.setHint("")},clearHintIfInvalid:function(){var e,t,n;n=(e=this.getInputValue())!==(t=this.getHint())&&0===t.indexOf(e),""!==e&&n&&!this.hasOverflow()||this.clearHint()},getLanguageDirection:function(){return(this.$input.css("direction")||"ltr").toLowerCase()},hasOverflow:function(){var e=this.$input.width()-2;return this.$overflowHelper.text(this.getInputValue()),this.$overflowHelper.width()>=e},isCursorAtEnd:function(){var e,t,n;return e=this.$input.val().length,t=this.$input[0].selectionStart,i.isNumber(t)?t===e:!document.selection||((n=document.selection.createRange()).moveStart("character",-e),e===n.text.length)},destroy:function(){this.$hint.off(".aa"),this.$input.off(".aa"),this.$hint=this.$input=this.$overflowHelper=null}}),e.exports=a},function(e,t,n){"use strict";var r,i,s,o=[n(54),n(55),n(56),n(57),n(58)],a=-1,c=[],u=!1;function l(){r&&i&&(r=!1,i.length?c=i.concat(c):a=-1,c.length&&h())}function h(){if(!r){u=!1,r=!0;for(var e=c.length,t=setTimeout(l);e;){for(i=c,c=[];i&&++a<e;)i[a].run();a=-1,e=c.length}i=null,a=-1,r=!1,clearTimeout(t)}}for(var d=-1,f=o.length;++d<f;)if(o[d]&&o[d].test&&o[d].test()){s=o[d].install(h);break}function p(e,t){this.fun=e,this.array=t}p.prototype.run=function(){var e=this.fun,t=this.array;switch(t.length){case 0:return e();case 1:return e(t[0]);case 2:return e(t[0],t[1]);case 3:return e(t[0],t[1],t[2]);default:return e.apply(null,t)}},e.exports=function(e){var t=new Array(arguments.length-1);if(arguments.length>1)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];c.push(new p(e,t)),u||r||(u=!0,s())}},function(e,t,n){"use strict";(function(e){t.test=function(){return void 0!==e&&!e.browser},t.install=function(t){return function(){e.nextTick(t)}}}).call(t,n(9))},function(e,t,n){"use strict";(function(e){var n=e.MutationObserver||e.WebKitMutationObserver;t.test=function(){return n},t.install=function(t){var r=0,i=new n(t),s=e.document.createTextNode("");return i.observe(s,{characterData:!0}),function(){s.data=r=++r%2}}}).call(t,n(4))},function(e,t,n){"use strict";(function(e){t.test=function(){return!e.setImmediate&&void 0!==e.MessageChannel},t.install=function(t){var n=new e.MessageChannel;return n.port1.onmessage=t,function(){n.port2.postMessage(0)}}}).call(t,n(4))},function(e,t,n){"use strict";(function(e){t.test=function(){return"document"in e&&"onreadystatechange"in e.document.createElement("script")},t.install=function(t){return function(){var n=e.document.createElement("script");return n.onreadystatechange=function(){t(),n.onreadystatechange=null,n.parentNode.removeChild(n),n=null},e.document.documentElement.appendChild(n),t}}}).call(t,n(4))},function(e,t,n){"use strict";t.test=function(){return!0},t.install=function(e){return function(){setTimeout(e,0)}}},function(e,t,n){"use strict";var r=n(0),i=n(1),s=n(10),o=n(60),a=n(11);function c(e){var t,n,s,o=this;(e=e||{}).menu||r.error("menu is required"),r.isArray(e.datasets)||r.isObject(e.datasets)||r.error("1 or more datasets required"),e.datasets||r.error("datasets is required"),this.isOpen=!1,this.isEmpty=!0,this.minLength=e.minLength||0,this.templates={},this.appendTo=e.appendTo||!1,this.css=r.mixin({},a,e.appendTo?a.appendTo:{}),this.cssClasses=e.cssClasses=r.mixin({},a.defaultClasses,e.cssClasses||{}),this.cssClasses.prefix=e.cssClasses.formattedPrefix||r.formatPrefix(this.cssClasses.prefix,this.cssClasses.noPrefix),t=r.bind(this._onSuggestionClick,this),n=r.bind(this._onSuggestionMouseEnter,this),s=r.bind(this._onSuggestionMouseLeave,this);var u=r.className(this.cssClasses.prefix,this.cssClasses.suggestion);this.$menu=i.element(e.menu).on("mouseenter.aa",u,n).on("mouseleave.aa",u,s).on("click.aa",u,t),this.$container=e.appendTo?e.wrapper:this.$menu,e.templates&&e.templates.header&&(this.templates.header=r.templatify(e.templates.header),this.$menu.prepend(this.templates.header())),e.templates&&e.templates.empty&&(this.templates.empty=r.templatify(e.templates.empty),this.$empty=i.element('<div class="'+r.className(this.cssClasses.prefix,this.cssClasses.empty,!0)+'"></div>'),this.$menu.append(this.$empty),this.$empty.hide()),this.datasets=r.map(e.datasets,function(t){return function(e,t,n){return new c.Dataset(r.mixin({$menu:e,cssClasses:n},t))}(o.$menu,t,e.cssClasses)}),r.each(this.datasets,function(e){var t=e.getRoot();t&&0===t.parent().length&&o.$menu.append(t),e.onSync("rendered",o._onRendered,o)}),e.templates&&e.templates.footer&&(this.templates.footer=r.templatify(e.templates.footer),this.$menu.append(this.templates.footer()));var l=this;i.element(window).resize(function(){l._redraw()})}r.mixin(c.prototype,s,{_onSuggestionClick:function(e){this.trigger("suggestionClicked",i.element(e.currentTarget))},_onSuggestionMouseEnter:function(e){var t=i.element(e.currentTarget);if(!t.hasClass(r.className(this.cssClasses.prefix,this.cssClasses.cursor,!0))){this._removeCursor();var n=this;setTimeout(function(){n._setCursor(t,!1)},0)}},_onSuggestionMouseLeave:function(e){if(e.relatedTarget&&i.element(e.relatedTarget).closest("."+r.className(this.cssClasses.prefix,this.cssClasses.cursor,!0)).length>0)return;this._removeCursor(),this.trigger("cursorRemoved")},_onRendered:function(e,t){if(this.isEmpty=r.every(this.datasets,function(e){return e.isEmpty()}),this.isEmpty)if(t.length>=this.minLength&&this.trigger("empty"),this.$empty)if(t.length<this.minLength)this._hide();else{var n=this.templates.empty({query:this.datasets[0]&&this.datasets[0].query});this.$empty.html(n),this.$empty.show(),this._show()}else r.any(this.datasets,function(e){return e.templates&&e.templates.empty})?t.length<this.minLength?this._hide():this._show():this._hide();else this.isOpen&&(this.$empty&&(this.$empty.empty(),this.$empty.hide()),t.length>=this.minLength?this._show():this._hide());this.trigger("datasetRendered")},_hide:function(){this.$container.hide()},_show:function(){this.$container.css("display","block"),this._redraw(),this.trigger("shown")},_redraw:function(){this.isOpen&&this.appendTo&&this.trigger("redrawn")},_getSuggestions:function(){return this.$menu.find(r.className(this.cssClasses.prefix,this.cssClasses.suggestion))},_getCursor:function(){return this.$menu.find(r.className(this.cssClasses.prefix,this.cssClasses.cursor)).first()},_setCursor:function(e,t){e.first().addClass(r.className(this.cssClasses.prefix,this.cssClasses.cursor,!0)).attr("aria-selected","true"),this.trigger("cursorMoved",t)},_removeCursor:function(){this._getCursor().removeClass(r.className(this.cssClasses.prefix,this.cssClasses.cursor,!0)).removeAttr("aria-selected")},_moveCursor:function(e){var t,n,r,i;this.isOpen&&(n=this._getCursor(),t=this._getSuggestions(),this._removeCursor(),-1!==(r=((r=t.index(n)+e)+1)%(t.length+1)-1)?(r<-1&&(r=t.length-1),this._setCursor(i=t.eq(r),!0),this._ensureVisible(i)):this.trigger("cursorRemoved"))},_ensureVisible:function(e){var t,n,r,i;n=(t=e.position().top)+e.height()+parseInt(e.css("margin-top"),10)+parseInt(e.css("margin-bottom"),10),r=this.$menu.scrollTop(),i=this.$menu.height()+parseInt(this.$menu.css("padding-top"),10)+parseInt(this.$menu.css("padding-bottom"),10),t<0?this.$menu.scrollTop(r+t):i<n&&this.$menu.scrollTop(r+(n-i))},close:function(){this.isOpen&&(this.isOpen=!1,this._removeCursor(),this._hide(),this.trigger("closed"))},open:function(){this.isOpen||(this.isOpen=!0,this.isEmpty||this._show(),this.trigger("opened"))},setLanguageDirection:function(e){this.$menu.css("ltr"===e?this.css.ltr:this.css.rtl)},moveCursorUp:function(){this._moveCursor(-1)},moveCursorDown:function(){this._moveCursor(1)},getDatumForSuggestion:function(e){var t=null;return e.length&&(t={raw:o.extractDatum(e),value:o.extractValue(e),datasetName:o.extractDatasetName(e)}),t},getCurrentCursor:function(){return this._getCursor().first()},getDatumForCursor:function(){return this.getDatumForSuggestion(this._getCursor().first())},getDatumForTopSuggestion:function(){return this.getDatumForSuggestion(this._getSuggestions().first())},cursorTopSuggestion:function(){this._setCursor(this._getSuggestions().first(),!1)},update:function(e){r.each(this.datasets,function(t){t.update(e)})},empty:function(){r.each(this.datasets,function(e){e.clear()}),this.isEmpty=!0},isVisible:function(){return this.isOpen&&!this.isEmpty},destroy:function(){this.$menu.off(".aa"),this.$menu=null,r.each(this.datasets,function(e){e.destroy()})}}),c.Dataset=o,e.exports=c},function(e,t,n){"use strict";var r="aaDataset",i="aaValue",s="aaDatum",o=n(0),a=n(1),c=n(17),u=n(11),l=n(10);function h(e){(e=e||{}).templates=e.templates||{},e.source||o.error("missing source"),e.name&&!function(e){return/^[_a-zA-Z0-9-]+$/.test(e)}(e.name)&&o.error("invalid dataset name: "+e.name),this.query=null,this._isEmpty=!0,this.highlight=!!e.highlight,this.name=void 0===e.name||null===e.name?o.getUniqueId():e.name,this.source=e.source,this.displayFn=function(e){return e=e||"value",o.isFunction(e)?e:function(t){return t[e]}}(e.display||e.displayKey),this.debounce=e.debounce,this.cache=!1!==e.cache,this.templates=function(e,t){return{empty:e.empty&&o.templatify(e.empty),header:e.header&&o.templatify(e.header),footer:e.footer&&o.templatify(e.footer),suggestion:e.suggestion||function(e){return"<p>"+t(e)+"</p>"}}}(e.templates,this.displayFn),this.css=o.mixin({},u,e.appendTo?u.appendTo:{}),this.cssClasses=e.cssClasses=o.mixin({},u.defaultClasses,e.cssClasses||{}),this.cssClasses.prefix=e.cssClasses.formattedPrefix||o.formatPrefix(this.cssClasses.prefix,this.cssClasses.noPrefix);var t=o.className(this.cssClasses.prefix,this.cssClasses.dataset);this.$el=e.$menu&&e.$menu.find(t+"-"+this.name).length>0?a.element(e.$menu.find(t+"-"+this.name)[0]):a.element(c.dataset.replace("%CLASS%",this.name).replace("%PREFIX%",this.cssClasses.prefix).replace("%DATASET%",this.cssClasses.dataset)),this.$menu=e.$menu,this.clearCachedSuggestions()}h.extractDatasetName=function(e){return a.element(e).data(r)},h.extractValue=function(e){return a.element(e).data(i)},h.extractDatum=function(e){var t=a.element(e).data(s);return"string"==typeof t&&(t=JSON.parse(t)),t},o.mixin(h.prototype,l,{_render:function(e,t){if(this.$el){var n,u=this,l=[].slice.call(arguments,2);if(this.$el.empty(),n=t&&t.length,this._isEmpty=!n,!n&&this.templates.empty)this.$el.html(function(){var t=[].slice.call(arguments,0);return t=[{query:e,isEmpty:!0}].concat(t),u.templates.empty.apply(this,t)}.apply(this,l)).prepend(u.templates.header?h.apply(this,l):null).append(u.templates.footer?d.apply(this,l):null);else if(n)this.$el.html(function(){var e,n,l=[].slice.call(arguments,0),h=this,d=c.suggestions.replace("%PREFIX%",this.cssClasses.prefix).replace("%SUGGESTIONS%",this.cssClasses.suggestions);return e=a.element(d).css(this.css.suggestions),n=o.map(t,function(e){var t,n=c.suggestion.replace("%PREFIX%",h.cssClasses.prefix).replace("%SUGGESTION%",h.cssClasses.suggestion);return(t=a.element(n).attr({role:"option",id:["option",Math.floor(1e8*Math.random())].join("-")}).append(u.templates.suggestion.apply(this,[e].concat(l)))).data(r,u.name),t.data(i,u.displayFn(e)||void 0),t.data(s,JSON.stringify(e)),t.children().each(function(){a.element(this).css(h.css.suggestionChild)}),t}),e.append.apply(e,n),e}.apply(this,l)).prepend(u.templates.header?h.apply(this,l):null).append(u.templates.footer?d.apply(this,l):null);else if(t&&!Array.isArray(t))throw new TypeError("suggestions must be an array");this.$menu&&this.$menu.addClass(this.cssClasses.prefix+(n?"with":"without")+"-"+this.name).removeClass(this.cssClasses.prefix+(n?"without":"with")+"-"+this.name),this.trigger("rendered",e)}function h(){var t=[].slice.call(arguments,0);return t=[{query:e,isEmpty:!n}].concat(t),u.templates.header.apply(this,t)}function d(){var t=[].slice.call(arguments,0);return t=[{query:e,isEmpty:!n}].concat(t),u.templates.footer.apply(this,t)}},getRoot:function(){return this.$el},update:function(e){function t(t){if(!this.canceled&&e===this.query){var n=[].slice.call(arguments,1);this.cacheSuggestions(e,t,n),this._render.apply(this,[e,t].concat(n))}}if(this.query=e,this.canceled=!1,this.shouldFetchFromCache(e))t.apply(this,[this.cachedSuggestions].concat(this.cachedRenderExtraArgs));else{var n=this,r=function(){n.canceled||n.source(e,t.bind(n))};if(this.debounce){clearTimeout(this.debounceTimeout),this.debounceTimeout=setTimeout(function(){n.debounceTimeout=null,r()},this.debounce)}else r()}},cacheSuggestions:function(e,t,n){this.cachedQuery=e,this.cachedSuggestions=t,this.cachedRenderExtraArgs=n},shouldFetchFromCache:function(e){return this.cache&&this.cachedQuery===e&&this.cachedSuggestions&&this.cachedSuggestions.length},clearCachedSuggestions:function(){delete this.cachedQuery,delete this.cachedSuggestions,delete this.cachedRenderExtraArgs},cancel:function(){this.canceled=!0},clear:function(){this.cancel(),this.$el.empty(),this.trigger("rendered","")},isEmpty:function(){return this._isEmpty},destroy:function(){this.clearCachedSuggestions(),this.$el=null}}),e.exports=h},function(e,t,n){"use strict";e.exports={hits:n(62),popularIn:n(63)}},function(e,t,n){"use strict";var r=n(0),i=n(18),s=n(19);e.exports=function(e,t){var n=s(e.as._ua);return n&&n[0]>=3&&n[1]>20&&((t=t||{}).additionalUA="autocomplete.js "+i),function(n,i){e.search(n,t,function(e,t){e?r.error(e.message):i(t.hits,t)})}}},function(e,t,n){"use strict";var r=n(0),i=n(18),s=n(19);e.exports=function(e,t,n,o){var a=s(e.as._ua);if(a&&a[0]>=3&&a[1]>20&&((t=t||{}).additionalUA="autocomplete.js "+i),!n.source)return r.error("Missing 'source' key");var c=r.isFunction(n.source)?n.source:function(e){return e[n.source]};if(!n.index)return r.error("Missing 'index' key");var u=n.index;return o=o||{},function(a,l){e.search(a,t,function(e,a){if(e)r.error(e.message);else{if(a.hits.length>0){var h=a.hits[0],d=r.mixin({hitsPerPage:0},n);delete d.source,delete d.index;var f=s(u.as._ua);return f&&f[0]>=3&&f[1]>20&&(t.additionalUA="autocomplete.js "+i),void u.search(c(h),d,function(e,t){if(e)r.error(e.message);else{var n=[];if(o.includeAll){var i=o.allTitle||"All departments";n.push(r.mixin({facet:{value:i,count:t.nbHits}},r.cloneDeep(h)))}r.each(t.facets,function(e,t){r.each(e,function(e,i){n.push(r.mixin({facet:{facet:t,value:i,count:e}},r.cloneDeep(h)))})});for(var s=1;s<a.hits.length;++s)n.push(a.hits[s]);l(n,a)}})}l([])}})}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r="algolia-docsearch-suggestion",i={suggestion:'\n <a class="'+r+"\n {{#isCategoryHeader}}"+r+"__main{{/isCategoryHeader}}\n {{#isSubCategoryHeader}}"+r+'__secondary{{/isSubCategoryHeader}}\n "\n aria-label="Link to the result"\n href="{{{url}}}"\n >\n <div class="'+r+'--category-header">\n <span class="'+r+'--category-header-lvl0">{{{category}}}</span>\n </div>\n <div class="'+r+'--wrapper">\n <div class="'+r+'--subcategory-column">\n <span class="'+r+'--subcategory-column-text">{{{subcategory}}}</span>\n </div>\n {{#isTextOrSubcategoryNonEmpty}}\n <div class="'+r+'--content">\n <div class="'+r+'--subcategory-inline">{{{subcategory}}}</div>\n <div class="'+r+'--title">{{{title}}}</div>\n {{#text}}<div class="'+r+'--text">{{{text}}}</div>{{/text}}\n </div>\n {{/isTextOrSubcategoryNonEmpty}}\n </div>\n </a>\n ',suggestionSimple:'\n <div class="'+r+"\n {{#isCategoryHeader}}"+r+"__main{{/isCategoryHeader}}\n {{#isSubCategoryHeader}}"+r+'__secondary{{/isSubCategoryHeader}}\n suggestion-layout-simple\n ">\n <div class="'+r+'--category-header">\n {{^isLvl0}}\n <span class="'+r+"--category-header-lvl0 "+r+'--category-header-item">{{{category}}}</span>\n {{^isLvl1}}\n {{^isLvl1EmptyOrDuplicate}}\n <span class="'+r+"--category-header-lvl1 "+r+'--category-header-item">\n {{{subcategory}}}\n </span>\n {{/isLvl1EmptyOrDuplicate}}\n {{/isLvl1}}\n {{/isLvl0}}\n <div class="'+r+"--title "+r+'--category-header-item">\n {{#isLvl2}}\n {{{title}}}\n {{/isLvl2}}\n {{#isLvl1}}\n {{{subcategory}}}\n {{/isLvl1}}\n {{#isLvl0}}\n {{{category}}}\n {{/isLvl0}}\n </div>\n </div>\n <div class="'+r+'--wrapper">\n {{#text}}\n <div class="'+r+'--content">\n <div class="'+r+'--text">{{{text}}}</div>\n </div>\n {{/text}}\n </div>\n </div>\n ',footer:'\n <div class="algolia-docsearch-footer">\n Search by <a class="algolia-docsearch-footer--logo" href="https://www.algolia.com/docsearch">Algolia</a>\n </div>\n ',empty:'\n <div class="'+r+'">\n <div class="'+r+'--wrapper">\n <div class="'+r+"--content "+r+'--no-results">\n <div class="'+r+'--title">\n <div class="'+r+'--text">\n No results found for query <b>"{{query}}"</b>\n </div>\n </div>\n </div>\n </div>\n </div>\n ',searchBox:'\n <form novalidate="novalidate" onsubmit="return false;" class="searchbox">\n <div role="search" class="searchbox__wrapper">\n <input id="docsearch" type="search" name="search" placeholder="Search the docs" autocomplete="off" required="required" class="searchbox__input"/>\n <button type="submit" title="Submit your search query." class="searchbox__submit" >\n <svg width=12 height=12 role="img" aria-label="Search">\n <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-search-13"></use>\n </svg>\n </button>\n <button type="reset" title="Clear the search query." class="searchbox__reset hide">\n <svg width=12 height=12 role="img" aria-label="Reset">\n <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-clear-3"></use>\n </svg>\n </button>\n </div>\n</form>\n\n<div class="svg-icons" style="height: 0; width: 0; position: absolute; visibility: hidden">\n <svg xmlns="http://www.w3.org/2000/svg">\n <symbol id="sbx-icon-clear-3" viewBox="0 0 40 40"><path d="M16.228 20L1.886 5.657 0 3.772 3.772 0l1.885 1.886L20 16.228 34.343 1.886 36.228 0 40 3.772l-1.886 1.885L23.772 20l14.342 14.343L40 36.228 36.228 40l-1.885-1.886L20 23.772 5.657 38.114 3.772 40 0 36.228l1.886-1.885L16.228 20z" fill-rule="evenodd"></symbol>\n <symbol id="sbx-icon-search-13" viewBox="0 0 40 40"><path d="M26.806 29.012a16.312 16.312 0 0 1-10.427 3.746C7.332 32.758 0 25.425 0 16.378 0 7.334 7.333 0 16.38 0c9.045 0 16.378 7.333 16.378 16.38 0 3.96-1.406 7.593-3.746 10.426L39.547 37.34c.607.608.61 1.59-.004 2.203a1.56 1.56 0 0 1-2.202.004L26.807 29.012zm-10.427.627c7.322 0 13.26-5.938 13.26-13.26 0-7.324-5.938-13.26-13.26-13.26-7.324 0-13.26 5.936-13.26 13.26 0 7.322 5.936 13.26 13.26 13.26z" fill-rule="evenodd"></symbol>\n </svg>\n</div>\n '};t.default=i},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(e){return e&&e.__esModule?e:{default:e}}(n(20));var s={mergeKeyWithParent:function(e,t){if(void 0===e[t])return e;if("object"!==r(e[t]))return e;var n=i.default.extend({},e,e[t]);return delete n[t],n},groupBy:function(e,t){var n={};return i.default.each(e,function(e,r){if(void 0===r[t])throw new Error("[groupBy]: Object has no key "+t);var i=r[t];"string"==typeof i&&(i=i.toLowerCase()),Object.prototype.hasOwnProperty.call(n,i)||(n[i]=[]),n[i].push(r)}),n},values:function(e){return Object.keys(e).map(function(t){return e[t]})},flatten:function(e){var t=[];return e.forEach(function(e){Array.isArray(e)?e.forEach(function(e){t.push(e)}):t.push(e)}),t},flattenAndFlagFirst:function(e,t){var n=this.values(e).map(function(e){return e.map(function(e,n){return e[t]=0===n,e})});return this.flatten(n)},compact:function(e){var t=[];return e.forEach(function(e){e&&t.push(e)}),t},getHighlightedValue:function(e,t){return e._highlightResult&&e._highlightResult.hierarchy_camel&&e._highlightResult.hierarchy_camel[t]&&e._highlightResult.hierarchy_camel[t].matchLevel&&"none"!==e._highlightResult.hierarchy_camel[t].matchLevel&&e._highlightResult.hierarchy_camel[t].value?e._highlightResult.hierarchy_camel[t].value:e._highlightResult&&e._highlightResult&&e._highlightResult[t]&&e._highlightResult[t].value?e._highlightResult[t].value:e[t]},getSnippetedValue:function(e,t){if(!e._snippetResult||!e._snippetResult[t]||!e._snippetResult[t].value)return e[t];var n=e._snippetResult[t].value;return n[0]!==n[0].toUpperCase()&&(n="…"+n),-1===[".","!","?"].indexOf(n[n.length-1])&&(n+="…"),n},deepClone:function(e){return JSON.parse(JSON.stringify(e))}};t.default=s}])})},function(e,t,n){var r=n(11);r.registerLanguage("bash",n(12)),r.registerLanguage("css",n(13)),r.registerLanguage("markdown",n(14)),r.registerLanguage("diff",n(15)),r.registerLanguage("javascript",n(16)),r.registerLanguage("json",n(17)),r.registerLanguage("yaml",n(18)),r.registerLanguage("xml",n(19)),r.registerLanguage("html",n(20)),r.registerLanguage("go",function(e){var t={keyword:"code output note warning break default func interface select case map struct chan else goto package switch const fallthrough if range end type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune id autoplay Get",literal:"file download copy true false iota nil Pages with",built_in:"append cap close complex highlight copy imag len make new panic print println real recover delete Site Data tweet youtube ref relref vimeo instagram gist figure innershortcode"};return{aliases:["golang","hugo"],k:t,i:"</",c:[e.CLCM,e.CBCM,{cN:"string",v:[e.QSM,{b:"'",e:"[^\\\\]'"},{b:"`",e:"`"}]},{cN:"number",v:[{b:e.CNR+"[dflsi]",r:1},e.CNM]},{b:/:=/},{cN:"function",bK:"func",e:/\s*\{/,eE:!0,c:[e.TM,{cN:"params",b:/\(/,e:/\)/,k:t,i:/["']/}]}]}}),r.initHighlightingOnLoad()},function(e,t,n){!function(e){"object"==typeof window&&window||"object"==typeof self&&self;(function(e){var t=[],n=Object.keys,r={},i={},s=/^(no-?highlight|plain|text)$/i,o=/\blang(?:uage)?-([\w-]+)\b/i,a=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,c="</span>",u={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};function l(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function h(e){return e.nodeName.toLowerCase()}function d(e,t){var n=e&&e.exec(t);return n&&0===n.index}function f(e){return s.test(e)}function p(e){var t,n={},r=Array.prototype.slice.call(arguments,1);for(t in e)n[t]=e[t];return r.forEach(function(e){for(t in e)n[t]=e[t]}),n}function g(e){var t=[];return function e(n,r){for(var i=n.firstChild;i;i=i.nextSibling)3===i.nodeType?r+=i.nodeValue.length:1===i.nodeType&&(t.push({event:"start",offset:r,node:i}),r=e(i,r),h(i).match(/br|hr|img|input/)||t.push({event:"stop",offset:r,node:i}));return r}(e,0),t}function m(e){function t(e){return e&&e.source||e}function r(n,r){return new RegExp(t(n),"m"+(e.case_insensitive?"i":"")+(r?"g":""))}!function i(s,o){if(s.compiled)return;s.compiled=!0;s.keywords=s.keywords||s.beginKeywords;if(s.keywords){var a={},c=function(t,n){e.case_insensitive&&(n=n.toLowerCase()),n.split(" ").forEach(function(e){var n=e.split("|");a[n[0]]=[t,n[1]?Number(n[1]):1]})};"string"==typeof s.keywords?c("keyword",s.keywords):n(s.keywords).forEach(function(e){c(e,s.keywords[e])}),s.keywords=a}s.lexemesRe=r(s.lexemes||/\w+/,!0);o&&(s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")\\b"),s.begin||(s.begin=/\B|\b/),s.beginRe=r(s.begin),s.endSameAsBegin&&(s.end=s.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(s.endRe=r(s.end)),s.terminator_end=t(s.end)||"",s.endsWithParent&&o.terminator_end&&(s.terminator_end+=(s.end?"|":"")+o.terminator_end));s.illegal&&(s.illegalRe=r(s.illegal));null==s.relevance&&(s.relevance=1);s.contains||(s.contains=[]);s.contains=Array.prototype.concat.apply([],s.contains.map(function(e){return function(e){e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map(function(t){return p(e,{variants:null},t)}));return e.cached_variants||e.endsWithParent&&[p(e)]||[e]}("self"===e?s:e)}));s.contains.forEach(function(e){i(e,s)});s.starts&&i(s.starts,o);var u=s.contains.map(function(e){return e.beginKeywords?"\\.?("+e.begin+")\\.?":e.begin}).concat([s.terminator_end,s.illegal]).map(t).filter(Boolean);s.terminators=u.length?r(u.join("|"),!0):{exec:function(){return null}}}(e)}function v(e,t,n,i){function s(e){return new RegExp(e.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}function o(e,t){var n=g.case_insensitive?t[0].toLowerCase():t[0];return e.keywords.hasOwnProperty(n)&&e.keywords[n]}function a(e,t,n,r){var i=r?"":u.classPrefix,s='<span class="'+i,o=n?"":c;return(s+=e+'">')+t+o}function h(){x+=null!=w.subLanguage?function(){var e="string"==typeof w.subLanguage;if(e&&!r[w.subLanguage])return l(S);var t=e?v(w.subLanguage,S,!0,_[w.subLanguage]):y(S,w.subLanguage.length?w.subLanguage:void 0);w.relevance>0&&(C+=t.relevance);e&&(_[w.subLanguage]=t.top);return a(t.language,t.value,!1,!0)}():function(){var e,t,n,r;if(!w.keywords)return l(S);r="",t=0,w.lexemesRe.lastIndex=0,n=w.lexemesRe.exec(S);for(;n;)r+=l(S.substring(t,n.index)),(e=o(w,n))?(C+=e[1],r+=a(e[0],l(n[0]))):r+=l(n[0]),t=w.lexemesRe.lastIndex,n=w.lexemesRe.exec(S);return r+l(S.substr(t))}(),S=""}function f(e){x+=e.className?a(e.className,"",!0):"",w=Object.create(e,{parent:{value:w}})}function p(e,t){if(S+=e,null==t)return h(),0;var r=function(e,t){var n,r;for(n=0,r=t.contains.length;n<r;n++)if(d(t.contains[n].beginRe,e))return t.contains[n].endSameAsBegin&&(t.contains[n].endRe=s(t.contains[n].beginRe.exec(e)[0])),t.contains[n]}(t,w);if(r)return r.skip?S+=t:(r.excludeBegin&&(S+=t),h(),r.returnBegin||r.excludeBegin||(S=t)),f(r),r.returnBegin?0:t.length;var i=function e(t,n){if(d(t.endRe,n)){for(;t.endsParent&&t.parent;)t=t.parent;return t}if(t.endsWithParent)return e(t.parent,n)}(w,t);if(i){var o=w;o.skip?S+=t:(o.returnEnd||o.excludeEnd||(S+=t),h(),o.excludeEnd&&(S=t));do{w.className&&(x+=c),w.skip||w.subLanguage||(C+=w.relevance),w=w.parent}while(w!==i.parent);return i.starts&&(i.endSameAsBegin&&(i.starts.endRe=i.endRe),f(i.starts)),o.returnEnd?0:t.length}if(function(e,t){return!n&&d(t.illegalRe,e)}(t,w))throw new Error('Illegal lexeme "'+t+'" for mode "'+(w.className||"<unnamed>")+'"');return S+=t,t.length||1}var g=E(e);if(!g)throw new Error('Unknown language: "'+e+'"');m(g);var b,w=i||g,_={},x="";for(b=w;b!==g;b=b.parent)b.className&&(x=a(b.className,"",!0)+x);var S="",C=0;try{for(var A,N,O=0;w.terminators.lastIndex=O,A=w.terminators.exec(t);)N=p(t.substring(O,A.index),A[0]),O=A.index+N;for(p(t.substr(O)),b=w;b.parent;b=b.parent)b.className&&(x+=c);return{relevance:C,value:x,language:e,top:w}}catch(e){if(e.message&&-1!==e.message.indexOf("Illegal"))return{relevance:0,value:l(t)};throw e}}function y(e,t){t=t||u.languages||n(r);var i={relevance:0,value:l(e)},s=i;return t.filter(E).filter(x).forEach(function(t){var n=v(t,e,!1);n.language=t,n.relevance>s.relevance&&(s=n),n.relevance>i.relevance&&(s=i,i=n)}),s.language&&(i.second_best=s),i}function b(e){return u.tabReplace||u.useBR?e.replace(a,function(e,t){return u.useBR&&"\n"===e?"<br>":u.tabReplace?t.replace(/\t/g,u.tabReplace):""}):e}function w(e){var n,r,s,a,c,d=function(e){var t,n,r,i,s=e.className+" ";if(s+=e.parentNode?e.parentNode.className:"",n=o.exec(s))return E(n[1])?n[1]:"no-highlight";for(s=s.split(/\s+/),t=0,r=s.length;t<r;t++)if(f(i=s[t])||E(i))return i}(e);f(d)||(u.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div")).innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n"):n=e,c=n.textContent,s=d?v(d,c,!0):y(c),(r=g(n)).length&&((a=document.createElementNS("http://www.w3.org/1999/xhtml","div")).innerHTML=s.value,s.value=function(e,n,r){var i=0,s="",o=[];function a(){return e.length&&n.length?e[0].offset!==n[0].offset?e[0].offset<n[0].offset?e:n:"start"===n[0].event?e:n:e.length?e:n}function c(e){s+="<"+h(e)+t.map.call(e.attributes,function(e){return" "+e.nodeName+'="'+l(e.value).replace('"',"&quot;")+'"'}).join("")+">"}function u(e){s+="</"+h(e)+">"}function d(e){("start"===e.event?c:u)(e.node)}for(;e.length||n.length;){var f=a();if(s+=l(r.substring(i,f[0].offset)),i=f[0].offset,f===e){o.reverse().forEach(u);do{d(f.splice(0,1)[0]),f=a()}while(f===e&&f.length&&f[0].offset===i);o.reverse().forEach(c)}else"start"===f[0].event?o.push(f[0].node):o.pop(),d(f.splice(0,1)[0])}return s+l(r.substr(i))}(r,g(a),c)),s.value=b(s.value),e.innerHTML=s.value,e.className=function(e,t,n){var r=t?i[t]:n,s=[e.trim()];e.match(/\bhljs\b/)||s.push("hljs");-1===e.indexOf(r)&&s.push(r);return s.join(" ").trim()}(e.className,d,s.language),e.result={language:s.language,re:s.relevance},s.second_best&&(e.second_best={language:s.second_best.language,re:s.second_best.relevance}))}function _(){if(!_.called){_.called=!0;var e=document.querySelectorAll("pre code");t.forEach.call(e,w)}}function E(e){return e=(e||"").toLowerCase(),r[e]||r[i[e]]}function x(e){var t=E(e);return t&&!t.disableAutodetect}e.highlight=v,e.highlightAuto=y,e.fixMarkup=b,e.highlightBlock=w,e.configure=function(e){u=p(u,e)},e.initHighlighting=_,e.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",_,!1),addEventListener("load",_,!1)},e.registerLanguage=function(t,n){var s=r[t]=n(e);s.aliases&&s.aliases.forEach(function(e){i[e]=t})},e.listLanguages=function(){return n(r)},e.getLanguage=E,e.autoDetection=x,e.inherit=p,e.IDENT_RE="[a-zA-Z]\\w*",e.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*",e.NUMBER_RE="\\b\\d+(\\.\\d+)?",e.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BINARY_NUMBER_RE="\\b(0b[01]+)",e.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BACKSLASH_ESCAPE={begin:"\\\\[\\s\\S]",relevance:0},e.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},e.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},e.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},e.COMMENT=function(t,n,r){var i=e.inherit({className:"comment",begin:t,end:n,contains:[]},r||{});return i.contains.push(e.PHRASAL_WORDS_MODE),i.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0}),i},e.C_LINE_COMMENT_MODE=e.COMMENT("//","$"),e.C_BLOCK_COMMENT_MODE=e.COMMENT("/\\*","\\*/"),e.HASH_COMMENT_MODE=e.COMMENT("#","$"),e.NUMBER_MODE={className:"number",begin:e.NUMBER_RE,relevance:0},e.C_NUMBER_MODE={className:"number",begin:e.C_NUMBER_RE,relevance:0},e.BINARY_NUMBER_MODE={className:"number",begin:e.BINARY_NUMBER_RE,relevance:0},e.CSS_NUMBER_MODE={className:"number",begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},e.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[e.BACKSLASH_ESCAPE]}]},e.TITLE_MODE={className:"title",begin:e.IDENT_RE,relevance:0},e.UNDERSCORE_TITLE_MODE={className:"title",begin:e.UNDERSCORE_IDENT_RE,relevance:0},e.METHOD_GUARD={begin:"\\.\\s*"+e.UNDERSCORE_IDENT_RE,relevance:0}})(t)}()},function(e,t){e.exports=function(e){var t={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},n={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,t,{className:"variable",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},e.HASH_COMMENT_MODE,n,{className:"string",begin:/'/,end:/'/},t]}}},function(e,t){e.exports=function(e){var t={begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}]},e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]};return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[e.C_BLOCK_COMMENT_MODE,t]}]}}},function(e,t){e.exports=function(e){return{aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$"},{begin:"^.+?\\n[=-]{2,}$"}]},{begin:"<",end:">",subLanguage:"xml",relevance:0},{className:"bullet",begin:"^([*+-]|(\\d+\\.))\\s+"},{className:"strong",begin:"[*_]{2}.+?[*_]{2}"},{className:"emphasis",variants:[{begin:"\\*.+?\\*"},{begin:"_.+?_",relevance:0}]},{className:"quote",begin:"^>\\s+",end:"$"},{className:"code",variants:[{begin:"^```w*s*$",end:"^```s*$"},{begin:"`.+?`"},{begin:"^( {4}|\t)",end:"$",relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},{begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}},function(e,t){e.exports=function(e){return{aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{begin:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{begin:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{className:"comment",variants:[{begin:/Index: /,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^\-{3}/,end:/$/},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/\*{5}/,end:/\*{5}$/}]},{className:"addition",begin:"^\\+",end:"$"},{className:"deletion",begin:"^\\-",end:"$"},{className:"addition",begin:"^\\!",end:"$"}]}}},function(e,t){e.exports=function(e){var t="[A-Za-z$_][0-9A-Za-z$_]*",n={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},r={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},i={className:"subst",begin:"\\$\\{",end:"\\}",keywords:n,contains:[]},s={className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,i]};i.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,r,e.REGEXP_MODE];var o=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:n,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},{className:"meta",begin:/^#!/,end:/$/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r,{begin:/[{,]\s*/,relevance:0,contains:[{begin:t+"\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:t,relevance:0}]}]},{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+t+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,contains:o}]}]},{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:[{begin:/<\w+\s*\/>/,skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:t}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{begin:/\$[(.]/},e.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}}},function(e,t){e.exports=function(e){var t={literal:"true false null"},n=[e.QUOTE_STRING_MODE,e.C_NUMBER_MODE],r={end:",",endsWithParent:!0,excludeEnd:!0,contains:n,keywords:t},i={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE],illegal:"\\n"},e.inherit(r,{begin:/:/})],illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[e.inherit(r)],illegal:"\\S"};return n.splice(n.length,0,i,s),{contains:n,keywords:t,illegal:"\\S"}}},function(e,t){e.exports=function(e){var t="[a-zA-Z_][\\w\\-]*",n={className:"attr",variants:[{begin:"^[ \\-]*"+t+":"},{begin:'^[ \\-]*"'+t+'":'},{begin:"^[ \\-]*'"+t+"':"}]},r={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[n,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:r.contains,end:n.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!"+e.UNDERSCORE_IDENT_RE},{className:"type",begin:"!!"+e.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:"true false yes no null",keywords:{literal:"true false yes no null"}},e.C_NUMBER_MODE,r]}}},function(e,t){e.exports=function(e){var t={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist"],case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[t],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[t],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},t]}]}}},function(e,t){e.exports=function(e){var t={"builtin-name":"each in with if else unless bindattr action collection debugger log outlet template unbound view yield"};return{aliases:["hbs","html.hbs","html.handlebars"],case_insensitive:!0,subLanguage:"xml",contains:[e.COMMENT("{{!(--)?","(--)?}}"),{className:"template-tag",begin:/\{\{[#\/]/,end:/\}\}/,contains:[{className:"name",begin:/[a-zA-Z\.-]+/,keywords:t,starts:{endsWithParent:!0,relevance:0,contains:[e.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{\{/,end:/\}\}/,keywords:t}]}}},function(e,t,n){n(0),n(22)},function(e,t,n){!function(t,r){var i=function(){r(t.lazySizes),t.removeEventListener("lazyunveilread",i,!0)};r=r.bind(null,t,t.document),e.exports?r(n(0)):t.lazySizes?i():t.addEventListener("lazyunveilread",i,!0)}(window,function(e,t,n){"use strict";var r,i,s={};function o(e,n){if(!s[e]){var r=t.createElement(n?"link":"script"),i=t.getElementsByTagName("script")[0];n?(r.rel="stylesheet",r.href=e):r.src=e,s[e]=!0,s[r.src||r.href]=!0,i.parentNode.insertBefore(r,i)}}t.addEventListener&&(i=/\(|\)|\s|'/,r=function(e,n){var r=t.createElement("img");r.onload=function(){r.onload=null,r.onerror=null,r=null,n()},r.onerror=r.onload,r.src=e,r&&r.complete&&r.onload&&r.onload()},addEventListener("lazybeforeunveil",function(e){var t,s,a;e.detail.instance==n&&(e.defaultPrevented||("none"==e.target.preload&&(e.target.preload="auto"),(t=e.target.getAttribute("data-link"))&&o(t,!0),(t=e.target.getAttribute("data-script"))&&o(t),(t=e.target.getAttribute("data-require"))&&(n.cfg.requireJs?n.cfg.requireJs([t]):o(t)),(s=e.target.getAttribute("data-bg"))&&(e.detail.firesLoad=!0,r(s,function(){e.target.style.backgroundImage="url("+(i.test(s)?JSON.stringify(s):s)+")",e.detail.firesLoad=!1,n.fire(e.target,"_lazyloaded",{},!0,!0)})),(a=e.target.getAttribute("data-poster"))&&(e.detail.firesLoad=!0,r(a,function(){e.target.poster=a,e.detail.firesLoad=!1,n.fire(e.target,"_lazyloaded",{},!0,!0)}))))},!1))})},function(e,t){for(var n=document.getElementsByClassName("js-toggle"),r=0;r<n.length;r++)n[r].addEventListener("click",i,!1);function i(){for(var e=this.dataset.target.split(" "),t=document.querySelector(".mobilemenu:not(.dn)"),n=document.querySelector(".desktopmenu:not(.dn)"),r=document.querySelector(".desktopmenu:not(.dn)"),i=0;i<e.length;i++){var s=document.querySelectorAll(e[i]);[].forEach.call(s,function(e){return e.classList.contains("dn")?e.classList.remove("dn"):e.classList.add("dn"),!1}),t&&t.classList.add("dn"),n&&n.classList.add("dn"),r&&r.classList.remove("db")}}},function(e,t,n){n(25)},function(e,t,n){!function(){"use strict";var e,t,n="data-scrolldir",r="down",i=document.documentElement,s=window,o=document.body,a=32,c=512,u=64,l=Array(a),h=0;function d(){var d=s.scrollY||s.pageYOffset,f=e.timeStamp,p="down"===r?Math.max:Math.min,g=o.scrollHeight-s.innerHeight;if(d=Math.max(0,d),d=Math.min(g,d),l.unshift({y:d,t:f}),l.pop(),d===p(t,d))return h=f,void(t=d);var m=f-c;if(h<m){t=d;for(var v=0;v<a&&l[v]&&!(l[v].t<m);v+=1)t=p(t,l[v].y)}Math.abs(d-t)>u&&(t=d,h=f,r="down"===r?"up":"down",i.setAttribute(n,r))}function f(t){return e=t,s.requestAnimationFrame(d)}t=s.scrollY||s.pageYOffset,i.setAttribute(n,r),s.addEventListener("scroll",f)}()},function(e,t){!function(){"use strict";if("querySelector"in document&&"addEventListener"in window&&Array.prototype.forEach){var e=document.querySelectorAll("#TableOfContents ul li a");[].forEach.call(e,function(e){e.addEventListener("click",function(t){t.preventDefault();var n=e.getAttribute("href"),r=document.querySelector(n),i=e.getAttribute("data-speed");r&&function(e,t){var n,r=window.pageYOffset,i=e.offsetTop,s=(i-r)/(t/16);n=s>=0?function(){var e=window.pageYOffset;(e>=i-s||window.innerHeight+e>=document.body.offsetHeight)&&clearInterval(o)}:function(){window.pageYOffset<=(i||0)&&clearInterval(o)};var o=setInterval(function(){window.scrollBy(0,s),n()},16)}(r,i||500)},!1)})}}()},function(e,t){var n,r=document.querySelectorAll("[data-toggle-tab]"),i=document.querySelectorAll("[data-pane]");function s(e){if(e.target){e.preventDefault();var t=e.currentTarget.getAttribute("data-toggle-tab")}else t=e;window.localStorage&&window.localStorage.setItem("configLangPref",t);for(var n=document.querySelectorAll("[data-toggle-tab='"+t+"']"),s=document.querySelectorAll("[data-pane='"+t+"']"),o=0;o<r.length;o++)r[o].classList.remove("active"),i[o].classList.remove("active");for(o=0;o<n.length;o++)n[o].classList.add("active"),s[o].classList.add("active")}for(n=0;n<r.length;n++)r[n].addEventListener("click",s);window.localStorage.getItem("configLangPref")&&s(window.localStorage.getItem("configLangPref"))},function(e,t){document.documentElement.className=document.documentElement.className.replace(/\bno-js\b/,"js")}]); \ No newline at end of file
diff --git a/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.json b/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.json
deleted file mode 100644
index 06787c13f..000000000
--- a/resources/_gen/assets/js/output/js/app.js_8848f55d07695b7ff7188138f23d69e3.json
+++ /dev/null
@@ -1 +0,0 @@
-{"Target":"output/js/app.09ca7921ca2b6e15c2dc516eccf642c08861fe5c249cc9073fb370c0a8a9022c.js","MediaType":"application/javascript","Data":{"Integrity":"sha256-Ccp5IcorbhXC3FFuzPZCwIhh/lwknMkHP7NwwKipAiw="}} \ No newline at end of file
diff --git a/resources/errorResource.go b/resources/errorResource.go
new file mode 100644
index 000000000..81375cc48
--- /dev/null
+++ b/resources/errorResource.go
@@ -0,0 +1,132 @@
+// Copyright 2021 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 (
+ "image"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/images"
+ "github.com/gohugoio/hugo/resources/images/exif"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+var (
+ _ error = (*errorResource)(nil)
+ // Imnage covers all current Resource implementations.
+ _ images.ImageResource = (*errorResource)(nil)
+ // The list of user facing and exported interfaces in resource.go
+ // Note that if we're missing some interface here, the user will still
+ // get an error, but not as pretty.
+ _ resource.ContentResource = (*errorResource)(nil)
+ _ resource.ReadSeekCloserResource = (*errorResource)(nil)
+ _ resource.ResourcesLanguageMerger = (*resource.Resources)(nil)
+ // Make sure it also fails when passed to a pipe function.
+ _ ResourceTransformer = (*errorResource)(nil)
+)
+
+// NewErrorResource wraps err in a Resource where all but the Err method will panic.
+func NewErrorResource(err resource.ResourceError) resource.Resource {
+ return &errorResource{ResourceError: err}
+}
+
+type errorResource struct {
+ resource.ResourceError
+}
+
+func (e *errorResource) Err() resource.ResourceError {
+ return e.ResourceError
+}
+
+func (e *errorResource) ReadSeekCloser() (hugio.ReadSeekCloser, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Content() (any, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) ResourceType() string {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) MediaType() media.Type {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Permalink() string {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) RelPermalink() string {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Name() string {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Title() string {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Params() maps.Params {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Data() any {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Height() int {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Width() int {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Crop(spec string) (images.ImageResource, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Fill(spec string) (images.ImageResource, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Fit(spec string) (images.ImageResource, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Resize(spec string) (images.ImageResource, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Filter(filters ...any) (images.ImageResource, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Exif() *exif.ExifInfo {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) DecodeImage() (image.Image, error) {
+ panic(e.ResourceError)
+}
+
+func (e *errorResource) Transform(...ResourceTransformation) (ResourceTransformer, error) {
+ panic(e.ResourceError)
+}
diff --git a/resources/image.go b/resources/image.go
new file mode 100644
index 000000000..8551cc2ab
--- /dev/null
+++ b/resources/image.go
@@ -0,0 +1,452 @@
+// 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 (
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/gif"
+ _ "image/gif"
+ _ "image/png"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/common/paths"
+
+ "github.com/disintegration/gift"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/resources/images/exif"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/resources/images"
+
+ // Blind import for image.Decode
+ _ "golang.org/x/image/webp"
+)
+
+var (
+ _ images.ImageResource = (*imageResource)(nil)
+ _ resource.Source = (*imageResource)(nil)
+ _ resource.Cloner = (*imageResource)(nil)
+)
+
+// imageResource represents an image resource.
+type imageResource struct {
+ *images.Image
+
+ // When a image is processed in a chain, this holds the reference to the
+ // original (first).
+ root *imageResource
+
+ metaInit sync.Once
+ metaInitErr error
+ meta *imageMeta
+
+ baseResource
+}
+
+type imageMeta struct {
+ Exif *exif.ExifInfo
+}
+
+func (i *imageResource) Exif() *exif.ExifInfo {
+ return i.root.getExif()
+}
+
+func (i *imageResource) getExif() *exif.ExifInfo {
+ i.metaInit.Do(func() {
+ supportsExif := i.Format == images.JPEG || i.Format == images.TIFF
+ if !supportsExif {
+ return
+ }
+
+ key := i.getImageMetaCacheTargetPath()
+
+ read := func(info filecache.ItemInfo, r io.ReadSeeker) error {
+ meta := &imageMeta{}
+ data, err := ioutil.ReadAll(r)
+ if err != nil {
+ return err
+ }
+
+ if err = json.Unmarshal(data, &meta); err != nil {
+ return err
+ }
+
+ i.meta = meta
+
+ return nil
+ }
+
+ create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) {
+ f, err := i.root.ReadSeekCloser()
+ if err != nil {
+ i.metaInitErr = err
+ return
+ }
+ defer f.Close()
+
+ x, err := i.getSpec().imaging.DecodeExif(f)
+ if err != nil {
+ i.getSpec().Logger.Warnf("Unable to decode Exif metadata from image: %s", i.Key())
+ return nil
+ }
+
+ i.meta = &imageMeta{Exif: x}
+
+ // Also write it to cache
+ enc := json.NewEncoder(w)
+ return enc.Encode(i.meta)
+ }
+
+ _, i.metaInitErr = i.getSpec().imageCache.fileCache.ReadOrCreate(key, read, create)
+ })
+
+ if i.metaInitErr != nil {
+ panic(fmt.Sprintf("metadata init failed: %s", i.metaInitErr))
+ }
+
+ if i.meta == nil {
+ return nil
+ }
+
+ return i.meta.Exif
+}
+
+// Clone is for internal use.
+func (i *imageResource) Clone() resource.Resource {
+ gr := i.baseResource.Clone().(baseResource)
+ return &imageResource{
+ root: i.root,
+ Image: i.WithSpec(gr),
+ baseResource: gr,
+ }
+}
+
+func (i *imageResource) cloneTo(targetPath string) resource.Resource {
+ gr := i.baseResource.cloneTo(targetPath).(baseResource)
+ return &imageResource{
+ root: i.root,
+ Image: i.WithSpec(gr),
+ baseResource: gr,
+ }
+}
+
+func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) {
+ base, err := i.baseResource.cloneWithUpdates(u)
+ if err != nil {
+ return nil, err
+ }
+
+ var img *images.Image
+
+ if u.isContentChanged() {
+ img = i.WithSpec(base)
+ } else {
+ img = i.Image
+ }
+
+ return &imageResource{
+ root: i.root,
+ Image: img,
+ baseResource: base,
+ }, nil
+}
+
+// Resize resizes the image to the specified width and height using the specified resampling
+// filter and returns the transformed image. If one of width or height is 0, the image aspect
+// ratio is preserved.
+func (i *imageResource) Resize(spec string) (images.ImageResource, error) {
+ conf, err := i.decodeImageConfig("resize", spec)
+ if err != nil {
+ return nil, err
+ }
+
+ return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.ApplyFiltersFromConfig(src, conf)
+ })
+}
+
+// Crop the image to the specified dimensions without resizing using the given anchor point.
+// Space delimited config, e.g. `200x300 TopLeft`.
+func (i *imageResource) Crop(spec string) (images.ImageResource, error) {
+ conf, err := i.decodeImageConfig("crop", spec)
+ if err != nil {
+ return nil, err
+ }
+
+ return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.ApplyFiltersFromConfig(src, conf)
+ })
+}
+
+// Fit scales down the image using the specified resample filter to fit the specified
+// maximum width and height.
+func (i *imageResource) Fit(spec string) (images.ImageResource, error) {
+ conf, err := i.decodeImageConfig("fit", spec)
+ if err != nil {
+ return nil, err
+ }
+
+ return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.ApplyFiltersFromConfig(src, conf)
+ })
+}
+
+// Fill scales the image to the smallest possible size that will cover the specified dimensions,
+// crops the resized image to the specified dimensions using the given anchor point.
+// Space delimited config, e.g. `200x300 TopLeft`.
+func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
+ conf, err := i.decodeImageConfig("fill", spec)
+ if err != nil {
+ return nil, err
+ }
+
+ img, err := i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.ApplyFiltersFromConfig(src, conf)
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if conf.Anchor == 0 && img.Width() == 0 || img.Height() == 0 {
+ // See https://github.com/gohugoio/hugo/issues/7955
+ // Smartcrop fails silently in some rare cases.
+ // Fall back to a center fill.
+ conf.Anchor = gift.CenterAnchor
+ conf.AnchorStr = "center"
+ return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.ApplyFiltersFromConfig(src, conf)
+ })
+ }
+
+ return img, err
+}
+
+func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
+ conf := images.GetDefaultImageConfig("filter", i.Proc.Cfg)
+
+ var gfilters []gift.Filter
+
+ for _, f := range filters {
+ gfilters = append(gfilters, images.ToFilters(f)...)
+ }
+
+ conf.Key = helpers.HashString(gfilters)
+ conf.TargetFormat = i.Format
+
+ return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+ return i.Proc.Filter(src, gfilters...)
+ })
+}
+
+// Serialize image processing. The imaging library spins up its own set of Go routines,
+// so there is not much to gain from adding more load to the mix. That
+// can even have negative effect in low resource scenarios.
+// Note that this only effects the non-cached scenario. Once the processed
+// image is written to disk, everything is fast, fast fast.
+const imageProcWorkers = 1
+
+var imageProcSem = make(chan bool, imageProcWorkers)
+
+func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src image.Image) (image.Image, error)) (images.ImageResource, error) {
+ img, err := i.getSpec().imageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) {
+ imageProcSem <- true
+ defer func() {
+ <-imageProcSem
+ }()
+
+ errOp := conf.Action
+ errPath := i.getSourceFilename()
+
+ src, err := i.DecodeImage()
+ if err != nil {
+ return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err}
+ }
+
+ converted, err := f(src)
+ if err != nil {
+ return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err}
+ }
+
+ hasAlpha := !images.IsOpaque(converted)
+ shouldFill := conf.BgColor != nil && hasAlpha
+ shouldFill = shouldFill || (!conf.TargetFormat.SupportsTransparency() && hasAlpha)
+ var bgColor color.Color
+
+ if shouldFill {
+ bgColor = conf.BgColor
+ if bgColor == nil {
+ bgColor = i.Proc.Cfg.BgColor
+ }
+ tmp := image.NewRGBA(converted.Bounds())
+ draw.Draw(tmp, tmp.Bounds(), image.NewUniform(bgColor), image.Point{}, draw.Src)
+ draw.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min, draw.Over)
+ converted = tmp
+ }
+
+ if conf.TargetFormat == images.PNG {
+ // Apply the colour palette from the source
+ if paletted, ok := src.(*image.Paletted); ok {
+ palette := paletted.Palette
+ if bgColor != nil && len(palette) < 256 {
+ palette = images.AddColorToPalette(bgColor, palette)
+ } else if bgColor != nil {
+ images.ReplaceColorInPalette(bgColor, palette)
+ }
+ tmp := image.NewPaletted(converted.Bounds(), palette)
+ draw.FloydSteinberg.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min)
+ converted = tmp
+ }
+ }
+
+ ci := i.clone(converted)
+ ci.setBasePath(conf)
+ ci.Format = conf.TargetFormat
+ ci.setMediaType(conf.TargetFormat.MediaType())
+
+ return ci, converted, nil
+ })
+ if err != nil {
+ if i.root != nil && i.root.getFileInfo() != nil {
+ return nil, fmt.Errorf("image %q: %w", i.root.getFileInfo().Meta().Filename, err)
+ }
+ }
+ return img, nil
+}
+
+func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) {
+ conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg, i.Format)
+ if err != nil {
+ return conf, err
+ }
+
+ return conf, nil
+}
+
+type giphy struct {
+ image.Image
+ gif *gif.GIF
+}
+
+func (g *giphy) GIF() *gif.GIF {
+ return g.gif
+}
+
+// DecodeImage decodes the image source into an Image.
+// This an internal method and may change.
+func (i *imageResource) DecodeImage() (image.Image, error) {
+ f, err := i.ReadSeekCloser()
+ if err != nil {
+ return nil, fmt.Errorf("failed to open image for decode: %w", err)
+ }
+ defer f.Close()
+
+ if i.Format == images.GIF {
+ g, err := gif.DecodeAll(f)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode gif: %w", err)
+ }
+ return &giphy{gif: g, Image: g.Image[0]}, nil
+ }
+ img, _, err := image.Decode(f)
+ return img, err
+}
+
+func (i *imageResource) clone(img image.Image) *imageResource {
+ spec := i.baseResource.Clone().(baseResource)
+
+ var image *images.Image
+ if img != nil {
+ image = i.WithImage(img)
+ } else {
+ image = i.WithSpec(spec)
+ }
+
+ return &imageResource{
+ Image: image,
+ root: i.root,
+ baseResource: spec,
+ }
+}
+
+func (i *imageResource) setBasePath(conf images.ImageConfig) {
+ i.getResourcePaths().relTargetDirFile = i.relTargetPathFromConfig(conf)
+}
+
+func (i *imageResource) getImageMetaCacheTargetPath() string {
+ const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache
+
+ cfgHash := i.getSpec().imaging.Cfg.CfgHash
+ df := i.getResourcePaths().relTargetDirFile
+ if fi := i.getFileInfo(); fi != nil {
+ df.dir = filepath.Dir(fi.Meta().Path)
+ }
+ p1, _ := paths.FileAndExt(df.file)
+ h, _ := i.hash()
+ idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfgHash)
+ p := path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr))
+ return p
+}
+
+func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile {
+ p1, p2 := paths.FileAndExt(i.getResourcePaths().relTargetDirFile.file)
+ if conf.TargetFormat != i.Format {
+ p2 = conf.TargetFormat.DefaultExtension()
+ }
+
+ h, _ := i.hash()
+ idStr := fmt.Sprintf("_hu%s_%d", h, i.size())
+
+ // Do not change for no good reason.
+ const md5Threshold = 100
+
+ key := conf.GetKey(i.Format)
+
+ // It is useful to have the key in clear text, but when nesting transforms, it
+ // can easily be too long to read, and maybe even too long
+ // for the different OSes to handle.
+ if len(p1)+len(idStr)+len(p2) > md5Threshold {
+ key = helpers.MD5String(p1 + key + p2)
+ huIdx := strings.Index(p1, "_hu")
+ if huIdx != -1 {
+ p1 = p1[:huIdx]
+ } else {
+ // This started out as a very long file name. Making it even longer
+ // could melt ice in the Arctic.
+ p1 = ""
+ }
+ } else if strings.Contains(p1, idStr) {
+ // On scaling an already scaled image, we get the file info from the original.
+ // Repeating the same info in the filename makes it stuttery for no good reason.
+ idStr = ""
+ }
+
+ return dirFile{
+ dir: i.getResourcePaths().relTargetDirFile.dir,
+ file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2),
+ }
+}
diff --git a/resources/image_cache.go b/resources/image_cache.go
new file mode 100644
index 000000000..ca651fd5c
--- /dev/null
+++ b/resources/image_cache.go
@@ -0,0 +1,168 @@
+// 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 (
+ "image"
+ "io"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/resources/images"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/helpers"
+)
+
+type imageCache struct {
+ pathSpec *helpers.PathSpec
+
+ fileCache *filecache.Cache
+
+ mu sync.RWMutex
+ store map[string]*resourceAdapter
+}
+
+func (c *imageCache) deleteIfContains(s string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ s = c.normalizeKeyBase(s)
+ for k := range c.store {
+ if strings.Contains(k, s) {
+ delete(c.store, k)
+ }
+ }
+}
+
+// The cache key is a lowercase path with Unix style slashes and it always starts with
+// a leading slash.
+func (c *imageCache) normalizeKey(key string) string {
+ return "/" + c.normalizeKeyBase(key)
+}
+
+func (c *imageCache) normalizeKeyBase(key string) string {
+ return strings.Trim(strings.ToLower(filepath.ToSlash(key)), "/")
+}
+
+func (c *imageCache) clear() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.store = make(map[string]*resourceAdapter)
+}
+
+func (c *imageCache) getOrCreate(
+ parent *imageResource, conf images.ImageConfig,
+ createImage func() (*imageResource, image.Image, error)) (*resourceAdapter, error) {
+ relTarget := parent.relTargetPathFromConfig(conf)
+ memKey := parent.relTargetPathForRel(relTarget.path(), false, false, false)
+ memKey = c.normalizeKey(memKey)
+
+ // For the file cache we want to generate and store it once if possible.
+ fileKeyPath := relTarget
+ if fi := parent.root.getFileInfo(); fi != nil {
+ fileKeyPath.dir = filepath.ToSlash(filepath.Dir(fi.Meta().Path))
+ }
+ fileKey := fileKeyPath.path()
+
+ // First check the in-memory store, then the disk.
+ c.mu.RLock()
+ cachedImage, found := c.store[memKey]
+ c.mu.RUnlock()
+
+ if found {
+ return cachedImage, nil
+ }
+
+ var img *imageResource
+
+ // These funcs are protected by a named lock.
+ // read clones the parent to its new name and copies
+ // the content to the destinations.
+ read := func(info filecache.ItemInfo, r io.ReadSeeker) error {
+ img = parent.clone(nil)
+ rp := img.getResourcePaths()
+ rp.relTargetDirFile.file = relTarget.file
+ img.setSourceFilename(info.Name)
+ img.setMediaType(conf.TargetFormat.MediaType())
+
+ if err := img.InitConfig(r); err != nil {
+ return err
+ }
+
+ r.Seek(0, 0)
+
+ w, err := img.openDestinationsForWriting()
+ if err != nil {
+ return err
+ }
+
+ if w == nil {
+ // Nothing to write.
+ return nil
+ }
+
+ defer w.Close()
+ _, err = io.Copy(w, r)
+
+ return err
+ }
+
+ // create creates the image and encodes it to the cache (w).
+ create := func(info filecache.ItemInfo, w io.WriteCloser) (err error) {
+ defer w.Close()
+
+ var conv image.Image
+ img, conv, err = createImage()
+ if err != nil {
+ return
+ }
+ rp := img.getResourcePaths()
+ rp.relTargetDirFile.file = relTarget.file
+ img.setSourceFilename(info.Name)
+
+ return img.EncodeTo(conf, conv, w)
+ }
+
+ // Now look in the file cache.
+
+ // The definition of this counter is not that we have processed that amount
+ // (e.g. resized etc.), it can be fetched from file cache,
+ // but the count of processed image variations for this site.
+ c.pathSpec.ProcessingStats.Incr(&c.pathSpec.ProcessingStats.ProcessedImages)
+
+ _, err := c.fileCache.ReadOrCreate(fileKey, read, create)
+ if err != nil {
+ return nil, err
+ }
+
+ // The file is now stored in this cache.
+ img.setSourceFs(c.fileCache.Fs)
+
+ c.mu.Lock()
+ if cachedImage, found = c.store[memKey]; found {
+ c.mu.Unlock()
+ return cachedImage, nil
+ }
+
+ imgAdapter := newResourceAdapter(parent.getSpec(), true, img)
+ c.store[memKey] = imgAdapter
+ c.mu.Unlock()
+
+ return imgAdapter, nil
+}
+
+func newImageCache(fileCache *filecache.Cache, ps *helpers.PathSpec) *imageCache {
+ return &imageCache{fileCache: fileCache, pathSpec: ps, store: make(map[string]*resourceAdapter)}
+}
diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go
new file mode 100644
index 000000000..a0b274f3e
--- /dev/null
+++ b/resources/image_extended_test.go
@@ -0,0 +1,42 @@
+// 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.
+
+//go:build extended
+// +build extended
+
+package resources
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/media"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestImageResizeWebP(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchImage(c, "sunset.webp")
+
+ c.Assert(image.MediaType(), qt.Equals, media.WEBPType)
+ c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.webp")
+ c.Assert(image.ResourceType(), qt.Equals, "image")
+ c.Assert(image.Exif(), qt.IsNil)
+
+ resized, err := image.Resize("123x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(image.MediaType(), qt.Equals, media.WEBPType)
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu36ee0b61ba924719ad36da960c273f96_59826_123x0_resize_q68_h2_linear_2.webp")
+ c.Assert(resized.Width(), qt.Equals, 123)
+}
diff --git a/resources/image_test.go b/resources/image_test.go
new file mode 100644
index 000000000..153a4e8c4
--- /dev/null
+++ b/resources/image_test.go
@@ -0,0 +1,843 @@
+// 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 (
+ "fmt"
+ "image"
+ "image/gif"
+ "io/ioutil"
+ "math/big"
+ "math/rand"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gohugoio/hugo/resources/images/webp"
+
+ "github.com/gohugoio/hugo/common/paths"
+
+ "github.com/spf13/afero"
+
+ "github.com/disintegration/gift"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/images"
+ "github.com/google/go-cmp/cmp"
+
+ "github.com/gohugoio/hugo/htesting/hqt"
+
+ qt "github.com/frankban/quicktest"
+)
+
+var eq = qt.CmpEquals(
+ cmp.Comparer(func(p1, p2 *resourceAdapter) bool {
+ return p1.resourceAdapterInner == p2.resourceAdapterInner
+ }),
+ cmp.Comparer(func(p1, p2 os.FileInfo) bool {
+ return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir()
+ }),
+ cmp.Comparer(func(p1, p2 *genericResource) bool { return p1 == p2 }),
+ cmp.Comparer(func(m1, m2 media.Type) bool {
+ return m1.Type() == m2.Type()
+ }),
+ cmp.Comparer(
+ func(v1, v2 *big.Rat) bool {
+ return v1.RatString() == v2.RatString()
+ },
+ ),
+ cmp.Comparer(func(v1, v2 time.Time) bool {
+ return v1.Unix() == v2.Unix()
+ }),
+)
+
+func TestImageTransformBasic(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchSunset(c)
+
+ fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs
+
+ assertWidthHeight := func(img images.ImageResource, w, h int) {
+ c.Helper()
+ c.Assert(img, qt.Not(qt.IsNil))
+ c.Assert(img.Width(), qt.Equals, w)
+ c.Assert(img.Height(), qt.Equals, h)
+ }
+
+ c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg")
+ c.Assert(image.ResourceType(), qt.Equals, "image")
+ assertWidthHeight(image, 900, 562)
+
+ resized, err := image.Resize("300x200")
+ c.Assert(err, qt.IsNil)
+ c.Assert(image != resized, qt.Equals, true)
+ c.Assert(image, qt.Not(eq), resized)
+ assertWidthHeight(resized, 300, 200)
+ assertWidthHeight(image, 900, 562)
+
+ resized0x, err := image.Resize("x200")
+ c.Assert(err, qt.IsNil)
+ assertWidthHeight(resized0x, 320, 200)
+ assertFileCache(c, fileCache, path.Base(resized0x.RelPermalink()), 320, 200)
+
+ resizedx0, err := image.Resize("200x")
+ c.Assert(err, qt.IsNil)
+ assertWidthHeight(resizedx0, 200, 125)
+ assertFileCache(c, fileCache, path.Base(resizedx0.RelPermalink()), 200, 125)
+
+ resizedAndRotated, err := image.Resize("x200 r90")
+ c.Assert(err, qt.IsNil)
+ assertWidthHeight(resizedAndRotated, 125, 200)
+
+ assertWidthHeight(resized, 300, 200)
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q68_linear.jpg")
+
+ fitted, err := resized.Fit("50x50")
+ c.Assert(err, qt.IsNil)
+ c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_625708021e2bb281c9f1002f88e4753f.jpg")
+ assertWidthHeight(fitted, 50, 33)
+
+ // Check the MD5 key threshold
+ fittedAgain, _ := fitted.Fit("10x20")
+ fittedAgain, err = fittedAgain.Fit("10x20")
+ c.Assert(err, qt.IsNil)
+ c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg")
+ assertWidthHeight(fittedAgain, 10, 7)
+
+ filled, err := image.Fill("200x100 bottomLeft")
+ c.Assert(err, qt.IsNil)
+ c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_bottomleft.jpg")
+ assertWidthHeight(filled, 200, 100)
+
+ smart, err := image.Fill("200x100 smart")
+ c.Assert(err, qt.IsNil)
+ c.Assert(smart.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", 1))
+ assertWidthHeight(smart, 200, 100)
+
+ // Check cache
+ filledAgain, err := image.Fill("200x100 bottomLeft")
+ c.Assert(err, qt.IsNil)
+ c.Assert(filled, eq, filledAgain)
+
+ cropped, err := image.Crop("300x300 topRight")
+ c.Assert(err, qt.IsNil)
+ c.Assert(cropped.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x300_crop_q68_linear_topright.jpg")
+ assertWidthHeight(cropped, 300, 300)
+
+ smartcropped, err := image.Crop("200x200 smart")
+ c.Assert(err, qt.IsNil)
+ c.Assert(smartcropped.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_crop_q68_linear_smart%d.jpg", 1))
+ assertWidthHeight(smartcropped, 200, 200)
+
+ // Check cache
+ croppedAgain, err := image.Crop("300x300 topRight")
+ c.Assert(err, qt.IsNil)
+ c.Assert(cropped, eq, croppedAgain)
+
+}
+
+func TestImageTransformFormat(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchSunset(c)
+
+ fileCache := image.(specProvider).getSpec().FileCaches.ImageCache().Fs
+
+ assertExtWidthHeight := func(img images.ImageResource, ext string, w, h int) {
+ c.Helper()
+ c.Assert(img, qt.Not(qt.IsNil))
+ c.Assert(paths.Ext(img.RelPermalink()), qt.Equals, ext)
+ c.Assert(img.Width(), qt.Equals, w)
+ c.Assert(img.Height(), qt.Equals, h)
+ }
+
+ c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg")
+ c.Assert(image.ResourceType(), qt.Equals, "image")
+ assertExtWidthHeight(image, ".jpg", 900, 562)
+
+ imagePng, err := image.Resize("450x png")
+ c.Assert(err, qt.IsNil)
+ c.Assert(imagePng.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_450x0_resize_linear.png")
+ c.Assert(imagePng.ResourceType(), qt.Equals, "image")
+ assertExtWidthHeight(imagePng, ".png", 450, 281)
+ c.Assert(imagePng.Name(), qt.Equals, "sunset.jpg")
+ c.Assert(imagePng.MediaType().String(), qt.Equals, "image/png")
+
+ assertFileCache(c, fileCache, path.Base(imagePng.RelPermalink()), 450, 281)
+
+ imageGif, err := image.Resize("225x gif")
+ c.Assert(err, qt.IsNil)
+ c.Assert(imageGif.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_225x0_resize_linear.gif")
+ c.Assert(imageGif.ResourceType(), qt.Equals, "image")
+ assertExtWidthHeight(imageGif, ".gif", 225, 141)
+ c.Assert(imageGif.Name(), qt.Equals, "sunset.jpg")
+ c.Assert(imageGif.MediaType().String(), qt.Equals, "image/gif")
+
+ assertFileCache(c, fileCache, path.Base(imageGif.RelPermalink()), 225, 141)
+}
+
+// https://github.com/gohugoio/hugo/issues/5730
+func TestImagePermalinkPublishOrder(t *testing.T) {
+ for _, checkOriginalFirst := range []bool{true, false} {
+ name := "OriginalFirst"
+ if !checkOriginalFirst {
+ name = "ResizedFirst"
+ }
+
+ t.Run(name, func(t *testing.T) {
+ c := qt.New(t)
+ spec, workDir := newTestResourceOsFs(c)
+ defer func() {
+ os.Remove(workDir)
+ }()
+
+ check1 := func(img images.ImageResource) {
+ resizedLink := "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x50_resize_q75_box.jpg"
+ c.Assert(img.RelPermalink(), qt.Equals, resizedLink)
+ assertImageFile(c, spec.PublishFs, resizedLink, 100, 50)
+ }
+
+ check2 := func(img images.ImageResource) {
+ c.Assert(img.RelPermalink(), qt.Equals, "/a/sunset.jpg")
+ assertImageFile(c, spec.PublishFs, "a/sunset.jpg", 900, 562)
+ }
+
+ orignal := fetchImageForSpec(spec, c, "sunset.jpg")
+ c.Assert(orignal, qt.Not(qt.IsNil))
+
+ if checkOriginalFirst {
+ check2(orignal)
+ }
+
+ resized, err := orignal.Resize("100x50")
+ c.Assert(err, qt.IsNil)
+
+ check1(resized.(images.ImageResource))
+
+ if !checkOriginalFirst {
+ check2(orignal)
+ }
+ })
+ }
+}
+
+func TestImageBugs(t *testing.T) {
+ c := qt.New(t)
+
+ // Issue #4261
+ c.Run("Transform long filename", func(c *qt.C) {
+ image := fetchImage(c, "1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg")
+ c.Assert(image, qt.Not(qt.IsNil))
+
+ resized, err := image.Resize("200x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized, qt.Not(qt.IsNil))
+ c.Assert(resized.Width(), qt.Equals, 200)
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_65b757a6e14debeae720fe8831f0a9bc.jpg")
+ resized, err = resized.Resize("100x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized, qt.Not(qt.IsNil))
+ c.Assert(resized.Width(), qt.Equals, 100)
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c876768085288f41211f768147ba2647.jpg")
+
+ })
+
+ // Issue #6137
+ c.Run("Transform upper case extension", func(c *qt.C) {
+ image := fetchImage(c, "sunrise.JPG")
+
+ resized, err := image.Resize("200x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized, qt.Not(qt.IsNil))
+ c.Assert(resized.Width(), qt.Equals, 200)
+
+ })
+
+ // Issue #7955
+ c.Run("Fill with smartcrop", func(c *qt.C) {
+ sunset := fetchImage(c, "sunset.jpg")
+
+ for _, test := range []struct {
+ originalDimensions string
+ targetWH int
+ }{
+ {"408x403", 400},
+ {"425x403", 400},
+ {"459x429", 400},
+ {"476x442", 400},
+ {"544x403", 400},
+ {"476x468", 400},
+ {"578x585", 550},
+ {"578x598", 550},
+ } {
+ c.Run(test.originalDimensions, func(c *qt.C) {
+ image, err := sunset.Resize(test.originalDimensions)
+ c.Assert(err, qt.IsNil)
+ resized, err := image.Fill(fmt.Sprintf("%dx%d smart", test.targetWH, test.targetWH))
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized, qt.Not(qt.IsNil))
+ c.Assert(resized.Width(), qt.Equals, test.targetWH)
+ c.Assert(resized.Height(), qt.Equals, test.targetWH)
+ })
+
+ }
+
+ })
+}
+
+func TestImageTransformConcurrent(t *testing.T) {
+ var wg sync.WaitGroup
+
+ c := qt.New(t)
+
+ spec, workDir := newTestResourceOsFs(c)
+ defer func() {
+ os.Remove(workDir)
+ }()
+
+ image := fetchImageForSpec(spec, c, "sunset.jpg")
+
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < 5; j++ {
+ img := image
+ for k := 0; k < 2; k++ {
+ r1, err := img.Resize(fmt.Sprintf("%dx", id-k))
+ if err != nil {
+ t.Error(err)
+ }
+
+ if r1.Width() != id-k {
+ t.Errorf("Width: %d:%d", r1.Width(), j)
+ }
+
+ r2, err := r1.Resize(fmt.Sprintf("%dx", id-k-1))
+ if err != nil {
+ t.Error(err)
+ }
+
+ img = r2
+ }
+ }
+ }(i + 20)
+ }
+
+ wg.Wait()
+}
+
+func TestImageWithMetadata(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchSunset(c)
+
+ meta := []map[string]any{
+ {
+ "title": "My Sunset",
+ "name": "Sunset #:counter",
+ "src": "*.jpg",
+ },
+ }
+
+ c.Assert(AssignMetadata(meta, image), qt.IsNil)
+ c.Assert(image.Name(), qt.Equals, "Sunset #1")
+
+ resized, err := image.Resize("200x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized.Name(), qt.Equals, "Sunset #1")
+}
+
+func TestImageResize8BitPNG(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchImage(c, "gohugoio.png")
+
+ c.Assert(image.MediaType().Type(), qt.Equals, "image/png")
+ c.Assert(image.RelPermalink(), qt.Equals, "/a/gohugoio.png")
+ c.Assert(image.ResourceType(), qt.Equals, "image")
+ c.Assert(image.Exif(), qt.IsNil)
+
+ resized, err := image.Resize("800x")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized.MediaType().Type(), qt.Equals, "image/png")
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_800x0_resize_linear_3.png")
+ c.Assert(resized.Width(), qt.Equals, 800)
+}
+
+func TestImageResizeInSubPath(t *testing.T) {
+ c := qt.New(t)
+
+ image := fetchImage(c, "sub/gohugoio2.png")
+
+ c.Assert(image.MediaType(), eq, media.PNGType)
+ c.Assert(image.RelPermalink(), qt.Equals, "/a/sub/gohugoio2.png")
+ c.Assert(image.ResourceType(), qt.Equals, "image")
+ c.Assert(image.Exif(), qt.IsNil)
+
+ resized, err := image.Resize("101x101")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resized.MediaType().Type(), qt.Equals, "image/png")
+ c.Assert(resized.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_3.png")
+ c.Assert(resized.Width(), qt.Equals, 101)
+ c.Assert(resized.Exif(), qt.IsNil)
+
+ publishedImageFilename := filepath.Clean(resized.RelPermalink())
+
+ spec := image.(specProvider).getSpec()
+
+ assertImageFile(c, spec.BaseFs.PublishFs, publishedImageFilename, 101, 101)
+ c.Assert(spec.BaseFs.PublishFs.Remove(publishedImageFilename), qt.IsNil)
+
+ // Clear mem cache to simulate reading from the file cache.
+ spec.imageCache.clear()
+
+ resizedAgain, err := image.Resize("101x101")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resizedAgain.RelPermalink(), qt.Equals, "/a/sub/gohugoio2_hu0e1b9e4a4be4d6f86c7b37b9ccce3fbc_73886_101x101_resize_linear_3.png")
+ c.Assert(resizedAgain.Width(), qt.Equals, 101)
+ assertImageFile(c, image.(specProvider).getSpec().BaseFs.PublishFs, publishedImageFilename, 101, 101)
+}
+
+func TestSVGImage(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ svg := fetchResourceForSpec(spec, c, "circle.svg")
+ c.Assert(svg, qt.Not(qt.IsNil))
+}
+
+func TestSVGImageContent(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ svg := fetchResourceForSpec(spec, c, "circle.svg")
+ c.Assert(svg, qt.Not(qt.IsNil))
+
+ content, err := svg.Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, hqt.IsSameType, "")
+ c.Assert(content.(string), qt.Contains, `<svg height="100" width="100">`)
+}
+
+func TestImageExif(t *testing.T) {
+ c := qt.New(t)
+ fs := afero.NewMemMapFs()
+ spec := newTestResourceSpec(specDescriptor{fs: fs, c: c})
+ image := fetchResourceForSpec(spec, c, "sunset.jpg").(images.ImageResource)
+
+ getAndCheckExif := func(c *qt.C, image images.ImageResource) {
+ x := image.Exif()
+ c.Assert(x, qt.Not(qt.IsNil))
+
+ c.Assert(x.Date.Format("2006-01-02"), qt.Equals, "2017-10-27")
+
+ // Malaga: https://goo.gl/taazZy
+ c.Assert(x.Lat, qt.Equals, float64(36.59744166666667))
+ c.Assert(x.Long, qt.Equals, float64(-4.50846))
+
+ v, found := x.Tags["LensModel"]
+ c.Assert(found, qt.Equals, true)
+ lensModel, ok := v.(string)
+ c.Assert(ok, qt.Equals, true)
+ c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM")
+ resized, _ := image.Resize("300x200")
+ x2 := resized.Exif()
+ c.Assert(x2, eq, x)
+ }
+
+ getAndCheckExif(c, image)
+ image = fetchResourceForSpec(spec, c, "sunset.jpg").(images.ImageResource)
+ // This will read from file cache.
+ getAndCheckExif(c, image)
+}
+
+func BenchmarkImageExif(b *testing.B) {
+ getImages := func(c *qt.C, b *testing.B, fs afero.Fs) []images.ImageResource {
+ spec := newTestResourceSpec(specDescriptor{fs: fs, c: c})
+ imgs := make([]images.ImageResource, b.N)
+ for i := 0; i < b.N; i++ {
+ imgs[i] = fetchResourceForSpec(spec, c, "sunset.jpg", strconv.Itoa(i)).(images.ImageResource)
+ }
+ return imgs
+ }
+
+ getAndCheckExif := func(c *qt.C, image images.ImageResource) {
+ x := image.Exif()
+ c.Assert(x, qt.Not(qt.IsNil))
+ c.Assert(x.Long, qt.Equals, float64(-4.50846))
+ }
+
+ b.Run("Cold cache", func(b *testing.B) {
+ b.StopTimer()
+ c := qt.New(b)
+ images := getImages(c, b, afero.NewMemMapFs())
+
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ getAndCheckExif(c, images[i])
+ }
+ })
+
+ b.Run("Cold cache, 10", func(b *testing.B) {
+ b.StopTimer()
+ c := qt.New(b)
+ images := getImages(c, b, afero.NewMemMapFs())
+
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ for j := 0; j < 10; j++ {
+ getAndCheckExif(c, images[i])
+ }
+ }
+ })
+
+ b.Run("Warm cache", func(b *testing.B) {
+ b.StopTimer()
+ c := qt.New(b)
+ fs := afero.NewMemMapFs()
+ images := getImages(c, b, fs)
+ for i := 0; i < b.N; i++ {
+ getAndCheckExif(c, images[i])
+ }
+
+ images = getImages(c, b, fs)
+
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ getAndCheckExif(c, images[i])
+ }
+ })
+}
+
+// usesFMA indicates whether "fused multiply and add" (FMA) instruction is
+// used. The command "grep FMADD go/test/codegen/floats.go" can help keep
+// the FMA-using architecture list updated.
+var usesFMA = runtime.GOARCH == "s390x" ||
+ runtime.GOARCH == "ppc64" ||
+ runtime.GOARCH == "ppc64le" ||
+ runtime.GOARCH == "arm64"
+
+// goldenEqual compares two NRGBA images. It is used in golden tests only.
+// A small tolerance is allowed on architectures using "fused multiply and add"
+// (FMA) instruction to accommodate for floating-point rounding differences
+// with control golden images that were generated on amd64 architecture.
+// See https://golang.org/ref/spec#Floating_point_operators
+// and https://github.com/gohugoio/hugo/issues/6387 for more information.
+//
+// Borrowed from https://github.com/disintegration/gift/blob/a999ff8d5226e5ab14b64a94fca07c4ac3f357cf/gift_test.go#L598-L625
+// Copyright (c) 2014-2019 Grigory Dryapak
+// Licensed under the MIT License.
+func goldenEqual(img1, img2 *image.NRGBA) bool {
+ maxDiff := 0
+ if usesFMA {
+ maxDiff = 1
+ }
+ if !img1.Rect.Eq(img2.Rect) {
+ return false
+ }
+ if len(img1.Pix) != len(img2.Pix) {
+ return false
+ }
+ for i := 0; i < len(img1.Pix); i++ {
+ diff := int(img1.Pix[i]) - int(img2.Pix[i])
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > maxDiff {
+ return false
+ }
+ }
+ return true
+}
+
+// Issue #8729
+func TestImageOperationsGoldenWebp(t *testing.T) {
+ if !webp.Supports() {
+ t.Skip("skip webp test")
+ }
+ c := qt.New(t)
+ c.Parallel()
+
+ devMode := false
+
+ testImages := []string{"fuzzy-cirlcle.png"}
+
+ spec, workDir := newTestResourceOsFs(c)
+ defer func() {
+ if !devMode {
+ os.Remove(workDir)
+ }
+ }()
+
+ if devMode {
+ fmt.Println(workDir)
+ }
+
+ for _, imageName := range testImages {
+ image := fetchImageForSpec(spec, c, imageName)
+ imageWebp, err := image.Resize("200x webp")
+ c.Assert(err, qt.IsNil)
+ c.Assert(imageWebp.Width(), qt.Equals, 200)
+ }
+
+ if devMode {
+ return
+ }
+
+ dir1 := filepath.Join(workDir, "resources/_gen/images")
+ dir2 := filepath.FromSlash("testdata/golden_webp")
+
+ assetGoldenDirs(c, dir1, dir2)
+
+}
+
+func TestImageOperationsGolden(t *testing.T) {
+ c := qt.New(t)
+ c.Parallel()
+
+ // Note, if you're enabling this on a MacOS M1 (ARM) you need to run the test with GOARCH=amd64.
+ // GOARCH=amd64 go test -timeout 30s -run "^TestImageOperationsGolden$" ./resources -v
+ devMode := false
+
+ testImages := []string{"sunset.jpg", "gohugoio8.png", "gohugoio24.png"}
+
+ spec, workDir := newTestResourceOsFs(c)
+ defer func() {
+ if !devMode {
+ os.Remove(workDir)
+ }
+ }()
+
+ if devMode {
+ fmt.Println(workDir)
+ }
+
+ gopher := fetchImageForSpec(spec, c, "gopher-hero8.png")
+ var err error
+ gopher, err = gopher.Resize("30x")
+ c.Assert(err, qt.IsNil)
+
+ // Test PNGs with alpha channel.
+ for _, img := range []string{"gopher-hero8.png", "gradient-circle.png"} {
+ orig := fetchImageForSpec(spec, c, img)
+ for _, resizeSpec := range []string{"200x #e3e615", "200x jpg #e3e615"} {
+ resized, err := orig.Resize(resizeSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+ }
+
+ // A simple Gif file (no animation).
+ orig := fetchImageForSpec(spec, c, "gohugoio-card.gif")
+ for _, resizeSpec := range []string{"100x", "220x"} {
+ resized, err := orig.Resize(resizeSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ // Animated GIF
+ orig = fetchImageForSpec(spec, c, "giphy.gif")
+ for _, resizeSpec := range []string{"200x", "512x"} {
+ resized, err := orig.Resize(resizeSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ for _, img := range testImages {
+
+ orig := fetchImageForSpec(spec, c, img)
+ for _, resizeSpec := range []string{"200x100", "600x", "200x r90 q50 Box"} {
+ resized, err := orig.Resize(resizeSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ for _, fillSpec := range []string{"300x200 Gaussian Smart", "100x100 Center", "300x100 TopLeft NearestNeighbor", "400x200 BottomLeft"} {
+ resized, err := orig.Fill(fillSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ for _, fitSpec := range []string{"300x200 Linear"} {
+ resized, err := orig.Fit(fitSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ f := &images.Filters{}
+
+ filters := []gift.Filter{
+ f.Grayscale(),
+ f.GaussianBlur(6),
+ f.Saturation(50),
+ f.Sepia(100),
+ f.Brightness(30),
+ f.ColorBalance(10, -10, -10),
+ f.Colorize(240, 50, 100),
+ f.Gamma(1.5),
+ f.UnsharpMask(1, 1, 0),
+ f.Sigmoid(0.5, 7),
+ f.Pixelate(5),
+ f.Invert(),
+ f.Hue(22),
+ f.Contrast(32.5),
+ f.Overlay(gopher.(images.ImageSource), 20, 30),
+ f.Text("No options"),
+ f.Text("This long text is to test line breaks. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."),
+ f.Text("Hugo rocks!", map[string]any{"x": 3, "y": 3, "size": 20, "color": "#fc03b1"}),
+ }
+
+ resized, err := orig.Fill("400x200 center")
+ c.Assert(err, qt.IsNil)
+
+ for _, filter := range filters {
+ resized, err := resized.Filter(filter)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ resized, err = resized.Filter(filters[0:4])
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ if devMode {
+ return
+ }
+
+ dir1 := filepath.Join(workDir, "resources/_gen/images")
+ dir2 := filepath.FromSlash("testdata/golden")
+
+ assetGoldenDirs(c, dir1, dir2)
+
+}
+
+func assetGoldenDirs(c *qt.C, dir1, dir2 string) {
+
+ // The two dirs above should now be the same.
+ dirinfos1, err := ioutil.ReadDir(dir1)
+ c.Assert(err, qt.IsNil)
+ dirinfos2, err := ioutil.ReadDir(dir2)
+ c.Assert(err, qt.IsNil)
+ c.Assert(len(dirinfos1), qt.Equals, len(dirinfos2))
+
+ for i, fi1 := range dirinfos1 {
+ fi2 := dirinfos2[i]
+ c.Assert(fi1.Name(), qt.Equals, fi2.Name())
+
+ f1, err := os.Open(filepath.Join(dir1, fi1.Name()))
+ c.Assert(err, qt.IsNil)
+ f2, err := os.Open(filepath.Join(dir2, fi2.Name()))
+ c.Assert(err, qt.IsNil)
+
+ decodeAll := func(f *os.File) []image.Image {
+ var images []image.Image
+
+ if strings.HasSuffix(f.Name(), ".gif") {
+ gif, err := gif.DecodeAll(f)
+ c.Assert(err, qt.IsNil)
+ images = make([]image.Image, len(gif.Image))
+ for i, img := range gif.Image {
+ images[i] = img
+ }
+ } else {
+ img, _, err := image.Decode(f)
+ c.Assert(err, qt.IsNil)
+ images = append(images, img)
+ }
+ return images
+ }
+
+ imgs1 := decodeAll(f1)
+ imgs2 := decodeAll(f2)
+ c.Assert(len(imgs1), qt.Equals, len(imgs2))
+
+ LOOP:
+ for i, img1 := range imgs1 {
+ img2 := imgs2[i]
+ nrgba1 := image.NewNRGBA(img1.Bounds())
+ gift.New().Draw(nrgba1, img1)
+ nrgba2 := image.NewNRGBA(img2.Bounds())
+ gift.New().Draw(nrgba2, img2)
+
+ if !goldenEqual(nrgba1, nrgba2) {
+ switch fi1.Name() {
+ case "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png",
+ "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png",
+ "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png",
+ "giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif":
+ c.Log("expectedly differs from golden due to dithering:", fi1.Name())
+ default:
+ c.Errorf("resulting image differs from golden: %s", fi1.Name())
+ break LOOP
+ }
+ }
+ }
+
+ if !usesFMA {
+ c.Assert(fi1, eq, fi2)
+
+ _, err = f1.Seek(0, 0)
+ c.Assert(err, qt.IsNil)
+ _, err = f2.Seek(0, 0)
+ c.Assert(err, qt.IsNil)
+
+ hash1, err := helpers.MD5FromReader(f1)
+ c.Assert(err, qt.IsNil)
+ hash2, err := helpers.MD5FromReader(f2)
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(hash1, qt.Equals, hash2)
+ }
+
+ f1.Close()
+ f2.Close()
+ }
+}
+
+func BenchmarkResizeParallel(b *testing.B) {
+ c := qt.New(b)
+ img := fetchSunset(c)
+
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ w := rand.Intn(10) + 10
+ resized, err := img.Resize(strconv.Itoa(w) + "x")
+ if err != nil {
+ b.Fatal(err)
+ }
+ _, err = resized.Resize(strconv.Itoa(w-1) + "x")
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+}
diff --git a/resources/images/color.go b/resources/images/color.go
new file mode 100644
index 000000000..057a9fb71
--- /dev/null
+++ b/resources/images/color.go
@@ -0,0 +1,83 @@
+// 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 images
+
+import (
+ "encoding/hex"
+ "fmt"
+ "image/color"
+ "strings"
+)
+
+// AddColorToPalette adds c as the first color in p if not already there.
+// Note that it does no additional checks, so callers must make sure
+// that the palette is valid for the relevant format.
+func AddColorToPalette(c color.Color, p color.Palette) color.Palette {
+ var found bool
+ for _, cc := range p {
+ if c == cc {
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ p = append(color.Palette{c}, p...)
+ }
+
+ return p
+}
+
+// ReplaceColorInPalette will replace the color in palette p closest to c in Euclidean
+// R,G,B,A space with c.
+func ReplaceColorInPalette(c color.Color, p color.Palette) {
+ p[p.Index(c)] = c
+}
+
+func hexStringToColor(s string) (color.Color, error) {
+ s = strings.TrimPrefix(s, "#")
+
+ if len(s) != 3 && len(s) != 6 {
+ return nil, fmt.Errorf("invalid color code: %q", s)
+ }
+
+ s = strings.ToLower(s)
+
+ if len(s) == 3 {
+ var v string
+ for _, r := range s {
+ v += string(r) + string(r)
+ }
+ s = v
+ }
+
+ // Standard colors.
+ if s == "ffffff" {
+ return color.White, nil
+ }
+
+ if s == "000000" {
+ return color.Black, nil
+ }
+
+ // Set Alfa to white.
+ s += "ff"
+
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+
+ return color.RGBA{b[0], b[1], b[2], b[3]}, nil
+}
diff --git a/resources/images/color_test.go b/resources/images/color_test.go
new file mode 100644
index 000000000..52871e691
--- /dev/null
+++ b/resources/images/color_test.go
@@ -0,0 +1,89 @@
+// 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 images
+
+import (
+ "image/color"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestHexStringToColor(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ arg string
+ expect any
+ }{
+ {"f", false},
+ {"#f", false},
+ {"#fffffff", false},
+ {"fffffff", false},
+ {"#fff", color.White},
+ {"fff", color.White},
+ {"FFF", color.White},
+ {"FfF", color.White},
+ {"#ffffff", color.White},
+ {"ffffff", color.White},
+ {"#000", color.Black},
+ {"#4287f5", color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0xff}},
+ {"777", color.RGBA{R: 0x77, G: 0x77, B: 0x77, A: 0xff}},
+ } {
+
+ test := test
+ c.Run(test.arg, func(c *qt.C) {
+ c.Parallel()
+
+ result, err := hexStringToColor(test.arg)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ return
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.DeepEquals, test.expect)
+ })
+
+ }
+}
+
+func TestAddColorToPalette(t *testing.T) {
+ c := qt.New(t)
+
+ palette := color.Palette{color.White, color.Black}
+
+ c.Assert(AddColorToPalette(color.White, palette), qt.HasLen, 2)
+
+ blue1, _ := hexStringToColor("34c3eb")
+ blue2, _ := hexStringToColor("34c3eb")
+ white, _ := hexStringToColor("fff")
+
+ c.Assert(AddColorToPalette(white, palette), qt.HasLen, 2)
+ c.Assert(AddColorToPalette(blue1, palette), qt.HasLen, 3)
+ c.Assert(AddColorToPalette(blue2, palette), qt.HasLen, 3)
+}
+
+func TestReplaceColorInPalette(t *testing.T) {
+ c := qt.New(t)
+
+ palette := color.Palette{color.White, color.Black}
+ offWhite, _ := hexStringToColor("fcfcfc")
+
+ ReplaceColorInPalette(offWhite, palette)
+
+ c.Assert(palette, qt.HasLen, 2)
+ c.Assert(palette[0], qt.Equals, offWhite)
+}
diff --git a/resources/images/config.go b/resources/images/config.go
new file mode 100644
index 000000000..62b5c72d8
--- /dev/null
+++ b/resources/images/config.go
@@ -0,0 +1,462 @@
+// 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 images
+
+import (
+ "fmt"
+ "image/color"
+ "strconv"
+ "strings"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/media"
+
+ "errors"
+
+ "github.com/bep/gowebp/libwebp/webpoptions"
+
+ "github.com/disintegration/gift"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+var (
+ imageFormats = map[string]Format{
+ ".jpg": JPEG,
+ ".jpeg": JPEG,
+ ".jpe": JPEG,
+ ".jif": JPEG,
+ ".jfif": JPEG,
+ ".png": PNG,
+ ".tif": TIFF,
+ ".tiff": TIFF,
+ ".bmp": BMP,
+ ".gif": GIF,
+ ".webp": WEBP,
+ }
+
+ imageFormatsBySubType = map[string]Format{
+ media.JPEGType.SubType: JPEG,
+ media.PNGType.SubType: PNG,
+ media.TIFFType.SubType: TIFF,
+ media.BMPType.SubType: BMP,
+ media.GIFType.SubType: GIF,
+ media.WEBPType.SubType: WEBP,
+ }
+
+ // Add or increment if changes to an image format's processing requires
+ // re-generation.
+ imageFormatsVersions = map[Format]int{
+ PNG: 3, // Fix transparency issue with 32 bit images.
+ WEBP: 2, // Fix transparency issue with 32 bit images.
+ }
+
+ // Increment to mark all processed images as stale. Only use when absolutely needed.
+ // See the finer grained smartCropVersionNumber and imageFormatsVersions.
+ mainImageVersionNumber = 0
+)
+
+var anchorPositions = map[string]gift.Anchor{
+ strings.ToLower("Center"): gift.CenterAnchor,
+ strings.ToLower("TopLeft"): gift.TopLeftAnchor,
+ strings.ToLower("Top"): gift.TopAnchor,
+ strings.ToLower("TopRight"): gift.TopRightAnchor,
+ strings.ToLower("Left"): gift.LeftAnchor,
+ strings.ToLower("Right"): gift.RightAnchor,
+ strings.ToLower("BottomLeft"): gift.BottomLeftAnchor,
+ strings.ToLower("Bottom"): gift.BottomAnchor,
+ strings.ToLower("BottomRight"): gift.BottomRightAnchor,
+}
+
+// These encoding hints are currently only relevant for Webp.
+var hints = map[string]webpoptions.EncodingPreset{
+ "picture": webpoptions.EncodingPresetPicture,
+ "photo": webpoptions.EncodingPresetPhoto,
+ "drawing": webpoptions.EncodingPresetDrawing,
+ "icon": webpoptions.EncodingPresetIcon,
+ "text": webpoptions.EncodingPresetText,
+}
+
+var imageFilters = map[string]gift.Resampling{
+
+ strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling,
+ strings.ToLower("Box"): gift.BoxResampling,
+ strings.ToLower("Linear"): gift.LinearResampling,
+ strings.ToLower("Hermite"): hermiteResampling,
+ strings.ToLower("MitchellNetravali"): mitchellNetravaliResampling,
+ strings.ToLower("CatmullRom"): catmullRomResampling,
+ strings.ToLower("BSpline"): bSplineResampling,
+ strings.ToLower("Gaussian"): gaussianResampling,
+ strings.ToLower("Lanczos"): gift.LanczosResampling,
+ strings.ToLower("Hann"): hannResampling,
+ strings.ToLower("Hamming"): hammingResampling,
+ strings.ToLower("Blackman"): blackmanResampling,
+ strings.ToLower("Bartlett"): bartlettResampling,
+ strings.ToLower("Welch"): welchResampling,
+ strings.ToLower("Cosine"): cosineResampling,
+}
+
+func ImageFormatFromExt(ext string) (Format, bool) {
+ f, found := imageFormats[ext]
+ return f, found
+}
+
+func ImageFormatFromMediaSubType(sub string) (Format, bool) {
+ f, found := imageFormatsBySubType[sub]
+ return f, found
+}
+
+const (
+ defaultJPEGQuality = 75
+ defaultResampleFilter = "box"
+ defaultBgColor = "ffffff"
+ defaultHint = "photo"
+)
+
+var defaultImaging = Imaging{
+ ResampleFilter: defaultResampleFilter,
+ BgColor: defaultBgColor,
+ Hint: defaultHint,
+ Quality: defaultJPEGQuality,
+}
+
+func DecodeConfig(m map[string]any) (ImagingConfig, error) {
+ if m == nil {
+ m = make(map[string]any)
+ }
+
+ i := ImagingConfig{
+ Cfg: defaultImaging,
+ CfgHash: helpers.HashString(m),
+ }
+
+ if err := mapstructure.WeakDecode(m, &i.Cfg); err != nil {
+ return i, err
+ }
+
+ if err := i.Cfg.init(); err != nil {
+ return i, err
+ }
+
+ var err error
+ i.BgColor, err = hexStringToColor(i.Cfg.BgColor)
+ if err != nil {
+ return i, err
+ }
+
+ if i.Cfg.Anchor != "" && i.Cfg.Anchor != smartCropIdentifier {
+ anchor, found := anchorPositions[i.Cfg.Anchor]
+ if !found {
+ return i, fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor)
+ }
+ i.Anchor = anchor
+ } else {
+ i.Cfg.Anchor = smartCropIdentifier
+ }
+
+ filter, found := imageFilters[i.Cfg.ResampleFilter]
+ if !found {
+ return i, fmt.Errorf("%q is not a valid resample filter", filter)
+ }
+ i.ResampleFilter = filter
+
+ if strings.TrimSpace(i.Cfg.Exif.IncludeFields) == "" && strings.TrimSpace(i.Cfg.Exif.ExcludeFields) == "" {
+ // Don't change this for no good reason. Please don't.
+ i.Cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
+ }
+
+ return i, nil
+}
+
+func DecodeImageConfig(action, config string, defaults ImagingConfig, sourceFormat Format) (ImageConfig, error) {
+ var (
+ c ImageConfig = GetDefaultImageConfig(action, defaults)
+ err error
+ )
+
+ c.Action = action
+
+ if config == "" {
+ return c, errors.New("image config cannot be empty")
+ }
+
+ parts := strings.Fields(config)
+ for _, part := range parts {
+ part = strings.ToLower(part)
+
+ if part == smartCropIdentifier {
+ c.AnchorStr = smartCropIdentifier
+ } else if pos, ok := anchorPositions[part]; ok {
+ c.Anchor = pos
+ c.AnchorStr = part
+ } else if filter, ok := imageFilters[part]; ok {
+ c.Filter = filter
+ c.FilterStr = part
+ } else if hint, ok := hints[part]; ok {
+ c.Hint = hint
+ } else if part[0] == '#' {
+ c.BgColorStr = part[1:]
+ c.BgColor, err = hexStringToColor(c.BgColorStr)
+ if err != nil {
+ return c, err
+ }
+ } else if part[0] == 'q' {
+ c.Quality, err = strconv.Atoi(part[1:])
+ if err != nil {
+ return c, err
+ }
+ if c.Quality < 1 || c.Quality > 100 {
+ return c, errors.New("quality ranges from 1 to 100 inclusive")
+ }
+ c.qualitySetForImage = true
+ } else if part[0] == 'r' {
+ c.Rotate, err = strconv.Atoi(part[1:])
+ if err != nil {
+ return c, err
+ }
+ } else if strings.Contains(part, "x") {
+ widthHeight := strings.Split(part, "x")
+ if len(widthHeight) <= 2 {
+ first := widthHeight[0]
+ if first != "" {
+ c.Width, err = strconv.Atoi(first)
+ if err != nil {
+ return c, err
+ }
+ }
+
+ if len(widthHeight) == 2 {
+ second := widthHeight[1]
+ if second != "" {
+ c.Height, err = strconv.Atoi(second)
+ if err != nil {
+ return c, err
+ }
+ }
+ }
+ } else {
+ return c, errors.New("invalid image dimensions")
+ }
+ } else if f, ok := ImageFormatFromExt("." + part); ok {
+ c.TargetFormat = f
+ }
+ }
+
+ switch c.Action {
+ case "crop", "fill", "fit":
+ if c.Width == 0 || c.Height == 0 {
+ return c, errors.New("must provide Width and Height")
+ }
+ case "resize":
+ if c.Width == 0 && c.Height == 0 {
+ return c, errors.New("must provide Width or Height")
+ }
+ default:
+ return c, fmt.Errorf("BUG: unknown action %q encountered while decoding image configuration", c.Action)
+ }
+
+ if c.FilterStr == "" {
+ c.FilterStr = defaults.Cfg.ResampleFilter
+ c.Filter = defaults.ResampleFilter
+ }
+
+ if c.Hint == 0 {
+ c.Hint = webpoptions.EncodingPresetPhoto
+ }
+
+ if c.AnchorStr == "" {
+ c.AnchorStr = defaults.Cfg.Anchor
+ c.Anchor = defaults.Anchor
+ }
+
+ // default to the source format
+ if c.TargetFormat == 0 {
+ c.TargetFormat = sourceFormat
+ }
+
+ if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
+ // We need a quality setting for all JPEGs and WEBPs.
+ c.Quality = defaults.Cfg.Quality
+ }
+
+ if c.BgColor == nil && c.TargetFormat != sourceFormat {
+ if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
+ c.BgColor = defaults.BgColor
+ c.BgColorStr = defaults.Cfg.BgColor
+ }
+ }
+
+ return c, nil
+}
+
+// ImageConfig holds configuration to create a new image from an existing one, resize etc.
+type ImageConfig struct {
+ // This defines the output format of the output image. It defaults to the source format.
+ TargetFormat Format
+
+ Action string
+
+ // If set, this will be used as the key in filenames etc.
+ Key string
+
+ // Quality ranges from 1 to 100 inclusive, higher is better.
+ // This is only relevant for JPEG and WEBP images.
+ // Default is 75.
+ Quality int
+ qualitySetForImage bool // Whether the above is set for this image.
+
+ // Rotate rotates an image by the given angle counter-clockwise.
+ // The rotation will be performed first.
+ Rotate int
+
+ // Used to fill any transparency.
+ // When set in site config, it's used when converting to a format that does
+ // not support transparency.
+ // When set per image operation, it's used even for formats that does support
+ // transparency.
+ BgColor color.Color
+ BgColorStr string
+
+ // Hint about what type of picture this is. Used to optimize encoding
+ // when target is set to webp.
+ Hint webpoptions.EncodingPreset
+
+ Width int
+ Height int
+
+ Filter gift.Resampling
+ FilterStr string
+
+ Anchor gift.Anchor
+ AnchorStr string
+}
+
+func (i ImageConfig) GetKey(format Format) string {
+ if i.Key != "" {
+ return i.Action + "_" + i.Key
+ }
+
+ k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
+ if i.Action != "" {
+ k += "_" + i.Action
+ }
+ // This slightly odd construct is here to preserve the old image keys.
+ if i.qualitySetForImage || i.TargetFormat.RequiresDefaultQuality() {
+ k += "_q" + strconv.Itoa(i.Quality)
+ }
+ if i.Rotate != 0 {
+ k += "_r" + strconv.Itoa(i.Rotate)
+ }
+ if i.BgColorStr != "" {
+ k += "_bg" + i.BgColorStr
+ }
+
+ if i.TargetFormat == WEBP {
+ k += "_h" + strconv.Itoa(int(i.Hint))
+ }
+
+ anchor := i.AnchorStr
+ if anchor == smartCropIdentifier {
+ anchor = anchor + strconv.Itoa(smartCropVersionNumber)
+ }
+
+ k += "_" + i.FilterStr
+
+ if strings.EqualFold(i.Action, "fill") || strings.EqualFold(i.Action, "crop") {
+ k += "_" + anchor
+ }
+
+ if v, ok := imageFormatsVersions[format]; ok {
+ k += "_" + strconv.Itoa(v)
+ }
+
+ if mainImageVersionNumber > 0 {
+ k += "_" + strconv.Itoa(mainImageVersionNumber)
+ }
+
+ return k
+}
+
+type ImagingConfig struct {
+ BgColor color.Color
+ Hint webpoptions.EncodingPreset
+ ResampleFilter gift.Resampling
+ Anchor gift.Anchor
+
+ // Config as provided by the user.
+ Cfg Imaging
+
+ // Hash of the config map provided by the user.
+ CfgHash string
+}
+
+// Imaging contains default image processing configuration. This will be fetched
+// from site (or language) config.
+type Imaging struct {
+ // Default image quality setting (1-100). Only used for JPEG images.
+ Quality int
+
+ // Resample filter to use in resize operations.
+ ResampleFilter string
+
+ // Hint about what type of image this is.
+ // Currently only used when encoding to Webp.
+ // Default is "photo".
+ // Valid values are "picture", "photo", "drawing", "icon", or "text".
+ Hint string
+
+ // The anchor to use in Fill. Default is "smart", i.e. Smart Crop.
+ Anchor string
+
+ // Default color used in fill operations (e.g. "fff" for white).
+ BgColor string
+
+ Exif ExifConfig
+}
+
+func (cfg *Imaging) init() error {
+ if cfg.Quality < 0 || cfg.Quality > 100 {
+ return errors.New("image quality must be a number between 1 and 100")
+ }
+
+ cfg.BgColor = strings.ToLower(strings.TrimPrefix(cfg.BgColor, "#"))
+ cfg.Anchor = strings.ToLower(cfg.Anchor)
+ cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter)
+ cfg.Hint = strings.ToLower(cfg.Hint)
+
+ return nil
+}
+
+type ExifConfig struct {
+
+ // Regexp matching the Exif fields you want from the (massive) set of Exif info
+ // available. As we cache this info to disk, this is for performance and
+ // disk space reasons more than anything.
+ // If you want it all, put ".*" in this config setting.
+ // Note that if neither this or ExcludeFields is set, Hugo will return a small
+ // default set.
+ IncludeFields string
+
+ // Regexp matching the Exif fields you want to exclude. This may be easier to use
+ // than IncludeFields above, depending on what you want.
+ ExcludeFields string
+
+ // Hugo extracts the "photo taken" date/time into .Date by default.
+ // Set this to true to turn it off.
+ DisableDate bool
+
+ // Hugo extracts the "photo taken where" (GPS latitude and longitude) into
+ // .Long and .Lat. Set this to true to turn it off.
+ DisableLatLong bool
+}
diff --git a/resources/images/config_test.go b/resources/images/config_test.go
new file mode 100644
index 000000000..1b785f7ca
--- /dev/null
+++ b/resources/images/config_test.go
@@ -0,0 +1,158 @@
+// 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 images
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestDecodeConfig(t *testing.T) {
+ c := qt.New(t)
+ m := map[string]any{
+ "quality": 42,
+ "resampleFilter": "NearestNeighbor",
+ "anchor": "topLeft",
+ }
+
+ imagingConfig, err := DecodeConfig(m)
+
+ c.Assert(err, qt.IsNil)
+ imaging := imagingConfig.Cfg
+ c.Assert(imaging.Quality, qt.Equals, 42)
+ c.Assert(imaging.ResampleFilter, qt.Equals, "nearestneighbor")
+ c.Assert(imaging.Anchor, qt.Equals, "topleft")
+
+ m = map[string]any{}
+
+ imagingConfig, err = DecodeConfig(m)
+ c.Assert(err, qt.IsNil)
+ imaging = imagingConfig.Cfg
+ c.Assert(imaging.ResampleFilter, qt.Equals, "box")
+ c.Assert(imaging.Anchor, qt.Equals, "smart")
+
+ _, err = DecodeConfig(map[string]any{
+ "quality": 123,
+ })
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ _, err = DecodeConfig(map[string]any{
+ "resampleFilter": "asdf",
+ })
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ _, err = DecodeConfig(map[string]any{
+ "anchor": "asdf",
+ })
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ imagingConfig, err = DecodeConfig(map[string]any{
+ "anchor": "Smart",
+ })
+ imaging = imagingConfig.Cfg
+ c.Assert(err, qt.IsNil)
+ c.Assert(imaging.Anchor, qt.Equals, "smart")
+
+ imagingConfig, err = DecodeConfig(map[string]any{
+ "exif": map[string]any{
+ "disableLatLong": true,
+ },
+ })
+ c.Assert(err, qt.IsNil)
+ imaging = imagingConfig.Cfg
+ c.Assert(imaging.Exif.DisableLatLong, qt.Equals, true)
+ c.Assert(imaging.Exif.ExcludeFields, qt.Equals, "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance")
+}
+
+func TestDecodeImageConfig(t *testing.T) {
+ for i, this := range []struct {
+ action string
+ in string
+ expect any
+ }{
+ {"resize", "300x400", newImageConfig("resize", 300, 400, 75, 0, "box", "smart", "")},
+ {"resize", "300x400 #fff", newImageConfig("resize", 300, 400, 75, 0, "box", "smart", "fff")},
+ {"resize", "100x200 bottomRight", newImageConfig("resize", 100, 200, 75, 0, "box", "BottomRight", "")},
+ {"resize", "10x20 topleft Lanczos", newImageConfig("resize", 10, 20, 75, 0, "Lanczos", "topleft", "")},
+ {"resize", "linear left 10x r180", newImageConfig("resize", 10, 0, 75, 180, "linear", "left", "")},
+ {"resize", "x20 riGht Cosine q95", newImageConfig("resize", 0, 20, 95, 0, "cosine", "right", "")},
+ {"crop", "300x400", newImageConfig("crop", 300, 400, 75, 0, "box", "smart", "")},
+ {"fill", "300x400", newImageConfig("fill", 300, 400, 75, 0, "box", "smart", "")},
+ {"fit", "300x400", newImageConfig("fit", 300, 400, 75, 0, "box", "smart", "")},
+
+ {"resize", "", false},
+ {"resize", "foo", false},
+ {"crop", "100x", false},
+ {"fill", "100x", false},
+ {"fit", "100x", false},
+ {"foo", "100x", false},
+ } {
+
+ cfg, err := DecodeConfig(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := DecodeImageConfig(this.action, this.in, cfg, PNG)
+ if b, ok := this.expect.(bool); ok && !b {
+ if err == nil {
+ t.Errorf("[%d] parseImageConfig didn't return an expected error", i)
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("[%d] err: %s", i, err)
+ }
+ if fmt.Sprint(result) != fmt.Sprint(this.expect) {
+ t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect)
+ }
+ }
+ }
+}
+
+func newImageConfig(action string, width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig {
+ var c ImageConfig = GetDefaultImageConfig(action, ImagingConfig{})
+ c.TargetFormat = PNG
+ c.Hint = 2
+ c.Width = width
+ c.Height = height
+ c.Quality = quality
+ c.qualitySetForImage = quality != 75
+ c.Rotate = rotate
+ c.BgColorStr = bgColor
+ c.BgColor, _ = hexStringToColor(bgColor)
+
+ if filter != "" {
+ filter = strings.ToLower(filter)
+ if v, ok := imageFilters[filter]; ok {
+ c.Filter = v
+ c.FilterStr = filter
+ }
+ }
+
+ if anchor != "" {
+ if anchor == smartCropIdentifier {
+ c.AnchorStr = anchor
+ } else {
+ anchor = strings.ToLower(anchor)
+ if v, ok := anchorPositions[anchor]; ok {
+ c.Anchor = v
+ c.AnchorStr = anchor
+ }
+ }
+ }
+
+ return c
+}
diff --git a/resources/images/exif/exif.go b/resources/images/exif/exif.go
new file mode 100644
index 000000000..487f250d5
--- /dev/null
+++ b/resources/images/exif/exif.go
@@ -0,0 +1,272 @@
+// 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 exif
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "math/big"
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/bep/tmc"
+
+ _exif "github.com/rwcarlsen/goexif/exif"
+ "github.com/rwcarlsen/goexif/tiff"
+)
+
+const exifTimeLayout = "2006:01:02 15:04:05"
+
+// ExifInfo holds the decoded Exif data for an Image.
+type ExifInfo struct {
+ // GPS latitude in degrees.
+ Lat float64
+
+ // GPS longitude in degrees.
+ Long float64
+
+ // Image creation date/time.
+ Date time.Time
+
+ // A collection of the available Exif tags for this Image.
+ Tags Tags
+}
+
+type Decoder struct {
+ includeFieldsRe *regexp.Regexp
+ excludeFieldsrRe *regexp.Regexp
+ noDate bool
+ noLatLong bool
+}
+
+func IncludeFields(expression string) func(*Decoder) error {
+ return func(d *Decoder) error {
+ re, err := compileRegexp(expression)
+ if err != nil {
+ return err
+ }
+ d.includeFieldsRe = re
+ return nil
+ }
+}
+
+func ExcludeFields(expression string) func(*Decoder) error {
+ return func(d *Decoder) error {
+ re, err := compileRegexp(expression)
+ if err != nil {
+ return err
+ }
+ d.excludeFieldsrRe = re
+ return nil
+ }
+}
+
+func WithLatLongDisabled(disabled bool) func(*Decoder) error {
+ return func(d *Decoder) error {
+ d.noLatLong = disabled
+ return nil
+ }
+}
+
+func WithDateDisabled(disabled bool) func(*Decoder) error {
+ return func(d *Decoder) error {
+ d.noDate = disabled
+ return nil
+ }
+}
+
+func compileRegexp(expression string) (*regexp.Regexp, error) {
+ expression = strings.TrimSpace(expression)
+ if expression == "" {
+ return nil, nil
+ }
+ if !strings.HasPrefix(expression, "(") {
+ // Make it case insensitive
+ expression = "(?i)" + expression
+ }
+
+ return regexp.Compile(expression)
+}
+
+func NewDecoder(options ...func(*Decoder) error) (*Decoder, error) {
+ d := &Decoder{}
+ for _, opt := range options {
+ if err := opt(d); err != nil {
+ return nil, err
+ }
+ }
+
+ return d, nil
+}
+
+func (d *Decoder) Decode(r io.Reader) (ex *ExifInfo, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("Exif failed: %v", r)
+ }
+ }()
+
+ var x *_exif.Exif
+ x, err = _exif.Decode(r)
+ if err != nil {
+ if err.Error() == "EOF" {
+ // Found no Exif
+ return nil, nil
+ }
+ return
+ }
+
+ var tm time.Time
+ var lat, long float64
+
+ if !d.noDate {
+ tm, _ = x.DateTime()
+ }
+
+ if !d.noLatLong {
+ lat, long, _ = x.LatLong()
+ }
+
+ walker := &exifWalker{x: x, vals: make(map[string]any), includeMatcher: d.includeFieldsRe, excludeMatcher: d.excludeFieldsrRe}
+ if err = x.Walk(walker); err != nil {
+ return
+ }
+
+ ex = &ExifInfo{Lat: lat, Long: long, Date: tm, Tags: walker.vals}
+
+ return
+}
+
+func decodeTag(x *_exif.Exif, f _exif.FieldName, t *tiff.Tag) (any, error) {
+ switch t.Format() {
+ case tiff.StringVal, tiff.UndefVal:
+ s := nullString(t.Val)
+ if strings.Contains(string(f), "DateTime") {
+ if d, err := tryParseDate(x, s); err == nil {
+ return d, nil
+ }
+ }
+ return s, nil
+ case tiff.OtherVal:
+ return "unknown", nil
+ }
+
+ var rv []any
+
+ for i := 0; i < int(t.Count); i++ {
+ switch t.Format() {
+ case tiff.RatVal:
+ n, d, _ := t.Rat2(i)
+ rat := big.NewRat(n, d)
+ if n == 1 {
+ rv = append(rv, rat)
+ } else {
+ f, _ := rat.Float64()
+ rv = append(rv, f)
+ }
+
+ case tiff.FloatVal:
+ v, _ := t.Float(i)
+ rv = append(rv, v)
+ case tiff.IntVal:
+ v, _ := t.Int(i)
+ rv = append(rv, v)
+ }
+ }
+
+ if t.Count == 1 {
+ if len(rv) == 1 {
+ return rv[0], nil
+ }
+ }
+
+ return rv, nil
+}
+
+// Code borrowed from exif.DateTime and adjusted.
+func tryParseDate(x *_exif.Exif, s string) (time.Time, error) {
+ dateStr := strings.TrimRight(s, "\x00")
+ // TODO(bep): look for timezone offset, GPS time, etc.
+ timeZone := time.Local
+ if tz, _ := x.TimeZone(); tz != nil {
+ timeZone = tz
+ }
+ return time.ParseInLocation(exifTimeLayout, dateStr, timeZone)
+}
+
+type exifWalker struct {
+ x *_exif.Exif
+ vals map[string]any
+ includeMatcher *regexp.Regexp
+ excludeMatcher *regexp.Regexp
+}
+
+func (e *exifWalker) Walk(f _exif.FieldName, tag *tiff.Tag) error {
+ name := string(f)
+ if e.excludeMatcher != nil && e.excludeMatcher.MatchString(name) {
+ return nil
+ }
+ if e.includeMatcher != nil && !e.includeMatcher.MatchString(name) {
+ return nil
+ }
+ val, err := decodeTag(e.x, f, tag)
+ if err != nil {
+ return err
+ }
+ e.vals[name] = val
+ return nil
+}
+
+func nullString(in []byte) string {
+ var rv bytes.Buffer
+ for len(in) > 0 {
+ r, size := utf8.DecodeRune(in)
+ if unicode.IsGraphic(r) {
+ rv.WriteRune(r)
+ }
+ in = in[size:]
+ }
+ return rv.String()
+}
+
+var tcodec *tmc.Codec
+
+func init() {
+ var err error
+ tcodec, err = tmc.New()
+ if err != nil {
+ panic(err)
+ }
+}
+
+type Tags map[string]any
+
+func (v *Tags) UnmarshalJSON(b []byte) error {
+ vv := make(map[string]any)
+ if err := tcodec.Unmarshal(b, &vv); err != nil {
+ return err
+ }
+
+ *v = vv
+
+ return nil
+}
+
+func (v Tags) MarshalJSON() ([]byte, error) {
+ return tcodec.Marshal(v)
+}
diff --git a/resources/images/exif/exif_test.go b/resources/images/exif/exif_test.go
new file mode 100644
index 000000000..cd5961404
--- /dev/null
+++ b/resources/images/exif/exif_test.go
@@ -0,0 +1,135 @@
+// 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 exif
+
+import (
+ "encoding/json"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/gohugoio/hugo/htesting/hqt"
+ "github.com/google/go-cmp/cmp"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestExif(t *testing.T) {
+ c := qt.New(t)
+ f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
+ c.Assert(err, qt.IsNil)
+ defer f.Close()
+
+ d, err := NewDecoder(IncludeFields("Lens|Date"))
+ c.Assert(err, qt.IsNil)
+ x, err := d.Decode(f)
+ c.Assert(err, qt.IsNil)
+ c.Assert(x.Date.Format("2006-01-02"), qt.Equals, "2017-10-27")
+
+ // Malaga: https://goo.gl/taazZy
+ c.Assert(x.Lat, qt.Equals, float64(36.59744166666667))
+ c.Assert(x.Long, qt.Equals, float64(-4.50846))
+
+ v, found := x.Tags["LensModel"]
+ c.Assert(found, qt.Equals, true)
+ lensModel, ok := v.(string)
+ c.Assert(ok, qt.Equals, true)
+ c.Assert(lensModel, qt.Equals, "smc PENTAX-DA* 16-50mm F2.8 ED AL [IF] SDM")
+
+ v, found = x.Tags["DateTime"]
+ c.Assert(found, qt.Equals, true)
+ c.Assert(v, hqt.IsSameType, time.Time{})
+
+ // Verify that it survives a round-trip to JSON and back.
+ data, err := json.Marshal(x)
+ c.Assert(err, qt.IsNil)
+ x2 := &ExifInfo{}
+ err = json.Unmarshal(data, x2)
+
+ c.Assert(x2, eq, x)
+}
+
+func TestExifPNG(t *testing.T) {
+ c := qt.New(t)
+
+ f, err := os.Open(filepath.FromSlash("../../testdata/gohugoio.png"))
+ c.Assert(err, qt.IsNil)
+ defer f.Close()
+
+ d, err := NewDecoder()
+ c.Assert(err, qt.IsNil)
+ _, err = d.Decode(f)
+ c.Assert(err, qt.Not(qt.IsNil))
+}
+
+func TestIssue8079(t *testing.T) {
+ c := qt.New(t)
+
+ f, err := os.Open(filepath.FromSlash("../../testdata/iss8079.jpg"))
+ c.Assert(err, qt.IsNil)
+ defer f.Close()
+
+ d, err := NewDecoder()
+ c.Assert(err, qt.IsNil)
+ x, err := d.Decode(f)
+ c.Assert(err, qt.IsNil)
+ c.Assert(x.Tags["ImageDescription"], qt.Equals, "Città del Vaticano #nanoblock #vatican #vaticancity")
+}
+
+func TestNullString(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ in string
+ expect string
+ }{
+ {"foo", "foo"},
+ {"\x20", "\x20"},
+ {"\xc4\x81", "\xc4\x81"}, // \u0101
+ {"\u0160", "\u0160"}, // non-breaking space
+ } {
+ res := nullString([]byte(test.in))
+ c.Assert(res, qt.Equals, test.expect)
+ }
+}
+
+func BenchmarkDecodeExif(b *testing.B) {
+ c := qt.New(b)
+ f, err := os.Open(filepath.FromSlash("../../testdata/sunset.jpg"))
+ c.Assert(err, qt.IsNil)
+ defer f.Close()
+
+ d, err := NewDecoder()
+ c.Assert(err, qt.IsNil)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err = d.Decode(f)
+ c.Assert(err, qt.IsNil)
+ f.Seek(0, 0)
+ }
+}
+
+var eq = qt.CmpEquals(
+ cmp.Comparer(
+ func(v1, v2 *big.Rat) bool {
+ return v1.RatString() == v2.RatString()
+ },
+ ),
+ cmp.Comparer(func(v1, v2 time.Time) bool {
+ return v1.Unix() == v2.Unix()
+ }),
+)
diff --git a/resources/images/filters.go b/resources/images/filters.go
new file mode 100644
index 000000000..90667af7c
--- /dev/null
+++ b/resources/images/filters.go
@@ -0,0 +1,236 @@
+// 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 images provides template functions for manipulating images.
+package images
+
+import (
+ "fmt"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/disintegration/gift"
+ "github.com/spf13/cast"
+)
+
+// Increment for re-generation of images using these filters.
+const filterAPIVersion = 0
+
+type Filters struct {
+}
+
+// Overlay creates a filter that overlays src at position x y.
+func (*Filters) Overlay(src ImageSource, x, y any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(src.Key(), x, y),
+ Filter: overlayFilter{src: src, x: cast.ToInt(x), y: cast.ToInt(y)},
+ }
+}
+
+// Text creates a filter that draws text with the given options.
+func (*Filters) Text(text string, options ...any) gift.Filter {
+ tf := textFilter{
+ text: text,
+ color: "#ffffff",
+ size: 20,
+ x: 10,
+ y: 10,
+ linespacing: 2,
+ }
+
+ var opt maps.Params
+ if len(options) > 0 {
+ opt = maps.MustToParamsAndPrepare(options[0])
+ for option, v := range opt {
+ switch option {
+ case "color":
+ tf.color = cast.ToString(v)
+ case "size":
+ tf.size = cast.ToFloat64(v)
+ case "x":
+ tf.x = cast.ToInt(v)
+ case "y":
+ tf.y = cast.ToInt(v)
+ case "linespacing":
+ tf.linespacing = cast.ToInt(v)
+ case "font":
+ if err, ok := v.(error); ok {
+ panic(fmt.Sprintf("invalid font source: %s", err))
+ }
+ fontSource, ok1 := v.(hugio.ReadSeekCloserProvider)
+ identifier, ok2 := v.(resource.Identifier)
+
+ if !(ok1 && ok2) {
+ panic(fmt.Sprintf("invalid text font source: %T", v))
+ }
+
+ tf.fontSource = fontSource
+
+ // The input value isn't hashable and will not make a stable key.
+ // Replace it with a string in the map used as basis for the
+ // hash string.
+ opt["font"] = identifier.Key()
+
+ }
+ }
+ }
+
+ return filter{
+ Options: newFilterOpts(text, opt),
+ Filter: tf,
+ }
+}
+
+// Brightness creates a filter that changes the brightness of an image.
+// The percentage parameter must be in range (-100, 100).
+func (*Filters) Brightness(percentage any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(percentage),
+ Filter: gift.Brightness(cast.ToFloat32(percentage)),
+ }
+}
+
+// ColorBalance creates a filter that changes the color balance of an image.
+// The percentage parameters for each color channel (red, green, blue) must be in range (-100, 500).
+func (*Filters) ColorBalance(percentageRed, percentageGreen, percentageBlue any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(percentageRed, percentageGreen, percentageBlue),
+ Filter: gift.ColorBalance(cast.ToFloat32(percentageRed), cast.ToFloat32(percentageGreen), cast.ToFloat32(percentageBlue)),
+ }
+}
+
+// Colorize creates a filter that produces a colorized version of an image.
+// The hue parameter is the angle on the color wheel, typically in range (0, 360).
+// The saturation parameter must be in range (0, 100).
+// The percentage parameter specifies the strength of the effect, it must be in range (0, 100).
+func (*Filters) Colorize(hue, saturation, percentage any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(hue, saturation, percentage),
+ Filter: gift.Colorize(cast.ToFloat32(hue), cast.ToFloat32(saturation), cast.ToFloat32(percentage)),
+ }
+}
+
+// Contrast creates a filter that changes the contrast of an image.
+// The percentage parameter must be in range (-100, 100).
+func (*Filters) Contrast(percentage any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(percentage),
+ Filter: gift.Contrast(cast.ToFloat32(percentage)),
+ }
+}
+
+// Gamma creates a filter that performs a gamma correction on an image.
+// The gamma parameter must be positive. Gamma = 1 gives the original image.
+// Gamma less than 1 darkens the image and gamma greater than 1 lightens it.
+func (*Filters) Gamma(gamma any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(gamma),
+ Filter: gift.Gamma(cast.ToFloat32(gamma)),
+ }
+}
+
+// GaussianBlur creates a filter that applies a gaussian blur to an image.
+func (*Filters) GaussianBlur(sigma any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(sigma),
+ Filter: gift.GaussianBlur(cast.ToFloat32(sigma)),
+ }
+}
+
+// Grayscale creates a filter that produces a grayscale version of an image.
+func (*Filters) Grayscale() gift.Filter {
+ return filter{
+ Filter: gift.Grayscale(),
+ }
+}
+
+// Hue creates a filter that rotates the hue of an image.
+// The hue angle shift is typically in range -180 to 180.
+func (*Filters) Hue(shift any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(shift),
+ Filter: gift.Hue(cast.ToFloat32(shift)),
+ }
+}
+
+// Invert creates a filter that negates the colors of an image.
+func (*Filters) Invert() gift.Filter {
+ return filter{
+ Filter: gift.Invert(),
+ }
+}
+
+// Pixelate creates a filter that applies a pixelation effect to an image.
+func (*Filters) Pixelate(size any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(size),
+ Filter: gift.Pixelate(cast.ToInt(size)),
+ }
+}
+
+// Saturation creates a filter that changes the saturation of an image.
+func (*Filters) Saturation(percentage any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(percentage),
+ Filter: gift.Saturation(cast.ToFloat32(percentage)),
+ }
+}
+
+// Sepia creates a filter that produces a sepia-toned version of an image.
+func (*Filters) Sepia(percentage any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(percentage),
+ Filter: gift.Sepia(cast.ToFloat32(percentage)),
+ }
+}
+
+// Sigmoid creates a filter that changes the contrast of an image using a sigmoidal function and returns the adjusted image.
+// It's a non-linear contrast change useful for photo adjustments as it preserves highlight and shadow detail.
+func (*Filters) Sigmoid(midpoint, factor any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(midpoint, factor),
+ Filter: gift.Sigmoid(cast.ToFloat32(midpoint), cast.ToFloat32(factor)),
+ }
+}
+
+// UnsharpMask creates a filter that sharpens an image.
+// The sigma parameter is used in a gaussian function and affects the radius of effect.
+// Sigma must be positive. Sharpen radius roughly equals 3 * sigma.
+// The amount parameter controls how much darker and how much lighter the edge borders become. Typically between 0.5 and 1.5.
+// The threshold parameter controls the minimum brightness change that will be sharpened. Typically between 0 and 0.05.
+func (*Filters) UnsharpMask(sigma, amount, threshold any) gift.Filter {
+ return filter{
+ Options: newFilterOpts(sigma, amount, threshold),
+ Filter: gift.UnsharpMask(cast.ToFloat32(sigma), cast.ToFloat32(amount), cast.ToFloat32(threshold)),
+ }
+}
+
+type filter struct {
+ Options filterOpts
+ gift.Filter
+}
+
+// For cache-busting.
+type filterOpts struct {
+ Version int
+ Vals any
+}
+
+func newFilterOpts(vals ...any) filterOpts {
+ return filterOpts{
+ Version: filterAPIVersion,
+ Vals: vals,
+ }
+}
diff --git a/resources/images/filters_test.go b/resources/images/filters_test.go
new file mode 100644
index 000000000..84c8b540d
--- /dev/null
+++ b/resources/images/filters_test.go
@@ -0,0 +1,33 @@
+// 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 images
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestFilterHash(t *testing.T) {
+ c := qt.New(t)
+
+ f := &Filters{}
+
+ c.Assert(helpers.HashString(f.Grayscale()), qt.Equals, helpers.HashString(f.Grayscale()))
+ c.Assert(helpers.HashString(f.Grayscale()), qt.Not(qt.Equals), helpers.HashString(f.Invert()))
+ c.Assert(helpers.HashString(f.Gamma(32)), qt.Not(qt.Equals), helpers.HashString(f.Gamma(33)))
+ c.Assert(helpers.HashString(f.Gamma(32)), qt.Equals, helpers.HashString(f.Gamma(32)))
+}
diff --git a/resources/images/image.go b/resources/images/image.go
new file mode 100644
index 000000000..4ffbaa229
--- /dev/null
+++ b/resources/images/image.go
@@ -0,0 +1,410 @@
+// 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 images
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "sync"
+
+ "github.com/bep/gowebp/libwebp/webpoptions"
+ "github.com/gohugoio/hugo/resources/images/webp"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/images/exif"
+
+ "github.com/disintegration/gift"
+ "golang.org/x/image/bmp"
+ "golang.org/x/image/tiff"
+
+ "errors"
+
+ "github.com/gohugoio/hugo/common/hugio"
+)
+
+func NewImage(f Format, proc *ImageProcessor, img image.Image, s Spec) *Image {
+ if img != nil {
+ return &Image{
+ Format: f,
+ Proc: proc,
+ Spec: s,
+ imageConfig: &imageConfig{
+ config: imageConfigFromImage(img),
+ configLoaded: true,
+ },
+ }
+ }
+ return &Image{Format: f, Proc: proc, Spec: s, imageConfig: &imageConfig{}}
+}
+
+type Image struct {
+ Format Format
+ Proc *ImageProcessor
+ Spec Spec
+ *imageConfig
+}
+
+func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
+ switch conf.TargetFormat {
+ case JPEG:
+
+ var rgba *image.RGBA
+ quality := conf.Quality
+
+ if nrgba, ok := img.(*image.NRGBA); ok {
+ if nrgba.Opaque() {
+ rgba = &image.RGBA{
+ Pix: nrgba.Pix,
+ Stride: nrgba.Stride,
+ Rect: nrgba.Rect,
+ }
+ }
+ }
+ if rgba != nil {
+ return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality})
+ }
+ return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
+ case PNG:
+ encoder := png.Encoder{CompressionLevel: png.DefaultCompression}
+ return encoder.Encode(w, img)
+
+ case GIF:
+ if giphy, ok := img.(Giphy); ok {
+ g := giphy.GIF()
+ return gif.EncodeAll(w, g)
+ }
+ return gif.Encode(w, img, &gif.Options{
+ NumColors: 256,
+ })
+ case TIFF:
+ return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
+
+ case BMP:
+ return bmp.Encode(w, img)
+ case WEBP:
+ return webp.Encode(
+ w,
+ img, webpoptions.EncodingOptions{
+ Quality: conf.Quality,
+ EncodingPreset: webpoptions.EncodingPreset(conf.Hint),
+ UseSharpYuv: true,
+ },
+ )
+ default:
+ return errors.New("format not supported")
+ }
+}
+
+// Height returns i's height.
+func (i *Image) Height() int {
+ i.initConfig()
+ return i.config.Height
+}
+
+// Width returns i's width.
+func (i *Image) Width() int {
+ i.initConfig()
+ return i.config.Width
+}
+
+func (i Image) WithImage(img image.Image) *Image {
+ i.Spec = nil
+ i.imageConfig = &imageConfig{
+ config: imageConfigFromImage(img),
+ configLoaded: true,
+ }
+
+ return &i
+}
+
+func (i Image) WithSpec(s Spec) *Image {
+ i.Spec = s
+ i.imageConfig = &imageConfig{}
+ return &i
+}
+
+// InitConfig reads the image config from the given reader.
+func (i *Image) InitConfig(r io.Reader) error {
+ var err error
+ i.configInit.Do(func() {
+ i.config, _, err = image.DecodeConfig(r)
+ })
+ return err
+}
+
+func (i *Image) initConfig() error {
+ var err error
+ i.configInit.Do(func() {
+ if i.configLoaded {
+ return
+ }
+
+ var f hugio.ReadSeekCloser
+
+ f, err = i.Spec.ReadSeekCloser()
+ if err != nil {
+ return
+ }
+ defer f.Close()
+
+ i.config, _, err = image.DecodeConfig(f)
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to load image config: %w", err)
+ }
+
+ return nil
+}
+
+func NewImageProcessor(cfg ImagingConfig) (*ImageProcessor, error) {
+ e := cfg.Cfg.Exif
+ exifDecoder, err := exif.NewDecoder(
+ exif.WithDateDisabled(e.DisableDate),
+ exif.WithLatLongDisabled(e.DisableLatLong),
+ exif.ExcludeFields(e.ExcludeFields),
+ exif.IncludeFields(e.IncludeFields),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &ImageProcessor{
+ Cfg: cfg,
+ exifDecoder: exifDecoder,
+ }, nil
+}
+
+type ImageProcessor struct {
+ Cfg ImagingConfig
+ exifDecoder *exif.Decoder
+}
+
+func (p *ImageProcessor) DecodeExif(r io.Reader) (*exif.ExifInfo, error) {
+ return p.exifDecoder.Decode(r)
+}
+
+func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
+ var filters []gift.Filter
+
+ if conf.Rotate != 0 {
+ // Apply any rotation before any resize.
+ filters = append(filters, gift.Rotate(float32(conf.Rotate), color.Transparent, gift.NearestNeighborInterpolation))
+ }
+
+ switch conf.Action {
+ case "resize":
+ filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
+ case "crop":
+ if conf.AnchorStr == smartCropIdentifier {
+ bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
+ if err != nil {
+ return nil, err
+ }
+
+ // First crop using the bounds returned by smartCrop.
+ filters = append(filters, gift.Crop(bounds))
+ // Then center crop the image to get an image the desired size without resizing.
+ filters = append(filters, gift.CropToSize(conf.Width, conf.Height, gift.CenterAnchor))
+
+ } else {
+ filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor))
+ }
+ case "fill":
+ if conf.AnchorStr == smartCropIdentifier {
+ bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
+ if err != nil {
+ return nil, err
+ }
+
+ // First crop it, then resize it.
+ filters = append(filters, gift.Crop(bounds))
+ filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
+
+ } else {
+ filters = append(filters, gift.ResizeToFill(conf.Width, conf.Height, conf.Filter, conf.Anchor))
+ }
+ case "fit":
+ filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
+ default:
+ return nil, fmt.Errorf("unsupported action: %q", conf.Action)
+ }
+
+ img, err := p.Filter(src, filters...)
+ if err != nil {
+ return nil, err
+ }
+
+ return img, nil
+}
+
+func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) {
+
+ filter := gift.New(filters...)
+
+ if giph, ok := src.(Giphy); ok && len(giph.GIF().Image) > 1 {
+ g := giph.GIF()
+ var bounds image.Rectangle
+ firstFrame := g.Image[0]
+ tmp := image.NewNRGBA(firstFrame.Bounds())
+ for i := range g.Image {
+ gift.New().DrawAt(tmp, g.Image[i], g.Image[i].Bounds().Min, gift.OverOperator)
+ bounds = filter.Bounds(tmp.Bounds())
+ dst := image.NewPaletted(bounds, g.Image[i].Palette)
+ filter.Draw(dst, tmp)
+ g.Image[i] = dst
+ }
+ g.Config.Width = bounds.Dx()
+ g.Config.Height = bounds.Dy()
+
+ return giph, nil
+ }
+
+ bounds := filter.Bounds(src.Bounds())
+
+ var dst draw.Image
+ switch src.(type) {
+ case *image.RGBA:
+ dst = image.NewRGBA(bounds)
+ case *image.NRGBA:
+ dst = image.NewNRGBA(bounds)
+ case *image.Gray:
+ dst = image.NewGray(bounds)
+ default:
+ dst = image.NewNRGBA(bounds)
+ }
+ filter.Draw(dst, src)
+
+ return dst, nil
+}
+
+func GetDefaultImageConfig(action string, defaults ImagingConfig) ImageConfig {
+ return ImageConfig{
+ Action: action,
+ Hint: defaults.Hint,
+ Quality: defaults.Cfg.Quality,
+ }
+}
+
+type Spec interface {
+ // Loads the image source.
+ ReadSeekCloser() (hugio.ReadSeekCloser, error)
+}
+
+// Format is an image file format.
+type Format int
+
+const (
+ JPEG Format = iota + 1
+ PNG
+ GIF
+ TIFF
+ BMP
+ WEBP
+)
+
+// RequiresDefaultQuality returns if the default quality needs to be applied to
+// images of this format.
+func (f Format) RequiresDefaultQuality() bool {
+ return f == JPEG || f == WEBP
+}
+
+// SupportsTransparency reports whether it supports transparency in any form.
+func (f Format) SupportsTransparency() bool {
+ return f != JPEG
+}
+
+// DefaultExtension returns the default file extension of this format, starting with a dot.
+// For example: .jpg for JPEG
+func (f Format) DefaultExtension() string {
+ return f.MediaType().FirstSuffix.FullSuffix
+}
+
+// MediaType returns the media type of this image, e.g. image/jpeg for JPEG
+func (f Format) MediaType() media.Type {
+ switch f {
+ case JPEG:
+ return media.JPEGType
+ case PNG:
+ return media.PNGType
+ case GIF:
+ return media.GIFType
+ case TIFF:
+ return media.TIFFType
+ case BMP:
+ return media.BMPType
+ case WEBP:
+ return media.WEBPType
+ default:
+ panic(fmt.Sprintf("%d is not a valid image format", f))
+ }
+}
+
+type imageConfig struct {
+ config image.Config
+ configInit sync.Once
+ configLoaded bool
+}
+
+func imageConfigFromImage(img image.Image) image.Config {
+ b := img.Bounds()
+ return image.Config{Width: b.Max.X, Height: b.Max.Y}
+}
+
+func ToFilters(in any) []gift.Filter {
+ switch v := in.(type) {
+ case []gift.Filter:
+ return v
+ case []filter:
+ vv := make([]gift.Filter, len(v))
+ for i, f := range v {
+ vv[i] = f
+ }
+ return vv
+ case gift.Filter:
+ return []gift.Filter{v}
+ default:
+ panic(fmt.Sprintf("%T is not an image filter", in))
+ }
+}
+
+// IsOpaque returns false if the image has alpha channel and there is at least 1
+// pixel that is not (fully) opaque.
+func IsOpaque(img image.Image) bool {
+ if oim, ok := img.(interface {
+ Opaque() bool
+ }); ok {
+ return oim.Opaque()
+ }
+
+ return false
+}
+
+// ImageSource identifies and decodes an image.
+type ImageSource interface {
+ DecodeImage() (image.Image, error)
+ Key() string
+}
+
+// Giphy represents a GIF Image that may be animated.
+type Giphy interface {
+ image.Image // The first frame.
+ GIF() *gif.GIF // All frames.
+}
diff --git a/resources/images/image_resource.go b/resources/images/image_resource.go
new file mode 100644
index 000000000..e0fec15a0
--- /dev/null
+++ b/resources/images/image_resource.go
@@ -0,0 +1,53 @@
+// Copyright 2022 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 images
+
+import (
+ "image"
+
+ "github.com/gohugoio/hugo/resources/images/exif"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// ImageResource represents an image resource.
+type ImageResource interface {
+ resource.Resource
+ ImageResourceOps
+}
+
+type ImageResourceOps interface {
+ // Height returns the height of the Image.
+ Height() int
+ // Width returns the width of the Image.
+ Width() int
+
+ // Crop an image to match the given dimensions without resizing.
+ // You must provide both width and height.
+ // Use the anchor option to change the crop box anchor point.
+ // {{ $image := $image.Crop "600x400" }}
+ Crop(spec string) (ImageResource, error)
+ Fill(spec string) (ImageResource, error)
+ Fit(spec string) (ImageResource, error)
+ Resize(spec string) (ImageResource, error)
+
+ // Filter applies one or more filters to an Image.
+ // {{ $image := $image.Filter (images.GaussianBlur 6) (images.Pixelate 8) }}
+ Filter(filters ...any) (ImageResource, error)
+
+ // Exif returns an ExifInfo object containing Image metadata.
+ Exif() *exif.ExifInfo
+
+ // Internal
+ DecodeImage() (image.Image, error)
+}
diff --git a/resources/images/overlay.go b/resources/images/overlay.go
new file mode 100644
index 000000000..780e28fd1
--- /dev/null
+++ b/resources/images/overlay.go
@@ -0,0 +1,43 @@
+// Copyright 2020 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 images
+
+import (
+ "fmt"
+ "image"
+ "image/draw"
+
+ "github.com/disintegration/gift"
+)
+
+var _ gift.Filter = (*overlayFilter)(nil)
+
+type overlayFilter struct {
+ src ImageSource
+ x, y int
+}
+
+func (f overlayFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
+ overlaySrc, err := f.src.DecodeImage()
+ if err != nil {
+ panic(fmt.Sprintf("failed to decode image: %s", err))
+ }
+
+ gift.New().Draw(dst, src)
+ gift.New().DrawAt(dst, overlaySrc, image.Pt(f.x, f.y), gift.OverOperator)
+}
+
+func (f overlayFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
+ return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy())
+}
diff --git a/resources/images/resampling.go b/resources/images/resampling.go
new file mode 100644
index 000000000..0cb267684
--- /dev/null
+++ b/resources/images/resampling.go
@@ -0,0 +1,214 @@
+// 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 images
+
+import "math"
+
+// We moved from imaging to the gift package for image processing at some point.
+// That package had more, but also less resampling filters. So we add the missing
+// ones here. They are fairly exotic, but someone may use them, so keep them here
+// for now.
+//
+// The filters below are ported from https://github.com/disintegration/imaging/blob/9aab30e6aa535fe3337b489b76759ef97dfaf362/resize.go#L369
+// MIT License.
+
+var (
+ // Hermite cubic spline filter (BC-spline; B=0; C=0).
+ hermiteResampling = resamp{
+ name: "Hermite",
+ support: 1.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 1.0 {
+ return bcspline(x, 0.0, 0.0)
+ }
+ return 0
+ },
+ }
+
+ // Mitchell-Netravali cubic filter (BC-spline; B=1/3; C=1/3).
+ mitchellNetravaliResampling = resamp{
+ name: "MitchellNetravali",
+ support: 2.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 2.0 {
+ return bcspline(x, 1.0/3.0, 1.0/3.0)
+ }
+ return 0
+ },
+ }
+
+ // Catmull-Rom - sharp cubic filter (BC-spline; B=0; C=0.5).
+ catmullRomResampling = resamp{
+ name: "CatmullRomResampling",
+ support: 2.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 2.0 {
+ return bcspline(x, 0.0, 0.5)
+ }
+ return 0
+ },
+ }
+
+ // BSpline is a smooth cubic filter (BC-spline; B=1; C=0).
+ bSplineResampling = resamp{
+ name: "BSplineResampling",
+ support: 2.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 2.0 {
+ return bcspline(x, 1.0, 0.0)
+ }
+ return 0
+ },
+ }
+
+ // Gaussian blurring filter.
+ gaussianResampling = resamp{
+ name: "GaussianResampling",
+ support: 2.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 2.0 {
+ return float32(math.Exp(float64(-2 * x * x)))
+ }
+ return 0
+ },
+ }
+
+ // Hann-windowed sinc filter (3 lobes).
+ hannResampling = resamp{
+ name: "HannResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * float32(0.5+0.5*math.Cos(math.Pi*float64(x)/3.0))
+ }
+ return 0
+ },
+ }
+
+ hammingResampling = resamp{
+ name: "HammingResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * float32(0.54+0.46*math.Cos(math.Pi*float64(x)/3.0))
+ }
+ return 0
+ },
+ }
+
+ // Blackman-windowed sinc filter (3 lobes).
+ blackmanResampling = resamp{
+ name: "BlackmanResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * float32(0.42-0.5*math.Cos(math.Pi*float64(x)/3.0+math.Pi)+0.08*math.Cos(2.0*math.Pi*float64(x)/3.0))
+ }
+ return 0
+ },
+ }
+
+ bartlettResampling = resamp{
+ name: "BartlettResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * (3.0 - x) / 3.0
+ }
+ return 0
+ },
+ }
+
+ // Welch-windowed sinc filter (parabolic window, 3 lobes).
+ welchResampling = resamp{
+ name: "WelchResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * (1.0 - (x * x / 9.0))
+ }
+ return 0
+ },
+ }
+
+ // Cosine-windowed sinc filter (3 lobes).
+ cosineResampling = resamp{
+ name: "CosineResampling",
+ support: 3.0,
+ kernel: func(x float32) float32 {
+ x = absf32(x)
+ if x < 3.0 {
+ return sinc(x) * float32(math.Cos((math.Pi/2.0)*(float64(x)/3.0)))
+ }
+ return 0
+ },
+ }
+)
+
+// The following code is borrowed from https://raw.githubusercontent.com/disintegration/gift/master/resize.go
+// MIT licensed.
+type resamp struct {
+ name string
+ support float32
+ kernel func(float32) float32
+}
+
+func (r resamp) String() string {
+ return r.name
+}
+
+func (r resamp) Support() float32 {
+ return r.support
+}
+
+func (r resamp) Kernel(x float32) float32 {
+ return r.kernel(x)
+}
+
+func bcspline(x, b, c float32) float32 {
+ if x < 0 {
+ x = -x
+ }
+ if x < 1 {
+ return ((12-9*b-6*c)*x*x*x + (-18+12*b+6*c)*x*x + (6 - 2*b)) / 6
+ }
+ if x < 2 {
+ return ((-b-6*c)*x*x*x + (6*b+30*c)*x*x + (-12*b-48*c)*x + (8*b + 24*c)) / 6
+ }
+ return 0
+}
+
+func absf32(x float32) float32 {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
+
+func sinc(x float32) float32 {
+ if x == 0 {
+ return 1
+ }
+ return float32(math.Sin(math.Pi*float64(x)) / (math.Pi * float64(x)))
+}
diff --git a/resources/images/smartcrop.go b/resources/images/smartcrop.go
new file mode 100644
index 000000000..864c6de0a
--- /dev/null
+++ b/resources/images/smartcrop.go
@@ -0,0 +1,104 @@
+// 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 images
+
+import (
+ "image"
+ "math"
+
+ "github.com/disintegration/gift"
+
+ "github.com/muesli/smartcrop"
+)
+
+const (
+ // Do not change.
+ smartCropIdentifier = "smart"
+
+ // This is just a increment, starting on 1. If Smart Crop improves its cropping, we
+ // need a way to trigger a re-generation of the crops in the wild, so increment this.
+ smartCropVersionNumber = 1
+)
+
+func (p *ImageProcessor) newSmartCropAnalyzer(filter gift.Resampling) smartcrop.Analyzer {
+ return smartcrop.NewAnalyzer(imagingResizer{p: p, filter: filter})
+}
+
+// Needed by smartcrop
+type imagingResizer struct {
+ p *ImageProcessor
+ filter gift.Resampling
+}
+
+func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image {
+ // See https://github.com/gohugoio/hugo/issues/7955#issuecomment-861710681
+ scaleX, scaleY := calcFactorsNfnt(width, height, float64(img.Bounds().Dx()), float64(img.Bounds().Dy()))
+ if width == 0 {
+ width = uint(math.Ceil(float64(img.Bounds().Dx()) / scaleX))
+ }
+ if height == 0 {
+ height = uint(math.Ceil(float64(img.Bounds().Dy()) / scaleY))
+ }
+ result, _ := r.p.Filter(img, gift.Resize(int(width), int(height), r.filter))
+ return result
+}
+
+func (p *ImageProcessor) smartCrop(img image.Image, width, height int, filter gift.Resampling) (image.Rectangle, error) {
+ if width <= 0 || height <= 0 {
+ return image.Rectangle{}, nil
+ }
+
+ srcBounds := img.Bounds()
+ srcW := srcBounds.Dx()
+ srcH := srcBounds.Dy()
+
+ if srcW <= 0 || srcH <= 0 {
+ return image.Rectangle{}, nil
+ }
+
+ if srcW == width && srcH == height {
+ return srcBounds, nil
+ }
+
+ smart := p.newSmartCropAnalyzer(filter)
+
+ rect, err := smart.FindBestCrop(img, width, height)
+ if err != nil {
+ return image.Rectangle{}, err
+ }
+
+ return img.Bounds().Intersect(rect), nil
+}
+
+// Calculates scaling factors using old and new image dimensions.
+// Code borrowed from https://github.com/nfnt/resize/blob/83c6a9932646f83e3267f353373d47347b6036b2/resize.go#L593
+func calcFactorsNfnt(width, height uint, oldWidth, oldHeight float64) (scaleX, scaleY float64) {
+ if width == 0 {
+ if height == 0 {
+ scaleX = 1.0
+ scaleY = 1.0
+ } else {
+ scaleY = oldHeight / float64(height)
+ scaleX = scaleY
+ }
+ } else {
+ scaleX = oldWidth / float64(width)
+ if height == 0 {
+ scaleY = scaleX
+ } else {
+ scaleY = oldHeight / float64(height)
+ }
+ }
+ return
+}
diff --git a/resources/images/text.go b/resources/images/text.go
new file mode 100644
index 000000000..cc67a5d1d
--- /dev/null
+++ b/resources/images/text.go
@@ -0,0 +1,108 @@
+// Copyright 2021 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 images
+
+import (
+ "image"
+ "image/draw"
+ "io"
+ "strings"
+
+ "github.com/disintegration/gift"
+ "github.com/gohugoio/hugo/common/hugio"
+
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/gofont/goregular"
+ "golang.org/x/image/font/opentype"
+ "golang.org/x/image/math/fixed"
+)
+
+var _ gift.Filter = (*textFilter)(nil)
+
+type textFilter struct {
+ text, color string
+ x, y int
+ size float64
+ linespacing int
+ fontSource hugio.ReadSeekCloserProvider
+}
+
+func (f textFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
+ color, err := hexStringToColor(f.color)
+ if err != nil {
+ panic(err)
+ }
+
+ // Load and parse font
+ ttf := goregular.TTF
+ if f.fontSource != nil {
+ rs, err := f.fontSource.ReadSeekCloser()
+ if err != nil {
+ panic(err)
+ }
+ defer rs.Close()
+ ttf, err = io.ReadAll(rs)
+ if err != nil {
+ panic(err)
+ }
+ }
+ otf, err := opentype.Parse(ttf)
+ if err != nil {
+ panic(err)
+ }
+
+ // Set font options
+ face, err := opentype.NewFace(otf, &opentype.FaceOptions{
+ Size: f.size,
+ DPI: 72,
+ Hinting: font.HintingNone,
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ d := font.Drawer{
+ Dst: dst,
+ Src: image.NewUniform(color),
+ Face: face,
+ }
+
+ gift.New().Draw(dst, src)
+
+ // Draw text, consider and include linebreaks
+ maxWidth := dst.Bounds().Dx() - 20
+ fontHeight := face.Metrics().Ascent.Ceil()
+
+ // Correct y position based on font and size
+ f.y = f.y + fontHeight
+
+ // Start position
+ y := f.y
+ d.Dot = fixed.P(f.x, f.y)
+
+ // Draw text and break line at max width
+ parts := strings.Fields(f.text)
+ for _, str := range parts {
+ strWith := font.MeasureString(face, str)
+ if (d.Dot.X.Ceil() + strWith.Ceil()) >= maxWidth {
+ y = y + fontHeight + f.linespacing
+ d.Dot = fixed.P(f.x, y)
+ }
+ d.DrawString(str + " ")
+ }
+}
+
+func (f textFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
+ return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy())
+}
diff --git a/resources/images/webp/webp.go b/resources/images/webp/webp.go
new file mode 100644
index 000000000..28336d2e0
--- /dev/null
+++ b/resources/images/webp/webp.go
@@ -0,0 +1,36 @@
+// Copyright 2021 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.
+
+//go:build extended
+// +build extended
+
+package webp
+
+import (
+ "image"
+ "io"
+
+ "github.com/bep/gowebp/libwebp"
+ "github.com/bep/gowebp/libwebp/webpoptions"
+)
+
+// Encode writes the Image m to w in Webp format with the given
+// options.
+func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error {
+ return libwebp.Encode(w, m, o)
+}
+
+// Supports returns whether webp encoding is supported in this build.
+func Supports() bool {
+ return true
+}
diff --git a/resources/images/webp/webp_notavailable.go b/resources/images/webp/webp_notavailable.go
new file mode 100644
index 000000000..70407f94e
--- /dev/null
+++ b/resources/images/webp/webp_notavailable.go
@@ -0,0 +1,36 @@
+// Copyright 2021 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.
+
+//go:build !extended
+// +build !extended
+
+package webp
+
+import (
+ "image"
+ "io"
+
+ "github.com/gohugoio/hugo/common/herrors"
+
+ "github.com/bep/gowebp/libwebp/webpoptions"
+)
+
+// Encode is only available in the extended version.
+func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error {
+ return herrors.ErrFeatureNotAvailable
+}
+
+// Supports returns whether webp encoding is supported in this build.
+func Supports() bool {
+ return false
+}
diff --git a/resources/integration_test.go b/resources/integration_test.go
new file mode 100644
index 000000000..92abcb612
--- /dev/null
+++ b/resources/integration_test.go
@@ -0,0 +1,96 @@
+// Copyright 2022 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_test
+
+import (
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+// Issue 8931
+func TestImageCache(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- config.toml --
+baseURL = "https://example.org"
+-- content/mybundle/index.md --
+---
+title: "My Bundle"
+---
+-- content/mybundle/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- layouts/foo.html --
+-- layouts/index.html --
+{{ $p := site.GetPage "mybundle"}}
+{{ $img := $p.Resources.Get "pixel.png" }}
+{{ $gif := $img.Resize "1x1 gif" }}
+{{ $bmp := $img.Resize "1x1 bmp" }}
+
+gif: {{ $gif.RelPermalink }}|{{ $gif.MediaType }}|
+bmp: {{ $bmp.RelPermalink }}|{{ $bmp.MediaType }}|
+`
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ Running: true,
+ }).Build()
+
+ assertImages := func() {
+ b.AssertFileContent("public/index.html", `
+ gif: /mybundle/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_1x1_resize_box_3.gif|image/gif|
+ bmp: /mybundle/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_1x1_resize_box_3.bmp|image/bmp|
+
+ `)
+ }
+
+ assertImages()
+
+ b.EditFileReplace("content/mybundle/index.md", func(s string) string { return strings.ReplaceAll(s, "Bundle", "BUNDLE") })
+ b.Build()
+
+ assertImages()
+
+}
+
+func TestSVGError(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- config.toml --
+-- assets/circle.svg --
+<svg height="100" width="100"><circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /></svg>
+-- layouts/index.html --
+{{ $svg := resources.Get "circle.svg" }}
+Width: {{ $svg.Width }}
+`
+
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ Running: true,
+ }).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `error calling Width: this method is only available for raster images. To determine if an image is SVG, you can do {{ if eq .MediaType.SubType "svg" }}{{ end }}`)
+
+}
diff --git a/resources/internal/key.go b/resources/internal/key.go
new file mode 100644
index 000000000..1b45d4cc4
--- /dev/null
+++ b/resources/internal/key.go
@@ -0,0 +1,42 @@
+// 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 internal
+
+import "github.com/gohugoio/hugo/helpers"
+
+// ResourceTransformationKey are provided by the different transformation implementations.
+// It identifies the transformation (name) and its configuration (elements).
+// We combine this in a chain with the rest of the transformations
+// with the target filename and a content hash of the origin to use as cache key.
+type ResourceTransformationKey struct {
+ Name string
+ elements []any
+}
+
+// NewResourceTransformationKey creates a new ResourceTransformationKey from the transformation
+// name and elements. We will create a 64 bit FNV hash from the elements, which when combined
+// with the other key elements should be unique for all practical applications.
+func NewResourceTransformationKey(name string, elements ...any) ResourceTransformationKey {
+ return ResourceTransformationKey{Name: name, elements: elements}
+}
+
+// Value returns the Key as a string.
+// Do not change this without good reasons.
+func (k ResourceTransformationKey) Value() string {
+ if len(k.elements) == 0 {
+ return k.Name
+ }
+
+ return k.Name + "_" + helpers.HashString(k.elements...)
+}
diff --git a/resources/internal/key_test.go b/resources/internal/key_test.go
new file mode 100644
index 000000000..38286333d
--- /dev/null
+++ b/resources/internal/key_test.go
@@ -0,0 +1,36 @@
+// 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 internal
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+type testStruct struct {
+ Name string
+ V1 int64
+ V2 int32
+ V3 int
+ V4 uint64
+}
+
+func TestResourceTransformationKey(t *testing.T) {
+ // We really need this key to be portable across OSes.
+ key := NewResourceTransformationKey("testing",
+ testStruct{Name: "test", V1: int64(10), V2: int32(20), V3: 30, V4: uint64(40)})
+ c := qt.New(t)
+ c.Assert(key.Value(), qt.Equals, "testing_518996646957295636")
+}
diff --git a/resources/jsconfig/jsconfig.go b/resources/jsconfig/jsconfig.go
new file mode 100644
index 000000000..1fd6d6103
--- /dev/null
+++ b/resources/jsconfig/jsconfig.go
@@ -0,0 +1,92 @@
+// Copyright 2020 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 jsconfig
+
+import (
+ "path/filepath"
+ "sort"
+ "sync"
+)
+
+// Builder builds a jsconfig.json file that, currently, is used only to assist
+// intellinsense in editors.
+type Builder struct {
+ sourceRootsMu sync.RWMutex
+ sourceRoots map[string]bool
+}
+
+// NewBuilder creates a new Builder.
+func NewBuilder() *Builder {
+ return &Builder{sourceRoots: make(map[string]bool)}
+}
+
+// Build builds a new Config with paths relative to dir.
+// This method is thread safe.
+func (b *Builder) Build(dir string) *Config {
+ b.sourceRootsMu.RLock()
+ defer b.sourceRootsMu.RUnlock()
+
+ if len(b.sourceRoots) == 0 {
+ return nil
+ }
+ conf := newJSConfig()
+
+ var roots []string
+ for root := range b.sourceRoots {
+ rel, err := filepath.Rel(dir, filepath.Join(root, "*"))
+ if err == nil {
+ roots = append(roots, rel)
+ }
+ }
+ sort.Strings(roots)
+ conf.CompilerOptions.Paths["*"] = roots
+
+ return conf
+}
+
+// AddSourceRoot adds a new source root.
+// This method is thread safe.
+func (b *Builder) AddSourceRoot(root string) {
+ b.sourceRootsMu.RLock()
+ found := b.sourceRoots[root]
+ b.sourceRootsMu.RUnlock()
+
+ if found {
+ return
+ }
+
+ b.sourceRootsMu.Lock()
+ b.sourceRoots[root] = true
+ b.sourceRootsMu.Unlock()
+}
+
+// CompilerOptions holds compilerOptions for jsonconfig.json.
+type CompilerOptions struct {
+ BaseURL string `json:"baseUrl"`
+ Paths map[string][]string `json:"paths"`
+}
+
+// Config holds the data for jsconfig.json.
+type Config struct {
+ CompilerOptions CompilerOptions `json:"compilerOptions"`
+}
+
+func newJSConfig() *Config {
+ return &Config{
+ CompilerOptions: CompilerOptions{
+ BaseURL: ".",
+ Paths: make(map[string][]string),
+ },
+ }
+}
diff --git a/resources/jsconfig/jsconfig_test.go b/resources/jsconfig/jsconfig_test.go
new file mode 100644
index 000000000..9a9657843
--- /dev/null
+++ b/resources/jsconfig/jsconfig_test.go
@@ -0,0 +1,35 @@
+// Copyright 2020 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 jsconfig
+
+import (
+ "path/filepath"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestJsConfigBuilder(t *testing.T) {
+ c := qt.New(t)
+
+ b := NewBuilder()
+ b.AddSourceRoot("/c/assets")
+ b.AddSourceRoot("/d/assets")
+
+ conf := b.Build("/a/b")
+ c.Assert(conf.CompilerOptions.BaseURL, qt.Equals, ".")
+ c.Assert(conf.CompilerOptions.Paths["*"], qt.DeepEquals, []string{filepath.FromSlash("../../c/assets/*"), filepath.FromSlash("../../d/assets/*")})
+
+ c.Assert(NewBuilder().Build("/a/b"), qt.IsNil)
+}
diff --git a/resources/page/integration_test.go b/resources/page/integration_test.go
new file mode 100644
index 000000000..9dc322b4a
--- /dev/null
+++ b/resources/page/integration_test.go
@@ -0,0 +1,138 @@
+// Copyright 2021 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 page_test
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestGroupByLocalizedDate(t *testing.T) {
+
+ files := `
+-- config.toml --
+defaultContentLanguage = 'en'
+defaultContentLanguageInSubdir = true
+[languages]
+[languages.en]
+title = 'My blog'
+weight = 1
+[languages.fr]
+title = 'Mon blogue'
+weight = 2
+[languages.nn]
+title = 'Bloggen min'
+weight = 3
+-- content/p1.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+-- content/p2.md --
+---
+title: "Post 2"
+date: "2020-02-01"
+---
+-- content/p1.fr.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+-- content/p2.fr.md --
+---
+title: "Post 2"
+date: "2020-02-01"
+---
+-- layouts/index.html --
+{{ range $k, $v := site.RegularPages.GroupByDate "January, 2006" }}{{ $k }}|{{ $v.Key }}|{{ $v.Pages }}{{ end }}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ b.AssertFileContent("public/en/index.html", "0|February, 2020|Pages(1)1|January, 2020|Pages(1)")
+ b.AssertFileContent("public/fr/index.html", "0|février, 2020|Pages(1)1|janvier, 2020|Pages(1)")
+}
+
+func TestPagesSortCollation(t *testing.T) {
+
+ files := `
+-- config.toml --
+defaultContentLanguage = 'en'
+defaultContentLanguageInSubdir = true
+[languages]
+[languages.en]
+title = 'My blog'
+weight = 1
+[languages.fr]
+title = 'Mon blogue'
+weight = 2
+[languages.nn]
+title = 'Bloggen min'
+weight = 3
+-- content/p1.md --
+---
+title: "zulu"
+date: "2020-01-01"
+param1: "xylophone"
+tags: ["xylophone", "éclair", "zulu", "emma"]
+---
+-- content/p2.md --
+---
+title: "émotion"
+date: "2020-01-01"
+param1: "violin"
+---
+-- content/p3.md --
+---
+title: "alpha"
+date: "2020-01-01"
+param1: "éclair"
+---
+-- layouts/index.html --
+ByTitle: {{ range site.RegularPages.ByTitle }}{{ .Title }}|{{ end }}
+ByLinkTitle: {{ range site.RegularPages.ByLinkTitle }}{{ .Title }}|{{ end }}
+ByParam: {{ range site.RegularPages.ByParam "param1" }}{{ .Params.param1 }}|{{ end }}
+Tags Alphabetical: {{ range site.Taxonomies.tags.Alphabetical }}{{ .Term }}|{{ end }}
+GroupBy: {{ range site.RegularPages.GroupBy "Title" }}{{ .Key }}|{{ end }}
+{{ with (site.GetPage "p1").Params.tags }}
+Sort: {{ sort . }}
+ByWeight: {{ range site.RegularPages.ByWeight }}{{ .Title }}|{{ end }}
+{{ end }}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ b.AssertFileContent("public/en/index.html", `
+ByTitle: alpha|émotion|zulu|
+ByLinkTitle: alpha|émotion|zulu|
+ByParam: éclair|violin|xylophone
+Tags Alphabetical: éclair|emma|xylophone|zulu|
+GroupBy: alpha|émotion|zulu|
+Sort: [éclair emma xylophone zulu]
+ByWeight: alpha|émotion|zulu|
+`)
+}
diff --git a/resources/page/page.go b/resources/page/page.go
new file mode 100644
index 000000000..50459c465
--- /dev/null
+++ b/resources/page/page.go
@@ -0,0 +1,420 @@
+// 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 page contains the core interfaces and types for the Page resource,
+// a core component in Hugo.
+package page
+
+import (
+ "html/template"
+
+ "github.com/gohugoio/hugo/identity"
+
+ "github.com/bep/gitmap"
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/tpl"
+
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/compare"
+ "github.com/gohugoio/hugo/hugofs/files"
+
+ "github.com/gohugoio/hugo/navigation"
+ "github.com/gohugoio/hugo/related"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/gohugoio/hugo/source"
+)
+
+// Clear clears any global package state.
+func Clear() error {
+ spc.clear()
+ return nil
+}
+
+// AlternativeOutputFormatsProvider provides alternative output formats for a
+// Page.
+type AlternativeOutputFormatsProvider interface {
+ // AlternativeOutputFormats gives the alternative output formats for the
+ // current output.
+ // Note that we use the term "alternative" and not "alternate" here, as it
+ // does not necessarily replace the other format, it is an alternative representation.
+ AlternativeOutputFormats() OutputFormats
+}
+
+// AuthorProvider provides author information.
+type AuthorProvider interface {
+ // Deprecated.
+ Author() Author
+ // Deprecated.
+ Authors() AuthorList
+}
+
+// ChildCareProvider provides accessors to child resources.
+type ChildCareProvider interface {
+ Pages() Pages
+
+ // RegularPages returns a list of pages of kind 'Page'.
+ // In Hugo 0.57 we changed the Pages method so it returns all page
+ // kinds, even sections. If you want the old behaviour, you can
+ // use RegularPages.
+ RegularPages() Pages
+
+ // RegularPagesRecursive returns all regular pages below the current
+ // section.
+ RegularPagesRecursive() Pages
+
+ Resources() resource.Resources
+}
+
+// ContentProvider provides the content related values for a Page.
+type ContentProvider interface {
+ Content() (any, error)
+
+ // Plain returns the Page Content stripped of HTML markup.
+ Plain() string
+
+ // PlainWords returns a string slice from splitting Plain using https://pkg.go.dev/strings#Fields.
+ PlainWords() []string
+
+ // Summary returns a generated summary of the content.
+ // The breakpoint can be set manually by inserting a summary separator in the source file.
+ Summary() template.HTML
+
+ // Truncated returns whether the Summary is truncated or not.
+ Truncated() bool
+
+ // FuzzyWordCount returns the approximate number of words in the content.
+ FuzzyWordCount() int
+
+ // WordCount returns the number of words in the content.
+ WordCount() int
+
+ // ReadingTime returns the reading time based on the length of plain text.
+ ReadingTime() int
+
+ // Len returns the length of the content.
+ Len() int
+}
+
+// FileProvider provides the source file.
+type FileProvider interface {
+ File() source.File
+}
+
+// GetPageProvider provides the GetPage method.
+type GetPageProvider interface {
+ // GetPage looks up a page for the given ref.
+ // {{ with .GetPage "blog" }}{{ .Title }}{{ end }}
+ //
+ // This will return nil when no page could be found, and will return
+ // an error if the ref is ambiguous.
+ GetPage(ref string) (Page, error)
+
+ // GetPageWithTemplateInfo is for internal use only.
+ GetPageWithTemplateInfo(info tpl.Info, ref string) (Page, error)
+}
+
+// GitInfoProvider provides Git info.
+type GitInfoProvider interface {
+ GitInfo() *gitmap.GitInfo
+ CodeOwners() []string
+}
+
+// InSectionPositioner provides section navigation.
+type InSectionPositioner interface {
+ NextInSection() Page
+ PrevInSection() Page
+}
+
+// InternalDependencies is considered an internal interface.
+type InternalDependencies interface {
+ // GetRelatedDocsHandler is for internal use only.
+ GetRelatedDocsHandler() *RelatedDocsHandler
+}
+
+// OutputFormatsProvider provides the OutputFormats of a Page.
+type OutputFormatsProvider interface {
+ OutputFormats() OutputFormats
+}
+
+// Page is the core interface in Hugo.
+type Page interface {
+ ContentProvider
+ TableOfContentsProvider
+ PageWithoutContent
+}
+
+// PageMetaProvider provides page metadata, typically provided via front matter.
+type PageMetaProvider interface {
+ // The 4 page dates
+ resource.Dated
+
+ // Aliases forms the base for redirects generation.
+ Aliases() []string
+
+ // BundleType returns the bundle type: `leaf`, `branch` or an empty string.
+ BundleType() files.ContentClass
+
+ // A configured description.
+ Description() string
+
+ // Whether this is a draft. Will only be true if run with the --buildDrafts (-D) flag.
+ Draft() bool
+
+ // IsHome returns whether this is the home page.
+ IsHome() bool
+
+ // Configured keywords.
+ Keywords() []string
+
+ // The Page Kind. One of page, home, section, taxonomy, term.
+ Kind() string
+
+ // The configured layout to use to render this page. Typically set in front matter.
+ Layout() string
+
+ // The title used for links.
+ LinkTitle() string
+
+ // IsNode returns whether this is an item of one of the list types in Hugo,
+ // i.e. not a regular content
+ IsNode() bool
+
+ // IsPage returns whether this is a regular content
+ IsPage() bool
+
+ // Param looks for a param in Page and then in Site config.
+ Param(key any) (any, error)
+
+ // Path gets the relative path, including file name and extension if relevant,
+ // to the source of this Page. It will be relative to any content root.
+ Path() string
+
+ // This is just a temporary bridge method. Use Path in templates.
+ // Pathc is for internal usage only.
+ Pathc() string
+
+ // The slug, typically defined in front matter.
+ Slug() string
+
+ // This page's language code. Will be the same as the site's.
+ Lang() string
+
+ // IsSection returns whether this is a section
+ IsSection() bool
+
+ // Section returns the first path element below the content root.
+ Section() string
+
+ // Returns a slice of sections (directories if it's a file) to this
+ // Page.
+ SectionsEntries() []string
+
+ // SectionsPath is SectionsEntries joined with a /.
+ SectionsPath() string
+
+ // Sitemap returns the sitemap configuration for this page.
+ Sitemap() config.Sitemap
+
+ // Type is a discriminator used to select layouts etc. It is typically set
+ // in front matter, but will fall back to the root section.
+ Type() string
+
+ // The configured weight, used as the first sort value in the default
+ // page sort if non-zero.
+ Weight() int
+}
+
+// PageRenderProvider provides a way for a Page to render content.
+type PageRenderProvider interface {
+ Render(layout ...string) (template.HTML, error)
+ RenderString(args ...any) (template.HTML, error)
+}
+
+// PageWithoutContent is the Page without any of the content methods.
+type PageWithoutContent interface {
+ RawContentProvider
+ resource.Resource
+ PageMetaProvider
+ resource.LanguageProvider
+
+ // For pages backed by a file.
+ FileProvider
+
+ GitInfoProvider
+
+ // Output formats
+ OutputFormatsProvider
+ AlternativeOutputFormatsProvider
+
+ // Tree navigation
+ ChildCareProvider
+ TreeProvider
+
+ // Horizontal navigation
+ InSectionPositioner
+ PageRenderProvider
+ PaginatorProvider
+ Positioner
+ navigation.PageMenusProvider
+
+ // TODO(bep)
+ AuthorProvider
+
+ // Page lookups/refs
+ GetPageProvider
+ RefProvider
+
+ resource.TranslationKeyProvider
+ TranslationsProvider
+
+ SitesProvider
+
+ // Helper methods
+ ShortcodeInfoProvider
+ compare.Eqer
+
+ // Scratch returns a Scratch that can be used to store temporary state.
+ // Note that this Scratch gets reset on server rebuilds. See Store() for a variant that survives.
+ maps.Scratcher
+
+ // Store returns a Scratch that can be used to store temporary state.
+ // In contrast to Scratch(), this Scratch is not reset on server rebuilds.
+ Store() *maps.Scratch
+
+ RelatedKeywordsProvider
+
+ // GetTerms gets the terms of a given taxonomy,
+ // e.g. GetTerms("categories")
+ GetTerms(taxonomy string) Pages
+
+ // Used in change/dependency tracking.
+ identity.Provider
+
+ DeprecatedWarningPageMethods
+}
+
+// Positioner provides next/prev navigation.
+type Positioner interface {
+ Next() Page
+ Prev() Page
+
+ // Deprecated: Use Prev. Will be removed in Hugo 0.57
+ PrevPage() Page
+
+ // Deprecated: Use Next. Will be removed in Hugo 0.57
+ NextPage() Page
+}
+
+// RawContentProvider provides the raw, unprocessed content of the page.
+type RawContentProvider interface {
+ RawContent() string
+}
+
+// RefProvider provides the methods needed to create reflinks to pages.
+type RefProvider interface {
+ Ref(argsm map[string]any) (string, error)
+
+ // RefFrom is for internal use only.
+ RefFrom(argsm map[string]any, source any) (string, error)
+
+ RelRef(argsm map[string]any) (string, error)
+
+ // RefFrom is for internal use only.
+ RelRefFrom(argsm map[string]any, source any) (string, error)
+}
+
+// RelatedKeywordsProvider allows a Page to be indexed.
+type RelatedKeywordsProvider interface {
+ // Make it indexable as a related.Document
+ // RelatedKeywords is meant for internal usage only.
+ RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error)
+}
+
+// ShortcodeInfoProvider provides info about the shortcodes in a Page.
+type ShortcodeInfoProvider interface {
+ // HasShortcode return whether the page has a shortcode with the given name.
+ // This method is mainly motivated with the Hugo Docs site's need for a list
+ // of pages with the `todo` shortcode in it.
+ HasShortcode(name string) bool
+}
+
+// SitesProvider provide accessors to get sites.
+type SitesProvider interface {
+ Site() Site
+ Sites() Sites
+}
+
+// TableOfContentsProvider provides the table of contents for a Page.
+type TableOfContentsProvider interface {
+ TableOfContents() template.HTML
+}
+
+// TranslationsProvider provides access to any translations.
+type TranslationsProvider interface {
+
+ // IsTranslated returns whether this content file is translated to
+ // other language(s).
+ IsTranslated() bool
+
+ // AllTranslations returns all translations, including the current Page.
+ AllTranslations() Pages
+
+ // Translations returns the translations excluding the current Page.
+ Translations() Pages
+}
+
+// TreeProvider provides section tree navigation.
+type TreeProvider interface {
+
+ // IsAncestor returns whether the current page is an ancestor of the given
+ // Note that this method is not relevant for taxonomy lists and taxonomy terms pages.
+ IsAncestor(other any) (bool, error)
+
+ // CurrentSection returns the page's current section or the page itself if home or a section.
+ // Note that this will return nil for pages that is not regular, home or section pages.
+ CurrentSection() Page
+
+ // IsDescendant returns whether the current page is a descendant of the given
+ // Note that this method is not relevant for taxonomy lists and taxonomy terms pages.
+ IsDescendant(other any) (bool, error)
+
+ // FirstSection returns the section on level 1 below home, e.g. "/docs".
+ // For the home page, this will return itself.
+ FirstSection() Page
+
+ // InSection returns whether the given page is in the current section.
+ // Note that this will always return false for pages that are
+ // not either regular, home or section pages.
+ InSection(other any) (bool, error)
+
+ // Parent returns a section's parent section or a page's section.
+ // To get a section's subsections, see Page's Sections method.
+ Parent() Page
+
+ // Sections returns this section's subsections, if any.
+ // Note that for non-sections, this method will always return an empty list.
+ Sections() Pages
+
+ // Page returns a reference to the Page itself, kept here mostly
+ // for legacy reasons.
+ Page() Page
+}
+
+// DeprecatedWarningPageMethods lists deprecated Page methods that will trigger
+// a WARNING if invoked.
+// This was added in Hugo 0.55.
+type DeprecatedWarningPageMethods any // This was emptied in Hugo 0.93.0.
+
+// Move here to trigger ERROR instead of WARNING.
+// TODO(bep) create wrappers and put into the Page once it has some methods.
+type DeprecatedErrorPageMethods any
diff --git a/resources/page/page_author.go b/resources/page/page_author.go
new file mode 100644
index 000000000..58be20426
--- /dev/null
+++ b/resources/page/page_author.go
@@ -0,0 +1,44 @@
+// 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 page
+
+// AuthorList is a list of all authors and their metadata.
+type AuthorList map[string]Author
+
+// Author contains details about the author of a page.
+type Author struct {
+ GivenName string
+ FamilyName string
+ DisplayName string
+ Thumbnail string
+ Image string
+ ShortBio string
+ LongBio string
+ Email string
+ Social AuthorSocial
+}
+
+// AuthorSocial is a place to put social details per author. These are the
+// standard keys that themes will expect to have available, but can be
+// expanded to any others on a per site basis
+// - website
+// - github
+// - facebook
+// - twitter
+// - pinterest
+// - instagram
+// - youtube
+// - linkedin
+// - skype
+type AuthorSocial map[string]string
diff --git a/resources/page/page_data.go b/resources/page/page_data.go
new file mode 100644
index 000000000..a7806438a
--- /dev/null
+++ b/resources/page/page_data.go
@@ -0,0 +1,42 @@
+// 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 page contains the core interfaces and types for the Page resource,
+// a core component in Hugo.
+package page
+
+import (
+ "fmt"
+)
+
+// Data represents the .Data element in a Page in Hugo. We make this
+// a type so we can do lazy loading of .Data.Pages
+type Data map[string]any
+
+// Pages returns the pages stored with key "pages". If this is a func,
+// it will be invoked.
+func (d Data) Pages() Pages {
+ v, found := d["pages"]
+ if !found {
+ return nil
+ }
+
+ switch vv := v.(type) {
+ case Pages:
+ return vv
+ case func() Pages:
+ return vv()
+ default:
+ panic(fmt.Sprintf("%T is not Pages", v))
+ }
+}
diff --git a/resources/page/page_data_test.go b/resources/page/page_data_test.go
new file mode 100644
index 000000000..c7d764d8a
--- /dev/null
+++ b/resources/page/page_data_test.go
@@ -0,0 +1,55 @@
+// 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 page
+
+import (
+ "bytes"
+ "testing"
+ "text/template"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestPageData(t *testing.T) {
+ c := qt.New(t)
+
+ data := make(Data)
+
+ c.Assert(data.Pages(), qt.IsNil)
+
+ pages := Pages{
+ &testPage{title: "a1"},
+ &testPage{title: "a2"},
+ }
+
+ data["pages"] = pages
+
+ c.Assert(data.Pages(), eq, pages)
+
+ data["pages"] = func() Pages {
+ return pages
+ }
+
+ c.Assert(data.Pages(), eq, pages)
+
+ templ, err := template.New("").Parse(`Pages: {{ .Pages }}`)
+
+ c.Assert(err, qt.IsNil)
+
+ var buff bytes.Buffer
+
+ c.Assert(templ.Execute(&buff, data), qt.IsNil)
+
+ c.Assert(buff.String(), qt.Contains, "Pages(2)")
+}
diff --git a/resources/page/page_generate/.gitignore b/resources/page/page_generate/.gitignore
new file mode 100644
index 000000000..84fd70a9f
--- /dev/null
+++ b/resources/page/page_generate/.gitignore
@@ -0,0 +1 @@
+generate \ No newline at end of file
diff --git a/resources/page/page_generate/generate_page_wrappers.go b/resources/page/page_generate/generate_page_wrappers.go
new file mode 100644
index 000000000..f4b40f717
--- /dev/null
+++ b/resources/page/page_generate/generate_page_wrappers.go
@@ -0,0 +1,280 @@
+// 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 page_generate
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "errors"
+
+ "github.com/gohugoio/hugo/common/maps"
+
+ "github.com/gohugoio/hugo/codegen"
+ "github.com/gohugoio/hugo/resources/page"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/gohugoio/hugo/source"
+)
+
+const header = `// 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.
+
+// This file is autogenerated.
+`
+
+var (
+ pageInterfaceDeprecated = reflect.TypeOf((*page.DeprecatedWarningPageMethods)(nil)).Elem()
+ pageInterface = reflect.TypeOf((*page.Page)(nil)).Elem()
+
+ packageDir = filepath.FromSlash("resources/page")
+)
+
+func Generate(c *codegen.Inspector) error {
+ if err := generateMarshalJSON(c); err != nil {
+ return fmt.Errorf("failed to generate JSON marshaler: %w", err)
+ }
+
+ if err := generateDeprecatedWrappers(c); err != nil {
+ return fmt.Errorf("failed to generate deprecate wrappers: %w", err)
+ }
+
+ if err := generateFileIsZeroWrappers(c); err != nil {
+ return fmt.Errorf("failed to generate file wrappers: %w", err)
+ }
+
+ return nil
+}
+
+func generateMarshalJSON(c *codegen.Inspector) error {
+ filename := filepath.Join(c.ProjectRootDir, packageDir, "page_marshaljson.autogen.go")
+ f, err := os.Create(filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ includes := []reflect.Type{pageInterface}
+
+ // Exclude these methods
+ excludes := []reflect.Type{
+ // We need to evaluate the deprecated vs JSON in the future,
+ // but leave them out for now.
+ pageInterfaceDeprecated,
+
+ // Leave this out for now. We need to revisit the author issue.
+ reflect.TypeOf((*page.AuthorProvider)(nil)).Elem(),
+
+ reflect.TypeOf((*resource.ErrProvider)(nil)).Elem(),
+
+ // navigation.PageMenus
+
+ // Prevent loops.
+ reflect.TypeOf((*page.SitesProvider)(nil)).Elem(),
+ reflect.TypeOf((*page.Positioner)(nil)).Elem(),
+
+ reflect.TypeOf((*page.ChildCareProvider)(nil)).Elem(),
+ reflect.TypeOf((*page.TreeProvider)(nil)).Elem(),
+ reflect.TypeOf((*page.InSectionPositioner)(nil)).Elem(),
+ reflect.TypeOf((*page.PaginatorProvider)(nil)).Elem(),
+ reflect.TypeOf((*maps.Scratcher)(nil)).Elem(),
+ }
+
+ methods := c.MethodsFromTypes(
+ includes,
+ excludes)
+
+ if len(methods) == 0 {
+ return errors.New("no methods found")
+ }
+
+ marshalJSON, pkgImports := methods.ToMarshalJSON(
+ "Page",
+ "github.com/gohugoio/hugo/resources/page",
+ // Exclusion regexps. Matches method names.
+ `\bPage\b`,
+ )
+
+ fmt.Fprintf(f, `%s
+
+package page
+
+%s
+
+
+%s
+
+
+`, header, importsString(pkgImports), marshalJSON)
+
+ return nil
+}
+
+func generateDeprecatedWrappers(c *codegen.Inspector) error {
+ filename := filepath.Join(c.ProjectRootDir, packageDir, "page_wrappers.autogen.go")
+ f, err := os.Create(filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Generate a wrapper for deprecated page methods
+
+ reasons := map[string]string{
+ "IsDraft": "Use .Draft.",
+ "Hugo": "Use the global hugo function.",
+ "LanguagePrefix": "Use .Site.LanguagePrefix.",
+ "GetParam": "Use .Param or .Params.myParam.",
+ "RSSLink": `Use the Output Format's link, e.g. something like:
+ {{ with .OutputFormats.Get "RSS" }}{{ .RelPermalink }}{{ end }}`,
+ "URL": "Use .Permalink or .RelPermalink. If what you want is the front matter URL value, use .Params.url",
+ }
+
+ deprecated := func(name string, tp reflect.Type) string {
+ alternative, found := reasons[name]
+ if !found {
+ panic(fmt.Sprintf("no deprecated reason found for %q", name))
+ }
+
+ return fmt.Sprintf("helpers.Deprecated(%q, %q, true)", "Page."+name, alternative)
+ }
+
+ var buff bytes.Buffer
+
+ methods := c.MethodsFromTypes([]reflect.Type{pageInterfaceDeprecated}, nil)
+
+ for _, m := range methods {
+ fmt.Fprint(&buff, m.Declaration("*pageDeprecated"))
+ fmt.Fprintln(&buff, " {")
+ fmt.Fprintf(&buff, "\t%s\n", deprecated(m.Name, m.Owner))
+ fmt.Fprintf(&buff, "\t%s\n}\n", m.Delegate("p", "p"))
+
+ }
+
+ pkgImports := methods.Imports()
+ // pkgImports := append(methods.Imports(), "github.com/gohugoio/hugo/helpers")
+
+ fmt.Fprintf(f, `%s
+
+package page
+
+%s
+// NewDeprecatedWarningPage adds deprecation warnings to the given implementation.
+func NewDeprecatedWarningPage(p DeprecatedWarningPageMethods) DeprecatedWarningPageMethods {
+ return &pageDeprecated{p: p}
+}
+
+type pageDeprecated struct {
+ p DeprecatedWarningPageMethods
+}
+
+%s
+
+`, header, importsString(pkgImports), buff.String())
+
+ return nil
+}
+
+func generateFileIsZeroWrappers(c *codegen.Inspector) error {
+ filename := filepath.Join(c.ProjectRootDir, packageDir, "zero_file.autogen.go")
+ f, err := os.Create(filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Generate warnings for zero file access
+
+ warning := func(name string, tp reflect.Type) string {
+ msg := fmt.Sprintf(".File.%s on zero object. Wrap it in if or with: {{ with .File }}{{ .%s }}{{ end }}", name, name)
+
+ // We made this a Warning in 0.92.0.
+ // When we remove this construct in 0.93.0, people will get a nil pointer.
+ return fmt.Sprintf("z.log.Warnln(%q)", msg)
+ }
+
+ var buff bytes.Buffer
+
+ methods := c.MethodsFromTypes([]reflect.Type{reflect.TypeOf((*source.File)(nil)).Elem()}, nil)
+
+ for _, m := range methods {
+ if m.Name == "IsZero" {
+ continue
+ }
+ fmt.Fprint(&buff, m.DeclarationNamed("zeroFile"))
+ fmt.Fprintln(&buff, " {")
+ fmt.Fprintf(&buff, "\t%s\n", warning(m.Name, m.Owner))
+ if len(m.Out) > 0 {
+ fmt.Fprintln(&buff, "\treturn")
+ }
+ fmt.Fprintln(&buff, "}")
+
+ }
+
+ pkgImports := append(methods.Imports(), "github.com/gohugoio/hugo/common/loggers", "github.com/gohugoio/hugo/source")
+
+ fmt.Fprintf(f, `%s
+
+package page
+
+%s
+
+// ZeroFile represents a zero value of source.File with warnings if invoked.
+type zeroFile struct {
+ log loggers.Logger
+}
+
+func NewZeroFile(log loggers.Logger) source.File {
+ return zeroFile{log: log}
+}
+
+func (zeroFile) IsZero() bool {
+ return true
+}
+
+%s
+
+`, header, importsString(pkgImports), buff.String())
+
+ return nil
+}
+
+func importsString(imps []string) string {
+ if len(imps) == 0 {
+ return ""
+ }
+
+ if len(imps) == 1 {
+ return fmt.Sprintf("import %q", imps[0])
+ }
+
+ impsStr := "import (\n"
+ for _, imp := range imps {
+ impsStr += fmt.Sprintf("%q\n", imp)
+ }
+
+ return impsStr + ")"
+}
diff --git a/resources/page/page_kinds.go b/resources/page/page_kinds.go
new file mode 100644
index 000000000..719375f66
--- /dev/null
+++ b/resources/page/page_kinds.go
@@ -0,0 +1,47 @@
+// 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 page
+
+import "strings"
+
+const (
+ KindPage = "page"
+
+ // The rest are node types; home page, sections etc.
+
+ KindHome = "home"
+ KindSection = "section"
+
+ // Note tha before Hugo 0.73 these were confusingly named
+ // taxonomy (now: term)
+ // taxonomyTerm (now: taxonomy)
+ KindTaxonomy = "taxonomy"
+ KindTerm = "term"
+)
+
+var kindMap = map[string]string{
+ strings.ToLower(KindPage): KindPage,
+ strings.ToLower(KindHome): KindHome,
+ strings.ToLower(KindSection): KindSection,
+ strings.ToLower(KindTaxonomy): KindTaxonomy,
+ strings.ToLower(KindTerm): KindTerm,
+
+ // Legacy, pre v0.53.0.
+ "taxonomyterm": KindTaxonomy,
+}
+
+// GetKind gets the page kind given a string, empty if not found.
+func GetKind(s string) string {
+ return kindMap[strings.ToLower(s)]
+}
diff --git a/resources/page/page_kinds_test.go b/resources/page/page_kinds_test.go
new file mode 100644
index 000000000..357be6739
--- /dev/null
+++ b/resources/page/page_kinds_test.go
@@ -0,0 +1,37 @@
+// 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 page
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestKind(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ // Add tests for these constants to make sure they don't change
+ c.Assert(KindPage, qt.Equals, "page")
+ c.Assert(KindHome, qt.Equals, "home")
+ c.Assert(KindSection, qt.Equals, "section")
+ c.Assert(KindTaxonomy, qt.Equals, "taxonomy")
+ c.Assert(KindTerm, qt.Equals, "term")
+
+ c.Assert(GetKind("TAXONOMYTERM"), qt.Equals, KindTaxonomy)
+ c.Assert(GetKind("Taxonomy"), qt.Equals, KindTaxonomy)
+ c.Assert(GetKind("Page"), qt.Equals, KindPage)
+ c.Assert(GetKind("Home"), qt.Equals, KindHome)
+ c.Assert(GetKind("SEction"), qt.Equals, KindSection)
+}
diff --git a/resources/page/page_lazy_contentprovider.go b/resources/page/page_lazy_contentprovider.go
new file mode 100644
index 000000000..ba4f8f8ef
--- /dev/null
+++ b/resources/page/page_lazy_contentprovider.go
@@ -0,0 +1,124 @@
+// 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 page
+
+import (
+ "html/template"
+
+ "github.com/gohugoio/hugo/lazy"
+)
+
+// OutputFormatContentProvider represents the method set that is "outputFormat aware" and that we
+// provide lazy initialization for in case they get invoked outside of their normal rendering context, e.g. via .Translations.
+// Note that this set is currently not complete, but should cover the most common use cases.
+// For the others, the implementation will be from the page.NoopPage.
+type OutputFormatContentProvider interface {
+ ContentProvider
+ TableOfContentsProvider
+ PageRenderProvider
+}
+
+// LazyContentProvider initializes itself when read. Each method of the
+// ContentProvider interface initializes a content provider and shares it
+// with other methods.
+//
+// Used in cases where we cannot guarantee whether the content provider
+// will be needed. Must create via NewLazyContentProvider.
+type LazyContentProvider struct {
+ init *lazy.Init
+ cp OutputFormatContentProvider
+}
+
+// NewLazyContentProvider returns a LazyContentProvider initialized with
+// function f. The resulting LazyContentProvider calls f in order to
+// retrieve a ContentProvider
+func NewLazyContentProvider(f func() (OutputFormatContentProvider, error)) *LazyContentProvider {
+ lcp := LazyContentProvider{
+ init: lazy.New(),
+ cp: NopPage,
+ }
+ lcp.init.Add(func() (any, error) {
+ cp, err := f()
+ if err != nil {
+ return nil, err
+ }
+ lcp.cp = cp
+ return nil, nil
+ })
+ return &lcp
+}
+
+func (lcp *LazyContentProvider) Reset() {
+ lcp.init.Reset()
+}
+
+func (lcp *LazyContentProvider) Content() (any, error) {
+ lcp.init.Do()
+ return lcp.cp.Content()
+}
+
+func (lcp *LazyContentProvider) Plain() string {
+ lcp.init.Do()
+ return lcp.cp.Plain()
+}
+
+func (lcp *LazyContentProvider) PlainWords() []string {
+ lcp.init.Do()
+ return lcp.cp.PlainWords()
+}
+
+func (lcp *LazyContentProvider) Summary() template.HTML {
+ lcp.init.Do()
+ return lcp.cp.Summary()
+}
+
+func (lcp *LazyContentProvider) Truncated() bool {
+ lcp.init.Do()
+ return lcp.cp.Truncated()
+}
+
+func (lcp *LazyContentProvider) FuzzyWordCount() int {
+ lcp.init.Do()
+ return lcp.cp.FuzzyWordCount()
+}
+
+func (lcp *LazyContentProvider) WordCount() int {
+ lcp.init.Do()
+ return lcp.cp.WordCount()
+}
+
+func (lcp *LazyContentProvider) ReadingTime() int {
+ lcp.init.Do()
+ return lcp.cp.ReadingTime()
+}
+
+func (lcp *LazyContentProvider) Len() int {
+ lcp.init.Do()
+ return lcp.cp.Len()
+}
+
+func (lcp *LazyContentProvider) Render(layout ...string) (template.HTML, error) {
+ lcp.init.Do()
+ return lcp.cp.Render(layout...)
+}
+
+func (lcp *LazyContentProvider) RenderString(args ...any) (template.HTML, error) {
+ lcp.init.Do()
+ return lcp.cp.RenderString(args...)
+}
+
+func (lcp *LazyContentProvider) TableOfContents() template.HTML {
+ lcp.init.Do()
+ return lcp.cp.TableOfContents()
+}
diff --git a/resources/page/page_marshaljson.autogen.go b/resources/page/page_marshaljson.autogen.go
new file mode 100644
index 000000000..0f73d81ae
--- /dev/null
+++ b/resources/page/page_marshaljson.autogen.go
@@ -0,0 +1,211 @@
+// 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.
+
+// This file is autogenerated.
+
+package page
+
+import (
+ "encoding/json"
+ "github.com/bep/gitmap"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/hugofs/files"
+ "github.com/gohugoio/hugo/identity"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/navigation"
+ "github.com/gohugoio/hugo/source"
+ "html/template"
+ "time"
+)
+
+func MarshalPageToJSON(p Page) ([]byte, error) {
+ content, err := p.Content()
+ if err != nil {
+ return nil, err
+ }
+ plain := p.Plain()
+ plainWords := p.PlainWords()
+ summary := p.Summary()
+ truncated := p.Truncated()
+ fuzzyWordCount := p.FuzzyWordCount()
+ wordCount := p.WordCount()
+ readingTime := p.ReadingTime()
+ length := p.Len()
+ tableOfContents := p.TableOfContents()
+ rawContent := p.RawContent()
+ resourceType := p.ResourceType()
+ mediaType := p.MediaType()
+ permalink := p.Permalink()
+ relPermalink := p.RelPermalink()
+ name := p.Name()
+ title := p.Title()
+ params := p.Params()
+ data := p.Data()
+ date := p.Date()
+ lastmod := p.Lastmod()
+ publishDate := p.PublishDate()
+ expiryDate := p.ExpiryDate()
+ aliases := p.Aliases()
+ bundleType := p.BundleType()
+ description := p.Description()
+ draft := p.Draft()
+ isHome := p.IsHome()
+ keywords := p.Keywords()
+ kind := p.Kind()
+ layout := p.Layout()
+ linkTitle := p.LinkTitle()
+ isNode := p.IsNode()
+ isPage := p.IsPage()
+ path := p.Path()
+ pathc := p.Pathc()
+ slug := p.Slug()
+ lang := p.Lang()
+ isSection := p.IsSection()
+ section := p.Section()
+ sectionsEntries := p.SectionsEntries()
+ sectionsPath := p.SectionsPath()
+ sitemap := p.Sitemap()
+ typ := p.Type()
+ weight := p.Weight()
+ language := p.Language()
+ file := p.File()
+ gitInfo := p.GitInfo()
+ outputFormats := p.OutputFormats()
+ alternativeOutputFormats := p.AlternativeOutputFormats()
+ menus := p.Menus()
+ translationKey := p.TranslationKey()
+ isTranslated := p.IsTranslated()
+ allTranslations := p.AllTranslations()
+ translations := p.Translations()
+ getIdentity := p.GetIdentity()
+
+ s := struct {
+ Content any
+ Plain string
+ PlainWords []string
+ Summary template.HTML
+ Truncated bool
+ FuzzyWordCount int
+ WordCount int
+ ReadingTime int
+ Len int
+ TableOfContents template.HTML
+ RawContent string
+ ResourceType string
+ MediaType media.Type
+ Permalink string
+ RelPermalink string
+ Name string
+ Title string
+ Params maps.Params
+ Data any
+ Date time.Time
+ Lastmod time.Time
+ PublishDate time.Time
+ ExpiryDate time.Time
+ Aliases []string
+ BundleType files.ContentClass
+ Description string
+ Draft bool
+ IsHome bool
+ Keywords []string
+ Kind string
+ Layout string
+ LinkTitle string
+ IsNode bool
+ IsPage bool
+ Path string
+ Pathc string
+ Slug string
+ Lang string
+ IsSection bool
+ Section string
+ SectionsEntries []string
+ SectionsPath string
+ Sitemap config.Sitemap
+ Type string
+ Weight int
+ Language *langs.Language
+ File source.File
+ GitInfo *gitmap.GitInfo
+ OutputFormats OutputFormats
+ AlternativeOutputFormats OutputFormats
+ Menus navigation.PageMenus
+ TranslationKey string
+ IsTranslated bool
+ AllTranslations Pages
+ Translations Pages
+ GetIdentity identity.Identity
+ }{
+ Content: content,
+ Plain: plain,
+ PlainWords: plainWords,
+ Summary: summary,
+ Truncated: truncated,
+ FuzzyWordCount: fuzzyWordCount,
+ WordCount: wordCount,
+ ReadingTime: readingTime,
+ Len: length,
+ TableOfContents: tableOfContents,
+ RawContent: rawContent,
+ ResourceType: resourceType,
+ MediaType: mediaType,
+ Permalink: permalink,
+ RelPermalink: relPermalink,
+ Name: name,
+ Title: title,
+ Params: params,
+ Data: data,
+ Date: date,
+ Lastmod: lastmod,
+ PublishDate: publishDate,
+ ExpiryDate: expiryDate,
+ Aliases: aliases,
+ BundleType: bundleType,
+ Description: description,
+ Draft: draft,
+ IsHome: isHome,
+ Keywords: keywords,
+ Kind: kind,
+ Layout: layout,
+ LinkTitle: linkTitle,
+ IsNode: isNode,
+ IsPage: isPage,
+ Path: path,
+ Pathc: pathc,
+ Slug: slug,
+ Lang: lang,
+ IsSection: isSection,
+ Section: section,
+ SectionsEntries: sectionsEntries,
+ SectionsPath: sectionsPath,
+ Sitemap: sitemap,
+ Type: typ,
+ Weight: weight,
+ Language: language,
+ File: file,
+ GitInfo: gitInfo,
+ OutputFormats: outputFormats,
+ AlternativeOutputFormats: alternativeOutputFormats,
+ Menus: menus,
+ TranslationKey: translationKey,
+ IsTranslated: isTranslated,
+ AllTranslations: allTranslations,
+ Translations: translations,
+ GetIdentity: getIdentity,
+ }
+
+ return json.Marshal(&s)
+}
diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go
new file mode 100644
index 000000000..c302ff21a
--- /dev/null
+++ b/resources/page/page_matcher.go
@@ -0,0 +1,142 @@
+// Copyright 2020 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 page
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/hugofs/glob"
+ "github.com/mitchellh/mapstructure"
+)
+
+// A PageMatcher can be used to match a Page with Glob patterns.
+// Note that the pattern matching is case insensitive.
+type PageMatcher struct {
+ // A Glob pattern matching the content path below /content.
+ // Expects Unix-styled slashes.
+ // Note that this is the virtual path, so it starts at the mount root
+ // with a leading "/".
+ Path string
+
+ // A Glob pattern matching the Page's Kind(s), e.g. "{home,section}"
+ Kind string
+
+ // A Glob pattern matching the Page's language, e.g. "{en,sv}".
+ Lang string
+
+ // A Glob pattern matching the Page's Environment, e.g. "{production,development}".
+ Environment string
+}
+
+// Matches returns whether p matches this matcher.
+func (m PageMatcher) Matches(p Page) bool {
+ if m.Kind != "" {
+ g, err := glob.GetGlob(m.Kind)
+ if err == nil && !g.Match(p.Kind()) {
+ return false
+ }
+ }
+
+ if m.Lang != "" {
+ g, err := glob.GetGlob(m.Lang)
+ if err == nil && !g.Match(p.Lang()) {
+ return false
+ }
+ }
+
+ if m.Path != "" {
+ g, err := glob.GetGlob(m.Path)
+ // TODO(bep) Path() vs filepath vs leading slash.
+ p := strings.ToLower(filepath.ToSlash(p.Pathc()))
+ if !(strings.HasPrefix(p, "/")) {
+ p = "/" + p
+ }
+ if err == nil && !g.Match(p) {
+ return false
+ }
+ }
+
+ if m.Environment != "" {
+ g, err := glob.GetGlob(m.Environment)
+ if err == nil && !g.Match(p.Site().Hugo().Environment) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// DecodeCascade decodes in which could be either a map or a slice of maps.
+func DecodeCascade(in any) (map[PageMatcher]maps.Params, error) {
+ m, err := maps.ToSliceStringMap(in)
+ if err != nil {
+ return map[PageMatcher]maps.Params{
+ {}: maps.ToStringMap(in),
+ }, nil
+ }
+
+ cascade := make(map[PageMatcher]maps.Params)
+
+ for _, vv := range m {
+ var m PageMatcher
+ if mv, found := vv["_target"]; found {
+ err := DecodePageMatcher(mv, &m)
+ if err != nil {
+ return nil, err
+ }
+ }
+ c, found := cascade[m]
+ if found {
+ // Merge
+ for k, v := range vv {
+ if _, found := c[k]; !found {
+ c[k] = v
+ }
+ }
+ } else {
+ cascade[m] = vv
+ }
+ }
+
+ return cascade, nil
+}
+
+// DecodePageMatcher decodes m into v.
+func DecodePageMatcher(m any, v *PageMatcher) error {
+ if err := mapstructure.WeakDecode(m, v); err != nil {
+ return err
+ }
+
+ v.Kind = strings.ToLower(v.Kind)
+ if v.Kind != "" {
+ g, _ := glob.GetGlob(v.Kind)
+ found := false
+ for _, k := range kindMap {
+ if g.Match(k) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("%q did not match a valid Page Kind", v.Kind)
+ }
+ }
+
+ v.Path = filepath.ToSlash(strings.ToLower(v.Path))
+
+ return nil
+}
diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
new file mode 100644
index 000000000..4a59dc502
--- /dev/null
+++ b/resources/page/page_matcher_test.go
@@ -0,0 +1,83 @@
+// Copyright 2020 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 page
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/gohugoio/hugo/common/hugo"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestPageMatcher(t *testing.T) {
+ c := qt.New(t)
+ developmentTestSite := testSite{h: hugo.NewInfo("development", nil)}
+ productionTestSite := testSite{h: hugo.NewInfo("production", nil)}
+
+ p1, p2, p3 :=
+ &testPage{path: "/p1", kind: "section", lang: "en", site: developmentTestSite},
+ &testPage{path: "p2", kind: "page", lang: "no", site: productionTestSite},
+ &testPage{path: "p3", kind: "page", lang: "en"}
+
+ c.Run("Matches", func(c *qt.C) {
+ m := PageMatcher{Kind: "section"}
+
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, false)
+
+ m = PageMatcher{Kind: "page"}
+ c.Assert(m.Matches(p1), qt.Equals, false)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ m = PageMatcher{Kind: "page", Path: "/p2"}
+ c.Assert(m.Matches(p1), qt.Equals, false)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, false)
+
+ m = PageMatcher{Path: "/p*"}
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ m = PageMatcher{Lang: "en"}
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, false)
+ c.Assert(m.Matches(p3), qt.Equals, true)
+
+ m = PageMatcher{Environment: "development"}
+ c.Assert(m.Matches(p1), qt.Equals, true)
+ c.Assert(m.Matches(p2), qt.Equals, false)
+ c.Assert(m.Matches(p3), qt.Equals, false)
+
+ m = PageMatcher{Environment: "production"}
+ c.Assert(m.Matches(p1), qt.Equals, false)
+ c.Assert(m.Matches(p2), qt.Equals, true)
+ c.Assert(m.Matches(p3), qt.Equals, false)
+ })
+
+ c.Run("Decode", func(c *qt.C) {
+ var v PageMatcher
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "foo"}, &v), qt.Not(qt.IsNil))
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "{foo,bar}"}, &v), qt.Not(qt.IsNil))
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "taxonomy"}, &v), qt.IsNil)
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "{taxonomy,foo}"}, &v), qt.IsNil)
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "{taxonomy,term}"}, &v), qt.IsNil)
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "*"}, &v), qt.IsNil)
+ c.Assert(DecodePageMatcher(map[string]any{"kind": "home", "path": filepath.FromSlash("/a/b/**")}, &v), qt.IsNil)
+ c.Assert(v, qt.Equals, PageMatcher{Kind: "home", Path: "/a/b/**"})
+ })
+}
diff --git a/resources/page/page_nop.go b/resources/page/page_nop.go
new file mode 100644
index 000000000..cdc5fd8b1
--- /dev/null
+++ b/resources/page/page_nop.go
@@ -0,0 +1,515 @@
+// 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 page contains the core interfaces and types for the Page resource,
+// a core component in Hugo.
+package page
+
+import (
+ "html/template"
+ "time"
+
+ "github.com/gohugoio/hugo/identity"
+
+ "github.com/gohugoio/hugo/hugofs/files"
+ "github.com/gohugoio/hugo/tpl"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/bep/gitmap"
+ "github.com/gohugoio/hugo/navigation"
+
+ "github.com/gohugoio/hugo/common/hugo"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/source"
+
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/related"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+var (
+ NopPage Page = new(nopPage)
+ NilPage *nopPage
+)
+
+// PageNop implements Page, but does nothing.
+type nopPage int
+
+func (p *nopPage) Err() resource.ResourceError {
+ return nil
+}
+
+func (p *nopPage) Aliases() []string {
+ return nil
+}
+
+func (p *nopPage) Sitemap() config.Sitemap {
+ return config.Sitemap{}
+}
+
+func (p *nopPage) Layout() string {
+ return ""
+}
+
+func (p *nopPage) RSSLink() template.URL {
+ return ""
+}
+
+func (p *nopPage) Author() Author {
+ return Author{}
+}
+
+func (p *nopPage) Authors() AuthorList {
+ return nil
+}
+
+func (p *nopPage) AllTranslations() Pages {
+ return nil
+}
+
+func (p *nopPage) LanguagePrefix() string {
+ return ""
+}
+
+func (p *nopPage) AlternativeOutputFormats() OutputFormats {
+ return nil
+}
+
+func (p *nopPage) BaseFileName() string {
+ return ""
+}
+
+func (p *nopPage) BundleType() files.ContentClass {
+ return ""
+}
+
+func (p *nopPage) Content() (any, error) {
+ return "", nil
+}
+
+func (p *nopPage) ContentBaseName() string {
+ return ""
+}
+
+func (p *nopPage) CurrentSection() Page {
+ return nil
+}
+
+func (p *nopPage) Data() any {
+ return nil
+}
+
+func (p *nopPage) Date() (t time.Time) {
+ return
+}
+
+func (p *nopPage) Description() string {
+ return ""
+}
+
+func (p *nopPage) RefFrom(argsm map[string]any, source any) (string, error) {
+ return "", nil
+}
+
+func (p *nopPage) RelRefFrom(argsm map[string]any, source any) (string, error) {
+ return "", nil
+}
+
+func (p *nopPage) Dir() string {
+ return ""
+}
+
+func (p *nopPage) Draft() bool {
+ return false
+}
+
+func (p *nopPage) Eq(other any) bool {
+ return p == other
+}
+
+func (p *nopPage) ExpiryDate() (t time.Time) {
+ return
+}
+
+func (p *nopPage) Ext() string {
+ return ""
+}
+
+func (p *nopPage) Extension() string {
+ return ""
+}
+
+var nilFile *source.FileInfo
+
+func (p *nopPage) File() source.File {
+ return nilFile
+}
+
+func (p *nopPage) FileInfo() hugofs.FileMetaInfo {
+ return nil
+}
+
+func (p *nopPage) Filename() string {
+ return ""
+}
+
+func (p *nopPage) FirstSection() Page {
+ return nil
+}
+
+func (p *nopPage) FuzzyWordCount() int {
+ return 0
+}
+
+func (p *nopPage) GetPage(ref string) (Page, error) {
+ return nil, nil
+}
+
+func (p *nopPage) GetPageWithTemplateInfo(info tpl.Info, ref string) (Page, error) {
+ return nil, nil
+}
+
+func (p *nopPage) GetParam(key string) any {
+ return nil
+}
+
+func (p *nopPage) GetTerms(taxonomy string) Pages {
+ return nil
+}
+
+func (p *nopPage) GitInfo() *gitmap.GitInfo {
+ return nil
+}
+
+func (p *nopPage) CodeOwners() []string {
+ return nil
+}
+
+func (p *nopPage) HasMenuCurrent(menuID string, me *navigation.MenuEntry) bool {
+ return false
+}
+
+func (p *nopPage) HasShortcode(name string) bool {
+ return false
+}
+
+func (p *nopPage) Hugo() (h hugo.Info) {
+ return
+}
+
+func (p *nopPage) InSection(other any) (bool, error) {
+ return false, nil
+}
+
+func (p *nopPage) IsAncestor(other any) (bool, error) {
+ return false, nil
+}
+
+func (p *nopPage) IsDescendant(other any) (bool, error) {
+ return false, nil
+}
+
+func (p *nopPage) IsDraft() bool {
+ return false
+}
+
+func (p *nopPage) IsHome() bool {
+ return false
+}
+
+func (p *nopPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool {
+ return false
+}
+
+func (p *nopPage) IsNode() bool {
+ return false
+}
+
+func (p *nopPage) IsPage() bool {
+ return false
+}
+
+func (p *nopPage) IsSection() bool {
+ return false
+}
+
+func (p *nopPage) IsTranslated() bool {
+ return false
+}
+
+func (p *nopPage) Keywords() []string {
+ return nil
+}
+
+func (p *nopPage) Kind() string {
+ return ""
+}
+
+func (p *nopPage) Lang() string {
+ return ""
+}
+
+func (p *nopPage) Language() *langs.Language {
+ return nil
+}
+
+func (p *nopPage) Lastmod() (t time.Time) {
+ return
+}
+
+func (p *nopPage) Len() int {
+ return 0
+}
+
+func (p *nopPage) LinkTitle() string {
+ return ""
+}
+
+func (p *nopPage) LogicalName() string {
+ return ""
+}
+
+func (p *nopPage) MediaType() (m media.Type) {
+ return
+}
+
+func (p *nopPage) Menus() (m navigation.PageMenus) {
+ return
+}
+
+func (p *nopPage) Name() string {
+ return ""
+}
+
+func (p *nopPage) Next() Page {
+ return nil
+}
+
+func (p *nopPage) OutputFormats() OutputFormats {
+ return nil
+}
+
+func (p *nopPage) Pages() Pages {
+ return nil
+}
+
+func (p *nopPage) RegularPages() Pages {
+ return nil
+}
+
+func (p *nopPage) RegularPagesRecursive() Pages {
+ return nil
+}
+
+func (p *nopPage) Paginate(seq any, options ...any) (*Pager, error) {
+ return nil, nil
+}
+
+func (p *nopPage) Paginator(options ...any) (*Pager, error) {
+ return nil, nil
+}
+
+func (p *nopPage) Param(key any) (any, error) {
+ return nil, nil
+}
+
+func (p *nopPage) Params() maps.Params {
+ return nil
+}
+
+func (p *nopPage) Page() Page {
+ return p
+}
+
+func (p *nopPage) Parent() Page {
+ return nil
+}
+
+func (p *nopPage) Path() string {
+ return ""
+}
+
+func (p *nopPage) Pathc() string {
+ return ""
+}
+
+func (p *nopPage) Permalink() string {
+ return ""
+}
+
+func (p *nopPage) Plain() string {
+ return ""
+}
+
+func (p *nopPage) PlainWords() []string {
+ return nil
+}
+
+func (p *nopPage) Prev() Page {
+ return nil
+}
+
+func (p *nopPage) PublishDate() (t time.Time) {
+ return
+}
+
+func (p *nopPage) PrevInSection() Page {
+ return nil
+}
+
+func (p *nopPage) NextInSection() Page {
+ return nil
+}
+
+func (p *nopPage) PrevPage() Page {
+ return nil
+}
+
+func (p *nopPage) NextPage() Page {
+ return nil
+}
+
+func (p *nopPage) RawContent() string {
+ return ""
+}
+
+func (p *nopPage) ReadingTime() int {
+ return 0
+}
+
+func (p *nopPage) Ref(argsm map[string]any) (string, error) {
+ return "", nil
+}
+
+func (p *nopPage) RelPermalink() string {
+ return ""
+}
+
+func (p *nopPage) RelRef(argsm map[string]any) (string, error) {
+ return "", nil
+}
+
+func (p *nopPage) Render(layout ...string) (template.HTML, error) {
+ return "", nil
+}
+
+func (p *nopPage) RenderString(args ...any) (template.HTML, error) {
+ return "", nil
+}
+
+func (p *nopPage) ResourceType() string {
+ return ""
+}
+
+func (p *nopPage) Resources() resource.Resources {
+ return nil
+}
+
+func (p *nopPage) Scratch() *maps.Scratch {
+ return nil
+}
+
+func (p *nopPage) Store() *maps.Scratch {
+ return nil
+}
+
+func (p *nopPage) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
+ return nil, nil
+}
+
+func (p *nopPage) Section() string {
+ return ""
+}
+
+func (p *nopPage) Sections() Pages {
+ return nil
+}
+
+func (p *nopPage) SectionsEntries() []string {
+ return nil
+}
+
+func (p *nopPage) SectionsPath() string {
+ return ""
+}
+
+func (p *nopPage) Site() Site {
+ return nil
+}
+
+func (p *nopPage) Sites() Sites {
+ return nil
+}
+
+func (p *nopPage) Slug() string {
+ return ""
+}
+
+func (p *nopPage) String() string {
+ return "nopPage"
+}
+
+func (p *nopPage) Summary() template.HTML {
+ return ""
+}
+
+func (p *nopPage) TableOfContents() template.HTML {
+ return ""
+}
+
+func (p *nopPage) Title() string {
+ return ""
+}
+
+func (p *nopPage) TranslationBaseName() string {
+ return ""
+}
+
+func (p *nopPage) TranslationKey() string {
+ return ""
+}
+
+func (p *nopPage) Translations() Pages {
+ return nil
+}
+
+func (p *nopPage) Truncated() bool {
+ return false
+}
+
+func (p *nopPage) Type() string {
+ return ""
+}
+
+func (p *nopPage) URL() string {
+ return ""
+}
+
+func (p *nopPage) UniqueID() string {
+ return ""
+}
+
+func (p *nopPage) Weight() int {
+ return 0
+}
+
+func (p *nopPage) WordCount() int {
+ return 0
+}
+
+func (p *nopPage) GetIdentity() identity.Identity {
+ return identity.NewPathIdentity("content", "foo/bar.md")
+}
diff --git a/resources/page/page_outputformat.go b/resources/page/page_outputformat.go
new file mode 100644
index 000000000..44f290025
--- /dev/null
+++ b/resources/page/page_outputformat.go
@@ -0,0 +1,95 @@
+// 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 page contains the core interfaces and types for the Page resource,
+// a core component in Hugo.
+package page
+
+import (
+ "strings"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/output"
+)
+
+// OutputFormats holds a list of the relevant output formats for a given page.
+type OutputFormats []OutputFormat
+
+// OutputFormat links to a representation of a resource.
+type OutputFormat struct {
+ // Rel contains a value that can be used to construct a rel link.
+ // This is value is fetched from the output format definition.
+ // Note that for pages with only one output format,
+ // this method will always return "canonical".
+ // As an example, the AMP output format will, by default, return "amphtml".
+ //
+ // See:
+ // https://www.ampproject.org/docs/guides/deploy/discovery
+ //
+ // Most other output formats will have "alternate" as value for this.
+ Rel string
+
+ Format output.Format
+
+ relPermalink string
+ permalink string
+}
+
+// Name returns this OutputFormat's name, i.e. HTML, AMP, JSON etc.
+func (o OutputFormat) Name() string {
+ return o.Format.Name
+}
+
+// MediaType returns this OutputFormat's MediaType (MIME type).
+func (o OutputFormat) MediaType() media.Type {
+ return o.Format.MediaType
+}
+
+// Permalink returns the absolute permalink to this output format.
+func (o OutputFormat) Permalink() string {
+ return o.permalink
+}
+
+// RelPermalink returns the relative permalink to this output format.
+func (o OutputFormat) RelPermalink() string {
+ return o.relPermalink
+}
+
+func NewOutputFormat(relPermalink, permalink string, isCanonical bool, f output.Format) OutputFormat {
+ isUserConfigured := true
+ for _, d := range output.DefaultFormats {
+ if strings.EqualFold(d.Name, f.Name) {
+ isUserConfigured = false
+ }
+ }
+ rel := f.Rel
+ // If the output format is the canonical format for the content, we want
+ // to specify this in the "rel" attribute of an HTML "link" element.
+ // However, for custom output formats, we don't want to surprise users by
+ // overwriting "rel"
+ if isCanonical && !isUserConfigured {
+ rel = "canonical"
+ }
+ return OutputFormat{Rel: rel, Format: f, relPermalink: relPermalink, permalink: permalink}
+}
+
+// Get gets a OutputFormat given its name, i.e. json, html etc.
+// It returns nil if none found.
+func (o OutputFormats) Get(name string) *OutputFormat {
+ for _, f := range o {
+ if strings.EqualFold(f.Format.Name, name) {
+ return &f
+ }
+ }
+ return nil
+}
diff --git a/resources/page/page_paths.go b/resources/page/page_paths.go
new file mode 100644
index 000000000..3d34866d1
--- /dev/null
+++ b/resources/page/page_paths.go
@@ -0,0 +1,342 @@
+// 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 page
+
+import (
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/output"
+)
+
+const slash = "/"
+
+// TargetPathDescriptor describes how a file path for a given resource
+// should look like on the file system. The same descriptor is then later used to
+// create both the permalinks and the relative links, paginator URLs etc.
+//
+// The big motivating behind this is to have only one source of truth for URLs,
+// and by that also get rid of most of the fragile string parsing/encoding etc.
+//
+//
+type TargetPathDescriptor struct {
+ PathSpec *helpers.PathSpec
+
+ Type output.Format
+ Kind string
+
+ Sections []string
+
+ // For regular content pages this is either
+ // 1) the Slug, if set,
+ // 2) the file base name (TranslationBaseName).
+ BaseName string
+
+ // Source directory.
+ Dir string
+
+ // Typically a language prefix added to file paths.
+ PrefixFilePath string
+
+ // Typically a language prefix added to links.
+ PrefixLink string
+
+ // If in multihost mode etc., every link/path needs to be prefixed, even
+ // if set in URL.
+ ForcePrefix bool
+
+ // URL from front matter if set. Will override any Slug etc.
+ URL string
+
+ // Used to create paginator links.
+ Addends string
+
+ // The expanded permalink if defined for the section, ready to use.
+ ExpandedPermalink string
+
+ // Some types cannot have uglyURLs, even if globally enabled, RSS being one example.
+ UglyURLs bool
+}
+
+// TODO(bep) move this type.
+type TargetPaths struct {
+
+ // Where to store the file on disk relative to the publish dir. OS slashes.
+ TargetFilename string
+
+ // The directory to write sub-resources of the above.
+ SubResourceBaseTarget string
+
+ // The base for creating links to sub-resources of the above.
+ SubResourceBaseLink string
+
+ // The relative permalink to this resources. Unix slashes.
+ Link string
+}
+
+func (p TargetPaths) RelPermalink(s *helpers.PathSpec) string {
+ return s.PrependBasePath(p.Link, false)
+}
+
+func (p TargetPaths) PermalinkForOutputFormat(s *helpers.PathSpec, f output.Format) string {
+ var baseURL string
+ var err error
+ if f.Protocol != "" {
+ baseURL, err = s.BaseURL.WithProtocol(f.Protocol)
+ if err != nil {
+ return ""
+ }
+ } else {
+ baseURL = s.BaseURL.String()
+ }
+
+ return s.PermalinkForBaseURL(p.Link, baseURL)
+}
+
+func isHtmlIndex(s string) bool {
+ return strings.HasSuffix(s, "/index.html")
+}
+
+func CreateTargetPaths(d TargetPathDescriptor) (tp TargetPaths) {
+ if d.Type.Name == "" {
+ panic("CreateTargetPath: missing type")
+ }
+
+ // Normalize all file Windows paths to simplify what's next.
+ if helpers.FilePathSeparator != slash {
+ d.Dir = filepath.ToSlash(d.Dir)
+ d.PrefixFilePath = filepath.ToSlash(d.PrefixFilePath)
+
+ }
+
+ if d.URL != "" && !strings.HasPrefix(d.URL, "/") {
+ // Treat this as a context relative URL
+ d.ForcePrefix = true
+ }
+
+ pagePath := slash
+ fullSuffix := d.Type.MediaType.FirstSuffix.FullSuffix
+
+ var (
+ pagePathDir string
+ link string
+ linkDir string
+ )
+
+ // The top level index files, i.e. the home page etc., needs
+ // the index base even when uglyURLs is enabled.
+ needsBase := true
+
+ isUgly := d.UglyURLs && !d.Type.NoUgly
+ baseNameSameAsType := d.BaseName != "" && d.BaseName == d.Type.BaseName
+
+ if d.ExpandedPermalink == "" && baseNameSameAsType {
+ isUgly = true
+ }
+
+ if d.Kind != KindPage && d.URL == "" && len(d.Sections) > 0 {
+ if d.ExpandedPermalink != "" {
+ pagePath = pjoin(pagePath, d.ExpandedPermalink)
+ } else {
+ pagePath = pjoin(d.Sections...)
+ }
+ needsBase = false
+ }
+
+ if d.Type.Path != "" {
+ pagePath = pjoin(pagePath, d.Type.Path)
+ }
+
+ if d.Kind != KindHome && d.URL != "" {
+ pagePath = pjoin(pagePath, d.URL)
+
+ if d.Addends != "" {
+ pagePath = pjoin(pagePath, d.Addends)
+ }
+
+ pagePathDir = pagePath
+ link = pagePath
+ hasDot := strings.Contains(d.URL, ".")
+ hasSlash := strings.HasSuffix(d.URL, slash)
+
+ if hasSlash || !hasDot {
+ pagePath = pjoin(pagePath, d.Type.BaseName+fullSuffix)
+ } else if hasDot {
+ pagePathDir = path.Dir(pagePathDir)
+ }
+
+ if !isHtmlIndex(pagePath) {
+ link = pagePath
+ } else if !hasSlash {
+ link += slash
+ }
+
+ linkDir = pagePathDir
+
+ if d.ForcePrefix {
+
+ // Prepend language prefix if not already set in URL
+ if d.PrefixFilePath != "" && !strings.HasPrefix(d.URL, slash+d.PrefixFilePath) {
+ pagePath = pjoin(d.PrefixFilePath, pagePath)
+ pagePathDir = pjoin(d.PrefixFilePath, pagePathDir)
+ }
+
+ if d.PrefixLink != "" && !strings.HasPrefix(d.URL, slash+d.PrefixLink) {
+ link = pjoin(d.PrefixLink, link)
+ linkDir = pjoin(d.PrefixLink, linkDir)
+ }
+ }
+
+ } else if d.Kind == KindPage {
+
+ if d.ExpandedPermalink != "" {
+ pagePath = pjoin(pagePath, d.ExpandedPermalink)
+ } else {
+ if d.Dir != "" {
+ pagePath = pjoin(pagePath, d.Dir)
+ }
+ if d.BaseName != "" {
+ pagePath = pjoin(pagePath, d.BaseName)
+ }
+ }
+
+ if d.Addends != "" {
+ pagePath = pjoin(pagePath, d.Addends)
+ }
+
+ link = pagePath
+
+ // TODO(bep) this should not happen after the fix in https://github.com/gohugoio/hugo/issues/4870
+ // but we may need some more testing before we can remove it.
+ if baseNameSameAsType {
+ link = strings.TrimSuffix(link, d.BaseName)
+ }
+
+ pagePathDir = link
+ link = link + slash
+ linkDir = pagePathDir
+
+ if isUgly {
+ pagePath = addSuffix(pagePath, fullSuffix)
+ } else {
+ pagePath = pjoin(pagePath, d.Type.BaseName+fullSuffix)
+ }
+
+ if !isHtmlIndex(pagePath) {
+ link = pagePath
+ }
+
+ if d.PrefixFilePath != "" {
+ pagePath = pjoin(d.PrefixFilePath, pagePath)
+ pagePathDir = pjoin(d.PrefixFilePath, pagePathDir)
+ }
+
+ if d.PrefixLink != "" {
+ link = pjoin(d.PrefixLink, link)
+ linkDir = pjoin(d.PrefixLink, linkDir)
+ }
+
+ } else {
+ if d.Addends != "" {
+ pagePath = pjoin(pagePath, d.Addends)
+ }
+
+ needsBase = needsBase && d.Addends == ""
+
+ // No permalink expansion etc. for node type pages (for now)
+ base := ""
+
+ if needsBase || !isUgly {
+ base = d.Type.BaseName
+ }
+
+ pagePathDir = pagePath
+ link = pagePath
+ linkDir = pagePathDir
+
+ if base != "" {
+ pagePath = path.Join(pagePath, addSuffix(base, fullSuffix))
+ } else {
+ pagePath = addSuffix(pagePath, fullSuffix)
+ }
+
+ if !isHtmlIndex(pagePath) {
+ link = pagePath
+ } else {
+ link += slash
+ }
+
+ if d.PrefixFilePath != "" {
+ pagePath = pjoin(d.PrefixFilePath, pagePath)
+ pagePathDir = pjoin(d.PrefixFilePath, pagePathDir)
+ }
+
+ if d.PrefixLink != "" {
+ link = pjoin(d.PrefixLink, link)
+ linkDir = pjoin(d.PrefixLink, linkDir)
+ }
+ }
+
+ pagePath = pjoin(slash, pagePath)
+ pagePathDir = strings.TrimSuffix(path.Join(slash, pagePathDir), slash)
+
+ hadSlash := strings.HasSuffix(link, slash)
+ link = strings.Trim(link, slash)
+ if hadSlash {
+ link += slash
+ }
+
+ if !strings.HasPrefix(link, slash) {
+ link = slash + link
+ }
+
+ linkDir = strings.TrimSuffix(path.Join(slash, linkDir), slash)
+
+ // if page URL is explicitly set in frontmatter,
+ // preserve its value without sanitization
+ if d.Kind != KindPage || d.URL == "" {
+ // Note: MakePathSanitized will lower case the path if
+ // disablePathToLower isn't set.
+ pagePath = d.PathSpec.MakePathSanitized(pagePath)
+ pagePathDir = d.PathSpec.MakePathSanitized(pagePathDir)
+ link = d.PathSpec.MakePathSanitized(link)
+ linkDir = d.PathSpec.MakePathSanitized(linkDir)
+ }
+
+ tp.TargetFilename = filepath.FromSlash(pagePath)
+ tp.SubResourceBaseTarget = filepath.FromSlash(pagePathDir)
+ tp.SubResourceBaseLink = linkDir
+ tp.Link = d.PathSpec.URLizeFilename(link)
+ if tp.Link == "" {
+ tp.Link = slash
+ }
+
+ return
+}
+
+func addSuffix(s, suffix string) string {
+ return strings.Trim(s, slash) + suffix
+}
+
+// Like path.Join, but preserves one trailing slash if present.
+func pjoin(elem ...string) string {
+ hadSlash := strings.HasSuffix(elem[len(elem)-1], slash)
+ joined := path.Join(elem...)
+ if hadSlash && !strings.HasSuffix(joined, slash) {
+ return joined + slash
+ }
+ return joined
+}
diff --git a/resources/page/page_paths_test.go b/resources/page/page_paths_test.go
new file mode 100644
index 000000000..28937899f
--- /dev/null
+++ b/resources/page/page_paths_test.go
@@ -0,0 +1,293 @@
+// 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 page
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gohugoio/hugo/media"
+
+ "github.com/gohugoio/hugo/output"
+)
+
+func TestPageTargetPath(t *testing.T) {
+ pathSpec := newTestPathSpec()
+
+ noExtNoDelimMediaType := media.WithDelimiterAndSuffixes(media.TextType, "", "")
+ noExtNoDelimMediaType.Delimiter = ""
+
+ // Netlify style _redirects
+ noExtDelimFormat := output.Format{
+ Name: "NER",
+ MediaType: noExtNoDelimMediaType,
+ BaseName: "_redirects",
+ }
+
+ for _, langPrefixPath := range []string{"", "no"} {
+ for _, langPrefixLink := range []string{"", "no"} {
+ for _, uglyURLs := range []bool{false, true} {
+
+ tests := []struct {
+ name string
+ d TargetPathDescriptor
+ expected TargetPaths
+ }{
+ {"JSON home", TargetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, TargetPaths{TargetFilename: "/index.json", SubResourceBaseTarget: "", Link: "/index.json"}},
+ {"AMP home", TargetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, TargetPaths{TargetFilename: "/amp/index.html", SubResourceBaseTarget: "/amp", Link: "/amp/"}},
+ {"HTML home", TargetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, TargetPaths{TargetFilename: "/index.html", SubResourceBaseTarget: "", Link: "/"}},
+ {"Netlify redirects", TargetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, TargetPaths{TargetFilename: "/_redirects", SubResourceBaseTarget: "", Link: "/_redirects"}},
+ {"HTML section list", TargetPathDescriptor{
+ Kind: KindSection,
+ Sections: []string{"sect1"},
+ BaseName: "_index",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/sect1/index.html", SubResourceBaseTarget: "/sect1", Link: "/sect1/"}},
+ {"HTML taxonomy term", TargetPathDescriptor{
+ Kind: KindTerm,
+ Sections: []string{"tags", "hugo"},
+ BaseName: "_index",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/tags/hugo/index.html", SubResourceBaseTarget: "/tags/hugo", Link: "/tags/hugo/"}},
+ {"HTML taxonomy", TargetPathDescriptor{
+ Kind: KindTaxonomy,
+ Sections: []string{"tags"},
+ BaseName: "_index",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/tags/index.html", SubResourceBaseTarget: "/tags", Link: "/tags/"}},
+ {
+ "HTML page", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b",
+ BaseName: "mypage",
+ Sections: []string{"a"},
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/a/b/mypage/index.html", SubResourceBaseTarget: "/a/b/mypage", Link: "/a/b/mypage/"},
+ },
+
+ {
+ "HTML page with index as base", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b",
+ BaseName: "index",
+ Sections: []string{"a"},
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/a/b/index.html", SubResourceBaseTarget: "/a/b", Link: "/a/b/"},
+ },
+
+ {
+ "HTML page with special chars", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b",
+ BaseName: "My Page!",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/a/b/my-page/index.html", SubResourceBaseTarget: "/a/b/my-page", Link: "/a/b/my-page/"},
+ },
+ {"RSS home", TargetPathDescriptor{Kind: "rss", Type: output.RSSFormat}, TargetPaths{TargetFilename: "/index.xml", SubResourceBaseTarget: "", Link: "/index.xml"}},
+ {"RSS section list", TargetPathDescriptor{
+ Kind: "rss",
+ Sections: []string{"sect1"},
+ Type: output.RSSFormat,
+ }, TargetPaths{TargetFilename: "/sect1/index.xml", SubResourceBaseTarget: "/sect1", Link: "/sect1/index.xml"}},
+ {
+ "AMP page", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b/c",
+ BaseName: "myamp",
+ Type: output.AMPFormat,
+ }, TargetPaths{TargetFilename: "/amp/a/b/c/myamp/index.html", SubResourceBaseTarget: "/amp/a/b/c/myamp", Link: "/amp/a/b/c/myamp/"},
+ },
+ {
+ "AMP page with URL with suffix", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/sect/",
+ BaseName: "mypage",
+ URL: "/some/other/url.xhtml",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/some/other/url.xhtml", SubResourceBaseTarget: "/some/other", Link: "/some/other/url.xhtml"},
+ },
+ {
+ "JSON page with URL without suffix", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/sect/",
+ BaseName: "mypage",
+ URL: "/some/other/path/",
+ Type: output.JSONFormat,
+ }, TargetPaths{TargetFilename: "/some/other/path/index.json", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/index.json"},
+ },
+ {
+ "JSON page with URL without suffix and no trailing slash", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/sect/",
+ BaseName: "mypage",
+ URL: "/some/other/path",
+ Type: output.JSONFormat,
+ }, TargetPaths{TargetFilename: "/some/other/path/index.json", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/index.json"},
+ },
+ {
+ "HTML page with URL without suffix and no trailing slash", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/sect/",
+ BaseName: "mypage",
+ URL: "/some/other/path",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/some/other/path/index.html", SubResourceBaseTarget: "/some/other/path", Link: "/some/other/path/"},
+ },
+ {
+ "HTML page with URL containing double hyphen", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/sect/",
+ BaseName: "mypage",
+ URL: "/some/other--url/",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/some/other--url/index.html", SubResourceBaseTarget: "/some/other--url", Link: "/some/other--url/"},
+ },
+ {
+ "HTML page with expanded permalink", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b",
+ BaseName: "mypage",
+ ExpandedPermalink: "/2017/10/my-title/",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/2017/10/my-title/index.html", SubResourceBaseTarget: "/2017/10/my-title", Link: "/2017/10/my-title/"},
+ },
+ {
+ "Paginated HTML home", TargetPathDescriptor{
+ Kind: KindHome,
+ BaseName: "_index",
+ Type: output.HTMLFormat,
+ Addends: "page/3",
+ }, TargetPaths{TargetFilename: "/page/3/index.html", SubResourceBaseTarget: "/page/3", Link: "/page/3/"},
+ },
+ {
+ "Paginated Taxonomy terms list", TargetPathDescriptor{
+ Kind: KindTerm,
+ BaseName: "_index",
+ Sections: []string{"tags", "hugo"},
+ Type: output.HTMLFormat,
+ Addends: "page/3",
+ }, TargetPaths{TargetFilename: "/tags/hugo/page/3/index.html", SubResourceBaseTarget: "/tags/hugo/page/3", Link: "/tags/hugo/page/3/"},
+ },
+ {
+ "Regular page with addend", TargetPathDescriptor{
+ Kind: KindPage,
+ Dir: "/a/b",
+ BaseName: "mypage",
+ Addends: "c/d/e",
+ Type: output.HTMLFormat,
+ }, TargetPaths{TargetFilename: "/a/b/mypage/c/d/e/index.html", SubResourceBaseTarget: "/a/b/mypage/c/d/e", Link: "/a/b/mypage/c/d/e/"},
+ },
+ }
+
+ for i, test := range tests {
+ t.Run(fmt.Sprintf("langPrefixPath=%s,langPrefixLink=%s,uglyURLs=%t,name=%s", langPrefixPath, langPrefixLink, uglyURLs, test.name),
+ func(t *testing.T) {
+ test.d.ForcePrefix = true
+ test.d.PathSpec = pathSpec
+ test.d.UglyURLs = uglyURLs
+ test.d.PrefixFilePath = langPrefixPath
+ test.d.PrefixLink = langPrefixLink
+ test.d.Dir = filepath.FromSlash(test.d.Dir)
+ isUgly := uglyURLs && !test.d.Type.NoUgly
+
+ expected := test.expected
+
+ // TODO(bep) simplify
+ if test.d.Kind == KindPage && test.d.BaseName == test.d.Type.BaseName {
+ } else if test.d.Kind == KindHome && test.d.Type.Path != "" {
+ } else if test.d.Type.MediaType.FirstSuffix.Suffix != "" && (!strings.HasPrefix(expected.TargetFilename, "/index") || test.d.Addends != "") && test.d.URL == "" && isUgly {
+ expected.TargetFilename = strings.Replace(expected.TargetFilename,
+ "/"+test.d.Type.BaseName+"."+test.d.Type.MediaType.FirstSuffix.Suffix,
+ "."+test.d.Type.MediaType.FirstSuffix.Suffix, 1)
+ expected.Link = strings.TrimSuffix(expected.Link, "/") + "." + test.d.Type.MediaType.FirstSuffix.Suffix
+
+ }
+
+ if test.d.PrefixFilePath != "" && !strings.HasPrefix(test.d.URL, "/"+test.d.PrefixFilePath) {
+ expected.TargetFilename = "/" + test.d.PrefixFilePath + expected.TargetFilename
+ expected.SubResourceBaseTarget = "/" + test.d.PrefixFilePath + expected.SubResourceBaseTarget
+ }
+
+ if test.d.PrefixLink != "" && !strings.HasPrefix(test.d.URL, "/"+test.d.PrefixLink) {
+ expected.Link = "/" + test.d.PrefixLink + expected.Link
+ }
+
+ expected.TargetFilename = filepath.FromSlash(expected.TargetFilename)
+ expected.SubResourceBaseTarget = filepath.FromSlash(expected.SubResourceBaseTarget)
+
+ pagePath := CreateTargetPaths(test.d)
+
+ if !eqTargetPaths(pagePath, expected) {
+ t.Fatalf("[%d] [%s] targetPath expected\n%#v, got:\n%#v", i, test.name, expected, pagePath)
+ }
+ })
+ }
+ }
+ }
+ }
+}
+
+func TestPageTargetPathPrefix(t *testing.T) {
+ pathSpec := newTestPathSpec()
+ tests := []struct {
+ name string
+ d TargetPathDescriptor
+ expected TargetPaths
+ }{
+ {
+ "URL set, prefix both, no force",
+ TargetPathDescriptor{Kind: KindPage, Type: output.JSONFormat, URL: "/mydir/my.json", ForcePrefix: false, PrefixFilePath: "pf", PrefixLink: "pl"},
+ TargetPaths{TargetFilename: "/mydir/my.json", SubResourceBaseTarget: "/mydir", SubResourceBaseLink: "/mydir", Link: "/mydir/my.json"},
+ },
+ {
+ "URL set, prefix both, force",
+ TargetPathDescriptor{Kind: KindPage, Type: output.JSONFormat, URL: "/mydir/my.json", ForcePrefix: true, PrefixFilePath: "pf", PrefixLink: "pl"},
+ TargetPaths{TargetFilename: "/pf/mydir/my.json", SubResourceBaseTarget: "/pf/mydir", SubResourceBaseLink: "/pl/mydir", Link: "/pl/mydir/my.json"},
+ },
+ }
+
+ for i, test := range tests {
+ t.Run(fmt.Sprintf(test.name),
+ func(t *testing.T) {
+ test.d.PathSpec = pathSpec
+ expected := test.expected
+ expected.TargetFilename = filepath.FromSlash(expected.TargetFilename)
+ expected.SubResourceBaseTarget = filepath.FromSlash(expected.SubResourceBaseTarget)
+
+ pagePath := CreateTargetPaths(test.d)
+
+ if pagePath != expected {
+ t.Fatalf("[%d] [%s] targetPath expected\n%#v, got:\n%#v", i, test.name, expected, pagePath)
+ }
+ })
+ }
+}
+
+func eqTargetPaths(p1, p2 TargetPaths) bool {
+ if p1.Link != p2.Link {
+ return false
+ }
+
+ if p1.SubResourceBaseTarget != p2.SubResourceBaseTarget {
+ return false
+ }
+
+ if p1.TargetFilename != p2.TargetFilename {
+ return false
+ }
+
+ return true
+}
diff --git a/resources/page/page_wrappers.autogen.go b/resources/page/page_wrappers.autogen.go
new file mode 100644
index 000000000..55dff47d5
--- /dev/null
+++ b/resources/page/page_wrappers.autogen.go
@@ -0,0 +1,25 @@
+// 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.
+
+// This file is autogenerated.
+
+package page
+
+// NewDeprecatedWarningPage adds deprecation warnings to the given implementation.
+func NewDeprecatedWarningPage(p DeprecatedWarningPageMethods) DeprecatedWarningPageMethods {
+ return &pageDeprecated{p: p}
+}
+
+type pageDeprecated struct {
+ p DeprecatedWarningPageMethods
+}
diff --git a/resources/page/pagegroup.go b/resources/page/pagegroup.go
new file mode 100644
index 000000000..3b32a1fae
--- /dev/null
+++ b/resources/page/pagegroup.go
@@ -0,0 +1,460 @@
+// 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 page
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cast"
+
+ "github.com/gohugoio/hugo/common/collections"
+ "github.com/gohugoio/hugo/common/hreflect"
+ "github.com/gohugoio/hugo/compare"
+ "github.com/gohugoio/hugo/langs"
+
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+var (
+ _ collections.Slicer = PageGroup{}
+ _ compare.ProbablyEqer = PageGroup{}
+ _ compare.ProbablyEqer = PagesGroup{}
+)
+
+// PageGroup represents a group of pages, grouped by the key.
+// The key is typically a year or similar.
+type PageGroup struct {
+ // The key, typically a year or similar.
+ Key any
+
+ // The Pages in this group.
+ Pages
+}
+
+type mapKeyValues []reflect.Value
+
+func (v mapKeyValues) Len() int { return len(v) }
+func (v mapKeyValues) Swap(i, j int) { v[i], v[j] = v[j], v[i] }
+
+type mapKeyByInt struct{ mapKeyValues }
+
+func (s mapKeyByInt) Less(i, j int) bool { return s.mapKeyValues[i].Int() < s.mapKeyValues[j].Int() }
+
+type mapKeyByStr struct {
+ less func(a, b string) bool
+ mapKeyValues
+}
+
+func (s mapKeyByStr) Less(i, j int) bool {
+ return s.less(s.mapKeyValues[i].String(), s.mapKeyValues[j].String())
+}
+
+func sortKeys(examplePage Page, v []reflect.Value, order string) []reflect.Value {
+ if len(v) <= 1 {
+ return v
+ }
+
+ switch v[0].Kind() {
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ if order == "desc" {
+ sort.Sort(sort.Reverse(mapKeyByInt{v}))
+ } else {
+ sort.Sort(mapKeyByInt{v})
+ }
+ case reflect.String:
+ stringLess, close := collatorStringLess(examplePage)
+ defer close()
+ if order == "desc" {
+ sort.Sort(sort.Reverse(mapKeyByStr{stringLess, v}))
+ } else {
+ sort.Sort(mapKeyByStr{stringLess, v})
+ }
+ }
+ return v
+}
+
+// PagesGroup represents a list of page groups.
+// This is what you get when doing page grouping in the templates.
+type PagesGroup []PageGroup
+
+// Reverse reverses the order of this list of page groups.
+func (p PagesGroup) Reverse() PagesGroup {
+ for i, j := 0, len(p)-1; i < j; i, j = i+1, j-1 {
+ p[i], p[j] = p[j], p[i]
+ }
+
+ return p
+}
+
+var (
+ errorType = reflect.TypeOf((*error)(nil)).Elem()
+ pagePtrType = reflect.TypeOf((*Page)(nil)).Elem()
+ pagesType = reflect.TypeOf(Pages{})
+)
+
+// GroupBy groups by the value in the given field or method name and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+func (p Pages) GroupBy(key string, order ...string) (PagesGroup, error) {
+ if len(p) < 1 {
+ return nil, nil
+ }
+
+ direction := "asc"
+
+ if len(order) > 0 && (strings.ToLower(order[0]) == "desc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse") {
+ direction = "desc"
+ }
+
+ var ft any
+ index := hreflect.GetMethodIndexByName(pagePtrType, key)
+ if index != -1 {
+ m := pagePtrType.Method(index)
+ if m.Type.NumOut() == 0 || m.Type.NumOut() > 2 {
+ return nil, errors.New(key + " is a Page method but you can't use it with GroupBy")
+ }
+ if m.Type.NumOut() == 1 && m.Type.Out(0).Implements(errorType) {
+ return nil, errors.New(key + " is a Page method but you can't use it with GroupBy")
+ }
+ if m.Type.NumOut() == 2 && !m.Type.Out(1).Implements(errorType) {
+ return nil, errors.New(key + " is a Page method but you can't use it with GroupBy")
+ }
+ ft = m
+ } else {
+ var ok bool
+ ft, ok = pagePtrType.Elem().FieldByName(key)
+ if !ok {
+ return nil, errors.New(key + " is neither a field nor a method of Page")
+ }
+ }
+
+ var tmp reflect.Value
+ switch e := ft.(type) {
+ case reflect.StructField:
+ tmp = reflect.MakeMap(reflect.MapOf(e.Type, pagesType))
+ case reflect.Method:
+ tmp = reflect.MakeMap(reflect.MapOf(e.Type.Out(0), pagesType))
+ }
+
+ for _, e := range p {
+ ppv := reflect.ValueOf(e)
+ var fv reflect.Value
+ switch ft.(type) {
+ case reflect.StructField:
+ fv = ppv.Elem().FieldByName(key)
+ case reflect.Method:
+ fv = hreflect.GetMethodByName(ppv, key).Call([]reflect.Value{})[0]
+ }
+ if !fv.IsValid() {
+ continue
+ }
+ if !tmp.MapIndex(fv).IsValid() {
+ tmp.SetMapIndex(fv, reflect.MakeSlice(pagesType, 0, 0))
+ }
+ tmp.SetMapIndex(fv, reflect.Append(tmp.MapIndex(fv), ppv))
+ }
+
+ sortedKeys := sortKeys(p[0], tmp.MapKeys(), direction)
+ r := make([]PageGroup, len(sortedKeys))
+ for i, k := range sortedKeys {
+ r[i] = PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().(Pages)}
+ }
+
+ return r, nil
+}
+
+// GroupByParam groups by the given page parameter key's value and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+func (p Pages) GroupByParam(key string, order ...string) (PagesGroup, error) {
+ if len(p) < 1 {
+ return nil, nil
+ }
+
+ direction := "asc"
+
+ if len(order) > 0 && (strings.ToLower(order[0]) == "desc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse") {
+ direction = "desc"
+ }
+
+ var tmp reflect.Value
+ var keyt reflect.Type
+ for _, e := range p {
+ param := resource.GetParamToLower(e, key)
+ if param != nil {
+ if _, ok := param.([]string); !ok {
+ keyt = reflect.TypeOf(param)
+ tmp = reflect.MakeMap(reflect.MapOf(keyt, pagesType))
+ break
+ }
+ }
+ }
+ if !tmp.IsValid() {
+ return nil, errors.New("there is no such a param")
+ }
+
+ for _, e := range p {
+ param := resource.GetParam(e, key)
+
+ if param == nil || reflect.TypeOf(param) != keyt {
+ continue
+ }
+ v := reflect.ValueOf(param)
+ if !tmp.MapIndex(v).IsValid() {
+ tmp.SetMapIndex(v, reflect.MakeSlice(pagesType, 0, 0))
+ }
+ tmp.SetMapIndex(v, reflect.Append(tmp.MapIndex(v), reflect.ValueOf(e)))
+ }
+
+ var r []PageGroup
+ for _, k := range sortKeys(p[0], tmp.MapKeys(), direction) {
+ r = append(r, PageGroup{Key: k.Interface(), Pages: tmp.MapIndex(k).Interface().(Pages)})
+ }
+
+ return r, nil
+}
+
+func (p Pages) groupByDateField(format string, sorter func(p Pages) Pages, getDate func(p Page) time.Time, order ...string) (PagesGroup, error) {
+ if len(p) < 1 {
+ return nil, nil
+ }
+
+ sp := sorter(p)
+
+ if !(len(order) > 0 && (strings.ToLower(order[0]) == "asc" || strings.ToLower(order[0]) == "rev" || strings.ToLower(order[0]) == "reverse")) {
+ sp = sp.Reverse()
+ }
+
+ if sp == nil {
+ return nil, nil
+ }
+
+ firstPage := sp[0].(Page)
+ date := getDate(firstPage)
+
+ // Pages may be a mix of multiple languages, so we need to use the language
+ // for the currently rendered Site.
+ currentSite := firstPage.Site().Current()
+ formatter := langs.GetTimeFormatter(currentSite.Language())
+ formatted := formatter.Format(date, format)
+ var r []PageGroup
+ r = append(r, PageGroup{Key: formatted, Pages: make(Pages, 0)})
+ r[0].Pages = append(r[0].Pages, sp[0])
+
+ i := 0
+ for _, e := range sp[1:] {
+ date = getDate(e.(Page))
+ formatted := formatter.Format(date, format)
+ if r[i].Key.(string) != formatted {
+ r = append(r, PageGroup{Key: formatted})
+ i++
+ }
+ r[i].Pages = append(r[i].Pages, e)
+ }
+ return r, nil
+}
+
+// GroupByDate groups by the given page's Date value in
+// the given format and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+// For valid format strings, see https://golang.org/pkg/time/#Time.Format
+func (p Pages) GroupByDate(format string, order ...string) (PagesGroup, error) {
+ sorter := func(p Pages) Pages {
+ return p.ByDate()
+ }
+ getDate := func(p Page) time.Time {
+ return p.Date()
+ }
+ return p.groupByDateField(format, sorter, getDate, order...)
+}
+
+// GroupByPublishDate groups by the given page's PublishDate value in
+// the given format and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+// For valid format strings, see https://golang.org/pkg/time/#Time.Format
+func (p Pages) GroupByPublishDate(format string, order ...string) (PagesGroup, error) {
+ sorter := func(p Pages) Pages {
+ return p.ByPublishDate()
+ }
+ getDate := func(p Page) time.Time {
+ return p.PublishDate()
+ }
+ return p.groupByDateField(format, sorter, getDate, order...)
+}
+
+// GroupByExpiryDate groups by the given page's ExpireDate value in
+// the given format and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+// For valid format strings, see https://golang.org/pkg/time/#Time.Format
+func (p Pages) GroupByExpiryDate(format string, order ...string) (PagesGroup, error) {
+ sorter := func(p Pages) Pages {
+ return p.ByExpiryDate()
+ }
+ getDate := func(p Page) time.Time {
+ return p.ExpiryDate()
+ }
+ return p.groupByDateField(format, sorter, getDate, order...)
+}
+
+// GroupByLastmod groups by the given page's Lastmod value in
+// the given format and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+// For valid format strings, see https://golang.org/pkg/time/#Time.Format
+func (p Pages) GroupByLastmod(format string, order ...string) (PagesGroup, error) {
+ sorter := func(p Pages) Pages {
+ return p.ByLastmod()
+ }
+ getDate := func(p Page) time.Time {
+ return p.Lastmod()
+ }
+ return p.groupByDateField(format, sorter, getDate, order...)
+}
+
+// GroupByParamDate groups by a date set as a param on the page in
+// the given format and with the given order.
+// Valid values for order is asc, desc, rev and reverse.
+// For valid format strings, see https://golang.org/pkg/time/#Time.Format
+func (p Pages) GroupByParamDate(key string, format string, order ...string) (PagesGroup, error) {
+ // Cache the dates.
+ dates := make(map[Page]time.Time)
+
+ sorter := func(pages Pages) Pages {
+ var r Pages
+
+ for _, p := range pages {
+ param := resource.GetParam(p, key)
+ var t time.Time
+
+ if param != nil {
+ var ok bool
+ if t, ok = param.(time.Time); !ok {
+ // Probably a string. Try to convert it to time.Time.
+ t = cast.ToTime(param)
+ }
+ }
+
+ dates[p] = t
+ r = append(r, p)
+ }
+
+ pdate := func(p1, p2 Page) bool {
+ return dates[p1].Unix() < dates[p2].Unix()
+ }
+ pageBy(pdate).Sort(r)
+ return r
+ }
+ getDate := func(p Page) time.Time {
+ return dates[p]
+ }
+ return p.groupByDateField(format, sorter, getDate, order...)
+}
+
+// ProbablyEq wraps compare.ProbablyEqer
+// For internal use.
+func (p PageGroup) ProbablyEq(other any) bool {
+ otherP, ok := other.(PageGroup)
+ if !ok {
+ return false
+ }
+
+ if p.Key != otherP.Key {
+ return false
+ }
+
+ return p.Pages.ProbablyEq(otherP.Pages)
+}
+
+// Slice is for internal use.
+// for the template functions. See collections.Slice.
+func (p PageGroup) Slice(in any) (any, error) {
+ switch items := in.(type) {
+ case PageGroup:
+ return items, nil
+ case []any:
+ groups := make(PagesGroup, len(items))
+ for i, v := range items {
+ g, ok := v.(PageGroup)
+ if !ok {
+ return nil, fmt.Errorf("type %T is not a PageGroup", v)
+ }
+ groups[i] = g
+ }
+ return groups, nil
+ default:
+ return nil, fmt.Errorf("invalid slice type %T", items)
+ }
+}
+
+// Len returns the number of pages in the page group.
+func (psg PagesGroup) Len() int {
+ l := 0
+ for _, pg := range psg {
+ l += len(pg.Pages)
+ }
+ return l
+}
+
+// ProbablyEq wraps compare.ProbablyEqer
+func (psg PagesGroup) ProbablyEq(other any) bool {
+ otherPsg, ok := other.(PagesGroup)
+ if !ok {
+ return false
+ }
+
+ if len(psg) != len(otherPsg) {
+ return false
+ }
+
+ for i := range psg {
+ if !psg[i].ProbablyEq(otherPsg[i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// ToPagesGroup tries to convert seq into a PagesGroup.
+func ToPagesGroup(seq any) (PagesGroup, error) {
+ switch v := seq.(type) {
+ case nil:
+ return nil, nil
+ case PagesGroup:
+ return v, nil
+ case []PageGroup:
+ return PagesGroup(v), nil
+ case []any:
+ l := len(v)
+ if l == 0 {
+ break
+ }
+ switch v[0].(type) {
+ case PageGroup:
+ pagesGroup := make(PagesGroup, l)
+ for i, ipg := range v {
+ if pg, ok := ipg.(PageGroup); ok {
+ pagesGroup[i] = pg
+ } else {
+ return nil, fmt.Errorf("unsupported type in paginate from slice, got %T instead of PageGroup", ipg)
+ }
+ }
+ return pagesGroup, nil
+ }
+ }
+
+ return nil, nil
+}
diff --git a/resources/page/pagegroup_test.go b/resources/page/pagegroup_test.go
new file mode 100644
index 000000000..ef0d24471
--- /dev/null
+++ b/resources/page/pagegroup_test.go
@@ -0,0 +1,466 @@
+// 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 page
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/spf13/cast"
+)
+
+type pageGroupTestObject struct {
+ path string
+ weight int
+ date string
+ param string
+}
+
+var pageGroupTestSources = []pageGroupTestObject{
+ {"/section1/testpage1.md", 3, "2012-04-06", "foo"},
+ {"/section1/testpage2.md", 3, "2012-01-01", "bar"},
+ {"/section1/testpage3.md", 2, "2012-04-06", "foo"},
+ {"/section2/testpage4.md", 1, "2012-03-02", "bar"},
+ // date might also be a full datetime:
+ {"/section2/testpage5.md", 1, "2012-04-06T00:00:00Z", "baz"},
+}
+
+func preparePageGroupTestPages(t *testing.T) Pages {
+ var pages Pages
+ for _, src := range pageGroupTestSources {
+ p := newTestPage()
+ p.path = src.path
+ if p.path != "" {
+ p.section = strings.Split(strings.TrimPrefix(p.path, "/"), "/")[0]
+ }
+ p.weight = src.weight
+ p.date = cast.ToTime(src.date)
+ p.pubDate = cast.ToTime(src.date)
+ p.expiryDate = cast.ToTime(src.date)
+ p.lastMod = cast.ToTime(src.date).AddDate(3, 0, 0)
+ p.params["custom_param"] = src.param
+ p.params["custom_date"] = cast.ToTime(src.date)
+ p.params["custom_string_date"] = src.date
+ pages = append(pages, p)
+ }
+ return pages
+}
+
+func TestGroupByWithFieldNameArg(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: 1, Pages: Pages{pages[3], pages[4]}},
+ {Key: 2, Pages: Pages{pages[2]}},
+ {Key: 3, Pages: Pages{pages[0], pages[1]}},
+ }
+
+ groups, err := pages.GroupBy("Weight")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByWithMethodNameArg(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "section1", Pages: Pages{pages[0], pages[1], pages[2]}},
+ {Key: "section2", Pages: Pages{pages[3], pages[4]}},
+ }
+
+ groups, err := pages.GroupBy("Type")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByWithSectionArg(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "section1", Pages: Pages{pages[0], pages[1], pages[2]}},
+ {Key: "section2", Pages: Pages{pages[3], pages[4]}},
+ }
+
+ groups, err := pages.GroupBy("Section")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be\n%#v, got\n%#v", expect, groups)
+ }
+}
+
+func TestGroupByInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: 3, Pages: Pages{pages[0], pages[1]}},
+ {Key: 2, Pages: Pages{pages[2]}},
+ {Key: 1, Pages: Pages{pages[3], pages[4]}},
+ }
+
+ groups, err := pages.GroupBy("Weight", "desc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByCalledWithEmptyPages(t *testing.T) {
+ t.Parallel()
+ var pages Pages
+ groups, err := pages.GroupBy("Weight")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if groups != nil {
+ t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups)
+ }
+}
+
+func TestGroupByParamCalledWithUnavailableKey(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ _, err := pages.GroupByParam("UnavailableKey")
+ if err == nil {
+ t.Errorf("GroupByParam should return an error but didn't")
+ }
+}
+
+func TestReverse(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+
+ groups1, err := pages.GroupBy("Weight", "desc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+
+ groups2, err := pages.GroupBy("Weight")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ groups2 = groups2.Reverse()
+
+ if !reflect.DeepEqual(groups2, groups1) {
+ t.Errorf("PagesGroup is sorted in unexpected order. It should be %#v, got %#v", groups2, groups1)
+ }
+}
+
+func TestGroupByParam(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "bar", Pages: Pages{pages[1], pages[3]}},
+ {Key: "baz", Pages: Pages{pages[4]}},
+ {Key: "foo", Pages: Pages{pages[0], pages[2]}},
+ }
+
+ groups, err := pages.GroupByParam("custom_param")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByParamInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "foo", Pages: Pages{pages[0], pages[2]}},
+ {Key: "baz", Pages: Pages{pages[4]}},
+ {Key: "bar", Pages: Pages{pages[1], pages[3]}},
+ }
+
+ groups, err := pages.GroupByParam("custom_param", "desc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByParamCalledWithCapitalLetterString(t *testing.T) {
+ c := qt.New(t)
+ testStr := "TestString"
+ p := newTestPage()
+ p.params["custom_param"] = testStr
+ pages := Pages{p}
+
+ groups, err := pages.GroupByParam("custom_param")
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(groups[0].Key, qt.Equals, testStr)
+}
+
+func TestGroupByParamCalledWithSomeUnavailableParams(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ delete(pages[1].Params(), "custom_param")
+ delete(pages[3].Params(), "custom_param")
+ delete(pages[4].Params(), "custom_param")
+
+ expect := PagesGroup{
+ {Key: "foo", Pages: Pages{pages[0], pages[2]}},
+ }
+
+ groups, err := pages.GroupByParam("custom_param")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByParamCalledWithEmptyPages(t *testing.T) {
+ t.Parallel()
+ var pages Pages
+ groups, err := pages.GroupByParam("custom_param")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if groups != nil {
+ t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups)
+ }
+}
+
+func TestGroupByParamCalledWithUnavailableParam(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ _, err := pages.GroupByParam("unavailable_param")
+ if err == nil {
+ t.Errorf("GroupByParam should return an error but didn't")
+ }
+}
+
+func TestGroupByDate(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByDate("2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByDateInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}},
+ }
+
+ groups, err := pages.GroupByDate("2006-01", "asc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByPublishDate(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByPublishDate("2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByPublishDateInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}},
+ }
+
+ groups, err := pages.GroupByDate("2006-01", "asc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByPublishDateWithEmptyPages(t *testing.T) {
+ t.Parallel()
+ var pages Pages
+ groups, err := pages.GroupByPublishDate("2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if groups != nil {
+ t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups)
+ }
+}
+
+func TestGroupByExpiryDate(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByExpiryDate("2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByParamDate(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByParamDate("custom_date", "2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+// https://github.com/gohugoio/hugo/issues/3983
+func TestGroupByParamDateWithStringParams(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByParamDate("custom_string_date", "2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByLastmod(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2015-04", Pages: Pages{pages[4], pages[2], pages[0]}},
+ {Key: "2015-03", Pages: Pages{pages[3]}},
+ {Key: "2015-01", Pages: Pages{pages[1]}},
+ }
+
+ groups, err := pages.GroupByLastmod("2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByLastmodInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2015-01", Pages: Pages{pages[1]}},
+ {Key: "2015-03", Pages: Pages{pages[3]}},
+ {Key: "2015-04", Pages: Pages{pages[0], pages[2], pages[4]}},
+ }
+
+ groups, err := pages.GroupByLastmod("2006-01", "asc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be\n%#v, got\n%#v", expect, groups)
+ }
+}
+
+func TestGroupByParamDateInReverseOrder(t *testing.T) {
+ t.Parallel()
+ pages := preparePageGroupTestPages(t)
+ expect := PagesGroup{
+ {Key: "2012-01", Pages: Pages{pages[1]}},
+ {Key: "2012-03", Pages: Pages{pages[3]}},
+ {Key: "2012-04", Pages: Pages{pages[0], pages[2], pages[4]}},
+ }
+
+ groups, err := pages.GroupByParamDate("custom_date", "2006-01", "asc")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if !reflect.DeepEqual(groups, expect) {
+ t.Errorf("PagesGroup has unexpected groups. It should be %#v, got %#v", expect, groups)
+ }
+}
+
+func TestGroupByParamDateWithEmptyPages(t *testing.T) {
+ t.Parallel()
+ var pages Pages
+ groups, err := pages.GroupByParamDate("custom_date", "2006-01")
+ if err != nil {
+ t.Fatalf("Unable to make PagesGroup array: %s", err)
+ }
+ if groups != nil {
+ t.Errorf("PagesGroup isn't empty. It should be %#v, got %#v", nil, groups)
+ }
+}
diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go
new file mode 100644
index 000000000..bc82773e8
--- /dev/null
+++ b/resources/page/pagemeta/page_frontmatter.go
@@ -0,0 +1,427 @@
+// 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 pagemeta
+
+import (
+ "strings"
+ "time"
+
+ "github.com/gohugoio/hugo/common/htime"
+ "github.com/gohugoio/hugo/common/paths"
+
+ "github.com/gohugoio/hugo/common/loggers"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/config"
+ "github.com/spf13/cast"
+)
+
+// FrontMatterHandler maps front matter into Page fields and .Params.
+// Note that we currently have only extracted the date logic.
+type FrontMatterHandler struct {
+ fmConfig frontmatterConfig
+
+ dateHandler frontMatterFieldHandler
+ lastModHandler frontMatterFieldHandler
+ publishDateHandler frontMatterFieldHandler
+ expiryDateHandler frontMatterFieldHandler
+
+ // A map of all date keys configured, including any custom.
+ allDateKeys map[string]bool
+
+ logger loggers.Logger
+}
+
+// FrontMatterDescriptor describes how to handle front matter for a given Page.
+// It has pointers to values in the receiving page which gets updated.
+type FrontMatterDescriptor struct {
+
+ // This the Page's front matter.
+ Frontmatter map[string]any
+
+ // This is the Page's base filename (BaseFilename), e.g. page.md., or
+ // if page is a leaf bundle, the bundle folder name (ContentBaseName).
+ BaseFilename string
+
+ // The content file's mod time.
+ ModTime time.Time
+
+ // May be set from the author date in Git.
+ GitAuthorDate time.Time
+
+ // The below are pointers to values on Page and will be modified.
+
+ // This is the Page's params.
+ Params map[string]any
+
+ // This is the Page's dates.
+ Dates *resource.Dates
+
+ // This is the Page's Slug etc.
+ PageURLs *URLPath
+
+ // The Location to use to parse dates without time zone info.
+ Location *time.Location
+}
+
+var dateFieldAliases = map[string][]string{
+ fmDate: {},
+ fmLastmod: {"modified"},
+ fmPubDate: {"pubdate", "published"},
+ fmExpiryDate: {"unpublishdate"},
+}
+
+// HandleDates updates all the dates given the current configuration and the
+// supplied front matter params. Note that this requires all lower-case keys
+// in the params map.
+func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error {
+ if d.Dates == nil {
+ panic("missing dates")
+ }
+
+ if f.dateHandler == nil {
+ panic("missing date handler")
+ }
+
+ if _, err := f.dateHandler(d); err != nil {
+ return err
+ }
+
+ if _, err := f.lastModHandler(d); err != nil {
+ return err
+ }
+
+ if _, err := f.publishDateHandler(d); err != nil {
+ return err
+ }
+
+ if _, err := f.expiryDateHandler(d); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// IsDateKey returns whether the given front matter key is considered a date by the current
+// configuration.
+func (f FrontMatterHandler) IsDateKey(key string) bool {
+ return f.allDateKeys[key]
+}
+
+// A Zero date is a signal that the name can not be parsed.
+// This follows the format as outlined in Jekyll, https://jekyllrb.com/docs/posts/:
+// "Where YEAR is a four-digit number, MONTH and DAY are both two-digit numbers"
+func dateAndSlugFromBaseFilename(location *time.Location, name string) (time.Time, string) {
+ withoutExt, _ := paths.FileAndExt(name)
+
+ if len(withoutExt) < 10 {
+ // This can not be a date.
+ return time.Time{}, ""
+ }
+
+ d, err := htime.ToTimeInDefaultLocationE(withoutExt[:10], location)
+ if err != nil {
+ return time.Time{}, ""
+ }
+
+ // Be a little lenient with the format here.
+ slug := strings.Trim(withoutExt[10:], " -_")
+
+ return d, slug
+}
+
+type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error)
+
+func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler {
+ return func(d *FrontMatterDescriptor) (bool, error) {
+ for _, h := range handlers {
+ // First successful handler wins.
+ success, err := h(d)
+ if err != nil {
+ f.logger.Errorln(err)
+ } else if success {
+ return true, nil
+ }
+ }
+ return false, nil
+ }
+}
+
+type frontmatterConfig struct {
+ date []string
+ lastmod []string
+ publishDate []string
+ expiryDate []string
+}
+
+const (
+ // These are all the date handler identifiers
+ // All identifiers not starting with a ":" maps to a front matter parameter.
+ fmDate = "date"
+ fmPubDate = "publishdate"
+ fmLastmod = "lastmod"
+ fmExpiryDate = "expirydate"
+
+ // Gets date from filename, e.g 218-02-22-mypage.md
+ fmFilename = ":filename"
+
+ // Gets date from file OS mod time.
+ fmModTime = ":filemodtime"
+
+ // Gets date from Git
+ fmGitAuthorDate = ":git"
+)
+
+// This is the config you get when doing nothing.
+func newDefaultFrontmatterConfig() frontmatterConfig {
+ return frontmatterConfig{
+ date: []string{fmDate, fmPubDate, fmLastmod},
+ lastmod: []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate},
+ publishDate: []string{fmPubDate, fmDate},
+ expiryDate: []string{fmExpiryDate},
+ }
+}
+
+func newFrontmatterConfig(cfg config.Provider) (frontmatterConfig, error) {
+ c := newDefaultFrontmatterConfig()
+ defaultConfig := c
+
+ if cfg.IsSet("frontmatter") {
+ fm := cfg.GetStringMap("frontmatter")
+ for k, v := range fm {
+ loki := strings.ToLower(k)
+ switch loki {
+ case fmDate:
+ c.date = toLowerSlice(v)
+ case fmPubDate:
+ c.publishDate = toLowerSlice(v)
+ case fmLastmod:
+ c.lastmod = toLowerSlice(v)
+ case fmExpiryDate:
+ c.expiryDate = toLowerSlice(v)
+ }
+ }
+ }
+
+ expander := func(c, d []string) []string {
+ out := expandDefaultValues(c, d)
+ out = addDateFieldAliases(out)
+ return out
+ }
+
+ c.date = expander(c.date, defaultConfig.date)
+ c.publishDate = expander(c.publishDate, defaultConfig.publishDate)
+ c.lastmod = expander(c.lastmod, defaultConfig.lastmod)
+ c.expiryDate = expander(c.expiryDate, defaultConfig.expiryDate)
+
+ return c, nil
+}
+
+func addDateFieldAliases(values []string) []string {
+ var complete []string
+
+ for _, v := range values {
+ complete = append(complete, v)
+ if aliases, found := dateFieldAliases[v]; found {
+ complete = append(complete, aliases...)
+ }
+ }
+ return helpers.UniqueStringsReuse(complete)
+}
+
+func expandDefaultValues(values []string, defaults []string) []string {
+ var out []string
+ for _, v := range values {
+ if v == ":default" {
+ out = append(out, defaults...)
+ } else {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+func toLowerSlice(in any) []string {
+ out := cast.ToStringSlice(in)
+ for i := 0; i < len(out); i++ {
+ out[i] = strings.ToLower(out[i])
+ }
+
+ return out
+}
+
+// NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration.
+// If no logger is provided, one will be created.
+func NewFrontmatterHandler(logger loggers.Logger, cfg config.Provider) (FrontMatterHandler, error) {
+ if logger == nil {
+ logger = loggers.NewErrorLogger()
+ }
+
+ frontMatterConfig, err := newFrontmatterConfig(cfg)
+ if err != nil {
+ return FrontMatterHandler{}, err
+ }
+
+ allDateKeys := make(map[string]bool)
+ addKeys := func(vals []string) {
+ for _, k := range vals {
+ if !strings.HasPrefix(k, ":") {
+ allDateKeys[k] = true
+ }
+ }
+ }
+
+ addKeys(frontMatterConfig.date)
+ addKeys(frontMatterConfig.expiryDate)
+ addKeys(frontMatterConfig.lastmod)
+ addKeys(frontMatterConfig.publishDate)
+
+ f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys}
+
+ if err := f.createHandlers(); err != nil {
+ return f, err
+ }
+
+ return f, nil
+}
+
+func (f *FrontMatterHandler) createHandlers() error {
+ var err error
+
+ if f.dateHandler, err = f.createDateHandler(f.fmConfig.date,
+ func(d *FrontMatterDescriptor, t time.Time) {
+ d.Dates.FDate = t
+ setParamIfNotSet(fmDate, t, d)
+ }); err != nil {
+ return err
+ }
+
+ if f.lastModHandler, err = f.createDateHandler(f.fmConfig.lastmod,
+ func(d *FrontMatterDescriptor, t time.Time) {
+ setParamIfNotSet(fmLastmod, t, d)
+ d.Dates.FLastmod = t
+ }); err != nil {
+ return err
+ }
+
+ if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.publishDate,
+ func(d *FrontMatterDescriptor, t time.Time) {
+ setParamIfNotSet(fmPubDate, t, d)
+ d.Dates.FPublishDate = t
+ }); err != nil {
+ return err
+ }
+
+ if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.expiryDate,
+ func(d *FrontMatterDescriptor, t time.Time) {
+ setParamIfNotSet(fmExpiryDate, t, d)
+ d.Dates.FExpiryDate = t
+ }); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func setParamIfNotSet(key string, value any, d *FrontMatterDescriptor) {
+ if _, found := d.Params[key]; found {
+ return
+ }
+ d.Params[key] = value
+}
+
+func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) {
+ var h *frontmatterFieldHandlers
+ var handlers []frontMatterFieldHandler
+
+ for _, identifier := range identifiers {
+ switch identifier {
+ case fmFilename:
+ handlers = append(handlers, h.newDateFilenameHandler(setter))
+ case fmModTime:
+ handlers = append(handlers, h.newDateModTimeHandler(setter))
+ case fmGitAuthorDate:
+ handlers = append(handlers, h.newDateGitAuthorDateHandler(setter))
+ default:
+ handlers = append(handlers, h.newDateFieldHandler(identifier, setter))
+ }
+ }
+
+ return f.newChainedFrontMatterFieldHandler(handlers...), nil
+}
+
+type frontmatterFieldHandlers int
+
+func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
+ return func(d *FrontMatterDescriptor) (bool, error) {
+ v, found := d.Frontmatter[key]
+
+ if !found {
+ return false, nil
+ }
+
+ date, err := htime.ToTimeInDefaultLocationE(v, d.Location)
+ if err != nil {
+ return false, nil
+ }
+
+ // We map several date keys to one, so, for example,
+ // "expirydate", "unpublishdate" will all set .ExpiryDate (first found).
+ setter(d, date)
+
+ // This is the params key as set in front matter.
+ d.Params[key] = date
+
+ return true, nil
+ }
+}
+
+func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
+ return func(d *FrontMatterDescriptor) (bool, error) {
+ date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename)
+ if date.IsZero() {
+ return false, nil
+ }
+
+ setter(d, date)
+
+ if _, found := d.Frontmatter["slug"]; !found {
+ // Use slug from filename
+ d.PageURLs.Slug = slug
+ }
+
+ return true, nil
+ }
+}
+
+func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
+ return func(d *FrontMatterDescriptor) (bool, error) {
+ if d.ModTime.IsZero() {
+ return false, nil
+ }
+ setter(d, d.ModTime)
+ return true, nil
+ }
+}
+
+func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
+ return func(d *FrontMatterDescriptor) (bool, error) {
+ if d.GitAuthorDate.IsZero() {
+ return false, nil
+ }
+ setter(d, d.GitAuthorDate)
+ return true, nil
+ }
+}
diff --git a/resources/page/pagemeta/page_frontmatter_test.go b/resources/page/pagemeta/page_frontmatter_test.go
new file mode 100644
index 000000000..c5c4ccf2d
--- /dev/null
+++ b/resources/page/pagemeta/page_frontmatter_test.go
@@ -0,0 +1,257 @@
+// 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 pagemeta
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gohugoio/hugo/config"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestDateAndSlugFromBaseFilename(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ tests := []struct {
+ name string
+ date string
+ slug string
+ }{
+ {"page.md", "0001-01-01", ""},
+ {"2012-09-12-page.md", "2012-09-12", "page"},
+ {"2018-02-28-page.md", "2018-02-28", "page"},
+ {"2018-02-28_page.md", "2018-02-28", "page"},
+ {"2018-02-28 page.md", "2018-02-28", "page"},
+ {"2018-02-28page.md", "2018-02-28", "page"},
+ {"2018-02-28-.md", "2018-02-28", ""},
+ {"2018-02-28-.md", "2018-02-28", ""},
+ {"2018-02-28.md", "2018-02-28", ""},
+ {"2018-02-28-page", "2018-02-28", "page"},
+ {"2012-9-12-page.md", "0001-01-01", ""},
+ {"asdfasdf.md", "0001-01-01", ""},
+ }
+
+ for _, test := range tests {
+ expecteFDate, err := time.Parse("2006-01-02", test.date)
+ c.Assert(err, qt.IsNil)
+
+ gotDate, gotSlug := dateAndSlugFromBaseFilename(time.UTC, test.name)
+
+ c.Assert(gotDate, qt.Equals, expecteFDate)
+ c.Assert(gotSlug, qt.Equals, test.slug)
+
+ }
+}
+
+func newTestFd() *FrontMatterDescriptor {
+ return &FrontMatterDescriptor{
+ Frontmatter: make(map[string]any),
+ Params: make(map[string]any),
+ Dates: &resource.Dates{},
+ PageURLs: &URLPath{},
+ Location: time.UTC,
+ }
+}
+
+func TestFrontMatterNewConfig(t *testing.T) {
+ c := qt.New(t)
+
+ cfg := config.New()
+
+ cfg.Set("frontmatter", map[string]any{
+ "date": []string{"publishDate", "LastMod"},
+ "Lastmod": []string{"publishDate"},
+ "expiryDate": []string{"lastMod"},
+ "publishDate": []string{"date"},
+ })
+
+ fc, err := newFrontmatterConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(fc.date, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "lastmod", "modified"})
+ c.Assert(fc.lastmod, qt.DeepEquals, []string{"publishdate", "pubdate", "published"})
+ c.Assert(fc.expiryDate, qt.DeepEquals, []string{"lastmod", "modified"})
+ c.Assert(fc.publishDate, qt.DeepEquals, []string{"date"})
+
+ // Default
+ cfg = config.New()
+ fc, err = newFrontmatterConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(fc.date, qt.DeepEquals, []string{"date", "publishdate", "pubdate", "published", "lastmod", "modified"})
+ c.Assert(fc.lastmod, qt.DeepEquals, []string{":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"})
+ c.Assert(fc.expiryDate, qt.DeepEquals, []string{"expirydate", "unpublishdate"})
+ c.Assert(fc.publishDate, qt.DeepEquals, []string{"publishdate", "pubdate", "published", "date"})
+
+ // :default keyword
+ cfg.Set("frontmatter", map[string]any{
+ "date": []string{"d1", ":default"},
+ "lastmod": []string{"d2", ":default"},
+ "expiryDate": []string{"d3", ":default"},
+ "publishDate": []string{"d4", ":default"},
+ })
+ fc, err = newFrontmatterConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(fc.date, qt.DeepEquals, []string{"d1", "date", "publishdate", "pubdate", "published", "lastmod", "modified"})
+ c.Assert(fc.lastmod, qt.DeepEquals, []string{"d2", ":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"})
+ c.Assert(fc.expiryDate, qt.DeepEquals, []string{"d3", "expirydate", "unpublishdate"})
+ c.Assert(fc.publishDate, qt.DeepEquals, []string{"d4", "publishdate", "pubdate", "published", "date"})
+}
+
+func TestFrontMatterDatesHandlers(t *testing.T) {
+ c := qt.New(t)
+
+ for _, handlerID := range []string{":filename", ":fileModTime", ":git"} {
+
+ cfg := config.New()
+
+ cfg.Set("frontmatter", map[string]any{
+ "date": []string{handlerID, "date"},
+ })
+
+ handler, err := NewFrontmatterHandler(nil, cfg)
+ c.Assert(err, qt.IsNil)
+
+ d1, _ := time.Parse("2006-01-02", "2018-02-01")
+ d2, _ := time.Parse("2006-01-02", "2018-02-02")
+
+ d := newTestFd()
+ switch strings.ToLower(handlerID) {
+ case ":filename":
+ d.BaseFilename = "2018-02-01-page.md"
+ case ":filemodtime":
+ d.ModTime = d1
+ case ":git":
+ d.GitAuthorDate = d1
+ }
+ d.Frontmatter["date"] = d2
+ c.Assert(handler.HandleDates(d), qt.IsNil)
+ c.Assert(d.Dates.FDate, qt.Equals, d1)
+ c.Assert(d.Params["date"], qt.Equals, d2)
+
+ d = newTestFd()
+ d.Frontmatter["date"] = d2
+ c.Assert(handler.HandleDates(d), qt.IsNil)
+ c.Assert(d.Dates.FDate, qt.Equals, d2)
+ c.Assert(d.Params["date"], qt.Equals, d2)
+
+ }
+}
+
+func TestFrontMatterDatesCustomConfig(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ cfg := config.New()
+ cfg.Set("frontmatter", map[string]any{
+ "date": []string{"mydate"},
+ "lastmod": []string{"publishdate"},
+ "publishdate": []string{"publishdate"},
+ })
+
+ handler, err := NewFrontmatterHandler(nil, cfg)
+ c.Assert(err, qt.IsNil)
+
+ testDate, err := time.Parse("2006-01-02", "2018-02-01")
+ c.Assert(err, qt.IsNil)
+
+ d := newTestFd()
+ d.Frontmatter["mydate"] = testDate
+ testDate = testDate.Add(24 * time.Hour)
+ d.Frontmatter["date"] = testDate
+ testDate = testDate.Add(24 * time.Hour)
+ d.Frontmatter["lastmod"] = testDate
+ testDate = testDate.Add(24 * time.Hour)
+ d.Frontmatter["publishdate"] = testDate
+ testDate = testDate.Add(24 * time.Hour)
+ d.Frontmatter["expirydate"] = testDate
+
+ c.Assert(handler.HandleDates(d), qt.IsNil)
+
+ c.Assert(d.Dates.FDate.Day(), qt.Equals, 1)
+ c.Assert(d.Dates.FLastmod.Day(), qt.Equals, 4)
+ c.Assert(d.Dates.FPublishDate.Day(), qt.Equals, 4)
+ c.Assert(d.Dates.FExpiryDate.Day(), qt.Equals, 5)
+
+ c.Assert(d.Params["date"], qt.Equals, d.Dates.FDate)
+ c.Assert(d.Params["mydate"], qt.Equals, d.Dates.FDate)
+ c.Assert(d.Params["publishdate"], qt.Equals, d.Dates.FPublishDate)
+ c.Assert(d.Params["expirydate"], qt.Equals, d.Dates.FExpiryDate)
+
+ c.Assert(handler.IsDateKey("date"), qt.Equals, false) // This looks odd, but is configured like this.
+ c.Assert(handler.IsDateKey("mydate"), qt.Equals, true)
+ c.Assert(handler.IsDateKey("publishdate"), qt.Equals, true)
+ c.Assert(handler.IsDateKey("pubdate"), qt.Equals, true)
+}
+
+func TestFrontMatterDatesDefaultKeyword(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ cfg := config.New()
+
+ cfg.Set("frontmatter", map[string]any{
+ "date": []string{"mydate", ":default"},
+ "publishdate": []string{":default", "mypubdate"},
+ })
+
+ handler, err := NewFrontmatterHandler(nil, cfg)
+ c.Assert(err, qt.IsNil)
+
+ testDate, _ := time.Parse("2006-01-02", "2018-02-01")
+ d := newTestFd()
+ d.Frontmatter["mydate"] = testDate
+ d.Frontmatter["date"] = testDate.Add(1 * 24 * time.Hour)
+ d.Frontmatter["mypubdate"] = testDate.Add(2 * 24 * time.Hour)
+ d.Frontmatter["publishdate"] = testDate.Add(3 * 24 * time.Hour)
+
+ c.Assert(handler.HandleDates(d), qt.IsNil)
+
+ c.Assert(d.Dates.FDate.Day(), qt.Equals, 1)
+ c.Assert(d.Dates.FLastmod.Day(), qt.Equals, 2)
+ c.Assert(d.Dates.FPublishDate.Day(), qt.Equals, 4)
+ c.Assert(d.Dates.FExpiryDate.IsZero(), qt.Equals, true)
+}
+
+func TestExpandDefaultValues(t *testing.T) {
+ c := qt.New(t)
+ c.Assert(expandDefaultValues([]string{"a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"a", "b", "c", "d"})
+ c.Assert(expandDefaultValues([]string{"a", "b", "c"}, []string{"a", "b", "c"}), qt.DeepEquals, []string{"a", "b", "c"})
+ c.Assert(expandDefaultValues([]string{":default", "a", ":default", "d"}, []string{"b", "c"}), qt.DeepEquals, []string{"b", "c", "a", "b", "c", "d"})
+}
+
+func TestFrontMatterDateFieldHandler(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ handlers := new(frontmatterFieldHandlers)
+
+ fd := newTestFd()
+ d, _ := time.Parse("2006-01-02", "2018-02-01")
+ fd.Frontmatter["date"] = d
+ h := handlers.newDateFieldHandler("date", func(d *FrontMatterDescriptor, t time.Time) { d.Dates.FDate = t })
+
+ handled, err := h(fd)
+ c.Assert(handled, qt.Equals, true)
+ c.Assert(err, qt.IsNil)
+ c.Assert(fd.Dates.FDate, qt.Equals, d)
+}
diff --git a/resources/page/pagemeta/pagemeta.go b/resources/page/pagemeta/pagemeta.go
new file mode 100644
index 000000000..94c6b00aa
--- /dev/null
+++ b/resources/page/pagemeta/pagemeta.go
@@ -0,0 +1,110 @@
+// 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 pagemeta
+
+import (
+ "github.com/mitchellh/mapstructure"
+)
+
+type URLPath struct {
+ URL string
+ Permalink string
+ Slug string
+ Section string
+}
+
+const (
+ Never = "never"
+ Always = "always"
+ ListLocally = "local"
+ Link = "link"
+)
+
+var defaultBuildConfig = BuildConfig{
+ List: Always,
+ Render: Always,
+ PublishResources: true,
+ set: true,
+}
+
+// BuildConfig holds configuration options about how to handle a Page in Hugo's
+// build process.
+type BuildConfig struct {
+ // Whether to add it to any of the page collections.
+ // Note that the page can always be found with .Site.GetPage.
+ // Valid values: never, always, local.
+ // Setting it to 'local' means they will be available via the local
+ // page collections, e.g. $section.Pages.
+ // Note: before 0.57.2 this was a bool, so we accept those too.
+ List string
+
+ // Whether to render it.
+ // Valid values: never, always, link.
+ // The value link means it will not be rendered, but it will get a RelPermalink/Permalink.
+ // Note that before 0.76.0 this was a bool, so we accept those too.
+ Render string
+
+ // Whether to publish its resources. These will still be published on demand,
+ // but enabling this can be useful if the originals (e.g. images) are
+ // never used.
+ PublishResources bool
+
+ set bool // BuildCfg is non-zero if this is set to true.
+}
+
+// Disable sets all options to their off value.
+func (b *BuildConfig) Disable() {
+ b.List = Never
+ b.Render = Never
+ b.PublishResources = false
+ b.set = true
+}
+
+func (b BuildConfig) IsZero() bool {
+ return !b.set
+}
+
+func DecodeBuildConfig(m any) (BuildConfig, error) {
+ b := defaultBuildConfig
+ if m == nil {
+ return b, nil
+ }
+
+ err := mapstructure.WeakDecode(m, &b)
+
+ // In 0.67.1 we changed the list attribute from a bool to a string (enum).
+ // Bool values will become 0 or 1.
+ switch b.List {
+ case "0":
+ b.List = Never
+ case "1":
+ b.List = Always
+ case Always, Never, ListLocally:
+ default:
+ b.List = Always
+ }
+
+ // In 0.76.0 we changed the Render from bool to a string.
+ switch b.Render {
+ case "0":
+ b.Render = Never
+ case "1":
+ b.Render = Always
+ case Always, Never, Link:
+ default:
+ b.Render = Always
+ }
+
+ return b, err
+}
diff --git a/resources/page/pagemeta/pagemeta_test.go b/resources/page/pagemeta/pagemeta_test.go
new file mode 100644
index 000000000..288dc7e26
--- /dev/null
+++ b/resources/page/pagemeta/pagemeta_test.go
@@ -0,0 +1,92 @@
+// Copyright 2020 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 pagemeta
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gohugoio/hugo/htesting/hqt"
+
+ "github.com/gohugoio/hugo/config"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestDecodeBuildConfig(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ configTempl := `
+[_build]
+render = %s
+list = %s
+publishResources = true`
+
+ for _, test := range []struct {
+ args []any
+ expect BuildConfig
+ }{
+ {
+ []any{"true", "true"},
+ BuildConfig{
+ Render: Always,
+ List: Always,
+ PublishResources: true,
+ set: true,
+ },
+ },
+ {[]any{"true", "false"}, BuildConfig{
+ Render: Always,
+ List: Never,
+ PublishResources: true,
+ set: true,
+ }},
+ {[]any{`"always"`, `"always"`}, BuildConfig{
+ Render: Always,
+ List: Always,
+ PublishResources: true,
+ set: true,
+ }},
+ {[]any{`"never"`, `"never"`}, BuildConfig{
+ Render: Never,
+ List: Never,
+ PublishResources: true,
+ set: true,
+ }},
+ {[]any{`"link"`, `"local"`}, BuildConfig{
+ Render: Link,
+ List: ListLocally,
+ PublishResources: true,
+ set: true,
+ }},
+ {[]any{`"always"`, `"asdfadf"`}, BuildConfig{
+ Render: Always,
+ List: Always,
+ PublishResources: true,
+ set: true,
+ }},
+ } {
+ cfg, err := config.FromConfigString(fmt.Sprintf(configTempl, test.args...), "toml")
+ c.Assert(err, qt.IsNil)
+ bcfg, err := DecodeBuildConfig(cfg.Get("_build"))
+ c.Assert(err, qt.IsNil)
+
+ eq := qt.CmpEquals(hqt.DeepAllowUnexported(BuildConfig{}))
+
+ c.Assert(bcfg, eq, test.expect)
+
+ }
+}
diff --git a/resources/page/pages.go b/resources/page/pages.go
new file mode 100644
index 000000000..f47af5114
--- /dev/null
+++ b/resources/page/pages.go
@@ -0,0 +1,157 @@
+// 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 page
+
+import (
+ "fmt"
+ "math/rand"
+
+ "github.com/gohugoio/hugo/compare"
+
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Pages is a slice of Page objects. This is the most common list type in Hugo.
+type Pages []Page
+
+// String returns a string representation of the list.
+// For internal use.
+func (ps Pages) String() string {
+ return fmt.Sprintf("Pages(%d)", len(ps))
+}
+
+// Used in tests.
+func (ps Pages) shuffle() {
+ for i := range ps {
+ j := rand.Intn(i + 1)
+ ps[i], ps[j] = ps[j], ps[i]
+ }
+}
+
+// ToResources wraps resource.ResourcesConverter.
+// For internal use.
+func (pages Pages) ToResources() resource.Resources {
+ r := make(resource.Resources, len(pages))
+ for i, p := range pages {
+ r[i] = p
+ }
+ return r
+}
+
+// ToPages tries to convert seq into Pages.
+func ToPages(seq any) (Pages, error) {
+ if seq == nil {
+ return Pages{}, nil
+ }
+
+ switch v := seq.(type) {
+ case Pages:
+ return v, nil
+ case *Pages:
+ return *(v), nil
+ case WeightedPages:
+ return v.Pages(), nil
+ case PageGroup:
+ return v.Pages, nil
+ case []Page:
+ pages := make(Pages, len(v))
+ for i, vv := range v {
+ pages[i] = vv
+ }
+ return pages, nil
+ case []any:
+ pages := make(Pages, len(v))
+ success := true
+ for i, vv := range v {
+ p, ok := vv.(Page)
+ if !ok {
+ success = false
+ break
+ }
+ pages[i] = p
+ }
+ if success {
+ return pages, nil
+ }
+ }
+
+ return nil, fmt.Errorf("cannot convert type %T to Pages", seq)
+}
+
+// Group groups the pages in in by key.
+// This implements collections.Grouper.
+func (p Pages) Group(key any, in any) (any, error) {
+ pages, err := ToPages(in)
+ if err != nil {
+ return PageGroup{}, err
+ }
+ return PageGroup{Key: key, Pages: pages}, nil
+}
+
+// Len returns the number of pages in the list.
+func (p Pages) Len() int {
+ return len(p)
+}
+
+// ProbablyEq wraps compare.ProbablyEqer
+// For internal use.
+func (pages Pages) ProbablyEq(other any) bool {
+ otherPages, ok := other.(Pages)
+ if !ok {
+ return false
+ }
+
+ if len(pages) != len(otherPages) {
+ return false
+ }
+
+ step := 1
+
+ for i := 0; i < len(pages); i += step {
+ if !pages[i].Eq(otherPages[i]) {
+ return false
+ }
+
+ if i > 50 {
+ // This is most likely the same.
+ step = 50
+ }
+ }
+
+ return true
+}
+
+func (ps Pages) removeFirstIfFound(p Page) Pages {
+ ii := -1
+ for i, pp := range ps {
+ if p.Eq(pp) {
+ ii = i
+ break
+ }
+ }
+
+ if ii != -1 {
+ ps = append(ps[:ii], ps[ii+1:]...)
+ }
+ return ps
+}
+
+// PagesFactory somehow creates some Pages.
+// We do a lot of lazy Pages initialization in Hugo, so we need a type.
+type PagesFactory func() Pages
+
+var (
+ _ resource.ResourcesConverter = Pages{}
+ _ compare.ProbablyEqer = Pages{}
+)
diff --git a/resources/page/pages_cache.go b/resources/page/pages_cache.go
new file mode 100644
index 000000000..9435cb308
--- /dev/null
+++ b/resources/page/pages_cache.go
@@ -0,0 +1,135 @@
+// 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 page
+
+import (
+ "sync"
+)
+
+type pageCacheEntry struct {
+ in []Pages
+ out Pages
+}
+
+func (entry pageCacheEntry) matches(pageLists []Pages) bool {
+ if len(entry.in) != len(pageLists) {
+ return false
+ }
+ for i, p := range pageLists {
+ if !pagesEqual(p, entry.in[i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+type pageCache struct {
+ sync.RWMutex
+ m map[string][]pageCacheEntry
+}
+
+func newPageCache() *pageCache {
+ return &pageCache{m: make(map[string][]pageCacheEntry)}
+}
+
+func (c *pageCache) clear() {
+ c.Lock()
+ defer c.Unlock()
+ c.m = make(map[string][]pageCacheEntry)
+}
+
+// get/getP gets a Pages slice from the cache matching the given key and
+// all the provided Pages slices.
+// If none found in cache, a copy of the first slice is created.
+//
+// If an apply func is provided, that func is applied to the newly created copy.
+//
+// The getP variant' apply func takes a pointer to Pages.
+//
+// The cache and the execution of the apply func is protected by a RWMutex.
+func (c *pageCache) get(key string, apply func(p Pages), pageLists ...Pages) (Pages, bool) {
+ return c.getP(key, func(p *Pages) {
+ if apply != nil {
+ apply(*p)
+ }
+ }, pageLists...)
+}
+
+func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) (Pages, bool) {
+ c.RLock()
+ if cached, ok := c.m[key]; ok {
+ for _, entry := range cached {
+ if entry.matches(pageLists) {
+ c.RUnlock()
+ return entry.out, true
+ }
+ }
+ }
+ c.RUnlock()
+
+ c.Lock()
+ defer c.Unlock()
+
+ // double-check
+ if cached, ok := c.m[key]; ok {
+ for _, entry := range cached {
+ if entry.matches(pageLists) {
+ return entry.out, true
+ }
+ }
+ }
+
+ p := pageLists[0]
+ pagesCopy := append(Pages(nil), p...)
+
+ if apply != nil {
+ apply(&pagesCopy)
+ }
+
+ entry := pageCacheEntry{in: pageLists, out: pagesCopy}
+ if v, ok := c.m[key]; ok {
+ c.m[key] = append(v, entry)
+ } else {
+ c.m[key] = []pageCacheEntry{entry}
+ }
+
+ return pagesCopy, false
+}
+
+// pagesEqual returns whether p1 and p2 are equal.
+func pagesEqual(p1, p2 Pages) bool {
+ if p1 == nil && p2 == nil {
+ return true
+ }
+
+ if p1 == nil || p2 == nil {
+ return false
+ }
+
+ if p1.Len() != p2.Len() {
+ return false
+ }
+
+ if p1.Len() == 0 {
+ return true
+ }
+
+ for i := 0; i < len(p1); i++ {
+ if p1[i] != p2[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/resources/page/pages_cache_test.go b/resources/page/pages_cache_test.go
new file mode 100644
index 000000000..825bdc31f
--- /dev/null
+++ b/resources/page/pages_cache_test.go
@@ -0,0 +1,87 @@
+// 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 page
+
+import (
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestPageCache(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ c1 := newPageCache()
+
+ changeFirst := func(p Pages) {
+ p[0].(*testPage).description = "changed"
+ }
+
+ var o1 uint64
+ var o2 uint64
+
+ var wg sync.WaitGroup
+
+ var l1 sync.Mutex
+ var l2 sync.Mutex
+
+ var testPageSets []Pages
+
+ for i := 0; i < 50; i++ {
+ testPageSets = append(testPageSets, createSortTestPages(i+1))
+ }
+
+ for j := 0; j < 100; j++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for k, pages := range testPageSets {
+ l1.Lock()
+ p, ca := c1.get("k1", nil, pages)
+ c.Assert(ca, qt.Equals, !atomic.CompareAndSwapUint64(&o1, uint64(k), uint64(k+1)))
+ l1.Unlock()
+ p2, c2 := c1.get("k1", nil, p)
+ c.Assert(c2, qt.Equals, true)
+ c.Assert(pagesEqual(p, p2), qt.Equals, true)
+ c.Assert(pagesEqual(p, pages), qt.Equals, true)
+ c.Assert(p, qt.Not(qt.IsNil))
+
+ l2.Lock()
+ p3, c3 := c1.get("k2", changeFirst, pages)
+ c.Assert(c3, qt.Equals, !atomic.CompareAndSwapUint64(&o2, uint64(k), uint64(k+1)))
+ l2.Unlock()
+ c.Assert(p3, qt.Not(qt.IsNil))
+ c.Assert("changed", qt.Equals, p3[0].(*testPage).description)
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func BenchmarkPageCache(b *testing.B) {
+ cache := newPageCache()
+ pages := make(Pages, 30)
+ for i := 0; i < 30; i++ {
+ pages[i] = &testPage{title: "p" + strconv.Itoa(i)}
+ }
+ key := "key"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ cache.getP(key, nil, pages)
+ }
+}
diff --git a/resources/page/pages_language_merge.go b/resources/page/pages_language_merge.go
new file mode 100644
index 000000000..4c5a926cf
--- /dev/null
+++ b/resources/page/pages_language_merge.go
@@ -0,0 +1,62 @@
+// 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 page
+
+import (
+ "fmt"
+)
+
+var _ pagesLanguageMerger = (*Pages)(nil)
+
+type pagesLanguageMerger interface {
+ MergeByLanguage(other Pages) Pages
+ // Needed for integration with the tpl package.
+ MergeByLanguageInterface(other any) (any, error)
+}
+
+// MergeByLanguage supplies missing translations in p1 with values from p2.
+// The result is sorted by the default sort order for pages.
+func (p1 Pages) MergeByLanguage(p2 Pages) Pages {
+ merge := func(pages *Pages) {
+ m := make(map[string]bool)
+ for _, p := range *pages {
+ m[p.TranslationKey()] = true
+ }
+
+ for _, p := range p2 {
+ if _, found := m[p.TranslationKey()]; !found {
+ *pages = append(*pages, p)
+ }
+ }
+
+ SortByDefault(*pages)
+ }
+
+ out, _ := spc.getP("pages.MergeByLanguage", merge, p1, p2)
+
+ return out
+}
+
+// MergeByLanguageInterface is the generic version of MergeByLanguage. It
+// is here just so it can be called from the tpl package.
+func (p1 Pages) MergeByLanguageInterface(in any) (any, error) {
+ if in == nil {
+ return p1, nil
+ }
+ p2, ok := in.(Pages)
+ if !ok {
+ return nil, fmt.Errorf("%T cannot be merged by language", in)
+ }
+ return p1.MergeByLanguage(p2), nil
+}
diff --git a/resources/page/pages_prev_next.go b/resources/page/pages_prev_next.go
new file mode 100644
index 000000000..753a3e415
--- /dev/null
+++ b/resources/page/pages_prev_next.go
@@ -0,0 +1,34 @@
+// 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 page
+
+// Next returns the next page relative to the given
+func (p Pages) Next(cur Page) Page {
+ x := searchPage(cur, p)
+ if x <= 0 {
+ return nil
+ }
+ return p[x-1]
+}
+
+// Prev returns the previous page reletive to the given
+func (p Pages) Prev(cur Page) Page {
+ x := searchPage(cur, p)
+
+ if x == -1 || len(p)-x < 2 {
+ return nil
+ }
+
+ return p[x+1]
+}
diff --git a/resources/page/pages_prev_next_test.go b/resources/page/pages_prev_next_test.go
new file mode 100644
index 000000000..0ee1564cd
--- /dev/null
+++ b/resources/page/pages_prev_next_test.go
@@ -0,0 +1,91 @@
+// 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 page
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/spf13/cast"
+)
+
+type pagePNTestObject struct {
+ path string
+ weight int
+ date string
+}
+
+var pagePNTestSources = []pagePNTestObject{
+ {"/section1/testpage1.md", 5, "2012-04-06"},
+ {"/section1/testpage2.md", 4, "2012-01-01"},
+ {"/section1/testpage3.md", 3, "2012-04-06"},
+ {"/section2/testpage4.md", 2, "2012-03-02"},
+ {"/section2/testpage5.md", 1, "2012-04-06"},
+}
+
+func TestPrev(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := preparePageGroupTestPages(t)
+
+ c.Assert(pages.Prev(pages[3]), qt.Equals, pages[4])
+ c.Assert(pages.Prev(pages[1]), qt.Equals, pages[2])
+ c.Assert(pages.Prev(pages[4]), qt.IsNil)
+}
+
+func TestNext(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := preparePageGroupTestPages(t)
+
+ c.Assert(pages.Next(pages[0]), qt.IsNil)
+ c.Assert(pages.Next(pages[1]), qt.Equals, pages[0])
+ c.Assert(pages.Next(pages[4]), qt.Equals, pages[3])
+}
+
+func prepareWeightedPagesPrevNext(t *testing.T) WeightedPages {
+ w := WeightedPages{}
+
+ for _, src := range pagePNTestSources {
+ p := newTestPage()
+ p.path = src.path
+ p.weight = src.weight
+ p.date = cast.ToTime(src.date)
+ p.pubDate = cast.ToTime(src.date)
+ w = append(w, WeightedPage{Weight: p.weight, Page: p})
+ }
+
+ w.Sort()
+ return w
+}
+
+func TestWeightedPagesPrev(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ w := prepareWeightedPagesPrevNext(t)
+
+ c.Assert(w.Prev(w[0].Page), qt.Equals, w[1].Page)
+ c.Assert(w.Prev(w[1].Page), qt.Equals, w[2].Page)
+ c.Assert(w.Prev(w[4].Page), qt.IsNil)
+}
+
+func TestWeightedPagesNext(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ w := prepareWeightedPagesPrevNext(t)
+
+ c.Assert(w.Next(w[0].Page), qt.IsNil)
+ c.Assert(w.Next(w[1].Page), qt.Equals, w[0].Page)
+ c.Assert(w.Next(w[4].Page), qt.Equals, w[3].Page)
+}
diff --git a/resources/page/pages_related.go b/resources/page/pages_related.go
new file mode 100644
index 000000000..35bb2965a
--- /dev/null
+++ b/resources/page/pages_related.go
@@ -0,0 +1,195 @@
+// 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 page
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/gohugoio/hugo/common/types"
+ "github.com/gohugoio/hugo/related"
+ "github.com/spf13/cast"
+)
+
+var (
+ // Assert that Pages and PageGroup implements the PageGenealogist interface.
+ _ PageGenealogist = (Pages)(nil)
+ _ PageGenealogist = PageGroup{}
+)
+
+// A PageGenealogist finds related pages in a page collection. This interface is implemented
+// by Pages and PageGroup, which makes it available as `{{ .RegularRelated . }}` etc.
+type PageGenealogist interface {
+
+ // Template example:
+ // {{ $related := .RegularPages.Related . }}
+ Related(doc related.Document) (Pages, error)
+
+ // Template example:
+ // {{ $related := .RegularPages.RelatedIndices . "tags" "date" }}
+ RelatedIndices(doc related.Document, indices ...any) (Pages, error)
+
+ // Template example:
+ // {{ $related := .RegularPages.RelatedTo ( keyVals "tags" "hugo", "rocks") ( keyVals "date" .Date ) }}
+ RelatedTo(args ...types.KeyValues) (Pages, error)
+}
+
+// Related searches all the configured indices with the search keywords from the
+// supplied document.
+func (p Pages) Related(doc related.Document) (Pages, error) {
+ result, err := p.searchDoc(doc)
+ if err != nil {
+ return nil, err
+ }
+
+ if page, ok := doc.(Page); ok {
+ return result.removeFirstIfFound(page), nil
+ }
+
+ return result, nil
+}
+
+// RelatedIndices searches the given indices with the search keywords from the
+// supplied document.
+func (p Pages) RelatedIndices(doc related.Document, indices ...any) (Pages, error) {
+ indicesStr, err := cast.ToStringSliceE(indices)
+ if err != nil {
+ return nil, err
+ }
+
+ result, err := p.searchDoc(doc, indicesStr...)
+ if err != nil {
+ return nil, err
+ }
+
+ if page, ok := doc.(Page); ok {
+ return result.removeFirstIfFound(page), nil
+ }
+
+ return result, nil
+}
+
+// RelatedTo searches the given indices with the corresponding values.
+func (p Pages) RelatedTo(args ...types.KeyValues) (Pages, error) {
+ if len(p) == 0 {
+ return nil, nil
+ }
+
+ return p.search(args...)
+}
+
+func (p Pages) search(args ...types.KeyValues) (Pages, error) {
+ return p.withInvertedIndex(func(idx *related.InvertedIndex) ([]related.Document, error) {
+ return idx.SearchKeyValues(args...)
+ })
+}
+
+func (p Pages) searchDoc(doc related.Document, indices ...string) (Pages, error) {
+ return p.withInvertedIndex(func(idx *related.InvertedIndex) ([]related.Document, error) {
+ return idx.SearchDoc(doc, indices...)
+ })
+}
+
+func (p Pages) withInvertedIndex(search func(idx *related.InvertedIndex) ([]related.Document, error)) (Pages, error) {
+ if len(p) == 0 {
+ return nil, nil
+ }
+
+ d, ok := p[0].(InternalDependencies)
+ if !ok {
+ return nil, fmt.Errorf("invalid type %T in related search", p[0])
+ }
+
+ cache := d.GetRelatedDocsHandler()
+
+ searchIndex, err := cache.getOrCreateIndex(p)
+ if err != nil {
+ return nil, err
+ }
+
+ result, err := search(searchIndex)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(result) > 0 {
+ mp := make(Pages, len(result))
+ for i, match := range result {
+ mp[i] = match.(Page)
+ }
+ return mp, nil
+ }
+
+ return nil, nil
+}
+
+type cachedPostingList struct {
+ p Pages
+
+ postingList *related.InvertedIndex
+}
+
+type RelatedDocsHandler struct {
+ cfg related.Config
+
+ postingLists []*cachedPostingList
+ mu sync.RWMutex
+}
+
+func NewRelatedDocsHandler(cfg related.Config) *RelatedDocsHandler {
+ return &RelatedDocsHandler{cfg: cfg}
+}
+
+func (s *RelatedDocsHandler) Clone() *RelatedDocsHandler {
+ return NewRelatedDocsHandler(s.cfg)
+}
+
+// This assumes that a lock has been acquired.
+func (s *RelatedDocsHandler) getIndex(p Pages) *related.InvertedIndex {
+ for _, ci := range s.postingLists {
+ if pagesEqual(p, ci.p) {
+ return ci.postingList
+ }
+ }
+ return nil
+}
+
+func (s *RelatedDocsHandler) getOrCreateIndex(p Pages) (*related.InvertedIndex, error) {
+ s.mu.RLock()
+ cachedIndex := s.getIndex(p)
+ if cachedIndex != nil {
+ s.mu.RUnlock()
+ return cachedIndex, nil
+ }
+ s.mu.RUnlock()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if cachedIndex := s.getIndex(p); cachedIndex != nil {
+ return cachedIndex, nil
+ }
+
+ searchIndex := related.NewInvertedIndex(s.cfg)
+
+ for _, page := range p {
+ if err := searchIndex.Add(page); err != nil {
+ return nil, err
+ }
+ }
+
+ s.postingLists = append(s.postingLists, &cachedPostingList{p: p, postingList: searchIndex})
+
+ return searchIndex, nil
+}
diff --git a/resources/page/pages_related_test.go b/resources/page/pages_related_test.go
new file mode 100644
index 000000000..3c5780a9a
--- /dev/null
+++ b/resources/page/pages_related_test.go
@@ -0,0 +1,86 @@
+// 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 page
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gohugoio/hugo/common/types"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestRelated(t *testing.T) {
+ c := qt.New(t)
+
+ t.Parallel()
+
+ pages := Pages{
+ &testPage{
+ title: "Page 1",
+ pubDate: mustParseDate("2017-01-03"),
+ params: map[string]any{
+ "keywords": []string{"hugo", "says"},
+ },
+ },
+ &testPage{
+ title: "Page 2",
+ pubDate: mustParseDate("2017-01-02"),
+ params: map[string]any{
+ "keywords": []string{"hugo", "rocks"},
+ },
+ },
+ &testPage{
+ title: "Page 3",
+ pubDate: mustParseDate("2017-01-01"),
+ params: map[string]any{
+ "keywords": []string{"bep", "says"},
+ },
+ },
+ }
+
+ result, err := pages.RelatedTo(types.NewKeyValuesStrings("keywords", "hugo", "rocks"))
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(len(result), qt.Equals, 2)
+ c.Assert(result[0].Title(), qt.Equals, "Page 2")
+ c.Assert(result[1].Title(), qt.Equals, "Page 1")
+
+ result, err = pages.Related(pages[0])
+ c.Assert(err, qt.IsNil)
+ c.Assert(len(result), qt.Equals, 2)
+ c.Assert(result[0].Title(), qt.Equals, "Page 2")
+ c.Assert(result[1].Title(), qt.Equals, "Page 3")
+
+ result, err = pages.RelatedIndices(pages[0], "keywords")
+ c.Assert(err, qt.IsNil)
+ c.Assert(len(result), qt.Equals, 2)
+ c.Assert(result[0].Title(), qt.Equals, "Page 2")
+ c.Assert(result[1].Title(), qt.Equals, "Page 3")
+
+ result, err = pages.RelatedTo(types.NewKeyValuesStrings("keywords", "bep", "rocks"))
+ c.Assert(err, qt.IsNil)
+ c.Assert(len(result), qt.Equals, 2)
+ c.Assert(result[0].Title(), qt.Equals, "Page 2")
+ c.Assert(result[1].Title(), qt.Equals, "Page 3")
+}
+
+func mustParseDate(s string) time.Time {
+ d, err := time.Parse("2006-01-02", s)
+ if err != nil {
+ panic(err)
+ }
+ return d
+}
diff --git a/resources/page/pages_sort.go b/resources/page/pages_sort.go
new file mode 100644
index 000000000..08cb34a32
--- /dev/null
+++ b/resources/page/pages_sort.go
@@ -0,0 +1,412 @@
+// 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 page
+
+import (
+ "sort"
+
+ "github.com/gohugoio/hugo/common/collections"
+ "github.com/gohugoio/hugo/langs"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/compare"
+ "github.com/spf13/cast"
+)
+
+var spc = newPageCache()
+
+/*
+ * Implementation of a custom sorter for Pages
+ */
+
+// A pageSorter implements the sort interface for Pages
+type pageSorter struct {
+ pages Pages
+ by pageBy
+}
+
+// pageBy is a closure used in the Sort.Less method.
+type pageBy func(p1, p2 Page) bool
+
+func getOrdinals(p1, p2 Page) (int, int) {
+ p1o, ok1 := p1.(collections.Order)
+ if !ok1 {
+ return -1, -1
+ }
+ p2o, ok2 := p2.(collections.Order)
+ if !ok2 {
+ return -1, -1
+ }
+
+ return p1o.Ordinal(), p2o.Ordinal()
+}
+
+// Sort stable sorts the pages given the receiver's sort order.
+func (by pageBy) Sort(pages Pages) {
+ ps := &pageSorter{
+ pages: pages,
+ by: by, // The Sort method's receiver is the function (closure) that defines the sort order.
+ }
+ sort.Stable(ps)
+}
+
+var (
+
+ // DefaultPageSort is the default sort func for pages in Hugo:
+ // Order by Ordinal, Weight, Date, LinkTitle and then full file path.
+ DefaultPageSort = func(p1, p2 Page) bool {
+ o1, o2 := getOrdinals(p1, p2)
+ if o1 != o2 && o1 != -1 && o2 != -1 {
+ return o1 < o2
+ }
+ if p1.Weight() == p2.Weight() {
+ if p1.Date().Unix() == p2.Date().Unix() {
+ c := collatorStringCompare(func(p Page) string { return p.LinkTitle() }, p1, p2)
+ if c == 0 {
+ if p1.File().IsZero() || p2.File().IsZero() {
+ return p1.File().IsZero()
+ }
+ return compare.LessStrings(p1.File().Filename(), p2.File().Filename())
+ }
+ return c < 0
+ }
+ return p1.Date().Unix() > p2.Date().Unix()
+ }
+
+ if p2.Weight() == 0 {
+ return true
+ }
+
+ if p1.Weight() == 0 {
+ return false
+ }
+
+ return p1.Weight() < p2.Weight()
+ }
+
+ lessPageLanguage = func(p1, p2 Page) bool {
+ if p1.Language().Weight == p2.Language().Weight {
+ if p1.Date().Unix() == p2.Date().Unix() {
+ c := compare.Strings(p1.LinkTitle(), p2.LinkTitle())
+ if c == 0 {
+ if !p1.File().IsZero() && !p2.File().IsZero() {
+ return compare.LessStrings(p1.File().Filename(), p2.File().Filename())
+ }
+ }
+ return c < 0
+ }
+ return p1.Date().Unix() > p2.Date().Unix()
+ }
+
+ if p2.Language().Weight == 0 {
+ return true
+ }
+
+ if p1.Language().Weight == 0 {
+ return false
+ }
+
+ return p1.Language().Weight < p2.Language().Weight
+ }
+
+ lessPageTitle = func(p1, p2 Page) bool {
+ return collatorStringCompare(func(p Page) string { return p.Title() }, p1, p2) < 0
+ }
+
+ lessPageLinkTitle = func(p1, p2 Page) bool {
+ return collatorStringCompare(func(p Page) string { return p.LinkTitle() }, p1, p2) < 0
+ }
+
+ lessPageDate = func(p1, p2 Page) bool {
+ return p1.Date().Unix() < p2.Date().Unix()
+ }
+
+ lessPagePubDate = func(p1, p2 Page) bool {
+ return p1.PublishDate().Unix() < p2.PublishDate().Unix()
+ }
+)
+
+func (ps *pageSorter) Len() int { return len(ps.pages) }
+func (ps *pageSorter) Swap(i, j int) { ps.pages[i], ps.pages[j] = ps.pages[j], ps.pages[i] }
+
+// Less is part of sort.Interface. It is implemented by calling the "by" closure in the sorter.
+func (ps *pageSorter) Less(i, j int) bool { return ps.by(ps.pages[i], ps.pages[j]) }
+
+// Limit limits the number of pages returned to n.
+func (p Pages) Limit(n int) Pages {
+ if len(p) > n {
+ return p[0:n]
+ }
+ return p
+}
+
+var collatorStringSort = func(getString func(Page) string) func(p Pages) {
+ return func(p Pages) {
+ if len(p) == 0 {
+ return
+ }
+ // Pages may be a mix of multiple languages, so we need to use the language
+ // for the currently rendered Site.
+ currentSite := p[0].Site().Current()
+ coll := langs.GetCollator(currentSite.Language())
+ coll.Lock()
+ defer coll.Unlock()
+
+ sort.SliceStable(p, func(i, j int) bool {
+ return coll.CompareStrings(getString(p[i]), getString(p[j])) < 0
+ })
+ }
+}
+
+var collatorStringCompare = func(getString func(Page) string, p1, p2 Page) int {
+ currentSite := p1.Site().Current()
+ coll := langs.GetCollator(currentSite.Language())
+ coll.Lock()
+ c := coll.CompareStrings(getString(p1), getString(p2))
+ coll.Unlock()
+ return c
+}
+
+var collatorStringLess = func(p Page) (less func(s1, s2 string) bool, close func()) {
+ currentSite := p.Site().Current()
+ coll := langs.GetCollator(currentSite.Language())
+ coll.Lock()
+ return func(s1, s2 string) bool {
+ return coll.CompareStrings(s1, s2) < 1
+ },
+ func() {
+ coll.Unlock()
+ }
+
+}
+
+// ByWeight sorts the Pages by weight and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByWeight() Pages {
+ const key = "pageSort.ByWeight"
+ pages, _ := spc.get(key, pageBy(DefaultPageSort).Sort, p)
+ return pages
+}
+
+// SortByDefault sorts pages by the default sort.
+func SortByDefault(pages Pages) {
+ pageBy(DefaultPageSort).Sort(pages)
+}
+
+// ByTitle sorts the Pages by title and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByTitle() Pages {
+ const key = "pageSort.ByTitle"
+
+ pages, _ := spc.get(key, collatorStringSort(func(p Page) string { return p.Title() }), p)
+
+ return pages
+}
+
+// ByLinkTitle sorts the Pages by link title and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByLinkTitle() Pages {
+ const key = "pageSort.ByLinkTitle"
+
+ pages, _ := spc.get(key, collatorStringSort(func(p Page) string { return p.LinkTitle() }), p)
+
+ return pages
+}
+
+// ByDate sorts the Pages by date and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByDate() Pages {
+ const key = "pageSort.ByDate"
+
+ pages, _ := spc.get(key, pageBy(lessPageDate).Sort, p)
+
+ return pages
+}
+
+// ByPublishDate sorts the Pages by publish date and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByPublishDate() Pages {
+ const key = "pageSort.ByPublishDate"
+
+ pages, _ := spc.get(key, pageBy(lessPagePubDate).Sort, p)
+
+ return pages
+}
+
+// ByExpiryDate sorts the Pages by publish date and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByExpiryDate() Pages {
+ const key = "pageSort.ByExpiryDate"
+
+ expDate := func(p1, p2 Page) bool {
+ return p1.ExpiryDate().Unix() < p2.ExpiryDate().Unix()
+ }
+
+ pages, _ := spc.get(key, pageBy(expDate).Sort, p)
+
+ return pages
+}
+
+// ByLastmod sorts the Pages by the last modification date and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByLastmod() Pages {
+ const key = "pageSort.ByLastmod"
+
+ date := func(p1, p2 Page) bool {
+ return p1.Lastmod().Unix() < p2.Lastmod().Unix()
+ }
+
+ pages, _ := spc.get(key, pageBy(date).Sort, p)
+
+ return pages
+}
+
+// ByLength sorts the Pages by length and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByLength() Pages {
+ const key = "pageSort.ByLength"
+
+ length := func(p1, p2 Page) bool {
+ p1l, ok1 := p1.(resource.LengthProvider)
+ p2l, ok2 := p2.(resource.LengthProvider)
+
+ if !ok1 {
+ return true
+ }
+
+ if !ok2 {
+ return false
+ }
+
+ return p1l.Len() < p2l.Len()
+ }
+
+ pages, _ := spc.get(key, pageBy(length).Sort, p)
+
+ return pages
+}
+
+// ByLanguage sorts the Pages by the language's Weight.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByLanguage() Pages {
+ const key = "pageSort.ByLanguage"
+
+ pages, _ := spc.get(key, pageBy(lessPageLanguage).Sort, p)
+
+ return pages
+}
+
+// SortByLanguage sorts the pages by language.
+func SortByLanguage(pages Pages) {
+ pageBy(lessPageLanguage).Sort(pages)
+}
+
+// Reverse reverses the order in Pages and returns a copy.
+//
+// Adjacent invocations on the same receiver will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) Reverse() Pages {
+ const key = "pageSort.Reverse"
+
+ reverseFunc := func(pages Pages) {
+ for i, j := 0, len(pages)-1; i < j; i, j = i+1, j-1 {
+ pages[i], pages[j] = pages[j], pages[i]
+ }
+ }
+
+ pages, _ := spc.get(key, reverseFunc, p)
+
+ return pages
+}
+
+// ByParam sorts the pages according to the given page Params key.
+//
+// Adjacent invocations on the same receiver with the same paramsKey will return a cached result.
+//
+// This may safely be executed in parallel.
+func (p Pages) ByParam(paramsKey any) Pages {
+ if len(p) < 2 {
+ return p
+ }
+ paramsKeyStr := cast.ToString(paramsKey)
+ key := "pageSort.ByParam." + paramsKeyStr
+
+ stringLess, close := collatorStringLess(p[0])
+ defer close()
+
+ paramsKeyComparator := func(p1, p2 Page) bool {
+ v1, _ := p1.Param(paramsKeyStr)
+ v2, _ := p2.Param(paramsKeyStr)
+
+ if v1 == nil {
+ return false
+ }
+
+ if v2 == nil {
+ return true
+ }
+
+ isNumeric := func(v any) bool {
+ switch v.(type) {
+ case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64, float32, float64:
+ return true
+ default:
+ return false
+ }
+ }
+
+ if isNumeric(v1) && isNumeric(v2) {
+ return cast.ToFloat64(v1) < cast.ToFloat64(v2)
+ }
+
+ s1 := cast.ToString(v1)
+ s2 := cast.ToString(v2)
+
+ return stringLess(s1, s2)
+
+ }
+
+ pages, _ := spc.get(key, pageBy(paramsKeyComparator).Sort, p)
+
+ return pages
+}
diff --git a/resources/page/pages_sort_search.go b/resources/page/pages_sort_search.go
new file mode 100644
index 000000000..b400f61e8
--- /dev/null
+++ b/resources/page/pages_sort_search.go
@@ -0,0 +1,125 @@
+// 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 page
+
+import "sort"
+
+// Used in page binary search, the most common in front.
+var pageLessFunctions = []func(p1, p2 Page) bool{
+ DefaultPageSort,
+ lessPageDate,
+ lessPagePubDate,
+ lessPageTitle,
+ lessPageLinkTitle,
+}
+
+func searchPage(p Page, pages Pages) int {
+ if len(pages) < 1000 {
+ // For smaller data sets, doing a linear search is faster.
+ return searchPageLinear(p, pages, 0)
+ }
+
+ less := isPagesProbablySorted(pages, pageLessFunctions...)
+ if less == nil {
+ return searchPageLinear(p, pages, 0)
+ }
+
+ i := searchPageBinary(p, pages, less)
+ if i != -1 {
+ return i
+ }
+
+ return searchPageLinear(p, pages, 0)
+}
+
+func searchPageLinear(p Page, pages Pages, start int) int {
+ for i := start; i < len(pages); i++ {
+ c := pages[i]
+ if c.Eq(p) {
+ return i
+ }
+ }
+ return -1
+}
+
+func searchPageBinary(p Page, pages Pages, less func(p1, p2 Page) bool) int {
+ n := len(pages)
+
+ f := func(i int) bool {
+ c := pages[i]
+ isLess := less(c, p)
+ return !isLess || c.Eq(p)
+ }
+
+ i := sort.Search(n, f)
+
+ if i == n {
+ return -1
+ }
+
+ return searchPageLinear(p, pages, i)
+}
+
+// isProbablySorted tests if the pages slice is probably sorted.
+func isPagesProbablySorted(pages Pages, lessFuncs ...func(p1, p2 Page) bool) func(p1, p2 Page) bool {
+ n := len(pages)
+ step := 1
+ if n > 500 {
+ step = 50
+ }
+
+ is := func(less func(p1, p2 Page) bool) bool {
+ samples := 0
+
+ for i := n - 1; i > 0; i = i - step {
+ if less(pages[i], pages[i-1]) {
+ return false
+ }
+ samples++
+ if samples >= 15 {
+ return true
+ }
+ }
+ return samples > 0
+ }
+
+ isReverse := func(less func(p1, p2 Page) bool) bool {
+ samples := 0
+
+ for i := 0; i < n-1; i = i + step {
+ if less(pages[i], pages[i+1]) {
+ return false
+ }
+ samples++
+
+ if samples > 15 {
+ return true
+ }
+ }
+ return samples > 0
+ }
+
+ for _, less := range lessFuncs {
+ if is(less) {
+ return less
+ }
+ if isReverse(less) {
+ return func(p1, p2 Page) bool {
+ return less(p2, p1)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/resources/page/pages_sort_search_test.go b/resources/page/pages_sort_search_test.go
new file mode 100644
index 000000000..8f115109c
--- /dev/null
+++ b/resources/page/pages_sort_search_test.go
@@ -0,0 +1,122 @@
+// 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 page
+
+import (
+ "fmt"
+ "math/rand"
+ "testing"
+ "time"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestSearchPage(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createSortTestPages(10)
+ for i, p := range pages {
+ p.(*testPage).title = fmt.Sprintf("Title %d", i%2)
+ }
+
+ for _, pages := range []Pages{pages.ByTitle(), pages.ByTitle().Reverse()} {
+ less := isPagesProbablySorted(pages, lessPageTitle)
+ c.Assert(less, qt.Not(qt.IsNil))
+ for i, p := range pages {
+ idx := searchPageBinary(p, pages, less)
+ c.Assert(idx, qt.Equals, i)
+ }
+ }
+}
+
+func BenchmarkSearchPage(b *testing.B) {
+ type Variant struct {
+ name string
+ preparePages func(pages Pages) Pages
+ search func(p Page, pages Pages) int
+ }
+
+ shufflePages := func(pages Pages) Pages {
+ rand.Shuffle(len(pages), func(i, j int) { pages[i], pages[j] = pages[j], pages[i] })
+ return pages
+ }
+
+ linearSearch := func(p Page, pages Pages) int {
+ return searchPageLinear(p, pages, 0)
+ }
+
+ createPages := func(num int) Pages {
+ pages := createSortTestPages(num)
+ for _, p := range pages {
+ tp := p.(*testPage)
+ tp.weight = rand.Intn(len(pages))
+ tp.title = fmt.Sprintf("Title %d", rand.Intn(len(pages)))
+
+ tp.pubDate = time.Now().Add(time.Duration(rand.Intn(len(pages)/5)) * time.Hour)
+ tp.date = time.Now().Add(time.Duration(rand.Intn(len(pages)/5)) * time.Hour)
+ }
+
+ return pages
+ }
+
+ for _, variant := range []Variant{
+ {"Shuffled", shufflePages, searchPage},
+ {"ByWeight", func(pages Pages) Pages {
+ return pages.ByWeight()
+ }, searchPage},
+ {"ByWeight.Reverse", func(pages Pages) Pages {
+ return pages.ByWeight().Reverse()
+ }, searchPage},
+ {"ByDate", func(pages Pages) Pages {
+ return pages.ByDate()
+ }, searchPage},
+ {"ByPublishDate", func(pages Pages) Pages {
+ return pages.ByPublishDate()
+ }, searchPage},
+ {"ByTitle", func(pages Pages) Pages {
+ return pages.ByTitle()
+ }, searchPage},
+ {"ByTitle Linear", func(pages Pages) Pages {
+ return pages.ByTitle()
+ }, linearSearch},
+ } {
+ for _, numPages := range []int{100, 500, 1000, 5000} {
+ b.Run(fmt.Sprintf("%s-%d", variant.name, numPages), func(b *testing.B) {
+ b.StopTimer()
+ pages := createPages(numPages)
+ if variant.preparePages != nil {
+ pages = variant.preparePages(pages)
+ }
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ j := rand.Intn(numPages)
+ k := variant.search(pages[j], pages)
+ if k != j {
+ b.Fatalf("%d != %d", k, j)
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestIsPagesProbablySorted(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ c.Assert(isPagesProbablySorted(createSortTestPages(6).ByWeight(), DefaultPageSort), qt.Not(qt.IsNil))
+ c.Assert(isPagesProbablySorted(createSortTestPages(300).ByWeight(), DefaultPageSort), qt.Not(qt.IsNil))
+ c.Assert(isPagesProbablySorted(createSortTestPages(6), DefaultPageSort), qt.IsNil)
+ c.Assert(isPagesProbablySorted(createSortTestPages(300).ByTitle(), pageLessFunctions...), qt.Not(qt.IsNil))
+}
diff --git a/resources/page/pages_sort_test.go b/resources/page/pages_sort_test.go
new file mode 100644
index 000000000..cf4e339ee
--- /dev/null
+++ b/resources/page/pages_sort_test.go
@@ -0,0 +1,289 @@
+// 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 page
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/google/go-cmp/cmp"
+
+ qt "github.com/frankban/quicktest"
+)
+
+var eq = qt.CmpEquals(
+ cmp.Comparer(func(p1, p2 testPage) bool {
+ return p1.path == p2.path && p1.weight == p2.weight
+ }),
+)
+
+func TestDefaultSort(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ d1 := time.Now()
+ d2 := d1.Add(-1 * time.Hour)
+ d3 := d1.Add(-2 * time.Hour)
+ d4 := d1.Add(-3 * time.Hour)
+
+ p := createSortTestPages(4)
+
+ // first by weight
+ setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "a", "c", "d"}, [4]int{4, 3, 2, 1}, p)
+ SortByDefault(p)
+
+ c.Assert(p[0].Weight(), qt.Equals, 1)
+
+ // Consider zero weight, issue #2673
+ setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "a", "d", "c"}, [4]int{0, 0, 0, 1}, p)
+ SortByDefault(p)
+
+ c.Assert(p[0].Weight(), qt.Equals, 1)
+
+ // next by date
+ setSortVals([4]time.Time{d3, d4, d1, d2}, [4]string{"a", "b", "c", "d"}, [4]int{1, 1, 1, 1}, p)
+ SortByDefault(p)
+ c.Assert(p[0].Date(), qt.Equals, d1)
+
+ // finally by link title
+ setSortVals([4]time.Time{d3, d3, d3, d3}, [4]string{"b", "c", "a", "d"}, [4]int{1, 1, 1, 1}, p)
+ SortByDefault(p)
+ c.Assert(p[0].LinkTitle(), qt.Equals, "al")
+ c.Assert(p[1].LinkTitle(), qt.Equals, "bl")
+ c.Assert(p[2].LinkTitle(), qt.Equals, "cl")
+}
+
+// https://github.com/gohugoio/hugo/issues/4953
+func TestSortByLinkTitle(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createSortTestPages(6)
+
+ for i, p := range pages {
+ pp := p.(*testPage)
+ if i < 5 {
+ pp.title = fmt.Sprintf("title%d", i)
+ }
+
+ if i > 2 {
+ pp.linkTitle = fmt.Sprintf("linkTitle%d", i)
+ }
+
+ }
+
+ pages.shuffle()
+
+ bylt := pages.ByLinkTitle()
+
+ for i, p := range bylt {
+ if i < 3 {
+ c.Assert(p.LinkTitle(), qt.Equals, fmt.Sprintf("linkTitle%d", i+3))
+ } else {
+ c.Assert(p.LinkTitle(), qt.Equals, fmt.Sprintf("title%d", i-3))
+ }
+ }
+}
+
+func TestSortByN(t *testing.T) {
+ t.Parallel()
+ d1 := time.Now()
+ d2 := d1.Add(-2 * time.Hour)
+ d3 := d1.Add(-10 * time.Hour)
+ d4 := d1.Add(-20 * time.Hour)
+
+ p := createSortTestPages(4)
+
+ for i, this := range []struct {
+ sortFunc func(p Pages) Pages
+ assertFunc func(p Pages) bool
+ }{
+ {(Pages).ByWeight, func(p Pages) bool { return p[0].Weight() == 1 }},
+ {(Pages).ByTitle, func(p Pages) bool { return p[0].Title() == "ab" }},
+ {(Pages).ByLinkTitle, func(p Pages) bool { return p[0].LinkTitle() == "abl" }},
+ {(Pages).ByDate, func(p Pages) bool { return p[0].Date() == d4 }},
+ {(Pages).ByPublishDate, func(p Pages) bool { return p[0].PublishDate() == d4 }},
+ {(Pages).ByExpiryDate, func(p Pages) bool { return p[0].ExpiryDate() == d4 }},
+ {(Pages).ByLastmod, func(p Pages) bool { return p[1].Lastmod() == d3 }},
+ {(Pages).ByLength, func(p Pages) bool { return p[0].(resource.LengthProvider).Len() == len(p[0].(*testPage).content) }},
+ } {
+ setSortVals([4]time.Time{d1, d2, d3, d4}, [4]string{"b", "ab", "cde", "fg"}, [4]int{0, 3, 2, 1}, p)
+
+ sorted := this.sortFunc(p)
+ if !this.assertFunc(sorted) {
+ t.Errorf("[%d] sort error", i)
+ }
+ }
+}
+
+func TestLimit(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ p := createSortTestPages(10)
+ firstFive := p.Limit(5)
+ c.Assert(len(firstFive), qt.Equals, 5)
+ for i := 0; i < 5; i++ {
+ c.Assert(firstFive[i], qt.Equals, p[i])
+ }
+ c.Assert(p.Limit(10), eq, p)
+ c.Assert(p.Limit(11), eq, p)
+}
+
+func TestPageSortReverse(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ p1 := createSortTestPages(10)
+ c.Assert(p1[0].(*testPage).fuzzyWordCount, qt.Equals, 0)
+ c.Assert(p1[9].(*testPage).fuzzyWordCount, qt.Equals, 9)
+ p2 := p1.Reverse()
+ c.Assert(p2[0].(*testPage).fuzzyWordCount, qt.Equals, 9)
+ c.Assert(p2[9].(*testPage).fuzzyWordCount, qt.Equals, 0)
+ // cached
+ c.Assert(pagesEqual(p2, p1.Reverse()), qt.Equals, true)
+}
+
+func TestPageSortByParam(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ var k any = "arbitrarily.nested"
+
+ unsorted := createSortTestPages(10)
+ delete(unsorted[9].Params(), "arbitrarily")
+
+ firstSetValue, _ := unsorted[0].Param(k)
+ secondSetValue, _ := unsorted[1].Param(k)
+ lastSetValue, _ := unsorted[8].Param(k)
+ unsetValue, _ := unsorted[9].Param(k)
+
+ c.Assert(firstSetValue, qt.Equals, "xyz100")
+ c.Assert(secondSetValue, qt.Equals, "xyz99")
+ c.Assert(lastSetValue, qt.Equals, "xyz92")
+ c.Assert(unsetValue, qt.Equals, nil)
+
+ sorted := unsorted.ByParam("arbitrarily.nested")
+ firstSetSortedValue, _ := sorted[0].Param(k)
+ secondSetSortedValue, _ := sorted[1].Param(k)
+ lastSetSortedValue, _ := sorted[8].Param(k)
+ unsetSortedValue, _ := sorted[9].Param(k)
+
+ c.Assert(firstSetSortedValue, qt.Equals, firstSetValue)
+ c.Assert(lastSetSortedValue, qt.Equals, secondSetValue)
+ c.Assert(secondSetSortedValue, qt.Equals, lastSetValue)
+ c.Assert(unsetSortedValue, qt.Equals, unsetValue)
+}
+
+func TestPageSortByParamNumeric(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ var k any = "arbitrarily.nested"
+
+ n := 10
+ unsorted := createSortTestPages(n)
+ for i := 0; i < n; i++ {
+ v := 100 - i
+ if i%2 == 0 {
+ v = 100.0 - i
+ }
+
+ unsorted[i].(*testPage).params = map[string]any{
+ "arbitrarily": map[string]any{
+ "nested": v,
+ },
+ }
+ }
+ delete(unsorted[9].Params(), "arbitrarily")
+
+ firstSetValue, _ := unsorted[0].Param(k)
+ secondSetValue, _ := unsorted[1].Param(k)
+ lastSetValue, _ := unsorted[8].Param(k)
+ unsetValue, _ := unsorted[9].Param(k)
+
+ c.Assert(firstSetValue, qt.Equals, 100)
+ c.Assert(secondSetValue, qt.Equals, 99)
+ c.Assert(lastSetValue, qt.Equals, 92)
+ c.Assert(unsetValue, qt.Equals, nil)
+
+ sorted := unsorted.ByParam("arbitrarily.nested")
+ firstSetSortedValue, _ := sorted[0].Param(k)
+ secondSetSortedValue, _ := sorted[1].Param(k)
+ lastSetSortedValue, _ := sorted[8].Param(k)
+ unsetSortedValue, _ := sorted[9].Param(k)
+
+ c.Assert(firstSetSortedValue, qt.Equals, 92)
+ c.Assert(secondSetSortedValue, qt.Equals, 93)
+ c.Assert(lastSetSortedValue, qt.Equals, 100)
+ c.Assert(unsetSortedValue, qt.Equals, unsetValue)
+}
+
+func BenchmarkSortByWeightAndReverse(b *testing.B) {
+ p := createSortTestPages(300)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p = p.ByWeight().Reverse()
+ }
+}
+
+func setSortVals(dates [4]time.Time, titles [4]string, weights [4]int, pages Pages) {
+ for i := range dates {
+ this := pages[i].(*testPage)
+ other := pages[len(dates)-1-i].(*testPage)
+
+ this.date = dates[i]
+ this.lastMod = dates[i]
+ this.weight = weights[i]
+ this.title = titles[i]
+ // make sure we compare apples and ... apples ...
+ other.linkTitle = this.Title() + "l"
+ other.pubDate = dates[i]
+ other.expiryDate = dates[i]
+ other.content = titles[i] + "_content"
+ }
+ lastLastMod := pages[2].Lastmod()
+ pages[2].(*testPage).lastMod = pages[1].Lastmod()
+ pages[1].(*testPage).lastMod = lastLastMod
+
+ for _, p := range pages {
+ p.(*testPage).content = ""
+ }
+}
+
+func createSortTestPages(num int) Pages {
+ pages := make(Pages, num)
+
+ for i := 0; i < num; i++ {
+ p := newTestPage()
+ p.path = fmt.Sprintf("/x/y/p%d.md", i)
+ p.title = fmt.Sprintf("Title %d", i%(num+1/2))
+ p.params = map[string]any{
+ "arbitrarily": map[string]any{
+ "nested": ("xyz" + fmt.Sprintf("%v", 100-i)),
+ },
+ }
+
+ w := 5
+
+ if i%2 == 0 {
+ w = 10
+ }
+ p.fuzzyWordCount = i
+ p.weight = w
+ p.description = "initial"
+
+ pages[i] = p
+ }
+
+ return pages
+}
diff --git a/resources/page/pages_test.go b/resources/page/pages_test.go
new file mode 100644
index 000000000..22ee698da
--- /dev/null
+++ b/resources/page/pages_test.go
@@ -0,0 +1,72 @@
+// 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 page
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestProbablyEq(t *testing.T) {
+ p1, p2, p3 := &testPage{title: "p1"}, &testPage{title: "p2"}, &testPage{title: "p3"}
+ pages12 := Pages{p1, p2}
+ pages21 := Pages{p2, p1}
+ pages123 := Pages{p1, p2, p3}
+
+ t.Run("Pages", func(t *testing.T) {
+ c := qt.New(t)
+
+ c.Assert(pages12.ProbablyEq(pages12), qt.Equals, true)
+ c.Assert(pages123.ProbablyEq(pages12), qt.Equals, false)
+ c.Assert(pages12.ProbablyEq(pages21), qt.Equals, false)
+ })
+
+ t.Run("PageGroup", func(t *testing.T) {
+ c := qt.New(t)
+
+ c.Assert(PageGroup{Key: "a", Pages: pages12}.ProbablyEq(PageGroup{Key: "a", Pages: pages12}), qt.Equals, true)
+ c.Assert(PageGroup{Key: "a", Pages: pages12}.ProbablyEq(PageGroup{Key: "b", Pages: pages12}), qt.Equals, false)
+ })
+
+ t.Run("PagesGroup", func(t *testing.T) {
+ c := qt.New(t)
+
+ pg1, pg2 := PageGroup{Key: "a", Pages: pages12}, PageGroup{Key: "b", Pages: pages123}
+
+ c.Assert(PagesGroup{pg1, pg2}.ProbablyEq(PagesGroup{pg1, pg2}), qt.Equals, true)
+ c.Assert(PagesGroup{pg1, pg2}.ProbablyEq(PagesGroup{pg2, pg1}), qt.Equals, false)
+ })
+}
+
+func TestToPages(t *testing.T) {
+ c := qt.New(t)
+
+ p1, p2 := &testPage{title: "p1"}, &testPage{title: "p2"}
+ pages12 := Pages{p1, p2}
+
+ mustToPages := func(in any) Pages {
+ p, err := ToPages(in)
+ c.Assert(err, qt.IsNil)
+ return p
+ }
+
+ c.Assert(mustToPages(nil), eq, Pages{})
+ c.Assert(mustToPages(pages12), eq, pages12)
+ c.Assert(mustToPages([]Page{p1, p2}), eq, pages12)
+ c.Assert(mustToPages([]any{p1, p2}), eq, pages12)
+
+ _, err := ToPages("not a page")
+ c.Assert(err, qt.Not(qt.IsNil))
+}
diff --git a/resources/page/pagination.go b/resources/page/pagination.go
new file mode 100644
index 000000000..9f4bfcff5
--- /dev/null
+++ b/resources/page/pagination.go
@@ -0,0 +1,396 @@
+// 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 page
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "math"
+ "reflect"
+
+ "github.com/gohugoio/hugo/config"
+
+ "github.com/spf13/cast"
+)
+
+// PaginatorProvider provides two ways to create a page paginator.
+type PaginatorProvider interface {
+ Paginator(options ...any) (*Pager, error)
+ Paginate(seq any, options ...any) (*Pager, error)
+}
+
+// Pager represents one of the elements in a paginator.
+// The number, starting on 1, represents its place.
+type Pager struct {
+ number int
+ *Paginator
+}
+
+func (p Pager) String() string {
+ return fmt.Sprintf("Pager %d", p.number)
+}
+
+type paginatedElement interface {
+ Len() int
+}
+
+type pagers []*Pager
+
+var (
+ paginatorEmptyPages Pages
+ paginatorEmptyPageGroups PagesGroup
+)
+
+type Paginator struct {
+ paginatedElements []paginatedElement
+ pagers
+ paginationURLFactory
+ total int
+ size int
+}
+
+type paginationURLFactory func(int) string
+
+// PageNumber returns the current page's number in the pager sequence.
+func (p *Pager) PageNumber() int {
+ return p.number
+}
+
+// URL returns the URL to the current page.
+func (p *Pager) URL() template.HTML {
+ return template.HTML(p.paginationURLFactory(p.PageNumber()))
+}
+
+// Pages returns the Pages on this page.
+// Note: If this return a non-empty result, then PageGroups() will return empty.
+func (p *Pager) Pages() Pages {
+ if len(p.paginatedElements) == 0 {
+ return paginatorEmptyPages
+ }
+
+ if pages, ok := p.element().(Pages); ok {
+ return pages
+ }
+
+ return paginatorEmptyPages
+}
+
+// PageGroups return Page groups for this page.
+// Note: If this return non-empty result, then Pages() will return empty.
+func (p *Pager) PageGroups() PagesGroup {
+ if len(p.paginatedElements) == 0 {
+ return paginatorEmptyPageGroups
+ }
+
+ if groups, ok := p.element().(PagesGroup); ok {
+ return groups
+ }
+
+ return paginatorEmptyPageGroups
+}
+
+func (p *Pager) element() paginatedElement {
+ if len(p.paginatedElements) == 0 {
+ return paginatorEmptyPages
+ }
+ return p.paginatedElements[p.PageNumber()-1]
+}
+
+// page returns the Page with the given index
+func (p *Pager) page(index int) (Page, error) {
+ if pages, ok := p.element().(Pages); ok {
+ if pages != nil && len(pages) > index {
+ return pages[index], nil
+ }
+ return nil, nil
+ }
+
+ // must be PagesGroup
+ // this construction looks clumsy, but ...
+ // ... it is the difference between 99.5% and 100% test coverage :-)
+ groups := p.element().(PagesGroup)
+
+ i := 0
+ for _, v := range groups {
+ for _, page := range v.Pages {
+ if i == index {
+ return page, nil
+ }
+ i++
+ }
+ }
+ return nil, nil
+}
+
+// NumberOfElements gets the number of elements on this page.
+func (p *Pager) NumberOfElements() int {
+ return p.element().Len()
+}
+
+// HasPrev tests whether there are page(s) before the current.
+func (p *Pager) HasPrev() bool {
+ return p.PageNumber() > 1
+}
+
+// Prev returns the pager for the previous page.
+func (p *Pager) Prev() *Pager {
+ if !p.HasPrev() {
+ return nil
+ }
+ return p.pagers[p.PageNumber()-2]
+}
+
+// HasNext tests whether there are page(s) after the current.
+func (p *Pager) HasNext() bool {
+ return p.PageNumber() < len(p.paginatedElements)
+}
+
+// Next returns the pager for the next page.
+func (p *Pager) Next() *Pager {
+ if !p.HasNext() {
+ return nil
+ }
+ return p.pagers[p.PageNumber()]
+}
+
+// First returns the pager for the first page.
+func (p *Pager) First() *Pager {
+ return p.pagers[0]
+}
+
+// Last returns the pager for the last page.
+func (p *Pager) Last() *Pager {
+ return p.pagers[len(p.pagers)-1]
+}
+
+// Pagers returns a list of pagers that can be used to build a pagination menu.
+func (p *Paginator) Pagers() pagers {
+ return p.pagers
+}
+
+// PageSize returns the size of each paginator page.
+func (p *Paginator) PageSize() int {
+ return p.size
+}
+
+// TotalPages returns the number of pages in the paginator.
+func (p *Paginator) TotalPages() int {
+ return len(p.paginatedElements)
+}
+
+// TotalNumberOfElements returns the number of elements on all pages in this paginator.
+func (p *Paginator) TotalNumberOfElements() int {
+ return p.total
+}
+
+func splitPages(pages Pages, size int) []paginatedElement {
+ var split []paginatedElement
+ for low, j := 0, len(pages); low < j; low += size {
+ high := int(math.Min(float64(low+size), float64(len(pages))))
+ split = append(split, pages[low:high])
+ }
+
+ return split
+}
+
+func splitPageGroups(pageGroups PagesGroup, size int) []paginatedElement {
+ type keyPage struct {
+ key any
+ page Page
+ }
+
+ var (
+ split []paginatedElement
+ flattened []keyPage
+ )
+
+ for _, g := range pageGroups {
+ for _, p := range g.Pages {
+ flattened = append(flattened, keyPage{g.Key, p})
+ }
+ }
+
+ numPages := len(flattened)
+
+ for low, j := 0, numPages; low < j; low += size {
+ high := int(math.Min(float64(low+size), float64(numPages)))
+
+ var (
+ pg PagesGroup
+ key any
+ groupIndex = -1
+ )
+
+ for k := low; k < high; k++ {
+ kp := flattened[k]
+ if key == nil || key != kp.key {
+ key = kp.key
+ pg = append(pg, PageGroup{Key: key})
+ groupIndex++
+ }
+ pg[groupIndex].Pages = append(pg[groupIndex].Pages, kp.page)
+ }
+ split = append(split, pg)
+ }
+
+ return split
+}
+
+func ResolvePagerSize(cfg config.Provider, options ...any) (int, error) {
+ if len(options) == 0 {
+ return cfg.GetInt("paginate"), nil
+ }
+
+ if len(options) > 1 {
+ return -1, errors.New("too many arguments, 'pager size' is currently the only option")
+ }
+
+ pas, err := cast.ToIntE(options[0])
+
+ if err != nil || pas <= 0 {
+ return -1, errors.New(("'pager size' must be a positive integer"))
+ }
+
+ return pas, nil
+}
+
+func Paginate(td TargetPathDescriptor, seq any, pagerSize int) (*Paginator, error) {
+ if pagerSize <= 0 {
+ return nil, errors.New("'paginate' configuration setting must be positive to paginate")
+ }
+
+ urlFactory := newPaginationURLFactory(td)
+
+ var paginator *Paginator
+
+ groups, err := ToPagesGroup(seq)
+ if err != nil {
+ return nil, err
+ }
+ if groups != nil {
+ paginator, _ = newPaginatorFromPageGroups(groups, pagerSize, urlFactory)
+ } else {
+ pages, err := ToPages(seq)
+ if err != nil {
+ return nil, err
+ }
+ paginator, _ = newPaginatorFromPages(pages, pagerSize, urlFactory)
+ }
+
+ return paginator, nil
+}
+
+// probablyEqual checks page lists for probable equality.
+// It may return false positives.
+// The motivation behind this is to avoid potential costly reflect.DeepEqual
+// when "probably" is good enough.
+func probablyEqualPageLists(a1 any, a2 any) bool {
+ if a1 == nil || a2 == nil {
+ return a1 == a2
+ }
+
+ t1 := reflect.TypeOf(a1)
+ t2 := reflect.TypeOf(a2)
+
+ if t1 != t2 {
+ return false
+ }
+
+ if g1, ok := a1.(PagesGroup); ok {
+ g2 := a2.(PagesGroup)
+ if len(g1) != len(g2) {
+ return false
+ }
+ if len(g1) == 0 {
+ return true
+ }
+ if g1.Len() != g2.Len() {
+ return false
+ }
+
+ return g1[0].Pages[0] == g2[0].Pages[0]
+ }
+
+ p1, err1 := ToPages(a1)
+ p2, err2 := ToPages(a2)
+
+ // probably the same wrong type
+ if err1 != nil && err2 != nil {
+ return true
+ }
+
+ if len(p1) != len(p2) {
+ return false
+ }
+
+ if len(p1) == 0 {
+ return true
+ }
+
+ return p1[0] == p2[0]
+}
+
+func newPaginatorFromPages(pages Pages, size int, urlFactory paginationURLFactory) (*Paginator, error) {
+ if size <= 0 {
+ return nil, errors.New("Paginator size must be positive")
+ }
+
+ split := splitPages(pages, size)
+
+ return newPaginator(split, len(pages), size, urlFactory)
+}
+
+func newPaginatorFromPageGroups(pageGroups PagesGroup, size int, urlFactory paginationURLFactory) (*Paginator, error) {
+ if size <= 0 {
+ return nil, errors.New("Paginator size must be positive")
+ }
+
+ split := splitPageGroups(pageGroups, size)
+
+ return newPaginator(split, pageGroups.Len(), size, urlFactory)
+}
+
+func newPaginator(elements []paginatedElement, total, size int, urlFactory paginationURLFactory) (*Paginator, error) {
+ p := &Paginator{total: total, paginatedElements: elements, size: size, paginationURLFactory: urlFactory}
+
+ var ps pagers
+
+ if len(elements) > 0 {
+ ps = make(pagers, len(elements))
+ for i := range p.paginatedElements {
+ ps[i] = &Pager{number: (i + 1), Paginator: p}
+ }
+ } else {
+ ps = make(pagers, 1)
+ ps[0] = &Pager{number: 1, Paginator: p}
+ }
+
+ p.pagers = ps
+
+ return p, nil
+}
+
+func newPaginationURLFactory(d TargetPathDescriptor) paginationURLFactory {
+ return func(pageNumber int) string {
+ pathDescriptor := d
+ var rel string
+ if pageNumber > 1 {
+ rel = fmt.Sprintf("/%s/%d/", d.PathSpec.PaginatePath, pageNumber)
+ pathDescriptor.Addends = rel
+ }
+
+ return CreateTargetPaths(pathDescriptor).RelPermalink(d.PathSpec)
+ }
+}
diff --git a/resources/page/pagination_test.go b/resources/page/pagination_test.go
new file mode 100644
index 000000000..e379f9b6b
--- /dev/null
+++ b/resources/page/pagination_test.go
@@ -0,0 +1,310 @@
+// 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 page
+
+import (
+ "fmt"
+ "html/template"
+ "testing"
+
+ "github.com/gohugoio/hugo/config"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/output"
+)
+
+func TestSplitPages(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createTestPages(21)
+ chunks := splitPages(pages, 5)
+ c.Assert(len(chunks), qt.Equals, 5)
+
+ for i := 0; i < 4; i++ {
+ c.Assert(chunks[i].Len(), qt.Equals, 5)
+ }
+
+ lastChunk := chunks[4]
+ c.Assert(lastChunk.Len(), qt.Equals, 1)
+}
+
+func TestSplitPageGroups(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createTestPages(21)
+ groups, _ := pages.GroupBy("Weight", "desc")
+ chunks := splitPageGroups(groups, 5)
+ c.Assert(len(chunks), qt.Equals, 5)
+
+ firstChunk := chunks[0]
+
+ // alternate weight 5 and 10
+ if groups, ok := firstChunk.(PagesGroup); ok {
+ c.Assert(groups.Len(), qt.Equals, 5)
+ for _, pg := range groups {
+ // first group 10 in weight
+ c.Assert(pg.Key, qt.Equals, 10)
+ for _, p := range pg.Pages {
+ c.Assert(p.FuzzyWordCount()%2 == 0, qt.Equals, true) // magic test
+ }
+ }
+ } else {
+ t.Fatal("Excepted PageGroup")
+ }
+
+ lastChunk := chunks[4]
+
+ if groups, ok := lastChunk.(PagesGroup); ok {
+ c.Assert(groups.Len(), qt.Equals, 1)
+ for _, pg := range groups {
+ // last should have 5 in weight
+ c.Assert(pg.Key, qt.Equals, 5)
+ for _, p := range pg.Pages {
+ c.Assert(p.FuzzyWordCount()%2 != 0, qt.Equals, true) // magic test
+ }
+ }
+ } else {
+ t.Fatal("Excepted PageGroup")
+ }
+}
+
+func TestPager(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createTestPages(21)
+ groups, _ := pages.GroupBy("Weight", "desc")
+
+ urlFactory := func(page int) string {
+ return fmt.Sprintf("page/%d/", page)
+ }
+
+ _, err := newPaginatorFromPages(pages, -1, urlFactory)
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ _, err = newPaginatorFromPageGroups(groups, -1, urlFactory)
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ pag, err := newPaginatorFromPages(pages, 5, urlFactory)
+ c.Assert(err, qt.IsNil)
+ doTestPages(t, pag)
+ first := pag.Pagers()[0].First()
+ c.Assert(first.String(), qt.Equals, "Pager 1")
+ c.Assert(first.Pages(), qt.Not(qt.HasLen), 0)
+ c.Assert(first.PageGroups(), qt.HasLen, 0)
+
+ pag, err = newPaginatorFromPageGroups(groups, 5, urlFactory)
+ c.Assert(err, qt.IsNil)
+ doTestPages(t, pag)
+ first = pag.Pagers()[0].First()
+ c.Assert(first.PageGroups(), qt.Not(qt.HasLen), 0)
+ c.Assert(first.Pages(), qt.HasLen, 0)
+}
+
+func doTestPages(t *testing.T, paginator *Paginator) {
+ c := qt.New(t)
+ paginatorPages := paginator.Pagers()
+
+ c.Assert(len(paginatorPages), qt.Equals, 5)
+ c.Assert(paginator.TotalNumberOfElements(), qt.Equals, 21)
+ c.Assert(paginator.PageSize(), qt.Equals, 5)
+ c.Assert(paginator.TotalPages(), qt.Equals, 5)
+
+ first := paginatorPages[0]
+ c.Assert(first.URL(), qt.Equals, template.HTML("page/1/"))
+ c.Assert(first.First(), qt.Equals, first)
+ c.Assert(first.HasNext(), qt.Equals, true)
+ c.Assert(first.Next(), qt.Equals, paginatorPages[1])
+ c.Assert(first.HasPrev(), qt.Equals, false)
+ c.Assert(first.Prev(), qt.IsNil)
+ c.Assert(first.NumberOfElements(), qt.Equals, 5)
+ c.Assert(first.PageNumber(), qt.Equals, 1)
+
+ third := paginatorPages[2]
+ c.Assert(third.HasNext(), qt.Equals, true)
+ c.Assert(third.HasPrev(), qt.Equals, true)
+ c.Assert(third.Prev(), qt.Equals, paginatorPages[1])
+
+ last := paginatorPages[4]
+ c.Assert(last.URL(), qt.Equals, template.HTML("page/5/"))
+ c.Assert(last.Last(), qt.Equals, last)
+ c.Assert(last.HasNext(), qt.Equals, false)
+ c.Assert(last.Next(), qt.IsNil)
+ c.Assert(last.HasPrev(), qt.Equals, true)
+ c.Assert(last.NumberOfElements(), qt.Equals, 1)
+ c.Assert(last.PageNumber(), qt.Equals, 5)
+}
+
+func TestPagerNoPages(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ pages := createTestPages(0)
+ groups, _ := pages.GroupBy("Weight", "desc")
+
+ urlFactory := func(page int) string {
+ return fmt.Sprintf("page/%d/", page)
+ }
+
+ paginator, _ := newPaginatorFromPages(pages, 5, urlFactory)
+ doTestPagerNoPages(t, paginator)
+
+ first := paginator.Pagers()[0].First()
+ c.Assert(first.PageGroups(), qt.HasLen, 0)
+ c.Assert(first.Pages(), qt.HasLen, 0)
+
+ paginator, _ = newPaginatorFromPageGroups(groups, 5, urlFactory)
+ doTestPagerNoPages(t, paginator)
+
+ first = paginator.Pagers()[0].First()
+ c.Assert(first.PageGroups(), qt.HasLen, 0)
+ c.Assert(first.Pages(), qt.HasLen, 0)
+}
+
+func doTestPagerNoPages(t *testing.T, paginator *Paginator) {
+ paginatorPages := paginator.Pagers()
+ c := qt.New(t)
+ c.Assert(len(paginatorPages), qt.Equals, 1)
+ c.Assert(paginator.TotalNumberOfElements(), qt.Equals, 0)
+ c.Assert(paginator.PageSize(), qt.Equals, 5)
+ c.Assert(paginator.TotalPages(), qt.Equals, 0)
+
+ // pageOne should be nothing but the first
+ pageOne := paginatorPages[0]
+ c.Assert(pageOne.First(), qt.Not(qt.IsNil))
+ c.Assert(pageOne.HasNext(), qt.Equals, false)
+ c.Assert(pageOne.HasPrev(), qt.Equals, false)
+ c.Assert(pageOne.Next(), qt.IsNil)
+ c.Assert(len(pageOne.Pagers()), qt.Equals, 1)
+ c.Assert(pageOne.Pages().Len(), qt.Equals, 0)
+ c.Assert(pageOne.NumberOfElements(), qt.Equals, 0)
+ c.Assert(pageOne.TotalNumberOfElements(), qt.Equals, 0)
+ c.Assert(pageOne.TotalPages(), qt.Equals, 0)
+ c.Assert(pageOne.PageNumber(), qt.Equals, 1)
+ c.Assert(pageOne.PageSize(), qt.Equals, 5)
+}
+
+func TestPaginationURLFactory(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ cfg := config.New()
+ cfg.Set("paginatePath", "zoo")
+
+ for _, uglyURLs := range []bool{false, true} {
+ c.Run(fmt.Sprintf("uglyURLs=%t", uglyURLs), func(c *qt.C) {
+ tests := []struct {
+ name string
+ d TargetPathDescriptor
+ baseURL string
+ page int
+ expected string
+ expectedUgly string
+ }{
+ {
+ "HTML home page 32",
+ TargetPathDescriptor{Kind: KindHome, Type: output.HTMLFormat},
+ "http://example.com/", 32, "/zoo/32/", "/zoo/32.html",
+ },
+ {
+ "JSON home page 42",
+ TargetPathDescriptor{Kind: KindHome, Type: output.JSONFormat},
+ "http://example.com/", 42, "/zoo/42/index.json", "/zoo/42.json",
+ },
+ }
+
+ for _, test := range tests {
+ d := test.d
+ cfg.Set("baseURL", test.baseURL)
+ cfg.Set("uglyURLs", uglyURLs)
+ d.UglyURLs = uglyURLs
+
+ pathSpec := newTestPathSpecFor(cfg)
+ d.PathSpec = pathSpec
+
+ factory := newPaginationURLFactory(d)
+
+ got := factory(test.page)
+
+ if uglyURLs {
+ c.Assert(got, qt.Equals, test.expectedUgly)
+ } else {
+ c.Assert(got, qt.Equals, test.expected)
+ }
+
+ }
+ })
+ }
+}
+
+func TestProbablyEqualPageLists(t *testing.T) {
+ t.Parallel()
+ fivePages := createTestPages(5)
+ zeroPages := createTestPages(0)
+ zeroPagesByWeight, _ := createTestPages(0).GroupBy("Weight", "asc")
+ fivePagesByWeight, _ := createTestPages(5).GroupBy("Weight", "asc")
+ ninePagesByWeight, _ := createTestPages(9).GroupBy("Weight", "asc")
+
+ for i, this := range []struct {
+ v1 any
+ v2 any
+ expect bool
+ }{
+ {nil, nil, true},
+ {"a", "b", true},
+ {"a", fivePages, false},
+ {fivePages, "a", false},
+ {fivePages, createTestPages(2), false},
+ {fivePages, fivePages, true},
+ {zeroPages, zeroPages, true},
+ {fivePagesByWeight, fivePagesByWeight, true},
+ {zeroPagesByWeight, fivePagesByWeight, false},
+ {zeroPagesByWeight, zeroPagesByWeight, true},
+ {fivePagesByWeight, fivePages, false},
+ {fivePagesByWeight, ninePagesByWeight, false},
+ } {
+ result := probablyEqualPageLists(this.v1, this.v2)
+
+ if result != this.expect {
+ t.Errorf("[%d] got %t but expected %t", i, result, this.expect)
+ }
+ }
+}
+
+func TestPaginationPage(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+ urlFactory := func(page int) string {
+ return fmt.Sprintf("page/%d/", page)
+ }
+
+ fivePages := createTestPages(7)
+ fivePagesFuzzyWordCount, _ := createTestPages(7).GroupBy("FuzzyWordCount", "asc")
+
+ p1, _ := newPaginatorFromPages(fivePages, 2, urlFactory)
+ p2, _ := newPaginatorFromPageGroups(fivePagesFuzzyWordCount, 2, urlFactory)
+
+ f1 := p1.pagers[0].First()
+ f2 := p2.pagers[0].First()
+
+ page11, _ := f1.page(1)
+ page1Nil, _ := f1.page(3)
+
+ page21, _ := f2.page(1)
+ page2Nil, _ := f2.page(3)
+
+ c.Assert(page11.FuzzyWordCount(), qt.Equals, 3)
+ c.Assert(page1Nil, qt.IsNil)
+
+ c.Assert(page21, qt.Not(qt.IsNil))
+ c.Assert(page21.FuzzyWordCount(), qt.Equals, 3)
+ c.Assert(page2Nil, qt.IsNil)
+}
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
new file mode 100644
index 000000000..c31d22a3c
--- /dev/null
+++ b/resources/page/permalinks.go
@@ -0,0 +1,371 @@
+// 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 page
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "errors"
+
+ "github.com/gohugoio/hugo/helpers"
+)
+
+// PermalinkExpander holds permalin mappings per section.
+type PermalinkExpander struct {
+ // knownPermalinkAttributes maps :tags in a permalink specification to a
+ // function which, given a page and the tag, returns the resulting string
+ // to be used to replace that tag.
+ knownPermalinkAttributes map[string]pageToPermaAttribute
+
+ expanders map[string]func(Page) (string, error)
+
+ ps *helpers.PathSpec
+}
+
+// Time for checking date formats. Every field is different than the
+// Go reference time for date formatting. This ensures that formatting this date
+// with a Go time format always has a different output than the format itself.
+var referenceTime = time.Date(2019, time.November, 9, 23, 1, 42, 1, time.UTC)
+
+// Return the callback for the given permalink attribute and a boolean indicating if the attribute is valid or not.
+func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
+ if callback, ok := p.knownPermalinkAttributes[attr]; ok {
+ return callback, true
+ }
+
+ if strings.HasPrefix(attr, "sections[") {
+ fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections"))
+ return func(p Page, s string) (string, error) {
+ return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil
+ }, true
+ }
+
+ // Make sure this comes after all the other checks.
+ if referenceTime.Format(attr) != attr {
+ return p.pageToPermalinkDate, true
+ }
+
+ return nil, false
+}
+
+// NewPermalinkExpander creates a new PermalinkExpander configured by the given
+// PathSpec.
+func NewPermalinkExpander(ps *helpers.PathSpec) (PermalinkExpander, error) {
+ p := PermalinkExpander{ps: ps}
+
+ p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
+ "year": p.pageToPermalinkDate,
+ "month": p.pageToPermalinkDate,
+ "monthname": p.pageToPermalinkDate,
+ "day": p.pageToPermalinkDate,
+ "weekday": p.pageToPermalinkDate,
+ "weekdayname": p.pageToPermalinkDate,
+ "yearday": p.pageToPermalinkDate,
+ "section": p.pageToPermalinkSection,
+ "sections": p.pageToPermalinkSections,
+ "title": p.pageToPermalinkTitle,
+ "slug": p.pageToPermalinkSlugElseTitle,
+ "slugorfilename": p.pageToPermalinkSlugElseFilename,
+ "filename": p.pageToPermalinkFilename,
+ }
+
+ patterns := ps.Cfg.GetStringMapString("permalinks")
+ if patterns == nil {
+ return p, nil
+ }
+
+ e, err := p.parse(patterns)
+ if err != nil {
+ return p, err
+ }
+
+ p.expanders = e
+
+ return p, nil
+}
+
+// Expand expands the path in p according to the rules defined for the given key.
+// If no rules are found for the given key, an empty string is returned.
+func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
+ expand, found := l.expanders[key]
+
+ if !found {
+ return "", nil
+ }
+
+ return expand(p)
+}
+
+func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
+ expanders := make(map[string]func(Page) (string, error))
+
+ // Allow " " and / to represent the root section.
+ const sectionCutSet = " /" + string(os.PathSeparator)
+
+ for k, pattern := range patterns {
+ k = strings.Trim(k, sectionCutSet)
+
+ if !l.validate(pattern) {
+ return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed}
+ }
+
+ pattern := pattern
+ matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
+
+ callbacks := make([]pageToPermaAttribute, len(matches))
+ replacements := make([]string, len(matches))
+ for i, m := range matches {
+ replacement := m[0]
+ attr := replacement[1:]
+ replacements[i] = replacement
+ callback, ok := l.callback(attr)
+
+ if !ok {
+ return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkAttributeUnknown}
+ }
+
+ callbacks[i] = callback
+ }
+
+ expanders[k] = func(p Page) (string, error) {
+ if matches == nil {
+ return pattern, nil
+ }
+
+ newField := pattern
+
+ for i, replacement := range replacements {
+ attr := replacement[1:]
+ callback := callbacks[i]
+ newAttr, err := callback(p, attr)
+ if err != nil {
+ return "", &permalinkExpandError{pattern: pattern, err: err}
+ }
+
+ newField = strings.Replace(newField, replacement, newAttr, 1)
+
+ }
+
+ return newField, nil
+ }
+
+ }
+
+ return expanders, nil
+}
+
+// pageToPermaAttribute is the type of a function which, given a page and a tag
+// can return a string to go in that position in the page (or an error)
+type pageToPermaAttribute func(Page, string) (string, error)
+
+var attributeRegexp = regexp.MustCompile(`:\w+(\[.+\])?`)
+
+// validate determines if a PathPattern is well-formed
+func (l PermalinkExpander) validate(pp string) bool {
+ fragments := strings.Split(pp[1:], "/")
+ bail := false
+ for i := range fragments {
+ if bail {
+ return false
+ }
+ if len(fragments[i]) == 0 {
+ bail = true
+ continue
+ }
+
+ matches := attributeRegexp.FindAllStringSubmatch(fragments[i], -1)
+ if matches == nil {
+ continue
+ }
+
+ for _, match := range matches {
+ k := match[0][1:]
+ if _, ok := l.callback(k); !ok {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+type permalinkExpandError struct {
+ pattern string
+ err error
+}
+
+func (pee *permalinkExpandError) Error() string {
+ return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err)
+}
+
+var (
+ errPermalinkIllFormed = errors.New("permalink ill-formed")
+ errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised")
+)
+
+func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) {
+ // a Page contains a Node which provides a field Date, time.Time
+ switch dateField {
+ case "year":
+ return strconv.Itoa(p.Date().Year()), nil
+ case "month":
+ return fmt.Sprintf("%02d", int(p.Date().Month())), nil
+ case "monthname":
+ return p.Date().Month().String(), nil
+ case "day":
+ return fmt.Sprintf("%02d", p.Date().Day()), nil
+ case "weekday":
+ return strconv.Itoa(int(p.Date().Weekday())), nil
+ case "weekdayname":
+ return p.Date().Weekday().String(), nil
+ case "yearday":
+ return strconv.Itoa(p.Date().YearDay()), nil
+ }
+
+ return p.Date().Format(dateField), nil
+}
+
+// pageToPermalinkTitle returns the URL-safe form of the title
+func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) {
+ return l.ps.URLize(p.Title()), nil
+}
+
+// pageToPermalinkFilename returns the URL-safe form of the filename
+func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) {
+ name := p.File().TranslationBaseName()
+ if name == "index" {
+ // Page bundles; the directory name will hopefully have a better name.
+ dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator)
+ _, name = filepath.Split(dir)
+ }
+
+ return l.ps.URLize(name), nil
+}
+
+// if the page has a slug, return the slug, else return the title
+func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) {
+ if p.Slug() != "" {
+ return l.ps.URLize(p.Slug()), nil
+ }
+ return l.pageToPermalinkTitle(p, a)
+}
+
+// if the page has a slug, return the slug, else return the filename
+func (l PermalinkExpander) pageToPermalinkSlugElseFilename(p Page, a string) (string, error) {
+ if p.Slug() != "" {
+ return l.ps.URLize(p.Slug()), nil
+ }
+ return l.pageToPermalinkFilename(p, a)
+}
+
+func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) {
+ return p.Section(), nil
+}
+
+func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
+ return p.CurrentSection().SectionsPath(), nil
+}
+
+var (
+ nilSliceFunc = func(s []string) []string {
+ return nil
+ }
+ allSliceFunc = func(s []string) []string {
+ return s
+ }
+)
+
+// toSliceFunc returns a slice func that slices s according to the cut spec.
+// The cut spec must be on form [low:high] (one or both can be omitted),
+// also allowing single slice indices (e.g. [2]) and the special [last] keyword
+// giving the last element of the slice.
+// The returned function will be lenient and not panic in out of bounds situation.
+//
+// The current use case for this is to use parts of the sections path in permalinks.
+func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
+ cut = strings.ToLower(strings.TrimSpace(cut))
+ if cut == "" {
+ return allSliceFunc
+ }
+
+ if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') {
+ return nilSliceFunc
+ }
+
+ toNFunc := func(s string, low bool) func(ss []string) int {
+ if s == "" {
+ if low {
+ return func(ss []string) int {
+ return 0
+ }
+ } else {
+ return func(ss []string) int {
+ return len(ss)
+ }
+ }
+ }
+
+ if s == "last" {
+ return func(ss []string) int {
+ return len(ss) - 1
+ }
+ }
+
+ n, _ := strconv.Atoi(s)
+ if n < 0 {
+ n = 0
+ }
+ return func(ss []string) int {
+ // Prevent out of bound situations. It would not make
+ // much sense to panic here.
+ if n > len(ss) {
+ return len(ss)
+ }
+ return n
+ }
+ }
+
+ opsStr := cut[1 : len(cut)-1]
+ opts := strings.Split(opsStr, ":")
+
+ if !strings.Contains(opsStr, ":") {
+ toN := toNFunc(opts[0], true)
+ return func(s []string) []string {
+ if len(s) == 0 {
+ return nil
+ }
+ v := s[toN(s)]
+ if v == "" {
+ return nil
+ }
+ return []string{v}
+ }
+ }
+
+ toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false)
+
+ return func(s []string) []string {
+ if len(s) == 0 {
+ return nil
+ }
+ return s[toN1(s):toN2(s)]
+ }
+
+}
diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go
new file mode 100644
index 000000000..7baf16503
--- /dev/null
+++ b/resources/page/permalinks_test.go
@@ -0,0 +1,241 @@
+// 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 page
+
+import (
+ "fmt"
+ "regexp"
+ "sync"
+ "testing"
+ "time"
+
+ qt "github.com/frankban/quicktest"
+)
+
+// testdataPermalinks is used by a couple of tests; the expandsTo content is
+// subject to the data in simplePageJSON.
+var testdataPermalinks = []struct {
+ spec string
+ valid bool
+ expandsTo string
+}{
+ {":title", true, "spf13-vim-3.0-release-and-new-website"},
+ {"/:year-:month-:title", true, "/2012-04-spf13-vim-3.0-release-and-new-website"},
+ {"/:year/:yearday/:month/:monthname/:day/:weekday/:weekdayname/", true, "/2012/97/04/April/06/5/Friday/"}, // Dates
+ {"/:section/", true, "/blue/"}, // Section
+ {"/:title/", true, "/spf13-vim-3.0-release-and-new-website/"}, // Title
+ {"/:slug/", true, "/the-slug/"}, // Slug
+ {"/:slugorfilename/", true, "/the-slug/"}, // Slug or filename
+ {"/:filename/", true, "/test-page/"}, // Filename
+ {"/:06-:1-:2-:Monday", true, "/12-4-6-Friday"}, // Dates with Go formatting
+ {"/:2006_01_02_15_04_05.000", true, "/2012_04_06_03_01_59.000"}, // Complicated custom date format
+ {"/:sections/", true, "/a/b/c/"}, // Sections
+ {"/:sections[last]/", true, "/c/"}, // Sections
+
+ // Failures
+ {"/blog/:fred", false, ""},
+ {"/:year//:title", false, ""},
+ {"/:TITLE", false, ""}, // case is not normalized
+ {"/:2017", false, ""}, // invalid date format
+ {"/:2006-01-02", false, ""}, // valid date format but invalid attribute name
+}
+
+func TestPermalinkExpansion(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ page := newTestPageWithFile("/test-page/index.md")
+ page.title = "Spf13 Vim 3.0 Release and new website"
+ d, _ := time.Parse("2006-01-02 15:04:05", "2012-04-06 03:01:59")
+ page.date = d
+ page.section = "blue"
+ page.slug = "The Slug"
+
+ for _, item := range testdataPermalinks {
+ if !item.valid {
+ continue
+ }
+
+ specNameCleaner := regexp.MustCompile(`[\:\/\[\]]`)
+ name := specNameCleaner.ReplaceAllString(item.spec, "")
+
+ c.Run(name, func(c *qt.C) {
+
+ permalinksConfig := map[string]string{
+ "posts": item.spec,
+ }
+
+ ps := newTestPathSpec()
+ ps.Cfg.Set("permalinks", permalinksConfig)
+
+ expander, err := NewPermalinkExpander(ps)
+ c.Assert(err, qt.IsNil)
+
+ expanded, err := expander.Expand("posts", page)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, item.expandsTo)
+ })
+
+ }
+}
+
+func TestPermalinkExpansionMultiSection(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ page := newTestPage()
+ page.title = "Page Title"
+ d, _ := time.Parse("2006-01-02", "2012-04-06")
+ page.date = d
+ page.section = "blue"
+ page.slug = "The Slug"
+
+ page_slug_fallback := newTestPageWithFile("/page-filename/index.md")
+ page_slug_fallback.title = "Page Title"
+
+ permalinksConfig := map[string]string{
+ "posts": "/:slug",
+ "blog": "/:section/:year",
+ "recipes": "/:slugorfilename",
+ }
+
+ ps := newTestPathSpec()
+ ps.Cfg.Set("permalinks", permalinksConfig)
+
+ expander, err := NewPermalinkExpander(ps)
+ c.Assert(err, qt.IsNil)
+
+ expanded, err := expander.Expand("posts", page)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, "/the-slug")
+
+ expanded, err = expander.Expand("blog", page)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, "/blue/2012")
+
+ expanded, err = expander.Expand("posts", page_slug_fallback)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, "/page-title")
+
+ expanded, err = expander.Expand("recipes", page_slug_fallback)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, "/page-filename")
+}
+
+func TestPermalinkExpansionConcurrent(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+
+ permalinksConfig := map[string]string{
+ "posts": "/:slug/",
+ }
+
+ ps := newTestPathSpec()
+ ps.Cfg.Set("permalinks", permalinksConfig)
+
+ expander, err := NewPermalinkExpander(ps)
+ c.Assert(err, qt.IsNil)
+
+ var wg sync.WaitGroup
+
+ for i := 1; i < 20; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ page := newTestPage()
+ for j := 1; j < 20; j++ {
+ page.slug = fmt.Sprintf("slug%d", i+j)
+ expanded, err := expander.Expand("posts", page)
+ c.Assert(err, qt.IsNil)
+ c.Assert(expanded, qt.Equals, fmt.Sprintf("/%s/", page.slug))
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestPermalinkExpansionSliceSyntax(t *testing.T) {
+ t.Parallel()
+
+ c := qt.New(t)
+ exp, _ := NewPermalinkExpander(newTestPathSpec())
+ slice := []string{"a", "b", "c", "d"}
+ fn := func(s string) []string {
+ return exp.toSliceFunc(s)(slice)
+ }
+
+ c.Run("Basic", func(c *qt.C) {
+ c.Assert(fn("[1:3]"), qt.DeepEquals, []string{"b", "c"})
+ c.Assert(fn("[1:]"), qt.DeepEquals, []string{"b", "c", "d"})
+ c.Assert(fn("[:2]"), qt.DeepEquals, []string{"a", "b"})
+ c.Assert(fn("[0:2]"), qt.DeepEquals, []string{"a", "b"})
+ c.Assert(fn("[:]"), qt.DeepEquals, []string{"a", "b", "c", "d"})
+ c.Assert(fn(""), qt.DeepEquals, []string{"a", "b", "c", "d"})
+ c.Assert(fn("[last]"), qt.DeepEquals, []string{"d"})
+ c.Assert(fn("[:last]"), qt.DeepEquals, []string{"a", "b", "c"})
+
+ })
+
+ c.Run("Out of bounds", func(c *qt.C) {
+ c.Assert(fn("[1:5]"), qt.DeepEquals, []string{"b", "c", "d"})
+ c.Assert(fn("[-1:5]"), qt.DeepEquals, []string{"a", "b", "c", "d"})
+ c.Assert(fn("[5:]"), qt.DeepEquals, []string{})
+ c.Assert(fn("[5:]"), qt.DeepEquals, []string{})
+ c.Assert(fn("[5:32]"), qt.DeepEquals, []string{})
+ c.Assert(exp.toSliceFunc("[:1]")(nil), qt.DeepEquals, []string(nil))
+ c.Assert(exp.toSliceFunc("[:1]")([]string{}), qt.DeepEquals, []string(nil))
+
+ // These all return nil
+ c.Assert(fn("[]"), qt.IsNil)
+ c.Assert(fn("[1:}"), qt.IsNil)
+ c.Assert(fn("foo"), qt.IsNil)
+
+ })
+
+}
+
+func BenchmarkPermalinkExpand(b *testing.B) {
+ page := newTestPage()
+ page.title = "Hugo Rocks"
+ d, _ := time.Parse("2006-01-02", "2019-02-28")
+ page.date = d
+
+ permalinksConfig := map[string]string{
+ "posts": "/:year-:month-:title",
+ }
+
+ ps := newTestPathSpec()
+ ps.Cfg.Set("permalinks", permalinksConfig)
+
+ expander, err := NewPermalinkExpander(ps)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ s, err := expander.Expand("posts", page)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if s != "/2019-02-hugo-rocks" {
+ b.Fatal(s)
+ }
+
+ }
+}
diff --git a/resources/page/site.go b/resources/page/site.go
new file mode 100644
index 000000000..f5806280c
--- /dev/null
+++ b/resources/page/site.go
@@ -0,0 +1,167 @@
+// 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 page
+
+import (
+ "html/template"
+ "time"
+
+ "github.com/gohugoio/hugo/common/maps"
+
+ "github.com/gohugoio/hugo/config"
+
+ "github.com/gohugoio/hugo/common/hugo"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/navigation"
+)
+
+// Site represents a site in the build. This is currently a very narrow interface,
+// but the actual implementation will be richer, see hugolib.SiteInfo.
+type Site interface {
+ // Returns the Language configured for this Site.
+ Language() *langs.Language
+
+ // Returns all the regular Pages in this Site.
+ RegularPages() Pages
+
+ // Returns all Pages in this Site.
+ Pages() Pages
+
+ // A shortcut to the home page.
+ Home() Page
+
+ // Returns true if we're running in a server.
+ IsServer() bool
+
+ // Returns the server port.
+ ServerPort() int
+
+ // Returns the configured title for this Site.
+ Title() string
+
+ // Returns all Sites for all languages.
+ Sites() Sites
+
+ // Returns Site currently rendering.
+ Current() Site
+
+ // Returns a struct with some information about the build.
+ Hugo() hugo.Info
+
+ // Returns the BaseURL for this Site.
+ BaseURL() template.URL
+
+ // Retuns a taxonomy map.
+ Taxonomies() any
+
+ // Returns the last modification date of the content.
+ LastChange() time.Time
+
+ // Returns the Menus for this site.
+ Menus() navigation.Menus
+
+ // Returns the Params configured for this site.
+ Params() maps.Params
+
+ // Returns a map of all the data inside /data.
+ Data() map[string]any
+}
+
+// Sites represents an ordered list of sites (languages).
+type Sites []Site
+
+// First is a convenience method to get the first Site, i.e. the main language.
+func (s Sites) First() Site {
+ if len(s) == 0 {
+ return nil
+ }
+ return s[0]
+}
+
+type testSite struct {
+ h hugo.Info
+ l *langs.Language
+}
+
+func (t testSite) Hugo() hugo.Info {
+ return t.h
+}
+
+func (t testSite) ServerPort() int {
+ return 1313
+}
+
+func (testSite) LastChange() (t time.Time) {
+ return
+}
+
+func (t testSite) Title() string {
+ return "foo"
+}
+
+func (t testSite) Sites() Sites {
+ return nil
+}
+
+func (t testSite) Current() Site {
+ return t
+}
+
+func (t testSite) IsServer() bool {
+ return false
+}
+
+func (t testSite) Language() *langs.Language {
+ return t.l
+}
+
+func (t testSite) Home() Page {
+ return nil
+}
+
+func (t testSite) Pages() Pages {
+ return nil
+}
+
+func (t testSite) RegularPages() Pages {
+ return nil
+}
+
+func (t testSite) Menus() navigation.Menus {
+ return nil
+}
+
+func (t testSite) Taxonomies() any {
+ return nil
+}
+
+func (t testSite) BaseURL() template.URL {
+ return ""
+}
+
+func (t testSite) Params() maps.Params {
+ return nil
+}
+
+func (t testSite) Data() map[string]any {
+ return nil
+}
+
+// NewDummyHugoSite creates a new minimal test site.
+func NewDummyHugoSite(cfg config.Provider) Site {
+ return testSite{
+ h: hugo.NewInfo(hugo.EnvironmentProduction, nil),
+ l: langs.NewLanguage("en", cfg),
+ }
+}
diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go
new file mode 100644
index 000000000..30b8e4dff
--- /dev/null
+++ b/resources/page/testhelpers_test.go
@@ -0,0 +1,622 @@
+// 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 page
+
+import (
+ "fmt"
+ "html/template"
+ "path"
+ "path/filepath"
+ "time"
+
+ "github.com/gohugoio/hugo/hugofs/files"
+ "github.com/gohugoio/hugo/identity"
+ "github.com/gohugoio/hugo/tpl"
+
+ "github.com/gohugoio/hugo/modules"
+
+ "github.com/bep/gitmap"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/navigation"
+
+ "github.com/gohugoio/hugo/common/hugo"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/related"
+
+ "github.com/gohugoio/hugo/source"
+)
+
+var (
+ _ resource.LengthProvider = (*testPage)(nil)
+ _ Page = (*testPage)(nil)
+)
+
+var relatedDocsHandler = NewRelatedDocsHandler(related.DefaultConfig)
+
+func newTestPage() *testPage {
+ return newTestPageWithFile("/a/b/c.md")
+}
+
+func newTestPageWithFile(filename string) *testPage {
+ filename = filepath.FromSlash(filename)
+ file := source.NewTestFile(filename)
+ return &testPage{
+ params: make(map[string]any),
+ data: make(map[string]any),
+ file: file,
+ currentSection: &testPage{
+ sectionEntries: []string{"a", "b", "c"},
+ },
+ site: testSite{l: langs.NewDefaultLanguage(config.New())},
+ }
+}
+
+func newTestPathSpec() *helpers.PathSpec {
+ return newTestPathSpecFor(config.New())
+}
+
+func newTestPathSpecFor(cfg config.Provider) *helpers.PathSpec {
+ config.SetBaseTestDefaults(cfg)
+ langs.LoadLanguageSettings(cfg, nil)
+ mod, err := modules.CreateProjectModule(cfg)
+ if err != nil {
+ panic(err)
+ }
+ cfg.Set("allModules", modules.Modules{mod})
+ fs := hugofs.NewMem(cfg)
+ s, err := helpers.NewPathSpec(fs, cfg, nil)
+ if err != nil {
+ panic(err)
+ }
+ return s
+}
+
+type testPage struct {
+ kind string
+ description string
+ title string
+ linkTitle string
+ lang string
+ section string
+ site testSite
+
+ content string
+
+ fuzzyWordCount int
+
+ path string
+
+ slug string
+
+ // Dates
+ date time.Time
+ lastMod time.Time
+ expiryDate time.Time
+ pubDate time.Time
+
+ weight int
+
+ params map[string]any
+ data map[string]any
+
+ file source.File
+
+ currentSection *testPage
+ sectionEntries []string
+}
+
+func (p *testPage) Err() resource.ResourceError {
+ return nil
+}
+
+func (p *testPage) Aliases() []string {
+ panic("not implemented")
+}
+
+func (p *testPage) AllTranslations() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) AlternativeOutputFormats() OutputFormats {
+ panic("not implemented")
+}
+
+func (p *testPage) Author() Author {
+ return Author{}
+}
+
+func (p *testPage) Authors() AuthorList {
+ return nil
+}
+
+func (p *testPage) BaseFileName() string {
+ panic("not implemented")
+}
+
+func (p *testPage) BundleType() files.ContentClass {
+ panic("not implemented")
+}
+
+func (p *testPage) Content() (any, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) ContentBaseName() string {
+ panic("not implemented")
+}
+
+func (p *testPage) CurrentSection() Page {
+ return p.currentSection
+}
+
+func (p *testPage) Data() any {
+ return p.data
+}
+
+func (p *testPage) Sitemap() config.Sitemap {
+ return config.Sitemap{}
+}
+
+func (p *testPage) Layout() string {
+ return ""
+}
+
+func (p *testPage) Date() time.Time {
+ return p.date
+}
+
+func (p *testPage) Description() string {
+ return ""
+}
+
+func (p *testPage) Dir() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Draft() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) Eq(other any) bool {
+ return p == other
+}
+
+func (p *testPage) ExpiryDate() time.Time {
+ return p.expiryDate
+}
+
+func (p *testPage) Ext() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Extension() string {
+ panic("not implemented")
+}
+
+func (p *testPage) File() source.File {
+ return p.file
+}
+
+func (p *testPage) FileInfo() hugofs.FileMetaInfo {
+ panic("not implemented")
+}
+
+func (p *testPage) Filename() string {
+ panic("not implemented")
+}
+
+func (p *testPage) FirstSection() Page {
+ panic("not implemented")
+}
+
+func (p *testPage) FuzzyWordCount() int {
+ return p.fuzzyWordCount
+}
+
+func (p *testPage) GetPage(ref string) (Page, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) GetPageWithTemplateInfo(info tpl.Info, ref string) (Page, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) GetParam(key string) any {
+ panic("not implemented")
+}
+
+func (p *testPage) GetTerms(taxonomy string) Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) GetRelatedDocsHandler() *RelatedDocsHandler {
+ return relatedDocsHandler
+}
+
+func (p *testPage) GitInfo() *gitmap.GitInfo {
+ return nil
+}
+
+func (p *testPage) CodeOwners() []string {
+ return nil
+}
+
+func (p *testPage) HasMenuCurrent(menuID string, me *navigation.MenuEntry) bool {
+ panic("not implemented")
+}
+
+func (p *testPage) HasShortcode(name string) bool {
+ panic("not implemented")
+}
+
+func (p *testPage) Hugo() hugo.Info {
+ panic("not implemented")
+}
+
+func (p *testPage) InSection(other any) (bool, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) IsAncestor(other any) (bool, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) IsDescendant(other any) (bool, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) IsDraft() bool {
+ return false
+}
+
+func (p *testPage) IsHome() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool {
+ panic("not implemented")
+}
+
+func (p *testPage) IsNode() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) IsPage() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) IsSection() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) IsTranslated() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) Keywords() []string {
+ return nil
+}
+
+func (p *testPage) Kind() string {
+ return p.kind
+}
+
+func (p *testPage) Lang() string {
+ return p.lang
+}
+
+func (p *testPage) Language() *langs.Language {
+ panic("not implemented")
+}
+
+func (p *testPage) LanguagePrefix() string {
+ return ""
+}
+
+func (p *testPage) Lastmod() time.Time {
+ return p.lastMod
+}
+
+func (p *testPage) Len() int {
+ return len(p.content)
+}
+
+func (p *testPage) LinkTitle() string {
+ if p.linkTitle == "" {
+ if p.title == "" {
+ return p.path
+ }
+ return p.title
+ }
+ return p.linkTitle
+}
+
+func (p *testPage) LogicalName() string {
+ panic("not implemented")
+}
+
+func (p *testPage) MediaType() media.Type {
+ panic("not implemented")
+}
+
+func (p *testPage) Menus() navigation.PageMenus {
+ return navigation.PageMenus{}
+}
+
+func (p *testPage) Name() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Next() Page {
+ panic("not implemented")
+}
+
+func (p *testPage) NextInSection() Page {
+ return nil
+}
+
+func (p *testPage) NextPage() Page {
+ return nil
+}
+
+func (p *testPage) OutputFormats() OutputFormats {
+ panic("not implemented")
+}
+
+func (p *testPage) Pages() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) RegularPages() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) RegularPagesRecursive() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) Paginate(seq any, options ...any) (*Pager, error) {
+ return nil, nil
+}
+
+func (p *testPage) Paginator(options ...any) (*Pager, error) {
+ return nil, nil
+}
+
+func (p *testPage) Param(key any) (any, error) {
+ return resource.Param(p, nil, key)
+}
+
+func (p *testPage) Params() maps.Params {
+ return p.params
+}
+
+func (p *testPage) Page() Page {
+ return p
+}
+
+func (p *testPage) Parent() Page {
+ panic("not implemented")
+}
+
+func (p *testPage) Path() string {
+ return p.path
+}
+
+func (p *testPage) Pathc() string {
+ return p.path
+}
+
+func (p *testPage) Permalink() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Plain() string {
+ panic("not implemented")
+}
+
+func (p *testPage) PlainWords() []string {
+ panic("not implemented")
+}
+
+func (p *testPage) Prev() Page {
+ panic("not implemented")
+}
+
+func (p *testPage) PrevInSection() Page {
+ return nil
+}
+
+func (p *testPage) PrevPage() Page {
+ return nil
+}
+
+func (p *testPage) PublishDate() time.Time {
+ return p.pubDate
+}
+
+func (p *testPage) RSSLink() template.URL {
+ return ""
+}
+
+func (p *testPage) RawContent() string {
+ panic("not implemented")
+}
+
+func (p *testPage) ReadingTime() int {
+ panic("not implemented")
+}
+
+func (p *testPage) Ref(argsm map[string]any) (string, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) RefFrom(argsm map[string]any, source any) (string, error) {
+ return "", nil
+}
+
+func (p *testPage) RelPermalink() string {
+ panic("not implemented")
+}
+
+func (p *testPage) RelRef(argsm map[string]any) (string, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) RelRefFrom(argsm map[string]any, source any) (string, error) {
+ return "", nil
+}
+
+func (p *testPage) Render(layout ...string) (template.HTML, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) RenderString(args ...any) (template.HTML, error) {
+ panic("not implemented")
+}
+
+func (p *testPage) ResourceType() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Resources() resource.Resources {
+ panic("not implemented")
+}
+
+func (p *testPage) Scratch() *maps.Scratch {
+ panic("not implemented")
+}
+
+func (p *testPage) Store() *maps.Scratch {
+ panic("not implemented")
+}
+
+func (p *testPage) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
+ v, err := p.Param(cfg.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ return cfg.ToKeywords(v)
+}
+
+func (p *testPage) Section() string {
+ return p.section
+}
+
+func (p *testPage) Sections() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) SectionsEntries() []string {
+ return p.sectionEntries
+}
+
+func (p *testPage) SectionsPath() string {
+ return path.Join(p.sectionEntries...)
+}
+
+func (p *testPage) Site() Site {
+ return p.site
+}
+
+func (p *testPage) Sites() Sites {
+ panic("not implemented")
+}
+
+func (p *testPage) Slug() string {
+ return p.slug
+}
+
+func (p *testPage) String() string {
+ return p.path
+}
+
+func (p *testPage) Summary() template.HTML {
+ panic("not implemented")
+}
+
+func (p *testPage) TableOfContents() template.HTML {
+ panic("not implemented")
+}
+
+func (p *testPage) Title() string {
+ return p.title
+}
+
+func (p *testPage) TranslationBaseName() string {
+ panic("not implemented")
+}
+
+func (p *testPage) TranslationKey() string {
+ return p.path
+}
+
+func (p *testPage) Translations() Pages {
+ panic("not implemented")
+}
+
+func (p *testPage) Truncated() bool {
+ panic("not implemented")
+}
+
+func (p *testPage) Type() string {
+ return p.section
+}
+
+func (p *testPage) URL() string {
+ return ""
+}
+
+func (p *testPage) UniqueID() string {
+ panic("not implemented")
+}
+
+func (p *testPage) Weight() int {
+ return p.weight
+}
+
+func (p *testPage) WordCount() int {
+ panic("not implemented")
+}
+
+func (p *testPage) GetIdentity() identity.Identity {
+ panic("not implemented")
+}
+
+func createTestPages(num int) Pages {
+ pages := make(Pages, num)
+
+ for i := 0; i < num; i++ {
+ m := &testPage{
+ path: fmt.Sprintf("/x/y/z/p%d.md", i),
+ weight: 5,
+ fuzzyWordCount: i + 2, // magic
+ }
+
+ if i%2 == 0 {
+ m.weight = 10
+ }
+ pages[i] = m
+
+ }
+
+ return pages
+}
diff --git a/resources/page/weighted.go b/resources/page/weighted.go
new file mode 100644
index 000000000..39034d26c
--- /dev/null
+++ b/resources/page/weighted.go
@@ -0,0 +1,138 @@
+// 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 page
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/gohugoio/hugo/common/collections"
+)
+
+var _ collections.Slicer = WeightedPage{}
+
+// WeightedPages is a list of Pages with their corresponding (and relative) weight
+// [{Weight: 30, Page: *1}, {Weight: 40, Page: *2}]
+type WeightedPages []WeightedPage
+
+// Page will return the Page (of Kind taxonomyList) that represents this set
+// of pages. This method will panic if p is empty, as that should never happen.
+func (p WeightedPages) Page() Page {
+ if len(p) == 0 {
+ panic("WeightedPages is empty")
+ }
+
+ first := p[0]
+
+ // TODO(bep) fix tests
+ if first.owner == nil {
+ return nil
+ }
+
+ return first.owner
+}
+
+// A WeightedPage is a Page with a weight.
+type WeightedPage struct {
+ Weight int
+ Page
+
+ // Reference to the owning Page. This avoids having to do
+ // manual .Site.GetPage lookups. It is implemented in this roundabout way
+ // because we cannot add additional state to the WeightedPages slice
+ // without breaking lots of templates in the wild.
+ owner Page
+}
+
+func NewWeightedPage(weight int, p Page, owner Page) WeightedPage {
+ return WeightedPage{Weight: weight, Page: p, owner: owner}
+}
+
+func (w WeightedPage) String() string {
+ return fmt.Sprintf("WeightedPage(%d,%q)", w.Weight, w.Page.Title())
+}
+
+// Slice is for internal use.
+// for the template functions. See collections.Slice.
+func (p WeightedPage) Slice(in any) (any, error) {
+ switch items := in.(type) {
+ case WeightedPages:
+ return items, nil
+ case []any:
+ weighted := make(WeightedPages, len(items))
+ for i, v := range items {
+ g, ok := v.(WeightedPage)
+ if !ok {
+ return nil, fmt.Errorf("type %T is not a WeightedPage", v)
+ }
+ weighted[i] = g
+ }
+ return weighted, nil
+ default:
+ return nil, fmt.Errorf("invalid slice type %T", items)
+ }
+}
+
+// Pages returns the Pages in this weighted page set.
+func (wp WeightedPages) Pages() Pages {
+ pages := make(Pages, len(wp))
+ for i := range wp {
+ pages[i] = wp[i].Page
+ }
+ return pages
+}
+
+// Next returns the next Page relative to the given Page in
+// this weighted page set.
+func (wp WeightedPages) Next(cur Page) Page {
+ for x, c := range wp {
+ if c.Page.Eq(cur) {
+ if x == 0 {
+ return nil
+ }
+ return wp[x-1].Page
+ }
+ }
+ return nil
+}
+
+// Prev returns the previous Page relative to the given Page in
+// this weighted page set.
+func (wp WeightedPages) Prev(cur Page) Page {
+ for x, c := range wp {
+ if c.Page.Eq(cur) {
+ if x < len(wp)-1 {
+ return wp[x+1].Page
+ }
+ return nil
+ }
+ }
+ return nil
+}
+
+func (wp WeightedPages) Len() int { return len(wp) }
+func (wp WeightedPages) Swap(i, j int) { wp[i], wp[j] = wp[j], wp[i] }
+
+// Sort stable sorts this weighted page set.
+func (wp WeightedPages) Sort() { sort.Stable(wp) }
+
+// Count returns the number of pages in this weighted page set.
+func (wp WeightedPages) Count() int { return len(wp) }
+
+func (wp WeightedPages) Less(i, j int) bool {
+ if wp[i].Weight == wp[j].Weight {
+ return DefaultPageSort(wp[i].Page, wp[j].Page)
+ }
+ return wp[i].Weight < wp[j].Weight
+}
diff --git a/resources/page/zero_file.autogen.go b/resources/page/zero_file.autogen.go
new file mode 100644
index 000000000..72d98998e
--- /dev/null
+++ b/resources/page/zero_file.autogen.go
@@ -0,0 +1,88 @@
+// 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.
+
+// This file is autogenerated.
+
+package page
+
+import (
+ "github.com/gohugoio/hugo/common/loggers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/source"
+)
+
+// ZeroFile represents a zero value of source.File with warnings if invoked.
+type zeroFile struct {
+ log loggers.Logger
+}
+
+func NewZeroFile(log loggers.Logger) source.File {
+ return zeroFile{log: log}
+}
+
+func (zeroFile) IsZero() bool {
+ return true
+}
+
+func (z zeroFile) Path() (o0 string) {
+ z.log.Warnln(".File.Path on zero object. Wrap it in if or with: {{ with .File }}{{ .Path }}{{ end }}")
+ return
+}
+func (z zeroFile) Section() (o0 string) {
+ z.log.Warnln(".File.Section on zero object. Wrap it in if or with: {{ with .File }}{{ .Section }}{{ end }}")
+ return
+}
+func (z zeroFile) Lang() (o0 string) {
+ z.log.Warnln(".File.Lang on zero object. Wrap it in if or with: {{ with .File }}{{ .Lang }}{{ end }}")
+ return
+}
+func (z zeroFile) Filename() (o0 string) {
+ z.log.Warnln(".File.Filename on zero object. Wrap it in if or with: {{ with .File }}{{ .Filename }}{{ end }}")
+ return
+}
+func (z zeroFile) Dir() (o0 string) {
+ z.log.Warnln(".File.Dir on zero object. Wrap it in if or with: {{ with .File }}{{ .Dir }}{{ end }}")
+ return
+}
+func (z zeroFile) Extension() (o0 string) {
+ z.log.Warnln(".File.Extension on zero object. Wrap it in if or with: {{ with .File }}{{ .Extension }}{{ end }}")
+ return
+}
+func (z zeroFile) Ext() (o0 string) {
+ z.log.Warnln(".File.Ext on zero object. Wrap it in if or with: {{ with .File }}{{ .Ext }}{{ end }}")
+ return
+}
+func (z zeroFile) LogicalName() (o0 string) {
+ z.log.Warnln(".File.LogicalName on zero object. Wrap it in if or with: {{ with .File }}{{ .LogicalName }}{{ end }}")
+ return
+}
+func (z zeroFile) BaseFileName() (o0 string) {
+ z.log.Warnln(".File.BaseFileName on zero object. Wrap it in if or with: {{ with .File }}{{ .BaseFileName }}{{ end }}")
+ return
+}
+func (z zeroFile) TranslationBaseName() (o0 string) {
+ z.log.Warnln(".File.TranslationBaseName on zero object. Wrap it in if or with: {{ with .File }}{{ .TranslationBaseName }}{{ end }}")
+ return
+}
+func (z zeroFile) ContentBaseName() (o0 string) {
+ z.log.Warnln(".File.ContentBaseName on zero object. Wrap it in if or with: {{ with .File }}{{ .ContentBaseName }}{{ end }}")
+ return
+}
+func (z zeroFile) UniqueID() (o0 string) {
+ z.log.Warnln(".File.UniqueID on zero object. Wrap it in if or with: {{ with .File }}{{ .UniqueID }}{{ end }}")
+ return
+}
+func (z zeroFile) FileInfo() (o0 hugofs.FileMetaInfo) {
+ z.log.Warnln(".File.FileInfo on zero object. Wrap it in if or with: {{ with .File }}{{ .FileInfo }}{{ end }}")
+ return
+}
diff --git a/resources/post_publish.go b/resources/post_publish.go
new file mode 100644
index 000000000..b2adfa5ce
--- /dev/null
+++ b/resources/post_publish.go
@@ -0,0 +1,51 @@
+// Copyright 2020 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 (
+ "github.com/gohugoio/hugo/resources/postpub"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+type transformationKeyer interface {
+ TransformationKey() string
+}
+
+// PostProcess wraps the given Resource for later processing.
+func (spec *Spec) PostProcess(r resource.Resource) (postpub.PostPublishedResource, error) {
+ key := r.(transformationKeyer).TransformationKey()
+ spec.postProcessMu.RLock()
+ result, found := spec.PostProcessResources[key]
+ spec.postProcessMu.RUnlock()
+ if found {
+ return result, nil
+ }
+
+ spec.postProcessMu.Lock()
+ defer spec.postProcessMu.Unlock()
+
+ // Double check
+ result, found = spec.PostProcessResources[key]
+ if found {
+ return result, nil
+ }
+
+ result = postpub.NewPostPublishResource(spec.incr.Incr(), r)
+ if result == nil {
+ panic("got nil result")
+ }
+ spec.PostProcessResources[key] = result
+
+ return result, nil
+}
diff --git a/resources/postpub/fields.go b/resources/postpub/fields.go
new file mode 100644
index 000000000..13b2963ce
--- /dev/null
+++ b/resources/postpub/fields.go
@@ -0,0 +1,59 @@
+// Copyright 2020 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 postpub
+
+import (
+ "reflect"
+)
+
+const (
+ FieldNotSupported = "__field_not_supported"
+)
+
+func structToMapWithPlaceholders(root string, in any, createPlaceholder func(s string) string) map[string]any {
+ m := structToMap(in)
+ insertFieldPlaceholders(root, m, createPlaceholder)
+ return m
+}
+
+func structToMap(s any) map[string]any {
+ m := make(map[string]any)
+ t := reflect.TypeOf(s)
+
+ for i := 0; i < t.NumMethod(); i++ {
+ method := t.Method(i)
+ if method.PkgPath != "" {
+ continue
+ }
+ if method.Type.NumIn() == 1 {
+ m[method.Name] = ""
+ }
+ }
+
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.PkgPath != "" {
+ continue
+ }
+ m[field.Name] = ""
+ }
+ return m
+}
+
+// insert placeholder for the templates. Do it very shallow for now.
+func insertFieldPlaceholders(root string, m map[string]any, createPlaceholder func(s string) string) {
+ for k := range m {
+ m[k] = createPlaceholder(root + "." + k)
+ }
+}
diff --git a/resources/postpub/fields_test.go b/resources/postpub/fields_test.go
new file mode 100644
index 000000000..8e80063f1
--- /dev/null
+++ b/resources/postpub/fields_test.go
@@ -0,0 +1,45 @@
+// Copyright 2020 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 postpub
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+
+ "github.com/gohugoio/hugo/media"
+)
+
+func TestCreatePlaceholders(t *testing.T) {
+ c := qt.New(t)
+
+ m := structToMap(media.CSSType)
+
+ insertFieldPlaceholders("foo", m, func(s string) string {
+ return "pre_" + s + "_post"
+ })
+
+ c.Assert(m, qt.DeepEquals, map[string]any{
+ "IsZero": "pre_foo.IsZero_post",
+ "MarshalJSON": "pre_foo.MarshalJSON_post",
+ "Suffixes": "pre_foo.Suffixes_post",
+ "Delimiter": "pre_foo.Delimiter_post",
+ "FirstSuffix": "pre_foo.FirstSuffix_post",
+ "IsText": "pre_foo.IsText_post",
+ "String": "pre_foo.String_post",
+ "Type": "pre_foo.Type_post",
+ "MainType": "pre_foo.MainType_post",
+ "SubType": "pre_foo.SubType_post",
+ })
+}
diff --git a/resources/postpub/postpub.go b/resources/postpub/postpub.go
new file mode 100644
index 000000000..400e00aa4
--- /dev/null
+++ b/resources/postpub/postpub.go
@@ -0,0 +1,181 @@
+// Copyright 2020 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 postpub
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cast"
+
+ "github.com/gohugoio/hugo/common/hreflect"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+type PostPublishedResource interface {
+ resource.ResourceTypeProvider
+ resource.ResourceLinksProvider
+ resource.ResourceMetaProvider
+ resource.ResourceParamsProvider
+ resource.ResourceDataProvider
+ resource.OriginProvider
+
+ MediaType() map[string]any
+}
+
+const (
+ PostProcessPrefix = "__h_pp_l1"
+
+ // The suffix has an '=' in it to prevent the minifier to remove any enclosing
+ // quoutes around the attribute values.
+ // See issue #8884.
+ PostProcessSuffix = "__e="
+)
+
+func NewPostPublishResource(id int, r resource.Resource) PostPublishedResource {
+ return &PostPublishResource{
+ prefix: PostProcessPrefix + "_" + strconv.Itoa(id) + "_",
+ delegate: r,
+ }
+}
+
+// postPublishResource holds a Resource to be transformed post publishing.
+type PostPublishResource struct {
+ prefix string
+ delegate resource.Resource
+}
+
+func (r *PostPublishResource) field(name string) string {
+ return r.prefix + name + PostProcessSuffix
+}
+
+func (r *PostPublishResource) Permalink() string {
+ return r.field("Permalink")
+}
+
+func (r *PostPublishResource) RelPermalink() string {
+ return r.field("RelPermalink")
+}
+
+func (r *PostPublishResource) Origin() resource.Resource {
+ return r.delegate
+}
+
+func (r *PostPublishResource) GetFieldString(pattern string) (string, bool) {
+ if r == nil {
+ panic("resource is nil")
+ }
+ prefixIdx := strings.Index(pattern, r.prefix)
+ if prefixIdx == -1 {
+ // Not a method on this resource.
+ return "", false
+ }
+
+ fieldAccessor := pattern[prefixIdx+len(r.prefix) : strings.Index(pattern, PostProcessSuffix)]
+
+ d := r.delegate
+ switch {
+ case fieldAccessor == "RelPermalink":
+ return d.RelPermalink(), true
+ case fieldAccessor == "Permalink":
+ return d.Permalink(), true
+ case fieldAccessor == "Name":
+ return d.Name(), true
+ case fieldAccessor == "Title":
+ return d.Title(), true
+ case fieldAccessor == "ResourceType":
+ return d.ResourceType(), true
+ case fieldAccessor == "Content":
+ content, err := d.(resource.ContentProvider).Content()
+ if err != nil {
+ return "", true
+ }
+ return cast.ToString(content), true
+ case strings.HasPrefix(fieldAccessor, "MediaType"):
+ return r.fieldToString(d.MediaType(), fieldAccessor), true
+ case fieldAccessor == "Data.Integrity":
+ return cast.ToString((d.Data().(map[string]any)["Integrity"])), true
+ default:
+ panic(fmt.Sprintf("unknown field accessor %q", fieldAccessor))
+ }
+}
+
+func (r *PostPublishResource) fieldToString(receiver any, path string) string {
+ fieldname := strings.Split(path, ".")[1]
+
+ receiverv := reflect.ValueOf(receiver)
+ switch receiverv.Kind() {
+ case reflect.Map:
+ v := receiverv.MapIndex(reflect.ValueOf(fieldname))
+ return cast.ToString(v.Interface())
+ default:
+ v := receiverv.FieldByName(fieldname)
+ if !v.IsValid() {
+ method := hreflect.GetMethodByName(receiverv, fieldname)
+ if method.IsValid() {
+ vals := method.Call(nil)
+ if len(vals) > 0 {
+ v = vals[0]
+ }
+
+ }
+ }
+
+ if v.IsValid() {
+ return cast.ToString(v.Interface())
+ }
+ return ""
+ }
+}
+
+func (r *PostPublishResource) Data() any {
+ m := map[string]any{
+ "Integrity": "",
+ }
+ insertFieldPlaceholders("Data", m, r.field)
+ return m
+}
+
+func (r *PostPublishResource) MediaType() map[string]any {
+ m := structToMapWithPlaceholders("MediaType", media.Type{}, r.field)
+ return m
+}
+
+func (r *PostPublishResource) ResourceType() string {
+ return r.field("ResourceType")
+}
+
+func (r *PostPublishResource) Name() string {
+ return r.field("Name")
+}
+
+func (r *PostPublishResource) Title() string {
+ return r.field("Title")
+}
+
+func (r *PostPublishResource) Params() maps.Params {
+ panic(r.fieldNotSupported("Params"))
+}
+
+func (r *PostPublishResource) Content() (any, error) {
+ return r.field("Content"), nil
+}
+
+func (r *PostPublishResource) fieldNotSupported(name string) string {
+ return fmt.Sprintf("method .%s is currently not supported in post-publish transformations.", name)
+}
diff --git a/resources/resource.go b/resources/resource.go
new file mode 100644
index 000000000..fd60fd4f6
--- /dev/null
+++ b/resources/resource.go
@@ -0,0 +1,709 @@
+// Copyright 2022 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 (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/gohugoio/hugo/common/herrors"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/source"
+
+ "errors"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/resources/page"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/spf13/afero"
+
+ "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)
+ _ permalinker = (*genericResource)(nil)
+ _ resource.Identifier = (*genericResource)(nil)
+ _ fileInfo = (*genericResource)(nil)
+)
+
+type ResourceSourceDescriptor struct {
+ // TargetPaths is a callback to fetch paths's relative to its owner.
+ TargetPaths func() page.TargetPaths
+
+ // Need one of these to load the resource content.
+ SourceFile source.File
+ OpenReadSeekCloser resource.OpenReadSeekCloser
+
+ FileInfo os.FileInfo
+
+ // If OpenReadSeekerCloser is not set, we use this to open the file.
+ SourceFilename string
+
+ Fs afero.Fs
+
+ // Set when its known up front, else it's resolved from the target filename.
+ MediaType media.Type
+
+ // The relative target filename without any language code.
+ RelTargetFilename 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
+
+ // Delay publishing until either Permalink or RelPermalink is called. Maybe never.
+ LazyPublish bool
+}
+
+func (r ResourceSourceDescriptor) Filename() string {
+ if r.SourceFile != nil {
+ return r.SourceFile.Filename()
+ }
+ return r.SourceFilename
+}
+
+type ResourceTransformer interface {
+ resource.Resource
+ Transformer
+}
+
+type Transformer interface {
+ Transform(...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
+
+ fileInfo
+ metaAssigner
+ targetPather
+
+ ReadSeekCloser() (hugio.ReadSeekCloser, error)
+
+ // Internal
+ cloneWithUpdates(*transformationUpdate) (baseResource, error)
+ tryTransformedFileCache(key string, u *transformationUpdate) io.ReadCloser
+
+ specProvider
+ getResourcePaths() *resourcePathDescriptor
+ getTargetFilenames() []string
+ openDestinationsForWriting() (io.WriteCloser, error)
+ openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error)
+
+ relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string
+}
+
+type specProvider interface {
+ getSpec() *Spec
+}
+
+type baseResource interface {
+ baseResourceResource
+ baseResourceInternal
+}
+
+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 dirFile struct {
+ // This is the directory component with Unix-style slashes.
+ dir string
+ // This is the file component.
+ file string
+}
+
+func (d dirFile) path() string {
+ return path.Join(d.dir, d.file)
+}
+
+type fileInfo interface {
+ getSourceFilename() string
+ setSourceFilename(string)
+ setSourceFs(afero.Fs)
+ getFileInfo() hugofs.FileMetaInfo
+ hash() (string, error)
+ size() int
+}
+
+// genericResource represents a generic linkable resource.
+type genericResource struct {
+ *resourcePathDescriptor
+ *resourceFileInfo
+ *resourceContent
+
+ spec *Spec
+
+ title string
+ name string
+ params map[string]any
+ data map[string]any
+
+ resourceType string
+ mediaType media.Type
+}
+
+func (l *genericResource) Clone() resource.Resource {
+ return l.clone()
+}
+
+func (l *genericResource) cloneTo(targetPath string) resource.Resource {
+ c := l.clone()
+
+ targetPath = helpers.ToSlashTrimLeading(targetPath)
+ dir, file := path.Split(targetPath)
+
+ c.resourcePathDescriptor = &resourcePathDescriptor{
+ relTargetDirFile: dirFile{dir: dir, file: file},
+ }
+
+ return c
+
+}
+
+func (l *genericResource) Content() (any, error) {
+ if err := l.initContent(); err != nil {
+ return nil, err
+ }
+
+ return l.content, nil
+}
+
+func (r *genericResource) Err() resource.ResourceError {
+ return nil
+}
+
+func (l *genericResource) Data() any {
+ return l.data
+}
+
+func (l *genericResource) Key() string {
+ if l.spec.BasePath == "" {
+ return l.RelPermalink()
+ }
+ return strings.TrimPrefix(l.RelPermalink(), l.spec.BasePath)
+}
+
+func (l *genericResource) MediaType() media.Type {
+ return l.mediaType
+}
+
+func (l *genericResource) setMediaType(mediaType media.Type) {
+ l.mediaType = mediaType
+}
+
+func (l *genericResource) Name() string {
+ return l.name
+}
+
+func (l *genericResource) Params() maps.Params {
+ return l.params
+}
+
+func (l *genericResource) Permalink() string {
+ return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(l.relTargetDirFile.path(), true), l.spec.BaseURL.HostURL())
+}
+
+func (l *genericResource) Publish() error {
+ var err error
+ l.publishInit.Do(func() {
+ 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, l.getTargetFilenames()...)
+ if err != nil {
+ return
+ }
+ defer fw.Close()
+
+ _, err = io.Copy(fw, fr)
+ })
+
+ return err
+}
+
+func (l *genericResource) RelPermalink() string {
+ return l.relPermalinkFor(l.relTargetDirFile.path())
+}
+
+func (l *genericResource) ResourceType() string {
+ return l.resourceType
+}
+
+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.relTargetDirFile.path()
+}
+
+func (l *genericResource) Title() string {
+ return l.title
+}
+
+func (l *genericResource) createBasePath(rel string, isURL bool) string {
+ if l.targetPathBuilder == nil {
+ return rel
+ }
+ tp := l.targetPathBuilder()
+
+ if isURL {
+ return path.Join(tp.SubResourceBaseLink, rel)
+ }
+
+ // TODO(bep) path
+ return path.Join(filepath.ToSlash(tp.SubResourceBaseTarget), rel)
+}
+
+func (l *genericResource) initContent() error {
+ var err error
+ l.contentInit.Do(func() {
+ var r hugio.ReadSeekCloser
+ r, err = l.ReadSeekCloser()
+ if err != nil {
+ return
+ }
+ defer r.Close()
+
+ var b []byte
+ b, err = ioutil.ReadAll(r)
+ if err != nil {
+ return
+ }
+
+ l.content = string(b)
+ })
+
+ return err
+}
+
+func (l *genericResource) setName(name string) {
+ l.name = name
+}
+
+func (l *genericResource) getResourcePaths() *resourcePathDescriptor {
+ return l.resourcePathDescriptor
+}
+
+func (l *genericResource) getSpec() *Spec {
+ return l.spec
+}
+
+func (l *genericResource) getTargetFilenames() []string {
+ paths := l.relTargetPaths()
+ for i, p := range paths {
+ paths[i] = filepath.Clean(p)
+ }
+ return paths
+}
+
+func (l *genericResource) setTitle(title string) {
+ l.title = title
+}
+
+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.data == nil {
+ r.data = make(map[string]any)
+ }
+ for k, v := range in {
+ if _, found := r.data[k]; !found {
+ r.data[k] = v
+ }
+ }
+}
+
+func (rc *genericResource) cloneWithUpdates(u *transformationUpdate) (baseResource, error) {
+ r := rc.clone()
+
+ if u.content != nil {
+ r.contentInit.Do(func() {
+ r.content = *u.content
+ r.openReadSeekerCloser = func() (hugio.ReadSeekCloser, error) {
+ return hugio.NewReadSeekerNoOpCloserFromString(r.content), nil
+ }
+ })
+ }
+
+ r.mediaType = u.mediaType
+
+ if u.sourceFilename != nil {
+ r.setSourceFilename(*u.sourceFilename)
+ }
+
+ if u.sourceFs != nil {
+ r.setSourceFs(u.sourceFs)
+ }
+
+ if u.targetPath == "" {
+ return nil, errors.New("missing targetPath")
+ }
+
+ fpath, fname := path.Split(u.targetPath)
+ r.resourcePathDescriptor.relTargetDirFile = dirFile{dir: fpath, file: fname}
+
+ r.mergeData(u.data)
+
+ return r, nil
+}
+
+func (l genericResource) clone() *genericResource {
+ gi := *l.resourceFileInfo
+ rp := *l.resourcePathDescriptor
+ l.resourceFileInfo = &gi
+ l.resourcePathDescriptor = &rp
+ l.resourceContent = &resourceContent{}
+ return &l
+}
+
+// returns an opened file or nil if nothing to write (it may already be published).
+func (l *genericResource) openDestinationsForWriting() (w io.WriteCloser, err error) {
+ l.publishInit.Do(func() {
+ targetFilenames := l.getTargetFilenames()
+ var changedFilenames []string
+
+ // Fast path:
+ // This is a processed version of the original;
+ // check if it already exists at the destination.
+ for _, targetFilename := range targetFilenames {
+ if _, err := l.getSpec().BaseFs.PublishFs.Stat(targetFilename); err == nil {
+ continue
+ }
+
+ changedFilenames = append(changedFilenames, targetFilename)
+ }
+
+ if len(changedFilenames) == 0 {
+ return
+ }
+
+ w, err = helpers.OpenFilesForWriting(l.getSpec().BaseFs.PublishFs, changedFilenames...)
+ })
+
+ return
+}
+
+func (r *genericResource) openPublishFileForWriting(relTargetPath string) (io.WriteCloser, error) {
+ return helpers.OpenFilesForWriting(r.spec.BaseFs.PublishFs, r.relTargetPathsFor(relTargetPath)...)
+}
+
+func (l *genericResource) permalinkFor(target string) string {
+ return l.spec.PermalinkForBaseURL(l.relPermalinkForRel(target, true), l.spec.BaseURL.HostURL())
+}
+
+func (l *genericResource) relPermalinkFor(target string) string {
+ return l.relPermalinkForRel(target, false)
+}
+
+func (l *genericResource) relPermalinkForRel(rel string, isAbs bool) string {
+ return l.spec.PathSpec.URLizeFilename(l.relTargetPathForRel(rel, false, isAbs, true))
+}
+
+func (l *genericResource) relTargetPathForRel(rel string, addBaseTargetPath, isAbs, isURL bool) string {
+ if addBaseTargetPath && len(l.baseTargetPathDirs) > 1 {
+ panic("multiple baseTargetPathDirs")
+ }
+ var basePath string
+ if addBaseTargetPath && len(l.baseTargetPathDirs) > 0 {
+ basePath = l.baseTargetPathDirs[0]
+ }
+
+ return l.relTargetPathForRelAndBasePath(rel, basePath, isAbs, isURL)
+}
+
+func (l *genericResource) relTargetPathForRelAndBasePath(rel, basePath string, isAbs, isURL bool) string {
+ rel = l.createBasePath(rel, isURL)
+
+ if basePath != "" {
+ rel = path.Join(basePath, rel)
+ }
+
+ if l.baseOffset != "" {
+ rel = path.Join(l.baseOffset, rel)
+ }
+
+ if isURL {
+ bp := l.spec.PathSpec.GetBasePath(!isAbs)
+ if bp != "" {
+ rel = path.Join(bp, rel)
+ }
+ }
+
+ if len(rel) == 0 || rel[0] != '/' {
+ rel = "/" + rel
+ }
+
+ return rel
+}
+
+func (l *genericResource) relTargetPaths() []string {
+ return l.relTargetPathsForRel(l.TargetPath())
+}
+
+func (l *genericResource) relTargetPathsFor(target string) []string {
+ return l.relTargetPathsForRel(target)
+}
+
+func (l *genericResource) relTargetPathsForRel(rel string) []string {
+ if len(l.baseTargetPathDirs) == 0 {
+ return []string{l.relTargetPathForRelAndBasePath(rel, "", false, false)}
+ }
+
+ targetPaths := make([]string, len(l.baseTargetPathDirs))
+ for i, dir := range l.baseTargetPathDirs {
+ targetPaths[i] = l.relTargetPathForRelAndBasePath(rel, dir, false, false)
+ }
+ return targetPaths
+}
+
+func (l *genericResource) updateParams(params map[string]any) {
+ if l.params == nil {
+ l.params = params
+ return
+ }
+
+ // Sets the params not already set
+ for k, v := range params {
+ if _, found := l.params[k]; !found {
+ l.params[k] = v
+ }
+ }
+}
+
+type targetPather interface {
+ TargetPath() string
+}
+
+type permalinker interface {
+ targetPather
+ permalinkFor(target string) string
+ relPermalinkFor(target string) string
+ relTargetPaths() []string
+ relTargetPathsFor(target string) []string
+}
+
+type resourceContent struct {
+ content string
+ contentInit sync.Once
+
+ publishInit sync.Once
+}
+
+type resourceFileInfo struct {
+ // Will be set if this resource is backed by something other than a file.
+ openReadSeekerCloser resource.OpenReadSeekCloser
+
+ // This may be set to tell us to look in another filesystem for this resource.
+ // We, by default, use the sourceFs filesystem in the spec below.
+ sourceFs afero.Fs
+
+ // Absolute filename to the source, including any content folder path.
+ // Note that this is absolute in relation to the filesystem it is stored in.
+ // It can be a base path filesystem, and then this filename will not match
+ // the path to the file on the real filesystem.
+ sourceFilename string
+
+ fi hugofs.FileMetaInfo
+
+ // A hash of the source content. Is only calculated in caching situations.
+ h *resourceHash
+}
+
+func (fi *resourceFileInfo) ReadSeekCloser() (hugio.ReadSeekCloser, error) {
+ if fi.openReadSeekerCloser != nil {
+ return fi.openReadSeekerCloser()
+ }
+
+ f, err := fi.getSourceFs().Open(fi.getSourceFilename())
+ if err != nil {
+ return nil, err
+ }
+ return f, nil
+}
+
+func (fi *resourceFileInfo) getFileInfo() hugofs.FileMetaInfo {
+ return fi.fi
+}
+
+func (fi *resourceFileInfo) getSourceFilename() string {
+ return fi.sourceFilename
+}
+
+func (fi *resourceFileInfo) setSourceFilename(s string) {
+ // Make sure it's always loaded by sourceFilename.
+ fi.openReadSeekerCloser = nil
+ fi.sourceFilename = s
+}
+
+func (fi *resourceFileInfo) getSourceFs() afero.Fs {
+ return fi.sourceFs
+}
+
+func (fi *resourceFileInfo) setSourceFs(fs afero.Fs) {
+ fi.sourceFs = fs
+}
+
+func (fi *resourceFileInfo) hash() (string, error) {
+ var err error
+ fi.h.init.Do(func() {
+ var hash string
+ var f hugio.ReadSeekCloser
+ f, err = fi.ReadSeekCloser()
+ if err != nil {
+ err = fmt.Errorf("failed to open source file: %w", err)
+ return
+ }
+ defer f.Close()
+
+ hash, err = helpers.MD5FromFileFast(f)
+ if err != nil {
+ return
+ }
+ fi.h.value = hash
+ })
+
+ return fi.h.value, err
+}
+
+func (fi *resourceFileInfo) size() int {
+ if fi.fi == nil {
+ return 0
+ }
+
+ return int(fi.fi.Size())
+}
+
+type resourceHash struct {
+ value string
+ init sync.Once
+}
+
+type resourcePathDescriptor struct {
+ // The relative target directory and filename.
+ relTargetDirFile dirFile
+
+ // Callback used to construct a target path relative to its owner.
+ targetPathBuilder func() page.TargetPaths
+
+ // This will normally be the same as above, but this will only apply to publishing
+ // of resources. It may be multiple values when in multihost mode.
+ baseTargetPathDirs []string
+
+ // baseOffset is set when the output format's path has a offset, e.g. for AMP.
+ baseOffset string
+}
diff --git a/resources/resource/dates.go b/resources/resource/dates.go
new file mode 100644
index 000000000..6d19ca7b9
--- /dev/null
+++ b/resources/resource/dates.go
@@ -0,0 +1,93 @@
+// 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 resource
+
+import (
+ "time"
+
+ "github.com/gohugoio/hugo/common/htime"
+)
+
+var _ Dated = Dates{}
+
+// Dated wraps a "dated resource". These are the 4 dates that makes
+// the date logic in Hugo.
+type Dated interface {
+ // Date returns the date of the resource.
+ Date() time.Time
+
+ // Lastmod returns the last modification date of the resource.
+ Lastmod() time.Time
+
+ // PublishDate returns the publish date of the resource.
+ PublishDate() time.Time
+
+ // ExpiryDate returns the expiration date of the resource.
+ ExpiryDate() time.Time
+}
+
+// Dates holds the 4 Hugo dates.
+type Dates struct {
+ FDate time.Time
+ FLastmod time.Time
+ FPublishDate time.Time
+ FExpiryDate time.Time
+}
+
+func (d *Dates) UpdateDateAndLastmodIfAfter(in Dated) {
+ if in.Date().After(d.Date()) {
+ d.FDate = in.Date()
+ }
+ if in.Lastmod().After(d.Lastmod()) {
+ d.FLastmod = in.Lastmod()
+ }
+}
+
+// IsFuture returns whether the argument represents the future.
+func IsFuture(d Dated) bool {
+ if d.PublishDate().IsZero() {
+ return false
+ }
+
+ return d.PublishDate().After(htime.Now())
+}
+
+// IsExpired returns whether the argument is expired.
+func IsExpired(d Dated) bool {
+ if d.ExpiryDate().IsZero() {
+ return false
+ }
+ return d.ExpiryDate().Before(htime.Now())
+}
+
+// IsZeroDates returns true if all of the dates are zero.
+func IsZeroDates(d Dated) bool {
+ return d.Date().IsZero() && d.Lastmod().IsZero() && d.ExpiryDate().IsZero() && d.PublishDate().IsZero()
+}
+
+func (p Dates) Date() time.Time {
+ return p.FDate
+}
+
+func (p Dates) Lastmod() time.Time {
+ return p.FLastmod
+}
+
+func (p Dates) PublishDate() time.Time {
+ return p.FPublishDate
+}
+
+func (p Dates) ExpiryDate() time.Time {
+ return p.FExpiryDate
+}
diff --git a/resources/resource/params.go b/resources/resource/params.go
new file mode 100644
index 000000000..d88424e9d
--- /dev/null
+++ b/resources/resource/params.go
@@ -0,0 +1,33 @@
+// 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 resource
+
+import (
+ "github.com/gohugoio/hugo/common/maps"
+
+ "github.com/spf13/cast"
+)
+
+func Param(r ResourceParamsProvider, fallback maps.Params, key any) (any, error) {
+ keyStr, err := cast.ToStringE(key)
+ if err != nil {
+ return nil, err
+ }
+
+ if fallback == nil {
+ return maps.GetNestedParam(keyStr, ".", r.Params())
+ }
+
+ return maps.GetNestedParam(keyStr, ".", r.Params(), fallback)
+}
diff --git a/resources/resource/resource_helpers.go b/resources/resource/resource_helpers.go
new file mode 100644
index 000000000..29f783ce3
--- /dev/null
+++ b/resources/resource/resource_helpers.go
@@ -0,0 +1,70 @@
+// 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 resource
+
+import (
+ "strings"
+ "time"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ "github.com/spf13/cast"
+)
+
+// GetParam will return the param with the given key from the Resource,
+// nil if not found.
+func GetParam(r Resource, key string) any {
+ return getParam(r, key, false)
+}
+
+// GetParamToLower is the same as GetParam but it will lower case any string
+// result, including string slices.
+func GetParamToLower(r Resource, key string) any {
+ return getParam(r, key, true)
+}
+
+func getParam(r Resource, key string, stringToLower bool) any {
+ v := r.Params()[strings.ToLower(key)]
+
+ if v == nil {
+ return nil
+ }
+
+ switch val := v.(type) {
+ case bool:
+ return val
+ case string:
+ if stringToLower {
+ return strings.ToLower(val)
+ }
+ return val
+ case int64, int32, int16, int8, int:
+ return cast.ToInt(v)
+ case float64, float32:
+ return cast.ToFloat64(v)
+ case time.Time:
+ return val
+ case []string:
+ if stringToLower {
+ return helpers.SliceToLower(val)
+ }
+ return v
+ case map[string]any:
+ return v
+ case map[any]any:
+ return v
+ }
+
+ return nil
+}
diff --git a/resources/resource/resources.go b/resources/resource/resources.go
new file mode 100644
index 000000000..a888d6fb4
--- /dev/null
+++ b/resources/resource/resources.go
@@ -0,0 +1,198 @@
+// 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 resource
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gohugoio/hugo/hugofs/glob"
+ "github.com/spf13/cast"
+)
+
+var _ ResourceFinder = (*Resources)(nil)
+
+// Resources represents a slice of resources, which can be a mix of different types.
+// I.e. both pages and images etc.
+type Resources []Resource
+
+// var _ resource.ResourceFinder = (*Namespace)(nil)
+// ResourcesConverter converts a given slice of Resource objects to Resources.
+type ResourcesConverter interface {
+ // For internal use.
+ ToResources() Resources
+}
+
+// ByType returns resources of a given resource type (e.g. "image").
+func (r Resources) ByType(typ any) Resources {
+ tpstr, err := cast.ToStringE(typ)
+ if err != nil {
+ panic(err)
+ }
+ var filtered Resources
+
+ for _, resource := range r {
+ if resource.ResourceType() == tpstr {
+ filtered = append(filtered, resource)
+ }
+ }
+ return filtered
+}
+
+// Get locates the name given in Resources.
+// The search is case insensitive.
+func (r Resources) Get(name any) Resource {
+ namestr, err := cast.ToStringE(name)
+ if err != nil {
+ panic(err)
+ }
+ namestr = strings.ToLower(namestr)
+ for _, resource := range r {
+ if strings.EqualFold(namestr, resource.Name()) {
+ return resource
+ }
+ }
+ return nil
+}
+
+// GetMatch finds the first Resource matching the given pattern, or nil if none found.
+// See Match for a more complete explanation about the rules used.
+func (r Resources) GetMatch(pattern any) Resource {
+ patternstr, err := cast.ToStringE(pattern)
+ if err != nil {
+ panic(err)
+ }
+
+ g, err := glob.GetGlob(patternstr)
+ if err != nil {
+ panic(err)
+ }
+
+ for _, resource := range r {
+ if g.Match(strings.ToLower(resource.Name())) {
+ return resource
+ }
+ }
+
+ return nil
+}
+
+// Match gets all resources matching the given base filename prefix, e.g
+// "*.png" will match all png files. The "*" does not match path delimiters (/),
+// so if you organize your resources in sub-folders, you need to be explicit about it, e.g.:
+// "images/*.png". To match any PNG image anywhere in the bundle you can do "**.png", and
+// to match all PNG images below the images folder, use "images/**.jpg".
+// The matching is case insensitive.
+// Match matches by using the value of Resource.Name, which, by default, is a filename with
+// path relative to the bundle root with Unix style slashes (/) and no leading slash, e.g. "images/logo.png".
+// See https://github.com/gobwas/glob for the full rules set.
+func (r Resources) Match(pattern any) Resources {
+ patternstr, err := cast.ToStringE(pattern)
+ if err != nil {
+ panic(err)
+ }
+
+ g, err := glob.GetGlob(patternstr)
+ if err != nil {
+ panic(err)
+ }
+
+ var matches Resources
+ for _, resource := range r {
+ if g.Match(strings.ToLower(resource.Name())) {
+ matches = append(matches, resource)
+ }
+ }
+ return matches
+}
+
+type translatedResource interface {
+ TranslationKey() string
+}
+
+// MergeByLanguage adds missing translations in r1 from r2.
+func (r Resources) MergeByLanguage(r2 Resources) Resources {
+ result := append(Resources(nil), r...)
+ m := make(map[string]bool)
+ for _, rr := range r {
+ if translated, ok := rr.(translatedResource); ok {
+ m[translated.TranslationKey()] = true
+ }
+ }
+
+ for _, rr := range r2 {
+ if translated, ok := rr.(translatedResource); ok {
+ if _, found := m[translated.TranslationKey()]; !found {
+ result = append(result, rr)
+ }
+ }
+ }
+ return result
+}
+
+// MergeByLanguageInterface is the generic version of MergeByLanguage. It
+// is here just so it can be called from the tpl package.
+func (r Resources) MergeByLanguageInterface(in any) (any, error) {
+ r2, ok := in.(Resources)
+ if !ok {
+ return nil, fmt.Errorf("%T cannot be merged by language", in)
+ }
+ return r.MergeByLanguage(r2), nil
+}
+
+// Source is an internal template and not meant for use in the templates. It
+// may change without notice.
+type Source interface {
+ Publish() error
+}
+
+// ResourceFinder provides methods to find Resources.
+// Note that GetRemote (as found in resources.GetRemote) is
+// not covered by this interface, as this is only available as a global template function.
+type ResourceFinder interface {
+
+ // Get locates the Resource with the given name in the current context (e.g. in .Page.Resources).
+ //
+ // It returns nil if no Resource could found, panics if name is invalid.
+ Get(name any) Resource
+
+ // GetMatch finds the first Resource matching the given pattern, or nil if none found.
+ //
+ // See Match for a more complete explanation about the rules used.
+ //
+ // It returns nil if no Resource could found, panics if pattern is invalid.
+ GetMatch(pattern any) Resource
+
+ // Match gets all resources matching the given base path prefix, e.g
+ // "*.png" will match all png files. The "*" does not match path delimiters (/),
+ // so if you organize your resources in sub-folders, you need to be explicit about it, e.g.:
+ // "images/*.png". To match any PNG image anywhere in the bundle you can do "**.png", and
+ // to match all PNG images below the images folder, use "images/**.jpg".
+ //
+ // The matching is case insensitive.
+ //
+ // Match matches by using a relative pathwith Unix style slashes (/) and no
+ // leading slash, e.g. "images/logo.png".
+ //
+ // See https://github.com/gobwas/glob for the full rules set.
+ //
+ // See Match for a more complete explanation about the rules used.
+ //
+ // It returns nil if no Resources could found, panics if pattern is invalid.
+ Match(pattern any) Resources
+
+ // ByType returns resources of a given resource type (e.g. "image").
+ // It returns nil if no Resources could found, panics if typ is invalid.
+ ByType(typ any) Resources
+}
diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go
new file mode 100644
index 000000000..4ba95c170
--- /dev/null
+++ b/resources/resource/resourcetypes.go
@@ -0,0 +1,224 @@
+// 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 resource
+
+import (
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/media"
+
+ "github.com/gohugoio/hugo/common/hugio"
+)
+
+var (
+ _ ResourceDataProvider = (*resourceError)(nil)
+ _ ResourceError = (*resourceError)(nil)
+)
+
+// Cloner is for internal use.
+type Cloner interface {
+ Clone() Resource
+}
+
+// OriginProvider provides the original Resource if this is wrapped.
+// This is an internal Hugo interface and not meant for use in the templates.
+type OriginProvider interface {
+ Origin() Resource
+ GetFieldString(pattern string) (string, bool)
+}
+
+// NewResourceError creates a new ResourceError.
+func NewResourceError(err error, data any) ResourceError {
+ return &resourceError{
+ error: err,
+ data: data,
+ }
+}
+
+type resourceError struct {
+ error
+ data any
+}
+
+// The data associated with this error.
+func (e *resourceError) Data() any {
+ return e.data
+}
+
+// ResourceError is the error return from .Err in Resource in error situations.
+type ResourceError interface {
+ error
+ ResourceDataProvider
+}
+
+// ErrProvider provides an Err.
+type ErrProvider interface {
+ Err() ResourceError
+}
+
+// Resource represents a linkable resource, i.e. a content page, image etc.
+type Resource interface {
+ ResourceTypeProvider
+ MediaTypeProvider
+ ResourceLinksProvider
+ ResourceMetaProvider
+ ResourceParamsProvider
+ ResourceDataProvider
+ ErrProvider
+}
+
+type ResourceTypeProvider interface {
+ // ResourceType is the resource type. For most file types, this is the main
+ // part of the MIME type, e.g. "image", "application", "text" etc.
+ // For content pages, this value is "page".
+ ResourceType() string
+}
+
+type ResourceTypesProvider interface {
+ ResourceTypeProvider
+ MediaTypeProvider
+}
+
+type MediaTypeProvider interface {
+ // MediaType is this resource's MIME type.
+ MediaType() media.Type
+}
+
+type ResourceLinksProvider interface {
+ // Permalink represents the absolute link to this resource.
+ Permalink() string
+
+ // RelPermalink represents the host relative link to this resource.
+ RelPermalink() string
+}
+
+type ResourceMetaProvider interface {
+ // Name is the logical name of this resource. This can be set in the front matter
+ // metadata for this resource. If not set, Hugo will assign a value.
+ // This will in most cases be the base filename.
+ // So, for the image "/some/path/sunset.jpg" this will be "sunset.jpg".
+ // The value returned by this method will be used in the GetByPrefix and ByPrefix methods
+ // on Resources.
+ Name() string
+
+ // Title returns the title if set in front matter. For content pages, this will be the expected value.
+ Title() string
+}
+
+type ResourceParamsProvider interface {
+ // Params set in front matter for this resource.
+ Params() maps.Params
+}
+
+type ResourceDataProvider interface {
+ // Resource specific data set by Hugo.
+ // One example would be.Data.Digest for fingerprinted resources.
+ Data() any
+}
+
+// ResourcesLanguageMerger describes an interface for merging resources from a
+// different language.
+type ResourcesLanguageMerger interface {
+ MergeByLanguage(other Resources) Resources
+
+ // Needed for integration with the tpl package.
+ // For internal use.
+ MergeByLanguageInterface(other any) (any, error)
+}
+
+// Identifier identifies a resource.
+type Identifier interface {
+ Key() string
+}
+
+// ContentResource represents a Resource that provides a way to get to its content.
+// Most Resource types in Hugo implements this interface, including Page.
+type ContentResource interface {
+ MediaType() media.Type
+ ContentProvider
+}
+
+// ContentProvider provides Content.
+// This should be used with care, as it will read the file content into memory, but it
+// should be cached as effectively as possible by the implementation.
+type ContentProvider interface {
+ // Content returns this resource's content. It will be equivalent to reading the content
+ // that RelPermalink points to in the published folder.
+ // The return type will be contextual, and should be what you would expect:
+ // * Page: template.HTML
+ // * JSON: String
+ // * Etc.
+ Content() (any, error)
+}
+
+// OpenReadSeekCloser allows setting some other way (than reading from a filesystem)
+// to open or create a ReadSeekCloser.
+type OpenReadSeekCloser func() (hugio.ReadSeekCloser, error)
+
+// ReadSeekCloserResource is a Resource that supports loading its content.
+type ReadSeekCloserResource interface {
+ MediaType() media.Type
+ hugio.ReadSeekCloserProvider
+}
+
+// LengthProvider is a Resource that provides a length
+// (typically the length of the content).
+type LengthProvider interface {
+ Len() int
+}
+
+// LanguageProvider is a Resource in a language.
+type LanguageProvider interface {
+ Language() *langs.Language
+}
+
+// TranslationKeyProvider connects translations of the same Resource.
+type TranslationKeyProvider interface {
+ TranslationKey() string
+}
+
+// UnmarshableResource represents a Resource that can be unmarshaled to some other format.
+type UnmarshableResource interface {
+ ReadSeekCloserResource
+ Identifier
+}
+
+type resourceTypesHolder struct {
+ mediaType media.Type
+ resourceType string
+}
+
+func (r resourceTypesHolder) MediaType() media.Type {
+ return r.mediaType
+}
+
+func (r resourceTypesHolder) ResourceType() string {
+ return r.resourceType
+}
+
+func NewResourceTypesProvider(mediaType media.Type, resourceType string) ResourceTypesProvider {
+ return resourceTypesHolder{mediaType: mediaType, resourceType: resourceType}
+}
+
+type languageHolder struct {
+ lang *langs.Language
+}
+
+func (l languageHolder) Language() *langs.Language {
+ return l.lang
+}
+
+func NewLanguageProvider(lang *langs.Language) LanguageProvider {
+ return languageHolder{lang: lang}
+}
diff --git a/resources/resource_cache.go b/resources/resource_cache.go
new file mode 100644
index 000000000..52a48871e
--- /dev/null
+++ b/resources/resource_cache.go
@@ -0,0 +1,305 @@
+// 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 (
+ "encoding/json"
+ "io"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ "github.com/gohugoio/hugo/hugofs/glob"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+
+ "github.com/BurntSushi/locker"
+)
+
+const (
+ CACHE_CLEAR_ALL = "clear_all"
+ CACHE_OTHER = "other"
+)
+
+type ResourceCache struct {
+ rs *Spec
+
+ sync.RWMutex
+
+ // Either resource.Resource or resource.Resources.
+ cache map[string]any
+
+ fileCache *filecache.Cache
+
+ // Provides named resource locks.
+ nlocker *locker.Locker
+}
+
+// ResourceCacheKey converts the filename into the format used in the resource
+// cache.
+func ResourceCacheKey(filename string) string {
+ filename = filepath.ToSlash(filename)
+ return path.Join(resourceKeyPartition(filename), filename)
+}
+
+func resourceKeyPartition(filename string) string {
+ ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(filename)), ".")
+ if ext == "" {
+ ext = CACHE_OTHER
+ }
+ return ext
+}
+
+// Commonly used aliases and directory names used for some types.
+var extAliasKeywords = map[string][]string{
+ "sass": {"scss"},
+ "scss": {"sass"},
+}
+
+// ResourceKeyPartitions resolves a ordered slice of partitions that is
+// used to do resource cache invalidations.
+//
+// We use the first directory path element and the extension, so:
+// a/b.json => "a", "json"
+// b.json => "json"
+//
+// For some of the extensions we will also map to closely related types,
+// e.g. "scss" will also return "sass".
+//
+func ResourceKeyPartitions(filename string) []string {
+ var partitions []string
+ filename = glob.NormalizePath(filename)
+ dir, name := path.Split(filename)
+ ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(name)), ".")
+
+ if dir != "" {
+ partitions = append(partitions, strings.Split(dir, "/")[0])
+ }
+
+ if ext != "" {
+ partitions = append(partitions, ext)
+ }
+
+ if aliases, found := extAliasKeywords[ext]; found {
+ partitions = append(partitions, aliases...)
+ }
+
+ if len(partitions) == 0 {
+ partitions = []string{CACHE_OTHER}
+ }
+
+ return helpers.UniqueStringsSorted(partitions)
+}
+
+// ResourceKeyContainsAny returns whether the key is a member of any of the
+// given partitions.
+//
+// This is used for resource cache invalidation.
+func ResourceKeyContainsAny(key string, partitions []string) bool {
+ parts := strings.Split(key, "/")
+ for _, p1 := range partitions {
+ for _, p2 := range parts {
+ if p1 == p2 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func newResourceCache(rs *Spec) *ResourceCache {
+ return &ResourceCache{
+ rs: rs,
+ fileCache: rs.FileCaches.AssetsCache(),
+ cache: make(map[string]any),
+ nlocker: locker.NewLocker(),
+ }
+}
+
+func (c *ResourceCache) clear() {
+ c.Lock()
+ defer c.Unlock()
+
+ c.cache = make(map[string]any)
+ c.nlocker = locker.NewLocker()
+}
+
+func (c *ResourceCache) Contains(key string) bool {
+ key = c.cleanKey(filepath.ToSlash(key))
+ _, found := c.get(key)
+ return found
+}
+
+func (c *ResourceCache) cleanKey(key string) string {
+ return strings.TrimPrefix(path.Clean(strings.ToLower(key)), "/")
+}
+
+func (c *ResourceCache) get(key string) (any, bool) {
+ c.RLock()
+ defer c.RUnlock()
+ r, found := c.cache[key]
+ return r, found
+}
+
+func (c *ResourceCache) GetOrCreate(key string, f func() (resource.Resource, error)) (resource.Resource, error) {
+ r, err := c.getOrCreate(key, func() (any, error) { return f() })
+ if r == nil || err != nil {
+ return nil, err
+ }
+ return r.(resource.Resource), nil
+}
+
+func (c *ResourceCache) GetOrCreateResources(key string, f func() (resource.Resources, error)) (resource.Resources, error) {
+ r, err := c.getOrCreate(key, func() (any, error) { return f() })
+ if r == nil || err != nil {
+ return nil, err
+ }
+ return r.(resource.Resources), nil
+}
+
+func (c *ResourceCache) getOrCreate(key string, f func() (any, error)) (any, error) {
+ key = c.cleanKey(key)
+ // First check in-memory cache.
+ r, found := c.get(key)
+ if found {
+ return r, nil
+ }
+ // This is a potentially long running operation, so get a named lock.
+ c.nlocker.Lock(key)
+
+ // Double check in-memory cache.
+ r, found = c.get(key)
+ if found {
+ c.nlocker.Unlock(key)
+ return r, nil
+ }
+
+ defer c.nlocker.Unlock(key)
+
+ r, err := f()
+ if err != nil {
+ return nil, err
+ }
+
+ c.set(key, r)
+
+ return r, nil
+}
+
+func (c *ResourceCache) getFilenames(key string) (string, string) {
+ filenameMeta := key + ".json"
+ filenameContent := key + ".content"
+
+ return filenameMeta, filenameContent
+}
+
+func (c *ResourceCache) getFromFile(key string) (filecache.ItemInfo, io.ReadCloser, transformedResourceMetadata, bool) {
+ c.RLock()
+ defer c.RUnlock()
+
+ var meta transformedResourceMetadata
+ filenameMeta, filenameContent := c.getFilenames(key)
+
+ _, jsonContent, _ := c.fileCache.GetBytes(filenameMeta)
+ if jsonContent == nil {
+ return filecache.ItemInfo{}, nil, meta, false
+ }
+
+ if err := json.Unmarshal(jsonContent, &meta); err != nil {
+ return filecache.ItemInfo{}, nil, meta, false
+ }
+
+ fi, rc, _ := c.fileCache.Get(filenameContent)
+
+ return fi, rc, meta, rc != nil
+}
+
+// writeMeta writes the metadata to file and returns a writer for the content part.
+func (c *ResourceCache) writeMeta(key string, meta transformedResourceMetadata) (filecache.ItemInfo, io.WriteCloser, error) {
+ filenameMeta, filenameContent := c.getFilenames(key)
+ raw, err := json.Marshal(meta)
+ if err != nil {
+ return filecache.ItemInfo{}, nil, err
+ }
+
+ _, fm, err := c.fileCache.WriteCloser(filenameMeta)
+ if err != nil {
+ return filecache.ItemInfo{}, nil, err
+ }
+ defer fm.Close()
+
+ if _, err := fm.Write(raw); err != nil {
+ return filecache.ItemInfo{}, nil, err
+ }
+
+ fi, fc, err := c.fileCache.WriteCloser(filenameContent)
+
+ return fi, fc, err
+}
+
+func (c *ResourceCache) set(key string, r any) {
+ c.Lock()
+ defer c.Unlock()
+ c.cache[key] = r
+}
+
+func (c *ResourceCache) DeletePartitions(partitions ...string) {
+ partitionsSet := map[string]bool{
+ // Always clear out the resources not matching any partition.
+ "other": true,
+ }
+ for _, p := range partitions {
+ partitionsSet[p] = true
+ }
+
+ if partitionsSet[CACHE_CLEAR_ALL] {
+ c.clear()
+ return
+ }
+
+ c.Lock()
+ defer c.Unlock()
+
+ for k := range c.cache {
+ clear := false
+ for p := range partitionsSet {
+ if strings.Contains(k, p) {
+ // There will be some false positive, but that's fine.
+ clear = true
+ break
+ }
+ }
+
+ if clear {
+ delete(c.cache, k)
+ }
+ }
+}
+
+func (c *ResourceCache) DeleteMatches(re *regexp.Regexp) {
+ c.Lock()
+ defer c.Unlock()
+
+ for k := range c.cache {
+ if re.MatchString(k) {
+ delete(c.cache, k)
+ }
+ }
+}
diff --git a/resources/resource_cache_test.go b/resources/resource_cache_test.go
new file mode 100644
index 000000000..bcb241025
--- /dev/null
+++ b/resources/resource_cache_test.go
@@ -0,0 +1,58 @@
+// 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 (
+ "path/filepath"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestResourceKeyPartitions(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ input string
+ expected []string
+ }{
+ {"a.js", []string{"js"}},
+ {"a.scss", []string{"sass", "scss"}},
+ {"a.sass", []string{"sass", "scss"}},
+ {"d/a.js", []string{"d", "js"}},
+ {"js/a.js", []string{"js"}},
+ {"D/a.JS", []string{"d", "js"}},
+ {"d/a", []string{"d"}},
+ {filepath.FromSlash("/d/a.js"), []string{"d", "js"}},
+ {filepath.FromSlash("/d/e/a.js"), []string{"d", "js"}},
+ } {
+ c.Assert(ResourceKeyPartitions(test.input), qt.DeepEquals, test.expected, qt.Commentf(test.input))
+ }
+}
+
+func TestResourceKeyContainsAny(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ key string
+ filename string
+ expected bool
+ }{
+ {"styles/css", "asdf.css", true},
+ {"styles/css", "styles/asdf.scss", true},
+ {"js/foo.bar", "asdf.css", false},
+ } {
+ c.Assert(ResourceKeyContainsAny(test.key, ResourceKeyPartitions(test.filename)), qt.Equals, test.expected)
+ }
+}
diff --git a/resources/resource_factories/bundler/bundler.go b/resources/resource_factories/bundler/bundler.go
new file mode 100644
index 000000000..7de228227
--- /dev/null
+++ b/resources/resource_factories/bundler/bundler.go
@@ -0,0 +1,148 @@
+// 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 bundler contains functions for concatenation etc. of Resource objects.
+package bundler
+
+import (
+ "fmt"
+ "io"
+ "path"
+ "path/filepath"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Client contains methods perform concatenation and other bundling related
+// tasks to Resource objects.
+type Client struct {
+ rs *resources.Spec
+}
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec) *Client {
+ return &Client{rs: rs}
+}
+
+type multiReadSeekCloser struct {
+ mr io.Reader
+ sources []hugio.ReadSeekCloser
+}
+
+func toReaders(sources []hugio.ReadSeekCloser) []io.Reader {
+ readers := make([]io.Reader, len(sources))
+ for i, r := range sources {
+ readers[i] = r
+ }
+ return readers
+}
+
+func newMultiReadSeekCloser(sources ...hugio.ReadSeekCloser) *multiReadSeekCloser {
+ mr := io.MultiReader(toReaders(sources)...)
+ return &multiReadSeekCloser{mr, sources}
+}
+
+func (r *multiReadSeekCloser) Read(p []byte) (n int, err error) {
+ return r.mr.Read(p)
+}
+
+func (r *multiReadSeekCloser) Seek(offset int64, whence int) (newOffset int64, err error) {
+ for _, s := range r.sources {
+ newOffset, err = s.Seek(offset, whence)
+ if err != nil {
+ return
+ }
+ }
+
+ r.mr = io.MultiReader(toReaders(r.sources)...)
+
+ return
+}
+
+func (r *multiReadSeekCloser) Close() error {
+ for _, s := range r.sources {
+ s.Close()
+ }
+ return nil
+}
+
+// Concat concatenates the list of Resource objects.
+func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resource, error) {
+ // The CACHE_OTHER will make sure this will be re-created and published on rebuilds.
+ return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) {
+ var resolvedm media.Type
+
+ // The given set of resources must be of the same Media Type.
+ // We may improve on that in the future, but then we need to know more.
+ for i, r := range r {
+ if i > 0 && r.MediaType().Type() != resolvedm.Type() {
+ return nil, fmt.Errorf("resources in Concat must be of the same Media Type, got %q and %q", r.MediaType().Type(), resolvedm.Type())
+ }
+ resolvedm = r.MediaType()
+ }
+
+ concatr := func() (hugio.ReadSeekCloser, error) {
+ var rcsources []hugio.ReadSeekCloser
+ for _, s := range r {
+ rcr, ok := s.(resource.ReadSeekCloserResource)
+ if !ok {
+ return nil, fmt.Errorf("resource %T does not implement resource.ReadSeekerCloserResource", s)
+ }
+ rc, err := rcr.ReadSeekCloser()
+ if err != nil {
+ // Close the already opened.
+ for _, rcs := range rcsources {
+ rcs.Close()
+ }
+ return nil, err
+ }
+
+ rcsources = append(rcsources, rc)
+ }
+
+ // Arbitrary JavaScript files require a barrier between them to be safely concatenated together.
+ // Without this, the last line of one file can affect the first line of the next file and change how both files are interpreted.
+ if resolvedm.MainType == media.JavascriptType.MainType && resolvedm.SubType == media.JavascriptType.SubType {
+ readers := make([]hugio.ReadSeekCloser, 2*len(rcsources)-1)
+ j := 0
+ for i := 0; i < len(rcsources); i++ {
+ if i > 0 {
+ readers[j] = hugio.NewReadSeekerNoOpCloserFromString("\n;\n")
+ j++
+ }
+ readers[j] = rcsources[i]
+ j++
+ }
+ return newMultiReadSeekCloser(readers...), nil
+ }
+
+ return newMultiReadSeekCloser(rcsources...), nil
+ }
+
+ composite, err := c.rs.New(
+ resources.ResourceSourceDescriptor{
+ Fs: c.rs.FileCaches.AssetsCache().Fs,
+ LazyPublish: true,
+ OpenReadSeekCloser: concatr,
+ RelTargetFilename: filepath.Clean(targetPath),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return composite, nil
+ })
+}
diff --git a/resources/resource_factories/bundler/bundler_test.go b/resources/resource_factories/bundler/bundler_test.go
new file mode 100644
index 000000000..17a74cc88
--- /dev/null
+++ b/resources/resource_factories/bundler/bundler_test.go
@@ -0,0 +1,40 @@
+// 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 bundler
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/common/hugio"
+)
+
+func TestMultiReadSeekCloser(t *testing.T) {
+ c := qt.New(t)
+
+ rc := newMultiReadSeekCloser(
+ hugio.NewReadSeekerNoOpCloserFromString("A"),
+ hugio.NewReadSeekerNoOpCloserFromString("B"),
+ hugio.NewReadSeekerNoOpCloserFromString("C"),
+ )
+
+ for i := 0; i < 3; i++ {
+ s1 := helpers.ReaderToString(rc)
+ c.Assert(s1, qt.Equals, "ABC")
+ _, err := rc.Seek(0, 0)
+ c.Assert(err, qt.IsNil)
+ }
+}
diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go
new file mode 100644
index 000000000..075d25736
--- /dev/null
+++ b/resources/resource_factories/create/create.go
@@ -0,0 +1,151 @@
+// 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 create contains functions for to create Resource objects. This will
+// typically non-files.
+package create
+
+import (
+ "net/http"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/gohugoio/hugo/hugofs/glob"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Client contains methods to create Resource objects.
+// tasks to Resource objects.
+type Client struct {
+ rs *resources.Spec
+ httpClient *http.Client
+ cacheGetResource *filecache.Cache
+}
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec) *Client {
+ return &Client{
+ rs: rs,
+ httpClient: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ cacheGetResource: rs.FileCaches.GetResourceCache(),
+ }
+}
+
+// Copy copies r to the new targetPath.
+func (c *Client) Copy(r resource.Resource, targetPath string) (resource.Resource, error) {
+ return c.rs.ResourceCache.GetOrCreate(resources.ResourceCacheKey(targetPath), func() (resource.Resource, error) {
+ return resources.Copy(r, targetPath), nil
+ })
+}
+
+// Get creates a new Resource by opening the given filename in the assets filesystem.
+func (c *Client) Get(filename string) (resource.Resource, error) {
+ filename = filepath.Clean(filename)
+ return c.rs.ResourceCache.GetOrCreate(resources.ResourceCacheKey(filename), func() (resource.Resource, error) {
+ return c.rs.New(resources.ResourceSourceDescriptor{
+ Fs: c.rs.BaseFs.Assets.Fs,
+ LazyPublish: true,
+ SourceFilename: filename,
+ })
+ })
+}
+
+// Match gets the resources matching the given pattern from the assets filesystem.
+func (c *Client) Match(pattern string) (resource.Resources, error) {
+ return c.match("__match", pattern, nil, false)
+}
+
+func (c *Client) ByType(tp string) resource.Resources {
+ res, err := c.match(path.Join("_byType", tp), "**", func(r resource.Resource) bool { return r.ResourceType() == tp }, false)
+ if err != nil {
+ panic(err)
+ }
+ return res
+}
+
+// GetMatch gets first resource matching the given pattern from the assets filesystem.
+func (c *Client) GetMatch(pattern string) (resource.Resource, error) {
+ res, err := c.match("__get-match", pattern, nil, true)
+ if err != nil || len(res) == 0 {
+ return nil, err
+ }
+ return res[0], err
+}
+
+func (c *Client) match(name, pattern string, matchFunc func(r resource.Resource) bool, firstOnly bool) (resource.Resources, error) {
+ pattern = glob.NormalizePath(pattern)
+ partitions := glob.FilterGlobParts(strings.Split(pattern, "/"))
+ if len(partitions) == 0 {
+ partitions = []string{resources.CACHE_OTHER}
+ }
+ key := path.Join(name, path.Join(partitions...))
+ key = path.Join(key, pattern)
+
+ return c.rs.ResourceCache.GetOrCreateResources(key, func() (resource.Resources, error) {
+ var res resource.Resources
+
+ handle := func(info hugofs.FileMetaInfo) (bool, error) {
+ meta := info.Meta()
+ r, err := c.rs.New(resources.ResourceSourceDescriptor{
+ LazyPublish: true,
+ FileInfo: info,
+ OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
+ return meta.Open()
+ },
+ RelTargetFilename: meta.Path,
+ })
+ if err != nil {
+ return true, err
+ }
+
+ if matchFunc != nil && !matchFunc(r) {
+ return false, nil
+ }
+
+ res = append(res, r)
+
+ return firstOnly, nil
+ }
+
+ if err := hugofs.Glob(c.rs.BaseFs.Assets.Fs, pattern, handle); err != nil {
+ return nil, err
+ }
+
+ return res, nil
+ })
+}
+
+// FromString creates a new Resource from a string with the given relative target path.
+func (c *Client) FromString(targetPath, content string) (resource.Resource, error) {
+ return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) {
+ return c.rs.New(
+ resources.ResourceSourceDescriptor{
+ Fs: c.rs.FileCaches.AssetsCache().Fs,
+ LazyPublish: true,
+ OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
+ return hugio.NewReadSeekerNoOpCloserFromString(content), nil
+ },
+ RelTargetFilename: filepath.Clean(targetPath),
+ })
+ })
+}
diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go
new file mode 100644
index 000000000..51199dc93
--- /dev/null
+++ b/resources/resource_factories/create/remote.go
@@ -0,0 +1,279 @@
+// Copyright 2021 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 create
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "mime"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/hugio"
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/gohugoio/hugo/common/types"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/mitchellh/mapstructure"
+)
+
+type HTTPError struct {
+ error
+ Data map[string]any
+
+ StatusCode int
+ Body string
+}
+
+func toHTTPError(err error, res *http.Response) *HTTPError {
+ if err == nil {
+ panic("err is nil")
+ }
+ if res == nil {
+ return &HTTPError{
+ error: err,
+ Data: map[string]any{},
+ }
+ }
+
+ var body []byte
+ body, _ = ioutil.ReadAll(res.Body)
+
+ return &HTTPError{
+ error: err,
+ Data: map[string]any{
+ "StatusCode": res.StatusCode,
+ "Status": res.Status,
+ "Body": string(body),
+ "TransferEncoding": res.TransferEncoding,
+ "ContentLength": res.ContentLength,
+ "ContentType": res.Header.Get("Content-Type"),
+ },
+ }
+}
+
+// FromRemote expects one or n-parts of a URL to a resource
+// If you provide multiple parts they will be joined together to the final URL.
+func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resource, error) {
+ rURL, err := url.Parse(uri)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse URL for resource %s: %w", uri, err)
+ }
+
+ resourceID := calculateResourceID(uri, optionsm)
+
+ _, httpResponse, err := c.cacheGetResource.GetOrCreate(resourceID, func() (io.ReadCloser, error) {
+ options, err := decodeRemoteOptions(optionsm)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode options for resource %s: %w", uri, err)
+ }
+ if err := c.validateFromRemoteArgs(uri, options); err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest(options.Method, uri, options.BodyReader())
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err)
+ }
+ addDefaultHeaders(req)
+
+ if options.Headers != nil {
+ addUserProvidedHeaders(options.Headers, req)
+ }
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ httpResponse, err := httputil.DumpResponse(res, true)
+ if err != nil {
+ return nil, toHTTPError(err, res)
+ }
+
+ if res.StatusCode != http.StatusNotFound {
+ if res.StatusCode < 200 || res.StatusCode > 299 {
+ return nil, toHTTPError(fmt.Errorf("failed to fetch remote resource: %s", http.StatusText(res.StatusCode)), res)
+
+ }
+ }
+
+ return hugio.ToReadCloser(bytes.NewReader(httpResponse)), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ defer httpResponse.Close()
+
+ res, err := http.ReadResponse(bufio.NewReader(httpResponse), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if res.StatusCode == http.StatusNotFound {
+ // Not found. This matches how looksup for local resources work.
+ return nil, nil
+ }
+
+ body, err := ioutil.ReadAll(res.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read remote resource %q: %w", uri, err)
+ }
+
+ filename := path.Base(rURL.Path)
+ if _, params, _ := mime.ParseMediaType(res.Header.Get("Content-Disposition")); params != nil {
+ if _, ok := params["filename"]; ok {
+ filename = params["filename"]
+ }
+ }
+
+ var extensionHints []string
+
+ contentType := res.Header.Get("Content-Type")
+
+ // mime.ExtensionsByType gives a long list of extensions for text/plain,
+ // just use ".txt".
+ if strings.HasPrefix(contentType, "text/plain") {
+ extensionHints = []string{".txt"}
+ } else {
+ exts, _ := mime.ExtensionsByType(contentType)
+ if exts != nil {
+ extensionHints = exts
+ }
+ }
+
+ // Look for a file extension. If it's .txt, look for a more specific.
+ if extensionHints == nil || extensionHints[0] == ".txt" {
+ if ext := path.Ext(filename); ext != "" {
+ extensionHints = []string{ext}
+ }
+ }
+
+ // Now resolve the media type primarily using the content.
+ mediaType := media.FromContent(c.rs.MediaTypes, extensionHints, body)
+ if mediaType.IsZero() {
+ return nil, fmt.Errorf("failed to resolve media type for remote resource %q", uri)
+ }
+
+ resourceID = filename[:len(filename)-len(path.Ext(filename))] + "_" + resourceID + mediaType.FirstSuffix.FullSuffix
+
+ return c.rs.New(
+ resources.ResourceSourceDescriptor{
+ MediaType: mediaType,
+ LazyPublish: true,
+ OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
+ return hugio.NewReadSeekerNoOpCloser(bytes.NewReader(body)), nil
+ },
+ RelTargetFilename: filepath.Clean(resourceID),
+ })
+}
+
+func (c *Client) validateFromRemoteArgs(uri string, options fromRemoteOptions) error {
+ if err := c.rs.ExecHelper.Sec().CheckAllowedHTTPURL(uri); err != nil {
+ return err
+ }
+
+ if err := c.rs.ExecHelper.Sec().CheckAllowedHTTPMethod(options.Method); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func calculateResourceID(uri string, optionsm map[string]any) string {
+ if key, found := maps.LookupEqualFold(optionsm, "key"); found {
+ return helpers.HashString(key)
+ }
+ return helpers.HashString(uri, optionsm)
+}
+
+func addDefaultHeaders(req *http.Request, accepts ...string) {
+ for _, accept := range accepts {
+ if !hasHeaderValue(req.Header, "Accept", accept) {
+ req.Header.Add("Accept", accept)
+ }
+ }
+ if !hasHeaderKey(req.Header, "User-Agent") {
+ req.Header.Add("User-Agent", "Hugo Static Site Generator")
+ }
+}
+
+func addUserProvidedHeaders(headers map[string]any, req *http.Request) {
+ if headers == nil {
+ return
+ }
+ for key, val := range headers {
+ vals := types.ToStringSlicePreserveString(val)
+ for _, s := range vals {
+ req.Header.Add(key, s)
+ }
+ }
+}
+
+func hasHeaderValue(m http.Header, key, value string) bool {
+ var s []string
+ var ok bool
+
+ if s, ok = m[key]; !ok {
+ return false
+ }
+
+ for _, v := range s {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
+func hasHeaderKey(m http.Header, key string) bool {
+ _, ok := m[key]
+ return ok
+}
+
+type fromRemoteOptions struct {
+ Method string
+ Headers map[string]any
+ Body []byte
+}
+
+func (o fromRemoteOptions) BodyReader() io.Reader {
+ if o.Body == nil {
+ return nil
+ }
+ return bytes.NewBuffer(o.Body)
+}
+
+func decodeRemoteOptions(optionsm map[string]any) (fromRemoteOptions, error) {
+ options := fromRemoteOptions{
+ Method: "GET",
+ }
+
+ err := mapstructure.WeakDecode(optionsm, &options)
+ if err != nil {
+ return options, err
+ }
+ options.Method = strings.ToUpper(options.Method)
+
+ return options, nil
+}
diff --git a/resources/resource_factories/create/remote_test.go b/resources/resource_factories/create/remote_test.go
new file mode 100644
index 000000000..c2a3b7b32
--- /dev/null
+++ b/resources/resource_factories/create/remote_test.go
@@ -0,0 +1,96 @@
+// Copyright 2021 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 create
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestDecodeRemoteOptions(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ name string
+ args map[string]any
+ want fromRemoteOptions
+ wantErr bool
+ }{
+ {
+ "POST",
+ map[string]any{
+ "meThod": "PoST",
+ "headers": map[string]any{
+ "foo": "bar",
+ },
+ },
+ fromRemoteOptions{
+ Method: "POST",
+ Headers: map[string]any{
+ "foo": "bar",
+ },
+ },
+ false,
+ },
+ {
+ "Body",
+ map[string]any{
+ "meThod": "POST",
+ "body": []byte("foo"),
+ },
+ fromRemoteOptions{
+ Method: "POST",
+ Body: []byte("foo"),
+ },
+ false,
+ },
+ {
+ "Body, string",
+ map[string]any{
+ "meThod": "POST",
+ "body": "foo",
+ },
+ fromRemoteOptions{
+ Method: "POST",
+ Body: []byte("foo"),
+ },
+ false,
+ },
+ } {
+ c.Run(test.name, func(c *qt.C) {
+ got, err := decodeRemoteOptions(test.args)
+ isErr := qt.IsNil
+ if test.wantErr {
+ isErr = qt.IsNotNil
+ }
+
+ c.Assert(err, isErr)
+ c.Assert(got, qt.DeepEquals, test.want)
+ })
+
+ }
+
+}
+
+func TestCalculateResourceID(t *testing.T) {
+ c := qt.New(t)
+
+ c.Assert(calculateResourceID("foo", nil), qt.Equals, "5917621528921068675")
+ c.Assert(calculateResourceID("foo", map[string]any{"bar": "baz"}), qt.Equals, "7294498335241413323")
+
+ c.Assert(calculateResourceID("foo", map[string]any{"key": "1234", "bar": "baz"}), qt.Equals, "14904296279238663669")
+ c.Assert(calculateResourceID("asdf", map[string]any{"key": "1234", "bar": "asdf"}), qt.Equals, "14904296279238663669")
+ c.Assert(calculateResourceID("asdf", map[string]any{"key": "12345", "bar": "asdf"}), qt.Equals, "12191037851845371770")
+}
diff --git a/resources/resource_metadata.go b/resources/resource_metadata.go
new file mode 100644
index 000000000..8954a5109
--- /dev/null
+++ b/resources/resource_metadata.go
@@ -0,0 +1,144 @@
+// 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 (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/gohugoio/hugo/hugofs/glob"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/spf13/cast"
+
+ "github.com/gohugoio/hugo/common/maps"
+)
+
+var (
+ _ metaAssigner = (*genericResource)(nil)
+ _ metaAssigner = (*imageResource)(nil)
+ _ metaAssignerProvider = (*resourceAdapter)(nil)
+)
+
+type metaAssignerProvider interface {
+ getMetaAssigner() metaAssigner
+}
+
+// metaAssigner allows updating metadata in resources that supports it.
+type metaAssigner interface {
+ setTitle(title string)
+ setName(name string)
+ setMediaType(mediaType media.Type)
+ updateParams(params map[string]any)
+}
+
+const counterPlaceHolder = ":counter"
+
+// AssignMetadata assigns the given metadata to those resources that supports updates
+// and matching by wildcard given in `src` using `filepath.Match` with lower cased values.
+// This assignment is additive, but the most specific match needs to be first.
+// The `name` and `title` metadata field support shell-matched collection it got a match in.
+// See https://golang.org/pkg/path/#Match
+func AssignMetadata(metadata []map[string]any, resources ...resource.Resource) error {
+ counters := make(map[string]int)
+
+ for _, r := range resources {
+ var ma metaAssigner
+ mp, ok := r.(metaAssignerProvider)
+ if ok {
+ ma = mp.getMetaAssigner()
+ } else {
+ ma, ok = r.(metaAssigner)
+ if !ok {
+ continue
+ }
+ }
+
+ var (
+ nameSet, titleSet bool
+ nameCounter, titleCounter = 0, 0
+ nameCounterFound, titleCounterFound bool
+ resourceSrcKey = strings.ToLower(r.Name())
+ )
+
+ for _, meta := range metadata {
+ src, found := meta["src"]
+ if !found {
+ return fmt.Errorf("missing 'src' in metadata for resource")
+ }
+
+ srcKey := strings.ToLower(cast.ToString(src))
+
+ glob, err := glob.GetGlob(srcKey)
+ if err != nil {
+ return fmt.Errorf("failed to match resource with metadata: %w", err)
+ }
+
+ match := glob.Match(resourceSrcKey)
+
+ if match {
+ if !nameSet {
+ name, found := meta["name"]
+ if found {
+ name := cast.ToString(name)
+ if !nameCounterFound {
+ nameCounterFound = strings.Contains(name, counterPlaceHolder)
+ }
+ if nameCounterFound && nameCounter == 0 {
+ counterKey := "name_" + srcKey
+ nameCounter = counters[counterKey] + 1
+ counters[counterKey] = nameCounter
+ }
+
+ ma.setName(replaceResourcePlaceholders(name, nameCounter))
+ nameSet = true
+ }
+ }
+
+ if !titleSet {
+ title, found := meta["title"]
+ if found {
+ title := cast.ToString(title)
+ if !titleCounterFound {
+ titleCounterFound = strings.Contains(title, counterPlaceHolder)
+ }
+ if titleCounterFound && titleCounter == 0 {
+ counterKey := "title_" + srcKey
+ titleCounter = counters[counterKey] + 1
+ counters[counterKey] = titleCounter
+ }
+ ma.setTitle((replaceResourcePlaceholders(title, titleCounter)))
+ titleSet = true
+ }
+ }
+
+ params, found := meta["params"]
+ if found {
+ m := maps.ToStringMap(params)
+ // Needed for case insensitive fetching of params values
+ maps.PrepareParams(m)
+ ma.updateParams(m)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func replaceResourcePlaceholders(in string, counter int) string {
+ return strings.Replace(in, counterPlaceHolder, strconv.Itoa(counter), -1)
+}
diff --git a/resources/resource_metadata_test.go b/resources/resource_metadata_test.go
new file mode 100644
index 000000000..fa9659162
--- /dev/null
+++ b/resources/resource_metadata_test.go
@@ -0,0 +1,221 @@
+// 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 (
+ "testing"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/resource"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestAssignMetadata(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ var foo1, foo2, foo3, logo1, logo2, logo3 resource.Resource
+ var resources resource.Resources
+
+ for _, this := range []struct {
+ metaData []map[string]any
+ assertFunc func(err error)
+ }{
+ {[]map[string]any{
+ {
+ "title": "My Resource",
+ "name": "My Name",
+ "src": "*",
+ },
+ }, func(err error) {
+ c.Assert(logo1.Title(), qt.Equals, "My Resource")
+ c.Assert(logo1.Name(), qt.Equals, "My Name")
+ c.Assert(foo2.Name(), qt.Equals, "My Name")
+ }},
+ {[]map[string]any{
+ {
+ "title": "My Logo",
+ "src": "*loGo*",
+ },
+ {
+ "title": "My Resource",
+ "name": "My Name",
+ "src": "*",
+ },
+ }, func(err error) {
+ c.Assert(logo1.Title(), qt.Equals, "My Logo")
+ c.Assert(logo2.Title(), qt.Equals, "My Logo")
+ c.Assert(logo1.Name(), qt.Equals, "My Name")
+ c.Assert(foo2.Name(), qt.Equals, "My Name")
+ c.Assert(foo3.Name(), qt.Equals, "My Name")
+ c.Assert(foo3.Title(), qt.Equals, "My Resource")
+ }},
+ {[]map[string]any{
+ {
+ "title": "My Logo",
+ "src": "*loGo*",
+ "params": map[string]any{
+ "Param1": true,
+ "icon": "logo",
+ },
+ },
+ {
+ "title": "My Resource",
+ "src": "*",
+ "params": map[string]any{
+ "Param2": true,
+ "icon": "resource",
+ },
+ },
+ }, func(err error) {
+ c.Assert(err, qt.IsNil)
+ c.Assert(logo1.Title(), qt.Equals, "My Logo")
+ c.Assert(foo3.Title(), qt.Equals, "My Resource")
+ _, p1 := logo2.Params()["param1"]
+ _, p2 := foo2.Params()["param2"]
+ _, p1_2 := foo2.Params()["param1"]
+ _, p2_2 := logo2.Params()["param2"]
+
+ icon1 := logo2.Params()["icon"]
+ icon2 := foo2.Params()["icon"]
+
+ c.Assert(p1, qt.Equals, true)
+ c.Assert(p2, qt.Equals, true)
+
+ // Check merge
+ c.Assert(p2_2, qt.Equals, true)
+ c.Assert(p1_2, qt.Equals, false)
+
+ c.Assert(icon1, qt.Equals, "logo")
+ c.Assert(icon2, qt.Equals, "resource")
+ }},
+ {[]map[string]any{
+ {
+ "name": "Logo Name #:counter",
+ "src": "*logo*",
+ },
+ {
+ "title": "Resource #:counter",
+ "name": "Name #:counter",
+ "src": "*",
+ },
+ }, func(err error) {
+ c.Assert(err, qt.IsNil)
+ c.Assert(logo2.Title(), qt.Equals, "Resource #2")
+ c.Assert(logo2.Name(), qt.Equals, "Logo Name #1")
+ c.Assert(logo1.Title(), qt.Equals, "Resource #4")
+ c.Assert(logo1.Name(), qt.Equals, "Logo Name #2")
+ c.Assert(foo2.Title(), qt.Equals, "Resource #1")
+ c.Assert(foo1.Title(), qt.Equals, "Resource #3")
+ c.Assert(foo1.Name(), qt.Equals, "Name #2")
+ c.Assert(foo3.Title(), qt.Equals, "Resource #5")
+
+ c.Assert(resources.GetMatch("logo name #1*"), qt.Equals, logo2)
+ }},
+ {[]map[string]any{
+ {
+ "title": "Third Logo #:counter",
+ "src": "logo3.png",
+ },
+ {
+ "title": "Other Logo #:counter",
+ "name": "Name #:counter",
+ "src": "logo*",
+ },
+ }, func(err error) {
+ c.Assert(err, qt.IsNil)
+ c.Assert(logo3.Title(), qt.Equals, "Third Logo #1")
+ c.Assert(logo3.Name(), qt.Equals, "Name #3")
+ c.Assert(logo2.Title(), qt.Equals, "Other Logo #1")
+ c.Assert(logo2.Name(), qt.Equals, "Name #1")
+ c.Assert(logo1.Title(), qt.Equals, "Other Logo #2")
+ c.Assert(logo1.Name(), qt.Equals, "Name #2")
+ }},
+ {[]map[string]any{
+ {
+ "title": "Third Logo",
+ "src": "logo3.png",
+ },
+ {
+ "title": "Other Logo #:counter",
+ "name": "Name #:counter",
+ "src": "logo*",
+ },
+ }, func(err error) {
+ c.Assert(err, qt.IsNil)
+ c.Assert(logo3.Title(), qt.Equals, "Third Logo")
+ c.Assert(logo3.Name(), qt.Equals, "Name #3")
+ c.Assert(logo2.Title(), qt.Equals, "Other Logo #1")
+ c.Assert(logo2.Name(), qt.Equals, "Name #1")
+ c.Assert(logo1.Title(), qt.Equals, "Other Logo #2")
+ c.Assert(logo1.Name(), qt.Equals, "Name #2")
+ }},
+ {[]map[string]any{
+ {
+ "name": "third-logo",
+ "src": "logo3.png",
+ },
+ {
+ "title": "Logo #:counter",
+ "name": "Name #:counter",
+ "src": "logo*",
+ },
+ }, func(err error) {
+ c.Assert(err, qt.IsNil)
+ c.Assert(logo3.Title(), qt.Equals, "Logo #3")
+ c.Assert(logo3.Name(), qt.Equals, "third-logo")
+ c.Assert(logo2.Title(), qt.Equals, "Logo #1")
+ c.Assert(logo2.Name(), qt.Equals, "Name #1")
+ c.Assert(logo1.Title(), qt.Equals, "Logo #2")
+ c.Assert(logo1.Name(), qt.Equals, "Name #2")
+ }},
+ {[]map[string]any{
+ {
+ "title": "Third Logo #:counter",
+ },
+ }, func(err error) {
+ // Missing src
+ c.Assert(err, qt.Not(qt.IsNil))
+ }},
+ {[]map[string]any{
+ {
+ "title": "Title",
+ "src": "[]",
+ },
+ }, func(err error) {
+ // Invalid pattern
+ c.Assert(err, qt.Not(qt.IsNil))
+ }},
+ } {
+
+ foo2 = spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType)
+ logo2 = spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType)
+ foo1 = spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType)
+ logo1 = spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType)
+ foo3 = spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType)
+ logo3 = spec.newGenericResource(nil, nil, nil, "/b/logo3.png", "logo3.png", pngType)
+
+ resources = resource.Resources{
+ foo2,
+ logo2,
+ foo1,
+ logo1,
+ foo3,
+ logo3,
+ }
+
+ this.assertFunc(AssignMetadata(this.metaData, resources...))
+ }
+}
diff --git a/resources/resource_spec.go b/resources/resource_spec.go
new file mode 100644
index 000000000..fd9653012
--- /dev/null
+++ b/resources/resource_spec.go
@@ -0,0 +1,345 @@
+// 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 (
+ "errors"
+ "fmt"
+ "mime"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/resources/jsconfig"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/common/hexec"
+
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/identity"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/resources/postpub"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/common/loggers"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/output"
+ "github.com/gohugoio/hugo/resources/images"
+ "github.com/gohugoio/hugo/resources/page"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/gohugoio/hugo/tpl"
+ "github.com/spf13/afero"
+)
+
+func NewSpec(
+ s *helpers.PathSpec,
+ fileCaches filecache.Caches,
+ incr identity.Incrementer,
+ logger loggers.Logger,
+ errorHandler herrors.ErrorSender,
+ execHelper *hexec.Exec,
+ outputFormats output.Formats,
+ mimeTypes media.Types) (*Spec, error) {
+ imgConfig, err := images.DecodeConfig(s.Cfg.GetStringMap("imaging"))
+ if err != nil {
+ return nil, err
+ }
+
+ imaging, err := images.NewImageProcessor(imgConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ if incr == nil {
+ incr = &identity.IncrementByOne{}
+ }
+
+ if logger == nil {
+ logger = loggers.NewErrorLogger()
+ }
+
+ permalinks, err := page.NewPermalinkExpander(s)
+ if err != nil {
+ return nil, err
+ }
+
+ rs := &Spec{
+ PathSpec: s,
+ Logger: logger,
+ ErrorSender: errorHandler,
+ imaging: imaging,
+ ExecHelper: execHelper,
+ incr: incr,
+ MediaTypes: mimeTypes,
+ OutputFormats: outputFormats,
+ Permalinks: permalinks,
+ BuildConfig: config.DecodeBuild(s.Cfg),
+ FileCaches: fileCaches,
+ PostBuildAssets: &PostBuildAssets{
+ PostProcessResources: make(map[string]postpub.PostPublishedResource),
+ JSConfigBuilder: jsconfig.NewBuilder(),
+ },
+ imageCache: newImageCache(
+ fileCaches.ImageCache(),
+
+ s,
+ ),
+ }
+
+ rs.ResourceCache = newResourceCache(rs)
+
+ return rs, nil
+}
+
+type Spec struct {
+ *helpers.PathSpec
+
+ MediaTypes media.Types
+ OutputFormats output.Formats
+
+ Logger loggers.Logger
+ ErrorSender herrors.ErrorSender
+
+ TextTemplates tpl.TemplateParseFinder
+
+ Permalinks page.PermalinkExpander
+ BuildConfig config.Build
+
+ // Holds default filter settings etc.
+ imaging *images.ImageProcessor
+
+ ExecHelper *hexec.Exec
+
+ incr identity.Incrementer
+ imageCache *imageCache
+ ResourceCache *ResourceCache
+ FileCaches filecache.Caches
+
+ // Assets used after the build is done.
+ // This is shared between all sites.
+ *PostBuildAssets
+}
+
+type PostBuildAssets struct {
+ postProcessMu sync.RWMutex
+ PostProcessResources map[string]postpub.PostPublishedResource
+ JSConfigBuilder *jsconfig.Builder
+}
+
+func (r *Spec) New(fd ResourceSourceDescriptor) (resource.Resource, error) {
+ return r.newResourceFor(fd)
+}
+
+func (r *Spec) CacheStats() string {
+ r.imageCache.mu.RLock()
+ defer r.imageCache.mu.RUnlock()
+
+ s := fmt.Sprintf("Cache entries: %d", len(r.imageCache.store))
+
+ count := 0
+ for k := range r.imageCache.store {
+ if count > 5 {
+ break
+ }
+ s += "\n" + k
+ count++
+ }
+
+ return s
+}
+
+func (r *Spec) ClearCaches() {
+ r.imageCache.clear()
+ r.ResourceCache.clear()
+}
+
+func (r *Spec) DeleteBySubstring(s string) {
+ r.imageCache.deleteIfContains(s)
+}
+
+func (s *Spec) String() string {
+ return "spec"
+}
+
+// TODO(bep) clean up below
+func (r *Spec) newGenericResource(sourceFs afero.Fs,
+ targetPathBuilder func() page.TargetPaths,
+ osFileInfo os.FileInfo,
+ sourceFilename,
+ baseFilename string,
+ mediaType media.Type) *genericResource {
+ return r.newGenericResourceWithBase(
+ sourceFs,
+ nil,
+ nil,
+ targetPathBuilder,
+ osFileInfo,
+ sourceFilename,
+ baseFilename,
+ mediaType,
+ )
+}
+
+func (r *Spec) newGenericResourceWithBase(
+ sourceFs afero.Fs,
+ openReadSeekerCloser resource.OpenReadSeekCloser,
+ targetPathBaseDirs []string,
+ targetPathBuilder func() page.TargetPaths,
+ osFileInfo os.FileInfo,
+ sourceFilename,
+ baseFilename string,
+ mediaType media.Type) *genericResource {
+ if osFileInfo != nil && osFileInfo.IsDir() {
+ panic(fmt.Sprintf("dirs not supported resource types: %v", osFileInfo))
+ }
+
+ // This value is used both to construct URLs and file paths, but start
+ // with a Unix-styled path.
+ baseFilename = helpers.ToSlashTrimLeading(baseFilename)
+ fpath, fname := path.Split(baseFilename)
+
+ resourceType := mediaType.MainType
+
+ pathDescriptor := &resourcePathDescriptor{
+ baseTargetPathDirs: helpers.UniqueStringsReuse(targetPathBaseDirs),
+ targetPathBuilder: targetPathBuilder,
+ relTargetDirFile: dirFile{dir: fpath, file: fname},
+ }
+
+ var fim hugofs.FileMetaInfo
+ if osFileInfo != nil {
+ fim = osFileInfo.(hugofs.FileMetaInfo)
+ }
+
+ gfi := &resourceFileInfo{
+ fi: fim,
+ openReadSeekerCloser: openReadSeekerCloser,
+ sourceFs: sourceFs,
+ sourceFilename: sourceFilename,
+ h: &resourceHash{},
+ }
+
+ g := &genericResource{
+ resourceFileInfo: gfi,
+ resourcePathDescriptor: pathDescriptor,
+ mediaType: mediaType,
+ resourceType: resourceType,
+ spec: r,
+ params: make(map[string]any),
+ name: baseFilename,
+ title: baseFilename,
+ resourceContent: &resourceContent{},
+ }
+
+ return g
+}
+
+func (r *Spec) newResource(sourceFs afero.Fs, fd ResourceSourceDescriptor) (resource.Resource, error) {
+ fi := fd.FileInfo
+ var sourceFilename string
+
+ if fd.OpenReadSeekCloser != nil {
+ } else if fd.SourceFilename != "" {
+ var err error
+ fi, err = sourceFs.Stat(fd.SourceFilename)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ sourceFilename = fd.SourceFilename
+ } else {
+ sourceFilename = fd.SourceFile.Filename()
+ }
+
+ if fd.RelTargetFilename == "" {
+ fd.RelTargetFilename = sourceFilename
+ }
+
+ mimeType := fd.MediaType
+ if mimeType.IsZero() {
+ ext := strings.ToLower(filepath.Ext(fd.RelTargetFilename))
+ var (
+ found bool
+ suffixInfo media.SuffixInfo
+ )
+ mimeType, suffixInfo, found = r.MediaTypes.GetFirstBySuffix(strings.TrimPrefix(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" && mimeType.SubType == "rss" {
+ mimeType, 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 != "" {
+ mimeType, _ = media.FromStringAndExt(mimeStr, ext)
+ }
+ }
+ }
+
+ gr := r.newGenericResourceWithBase(
+ sourceFs,
+ fd.OpenReadSeekCloser,
+ fd.TargetBasePaths,
+ fd.TargetPaths,
+ fi,
+ sourceFilename,
+ fd.RelTargetFilename,
+ mimeType)
+
+ if mimeType.MainType == "image" {
+ imgFormat, ok := images.ImageFormatFromMediaSubType(mimeType.SubType)
+ if ok {
+ ir := &imageResource{
+ Image: images.NewImage(imgFormat, r.imaging, nil, gr),
+ baseResource: gr,
+ }
+ ir.root = ir
+ return newResourceAdapter(gr.spec, fd.LazyPublish, ir), nil
+ }
+
+ }
+
+ return newResourceAdapter(gr.spec, fd.LazyPublish, gr), nil
+}
+
+func (r *Spec) newResourceFor(fd ResourceSourceDescriptor) (resource.Resource, error) {
+ if fd.OpenReadSeekCloser == nil {
+ if fd.SourceFile != nil && fd.SourceFilename != "" {
+ return nil, errors.New("both SourceFile and AbsSourceFilename provided")
+ } else if fd.SourceFile == nil && fd.SourceFilename == "" {
+ return nil, errors.New("either SourceFile or AbsSourceFilename must be provided")
+ }
+ }
+
+ if fd.RelTargetFilename == "" {
+ fd.RelTargetFilename = fd.Filename()
+ }
+
+ if len(fd.TargetBasePaths) == 0 {
+ // If not set, we publish the same resource to all hosts.
+ fd.TargetBasePaths = r.MultihostTargetBasePaths
+ }
+
+ return r.newResource(fd.Fs, fd)
+}
diff --git a/resources/resource_test.go b/resources/resource_test.go
new file mode 100644
index 000000000..031c7b3c6
--- /dev/null
+++ b/resources/resource_test.go
@@ -0,0 +1,270 @@
+// 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 (
+ "fmt"
+ "math/rand"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ "github.com/gohugoio/hugo/media"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestGenericResource(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ r := spec.newGenericResource(nil, nil, nil, "/a/foo.css", "foo.css", media.CSSType)
+
+ c.Assert(r.Permalink(), qt.Equals, "https://example.com/foo.css")
+ c.Assert(r.RelPermalink(), qt.Equals, "/foo.css")
+ c.Assert(r.ResourceType(), qt.Equals, "text")
+}
+
+func TestGenericResourceWithLinkFactory(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ factory := newTargetPaths("/foo")
+
+ r := spec.newGenericResource(nil, factory, nil, "/a/foo.css", "foo.css", media.CSSType)
+
+ c.Assert(r.Permalink(), qt.Equals, "https://example.com/foo/foo.css")
+ c.Assert(r.RelPermalink(), qt.Equals, "/foo/foo.css")
+ c.Assert(r.Key(), qt.Equals, "/foo/foo.css")
+ c.Assert(r.ResourceType(), qt.Equals, "text")
+}
+
+func TestNewResourceFromFilename(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ writeSource(t, spec.Fs, "content/a/b/logo.png", "image")
+ writeSource(t, spec.Fs, "content/a/b/data.json", "json")
+
+ bfs := afero.NewBasePathFs(spec.Fs.Source, "content")
+
+ r, err := spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: "a/b/logo.png"})
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(r, qt.Not(qt.IsNil))
+ c.Assert(r.ResourceType(), qt.Equals, "image")
+ c.Assert(r.RelPermalink(), qt.Equals, "/a/b/logo.png")
+ c.Assert(r.Permalink(), qt.Equals, "https://example.com/a/b/logo.png")
+
+ r, err = spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: "a/b/data.json"})
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(r, qt.Not(qt.IsNil))
+ c.Assert(r.ResourceType(), qt.Equals, "application")
+}
+
+func TestNewResourceFromFilenameSubPathInBaseURL(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c, baseURL: "https://example.com/docs"})
+
+ writeSource(t, spec.Fs, "content/a/b/logo.png", "image")
+ bfs := afero.NewBasePathFs(spec.Fs.Source, "content")
+
+ fmt.Println()
+ r, err := spec.New(ResourceSourceDescriptor{Fs: bfs, SourceFilename: filepath.FromSlash("a/b/logo.png")})
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(r, qt.Not(qt.IsNil))
+ c.Assert(r.ResourceType(), qt.Equals, "image")
+ c.Assert(r.RelPermalink(), qt.Equals, "/docs/a/b/logo.png")
+ c.Assert(r.Permalink(), qt.Equals, "https://example.com/docs/a/b/logo.png")
+}
+
+var pngType, _ = media.FromStringAndExt("image/png", "png")
+
+func TestResourcesByType(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ resources := resource.Resources{
+ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/a/logo.png", "logo.css", pngType),
+ spec.newGenericResource(nil, nil, nil, "/a/foo2.css", "foo2.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/a/foo3.css", "foo3.css", media.CSSType),
+ }
+
+ c.Assert(len(resources.ByType("text")), qt.Equals, 3)
+ c.Assert(len(resources.ByType("image")), qt.Equals, 1)
+}
+
+func TestResourcesGetByPrefix(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ resources := resource.Resources{
+ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType),
+ spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType),
+ spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType),
+ }
+
+ c.Assert(resources.GetMatch("asdf*"), qt.IsNil)
+ c.Assert(resources.GetMatch("logo*").RelPermalink(), qt.Equals, "/logo1.png")
+ c.Assert(resources.GetMatch("loGo*").RelPermalink(), qt.Equals, "/logo1.png")
+ c.Assert(resources.GetMatch("logo2*").RelPermalink(), qt.Equals, "/Logo2.png")
+ c.Assert(resources.GetMatch("foo2*").RelPermalink(), qt.Equals, "/foo2.css")
+ c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css")
+ c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css")
+ c.Assert(resources.GetMatch("asdfasdf*"), qt.IsNil)
+
+ c.Assert(len(resources.Match("logo*")), qt.Equals, 2)
+ c.Assert(len(resources.Match("logo2*")), qt.Equals, 1)
+
+ logo := resources.GetMatch("logo*")
+ c.Assert(logo.Params(), qt.Not(qt.IsNil))
+ c.Assert(logo.Name(), qt.Equals, "logo1.png")
+ c.Assert(logo.Title(), qt.Equals, "logo1.png")
+}
+
+func TestResourcesGetMatch(t *testing.T) {
+ c := qt.New(t)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ resources := resource.Resources{
+ spec.newGenericResource(nil, nil, nil, "/a/foo1.css", "foo1.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/a/logo1.png", "logo1.png", pngType),
+ spec.newGenericResource(nil, nil, nil, "/b/Logo2.png", "Logo2.png", pngType),
+ spec.newGenericResource(nil, nil, nil, "/b/foo2.css", "foo2.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/b/foo3.css", "foo3.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/b/c/foo4.css", "c/foo4.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/b/c/foo5.css", "c/foo5.css", media.CSSType),
+ spec.newGenericResource(nil, nil, nil, "/b/c/d/foo6.css", "c/d/foo6.css", media.CSSType),
+ }
+
+ c.Assert(resources.GetMatch("logo*").RelPermalink(), qt.Equals, "/logo1.png")
+ c.Assert(resources.GetMatch("loGo*").RelPermalink(), qt.Equals, "/logo1.png")
+ c.Assert(resources.GetMatch("logo2*").RelPermalink(), qt.Equals, "/Logo2.png")
+ c.Assert(resources.GetMatch("foo2*").RelPermalink(), qt.Equals, "/foo2.css")
+ c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css")
+ c.Assert(resources.GetMatch("foo1*").RelPermalink(), qt.Equals, "/foo1.css")
+ c.Assert(resources.GetMatch("*/foo*").RelPermalink(), qt.Equals, "/c/foo4.css")
+
+ c.Assert(resources.GetMatch("asdfasdf"), qt.IsNil)
+
+ c.Assert(len(resources.Match("Logo*")), qt.Equals, 2)
+ c.Assert(len(resources.Match("logo2*")), qt.Equals, 1)
+ c.Assert(len(resources.Match("c/*")), qt.Equals, 2)
+
+ c.Assert(len(resources.Match("**.css")), qt.Equals, 6)
+ c.Assert(len(resources.Match("**/*.css")), qt.Equals, 3)
+ c.Assert(len(resources.Match("c/**/*.css")), qt.Equals, 1)
+
+ // Matches only CSS files in c/
+ c.Assert(len(resources.Match("c/**.css")), qt.Equals, 3)
+
+ // Matches all CSS files below c/ (including in c/d/)
+ c.Assert(len(resources.Match("c/**.css")), qt.Equals, 3)
+
+ // Patterns beginning with a slash will not match anything.
+ // We could maybe consider trimming that slash, but let's be explicit about this.
+ // (it is possible for users to do a rename)
+ // This is analogous to standing in a directory and doing "ls *.*".
+ c.Assert(len(resources.Match("/c/**.css")), qt.Equals, 0)
+}
+
+func BenchmarkResourcesMatch(b *testing.B) {
+ resources := benchResources(b)
+ prefixes := []string{"abc*", "jkl*", "nomatch*", "sub/*"}
+
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ resources.Match(prefixes[rand.Intn(len(prefixes))])
+ }
+ })
+}
+
+// This adds a benchmark for the a100 test case as described by Russ Cox here:
+// https://research.swtch.com/glob (really interesting article)
+// I don't expect Hugo users to "stumble upon" this problem, so this is more to satisfy
+// my own curiosity.
+func BenchmarkResourcesMatchA100(b *testing.B) {
+ c := qt.New(b)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ a100 := strings.Repeat("a", 100)
+ pattern := "a*a*a*a*a*a*a*a*b"
+
+ resources := resource.Resources{spec.newGenericResource(nil, nil, nil, "/a/"+a100, a100, media.CSSType)}
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resources.Match(pattern)
+ }
+}
+
+func benchResources(b *testing.B) resource.Resources {
+ c := qt.New(b)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ var resources resource.Resources
+
+ for i := 0; i < 30; i++ {
+ name := fmt.Sprintf("abcde%d_%d.css", i%5, i)
+ resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType))
+ }
+
+ for i := 0; i < 30; i++ {
+ name := fmt.Sprintf("efghi%d_%d.css", i%5, i)
+ resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType))
+ }
+
+ for i := 0; i < 30; i++ {
+ name := fmt.Sprintf("jklmn%d_%d.css", i%5, i)
+ resources = append(resources, spec.newGenericResource(nil, nil, nil, "/b/sub/"+name, "sub/"+name, media.CSSType))
+ }
+
+ return resources
+}
+
+func BenchmarkAssignMetadata(b *testing.B) {
+ c := qt.New(b)
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ for i := 0; i < b.N; i++ {
+ b.StopTimer()
+ var resources resource.Resources
+ meta := []map[string]any{
+ {
+ "title": "Foo #:counter",
+ "name": "Foo Name #:counter",
+ "src": "foo1*",
+ },
+ {
+ "title": "Rest #:counter",
+ "name": "Rest Name #:counter",
+ "src": "*",
+ },
+ }
+ for i := 0; i < 20; i++ {
+ name := fmt.Sprintf("foo%d_%d.css", i%5, i)
+ resources = append(resources, spec.newGenericResource(nil, nil, nil, "/a/"+name, name, media.CSSType))
+ }
+ b.StartTimer()
+
+ if err := AssignMetadata(meta, resources...); err != nil {
+ b.Fatal(err)
+ }
+
+ }
+}
diff --git a/resources/resource_transformers/babel/babel.go b/resources/resource_transformers/babel/babel.go
new file mode 100644
index 000000000..9a9110f62
--- /dev/null
+++ b/resources/resource_transformers/babel/babel.go
@@ -0,0 +1,239 @@
+// Copyright 2020 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 babel
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+
+ "github.com/gohugoio/hugo/common/hexec"
+ "github.com/gohugoio/hugo/common/loggers"
+
+ "github.com/gohugoio/hugo/common/hugo"
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Options from https://babeljs.io/docs/en/options
+type Options struct {
+ Config string // Custom path to config file
+
+ Minified bool
+ NoComments bool
+ Compact *bool
+ Verbose bool
+ NoBabelrc bool
+ SourceMap string
+}
+
+// DecodeOptions decodes options to and generates command flags
+func DecodeOptions(m map[string]any) (opts Options, err error) {
+ if m == nil {
+ return
+ }
+ err = mapstructure.WeakDecode(m, &opts)
+ return
+}
+
+func (opts Options) toArgs() []any {
+ var args []any
+
+ // external is not a known constant on the babel command line
+ // .sourceMaps must be a boolean, "inline", "both", or undefined
+ switch opts.SourceMap {
+ case "external":
+ args = append(args, "--source-maps")
+ case "inline":
+ args = append(args, "--source-maps=inline")
+ }
+ if opts.Minified {
+ args = append(args, "--minified")
+ }
+ if opts.NoComments {
+ args = append(args, "--no-comments")
+ }
+ if opts.Compact != nil {
+ args = append(args, "--compact="+strconv.FormatBool(*opts.Compact))
+ }
+ if opts.Verbose {
+ args = append(args, "--verbose")
+ }
+ if opts.NoBabelrc {
+ args = append(args, "--no-babelrc")
+ }
+ return args
+}
+
+// Client is the client used to do Babel transformations.
+type Client struct {
+ rs *resources.Spec
+}
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec) *Client {
+ return &Client{rs: rs}
+}
+
+type babelTransformation struct {
+ options Options
+ rs *resources.Spec
+}
+
+func (t *babelTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("babel", t.options)
+}
+
+// Transform shells out to babel-cli to do the heavy lifting.
+// For this to work, you need some additional tools. To install them globally:
+// npm install -g @babel/core @babel/cli
+// If you want to use presets or plugins such as @babel/preset-env
+// Then you should install those globally as well. e.g:
+// npm install -g @babel/preset-env
+// Instead of installing globally, you can also install everything as a dev-dependency (--save-dev instead of -g)
+func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ const binaryName = "babel"
+
+ ex := t.rs.ExecHelper
+
+ if err := ex.Sec().CheckAllowedExec(binaryName); err != nil {
+ return err
+ }
+
+ var configFile string
+ logger := t.rs.Logger
+
+ var errBuf bytes.Buffer
+ infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "babel")
+
+ if t.options.Config != "" {
+ configFile = t.options.Config
+ } else {
+ configFile = "babel.config.js"
+ }
+
+ configFile = filepath.Clean(configFile)
+
+ // We need an absolute filename to the config file.
+ if !filepath.IsAbs(configFile) {
+ configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile)
+ if configFile == "" && t.options.Config != "" {
+ // Only fail if the user specified config file is not found.
+ return fmt.Errorf("babel config %q not found:", configFile)
+ }
+ }
+
+ ctx.ReplaceOutPathExtension(".js")
+
+ var cmdArgs []any
+
+ if configFile != "" {
+ logger.Infoln("babel: use config file", configFile)
+ cmdArgs = []any{"--config-file", configFile}
+ }
+
+ if optArgs := t.options.toArgs(); len(optArgs) > 0 {
+ cmdArgs = append(cmdArgs, optArgs...)
+ }
+ cmdArgs = append(cmdArgs, "--filename="+ctx.SourcePath)
+
+ // Create compile into a real temp file:
+ // 1. separate stdout/stderr messages from babel (https://github.com/gohugoio/hugo/issues/8136)
+ // 2. allow generation and retrieval of external source map.
+ compileOutput, err := ioutil.TempFile("", "compileOut-*.js")
+ if err != nil {
+ return err
+ }
+
+ cmdArgs = append(cmdArgs, "--out-file="+compileOutput.Name())
+ stderr := io.MultiWriter(infoW, &errBuf)
+ cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
+ cmdArgs = append(cmdArgs, hexec.WithStdout(stderr))
+ cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
+
+ defer os.Remove(compileOutput.Name())
+
+ // ARGA [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel812882892/babel.config.js --source-maps --filename=js/main2.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-2237820197.js]
+ // [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel332846848/babel.config.js --filename=js/main.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-1451390834.js 0x10304ee60 0x10304ed60 0x10304f060]
+ cmd, err := ex.Npx(binaryName, cmdArgs...)
+
+ if err != nil {
+ if hexec.IsNotFound(err) {
+ // This may be on a CI server etc. Will fall back to pre-built assets.
+ return herrors.ErrFeatureNotAvailable
+ }
+ return err
+ }
+
+ stdin, err := cmd.StdinPipe()
+
+ if err != nil {
+ return err
+ }
+
+ go func() {
+ defer stdin.Close()
+ io.Copy(stdin, ctx.From)
+ }()
+
+ err = cmd.Run()
+ if err != nil {
+ if hexec.IsNotFound(err) {
+ return herrors.ErrFeatureNotAvailable
+ }
+ return fmt.Errorf(errBuf.String()+": %w", err)
+ }
+
+ content, err := ioutil.ReadAll(compileOutput)
+ if err != nil {
+ return err
+ }
+
+ mapFile := compileOutput.Name() + ".map"
+ if _, err := os.Stat(mapFile); err == nil {
+ defer os.Remove(mapFile)
+ sourceMap, err := ioutil.ReadFile(mapFile)
+ if err != nil {
+ return err
+ }
+ if err = ctx.PublishSourceMap(string(sourceMap)); err != nil {
+ return err
+ }
+ targetPath := path.Base(ctx.OutPath) + ".map"
+ re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
+ content = []byte(re.ReplaceAllString(string(content), "//# sourceMappingURL="+targetPath+"\n"))
+ }
+
+ ctx.To.Write(content)
+
+ return nil
+}
+
+// Process transforms the given Resource with the Babel processor.
+func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) {
+ return res.Transform(
+ &babelTransformation{rs: c.rs, options: options},
+ )
+}
diff --git a/resources/resource_transformers/babel/integration_test.go b/resources/resource_transformers/babel/integration_test.go
new file mode 100644
index 000000000..164e7fd40
--- /dev/null
+++ b/resources/resource_transformers/babel/integration_test.go
@@ -0,0 +1,94 @@
+// Copyright 2021 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 babel_test
+
+import (
+ "testing"
+
+ jww "github.com/spf13/jwalterweatherman"
+
+ "github.com/gohugoio/hugo/htesting"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestTransformBabel(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ files := `
+-- assets/js/main.js --
+/* A Car */
+class Car {
+ constructor(brand) {
+ this.carname = brand;
+ }
+}
+-- assets/js/main2.js --
+/* A Car2 */
+class Car2 {
+ constructor(brand) {
+ this.carname = brand;
+ }
+}
+-- babel.config.js --
+console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT );
+
+module.exports = {
+ presets: ["@babel/preset-env"],
+};
+-- config.toml --
+disablekinds = ['taxonomy', 'term', 'page']
+[security]
+ [security.exec]
+ allow = ['^npx$', '^babel$']
+-- layouts/index.html --
+{{ $options := dict "noComments" true }}
+{{ $transpiled := resources.Get "js/main.js" | babel -}}
+Transpiled: {{ $transpiled.Content | safeJS }}
+
+{{ $transpiled := resources.Get "js/main2.js" | babel (dict "sourceMap" "inline") -}}
+Transpiled2: {{ $transpiled.Content | safeJS }}
+
+{{ $transpiled := resources.Get "js/main2.js" | babel (dict "sourceMap" "external") -}}
+Transpiled3: {{ $transpiled.Permalink }}
+-- package.json --
+{
+ "scripts": {},
+
+ "devDependencies": {
+ "@babel/cli": "7.8.4",
+ "@babel/core": "7.9.0",
+ "@babel/preset-env": "7.9.5"
+ }
+}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ LogLevel: jww.LevelInfo,
+ }).Build()
+
+ b.AssertLogContains("babel: Hugo Environment: production")
+ b.AssertFileContent("public/index.html", `var Car2 =`)
+ b.AssertFileContent("public/js/main2.js", `var Car2 =`)
+ b.AssertFileContent("public/js/main2.js.map", `{"version":3,`)
+ b.AssertFileContent("public/index.html", `
+//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozL`)
+}
diff --git a/resources/resource_transformers/htesting/testhelpers.go b/resources/resource_transformers/htesting/testhelpers.go
new file mode 100644
index 000000000..3c91fc0dd
--- /dev/null
+++ b/resources/resource_transformers/htesting/testhelpers.go
@@ -0,0 +1,78 @@
+// 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 htesting
+
+import (
+ "path/filepath"
+
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/output"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/spf13/afero"
+)
+
+func NewTestResourceSpec() (*resources.Spec, error) {
+ cfg := config.NewWithTestDefaults()
+
+ imagingCfg := map[string]any{
+ "resampleFilter": "linear",
+ "quality": 68,
+ "anchor": "left",
+ }
+
+ cfg.Set("imaging", imagingCfg)
+
+ fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(afero.NewMemMapFs()), cfg)
+
+ s, err := helpers.NewPathSpec(fs, cfg, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ filecaches, err := filecache.NewCaches(s)
+ if err != nil {
+ return nil, err
+ }
+
+ spec, err := resources.NewSpec(s, filecaches, nil, nil, nil, nil, output.DefaultFormats, media.DefaultTypes)
+ return spec, err
+}
+
+func NewResourceTransformer(filename, content string) (resources.ResourceTransformer, error) {
+ spec, err := NewTestResourceSpec()
+ if err != nil {
+ return nil, err
+ }
+ return NewResourceTransformerForSpec(spec, filename, content)
+}
+
+func NewResourceTransformerForSpec(spec *resources.Spec, filename, content string) (resources.ResourceTransformer, error) {
+ filename = filepath.FromSlash(filename)
+
+ fs := spec.Fs.Source
+ if err := afero.WriteFile(fs, filename, []byte(content), 0777); err != nil {
+ return nil, err
+ }
+
+ r, err := spec.New(resources.ResourceSourceDescriptor{Fs: fs, SourceFilename: filename})
+ if err != nil {
+ return nil, err
+ }
+
+ return r.(resources.ResourceTransformer), nil
+}
diff --git a/resources/resource_transformers/integrity/integrity.go b/resources/resource_transformers/integrity/integrity.go
new file mode 100644
index 000000000..e15754685
--- /dev/null
+++ b/resources/resource_transformers/integrity/integrity.go
@@ -0,0 +1,120 @@
+// Copyright 2018 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 integrity
+
+import (
+ "crypto/md5"
+ "crypto/sha256"
+ "crypto/sha512"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "hash"
+ "html/template"
+ "io"
+
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+const defaultHashAlgo = "sha256"
+
+// Client contains methods to fingerprint (cachebusting) and other integrity-related
+// methods.
+type Client struct {
+ rs *resources.Spec
+}
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec) *Client {
+ return &Client{rs: rs}
+}
+
+type fingerprintTransformation struct {
+ algo string
+}
+
+func (t *fingerprintTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("fingerprint", t.algo)
+}
+
+// Transform creates a MD5 hash of the Resource content and inserts that hash before
+// the extension in the filename.
+func (t *fingerprintTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ h, err := newHash(t.algo)
+ if err != nil {
+ return err
+ }
+
+ var w io.Writer
+ if rc, ok := ctx.From.(io.ReadSeeker); ok {
+ // This transformation does not change the content, so try to
+ // avoid writing to To if we can.
+ defer rc.Seek(0, 0)
+ w = h
+ } else {
+ w = io.MultiWriter(h, ctx.To)
+ }
+
+ io.Copy(w, ctx.From)
+ d, err := digest(h)
+ if err != nil {
+ return err
+ }
+
+ ctx.Data["Integrity"] = integrity(t.algo, d)
+ ctx.AddOutPathIdentifier("." + hex.EncodeToString(d[:]))
+ return nil
+}
+
+func newHash(algo string) (hash.Hash, error) {
+ switch algo {
+ case "md5":
+ return md5.New(), nil
+ case "sha256":
+ return sha256.New(), nil
+ case "sha384":
+ return sha512.New384(), nil
+ case "sha512":
+ return sha512.New(), nil
+ default:
+ return nil, fmt.Errorf("unsupported crypto algo: %q, use either md5, sha256, sha384 or sha512", algo)
+ }
+}
+
+// Fingerprint applies fingerprinting of the given resource and hash algorithm.
+// It defaults to sha256 if none given, and the options are md5, sha256 or sha512.
+// The same algo is used for both the fingerprinting part (aka cache busting) and
+// the base64-encoded Subresource Integrity hash, so you will have to stay away from
+// md5 if you plan to use both.
+// See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
+func (c *Client) Fingerprint(res resources.ResourceTransformer, algo string) (resource.Resource, error) {
+ if algo == "" {
+ algo = defaultHashAlgo
+ }
+
+ return res.Transform(&fingerprintTransformation{algo: algo})
+}
+
+func integrity(algo string, sum []byte) template.HTMLAttr {
+ encoded := base64.StdEncoding.EncodeToString(sum)
+ return template.HTMLAttr(algo + "-" + encoded)
+}
+
+func digest(h hash.Hash) ([]byte, error) {
+ sum := h.Sum(nil)
+ return sum, nil
+}
diff --git a/resources/resource_transformers/integrity/integrity_test.go b/resources/resource_transformers/integrity/integrity_test.go
new file mode 100644
index 000000000..cba993d1e
--- /dev/null
+++ b/resources/resource_transformers/integrity/integrity_test.go
@@ -0,0 +1,69 @@
+// 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 integrity
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/resources/resource_transformers/htesting"
+)
+
+func TestHashFromAlgo(t *testing.T) {
+ for _, algo := range []struct {
+ name string
+ bits int
+ }{
+ {"md5", 128},
+ {"sha256", 256},
+ {"sha384", 384},
+ {"sha512", 512},
+ {"shaman", -1},
+ } {
+ t.Run(algo.name, func(t *testing.T) {
+ c := qt.New(t)
+ h, err := newHash(algo.name)
+ if algo.bits > 0 {
+ c.Assert(err, qt.IsNil)
+ c.Assert(h.Size(), qt.Equals, algo.bits/8)
+ } else {
+ c.Assert(err, qt.Not(qt.IsNil))
+ c.Assert(err.Error(), qt.Contains, "use either md5, sha256, sha384 or sha512")
+ }
+ })
+ }
+}
+
+func TestTransform(t *testing.T) {
+ c := qt.New(t)
+
+ spec, err := htesting.NewTestResourceSpec()
+ c.Assert(err, qt.IsNil)
+ client := New(spec)
+
+ r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.txt", "Hugo Rocks!")
+ c.Assert(err, qt.IsNil)
+
+ transformed, err := client.Fingerprint(r, "")
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.a5ad1c6961214a55de53c1ce6e60d27b6b761f54851fa65e33066460dfa6a0db.txt")
+ c.Assert(transformed.Data(), qt.DeepEquals, map[string]any{"Integrity": template.HTMLAttr("sha256-pa0caWEhSlXeU8HObmDSe2t2H1SFH6ZeMwZkYN+moNs=")})
+ content, err := transformed.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, qt.Equals, "Hugo Rocks!")
+}
diff --git a/resources/resource_transformers/js/build.go b/resources/resource_transformers/js/build.go
new file mode 100644
index 000000000..23e28f675
--- /dev/null
+++ b/resources/resource_transformers/js/build.go
@@ -0,0 +1,222 @@
+// Copyright 2020 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 js
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "errors"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/common/text"
+
+ "github.com/gohugoio/hugo/hugolib/filesystems"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Client context for ESBuild.
+type Client struct {
+ rs *resources.Spec
+ sfs *filesystems.SourceFilesystem
+}
+
+// New creates a new client context.
+func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
+ return &Client{
+ rs: rs,
+ sfs: fs,
+ }
+}
+
+type buildTransformation struct {
+ optsm map[string]any
+ c *Client
+}
+
+func (t *buildTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("jsbuild", t.optsm)
+}
+
+func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ ctx.OutMediaType = media.JavascriptType
+
+ opts, err := decodeOptions(t.optsm)
+ if err != nil {
+ return err
+ }
+
+ if opts.TargetPath != "" {
+ ctx.OutPath = opts.TargetPath
+ } else {
+ ctx.ReplaceOutPathExtension(".js")
+ }
+
+ src, err := ioutil.ReadAll(ctx.From)
+ if err != nil {
+ return err
+ }
+
+ opts.sourceDir = filepath.FromSlash(path.Dir(ctx.SourcePath))
+ opts.resolveDir = t.c.rs.WorkingDir // where node_modules gets resolved
+ opts.contents = string(src)
+ opts.mediaType = ctx.InMediaType
+
+ buildOptions, err := toBuildOptions(opts)
+ if err != nil {
+ return err
+ }
+
+ buildOptions.Plugins, err = createBuildPlugins(t.c, opts)
+ if err != nil {
+ return err
+ }
+
+ if buildOptions.Sourcemap == api.SourceMapExternal && buildOptions.Outdir == "" {
+ buildOptions.Outdir, err = ioutil.TempDir(os.TempDir(), "compileOutput")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(buildOptions.Outdir)
+ }
+
+ if opts.Inject != nil {
+ // Resolve the absolute filenames.
+ for i, ext := range opts.Inject {
+ impPath := filepath.FromSlash(ext)
+ if filepath.IsAbs(impPath) {
+ return fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
+ }
+
+ m := resolveComponentInAssets(t.c.rs.Assets.Fs, impPath)
+
+ if m == nil {
+ return fmt.Errorf("inject: file %q not found", ext)
+ }
+
+ opts.Inject[i] = m.Filename
+
+ }
+
+ buildOptions.Inject = opts.Inject
+
+ }
+
+ result := api.Build(buildOptions)
+
+ if len(result.Errors) > 0 {
+
+ createErr := func(msg api.Message) error {
+ loc := msg.Location
+ if loc == nil {
+ return errors.New(msg.Text)
+ }
+ path := loc.File
+ if path == stdinImporter {
+ path = ctx.SourcePath
+ }
+
+ errorMessage := msg.Text
+ errorMessage = strings.ReplaceAll(errorMessage, nsImportHugo+":", "")
+
+ var (
+ f afero.File
+ err error
+ )
+
+ if strings.HasPrefix(path, nsImportHugo) {
+ path = strings.TrimPrefix(path, nsImportHugo+":")
+ f, err = hugofs.Os.Open(path)
+ } else {
+ var fi os.FileInfo
+ fi, err = t.c.sfs.Fs.Stat(path)
+ if err == nil {
+ m := fi.(hugofs.FileMetaInfo).Meta()
+ path = m.Filename
+ f, err = m.Open()
+ }
+
+ }
+
+ if err == nil {
+ fe := herrors.
+ NewFileErrorFromName(errors.New(errorMessage), path).
+ UpdatePosition(text.Position{Offset: -1, LineNumber: loc.Line, ColumnNumber: loc.Column}).
+ UpdateContent(f, nil)
+
+ f.Close()
+ return fe
+ }
+
+ return fmt.Errorf("%s", errorMessage)
+ }
+
+ var errors []error
+
+ for _, msg := range result.Errors {
+ errors = append(errors, createErr(msg))
+ }
+
+ // Return 1, log the rest.
+ for i, err := range errors {
+ if i > 0 {
+ t.c.rs.Logger.Errorf("js.Build failed: %s", err)
+ }
+ }
+
+ return errors[0]
+ }
+
+ if buildOptions.Sourcemap == api.SourceMapExternal {
+ content := string(result.OutputFiles[1].Contents)
+ symPath := path.Base(ctx.OutPath) + ".map"
+ re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
+ content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
+
+ if err = ctx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
+ return err
+ }
+ _, err := ctx.To.Write([]byte(content))
+ if err != nil {
+ return err
+ }
+ } else {
+ _, err := ctx.To.Write(result.OutputFiles[0].Contents)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Process process esbuild transform
+func (c *Client) Process(res resources.ResourceTransformer, opts map[string]any) (resource.Resource, error) {
+ return res.Transform(
+ &buildTransformation{c: c, optsm: opts},
+ )
+}
diff --git a/resources/resource_transformers/js/build_test.go b/resources/resource_transformers/js/build_test.go
new file mode 100644
index 000000000..30a4490ed
--- /dev/null
+++ b/resources/resource_transformers/js/build_test.go
@@ -0,0 +1,14 @@
+// Copyright 2020 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 js
diff --git a/resources/resource_transformers/js/integration_test.go b/resources/resource_transformers/js/integration_test.go
new file mode 100644
index 000000000..b9f466873
--- /dev/null
+++ b/resources/resource_transformers/js/integration_test.go
@@ -0,0 +1,261 @@
+// Copyright 2021 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 js_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/htesting"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestBuildVariants(t *testing.T) {
+ c := qt.New(t)
+
+ mainWithImport := `
+-- config.toml --
+disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"]
+-- assets/js/main.js --
+import { hello1, hello2 } from './util1';
+hello1();
+hello2();
+-- assets/js/util1.js --
+import { hello3 } from './util2';
+export function hello1() {
+ return 'abcd';
+}
+export function hello2() {
+ return hello3();
+}
+-- assets/js/util2.js --
+export function hello3() {
+ return 'efgh';
+}
+-- layouts/index.html --
+{{ $js := resources.Get "js/main.js" | js.Build }}
+JS Content:{{ $js.Content }}:End:
+
+ `
+
+ c.Run("Basic", func(c *qt.C) {
+ b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: mainWithImport}).Build()
+
+ b.AssertFileContent("public/index.html", `abcd`)
+ })
+
+ c.Run("Edit Import", func(c *qt.C) {
+ b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, Running: true, NeedsOsFS: true, TxtarString: mainWithImport}).Build()
+
+ b.AssertFileContent("public/index.html", `abcd`)
+ b.EditFileReplace("assets/js/util1.js", func(s string) string { return strings.ReplaceAll(s, "abcd", "1234") }).Build()
+ b.AssertFileContent("public/index.html", `1234`)
+ })
+
+ c.Run("Edit Import Nested", func(c *qt.C) {
+ b := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, Running: true, NeedsOsFS: true, TxtarString: mainWithImport}).Build()
+
+ b.AssertFileContent("public/index.html", `efgh`)
+ b.EditFileReplace("assets/js/util2.js", func(s string) string { return strings.ReplaceAll(s, "efgh", "1234") }).Build()
+ b.AssertFileContent("public/index.html", `1234`)
+ })
+}
+
+func TestBuildWithModAndNpm(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("skip (relative) long running modules test when running locally")
+ }
+
+ c := qt.New(t)
+
+ files := `
+-- config.toml --
+baseURL = "https://example.org"
+disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"]
+[module]
+[[module.imports]]
+path="github.com/gohugoio/hugoTestProjectJSModImports"
+-- go.mod --
+module github.com/gohugoio/tests/testHugoModules
+
+go 1.16
+
+require github.com/gohugoio/hugoTestProjectJSModImports v0.10.0 // indirect
+-- package.json --
+{
+ "dependencies": {
+ "date-fns": "^2.16.1"
+ }
+}
+
+`
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ TxtarString: files,
+ Verbose: true,
+ }).Build()
+
+ b.AssertFileContent("public/js/main.js", `
+greeting: "greeting configured in mod2"
+Hello1 from mod1: $
+return "Hello2 from mod1";
+var Hugo = "Rocks!";
+Hello3 from mod2. Date from date-fns: ${today}
+Hello from lib in the main project
+Hello5 from mod2.
+var myparam = "Hugo Rocks!";
+shim cwd
+`)
+
+ // React JSX, verify the shimming.
+ b.AssertFileContent("public/js/like.js", filepath.FromSlash(`@v0.10.0/assets/js/shims/react.js
+module.exports = window.ReactDOM;
+`))
+}
+
+func TestBuildWithNpm(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("skip (relative) long running modules test when running locally")
+ }
+
+ c := qt.New(t)
+
+ files := `
+-- assets/js/included.js --
+console.log("included");
+-- assets/js/main.js --
+import "./included";
+ import { toCamelCase } from "to-camel-case";
+
+ console.log("main");
+ console.log("To camel:", toCamelCase("space case"));
+-- assets/js/myjsx.jsx --
+import * as React from 'react'
+import * as ReactDOM from 'react-dom'
+
+ ReactDOM.render(
+ <h1>Hello, world!</h1>,
+ document.getElementById('root')
+ );
+-- assets/js/myts.ts --
+function greeter(person: string) {
+ return "Hello, " + person;
+}
+let user = [0, 1, 2];
+document.body.textContent = greeter(user);
+-- config.toml --
+disablekinds = ['taxonomy', 'term', 'page']
+-- content/p1.md --
+Content.
+-- data/hugo.toml --
+slogan = "Hugo Rocks!"
+-- i18n/en.yaml --
+hello:
+ other: "Hello"
+-- i18n/fr.yaml --
+hello:
+ other: "Bonjour"
+-- layouts/index.html --
+{{ $options := dict "minify" false "externals" (slice "react" "react-dom") }}
+{{ $js := resources.Get "js/main.js" | js.Build $options }}
+JS: {{ template "print" $js }}
+{{ $jsx := resources.Get "js/myjsx.jsx" | js.Build $options }}
+JSX: {{ template "print" $jsx }}
+{{ $ts := resources.Get "js/myts.ts" | js.Build (dict "sourcemap" "inline")}}
+TS: {{ template "print" $ts }}
+{{ $ts2 := resources.Get "js/myts.ts" | js.Build (dict "sourcemap" "external" "TargetPath" "js/myts2.js")}}
+TS2: {{ template "print" $ts2 }}
+{{ define "print" }}RelPermalink: {{.RelPermalink}}|MIME: {{ .MediaType }}|Content: {{ .Content | safeJS }}{{ end }}
+-- package.json --
+{
+ "scripts": {},
+
+ "dependencies": {
+ "to-camel-case": "1.0.0"
+ }
+}
+`
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ TxtarString: files,
+ }).Build()
+
+ b.AssertFileContent("public/js/myts.js", `//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJz`)
+ b.AssertFileContent("public/js/myts2.js.map", `"version": 3,`)
+ b.AssertFileContent("public/index.html", `
+ console.log(&#34;included&#34;);
+ if (hasSpace.test(string))
+ var React = __toESM(__require(&#34;react&#34;));
+ function greeter(person) {
+`)
+}
+
+func TestBuildError(t *testing.T) {
+ c := qt.New(t)
+
+ filesTemplate := `
+-- config.toml --
+disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"]
+-- assets/js/main.js --
+// A comment.
+import { hello1, hello2 } from './util1';
+hello1();
+hello2();
+-- assets/js/util1.js --
+/* Some
+comments.
+*/
+import { hello3 } from './util2';
+export function hello1() {
+ return 'abcd';
+}
+export function hello2() {
+ return hello3();
+}
+-- assets/js/util2.js --
+export function hello3() {
+ return 'efgh';
+}
+-- layouts/index.html --
+{{ $js := resources.Get "js/main.js" | js.Build }}
+JS Content:{{ $js.Content }}:End:
+
+ `
+
+ c.Run("Import from main not found", func(c *qt.C) {
+ c.Parallel()
+ files := strings.Replace(filesTemplate, "import { hello1, hello2 }", "import { hello1, hello2, FOOBAR }", 1)
+ b, err := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: files}).BuildE()
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `main.js:2:25": No matching export`)
+ })
+
+ c.Run("Import from import not found", func(c *qt.C) {
+ c.Parallel()
+ files := strings.Replace(filesTemplate, "import { hello3 } from './util2';", "import { hello3, FOOBAR } from './util2';", 1)
+ b, err := hugolib.NewIntegrationTestBuilder(hugolib.IntegrationTestConfig{T: c, NeedsOsFS: true, TxtarString: files}).BuildE()
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `util1.js:4:17": No matching export in`)
+ })
+
+}
diff --git a/resources/resource_transformers/js/options.go b/resources/resource_transformers/js/options.go
new file mode 100644
index 000000000..2987f5915
--- /dev/null
+++ b/resources/resource_transformers/js/options.go
@@ -0,0 +1,424 @@
+// Copyright 2020 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 js
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/maps"
+ "github.com/spf13/afero"
+
+ "github.com/evanw/esbuild/pkg/api"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/mitchellh/mapstructure"
+)
+
+const (
+ nsImportHugo = "ns-hugo"
+ nsParams = "ns-params"
+
+ stdinImporter = "<stdin>"
+)
+
+// Options esbuild configuration
+type Options struct {
+ // If not set, the source path will be used as the base target path.
+ // Note that the target path's extension may change if the target MIME type
+ // is different, e.g. when the source is TypeScript.
+ TargetPath string
+
+ // Whether to minify to output.
+ Minify bool
+
+ // Whether to write mapfiles
+ SourceMap string
+
+ // The language target.
+ // One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
+ // Default is esnext.
+ Target string
+
+ // The output format.
+ // One of: iife, cjs, esm
+ // Default is to esm.
+ Format string
+
+ // External dependencies, e.g. "react".
+ Externals []string
+
+ // This option allows you to automatically replace a global variable with an import from another file.
+ // The filenames must be relative to /assets.
+ // See https://esbuild.github.io/api/#inject
+ Inject []string
+
+ // User defined symbols.
+ Defines map[string]any
+
+ // Maps a component import to another.
+ Shims map[string]string
+
+ // User defined params. Will be marshaled to JSON and available as "@params", e.g.
+ // import * as params from '@params';
+ Params any
+
+ // What to use instead of React.createElement.
+ JSXFactory string
+
+ // What to use instead of React.Fragment.
+ JSXFragment string
+
+ // There is/was a bug in WebKit with severe performance issue with the tracking
+ // of TDZ checks in JavaScriptCore.
+ //
+ // Enabling this flag removes the TDZ and `const` assignment checks and
+ // may improve performance of larger JS codebases until the WebKit fix
+ // is in widespread use.
+ //
+ // See https://bugs.webkit.org/show_bug.cgi?id=199866
+ // Deprecated: This no longer have any effect and will be removed.
+ // TODO(bep) remove. See https://github.com/evanw/esbuild/commit/869e8117b499ca1dbfc5b3021938a53ffe934dba
+ AvoidTDZ bool
+
+ mediaType media.Type
+ outDir string
+ contents string
+ sourceDir string
+ resolveDir string
+ tsConfig string
+}
+
+func decodeOptions(m map[string]any) (Options, error) {
+ var opts Options
+
+ if err := mapstructure.WeakDecode(m, &opts); err != nil {
+ return opts, err
+ }
+
+ if opts.TargetPath != "" {
+ opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
+ }
+
+ opts.Target = strings.ToLower(opts.Target)
+ opts.Format = strings.ToLower(opts.Format)
+
+ return opts, nil
+}
+
+var extensionToLoaderMap = map[string]api.Loader{
+ ".js": api.LoaderJS,
+ ".mjs": api.LoaderJS,
+ ".cjs": api.LoaderJS,
+ ".jsx": api.LoaderJSX,
+ ".ts": api.LoaderTS,
+ ".tsx": api.LoaderTSX,
+ ".css": api.LoaderCSS,
+ ".json": api.LoaderJSON,
+ ".txt": api.LoaderText,
+}
+
+func loaderFromFilename(filename string) api.Loader {
+ l, found := extensionToLoaderMap[filepath.Ext(filename)]
+ if found {
+ return l
+ }
+ return api.LoaderJS
+}
+
+func resolveComponentInAssets(fs afero.Fs, impPath string) *hugofs.FileMeta {
+ findFirst := func(base string) *hugofs.FileMeta {
+ // This is the most common sub-set of ESBuild's default extensions.
+ // We assume that imports of JSON, CSS etc. will be using their full
+ // name with extension.
+ for _, ext := range []string{".js", ".ts", ".tsx", ".jsx"} {
+ if strings.HasSuffix(impPath, ext) {
+ // Import of foo.js.js need the full name.
+ return nil
+ }
+ if fi, err := fs.Stat(base + ext); err == nil {
+ return fi.(hugofs.FileMetaInfo).Meta()
+ }
+ }
+
+ // Not found.
+ return nil
+ }
+
+ var m *hugofs.FileMeta
+
+ // See issue #8949.
+ // We need to check if this is a regular file imported without an extension.
+ // There may be ambigous situations where both foo.js and foo/index.js exists.
+ // This import order is in line with both how Node and ESBuild's native
+ // import resolver works.
+ // This was fixed in Hugo 0.88.
+
+ // It may be a regular file imported without an extension, e.g.
+ // foo or foo/index.
+ m = findFirst(impPath)
+ if m != nil {
+ return m
+ }
+ if filepath.Base(impPath) == "index" {
+ m = findFirst(impPath + ".esm")
+ if m != nil {
+ return m
+ }
+ }
+
+ // Finally check the path as is.
+ fi, err := fs.Stat(impPath)
+
+ if err == nil {
+ if fi.IsDir() {
+ m = findFirst(filepath.Join(impPath, "index"))
+ if m == nil {
+ m = findFirst(filepath.Join(impPath, "index.esm"))
+ }
+ } else {
+ m = fi.(hugofs.FileMetaInfo).Meta()
+ }
+ }
+
+ return m
+}
+
+func createBuildPlugins(c *Client, opts Options) ([]api.Plugin, error) {
+ fs := c.rs.Assets
+
+ resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ impPath := args.Path
+ if opts.Shims != nil {
+ override, found := opts.Shims[impPath]
+ if found {
+ impPath = override
+ }
+ }
+ isStdin := args.Importer == stdinImporter
+ var relDir string
+ if !isStdin {
+ rel, found := fs.MakePathRelative(args.Importer)
+ if !found {
+ // Not in any of the /assets folders.
+ // This is an import from a node_modules, let
+ // ESBuild resolve this.
+ return api.OnResolveResult{}, nil
+ }
+ relDir = filepath.Dir(rel)
+ } else {
+ relDir = opts.sourceDir
+ }
+
+ // Imports not starting with a "." is assumed to live relative to /assets.
+ // Hugo makes no assumptions about the directory structure below /assets.
+ if relDir != "" && strings.HasPrefix(impPath, ".") {
+ impPath = filepath.Join(relDir, impPath)
+ }
+
+ m := resolveComponentInAssets(fs.Fs, impPath)
+
+ if m != nil {
+ // Store the source root so we can create a jsconfig.json
+ // to help intellisense when the build is done.
+ // This should be a small number of elements, and when
+ // in server mode, we may get stale entries on renames etc.,
+ // but that shouldn't matter too much.
+ c.rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
+ return api.OnResolveResult{Path: m.Filename, Namespace: nsImportHugo}, nil
+ }
+
+ // Fall back to ESBuild's resolve.
+ return api.OnResolveResult{}, nil
+ }
+
+ importResolver := api.Plugin{
+ Name: "hugo-import-resolver",
+ Setup: func(build api.PluginBuild) {
+ build.OnResolve(api.OnResolveOptions{Filter: `.*`},
+ func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ return resolveImport(args)
+ })
+ build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsImportHugo},
+ func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+ b, err := ioutil.ReadFile(args.Path)
+ if err != nil {
+ return api.OnLoadResult{}, fmt.Errorf("failed to read %q: %w", args.Path, err)
+ }
+ c := string(b)
+ return api.OnLoadResult{
+ // See https://github.com/evanw/esbuild/issues/502
+ // This allows all modules to resolve dependencies
+ // in the main project's node_modules.
+ ResolveDir: opts.resolveDir,
+ Contents: &c,
+ Loader: loaderFromFilename(args.Path),
+ }, nil
+ })
+ },
+ }
+
+ params := opts.Params
+ if params == nil {
+ // This way @params will always resolve to something.
+ params = make(map[string]any)
+ }
+
+ b, err := json.Marshal(params)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal params: %w", err)
+ }
+ bs := string(b)
+ paramsPlugin := api.Plugin{
+ Name: "hugo-params-plugin",
+ Setup: func(build api.PluginBuild) {
+ build.OnResolve(api.OnResolveOptions{Filter: `^@params$`},
+ func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ return api.OnResolveResult{
+ Path: args.Path,
+ Namespace: nsParams,
+ }, nil
+ })
+ build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: nsParams},
+ func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+ return api.OnLoadResult{
+ Contents: &bs,
+ Loader: api.LoaderJSON,
+ }, nil
+ })
+ },
+ }
+
+ return []api.Plugin{importResolver, paramsPlugin}, nil
+}
+
+func toBuildOptions(opts Options) (buildOptions api.BuildOptions, err error) {
+ var target api.Target
+ switch opts.Target {
+ case "", "esnext":
+ target = api.ESNext
+ case "es5":
+ target = api.ES5
+ case "es6", "es2015":
+ target = api.ES2015
+ case "es2016":
+ target = api.ES2016
+ case "es2017":
+ target = api.ES2017
+ case "es2018":
+ target = api.ES2018
+ case "es2019":
+ target = api.ES2019
+ case "es2020":
+ target = api.ES2020
+ default:
+ err = fmt.Errorf("invalid target: %q", opts.Target)
+ return
+ }
+
+ mediaType := opts.mediaType
+ if mediaType.IsZero() {
+ mediaType = media.JavascriptType
+ }
+
+ var loader api.Loader
+ switch mediaType.SubType {
+ // TODO(bep) ESBuild support a set of other loaders, but I currently fail
+ // to see the relevance. That may change as we start using this.
+ case media.JavascriptType.SubType:
+ loader = api.LoaderJS
+ case media.TypeScriptType.SubType:
+ loader = api.LoaderTS
+ case media.TSXType.SubType:
+ loader = api.LoaderTSX
+ case media.JSXType.SubType:
+ loader = api.LoaderJSX
+ default:
+ err = fmt.Errorf("unsupported Media Type: %q", opts.mediaType)
+ return
+ }
+
+ var format api.Format
+ // One of: iife, cjs, esm
+ switch opts.Format {
+ case "", "iife":
+ format = api.FormatIIFE
+ case "esm":
+ format = api.FormatESModule
+ case "cjs":
+ format = api.FormatCommonJS
+ default:
+ err = fmt.Errorf("unsupported script output format: %q", opts.Format)
+ return
+ }
+
+ var defines map[string]string
+ if opts.Defines != nil {
+ defines = maps.ToStringMapString(opts.Defines)
+ }
+
+ // By default we only need to specify outDir and no outFile
+ outDir := opts.outDir
+ outFile := ""
+ var sourceMap api.SourceMap
+ switch opts.SourceMap {
+ case "inline":
+ sourceMap = api.SourceMapInline
+ case "external":
+ sourceMap = api.SourceMapExternal
+ case "":
+ sourceMap = api.SourceMapNone
+ default:
+ err = fmt.Errorf("unsupported sourcemap type: %q", opts.SourceMap)
+ return
+ }
+
+ buildOptions = api.BuildOptions{
+ Outfile: outFile,
+ Bundle: true,
+
+ Target: target,
+ Format: format,
+ Sourcemap: sourceMap,
+
+ MinifyWhitespace: opts.Minify,
+ MinifyIdentifiers: opts.Minify,
+ MinifySyntax: opts.Minify,
+
+ Outdir: outDir,
+ Define: defines,
+
+ External: opts.Externals,
+
+ JSXFactory: opts.JSXFactory,
+ JSXFragment: opts.JSXFragment,
+
+ Tsconfig: opts.tsConfig,
+
+ // Note: We're not passing Sourcefile to ESBuild.
+ // This makes ESBuild pass `stdin` as the Importer to the import
+ // resolver, which is what we need/expect.
+ Stdin: &api.StdinOptions{
+ Contents: opts.contents,
+ ResolveDir: opts.resolveDir,
+ Loader: loader,
+ },
+ }
+ return
+}
diff --git a/resources/resource_transformers/js/options_test.go b/resources/resource_transformers/js/options_test.go
new file mode 100644
index 000000000..135164d18
--- /dev/null
+++ b/resources/resource_transformers/js/options_test.go
@@ -0,0 +1,184 @@
+// Copyright 2020 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 js
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/media"
+
+ "github.com/evanw/esbuild/pkg/api"
+
+ qt "github.com/frankban/quicktest"
+)
+
+// This test is added to test/warn against breaking the "stability" of the
+// cache key. It's sometimes needed to break this, but should be avoided if possible.
+func TestOptionKey(t *testing.T) {
+ c := qt.New(t)
+
+ opts := map[string]any{
+ "TargetPath": "foo",
+ "Target": "es2018",
+ }
+
+ key := (&buildTransformation{optsm: opts}).Key()
+
+ c.Assert(key.Value(), qt.Equals, "jsbuild_7891849149754191852")
+}
+
+func TestToBuildOptions(t *testing.T) {
+ c := qt.New(t)
+
+ opts, err := toBuildOptions(Options{mediaType: media.JavascriptType})
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ESNext,
+ Format: api.FormatIIFE,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts, err = toBuildOptions(Options{
+ Target: "es2018",
+ Format: "cjs",
+ Minify: true,
+ mediaType: media.JavascriptType,
+ AvoidTDZ: true,
+ })
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts, err = toBuildOptions(Options{
+ Target: "es2018", Format: "cjs", Minify: true, mediaType: media.JavascriptType,
+ SourceMap: "inline",
+ })
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Sourcemap: api.SourceMapInline,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts, err = toBuildOptions(Options{
+ Target: "es2018", Format: "cjs", Minify: true, mediaType: media.JavascriptType,
+ SourceMap: "inline",
+ })
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Sourcemap: api.SourceMapInline,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+
+ opts, err = toBuildOptions(Options{
+ Target: "es2018", Format: "cjs", Minify: true, mediaType: media.JavascriptType,
+ SourceMap: "external",
+ })
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts, qt.DeepEquals, api.BuildOptions{
+ Bundle: true,
+ Target: api.ES2018,
+ Format: api.FormatCommonJS,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ MinifyWhitespace: true,
+ Sourcemap: api.SourceMapExternal,
+ Stdin: &api.StdinOptions{
+ Loader: api.LoaderJS,
+ },
+ })
+}
+
+func TestResolveComponentInAssets(t *testing.T) {
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ name string
+ files []string
+ impPath string
+ expect string
+ }{
+ {"Basic, extension", []string{"foo.js", "bar.js"}, "foo.js", "foo.js"},
+ {"Basic, no extension", []string{"foo.js", "bar.js"}, "foo", "foo.js"},
+ {"Basic, no extension, typescript", []string{"foo.ts", "bar.js"}, "foo", "foo.ts"},
+ {"Not found", []string{"foo.js", "bar.js"}, "moo.js", ""},
+ {"Not found, double js extension", []string{"foo.js.js", "bar.js"}, "foo.js", ""},
+ {"Index file, folder only", []string{"foo/index.js", "bar.js"}, "foo", "foo/index.js"},
+ {"Index file, folder and index", []string{"foo/index.js", "bar.js"}, "foo/index", "foo/index.js"},
+ {"Index file, folder and index and suffix", []string{"foo/index.js", "bar.js"}, "foo/index.js", "foo/index.js"},
+ {"Index ESM file, folder only", []string{"foo/index.esm.js", "bar.js"}, "foo", "foo/index.esm.js"},
+ {"Index ESM file, folder and index", []string{"foo/index.esm.js", "bar.js"}, "foo/index", "foo/index.esm.js"},
+ {"Index ESM file, folder and index and suffix", []string{"foo/index.esm.js", "bar.js"}, "foo/index.esm.js", "foo/index.esm.js"},
+ // We added these index.esm.js cases in v0.101.0. The case below is unlikely to happen in the wild, but add a test
+ // to document Hugo's behavior. We pick the file with the name index.js; anything else would be breaking.
+ {"Index and Index ESM file, folder only", []string{"foo/index.esm.js", "foo/index.js", "bar.js"}, "foo", "foo/index.js"},
+
+ // Issue #8949
+ {"Check file before directory", []string{"foo.js", "foo/index.js"}, "foo", "foo.js"},
+ } {
+ c.Run(test.name, func(c *qt.C) {
+ baseDir := "assets"
+ mfs := afero.NewMemMapFs()
+
+ for _, filename := range test.files {
+ c.Assert(afero.WriteFile(mfs, filepath.Join(baseDir, filename), []byte("let foo='bar';"), 0777), qt.IsNil)
+ }
+
+ bfs := hugofs.DecorateBasePathFs(afero.NewBasePathFs(mfs, baseDir).(*afero.BasePathFs))
+
+ got := resolveComponentInAssets(bfs, test.impPath)
+
+ gotPath := ""
+ if got != nil {
+ gotPath = filepath.ToSlash(got.Path)
+ }
+
+ c.Assert(gotPath, qt.Equals, test.expect)
+ })
+
+ }
+}
diff --git a/resources/resource_transformers/minifier/integration_test.go b/resources/resource_transformers/minifier/integration_test.go
new file mode 100644
index 000000000..fb4cc7a65
--- /dev/null
+++ b/resources/resource_transformers/minifier/integration_test.go
@@ -0,0 +1,47 @@
+// Copyright 2021 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 minifier_test
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+// Issue 8954
+func TestTransformMinify(t *testing.T) {
+ c := qt.New(t)
+
+ files := `
+-- assets/js/test.js --
+new Date(2002, 04, 11)
+-- config.toml --
+-- layouts/index.html --
+{{ $js := resources.Get "js/test.js" | minify }}
+<script>
+{{ $js.Content }}
+</script>
+`
+
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: files,
+ },
+ ).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err, qt.ErrorMatches, "(?s).*legacy octal numbers.*line 1.*")
+}
diff --git a/resources/resource_transformers/minifier/minify.go b/resources/resource_transformers/minifier/minify.go
new file mode 100644
index 000000000..c00d478af
--- /dev/null
+++ b/resources/resource_transformers/minifier/minify.go
@@ -0,0 +1,59 @@
+// Copyright 2018 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 minifier
+
+import (
+ "github.com/gohugoio/hugo/minifiers"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/internal"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+// Client for minification of Resource objects. Supported minifiers are:
+// css, html, js, json, svg and xml.
+type Client struct {
+ rs *resources.Spec
+ m minifiers.Client
+}
+
+// New creates a new Client given a specification. Note that it is the media types
+// configured for the site that is used to match files to the correct minifier.
+func New(rs *resources.Spec) (*Client, error) {
+ m, err := minifiers.New(rs.MediaTypes, rs.OutputFormats, rs.Cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &Client{rs: rs, m: m}, nil
+}
+
+type minifyTransformation struct {
+ rs *resources.Spec
+ m minifiers.Client
+}
+
+func (t *minifyTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("minify")
+}
+
+func (t *minifyTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ ctx.AddOutPathIdentifier(".min")
+ return t.m.Minify(ctx.InMediaType, ctx.To, ctx.From)
+}
+
+func (c *Client) Minify(res resources.ResourceTransformer) (resource.Resource, error) {
+ return res.Transform(&minifyTransformation{
+ rs: c.rs,
+ m: c.m,
+ })
+}
diff --git a/resources/resource_transformers/minifier/minify_test.go b/resources/resource_transformers/minifier/minify_test.go
new file mode 100644
index 000000000..b0ebe3171
--- /dev/null
+++ b/resources/resource_transformers/minifier/minify_test.go
@@ -0,0 +1,42 @@
+// 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 minifier
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/resources/resource"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/resources/resource_transformers/htesting"
+)
+
+func TestTransform(t *testing.T) {
+ c := qt.New(t)
+
+ spec, err := htesting.NewTestResourceSpec()
+ c.Assert(err, qt.IsNil)
+ client, _ := New(spec)
+
+ r, err := htesting.NewResourceTransformerForSpec(spec, "hugo.html", "<h1> Hugo Rocks! </h1>")
+ c.Assert(err, qt.IsNil)
+
+ transformed, err := client.Minify(r)
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(transformed.RelPermalink(), qt.Equals, "/hugo.min.html")
+ content, err := transformed.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, qt.Equals, "<h1>Hugo Rocks!</h1>")
+}
diff --git a/resources/resource_transformers/postcss/integration_test.go b/resources/resource_transformers/postcss/integration_test.go
new file mode 100644
index 000000000..ab48297e4
--- /dev/null
+++ b/resources/resource_transformers/postcss/integration_test.go
@@ -0,0 +1,244 @@
+// Copyright 2021 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 postcss_test
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ jww "github.com/spf13/jwalterweatherman"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/htesting"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+const postCSSIntegrationTestFiles = `
+-- assets/css/components/a.css --
+/* A comment. */
+/* Another comment. */
+class-in-a {
+ color: blue;
+}
+
+-- assets/css/components/all.css --
+@import "a.css";
+@import "b.css";
+-- assets/css/components/b.css --
+@import "a.css";
+
+class-in-b {
+ color: blue;
+}
+
+-- assets/css/styles.css --
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+ @import "components/all.css";
+h1 {
+ @apply text-2xl font-bold;
+}
+
+-- config.toml --
+disablekinds = ['taxonomy', 'term', 'page']
+baseURL = "https://example.com"
+[build]
+useResourceCacheWhen = 'never'
+-- content/p1.md --
+-- data/hugo.toml --
+slogan = "Hugo Rocks!"
+-- i18n/en.yaml --
+hello:
+ other: "Hello"
+-- i18n/fr.yaml --
+hello:
+ other: "Bonjour"
+-- layouts/index.html --
+{{ $options := dict "inlineImports" true }}
+{{ $styles := resources.Get "css/styles.css" | resources.PostCSS $options }}
+Styles RelPermalink: {{ $styles.RelPermalink }}
+{{ $cssContent := $styles.Content }}
+Styles Content: Len: {{ len $styles.Content }}|
+-- package.json --
+{
+ "scripts": {},
+
+ "devDependencies": {
+ "postcss-cli": "7.1.0",
+ "tailwindcss": "1.2.0"
+ }
+}
+-- postcss.config.js --
+console.error("Hugo Environment:", process.env.HUGO_ENVIRONMENT );
+// https://github.com/gohugoio/hugo/issues/7656
+console.error("package.json:", process.env.HUGO_FILE_PACKAGE_JSON );
+console.error("PostCSS Config File:", process.env.HUGO_FILE_POSTCSS_CONFIG_JS );
+
+module.exports = {
+ plugins: [
+ require('tailwindcss')
+ ]
+}
+
+`
+
+func TestTransformPostCSS(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ c := qt.New(t)
+ tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test")
+ c.Assert(err, qt.IsNil)
+ c.Cleanup(clean)
+
+ for _, s := range []string{"never", "always"} {
+
+ repl := strings.NewReplacer(
+ "https://example.com",
+ "https://example.com/foo",
+ "useResourceCacheWhen = 'never'",
+ fmt.Sprintf("useResourceCacheWhen = '%s'", s),
+ )
+
+ files := repl.Replace(postCSSIntegrationTestFiles)
+
+ fmt.Println("===>", s, files)
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ LogLevel: jww.LevelInfo,
+ WorkingDir: tempDir,
+ TxtarString: files,
+ }).Build()
+
+ b.AssertFileContent("public/index.html", `
+Styles RelPermalink: /foo/css/styles.css
+Styles Content: Len: 770917|
+`)
+
+ }
+
+}
+
+// 9880
+func TestTransformPostCSSError(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ c := qt.New(t)
+
+ s, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, "color: blue;", "@apply foo;"), // Syntax error
+ }).BuildE()
+
+ s.AssertIsFileError(err)
+ c.Assert(err.Error(), qt.Contains, "a.css:4:2")
+
+}
+
+// #9895
+func TestTransformPostCSSImportError(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ c := qt.New(t)
+
+ s, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ LogLevel: jww.LevelInfo,
+ TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`),
+ }).BuildE()
+
+ s.AssertIsFileError(err)
+ c.Assert(err.Error(), qt.Contains, "styles.css:4:3")
+ c.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to resolve CSS @import "css/components/doesnotexist.css"`))
+
+}
+
+func TestTransformPostCSSImporSkipInlineImportsNotFound(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ c := qt.New(t)
+
+ files := strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`)
+ files = strings.ReplaceAll(files, `{{ $options := dict "inlineImports" true }}`, `{{ $options := dict "inlineImports" true "skipInlineImportsNotFound" true }}`)
+
+ s := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ LogLevel: jww.LevelInfo,
+ TxtarString: files,
+ }).Build()
+
+ s.AssertFileContent("public/css/styles.css", `@import "components/doesnotexist.css";`)
+
+}
+
+// Issue 9787
+func TestTransformPostCSSResourceCacheWithPathInBaseURL(t *testing.T) {
+ if !htesting.IsCI() {
+ t.Skip("Skip long running test when running locally")
+ }
+
+ c := qt.New(t)
+ tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test")
+ c.Assert(err, qt.IsNil)
+ c.Cleanup(clean)
+
+ for i := 0; i < 2; i++ {
+ files := postCSSIntegrationTestFiles
+
+ if i == 1 {
+ files = strings.ReplaceAll(files, "https://example.com", "https://example.com/foo")
+ files = strings.ReplaceAll(files, "useResourceCacheWhen = 'never'", " useResourceCacheWhen = 'always'")
+ }
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ NeedsOsFS: true,
+ NeedsNpmInstall: true,
+ LogLevel: jww.LevelInfo,
+ TxtarString: files,
+ WorkingDir: tempDir,
+ }).Build()
+
+ b.AssertFileContent("public/index.html", `
+Styles Content: Len: 770917
+`)
+
+ }
+
+}
diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/postcss/postcss.go
new file mode 100644
index 000000000..eab52b8c5
--- /dev/null
+++ b/resources/resource_transformers/postcss/postcss.go
@@ -0,0 +1,440 @@
+// Copyright 2018 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 postcss
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/collections"
+ "github.com/gohugoio/hugo/common/hexec"
+ "github.com/gohugoio/hugo/common/text"
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/gohugoio/hugo/common/hugo"
+
+ "github.com/gohugoio/hugo/common/loggers"
+
+ "github.com/gohugoio/hugo/resources/internal"
+ "github.com/spf13/afero"
+ "github.com/spf13/cast"
+
+ "errors"
+
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+const importIdentifier = "@import"
+
+var (
+ cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`)
+ shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`)
+)
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec) *Client {
+ return &Client{rs: rs}
+}
+
+func decodeOptions(m map[string]any) (opts Options, err error) {
+ if m == nil {
+ return
+ }
+ err = mapstructure.WeakDecode(m, &opts)
+
+ if !opts.NoMap {
+ // There was for a long time a discrepancy between documentation and
+ // implementation for the noMap property, so we need to support both
+ // camel and snake case.
+ opts.NoMap = cast.ToBool(m["no-map"])
+ }
+
+ return
+}
+
+// Client is the client used to do PostCSS transformations.
+type Client struct {
+ rs *resources.Spec
+}
+
+// Process transforms the given Resource with the PostCSS processor.
+func (c *Client) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) {
+ return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options})
+}
+
+// Some of the options from https://github.com/postcss/postcss-cli
+type Options struct {
+
+ // Set a custom path to look for a config file.
+ Config string
+
+ NoMap bool // Disable the default inline sourcemaps
+
+ // Enable inlining of @import statements.
+ // Does so recursively, but currently once only per file;
+ // that is, it's not possible to import the same file in
+ // different scopes (root, media query...)
+ // Note that this import routine does not care about the CSS spec,
+ // so you can have @import anywhere in the file.
+ InlineImports bool
+
+ // When InlineImports is enabled, we fail the build if an import cannot be resolved.
+ // You can enable this to allow the build to continue and leave the import statement in place.
+ // Note that the inline importer does not process url location or imports with media queries,
+ // so those will be left as-is even without enabling this option.
+ SkipInlineImportsNotFound bool
+
+ // Options for when not using a config file
+ Use string // List of postcss plugins to use
+ Parser string // Custom postcss parser
+ Stringifier string // Custom postcss stringifier
+ Syntax string // Custom postcss syntax
+}
+
+func (opts Options) toArgs() []string {
+ var args []string
+ if opts.NoMap {
+ args = append(args, "--no-map")
+ }
+ if opts.Use != "" {
+ args = append(args, "--use")
+ args = append(args, strings.Fields(opts.Use)...)
+ }
+ if opts.Parser != "" {
+ args = append(args, "--parser", opts.Parser)
+ }
+ if opts.Stringifier != "" {
+ args = append(args, "--stringifier", opts.Stringifier)
+ }
+ if opts.Syntax != "" {
+ args = append(args, "--syntax", opts.Syntax)
+ }
+ return args
+}
+
+type postcssTransformation struct {
+ optionsm map[string]any
+ rs *resources.Spec
+}
+
+func (t *postcssTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("postcss", t.optionsm)
+}
+
+// Transform shells out to postcss-cli to do the heavy lifting.
+// For this to work, you need some additional tools. To install them globally:
+// npm install -g postcss-cli
+// npm install -g autoprefixer
+func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ const binaryName = "postcss"
+
+ ex := t.rs.ExecHelper
+
+ var configFile string
+ logger := t.rs.Logger
+
+ var options Options
+ if t.optionsm != nil {
+ var err error
+ options, err = decodeOptions(t.optionsm)
+ if err != nil {
+ return err
+ }
+ }
+
+ if options.Config != "" {
+ configFile = options.Config
+ } else {
+ configFile = "postcss.config.js"
+ }
+
+ configFile = filepath.Clean(configFile)
+
+ // We need an absolute filename to the config file.
+ if !filepath.IsAbs(configFile) {
+ configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile)
+ if configFile == "" && options.Config != "" {
+ // Only fail if the user specified config file is not found.
+ return fmt.Errorf("postcss config %q not found:", options.Config)
+ }
+ }
+
+ var cmdArgs []any
+
+ if configFile != "" {
+ logger.Infoln("postcss: use config file", configFile)
+ cmdArgs = []any{"--config", configFile}
+ }
+
+ if optArgs := options.toArgs(); len(optArgs) > 0 {
+ cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...)
+ }
+
+ var errBuf bytes.Buffer
+ infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "postcss")
+
+ stderr := io.MultiWriter(infoW, &errBuf)
+ cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
+ cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+ cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
+
+ cmd, err := ex.Npx(binaryName, cmdArgs...)
+ if err != nil {
+ if hexec.IsNotFound(err) {
+ // This may be on a CI server etc. Will fall back to pre-built assets.
+ return herrors.ErrFeatureNotAvailable
+ }
+ return err
+ }
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+
+ src := ctx.From
+
+ imp := newImportResolver(
+ ctx.From,
+ ctx.InPath,
+ options,
+ t.rs.Assets.Fs, t.rs.Logger,
+ )
+
+ if options.InlineImports {
+ var err error
+ src, err = imp.resolve()
+ if err != nil {
+ return err
+ }
+ }
+
+ go func() {
+ defer stdin.Close()
+ io.Copy(stdin, src)
+ }()
+
+ err = cmd.Run()
+ if err != nil {
+ if hexec.IsNotFound(err) {
+ return herrors.ErrFeatureNotAvailable
+ }
+ return imp.toFileError(errBuf.String())
+ }
+
+ return nil
+}
+
+type fileOffset struct {
+ Filename string
+ Offset int
+}
+
+type importResolver struct {
+ r io.Reader
+ inPath string
+ opts Options
+
+ contentSeen map[string]bool
+ linemap map[int]fileOffset
+ fs afero.Fs
+ logger loggers.Logger
+}
+
+func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger) *importResolver {
+ return &importResolver{
+ r: r,
+ inPath: inPath,
+ fs: fs, logger: logger,
+ linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
+ opts: opts,
+ }
+}
+
+func (imp *importResolver) contentHash(filename string) ([]byte, string) {
+ b, err := afero.ReadFile(imp.fs, filename)
+ if err != nil {
+ return nil, ""
+ }
+ h := sha256.New()
+ h.Write(b)
+ return b, hex.EncodeToString(h.Sum(nil))
+}
+
+func (imp *importResolver) importRecursive(
+ lineNum int,
+ content string,
+ inPath string) (int, string, error) {
+ basePath := path.Dir(inPath)
+
+ var replacements []string
+ lines := strings.Split(content, "\n")
+
+ trackLine := func(i, offset int, line string) {
+ // TODO(bep) this is not very efficient.
+ imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
+ }
+
+ i := 0
+ for offset, line := range lines {
+ i++
+ lineTrimmed := strings.TrimSpace(line)
+ column := strings.Index(line, lineTrimmed)
+ line = lineTrimmed
+
+ if !imp.shouldImport(line) {
+ trackLine(i, offset, line)
+ } else {
+ path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
+ filename := filepath.Join(basePath, path)
+ importContent, hash := imp.contentHash(filename)
+
+ if importContent == nil {
+ if imp.opts.SkipInlineImportsNotFound {
+ trackLine(i, offset, line)
+ continue
+ }
+ pos := text.Position{
+ Filename: inPath,
+ LineNumber: offset + 1,
+ ColumnNumber: column + 1,
+ }
+ return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil)
+ }
+
+ i--
+
+ if imp.contentSeen[hash] {
+ i++
+ // Just replace the line with an empty string.
+ replacements = append(replacements, []string{line, ""}...)
+ trackLine(i, offset, "IMPORT")
+ continue
+ }
+
+ imp.contentSeen[hash] = true
+
+ // Handle recursive imports.
+ l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
+ if err != nil {
+ return 0, "", err
+ }
+
+ trackLine(i, offset, line)
+
+ i += l
+
+ importContent = []byte(nested)
+
+ replacements = append(replacements, []string{line, string(importContent)}...)
+ }
+ }
+
+ if len(replacements) > 0 {
+ repl := strings.NewReplacer(replacements...)
+ content = repl.Replace(content)
+ }
+
+ return i, content, nil
+}
+
+func (imp *importResolver) resolve() (io.Reader, error) {
+ const importIdentifier = "@import"
+
+ content, err := ioutil.ReadAll(imp.r)
+ if err != nil {
+ return nil, err
+ }
+
+ contents := string(content)
+
+ _, newContent, err := imp.importRecursive(0, contents, imp.inPath)
+ if err != nil {
+ return nil, err
+ }
+
+ return strings.NewReader(newContent), nil
+}
+
+// See https://www.w3schools.com/cssref/pr_import_rule.asp
+// We currently only support simple file imports, no urls, no media queries.
+// So this is OK:
+// @import "navigation.css";
+// This is not:
+// @import url("navigation.css");
+// @import "mobstyle.css" screen and (max-width: 768px);
+func (imp *importResolver) shouldImport(s string) bool {
+ if !strings.HasPrefix(s, importIdentifier) {
+ return false
+ }
+ if strings.Contains(s, "url(") {
+ return false
+ }
+
+ return shouldImportRe.MatchString(s)
+}
+
+func (imp *importResolver) toFileError(output string) error {
+ output = strings.TrimSpace(loggers.RemoveANSIColours(output))
+ inErr := errors.New(output)
+
+ match := cssSyntaxErrorRe.FindStringSubmatch(output)
+ if match == nil {
+ return inErr
+ }
+
+ lineNum, err := strconv.Atoi(match[1])
+ if err != nil {
+ return inErr
+ }
+
+ file, ok := imp.linemap[lineNum]
+ if !ok {
+ return inErr
+ }
+
+ fi, err := imp.fs.Stat(file.Filename)
+ if err != nil {
+ return inErr
+ }
+
+ meta := fi.(hugofs.FileMetaInfo).Meta()
+ realFilename := meta.Filename
+ f, err := meta.Open()
+ if err != nil {
+ return inErr
+ }
+ defer f.Close()
+
+ ferr := herrors.NewFileErrorFromName(inErr, realFilename)
+ pos := ferr.Position()
+ pos.LineNumber = file.Offset + 1
+ return ferr.UpdatePosition(pos).UpdateContent(f, nil)
+
+ //return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher)
+
+}
diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/postcss/postcss_test.go
new file mode 100644
index 000000000..6901d69de
--- /dev/null
+++ b/resources/resource_transformers/postcss/postcss_test.go
@@ -0,0 +1,166 @@
+// Copyright 2020 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 postcss
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/gohugoio/hugo/htesting/hqt"
+
+ "github.com/gohugoio/hugo/common/loggers"
+ "github.com/gohugoio/hugo/helpers"
+
+ "github.com/spf13/afero"
+
+ qt "github.com/frankban/quicktest"
+)
+
+// Issue 6166
+func TestDecodeOptions(t *testing.T) {
+ c := qt.New(t)
+ opts1, err := decodeOptions(map[string]any{
+ "no-map": true,
+ })
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts1.NoMap, qt.Equals, true)
+
+ opts2, err := decodeOptions(map[string]any{
+ "noMap": true,
+ })
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(opts2.NoMap, qt.Equals, true)
+}
+
+func TestShouldImport(t *testing.T) {
+ c := qt.New(t)
+ var imp *importResolver
+
+ for _, test := range []struct {
+ input string
+ expect bool
+ }{
+ {input: `@import "navigation.css";`, expect: true},
+ {input: `@import "navigation.css"; /* Using a string */`, expect: true},
+ {input: `@import "navigation.css"`, expect: true},
+ {input: `@import 'navigation.css';`, expect: true},
+ {input: `@import url("navigation.css");`, expect: false},
+ {input: `@import url('https://fonts.googleapis.com/css?family=Open+Sans:400,400i,800,800i&display=swap');`, expect: false},
+ {input: `@import "printstyle.css" print;`, expect: false},
+ } {
+ c.Assert(imp.shouldImport(test.input), qt.Equals, test.expect)
+ }
+}
+
+func TestImportResolver(t *testing.T) {
+ c := qt.New(t)
+ fs := afero.NewMemMapFs()
+
+ writeFile := func(name, content string) {
+ c.Assert(afero.WriteFile(fs, name, []byte(content), 0777), qt.IsNil)
+ }
+
+ writeFile("a.css", `@import "b.css";
+@import "c.css";
+A_STYLE1
+A_STYLE2
+`)
+
+ writeFile("b.css", `B_STYLE`)
+ writeFile("c.css", "@import \"d.css\"\nC_STYLE")
+ writeFile("d.css", "@import \"a.css\"\n\nD_STYLE")
+ writeFile("e.css", "E_STYLE")
+
+ mainStyles := strings.NewReader(`@import "a.css";
+@import "b.css";
+LOCAL_STYLE
+@import "c.css";
+@import "e.css";`)
+
+ imp := newImportResolver(
+ mainStyles,
+ "styles.css",
+ Options{},
+ fs, loggers.NewErrorLogger(),
+ )
+
+ r, err := imp.resolve()
+ c.Assert(err, qt.IsNil)
+ rs := helpers.ReaderToString(r)
+ result := regexp.MustCompile(`\n+`).ReplaceAllString(rs, "\n")
+
+ c.Assert(result, hqt.IsSameString, `B_STYLE
+D_STYLE
+C_STYLE
+A_STYLE1
+A_STYLE2
+LOCAL_STYLE
+E_STYLE`)
+
+ dline := imp.linemap[3]
+ c.Assert(dline, qt.DeepEquals, fileOffset{
+ Offset: 1,
+ Filename: "d.css",
+ })
+}
+
+func BenchmarkImportResolver(b *testing.B) {
+ c := qt.New(b)
+ fs := afero.NewMemMapFs()
+
+ writeFile := func(name, content string) {
+ c.Assert(afero.WriteFile(fs, name, []byte(content), 0777), qt.IsNil)
+ }
+
+ writeFile("a.css", `@import "b.css";
+@import "c.css";
+A_STYLE1
+A_STYLE2
+`)
+
+ writeFile("b.css", `B_STYLE`)
+ writeFile("c.css", "@import \"d.css\"\nC_STYLE"+strings.Repeat("\nSTYLE", 12))
+ writeFile("d.css", "@import \"a.css\"\n\nD_STYLE"+strings.Repeat("\nSTYLE", 55))
+ writeFile("e.css", "E_STYLE")
+
+ mainStyles := `@import "a.css";
+@import "b.css";
+LOCAL_STYLE
+@import "c.css";
+@import "e.css";
+@import "missing.css";`
+
+ logger := loggers.NewErrorLogger()
+
+ for i := 0; i < b.N; i++ {
+ b.StopTimer()
+ imp := newImportResolver(
+ strings.NewReader(mainStyles),
+ "styles.css",
+ Options{},
+ fs, logger,
+ )
+
+ b.StartTimer()
+
+ _, err := imp.resolve()
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ }
+}
diff --git a/resources/resource_transformers/templates/execute_as_template.go b/resources/resource_transformers/templates/execute_as_template.go
new file mode 100644
index 000000000..5fe4230f1
--- /dev/null
+++ b/resources/resource_transformers/templates/execute_as_template.go
@@ -0,0 +1,74 @@
+// Copyright 2018 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 templates contains functions for template processing of Resource objects.
+package templates
+
+import (
+ "fmt"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/internal"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/gohugoio/hugo/tpl"
+)
+
+// Client contains methods to perform template processing of Resource objects.
+type Client struct {
+ rs *resources.Spec
+ t tpl.TemplatesProvider
+}
+
+// New creates a new Client with the given specification.
+func New(rs *resources.Spec, t tpl.TemplatesProvider) *Client {
+ if rs == nil {
+ panic("must provice a resource Spec")
+ }
+ if t == nil {
+ panic("must provide a template provider")
+ }
+ return &Client{rs: rs, t: t}
+}
+
+type executeAsTemplateTransform struct {
+ rs *resources.Spec
+ t tpl.TemplatesProvider
+ targetPath string
+ data any
+}
+
+func (t *executeAsTemplateTransform) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey("execute-as-template", t.targetPath)
+}
+
+func (t *executeAsTemplateTransform) Transform(ctx *resources.ResourceTransformationCtx) error {
+ tplStr := helpers.ReaderToString(ctx.From)
+ templ, err := t.t.TextTmpl().Parse(ctx.InPath, tplStr)
+ if err != nil {
+ return fmt.Errorf("failed to parse Resource %q as Template:: %w", ctx.InPath, err)
+ }
+
+ ctx.OutPath = t.targetPath
+
+ return t.t.Tmpl().Execute(templ, ctx.To, t.data)
+}
+
+func (c *Client) ExecuteAsTemplate(res resources.ResourceTransformer, targetPath string, data any) (resource.Resource, error) {
+ return res.Transform(&executeAsTemplateTransform{
+ rs: c.rs,
+ targetPath: helpers.ToSlashTrimLeading(targetPath),
+ t: c.t,
+ data: data,
+ })
+}
diff --git a/resources/resource_transformers/templates/integration_test.go b/resources/resource_transformers/templates/integration_test.go
new file mode 100644
index 000000000..4eaac8e27
--- /dev/null
+++ b/resources/resource_transformers/templates/integration_test.go
@@ -0,0 +1,77 @@
+// Copyright 2021 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 templates_test
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestExecuteAsTemplateMultipleLanguages(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- config.toml --
+baseURL = "http://example.com/blog"
+defaultContentLanguage = "fr"
+defaultContentLanguageInSubdir = true
+[Languages]
+[Languages.en]
+weight = 10
+title = "In English"
+languageName = "English"
+[Languages.fr]
+weight = 20
+title = "Le Français"
+languageName = "Français"
+-- i18n/en.toml --
+[hello]
+other = "Hello"
+-- i18n/fr.toml --
+[hello]
+other = "Bonjour"
+-- layouts/index.fr.html --
+Lang: {{ site.Language.Lang }}
+{{ $templ := "{{T \"hello\"}}" | resources.FromString "f1.html" }}
+{{ $helloResource := $templ | resources.ExecuteAsTemplate (print "f%s.html" .Lang) . }}
+Hello1: {{T "hello"}}
+Hello2: {{ $helloResource.Content }}
+LangURL: {{ relLangURL "foo" }}
+-- layouts/index.html --
+Lang: {{ site.Language.Lang }}
+{{ $templ := "{{T \"hello\"}}" | resources.FromString "f1.html" }}
+{{ $helloResource := $templ | resources.ExecuteAsTemplate (print "f%s.html" .Lang) . }}
+Hello1: {{T "hello"}}
+Hello2: {{ $helloResource.Content }}
+LangURL: {{ relLangURL "foo" }}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ }).Build()
+
+ b.AssertFileContent("public/en/index.html", `
+ Hello1: Hello
+ Hello2: Hello
+ `)
+
+ b.AssertFileContent("public/fr/index.html", `
+ Hello1: Bonjour
+ Hello2: Bonjour
+ `)
+}
diff --git a/resources/resource_transformers/tocss/dartsass/client.go b/resources/resource_transformers/tocss/dartsass/client.go
new file mode 100644
index 000000000..7c3a7ecba
--- /dev/null
+++ b/resources/resource_transformers/tocss/dartsass/client.go
@@ -0,0 +1,143 @@
+// Copyright 2020 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 godartsass integrates with the Dass Sass Embedded protocol to transpile
+// SCSS/SASS.
+package dartsass
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/hugolib/filesystems"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/spf13/afero"
+
+ "github.com/bep/godartsass"
+ "github.com/mitchellh/mapstructure"
+)
+
+// used as part of the cache key.
+const transformationName = "tocss-dart"
+
+// See https://github.com/sass/dart-sass-embedded/issues/24
+// Note: This prefix must be all lower case.
+const dartSassStdinPrefix = "hugostdin:"
+
+func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) (*Client, error) {
+ if !Supports() {
+ return &Client{dartSassNotAvailable: true}, nil
+ }
+
+ if err := rs.ExecHelper.Sec().CheckAllowedExec(dartSassEmbeddedBinaryName); err != nil {
+ return nil, err
+ }
+
+ transpiler, err := godartsass.Start(godartsass.Options{
+ LogEventHandler: func(event godartsass.LogEvent) {
+ message := strings.ReplaceAll(event.Message, dartSassStdinPrefix, "")
+ switch event.Type {
+ case godartsass.LogEventTypeDebug:
+ // Log as Info for now, we may adjust this if it gets too chatty.
+ rs.Logger.Infof("Dart Sass: %s", message)
+ default:
+ // The rest are either deprecations or @warn statements.
+ rs.Logger.Warnf("Dart Sass: %s", message)
+ }
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &Client{sfs: fs, workFs: rs.BaseFs.Work, rs: rs, transpiler: transpiler}, nil
+}
+
+type Client struct {
+ dartSassNotAvailable bool
+ rs *resources.Spec
+ sfs *filesystems.SourceFilesystem
+ workFs afero.Fs
+ transpiler *godartsass.Transpiler
+}
+
+func (c *Client) ToCSS(res resources.ResourceTransformer, args map[string]any) (resource.Resource, error) {
+ if c.dartSassNotAvailable {
+ return res.Transform(resources.NewFeatureNotAvailableTransformer(transformationName, args))
+ }
+ return res.Transform(&transform{c: c, optsm: args})
+}
+
+func (c *Client) Close() error {
+ if c.transpiler == nil {
+ return nil
+ }
+ return c.transpiler.Close()
+}
+
+func (c *Client) toCSS(args godartsass.Args, src io.Reader) (godartsass.Result, error) {
+ var res godartsass.Result
+
+ in := helpers.ReaderToString(src)
+ args.Source = in
+
+ res, err := c.transpiler.Execute(args)
+ if err != nil {
+ if err.Error() == "unexpected EOF" {
+ return res, fmt.Errorf("got unexpected EOF when executing %q. The user running hugo must have read and execute permissions on this program. With execute permissions only, this error is thrown.", dartSassEmbeddedBinaryName)
+ }
+ return res, herrors.NewFileErrorFromFileInErr(err, hugofs.Os, herrors.OffsetMatcher)
+ }
+
+ return res, err
+}
+
+type Options struct {
+
+ // Hugo, will by default, just replace the extension of the source
+ // to .css, e.g. "scss/main.scss" becomes "scss/main.css". You can
+ // control this by setting this, e.g. "styles/main.css" will create
+ // a Resource with that as a base for RelPermalink etc.
+ TargetPath string
+
+ // Hugo automatically adds the entry directories (where the main.scss lives)
+ // for project and themes to the list of include paths sent to LibSASS.
+ // Any paths set in this setting will be appended. Note that these will be
+ // treated as relative to the working dir, i.e. no include paths outside the
+ // project/themes.
+ IncludePaths []string
+
+ // Default is nested.
+ // One of nested, expanded, compact, compressed.
+ OutputStyle string
+
+ // When enabled, Hugo will generate a source map.
+ EnableSourceMap bool
+}
+
+func decodeOptions(m map[string]any) (opts Options, err error) {
+ if m == nil {
+ return
+ }
+ err = mapstructure.WeakDecode(m, &opts)
+
+ if opts.TargetPath != "" {
+ opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
+ }
+
+ return
+}
diff --git a/resources/resource_transformers/tocss/dartsass/integration_test.go b/resources/resource_transformers/tocss/dartsass/integration_test.go
new file mode 100644
index 000000000..c127057a5
--- /dev/null
+++ b/resources/resource_transformers/tocss/dartsass/integration_test.go
@@ -0,0 +1,273 @@
+// Copyright 2021 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 dartsass_test
+
+import (
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/hugolib"
+ "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
+ jww "github.com/spf13/jwalterweatherman"
+)
+
+func TestTransformIncludePaths(t *testing.T) {
+ if !dartsass.Supports() {
+ t.Skip()
+ }
+
+ files := `
+-- assets/scss/main.scss --
+@import "moo";
+-- node_modules/foo/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- config.toml --
+-- layouts/index.html --
+{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo") "transpiler" "dartsass" ) }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ b.AssertFileContent("public/index.html", `T1: moo{color:#fff}`)
+}
+
+func TestTransformImportRegularCSS(t *testing.T) {
+ if !dartsass.Supports() {
+ t.Skip()
+ }
+
+ files := `
+-- assets/scss/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- assets/scss/another.css --
+
+-- assets/scss/main.scss --
+@import "moo";
+@import "regular.css";
+@import "moo";
+@import "another.css";
+
+/* foo */
+-- assets/scss/regular.css --
+
+-- config.toml --
+-- layouts/index.html --
+{{ $r := resources.Get "scss/main.scss" | toCSS (dict "transpiler" "dartsass") }}
+T1: {{ $r.Content | safeHTML }}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ },
+ ).Build()
+
+ // Dart Sass does not follow regular CSS import, but they
+ // get pulled to the top.
+ b.AssertFileContent("public/index.html", `T1: @import "regular.css";
+ @import "another.css";
+ moo {
+ color: #fff;
+ }
+
+ moo {
+ color: #fff;
+ }
+
+ /* foo */`)
+}
+
+func TestTransformThemeOverrides(t *testing.T) {
+ if !dartsass.Supports() {
+ t.Skip()
+ }
+
+ files := `
+-- assets/scss/components/_boo.scss --
+$boolor: green;
+
+boo {
+ color: $boolor;
+}
+-- assets/scss/components/_moo.scss --
+$moolor: #ccc;
+
+moo {
+ color: $moolor;
+}
+-- config.toml --
+theme = 'mytheme'
+-- layouts/index.html --
+{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo" ) "transpiler" "dartsass" ) }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+-- themes/mytheme/assets/scss/components/_boo.scss --
+$boolor: orange;
+
+boo {
+ color: $boolor;
+}
+-- themes/mytheme/assets/scss/components/_imports.scss --
+@import "moo";
+@import "_boo";
+@import "_zoo";
+-- themes/mytheme/assets/scss/components/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- themes/mytheme/assets/scss/components/_zoo.scss --
+$zoolor: pink;
+
+zoo {
+ color: $zoolor;
+}
+-- themes/mytheme/assets/scss/main.scss --
+@import "components/imports";
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ },
+ ).Build()
+
+ b.AssertFileContent("public/index.html", `T1: moo{color:#ccc}boo{color:green}zoo{color:pink}`)
+}
+
+func TestTransformLogging(t *testing.T) {
+ if !dartsass.Supports() {
+ t.Skip()
+ }
+
+ files := `
+-- assets/scss/main.scss --
+@warn "foo";
+@debug "bar";
+
+-- config.toml --
+disableKinds = ["term", "taxonomy", "section", "page"]
+-- layouts/index.html --
+{{ $cssOpts := (dict "transpiler" "dartsass" ) }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts }}
+T1: {{ $r.Content }}
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: t,
+ TxtarString: files,
+ NeedsOsFS: true,
+ LogLevel: jww.LevelInfo,
+ }).Build()
+
+ b.AssertLogMatches(`WARN.*Dart Sass: foo`)
+ b.AssertLogMatches(`INFO.*Dart Sass: .*assets.*main.scss:1:0: bar`)
+
+}
+
+func TestTransformErrors(t *testing.T) {
+ if !dartsass.Supports() {
+ t.Skip()
+ }
+
+ c := qt.New(t)
+
+ const filesTemplate = `
+-- config.toml --
+-- assets/scss/components/_foo.scss --
+/* comment line 1 */
+$foocolor: #ccc;
+
+foo {
+ color: $foocolor;
+}
+-- assets/scss/main.scss --
+/* comment line 1 */
+/* comment line 2 */
+@import "components/foo";
+/* comment line 4 */
+
+ $maincolor: #eee;
+
+body {
+ color: $maincolor;
+}
+
+-- layouts/index.html --
+{{ $cssOpts := dict "transpiler" "dartsass" }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+
+ `
+
+ c.Run("error in main", func(c *qt.C) {
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: strings.Replace(filesTemplate, "$maincolor: #eee;", "$maincolor #eee;", 1),
+ NeedsOsFS: true,
+ }).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `main.scss:8:13":`)
+ b.Assert(err.Error(), qt.Contains, `: expected ":".`)
+ fe := b.AssertIsFileError(err)
+ b.Assert(fe.ErrorContext(), qt.IsNotNil)
+ b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{" $maincolor #eee;", "", "body {", "\tcolor: $maincolor;", "}"})
+ b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
+
+ })
+
+ c.Run("error in import", func(c *qt.C) {
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: strings.Replace(filesTemplate, "$foocolor: #ccc;", "$foocolor #ccc;", 1),
+ NeedsOsFS: true,
+ }).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `_foo.scss:2:10":`)
+ b.Assert(err.Error(), qt.Contains, `: expected ":".`)
+ fe := b.AssertIsFileError(err)
+ b.Assert(fe.ErrorContext(), qt.IsNotNil)
+ b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"})
+ b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
+
+ })
+
+}
diff --git a/resources/resource_transformers/tocss/dartsass/transform.go b/resources/resource_transformers/tocss/dartsass/transform.go
new file mode 100644
index 000000000..9d17d3bcc
--- /dev/null
+++ b/resources/resource_transformers/tocss/dartsass/transform.go
@@ -0,0 +1,182 @@
+// Copyright 2020 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 dartsass
+
+import (
+ "fmt"
+ "io"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/common/hexec"
+ "github.com/gohugoio/hugo/common/paths"
+ "github.com/gohugoio/hugo/htesting"
+ "github.com/gohugoio/hugo/media"
+
+ "github.com/gohugoio/hugo/resources"
+
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/bep/godartsass"
+)
+
+const (
+ dartSassEmbeddedBinaryName = "dart-sass-embedded"
+)
+
+// Supports returns whether dart-sass-embedded is found in $PATH.
+func Supports() bool {
+ if htesting.SupportsAll() {
+ return true
+ }
+ return hexec.InPath(dartSassEmbeddedBinaryName)
+}
+
+type transform struct {
+ optsm map[string]any
+ c *Client
+}
+
+func (t *transform) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey(transformationName, t.optsm)
+}
+
+func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
+ ctx.OutMediaType = media.CSSType
+
+ opts, err := decodeOptions(t.optsm)
+ if err != nil {
+ return err
+ }
+
+ if opts.TargetPath != "" {
+ ctx.OutPath = opts.TargetPath
+ } else {
+ ctx.ReplaceOutPathExtension(".css")
+ }
+
+ baseDir := path.Dir(ctx.SourcePath)
+ filename := dartSassStdinPrefix
+
+ if ctx.SourcePath != "" {
+ filename += t.c.sfs.RealFilename(ctx.SourcePath)
+ }
+
+ args := godartsass.Args{
+ URL: filename,
+ IncludePaths: t.c.sfs.RealDirs(baseDir),
+ ImportResolver: importResolver{
+ baseDir: baseDir,
+ c: t.c,
+ },
+ OutputStyle: godartsass.ParseOutputStyle(opts.OutputStyle),
+ EnableSourceMap: opts.EnableSourceMap,
+ }
+
+ // Append any workDir relative include paths
+ for _, ip := range opts.IncludePaths {
+ info, err := t.c.workFs.Stat(filepath.Clean(ip))
+ if err == nil {
+ filename := info.(hugofs.FileMetaInfo).Meta().Filename
+ args.IncludePaths = append(args.IncludePaths, filename)
+ }
+ }
+
+ if ctx.InMediaType.SubType == media.SASSType.SubType {
+ args.SourceSyntax = godartsass.SourceSyntaxSASS
+ }
+
+ res, err := t.c.toCSS(args, ctx.From)
+ if err != nil {
+ return err
+ }
+
+ out := res.CSS
+
+ _, err = io.WriteString(ctx.To, out)
+ if err != nil {
+ return err
+ }
+
+ if opts.EnableSourceMap && res.SourceMap != "" {
+ if err := ctx.PublishSourceMap(res.SourceMap); err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map")
+ }
+
+ return err
+}
+
+type importResolver struct {
+ baseDir string
+ c *Client
+}
+
+func (t importResolver) CanonicalizeURL(url string) (string, error) {
+ filePath, isURL := paths.UrlToFilename(url)
+ var prevDir string
+ var pathDir string
+ if isURL {
+ var found bool
+ prevDir, found = t.c.sfs.MakePathRelative(filepath.Dir(filePath))
+
+ if !found {
+ // Not a member of this filesystem, let Dart Sass handle it.
+ return "", nil
+ }
+ } else {
+ prevDir = t.baseDir
+ pathDir = path.Dir(url)
+ }
+
+ basePath := filepath.Join(prevDir, pathDir)
+ name := filepath.Base(filePath)
+
+ // Pick the first match.
+ var namePatterns []string
+ if strings.Contains(name, ".") {
+ namePatterns = []string{"_%s", "%s"}
+ } else if strings.HasPrefix(name, "_") {
+ namePatterns = []string{"_%s.scss", "_%s.sass"}
+ } else {
+ namePatterns = []string{"_%s.scss", "%s.scss", "_%s.sass", "%s.sass"}
+ }
+
+ name = strings.TrimPrefix(name, "_")
+
+ for _, namePattern := range namePatterns {
+ filenameToCheck := filepath.Join(basePath, fmt.Sprintf(namePattern, name))
+ fi, err := t.c.sfs.Fs.Stat(filenameToCheck)
+ if err == nil {
+ if fim, ok := fi.(hugofs.FileMetaInfo); ok {
+ return "file://" + filepath.ToSlash(fim.Meta().Filename), nil
+ }
+ }
+ }
+
+ // Not found, let Dart Dass handle it
+ return "", nil
+}
+
+func (t importResolver) Load(url string) (string, error) {
+ filename, _ := paths.UrlToFilename(url)
+ b, err := afero.ReadFile(hugofs.Os, filename)
+ return string(b), err
+}
diff --git a/resources/resource_transformers/tocss/scss/client.go b/resources/resource_transformers/tocss/scss/client.go
new file mode 100644
index 000000000..ecaceaa6c
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/client.go
@@ -0,0 +1,90 @@
+// Copyright 2018 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 scss
+
+import (
+ "regexp"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugolib/filesystems"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/spf13/afero"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+const transformationName = "tocss"
+
+type Client struct {
+ rs *resources.Spec
+ sfs *filesystems.SourceFilesystem
+ workFs afero.Fs
+}
+
+func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) (*Client, error) {
+ return &Client{sfs: fs, workFs: rs.BaseFs.Work, rs: rs}, nil
+}
+
+type Options struct {
+
+ // Hugo, will by default, just replace the extension of the source
+ // to .css, e.g. "scss/main.scss" becomes "scss/main.css". You can
+ // control this by setting this, e.g. "styles/main.css" will create
+ // a Resource with that as a base for RelPermalink etc.
+ TargetPath string
+
+ // Hugo automatically adds the entry directories (where the main.scss lives)
+ // for project and themes to the list of include paths sent to LibSASS.
+ // Any paths set in this setting will be appended. Note that these will be
+ // treated as relative to the working dir, i.e. no include paths outside the
+ // project/themes.
+ IncludePaths []string
+
+ // Default is nested.
+ // One of nested, expanded, compact, compressed.
+ OutputStyle string
+
+ // Precision of floating point math.
+ Precision int
+
+ // When enabled, Hugo will generate a source map.
+ EnableSourceMap bool
+}
+
+func DecodeOptions(m map[string]any) (opts Options, err error) {
+ if m == nil {
+ return
+ }
+ err = mapstructure.WeakDecode(m, &opts)
+
+ if opts.TargetPath != "" {
+ opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
+ }
+
+ return
+}
+
+var (
+ regularCSSImportTo = regexp.MustCompile(`.*(@import "(.*\.css)";).*`)
+ regularCSSImportFrom = regexp.MustCompile(`.*(\/\* HUGO_IMPORT_START (.*) HUGO_IMPORT_END \*\/).*`)
+)
+
+func replaceRegularImportsIn(s string) (string, bool) {
+ replaced := regularCSSImportTo.ReplaceAllString(s, "/* HUGO_IMPORT_START $2 HUGO_IMPORT_END */")
+ return replaced, s != replaced
+}
+
+func replaceRegularImportsOut(s string) string {
+ return regularCSSImportFrom.ReplaceAllString(s, "@import \"$2\";")
+}
diff --git a/resources/resource_transformers/tocss/scss/client_extended.go b/resources/resource_transformers/tocss/scss/client_extended.go
new file mode 100644
index 000000000..bfee39499
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/client_extended.go
@@ -0,0 +1,60 @@
+// Copyright 2018 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.
+
+//go:build extended
+// +build extended
+
+package scss
+
+import (
+ "github.com/bep/golibsass/libsass"
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/internal"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+type options struct {
+ // The options we receive from the end user.
+ from Options
+
+ // The options we send to the SCSS library.
+ to libsass.Options
+}
+
+func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) {
+ internalOptions := options{
+ from: opts,
+ }
+
+ // Transfer values from client.
+ internalOptions.to.Precision = opts.Precision
+ internalOptions.to.OutputStyle = libsass.ParseOutputStyle(opts.OutputStyle)
+
+ if internalOptions.to.Precision == 0 {
+ // bootstrap-sass requires 8 digits precision. The libsass default is 5.
+ // https://github.com/twbs/bootstrap-sass/blob/master/README.md#sass-number-precision
+ internalOptions.to.Precision = 8
+ }
+
+ return res.Transform(&toCSSTransformation{c: c, options: internalOptions})
+
+}
+
+type toCSSTransformation struct {
+ c *Client
+ options options
+}
+
+func (t *toCSSTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey(transformationName, t.options.from)
+}
diff --git a/resources/resource_transformers/tocss/scss/client_notavailable.go b/resources/resource_transformers/tocss/scss/client_notavailable.go
new file mode 100644
index 000000000..efd79109b
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/client_notavailable.go
@@ -0,0 +1,31 @@
+// Copyright 2020 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.
+
+//go:build !extended
+// +build !extended
+
+package scss
+
+import (
+ "github.com/gohugoio/hugo/resources"
+ "github.com/gohugoio/hugo/resources/resource"
+)
+
+func (c *Client) ToCSS(res resources.ResourceTransformer, opts Options) (resource.Resource, error) {
+ return res.Transform(resources.NewFeatureNotAvailableTransformer(transformationName, opts))
+}
+
+// Used in tests.
+func Supports() bool {
+ return false
+}
diff --git a/resources/resource_transformers/tocss/scss/client_test.go b/resources/resource_transformers/tocss/scss/client_test.go
new file mode 100644
index 000000000..9dddd3869
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/client_test.go
@@ -0,0 +1,49 @@
+// Copyright 2020 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 scss
+
+import (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestReplaceRegularCSSImports(t *testing.T) {
+ c := qt.New(t)
+
+ scssWithImport := `
+
+@import "moo";
+@import "regular.css";
+@import "moo";
+@import "another.css";
+@import "foo.scss";
+
+/* foo */`
+
+ scssWithoutImport := `
+@import "moo";
+/* foo */`
+
+ res, replaced := replaceRegularImportsIn(scssWithImport)
+ c.Assert(replaced, qt.Equals, true)
+ c.Assert(res, qt.Equals, "\n\t\n@import \"moo\";\n/* HUGO_IMPORT_START regular.css HUGO_IMPORT_END */\n@import \"moo\";\n/* HUGO_IMPORT_START another.css HUGO_IMPORT_END */\n@import \"foo.scss\";\n\n/* foo */")
+
+ res2, replaced2 := replaceRegularImportsIn(scssWithoutImport)
+ c.Assert(replaced2, qt.Equals, false)
+ c.Assert(res2, qt.Equals, scssWithoutImport)
+
+ reverted := replaceRegularImportsOut(res)
+ c.Assert(reverted, qt.Equals, scssWithImport)
+}
diff --git a/resources/resource_transformers/tocss/scss/integration_test.go b/resources/resource_transformers/tocss/scss/integration_test.go
new file mode 100644
index 000000000..13b664cc7
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/integration_test.go
@@ -0,0 +1,247 @@
+// Copyright 2021 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 scss_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+
+ "github.com/gohugoio/hugo/hugolib"
+ "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
+)
+
+func TestTransformIncludePaths(t *testing.T) {
+ if !scss.Supports() {
+ t.Skip()
+ }
+ c := qt.New(t)
+
+ files := `
+-- assets/scss/main.scss --
+@import "moo";
+-- node_modules/foo/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- config.toml --
+-- layouts/index.html --
+{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo") ) }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ b.AssertFileContent("public/index.html", `T1: moo{color:#fff}`)
+}
+
+func TestTransformImportRegularCSS(t *testing.T) {
+ if !scss.Supports() {
+ t.Skip()
+ }
+
+ c := qt.New(t)
+
+ files := `
+-- assets/scss/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- assets/scss/another.css --
+
+-- assets/scss/main.scss --
+@import "moo";
+@import "regular.css";
+@import "moo";
+@import "another.css";
+
+/* foo */
+-- assets/scss/regular.css --
+
+-- config.toml --
+-- layouts/index.html --
+{{ $r := resources.Get "scss/main.scss" | toCSS }}
+T1: {{ $r.Content | safeHTML }}
+
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ // LibSass does not support regular CSS imports. There
+ // is an open bug about it that probably will never be resolved.
+ // Hugo works around this by preserving them in place:
+ b.AssertFileContent("public/index.html", `
+ T1: moo {
+ color: #fff; }
+
+@import "regular.css";
+moo {
+ color: #fff; }
+
+@import "another.css";
+/* foo */
+
+`)
+}
+
+func TestTransformThemeOverrides(t *testing.T) {
+ if !scss.Supports() {
+ t.Skip()
+ }
+
+ c := qt.New(t)
+
+ files := `
+-- assets/scss/components/_boo.scss --
+$boolor: green;
+
+boo {
+ color: $boolor;
+}
+-- assets/scss/components/_moo.scss --
+$moolor: #ccc;
+
+moo {
+ color: $moolor;
+}
+-- config.toml --
+theme = 'mytheme'
+-- layouts/index.html --
+{{ $cssOpts := (dict "includePaths" (slice "node_modules/foo" ) ) }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+-- themes/mytheme/assets/scss/components/_boo.scss --
+$boolor: orange;
+
+boo {
+ color: $boolor;
+}
+-- themes/mytheme/assets/scss/components/_imports.scss --
+@import "moo";
+@import "_boo";
+@import "_zoo";
+-- themes/mytheme/assets/scss/components/_moo.scss --
+$moolor: #fff;
+
+moo {
+ color: $moolor;
+}
+-- themes/mytheme/assets/scss/components/_zoo.scss --
+$zoolor: pink;
+
+zoo {
+ color: $zoolor;
+}
+-- themes/mytheme/assets/scss/main.scss --
+@import "components/imports";
+ `
+
+ b := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: files,
+ NeedsOsFS: true,
+ }).Build()
+
+ b.AssertFileContent("public/index.html", `T1: moo{color:#ccc}boo{color:green}zoo{color:pink}`)
+}
+
+func TestTransformErrors(t *testing.T) {
+ if !scss.Supports() {
+ t.Skip()
+ }
+
+ c := qt.New(t)
+
+ const filesTemplate = `
+-- config.toml --
+theme = 'mytheme'
+-- assets/scss/components/_foo.scss --
+/* comment line 1 */
+$foocolor: #ccc;
+
+foo {
+ color: $foocolor;
+}
+-- themes/mytheme/assets/scss/main.scss --
+/* comment line 1 */
+/* comment line 2 */
+@import "components/foo";
+/* comment line 4 */
+
+$maincolor: #eee;
+
+body {
+ color: $maincolor;
+}
+
+-- layouts/index.html --
+{{ $cssOpts := dict }}
+{{ $r := resources.Get "scss/main.scss" | toCSS $cssOpts | minify }}
+T1: {{ $r.Content }}
+
+ `
+
+ c.Run("error in main", func(c *qt.C) {
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: strings.Replace(filesTemplate, "$maincolor: #eee;", "$maincolor #eee;", 1),
+ NeedsOsFS: true,
+ }).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`themes/mytheme/assets/scss/main.scss:6:1": expected ':' after $maincolor in assignment statement`))
+ fe := b.AssertIsFileError(err)
+ b.Assert(fe.ErrorContext(), qt.IsNotNil)
+ b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 4 */", "", "$maincolor #eee;", "", "body {"})
+ b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
+
+ })
+
+ c.Run("error in import", func(c *qt.C) {
+ b, err := hugolib.NewIntegrationTestBuilder(
+ hugolib.IntegrationTestConfig{
+ T: c,
+ TxtarString: strings.Replace(filesTemplate, "$foocolor: #ccc;", "$foocolor #ccc;", 1),
+ NeedsOsFS: true,
+ }).BuildE()
+
+ b.Assert(err, qt.IsNotNil)
+ b.Assert(err.Error(), qt.Contains, `assets/scss/components/_foo.scss:2:1": expected ':' after $foocolor in assignment statement`)
+ fe := b.AssertIsFileError(err)
+ b.Assert(fe.ErrorContext(), qt.IsNotNil)
+ b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"})
+ b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
+
+ })
+
+}
diff --git a/resources/resource_transformers/tocss/scss/tocss.go b/resources/resource_transformers/tocss/scss/tocss.go
new file mode 100644
index 000000000..57ac16711
--- /dev/null
+++ b/resources/resource_transformers/tocss/scss/tocss.go
@@ -0,0 +1,204 @@
+// Copyright 2018 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.
+
+//go:build extended
+// +build extended
+
+package scss
+
+import (
+ "fmt"
+ "io"
+ "path"
+
+ "path/filepath"
+ "strings"
+
+ "github.com/bep/golibsass/libsass"
+ "github.com/bep/golibsass/libsass/libsasserrors"
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources"
+)
+
+// Used in tests. This feature requires Hugo to be built with the extended tag.
+func Supports() bool {
+ return true
+}
+
+func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
+ ctx.OutMediaType = media.CSSType
+
+ var outName string
+ if t.options.from.TargetPath != "" {
+ ctx.OutPath = t.options.from.TargetPath
+ } else {
+ ctx.ReplaceOutPathExtension(".css")
+ }
+
+ outName = path.Base(ctx.OutPath)
+
+ options := t.options
+ baseDir := path.Dir(ctx.SourcePath)
+ options.to.IncludePaths = t.c.sfs.RealDirs(baseDir)
+
+ // Append any workDir relative include paths
+ for _, ip := range options.from.IncludePaths {
+ info, err := t.c.workFs.Stat(filepath.Clean(ip))
+ if err == nil {
+ filename := info.(hugofs.FileMetaInfo).Meta().Filename
+ options.to.IncludePaths = append(options.to.IncludePaths, filename)
+ }
+ }
+
+ // To allow for overrides of SCSS files anywhere in the project/theme hierarchy, we need
+ // to help libsass revolve the filename by looking in the composite filesystem first.
+ // We add the entry directories for both project and themes to the include paths list, but
+ // that only work for overrides on the top level.
+ options.to.ImportResolver = func(url string, prev string) (newUrl string, body string, resolved bool) {
+ // We get URL paths from LibSASS, but we need file paths.
+ url = filepath.FromSlash(url)
+ prev = filepath.FromSlash(prev)
+
+ var basePath string
+ urlDir := filepath.Dir(url)
+ var prevDir string
+
+ if prev == "stdin" {
+ prevDir = baseDir
+ } else {
+ prevDir, _ = t.c.sfs.MakePathRelative(filepath.Dir(prev))
+
+ if prevDir == "" {
+ // Not a member of this filesystem. Let LibSASS handle it.
+ return "", "", false
+ }
+ }
+
+ basePath = filepath.Join(prevDir, urlDir)
+ name := filepath.Base(url)
+
+ // Libsass throws an error in cases where you have several possible candidates.
+ // We make this simpler and pick the first match.
+ var namePatterns []string
+ if strings.Contains(name, ".") {
+ namePatterns = []string{"_%s", "%s"}
+ } else if strings.HasPrefix(name, "_") {
+ namePatterns = []string{"_%s.scss", "_%s.sass"}
+ } else {
+ namePatterns = []string{"_%s.scss", "%s.scss", "_%s.sass", "%s.sass"}
+ }
+
+ name = strings.TrimPrefix(name, "_")
+
+ for _, namePattern := range namePatterns {
+ filenameToCheck := filepath.Join(basePath, fmt.Sprintf(namePattern, name))
+ fi, err := t.c.sfs.Fs.Stat(filenameToCheck)
+ if err == nil {
+ if fim, ok := fi.(hugofs.FileMetaInfo); ok {
+ return fim.Meta().Filename, "", true
+ }
+ }
+ }
+
+ // Not found, let LibSASS handle it
+ return "", "", false
+ }
+
+ if ctx.InMediaType.SubType == media.SASSType.SubType {
+ options.to.SassSyntax = true
+ }
+
+ if options.from.EnableSourceMap {
+
+ options.to.SourceMapOptions.Filename = outName + ".map"
+ options.to.SourceMapOptions.Root = t.c.rs.WorkingDir
+
+ // Setting this to the relative input filename will get the source map
+ // more correct for the main entry path (main.scss typically), but
+ // it will mess up the import mappings. As a workaround, we do a replacement
+ // in the source map itself (see below).
+ // options.InputPath = inputPath
+ options.to.SourceMapOptions.OutputPath = outName
+ options.to.SourceMapOptions.Contents = true
+ options.to.SourceMapOptions.OmitURL = false
+ options.to.SourceMapOptions.EnableEmbedded = false
+ }
+
+ res, err := t.c.toCSS(options.to, ctx.To, ctx.From)
+ if err != nil {
+ if sasserr, ok := err.(libsasserrors.Error); ok {
+ if sasserr.File == "stdin" && ctx.SourcePath != "" {
+ sasserr.File = t.c.sfs.RealFilename(ctx.SourcePath)
+ err = sasserr
+ }
+ }
+ return herrors.NewFileErrorFromFileInErr(err, hugofs.Os, nil)
+
+ }
+
+ if options.from.EnableSourceMap && res.SourceMapContent != "" {
+ sourcePath := t.c.sfs.RealFilename(ctx.SourcePath)
+
+ if strings.HasPrefix(sourcePath, t.c.rs.WorkingDir) {
+ sourcePath = strings.TrimPrefix(sourcePath, t.c.rs.WorkingDir+helpers.FilePathSeparator)
+ }
+
+ // This needs to be Unix-style slashes, even on Windows.
+ // See https://github.com/gohugoio/hugo/issues/4968
+ sourcePath = filepath.ToSlash(sourcePath)
+
+ // This is a workaround for what looks like a bug in Libsass. But
+ // getting this resolution correct in tools like Chrome Workspaces
+ // is important enough to go this extra mile.
+ mapContent := strings.Replace(res.SourceMapContent, `stdin",`, fmt.Sprintf("%s\",", sourcePath), 1)
+
+ return ctx.PublishSourceMap(mapContent)
+ }
+ return nil
+}
+
+func (c *Client) toCSS(options libsass.Options, dst io.Writer, src io.Reader) (libsass.Result, error) {
+ var res libsass.Result
+
+ transpiler, err := libsass.New(options)
+ if err != nil {
+ return res, err
+ }
+
+ in := helpers.ReaderToString(src)
+
+ // See https://github.com/gohugoio/hugo/issues/7059
+ // We need to preserve the regular CSS imports. This is by far
+ // a perfect solution, and only works for the main entry file, but
+ // that should cover many use cases, e.g. using SCSS as a preprocessor
+ // for Tailwind.
+ var importsReplaced bool
+ in, importsReplaced = replaceRegularImportsIn(in)
+
+ res, err = transpiler.Execute(in)
+ if err != nil {
+ return res, err
+ }
+
+ out := res.CSS
+ if importsReplaced {
+ out = replaceRegularImportsOut(out)
+ }
+
+ _, err = io.WriteString(dst, out)
+
+ return res, err
+}
diff --git a/resources/testdata/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg b/resources/testdata/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg
new file mode 100644
index 000000000..7d7307bed
--- /dev/null
+++ b/resources/testdata/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph.jpg
Binary files differ
diff --git a/resources/testdata/circle.svg b/resources/testdata/circle.svg
new file mode 100644
index 000000000..2759ae703
--- /dev/null
+++ b/resources/testdata/circle.svg
@@ -0,0 +1,5 @@
+<svg height="100" width="100">
+ <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+ Sorry, your browser does not support inline SVG.
+</svg>
+ \ No newline at end of file
diff --git a/resources/testdata/fuzzy-cirlcle.png b/resources/testdata/fuzzy-cirlcle.png
new file mode 100644
index 000000000..95497d822
--- /dev/null
+++ b/resources/testdata/fuzzy-cirlcle.png
Binary files differ
diff --git a/resources/testdata/giphy.gif b/resources/testdata/giphy.gif
new file mode 100644
index 000000000..f82b32cbe
--- /dev/null
+++ b/resources/testdata/giphy.gif
Binary files differ
diff --git a/resources/testdata/gohugoio-card.gif b/resources/testdata/gohugoio-card.gif
new file mode 100644
index 000000000..6bc20d83a
--- /dev/null
+++ b/resources/testdata/gohugoio-card.gif
Binary files differ
diff --git a/resources/testdata/gohugoio.png b/resources/testdata/gohugoio.png
new file mode 100644
index 000000000..0591db959
--- /dev/null
+++ b/resources/testdata/gohugoio.png
Binary files differ
diff --git a/resources/testdata/gohugoio24.png b/resources/testdata/gohugoio24.png
new file mode 100644
index 000000000..9b004b897
--- /dev/null
+++ b/resources/testdata/gohugoio24.png
Binary files differ
diff --git a/resources/testdata/gohugoio8.png b/resources/testdata/gohugoio8.png
new file mode 100644
index 000000000..0993f90e4
--- /dev/null
+++ b/resources/testdata/gohugoio8.png
Binary files differ
diff --git a/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif
new file mode 100644
index 000000000..ca826432c
--- /dev/null
+++ b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif
Binary files differ
diff --git a/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif
new file mode 100644
index 000000000..590d2a780
--- /dev/null
+++ b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif
Binary files differ
diff --git a/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_100x0_resize_box.gif b/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_100x0_resize_box.gif
new file mode 100644
index 000000000..7d810c1f9
--- /dev/null
+++ b/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_100x0_resize_box.gif
Binary files differ
diff --git a/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_220x0_resize_box.gif b/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_220x0_resize_box.gif
new file mode 100644
index 000000000..c4b39b041
--- /dev/null
+++ b/resources/testdata/golden/gohugoio-card_hu4d09f75255d3942fd4680641110a1a73_10820_220x0_resize_box.gif
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_3.png
new file mode 100644
index 000000000..d2f0afd27
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_1a923841aa34545db29f46a8fc4c5b0d.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_1a923841aa34545db29f46a8fc4c5b0d.png
new file mode 100644
index 000000000..a48a0f25a
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_1a923841aa34545db29f46a8fc4c5b0d.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_3.png
new file mode 100644
index 000000000..5abf378b4
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_3.png
new file mode 100644
index 000000000..cd56200ea
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_3.png
new file mode 100644
index 000000000..dd11ce7ed
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_3.png
new file mode 100644
index 000000000..4ef633564
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_3.png
new file mode 100644
index 000000000..5ad74bf79
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_324b4d42c8746a684068d123fad8b744.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_324b4d42c8746a684068d123fad8b744.png
new file mode 100644
index 000000000..eba4b1e66
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_324b4d42c8746a684068d123fad8b744.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_3.png
new file mode 100644
index 000000000..76deeabc7
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_3.png
new file mode 100644
index 000000000..76deeabc7
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_43055c40cb4a15bd8491bfc502799f43.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_43055c40cb4a15bd8491bfc502799f43.png
new file mode 100644
index 000000000..0ce82e49c
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_43055c40cb4a15bd8491bfc502799f43.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_4ea8f246299cc5fba9744bdf162bd57d.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_4ea8f246299cc5fba9744bdf162bd57d.png
new file mode 100644
index 000000000..841d369ef
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_4ea8f246299cc5fba9744bdf162bd57d.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_3.png
new file mode 100644
index 000000000..28028b72d
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_60c098f0ca6626668d9e3ad6bfb38b5b.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_60c098f0ca6626668d9e3ad6bfb38b5b.png
new file mode 100644
index 000000000..46fa3fd1b
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_60c098f0ca6626668d9e3ad6bfb38b5b.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_6575f3a3c39a30cba9d76a6045c36de6.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_6575f3a3c39a30cba9d76a6045c36de6.png
new file mode 100644
index 000000000..056648a74
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_6575f3a3c39a30cba9d76a6045c36de6.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_8166ccaf22bdabb94c9bb90bffe64133.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_8166ccaf22bdabb94c9bb90bffe64133.png
new file mode 100644
index 000000000..2fece7804
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_8166ccaf22bdabb94c9bb90bffe64133.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9165e5559db8ba31a401327b5617c098.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9165e5559db8ba31a401327b5617c098.png
new file mode 100644
index 000000000..50fae767a
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9165e5559db8ba31a401327b5617c098.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9a8d95423df65a9c230a4cc88056c13a.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9a8d95423df65a9c230a4cc88056c13a.png
new file mode 100644
index 000000000..32c5b49d8
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9a8d95423df65a9c230a4cc88056c13a.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a38a1924befb1721a09be7d432f5f70f.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a38a1924befb1721a09be7d432f5f70f.png
new file mode 100644
index 000000000..603b95ae0
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a38a1924befb1721a09be7d432f5f70f.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a487ef4bea3dba1e1a84be5358cfef39.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a487ef4bea3dba1e1a84be5358cfef39.png
new file mode 100644
index 000000000..dde14757c
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a487ef4bea3dba1e1a84be5358cfef39.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a751b6cd969d7feab12540a8bb0ca927.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a751b6cd969d7feab12540a8bb0ca927.png
new file mode 100644
index 000000000..93f8dfda2
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a751b6cd969d7feab12540a8bb0ca927.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_abcdd770eaed9301cfff4bc2f96459ba.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_abcdd770eaed9301cfff4bc2f96459ba.png
new file mode 100644
index 000000000..0991ca984
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_abcdd770eaed9301cfff4bc2f96459ba.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_aeaaf23afe6fb4702bd3992426d0cad3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_aeaaf23afe6fb4702bd3992426d0cad3.png
new file mode 100644
index 000000000..ce791767f
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_aeaaf23afe6fb4702bd3992426d0cad3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_bdde5e36f15689c1451933f92fd357b3.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_bdde5e36f15689c1451933f92fd357b3.png
new file mode 100644
index 000000000..25ac82485
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_bdde5e36f15689c1451933f92fd357b3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d111079da5d8d143b6cae10d6fedbc24.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d111079da5d8d143b6cae10d6fedbc24.png
new file mode 100644
index 000000000..362be673b
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d111079da5d8d143b6cae10d6fedbc24.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d87fd348ad697a9b16399709441d9d56.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d87fd348ad697a9b16399709441d9d56.png
new file mode 100644
index 000000000..174649232
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d87fd348ad697a9b16399709441d9d56.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_e8ef2efdde4357a79694ea9c2be82f63.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_e8ef2efdde4357a79694ea9c2be82f63.png
new file mode 100644
index 000000000..697ac914e
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_e8ef2efdde4357a79694ea9c2be82f63.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_ee57777f148caaa6993972d9709fdf2d.png b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_ee57777f148caaa6993972d9709fdf2d.png
new file mode 100644
index 000000000..c1a64b59f
--- /dev/null
+++ b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_ee57777f148caaa6993972d9709fdf2d.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_00cd4ff18b53ecbd78e42aefe5fbf522.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_00cd4ff18b53ecbd78e42aefe5fbf522.png
new file mode 100644
index 000000000..1fa2bc9de
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_00cd4ff18b53ecbd78e42aefe5fbf522.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_3.png
new file mode 100644
index 000000000..0eef0aaf3
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_3.png
new file mode 100644
index 000000000..c35f00722
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_3.png
new file mode 100644
index 000000000..6ddb55158
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_276650b97daa7ae98e79b929d7f87c19.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_276650b97daa7ae98e79b929d7f87c19.png
new file mode 100644
index 000000000..0b914391c
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_276650b97daa7ae98e79b929d7f87c19.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_2e05d39f4cb329be10e8c515494cef76.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_2e05d39f4cb329be10e8c515494cef76.png
new file mode 100644
index 000000000..795a608e8
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_2e05d39f4cb329be10e8c515494cef76.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_3.png
new file mode 100644
index 000000000..08eccf7cd
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_3.png
new file mode 100644
index 000000000..162dc4ec9
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_3.png
new file mode 100644
index 000000000..0660c20d7
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3980c5868e0b6f20ec95424dfdcb1d67.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3980c5868e0b6f20ec95424dfdcb1d67.png
new file mode 100644
index 000000000..7134de473
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3980c5868e0b6f20ec95424dfdcb1d67.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_398ca764abfff83bb15318068105dcb9.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_398ca764abfff83bb15318068105dcb9.png
new file mode 100644
index 000000000..37dc0f798
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_398ca764abfff83bb15318068105dcb9.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3ed273f49d1dc83891f5736e21fc5f44.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3ed273f49d1dc83891f5736e21fc5f44.png
new file mode 100644
index 000000000..1a229a429
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_3ed273f49d1dc83891f5736e21fc5f44.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_3.png
new file mode 100644
index 000000000..acde6a0f7
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_3.png
new file mode 100644
index 000000000..acde6a0f7
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_428e769d14483c2fcdd6f5c5138e2066.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_428e769d14483c2fcdd6f5c5138e2066.png
new file mode 100644
index 000000000..c96e04108
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_428e769d14483c2fcdd6f5c5138e2066.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_3.png
new file mode 100644
index 000000000..40fffa23a
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png
new file mode 100644
index 000000000..51f6cfa7e
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_871826faffc414ca3746f65fc9910eed.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_871826faffc414ca3746f65fc9910eed.png
new file mode 100644
index 000000000..53dd0b224
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_871826faffc414ca3746f65fc9910eed.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0505112c99af88626ac9b9a16a27acb.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0505112c99af88626ac9b9a16a27acb.png
new file mode 100644
index 000000000..156b42f43
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0505112c99af88626ac9b9a16a27acb.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0ffc0f22f22e6920f3cad414d6db6ba.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0ffc0f22f22e6920f3cad414d6db6ba.png
new file mode 100644
index 000000000..a5852e14c
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_a0ffc0f22f22e6920f3cad414d6db6ba.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png
new file mode 100644
index 000000000..c8f782598
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_b34412412a1cf1658e516a335b0a8dd4.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_b34412412a1cf1658e516a335b0a8dd4.png
new file mode 100644
index 000000000..c29c6e613
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_b34412412a1cf1658e516a335b0a8dd4.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c5140f11378ddb13843432a5b489594a.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c5140f11378ddb13843432a5b489594a.png
new file mode 100644
index 000000000..09d991972
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c5140f11378ddb13843432a5b489594a.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d17d0184674fcf0a4d770c90bed503db.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d17d0184674fcf0a4d770c90bed503db.png
new file mode 100644
index 000000000..325c31acd
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d17d0184674fcf0a4d770c90bed503db.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png
new file mode 100644
index 000000000..2def214c8
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_e4d38674b70d9ef559c5df72c9262790.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_e4d38674b70d9ef559c5df72c9262790.png
new file mode 100644
index 000000000..414acff3b
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_e4d38674b70d9ef559c5df72c9262790.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_eff9583d9b94ac79c60cb099846ce8f3.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_eff9583d9b94ac79c60cb099846ce8f3.png
new file mode 100644
index 000000000..69aa35885
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_eff9583d9b94ac79c60cb099846ce8f3.png
Binary files differ
diff --git a/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_f17bba59421e7a500387232295512fc0.png b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_f17bba59421e7a500387232295512fc0.png
new file mode 100644
index 000000000..64b0b3f7a
--- /dev/null
+++ b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_f17bba59421e7a500387232295512fc0.png
Binary files differ
diff --git a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_3.png b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_3.png
new file mode 100644
index 000000000..50c55c9eb
--- /dev/null
+++ b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_bge3e615_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_3.jpg b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_3.jpg
new file mode 100644
index 000000000..17fca6e6a
--- /dev/null
+++ b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_200x0_resize_q75_bge3e615_box_3.jpg
Binary files differ
diff --git a/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_30x0_resize_box_3.png b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_30x0_resize_box_3.png
new file mode 100644
index 000000000..eb9f1170c
--- /dev/null
+++ b/resources/testdata/golden/gopher-hero8_huaa0cd7d2cfc14ff32a57f171896f2285_13327_30x0_resize_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_3.png b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_3.png
new file mode 100644
index 000000000..b01efee50
--- /dev/null
+++ b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_bge3e615_box_3.png
Binary files differ
diff --git a/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_3.jpg b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_3.jpg
new file mode 100644
index 000000000..56642d7e1
--- /dev/null
+++ b/resources/testdata/golden/gradient-circle_huf3d35257a40a8d6f525263a856c5ecfd_20069_200x0_resize_q75_bge3e615_box_3.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg
new file mode 100644
index 000000000..1e2cb535b
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg
new file mode 100644
index 000000000..8e6164e32
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg
new file mode 100644
index 000000000..2aa3dad2b
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg
new file mode 100644
index 000000000..05d98c67a
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg
new file mode 100644
index 000000000..f12dd18fc
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg
new file mode 100644
index 000000000..8ac3b2524
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg
new file mode 100644
index 000000000..03de912fb
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg
new file mode 100644
index 000000000..3801c17d9
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg
new file mode 100644
index 000000000..60207a829
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg
new file mode 100644
index 000000000..f7e84e33d
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg
new file mode 100644
index 000000000..17a5927e2
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg
new file mode 100644
index 000000000..93b914161
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg
new file mode 100644
index 000000000..9a6255687
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg
new file mode 100644
index 000000000..b2db97485
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_510813cc53c37e2d489d2f9fdb13f749.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_510813cc53c37e2d489d2f9fdb13f749.jpg
new file mode 100644
index 000000000..6c3da1385
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_510813cc53c37e2d489d2f9fdb13f749.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg
new file mode 100644
index 000000000..a5ad199d8
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6673ece428cb7d523234ca0d7c299542.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6673ece428cb7d523234ca0d7c299542.jpg
new file mode 100644
index 000000000..7e2bdeef0
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6673ece428cb7d523234ca0d7c299542.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg
new file mode 100644
index 000000000..e77e78d7b
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg
new file mode 100644
index 000000000..ee246814d
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg
new file mode 100644
index 000000000..e7db706c2
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg
new file mode 100644
index 000000000..9688c99c3
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c2d24766b49f3147f5a4137a8db592ac.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c2d24766b49f3147f5a4137a8db592ac.jpg
new file mode 100644
index 000000000..b425b0d92
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c2d24766b49f3147f5a4137a8db592ac.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg
new file mode 100644
index 000000000..41b42a883
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c50a17db1e6d1bd0fe31a9a3444f1587.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c50a17db1e6d1bd0fe31a9a3444f1587.jpg
new file mode 100644
index 000000000..1857f8758
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c50a17db1e6d1bd0fe31a9a3444f1587.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg
new file mode 100644
index 000000000..f09ff9e33
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg
new file mode 100644
index 000000000..0b7d4e5d0
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg
new file mode 100644
index 000000000..7e35750db
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg
Binary files differ
diff --git a/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg
new file mode 100644
index 000000000..b67650061
--- /dev/null
+++ b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg
Binary files differ
diff --git a/resources/testdata/golden_webp/fuzzy-cirlcle_hu525d1a6cf670e85f5e8f19890241399b_26792_200x0_resize_q75_h2_box_3.webp b/resources/testdata/golden_webp/fuzzy-cirlcle_hu525d1a6cf670e85f5e8f19890241399b_26792_200x0_resize_q75_h2_box_3.webp
new file mode 100644
index 000000000..0b9e6752a
--- /dev/null
+++ b/resources/testdata/golden_webp/fuzzy-cirlcle_hu525d1a6cf670e85f5e8f19890241399b_26792_200x0_resize_q75_h2_box_3.webp
Binary files differ
diff --git a/resources/testdata/gopher-hero8.png b/resources/testdata/gopher-hero8.png
new file mode 100644
index 000000000..08ae570d2
--- /dev/null
+++ b/resources/testdata/gopher-hero8.png
Binary files differ
diff --git a/resources/testdata/gradient-circle.png b/resources/testdata/gradient-circle.png
new file mode 100644
index 000000000..a4ace53a1
--- /dev/null
+++ b/resources/testdata/gradient-circle.png
Binary files differ
diff --git a/resources/testdata/iss8079.jpg b/resources/testdata/iss8079.jpg
new file mode 100644
index 000000000..a9049e81b
--- /dev/null
+++ b/resources/testdata/iss8079.jpg
Binary files differ
diff --git a/resources/testdata/sub/gohugoio2.png b/resources/testdata/sub/gohugoio2.png
new file mode 100644
index 000000000..0591db959
--- /dev/null
+++ b/resources/testdata/sub/gohugoio2.png
Binary files differ
diff --git a/resources/testdata/sunrise.JPG b/resources/testdata/sunrise.JPG
new file mode 100644
index 000000000..7d7307bed
--- /dev/null
+++ b/resources/testdata/sunrise.JPG
Binary files differ
diff --git a/resources/testdata/sunset.jpg b/resources/testdata/sunset.jpg
new file mode 100644
index 000000000..7d7307bed
--- /dev/null
+++ b/resources/testdata/sunset.jpg
Binary files differ
diff --git a/resources/testdata/sunset.webp b/resources/testdata/sunset.webp
new file mode 100644
index 000000000..4365e7b9f
--- /dev/null
+++ b/resources/testdata/sunset.webp
Binary files differ
diff --git a/resources/testhelpers_test.go b/resources/testhelpers_test.go
new file mode 100644
index 000000000..3a4e7e580
--- /dev/null
+++ b/resources/testhelpers_test.go
@@ -0,0 +1,205 @@
+package resources
+
+import (
+ "image"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/langs"
+ "github.com/gohugoio/hugo/modules"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/cache/filecache"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/hugofs"
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/output"
+ "github.com/gohugoio/hugo/resources/images"
+ "github.com/gohugoio/hugo/resources/page"
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/spf13/afero"
+)
+
+type specDescriptor struct {
+ baseURL string
+ c *qt.C
+ fs afero.Fs
+}
+
+func createTestCfg() config.Provider {
+ cfg := config.New()
+ cfg.Set("resourceDir", "resources")
+ cfg.Set("contentDir", "content")
+ cfg.Set("dataDir", "data")
+ cfg.Set("i18nDir", "i18n")
+ cfg.Set("layoutDir", "layouts")
+ cfg.Set("assetDir", "assets")
+ cfg.Set("archetypeDir", "archetypes")
+ cfg.Set("publishDir", "public")
+
+ langs.LoadLanguageSettings(cfg, nil)
+ mod, err := modules.CreateProjectModule(cfg)
+ if err != nil {
+ panic(err)
+ }
+ cfg.Set("allModules", modules.Modules{mod})
+
+ return cfg
+}
+
+func newTestResourceSpec(desc specDescriptor) *Spec {
+ baseURL := desc.baseURL
+ if baseURL == "" {
+ baseURL = "https://example.com/"
+ }
+
+ afs := desc.fs
+ if afs == nil {
+ afs = afero.NewMemMapFs()
+ }
+
+ afs = hugofs.NewBaseFileDecorator(afs)
+
+ c := desc.c
+
+ cfg := createTestCfg()
+ cfg.Set("baseURL", baseURL)
+
+ imagingCfg := map[string]any{
+ "resampleFilter": "linear",
+ "quality": 68,
+ "anchor": "left",
+ }
+
+ cfg.Set("imaging", imagingCfg)
+
+ fs := hugofs.NewFrom(afs, cfg)
+ fs.PublishDir = hugofs.NewCreateCountingFs(fs.PublishDir)
+
+ s, err := helpers.NewPathSpec(fs, cfg, nil)
+ c.Assert(err, qt.IsNil)
+
+ filecaches, err := filecache.NewCaches(s)
+ c.Assert(err, qt.IsNil)
+
+ spec, err := NewSpec(s, filecaches, nil, nil, nil, nil, output.DefaultFormats, media.DefaultTypes)
+ c.Assert(err, qt.IsNil)
+ return spec
+}
+
+func newTargetPaths(link string) func() page.TargetPaths {
+ return func() page.TargetPaths {
+ return page.TargetPaths{
+ SubResourceBaseTarget: filepath.FromSlash(link),
+ SubResourceBaseLink: link,
+ }
+ }
+}
+
+func newTestResourceOsFs(c *qt.C) (*Spec, string) {
+ cfg := createTestCfg()
+ cfg.Set("baseURL", "https://example.com")
+
+ workDir, err := ioutil.TempDir("", "hugores")
+ c.Assert(err, qt.IsNil)
+ c.Assert(workDir, qt.Not(qt.Equals), "")
+
+ if runtime.GOOS == "darwin" && !strings.HasPrefix(workDir, "/private") {
+ // To get the entry folder in line with the rest. This its a little bit
+ // mysterious, but so be it.
+ workDir = "/private" + workDir
+ }
+
+ cfg.Set("workingDir", workDir)
+
+ fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(hugofs.Os), cfg)
+
+ s, err := helpers.NewPathSpec(fs, cfg, nil)
+ c.Assert(err, qt.IsNil)
+
+ filecaches, err := filecache.NewCaches(s)
+ c.Assert(err, qt.IsNil)
+
+ spec, err := NewSpec(s, filecaches, nil, nil, nil, nil, output.DefaultFormats, media.DefaultTypes)
+ c.Assert(err, qt.IsNil)
+
+ return spec, workDir
+}
+
+func fetchSunset(c *qt.C) images.ImageResource {
+ return fetchImage(c, "sunset.jpg")
+}
+
+func fetchImage(c *qt.C, name string) images.ImageResource {
+ spec := newTestResourceSpec(specDescriptor{c: c})
+ return fetchImageForSpec(spec, c, name)
+}
+
+func fetchImageForSpec(spec *Spec, c *qt.C, name string) images.ImageResource {
+ r := fetchResourceForSpec(spec, c, name)
+
+ img := r.(images.ImageResource)
+
+ c.Assert(img, qt.Not(qt.IsNil))
+ c.Assert(img.(specProvider).getSpec(), qt.Not(qt.IsNil))
+
+ return img
+}
+
+func fetchResourceForSpec(spec *Spec, c *qt.C, name string, targetPathAddends ...string) resource.ContentResource {
+ src, err := os.Open(filepath.FromSlash("testdata/" + name))
+ c.Assert(err, qt.IsNil)
+ workDir := spec.WorkingDir
+ if len(targetPathAddends) > 0 {
+ addends := strings.Join(targetPathAddends, "_")
+ name = addends + "_" + name
+ }
+ targetFilename := filepath.Join(workDir, name)
+ out, err := helpers.OpenFileForWriting(spec.Fs.Source, targetFilename)
+ c.Assert(err, qt.IsNil)
+ _, err = io.Copy(out, src)
+ out.Close()
+ src.Close()
+ c.Assert(err, qt.IsNil)
+
+ factory := newTargetPaths("/a")
+
+ r, err := spec.New(ResourceSourceDescriptor{Fs: spec.Fs.Source, TargetPaths: factory, LazyPublish: true, RelTargetFilename: name, SourceFilename: targetFilename})
+ c.Assert(err, qt.IsNil)
+ c.Assert(r, qt.Not(qt.IsNil))
+
+ return r.(resource.ContentResource)
+}
+
+func assertImageFile(c *qt.C, fs afero.Fs, filename string, width, height int) {
+ filename = filepath.Clean(filename)
+ f, err := fs.Open(filename)
+ c.Assert(err, qt.IsNil)
+ defer f.Close()
+
+ config, _, err := image.DecodeConfig(f)
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(config.Width, qt.Equals, width)
+ c.Assert(config.Height, qt.Equals, height)
+}
+
+func assertFileCache(c *qt.C, fs afero.Fs, filename string, width, height int) {
+ assertImageFile(c, fs, filepath.Clean(filename), width, height)
+}
+
+func writeSource(t testing.TB, fs *hugofs.Fs, filename, content string) {
+ writeToFs(t, fs.Source, filename, content)
+}
+
+func writeToFs(t testing.TB, fs afero.Fs, filename, content string) {
+ if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0755); err != nil {
+ t.Fatalf("Failed to write file: %s", err)
+ }
+}
diff --git a/resources/transform.go b/resources/transform.go
new file mode 100644
index 000000000..7d81f9b21
--- /dev/null
+++ b/resources/transform.go
@@ -0,0 +1,670 @@
+// 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"
+ "fmt"
+ "image"
+ "io"
+ "path"
+ "strings"
+ "sync"
+
+ "github.com/gohugoio/hugo/common/paths"
+
+ "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/helpers"
+ "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.Source = (*resourceAdapter)(nil)
+ _ resource.Identifier = (*resourceAdapter)(nil)
+ _ resource.ResourceMetaProvider = (*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{},
+ resourceAdapterInner: &resourceAdapterInner{
+ spec: spec,
+ publishOnce: po,
+ target: target,
+ },
+ }
+}
+
+// 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 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
+}
+
+func (r *resourceAdapter) Content() (any, error) {
+ r.init(false, true)
+ if r.transformationsErr != nil {
+ return nil, r.transformationsErr
+ }
+ return r.target.Content()
+}
+
+func (r *resourceAdapter) Err() resource.ResourceError {
+ return nil
+}
+
+func (r *resourceAdapter) Data() any {
+ r.init(false, false)
+ return r.target.Data()
+}
+
+func (r resourceAdapter) cloneTo(targetPath string) resource.Resource {
+ newtTarget := r.target.cloneTo(targetPath)
+ newInner := &resourceAdapterInner{
+ spec: r.spec,
+ target: newtTarget.(transformableResource),
+ }
+ if r.resourceAdapterInner.publishOnce != nil {
+ newInner.publishOnce = &publishOnce{}
+ }
+ r.resourceAdapterInner = newInner
+ return &r
+}
+
+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) Height() int {
+ return r.getImageOps().Height()
+}
+
+func (r *resourceAdapter) Exif() *exif.ExifInfo {
+ return r.getImageOps().Exif()
+}
+
+func (r *resourceAdapter) Key() string {
+ r.init(false, false)
+ return r.target.(resource.Identifier).Key()
+}
+
+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.target.Name()
+}
+
+func (r *resourceAdapter) Params() maps.Params {
+ r.init(false, false)
+ return r.target.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) Resize(spec string) (images.ImageResource, error) {
+ return r.getImageOps().Resize(spec)
+}
+
+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.target.Title()
+}
+
+func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransformer, error) {
+ r.resourceTransformations = &resourceTransformations{
+ transformations: append(r.transformations, t...),
+ }
+
+ r.resourceAdapterInner = &resourceAdapterInner{
+ spec: r.spec,
+ 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) 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) getMetaAssigner() metaAssigner {
+ return r.target
+}
+
+func (r *resourceAdapter) getSpec() *Spec {
+ return r.spec
+}
+
+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 {
+ // Files with a suffix will be stored in cache (both on disk and in memory)
+ // partitioned by their suffix.
+ var key string
+ for _, tr := range r.transformations {
+ key = key + "_" + tr.Key().Value()
+ }
+
+ base := ResourceCacheKey(r.target.Key())
+ return r.spec.ResourceCache.cleanKey(base) + "_" + helpers.MD5String(key)
+}
+
+func (r *resourceAdapter) transform(publish, setContent bool) error {
+ cache := r.spec.ResourceCache
+
+ key := r.TransformationKey()
+
+ cached, found := cache.get(key)
+
+ if found {
+ r.resourceAdapterInner = cached.(*resourceAdapterInner)
+ return nil
+ }
+
+ // Acquire a write lock for the named transformation.
+ cache.nlocker.Lock(key)
+ // Check the cache again.
+ cached, found = cache.get(key)
+ if found {
+ r.resourceAdapterInner = cached.(*resourceAdapterInner)
+ cache.nlocker.Unlock(key)
+ return nil
+ }
+
+ defer cache.nlocker.Unlock(key)
+ defer cache.set(key, r.resourceAdapterInner)
+
+ b1 := bp.GetBuffer()
+ b2 := bp.GetBuffer()
+ defer bp.PutBuffer(b1)
+ defer bp.PutBuffer(b2)
+
+ tctx := &ResourceTransformationCtx{
+ Data: make(map[string]any),
+ OpenResourcePublisher: r.target.openPublishFileForWriting,
+ }
+
+ 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 err
+ }
+
+ defer contentrc.Close()
+
+ tctx.From = contentrc
+ tctx.To = b1
+
+ tctx.InPath = r.target.TargetPath()
+ tctx.SourcePath = 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 err == herrors.ErrFeatureNotAvailable {
+ var errMsg string
+ if tr.Key().Name == "postcss" {
+ // This transformation is not available in this
+ // Most likely because PostCSS is not installed.
+ errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/"
+ } else if tr.Key().Name == "tocss" {
+ errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS."
+ } else if tr.Key().Name == "tocss-dart" {
+ errMsg = ". You need dart-sass-embedded in your system $PATH."
+
+ } else if tr.Key().Name == "babel" {
+ errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
+ }
+
+ return fmt.Errorf(msg+errMsg+": %w", err)
+ }
+
+ return fmt.Errorf(msg+": %w", err)
+ }
+
+ var tryFileCache bool
+
+ if mayBeCachedOnDisk && r.spec.BuildConfig.UseResourceCache(nil) {
+ tryFileCache = true
+ } else {
+ err = tr.Transform(tctx)
+ if err != nil && err != herrors.ErrFeatureNotAvailable {
+ return newErr(err)
+ }
+
+ if mayBeCachedOnDisk {
+ tryFileCache = r.spec.BuildConfig.UseResourceCache(err)
+ }
+ if err != nil && !tryFileCache {
+ return newErr(err)
+ }
+ }
+
+ if tryFileCache {
+ f := r.target.tryTransformedFileCache(key, updates)
+ if f == nil {
+ if err != nil {
+ return newErr(err)
+ }
+ return 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 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 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 err
+ }
+ publishw.Close()
+
+ if setContent {
+ s := contentmemw.String()
+ updates.content = &s
+ }
+
+ newTarget, err := r.target.cloneWithUpdates(updates)
+ if err != nil {
+ return err
+ }
+ r.target = newTarget
+
+ return 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.transform(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 {
+ target transformableResource
+
+ spec *Spec
+
+ // Handles publishing (to /public) if needed.
+ *publishOnce
+}
+
+type resourceTransformations struct {
+ transformationsInit sync.Once
+ transformationsErr error
+ transformations []ResourceTransformation
+}
+
+type transformableResource interface {
+ baseResourceInternal
+
+ resource.ContentProvider
+ resource.Resource
+ resource.Identifier
+ 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)
+
+ }
+}
diff --git a/resources/transform_test.go b/resources/transform_test.go
new file mode 100644
index 000000000..af8ccbc1f
--- /dev/null
+++ b/resources/transform_test.go
@@ -0,0 +1,440 @@
+// 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 (
+ "encoding/base64"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/gohugoio/hugo/htesting"
+
+ "github.com/gohugoio/hugo/common/herrors"
+ "github.com/gohugoio/hugo/hugofs"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/resources/images"
+ "github.com/gohugoio/hugo/resources/internal"
+
+ "github.com/gohugoio/hugo/helpers"
+
+ "github.com/gohugoio/hugo/resources/resource"
+ "github.com/spf13/afero"
+
+ qt "github.com/frankban/quicktest"
+)
+
+const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDsJkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAAILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36IMRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZmrSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhktrRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEjyy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5feA+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fUyLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2SnssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==`
+
+func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
+
+func TestTransform(t *testing.T) {
+ c := qt.New(t)
+
+ createTransformer := func(spec *Spec, filename, content string) Transformer {
+ filename = filepath.FromSlash(filename)
+ fs := spec.Fs.Source
+ afero.WriteFile(fs, filename, []byte(content), 0777)
+ r, _ := spec.New(ResourceSourceDescriptor{Fs: fs, SourceFilename: filename})
+ return r.(Transformer)
+ }
+
+ createContentReplacer := func(name, old, new string) ResourceTransformation {
+ return &testTransformation{
+ name: name,
+ transform: func(ctx *ResourceTransformationCtx) error {
+ in := helpers.ReaderToString(ctx.From)
+ in = strings.Replace(in, old, new, 1)
+ ctx.AddOutPathIdentifier("." + name)
+ fmt.Fprint(ctx.To, in)
+ return nil
+ },
+ }
+ }
+
+ // Verify that we publish the same file once only.
+ assertNoDuplicateWrites := func(c *qt.C, spec *Spec) {
+ c.Helper()
+ d := spec.Fs.PublishDir.(hugofs.DuplicatesReporter)
+ c.Assert(d.ReportDuplicates(), qt.Equals, "")
+ }
+
+ assertShouldExist := func(c *qt.C, spec *Spec, filename string, should bool) {
+ c.Helper()
+ exists, _ := helpers.Exists(filepath.FromSlash(filename), spec.Fs.WorkingDirReadOnly)
+ c.Assert(exists, qt.Equals, should)
+ }
+
+ c.Run("All values", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ transformation := &testTransformation{
+ name: "test",
+ transform: func(ctx *ResourceTransformationCtx) error {
+ // Content
+ in := helpers.ReaderToString(ctx.From)
+ in = strings.Replace(in, "blue", "green", 1)
+ fmt.Fprint(ctx.To, in)
+
+ // Media type
+ ctx.OutMediaType = media.CSVType
+
+ // Change target
+ ctx.ReplaceOutPathExtension(".csv")
+
+ // Add some data to context
+ ctx.Data["mydata"] = "Hugo Rocks!"
+
+ return nil
+ },
+ }
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ tr, err := r.Transform(transformation)
+ c.Assert(err, qt.IsNil)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(content, qt.Equals, "color is green")
+ c.Assert(tr.MediaType(), eq, media.CSVType)
+ c.Assert(tr.RelPermalink(), qt.Equals, "/f1.csv")
+ assertShouldExist(c, spec, "public/f1.csv", true)
+
+ data := tr.Data().(map[string]any)
+ c.Assert(data["mydata"], qt.Equals, "Hugo Rocks!")
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Meta only", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ transformation := &testTransformation{
+ name: "test",
+ transform: func(ctx *ResourceTransformationCtx) error {
+ // Change media type only
+ ctx.OutMediaType = media.CSVType
+ ctx.ReplaceOutPathExtension(".csv")
+
+ return nil
+ },
+ }
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ tr, err := r.Transform(transformation)
+ c.Assert(err, qt.IsNil)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(content, qt.Equals, "color is blue")
+ c.Assert(tr.MediaType(), eq, media.CSVType)
+
+ // The transformed file should only be published if RelPermalink
+ // or Permalink is called.
+ n := htesting.Rnd.Intn(3)
+ shouldExist := true
+ switch n {
+ case 0:
+ tr.RelPermalink()
+ case 1:
+ tr.Permalink()
+ default:
+ shouldExist = false
+ }
+
+ assertShouldExist(c, spec, "public/f1.csv", shouldExist)
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Memory-cached transformation", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ // Two transformations with same id, different behaviour.
+ t1 := createContentReplacer("t1", "blue", "green")
+ t2 := createContentReplacer("t1", "color", "car")
+
+ for i, transformation := range []ResourceTransformation{t1, t2} {
+ r := createTransformer(spec, "f1.txt", "color is blue")
+ tr, _ := r.Transform(transformation)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, qt.Equals, "color is green", qt.Commentf("i=%d", i))
+
+ assertShouldExist(c, spec, "public/f1.t1.txt", false)
+ }
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("File-cached transformation", func(c *qt.C) {
+ c.Parallel()
+
+ fs := afero.NewMemMapFs()
+
+ for i := 0; i < 2; i++ {
+ spec := newTestResourceSpec(specDescriptor{c: c, fs: fs})
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ var transformation ResourceTransformation
+
+ if i == 0 {
+ // There is currently a hardcoded list of transformations that we
+ // persist to disk (tocss, postcss).
+ transformation = &testTransformation{
+ name: "tocss",
+ transform: func(ctx *ResourceTransformationCtx) error {
+ in := helpers.ReaderToString(ctx.From)
+ in = strings.Replace(in, "blue", "green", 1)
+ ctx.AddOutPathIdentifier("." + "cached")
+ ctx.OutMediaType = media.CSVType
+ ctx.Data = map[string]any{
+ "Hugo": "Rocks!",
+ }
+ fmt.Fprint(ctx.To, in)
+ return nil
+ },
+ }
+ } else {
+ // Force read from file cache.
+ transformation = &testTransformation{
+ name: "tocss",
+ transform: func(ctx *ResourceTransformationCtx) error {
+ return herrors.ErrFeatureNotAvailable
+ },
+ }
+ }
+
+ msg := qt.Commentf("i=%d", i)
+
+ tr, _ := r.Transform(transformation)
+ c.Assert(tr.RelPermalink(), qt.Equals, "/f1.cached.txt", msg)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, qt.Equals, "color is green", msg)
+ c.Assert(tr.MediaType(), eq, media.CSVType)
+ c.Assert(tr.Data(), qt.DeepEquals, map[string]any{
+ "Hugo": "Rocks!",
+ })
+
+ assertNoDuplicateWrites(c, spec)
+ assertShouldExist(c, spec, "public/f1.cached.txt", true)
+
+ }
+ })
+
+ c.Run("Access RelPermalink first", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ t1 := createContentReplacer("t1", "blue", "green")
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ tr, _ := r.Transform(t1)
+
+ relPermalink := tr.RelPermalink()
+
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(relPermalink, qt.Equals, "/f1.t1.txt")
+ c.Assert(content, qt.Equals, "color is green")
+ c.Assert(tr.MediaType(), eq, media.TextType)
+
+ assertNoDuplicateWrites(c, spec)
+ assertShouldExist(c, spec, "public/f1.t1.txt", true)
+ })
+
+ c.Run("Content two", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ t1 := createContentReplacer("t1", "blue", "green")
+ t2 := createContentReplacer("t1", "color", "car")
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ tr, _ := r.Transform(t1, t2)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(content, qt.Equals, "car is green")
+ c.Assert(tr.MediaType(), eq, media.TextType)
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Content two chained", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ t1 := createContentReplacer("t1", "blue", "green")
+ t2 := createContentReplacer("t2", "color", "car")
+
+ r := createTransformer(spec, "f1.txt", "color is blue")
+
+ tr1, _ := r.Transform(t1)
+ tr2, _ := tr1.Transform(t2)
+
+ content1, err := tr1.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ content2, err := tr2.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(content1, qt.Equals, "color is green")
+ c.Assert(content2, qt.Equals, "car is green")
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Content many", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ const count = 26 // A-Z
+
+ transformations := make([]ResourceTransformation, count)
+ for i := 0; i < count; i++ {
+ transformations[i] = createContentReplacer(fmt.Sprintf("t%d", i), fmt.Sprint(i), string(rune(i+65)))
+ }
+
+ var countstr strings.Builder
+ for i := 0; i < count; i++ {
+ countstr.WriteString(fmt.Sprint(i))
+ }
+
+ r := createTransformer(spec, "f1.txt", countstr.String())
+
+ tr, _ := r.Transform(transformations...)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+
+ c.Assert(content, qt.Equals, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Image", func(c *qt.C) {
+ c.Parallel()
+
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ transformation := &testTransformation{
+ name: "test",
+ transform: func(ctx *ResourceTransformationCtx) error {
+ ctx.AddOutPathIdentifier(".changed")
+ return nil
+ },
+ }
+
+ r := createTransformer(spec, "gopher.png", helpers.ReaderToString(gopherPNG()))
+
+ tr, err := r.Transform(transformation)
+ c.Assert(err, qt.IsNil)
+ c.Assert(tr.MediaType(), eq, media.PNGType)
+
+ img, ok := tr.(images.ImageResource)
+ c.Assert(ok, qt.Equals, true)
+
+ c.Assert(img.Width(), qt.Equals, 75)
+ c.Assert(img.Height(), qt.Equals, 60)
+
+ // RelPermalink called.
+ resizedPublished1, err := img.Resize("40x40")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resizedPublished1.Height(), qt.Equals, 40)
+ c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_3.png")
+ assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_40x40_resize_linear_3.png", true)
+
+ // Permalink called.
+ resizedPublished2, err := img.Resize("30x30")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resizedPublished2.Height(), qt.Equals, 30)
+ c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_3.png")
+ assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_30x30_resize_linear_3.png", true)
+
+ // Not published because none of RelPermalink or Permalink was called.
+ resizedNotPublished, err := img.Resize("50x50")
+ c.Assert(err, qt.IsNil)
+ c.Assert(resizedNotPublished.Height(), qt.Equals, 50)
+ // c.Assert(resized.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png")
+ assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_3.png", false)
+
+ assertNoDuplicateWrites(c, spec)
+ })
+
+ c.Run("Concurrent", func(c *qt.C) {
+ spec := newTestResourceSpec(specDescriptor{c: c})
+
+ transformers := make([]Transformer, 10)
+ transformations := make([]ResourceTransformation, 10)
+
+ for i := 0; i < 10; i++ {
+ transformers[i] = createTransformer(spec, fmt.Sprintf("f%d.txt", i), fmt.Sprintf("color is %d", i))
+ transformations[i] = createContentReplacer("test", strconv.Itoa(i), "blue")
+ }
+
+ var wg sync.WaitGroup
+
+ for i := 0; i < 13; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < 23; j++ {
+ id := (i + j) % 10
+ tr, err := transformers[id].Transform(transformations[id])
+ c.Assert(err, qt.IsNil)
+ content, err := tr.(resource.ContentProvider).Content()
+ c.Assert(err, qt.IsNil)
+ c.Assert(content, qt.Equals, "color is blue")
+ c.Assert(tr.RelPermalink(), qt.Equals, fmt.Sprintf("/f%d.test.txt", id))
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ assertNoDuplicateWrites(c, spec)
+ })
+}
+
+type testTransformation struct {
+ name string
+ transform func(ctx *ResourceTransformationCtx) error
+}
+
+func (t *testTransformation) Key() internal.ResourceTransformationKey {
+ return internal.NewResourceTransformationKey(t.name)
+}
+
+func (t *testTransformation) Transform(ctx *ResourceTransformationCtx) error {
+ return t.transform(ctx)
+}