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();