diff --git a/frontend/.gitignore b/frontend/.gitignore index 23f50edb..bff5f503 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,6 @@ node_modules/ public/build/ +public/index.html dist/ coverage/ test-results/ diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index f4b445c4..096a507b 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -85,11 +85,6 @@ server { expires 1y; } - # Cache build directory assets with long expiry - location /build/ { - expires 1y; - } - # HTML files should not be cached location ~* \.html$ { expires -1; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c45a7909..1b81a318 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "@playwright/test": "^1.58.2", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-html": "^2.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.1", @@ -67,6 +68,7 @@ "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "rollup": "^4.59.0", + "rollup-plugin-cleandir": "^3.0.0", "rollup-plugin-css-only": "^4.3.0", "rollup-plugin-livereload": "^2.0.0", "rollup-plugin-postcss": "^4.0.2", @@ -1447,6 +1449,15 @@ "svelte": "^5.0.0" } }, + "node_modules/@mstssk/cleandir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@mstssk/cleandir/-/cleandir-2.0.0.tgz", + "integrity": "sha512-CidYeaV4VQLIMbZlYqs0SJaZe/DyI0E4nsbFmPtCa2koKzMjZj/BThTCb+bvzcGhzp2A4Js1c4jDg6lqaqapyQ==", + "dev": true, + "bin": { + "cleandir": "bin/cleandir.js" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1546,6 +1557,23 @@ } } }, + "node_modules/@rollup/plugin-html": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-html/-/plugin-html-2.0.0.tgz", + "integrity": "sha512-6S0ezKfDRw5FvHk3xm1T/eXP9IlLf82zmV+z2V1/yQL5hXwIUL/Gl2RrNMMIe2rt85rk6+0smvSAtjfTLGylDQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", @@ -8330,6 +8358,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-cleandir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-cleandir/-/rollup-plugin-cleandir-3.0.0.tgz", + "integrity": "sha512-+1AlxObWpWechyLVcnpjbxBiYlQg7bXF7vw5fu6P9B0B8R4meQliG7aGCnK8MvtdXzKsjeI0lJc43d0UcQj1fg==", + "dev": true, + "dependencies": { + "@mstssk/cleandir": "^2.0.0" + }, + "peerDependencies": { + "rollup": ">=4.0.0" + } + }, "node_modules/rollup-plugin-css-only": { "version": "4.5.5", "resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index b23307d3..158d692e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "@playwright/test": "^1.58.2", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-html": "^2.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.1", @@ -79,6 +80,7 @@ "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "rollup": "^4.59.0", + "rollup-plugin-cleandir": "^3.0.0", "rollup-plugin-css-only": "^4.3.0", "rollup-plugin-livereload": "^2.0.0", "rollup-plugin-postcss": "^4.0.2", diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index 20745f65..9cab9b47 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -8,10 +8,13 @@ import replace from '@rollup/plugin-replace'; import typescript from '@rollup/plugin-typescript'; import alias from '@rollup/plugin-alias'; import dotenv from 'dotenv'; -import fs from 'fs'; -import https from 'https'; -import path from 'path'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; import json from '@rollup/plugin-json'; +import html from '@rollup/plugin-html'; +import { cleandir } from 'rollup-plugin-cleandir'; // Path aliases - must match tsconfig.json paths const projectRoot = path.resolve('.'); @@ -134,7 +137,9 @@ export default { sourcemap: !production, format: 'es', name: 'app', - dir: 'public/build', + dir: 'public', + entryFileNames: production ? 'build/[name]-[hash].js' : 'build/[name].js', + chunkFileNames: 'build/[name]-[hash].js', manualChunks: { 'vendor': [ 'svelte', @@ -175,7 +180,7 @@ export default { } }), postcss({ - extract: 'bundle.css', + extract: 'build/bundle.css', minimize: false, }), typescript({ @@ -199,6 +204,33 @@ export default { startServer(); } }, + production && cleandir('public/build', { hook: 'generateBundle' }), + production && { + name: 'css-hash', + generateBundle(_, bundle) { + const css = bundle['build/bundle.css']; + if (!css) return; + const hash = crypto.createHash('sha256').update(css.source).digest('hex').slice(0, 8); + const name = `build/bundle-${hash}.css`; + css.fileName = name; + bundle[name] = css; + delete bundle['build/bundle.css']; + }, + }, + html({ + template({ bundle }) { + let tmpl = fs.readFileSync('src/index.html', 'utf-8'); + const entryChunk = Object.values(bundle).find(f => f.type === 'chunk' && f.isEntry); + const cssAsset = Object.values(bundle).find(f => f.type === 'asset' && f.fileName.endsWith('.css')); + if (cssAsset) { + tmpl = tmpl.replace('', ` \n`); + } + if (entryChunk) { + tmpl = tmpl.replace('', ` \n`); + } + return tmpl; + }, + }), production && terser({ ecma: 2020, module: true, diff --git a/frontend/public/index.html b/frontend/src/index.html similarity index 94% rename from frontend/public/index.html rename to frontend/src/index.html index 109cd071..e18b7223 100644 --- a/frontend/public/index.html +++ b/frontend/src/index.html @@ -30,9 +30,6 @@ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } @keyframes spin { to { transform: rotate(360deg); } } - - -