diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338fecd..c1ec959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - name: Install dependencies diff --git a/package-lock.json b/package-lock.json index c52fe20..4cf9b16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^15.15.0", + "puppeteer": "^25.1.0", "tailwindcss": "4.1.12", "typescript": "~5.7.3", "typescript-eslint": "^8.61.1", @@ -2275,6 +2276,31 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -5257,6 +5283,23 @@ "node": ">=18" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5595,6 +5638,13 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1624250", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dnd-core": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", @@ -6843,6 +6893,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6998,6 +7061,23 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/motion": { "version": "12.23.24", "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", @@ -7339,6 +7419,46 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/re2js": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/re2js/-/re2js-0.4.3.tgz", @@ -8264,6 +8384,13 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -8537,6 +8664,13 @@ "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -8613,6 +8747,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8669,6 +8825,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 755621b..9dc253a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --open", - "build": "vite build", + "build": "vite build && node scripts/prerender.js", "typecheck": "tsc --noEmit", "lint": "eslint .", "deploy": "npm run build && firebase deploy --only hosting", @@ -86,6 +86,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^15.15.0", + "puppeteer": "^25.1.0", "tailwindcss": "4.1.12", "typescript": "~5.7.3", "typescript-eslint": "^8.61.1", @@ -108,4 +109,4 @@ "vite": "6.3.5" } } -} \ No newline at end of file +} diff --git a/scripts/prerender.js b/scripts/prerender.js new file mode 100644 index 0000000..4380df1 --- /dev/null +++ b/scripts/prerender.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * Pre-rendering script for static HTML generation + * Runs after `vite build` to generate static HTML files for better SEO + */ + +import puppeteer from 'puppeteer'; +import fs from 'fs'; +import path from 'path'; +import http from 'http'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DIST_DIR = path.join(__dirname, '../dist'); +const PORT = 3000; +const ROUTES = ['/', '404.html']; + +// Simple HTTP server to serve dist files +function startServer(port) { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + // Normalize the URL pathname to prevent directory traversal + let urlPath = new URL(req.url, `http://localhost:${port}`).pathname; + urlPath = path.normalize(urlPath); + + // Default to index.html for root or 404.html + if (urlPath === '/' || urlPath === '/404.html') { + urlPath = '/index.html'; + } + + // Resolve the full file path + let filePath = path.resolve(DIST_DIR, '.' + urlPath); + const distDirResolved = path.resolve(DIST_DIR); + + // Validate that the resolved path is within DIST_DIR (prevent directory traversal) + if (!filePath.startsWith(distDirResolved)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const ext = path.extname(filePath); + const contentType = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + }[ext] || 'text/plain'; + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + }); + + server.listen(port, () => { + console.log(`[Prerender] Server started on http://localhost:${port}`); + resolve(server); + }); + }); +} + +async function prerender() { + console.log('[Prerender] Starting pre-rendering process...'); + + if (!fs.existsSync(DIST_DIR)) { + console.error(`[Prerender] Error: dist directory not found at ${DIST_DIR}`); + process.exit(1); + } + + let server; + let browser; + + try { + // Start server + server = await startServer(PORT); + + // Launch browser — --no-sandbox is required for GitHub Actions / CI environments + // where unprivileged user namespaces are disabled (Ubuntu 23.10+ with AppArmor). + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + console.log('[Prerender] Browser launched'); + + // Track failed routes for error reporting + const failedRoutes = []; + + // Pre-render each route + for (const route of ROUTES) { + // Skip 404.html as it's not a real route + if (route === '404.html') { + continue; + } + + const routePath = route === '/' ? '/index.html' : `/${route}`; + const url = `http://localhost:${PORT}${route}`; + + try { + const page = await browser.newPage(); + + // Set viewport to ensure consistent rendering + await page.setViewport({ width: 1280, height: 800 }); + + // Wait for page to fully load + await page.goto(url, { waitUntil: 'networkidle2' }); + console.log(`[Prerender] Rendered: ${route}`); + + // Get HTML content + const html = await page.content(); + + // Save to file + const outputPath = path.join(DIST_DIR, routePath); + const outputDir = path.dirname(outputPath); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputPath, html); + console.log(`[Prerender] Saved: ${outputPath}`); + + await page.close(); + } catch (error) { + console.error(`[Prerender] Error rendering ${route}:`, error.message); + failedRoutes.push(route); + } + } + + // Check for failed routes and exit with error if any occurred + if (failedRoutes.length > 0) { + throw new Error(`Pre-rendering failed for routes: ${failedRoutes.join(', ')}`); + } + + console.log('[Prerender] ✓ Pre-rendering completed successfully'); + } catch (error) { + console.error('[Prerender] Fatal error:', error); + process.exit(1); + } finally { + // Cleanup + if (browser) { + await browser.close(); + } + if (server) { + server.close(); + } + } +} + +prerender();