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",
"scripts": {
"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",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -38,9 +40,11 @@
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",
"postcss-loader": "^8.0.0",
"prettier": "^3.2.0",
"puppeteer": "^24.0.0",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.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;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
}
const INITIAL_VIEW: ViewState = {
@ -86,6 +87,7 @@ export default memo(function Map({
onHexagonHover,
initialViewState,
theme = 'light',
screenshotMode = false,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -389,6 +391,17 @@ export default memo(function Map({
>
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
{screenshotMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcodes
</h1>
</div>
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewSource === 'eye' && viewFeature && (
<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">
@ -435,6 +448,8 @@ export default memo(function Map({
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</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 './index.css';
import { initPlausible } from './usePlausible';
@ -9,5 +9,9 @@ const container = document.getElementById('root');
if (!container) {
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 HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
@ -26,7 +27,11 @@ module.exports = (env, argv) => {
},
{
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({
template: './src/index.html',
}),
...(isProduction ? [new MiniCssExtractPlugin()] : []),
],
devServer: {
port: 3000,