Add prerendering
This commit is contained in:
parent
0242722268
commit
a42591c701
6 changed files with 1009 additions and 48 deletions
792
frontend/package-lock.json
generated
792
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
140
frontend/scripts/prerender.mjs
Normal file
140
frontend/scripts/prerender.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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,51 +391,64 @@ export default memo(function Map({
|
|||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
<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">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
Previewing “{viewFeature}”
|
||||
</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"
|
||||
{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)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
Your perfect postcodes
|
||||
</h1>
|
||||
</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"
|
||||
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>
|
||||
<>
|
||||
<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">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
Previewing “{viewFeature}”
|
||||
</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
|
||||
</button>
|
||||
</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"
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -10,7 +11,7 @@ module.exports = (env, argv) => {
|
|||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
clean: true,
|
||||
|
||||
|
||||
// Empty string generates relative paths that work through proxies
|
||||
publicPath: '',
|
||||
},
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue