fix: serve theme assets from the production host, keep images on the CDN

This commit is contained in:
fromme committed 2026-09-25 17:35:07 +02:00
1 parent b5d0c484c0
commit f10d784a5c
6 files changed
+59 -57

No files matched your search

+4 -1
View File
@@ -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,
];
+10
View File
@@ -139,6 +139,16 @@ AddType image/webp .webp
</IfModule>
# 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.
<IfModule mod_headers.c>
<FilesMatch "\.[0-9a-f]{8}\.(js|css|woff2?|jpe?g|png|gif|svg|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
### End: Browser caching of resource files ###
@@ -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 <use href="...#icon"> 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.<hash>.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 {
@@ -57,13 +57,21 @@ page {
<link rel="icon" type="image/png" href="/typo3conf/ext/ep_theme/Resources/Public/images/favicon-196x196.png" sizes="196x196">
)
# 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 = <script src="{$plugin.tx_eptheme.settings.cookieFirstScriptUrl}"></script>
@@ -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");
+15 -7
View File
@@ -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
// `?<mtime>` 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();