Add prerendering

This commit is contained in:
Andras Schmelczer 2026-02-03 19:25:41 +00:00
parent 0242722268
commit a42591c701
6 changed files with 1009 additions and 48 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "webpack serve --mode development --port 3030", "dev": "webpack serve --mode development --port 3030",
"build": "webpack --mode production", "build": "webpack --mode production && node scripts/prerender.mjs",
"build:no-prerender": "webpack --mode production",
"prerender": "node scripts/prerender.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx", "lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix", "lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -38,9 +40,11 @@
"eslint-plugin-react": "^7.34.0", "eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"postcss-loader": "^8.0.0", "postcss-loader": "^8.0.0",
"prettier": "^3.2.0", "prettier": "^3.2.0",
"puppeteer": "^24.0.0",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"ts-loader": "^9.5.0", "ts-loader": "^9.5.0",

View file

@ -0,0 +1,140 @@
import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, extname } from 'path';
import { launch } from 'puppeteer';
const DIST_DIR = join(import.meta.dirname, '..', 'dist');
const INDEX_PATH = join(DIST_DIR, 'index.html');
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
};
function startServer() {
return new Promise((resolve) => {
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
let filePath = join(DIST_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
// SPA fallback
filePath = INDEX_PATH;
}
const ext = extname(filePath);
const mime = MIME_TYPES[ext] || 'application/octet-stream';
const content = readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(content);
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
async function prerender() {
console.log('Starting prerender...');
const { server, port } = await startServer();
console.log(`Static server on port ${port}`);
const browser = await launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
// Intercept API requests to prevent real fetches and retry loops
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/features')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
});
await page.goto(`http://127.0.0.1:${port}/`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
// Wait for the home page heading to render
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
// Strip fade-in-visible classes (added by IntersectionObserver effects)
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
});
// Clean canvas elements (dimensions set by ResizeObserver effect)
root.querySelectorAll('canvas').forEach((canvas) => {
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
});
return root.innerHTML;
});
if (!html || html.length < 100) {
throw new Error('Prerender produced too little HTML — something went wrong');
}
// Inject into dist/index.html
const indexHtml = readFileSync(INDEX_PATH, 'utf-8');
const updated = indexHtml.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
if (updated === indexHtml) {
throw new Error('Could not find <div id="root"></div> in index.html');
}
writeFileSync(INDEX_PATH, updated);
console.log(`Prerendered ${html.length} chars into dist/index.html`);
} finally {
await browser.close();
server.close();
}
}
prerender().catch((err) => {
console.error('Prerender failed:', err);
process.exit(1);
});

View file

@ -36,6 +36,7 @@ interface MapProps {
onHexagonHover: (h3: string | null) => void; onHexagonHover: (h3: string | null) => void;
initialViewState?: ViewState; initialViewState?: ViewState;
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
screenshotMode?: boolean;
} }
const INITIAL_VIEW: ViewState = { const INITIAL_VIEW: ViewState = {
@ -86,6 +87,7 @@ export default memo(function Map({
onHexagonHover, onHexagonHover,
initialViewState, initialViewState,
theme = 'light', theme = 'light',
screenshotMode = false,
}: MapProps) { }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW); const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -389,51 +391,64 @@ export default memo(function Map({
> >
<DeckOverlay layers={layers} getTooltip={null} /> <DeckOverlay layers={layers} getTooltip={null} />
</MapGL> </MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} /> {screenshotMode ? (
{viewSource === 'eye' && viewFeature && ( <div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3"> <h1
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100"> className="text-5xl font-bold text-white drop-shadow-lg"
Previewing &ldquo;{viewFeature}&rdquo; style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
</span>
<button
onClick={onCancelPin}
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
> >
Cancel Your perfect postcodes
</button> </h1>
</div> </div>
)}
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : ( ) : (
<MapLegend <>
featureLabel="Property density" <PostcodeSearch onFlyTo={handleFlyTo} />
range={[0, 0]} {viewSource === 'eye' && viewFeature && (
showCancel={false} <div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
onCancel={onCancelPin} <span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
mode="density" Previewing &ldquo;{viewFeature}&rdquo;
/> </span>
)} <button
{popupInfo && ( onClick={onCancelPin}
<div className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200" >
style={{ Cancel
left: popupInfo.x, </button>
top: popupInfo.y - 40, </div>
transform: 'translateX(-50%)', )}
zIndex: 9999, {viewFeature && colorRange && colorFeatureMeta ? (
}} <MapLegend
> featureLabel={colorFeatureMeta.name}
<strong>{popupInfo.name}</strong> range={colorRange}
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</div> showCancel={viewSource === 'eye'}
</div> onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
/>
)}
{popupInfo && (
<div
className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
transform: 'translateX(-50%)',
zIndex: 9999,
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
</div>
)}
</>
)} )}
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { createRoot } from 'react-dom/client'; import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { initPlausible } from './usePlausible'; import { initPlausible } from './usePlausible';
@ -9,5 +9,9 @@ const container = document.getElementById('root');
if (!container) { if (!container) {
throw new Error('Root element not found'); throw new Error('Root element not found');
} }
const root = createRoot(container);
root.render(<App />); if (container.children.length > 0) {
hydrateRoot(container, <App />);
} else {
createRoot(container).render(<App />);
}

View file

@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => { module.exports = (env, argv) => {
const isProduction = argv.mode === 'production'; const isProduction = argv.mode === 'production';
@ -10,7 +11,7 @@ module.exports = (env, argv) => {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js', filename: 'bundle.js',
clean: true, clean: true,
// Empty string generates relative paths that work through proxies // Empty string generates relative paths that work through proxies
publicPath: '', publicPath: '',
}, },
@ -26,7 +27,11 @@ module.exports = (env, argv) => {
}, },
{ {
test: /\.css$/, test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'], use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'postcss-loader',
],
}, },
], ],
}, },
@ -34,6 +39,7 @@ module.exports = (env, argv) => {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: './src/index.html', template: './src/index.html',
}), }),
...(isProduction ? [new MiniCssExtractPlugin()] : []),
], ],
devServer: { devServer: {
port: 3000, port: 3000,