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",
|
"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",
|
||||||
|
|
|
||||||
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;
|
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 “{viewFeature}”
|
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 “{viewFeature}”
|
||||||
/>
|
</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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 />);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue