diff --git a/deploy.php b/deploy.php index 45a001f8..e1c3b557 100644 --- a/deploy.php +++ b/deploy.php @@ -52,7 +52,10 @@ $rsyncOptions = [ 'filter' => [], 'filter-file' => false, 'filter-perdir' => false, - 'flags' => 'rz', + // -t preserves mtimes: without it every file lands with a fresh timestamp, so unchanged + // files get a new Last-Modified on every deploy and every conditional request revalidates + // into a full response instead of a 304. + 'flags' => 'rzt', 'options' => ['delete', 'links', 'chmod=D755,F664'], 'timeout' => 300, ]; diff --git a/public/.htaccess b/public/.htaccess index f2387c33..3bc1719d 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -139,6 +139,16 @@ AddType image/webp .webp +# Webpack writes an 8-hex-character content hash into the filename of everything it emits, so +# these URLs can never change meaning - a new build is a new URL. `immutable` stops browsers +# revalidating them on reload, and overrides the per-type rules above, which would otherwise +# expire the hashed fonts after a month. + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + ### End: Browser caching of resource files ### diff --git a/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Extensions/Replacer.typoscript b/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Extensions/Replacer.typoscript index da25e3d0..beba97c9 100644 --- a/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Extensions/Replacer.typoscript +++ b/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Extensions/Replacer.typoscript @@ -1,17 +1,23 @@ config.tx_replacer { enable_regex = 1 - # The quote character is captured rather than matched literally, because the - # @font-face block in Page/Main.typoscript writes its url() values with single - # quotes - anchoring on `"` alone would skip every font. - # typo3conf is scoped to ep_theme/Resources/Public on purpose: it keeps other - # extensions, Configuration/ and Resources/Private/ out of the CDN. - # The icon sprite is excluded: SVG may only reference a - # document from the same origin - browsers block an external sprite on the CDN - # host outright, and no CORS header on bunny.net changes that. It has to stay - # on the origin domain or every icon renders empty. + # Only images go to the CDN. The theme's own css/js/fonts are served by the production + # host: they are already minified by Webpack, gzipped by mod_deflate and content-hashed + # for a year of immutable browser caching, and keeping them on the page's own origin + # means they arrive over the connection the browser has already opened rather than + # costing a second DNS lookup and TLS handshake on the critical path. + # + # It also keeps them clear of the pull zone's habit of occasionally wedging a single + # cache key (10.0s -> 504 errorcode 106 until that key is purged, while the origin + # serves the same bytes in under 100ms). For an image that degrades one element; for + # js/main..js it would take every visitor's JavaScript down. + # + # typo3temp is scoped to generated images on purpose - assets/compressed holds other + # extensions' concatenated css/js, which belongs on the origin for the same reason. + # The quote character is captured rather than matched literally, so url() values + # written with single quotes are rewritten too. search { - 10 = /(["'])\/?(fileadmin|typo3temp|uploads|typo3conf\/ext\/ep_theme\/Resources\/Public(?!\/images\/icons_sprite\.svg))/ + 10 = /(["'])\/?(fileadmin|uploads|typo3temp\/assets\/(?:_processed_|images))/ } replace { diff --git a/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Page/Main.typoscript b/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Page/Main.typoscript index 08de6934..b12f9bfb 100644 --- a/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Page/Main.typoscript +++ b/public/typo3conf/ext/ep_theme/Configuration/TypoScript/Page/Main.typoscript @@ -57,13 +57,21 @@ page { ) + # disableCompression keeps ResourceCompressor's hands off the Encore entries: Webpack + # already minifies them, and copying them to typo3temp/assets/compressed only replaces the + # content hash with a name derived from md5(abspath + mtime + size), which changes on every + # deploy even when the bytes do not - defeating the immutable caching the hash exists for. + # RequestHandler turns this into `compress => false`, which PageRendererHooks forwards to + # typo3_encore's TagRenderer (whose own default it would otherwise override). includeCSS { main = typo3_encore:EpThemeConfig:main + main.disableCompression = 1 } includeJSFooter { main = typo3_encore:EpThemeConfig:main main.defer = 1 + main.disableCompression = 1 } 10 = FLUIDTEMPLATE @@ -150,45 +158,6 @@ page { settings < plugin.tx_eptheme.settings } - CSS_inlineStyle ( -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: local(''), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-300.woff2') format('woff2'), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-300.woff') format('woff'); -} -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: local(''), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-regular.woff2') format('woff2'), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-regular.woff') format('woff'); -} -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: local(''), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-700.woff2') format('woff2'), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-700.woff') format('woff'); -} -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: local(''), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-900.woff2') format('woff2'), - url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-900.woff') format('woff'); -} - ) - headerData.990 = TEXT headerData.990 { value = diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/main.scss b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/main.scss index d1bae296..5dc70230 100644 --- a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/main.scss +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/main.scss @@ -1,5 +1,11 @@ @use "sass:meta"; +// Outside the cascade layers on purpose: @font-face is invalid inside @layer, which is why +// _base.scss leaves base/fonts out. Loading it here lets Webpack resolve the url()s to +// content-hashed filenames, and replaces the hand-maintained @font-face block that used to +// live in ep_theme Page/Main.typoscript. +@include meta.load-css("base/fonts"); + @include meta.load-css("tailwindcss/base"); @include meta.load-css("base"); diff --git a/webpack.config.js b/webpack.config.js index 8a7f8158..cf4a8f72 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,7 +12,10 @@ Encore .addEntry('main', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/main.js') .addStyleEntry('rte', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/rte.scss') .splitEntryChunks() - .enableSingleRuntimeChunk() + // There is only one JS entry, so a shared runtime chunk buys nothing and costs a separate + // 1.6KB request that every other bundle hard-depends on. Inlining it into main.js removes + // both, and stops the `rte` style entry from dragging a JS file in behind it. + .disableSingleRuntimeChunk() .enableSassLoader(options => { // Opt into the modern Sass JS API (sass-loader defaults to the legacy one, // which Dart Sass 2.0 removes). Encore still passes `outputStyle`, which @@ -37,20 +40,25 @@ Encore } }) .copyFiles([ - { from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/fonts', to: 'fonts/[name].[ext]' }, { from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images', to: 'images/[name].[ext]' }, { from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images/icons', to: 'images/icons/[name].[ext]' }, { from: './node_modules/leaflet/dist/images', to: 'images/[name].[ext]' }, ]) + // The content hash is what lets .htaccess serve these as `immutable` for a year: a new + // build is a new URL, so a browser never has to revalidate one. It also replaces TYPO3's + // `?` query buster, which changed on every deploy even when the bytes did not. + // `rte` stays unhashed - Configuration/RTE/*.yaml reference rte.css by literal path. .configureFilenames({ - js: 'js/[name].js', - css: 'css/[name].css', + js: Encore.isProduction() ? 'js/[name].[contenthash:8].js' : 'js/[name].js', + css: (pathData) => Encore.isProduction() && pathData.chunk.name !== 'rte' + ? 'css/[name].[contenthash:8].css' + : 'css/[name].css', }) .configureImageRule({ - filename: 'images/[name][ext]', + filename: 'images/[name].[contenthash:8][ext]', }) .configureFontRule({ - filename: 'fonts/[name][ext]', + filename: 'fonts/[name].[contenthash:8][ext]', }) .configureBabelPresetEnv((config) => { config.shippedProposals = true @@ -59,7 +67,7 @@ Encore const EpThemeConfig = Encore.getWebpackConfig(); EpThemeConfig.name = 'EpThemeConfig'; -EpThemeConfig.output.chunkFilename = 'js/[name].[chunkhash:8].js'; +EpThemeConfig.output.chunkFilename = 'js/[name].[contenthash:8].js'; Encore.reset();