The bundles carried all thirteen translations at once and a page fetched them a third time as raw source; each page now loads only its own language. Alongside that: JavaScript is minified, the eight stylesheets are served as one file, the QR scanner is fetched after the app is up instead of on every visit, Inter ships once rather than five copies of the same file, and Font Awesome is subset to the 82 icons this app draws instead of all 2468. 1.85 MB across 43 requests becomes under 700 KB across 33. On mobile the page starts drawing in 1.6 s instead of 6.3 s and is usable in 4.5 s instead of 11 s. Pages also carry their text in the HTML now. Everything was drawn by JavaScript into an empty div, so crawlers saw correct metadata around nothing, and twelve of the thirteen language pages had never been shown to anyone. The documentation is published under /docs/ with a new FAQ, and unknown addresses return a real 404. Separately: the localized shells were served with the year-long immutable cache header meant for static assets, which pinned anyone who opened /de/ or /ru/ to that build. The header is fixed and the service worker refreshes what it cached. Claude-Session: https://claude.ai/code/session_014KjzTXxrhzYoDDWChYQ4u2
93 lines
3.8 KiB
JavaScript
93 lines
3.8 KiB
JavaScript
/**
|
|
* build-css.js — concatenates every render-blocking stylesheet into one file.
|
|
*
|
|
* The page used to link eight of them: the Tailwind build, the Inter @font-face
|
|
* declarations, five hand-written sheets, and pwa.css at the end of <body>. Together
|
|
* they are about 21 KB compressed, which is nothing — but each one is a separate
|
|
* request that has to complete before the browser paints, and on a throttled mobile
|
|
* connection each cost between 400 and 900 ms of latency for a file of 1 to 7 KB.
|
|
* GTmetrix put the whole thing at 441 ms of render-blocking time, spent almost
|
|
* entirely on round trips rather than bytes.
|
|
*
|
|
* Order is the one thing this must not get wrong. The sheets are written to argue with
|
|
* each other on source order: components.css settles layout, apple-motion.css then
|
|
* overrides press feedback and reduced-motion behaviour on top of it, and rtl.css has
|
|
* to win over everything because its rules only apply under [dir="rtl"]. Concatenating
|
|
* in the same sequence the <link> tags had preserves that exactly — the cascade cannot
|
|
* tell the difference between eight files and one.
|
|
*
|
|
* The only rewriting done here is url(): inter.css refers to its woff2 files relatively,
|
|
* and those paths are resolved against the sheet's own directory before it moves.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const OUT = path.join(ROOT, 'assets', 'app.css');
|
|
|
|
// The exact sequence the <link> tags had, head first and pwa.css last.
|
|
const SHEETS = [
|
|
'assets/tailwind.css',
|
|
// Font Awesome, cut to the 82 icons this interface draws (scripts/subset-icons.py).
|
|
// The full sheet was 102 KB and loaded asynchronously to keep it off the critical
|
|
// path; at 7 KB it is cheaper to have it here than to spend a request on it, and
|
|
// icons stop arriving a beat after the text.
|
|
'assets/fontawesome/css/subset.css',
|
|
'assets/fonts/inter/inter.css',
|
|
'src/styles/main.css',
|
|
'src/styles/animations.css',
|
|
'src/styles/components.css',
|
|
'src/styles/apple-motion.css',
|
|
'src/styles/rtl.css',
|
|
'src/styles/pwa.css',
|
|
];
|
|
|
|
/**
|
|
* Make every relative url() root-absolute, resolved against the sheet it came from.
|
|
* Absolute paths, full URLs and data: URIs are already correct and left alone.
|
|
*/
|
|
function absolutizeUrls(css, sheetPath) {
|
|
const dir = path.posix.dirname(`/${sheetPath}`);
|
|
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (whole, quote, target) => {
|
|
if (/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(target.trim())) return whole;
|
|
return `url(${quote}${path.posix.join(dir, target.trim())}${quote})`;
|
|
});
|
|
}
|
|
|
|
function build() {
|
|
const parts = [];
|
|
for (const sheet of SHEETS) {
|
|
const file = path.join(ROOT, sheet);
|
|
if (!fs.existsSync(file)) {
|
|
throw new Error(`stylesheet listed in build-css.js is missing: ${sheet}`);
|
|
}
|
|
parts.push(`/* ${sheet} */\n${absolutizeUrls(fs.readFileSync(file, 'utf8'), sheet)}`);
|
|
}
|
|
|
|
const combined = parts.join('\n\n');
|
|
// esbuild minifies from stdin so nothing intermediate has to be written to disk.
|
|
const minified = execFileSync(
|
|
'npx',
|
|
['--no-install', 'esbuild', '--loader=css', '--minify'],
|
|
{ input: combined, encoding: 'utf8', cwd: ROOT, maxBuffer: 32 * 1024 * 1024 }
|
|
);
|
|
|
|
fs.writeFileSync(OUT, minified, 'utf8');
|
|
return { bytes: Buffer.byteLength(minified), sheets: SHEETS.length };
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
const { bytes, sheets } = build();
|
|
console.log('🎨 Bundling stylesheets...');
|
|
console.log(` ✅ assets/app.css — ${sheets} sheets, ${bytes.toLocaleString('en-US')} bytes`);
|
|
} catch (error) {
|
|
console.error('❌ css build failed:', error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = { build, SHEETS };
|