#!/usr/bin/env node /** * Pre-render script for TailTrails * * Renders every route in Puppeteer so crawlers see full HTML content * (blogs, landing, legal pages), and injects a unique + * <meta name="description"> per page for SEO. * * Usage: node scripts/prerender.mjs * Runs as part of: npm run build */ import puppeteer from "puppeteer"; import { createServer } from "http"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DIST_DIR = path.resolve(__dirname, "..", "dist"); const POSTS_DIR = path.resolve(__dirname, "..", "src", "blogs", "posts"); const CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6; // ── Static routes ──────────────────────────────────────────────────────────── const STATIC_ROUTES = [ "/", "/blog", "/compare", "/compare/dog-walking-apps-compared", "/compare/tailtrails-vs-wooftrax", "/compare/tailtrails-vs-tractive", "/compare/tailtrails-vs-alltrails", "/compare/tailtrails-vs-fitbark", "/compare/tailtrails-vs-pitpat", "/privacy", "/terms", "/forget-me", "/finalize-forget-me", ]; // ── Per-route SEO metadata ─────────────────────────────────────────────────── const DEFAULT_TITLE = "TailTrails — Life Unleashed"; const DEFAULT_DESC = "Discover new trails that match your dog's needs, and turn everyday walks into real adventures. Download TailTrails on iOS and Android."; const STATIC_SEO = { "/": { title: DEFAULT_TITLE, desc: DEFAULT_DESC }, "/blog": { title: "TailTrails Blog — Dog Walking Tips, Trails & Guides", desc: "Guides on choosing the right walks for your dog, off-leash rules in Portugal, hidden dangers, and the best hikes in the Algarve.", }, "/privacy": { title: "Privacy Policy — TailTrails", desc: "How TailTrails handles your data and privacy." }, "/terms": { title: "Terms & Conditions — TailTrails", desc: "The terms and conditions for using TailTrails." }, "/forget-me": { title: "Delete Your Account — TailTrails", desc: "Request deletion of your TailTrails account and data." }, "/finalize-forget-me": { title: "Confirm Account Deletion — TailTrails", desc: "Confirm deletion of your TailTrails account." }, "/download": { title: "Download TailTrails — iOS & Android", desc: "Get TailTrails on the App Store or Google Play." }, }; // ── Discover blog slugs + metadata from src/blogs/posts/*.tsx ──────────────── // ponytail: read slugs from the filesystem so the prerender list can't drift // from the actual posts. Meta is regex-pulled from each file's `export const meta`. function discoverPosts() { if (!fs.existsSync(POSTS_DIR)) return []; return fs.readdirSync(POSTS_DIR) .filter((f) => f.endsWith(".tsx")) .map((f) => { const slug = f.replace(/\.tsx$/, ""); const src = fs.readFileSync(path.join(POSTS_DIR, f), "utf-8"); const title = src.match(/title:\s*'([^']*)'/)?.[1]; const description = src.match(/description:\s*'([^']*)'/)?.[1]; return { slug, title, description }; }); } const posts = discoverPosts(); const BLOG_ROUTES = posts.map((p) => `/blog/${p.slug}`); // ── Static file server (SPA fallback) ──────────────────────────────────────── function startServer(port) { const mimes = { ".html": "text/html", ".js": "application/javascript", ".mjs": "application/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".webp": "image/webp", ".avif": "image/avif", ".mp4": "video/mp4", ".txt": "text/plain", }; return new Promise((resolve, reject) => { const server = createServer((req, res) => { let fp = path.join(DIST_DIR, req.url === "/" ? "index.html" : req.url); if (!fs.existsSync(fp) || fs.statSync(fp).isDirectory()) fp = path.join(DIST_DIR, "index.html"); fs.readFile(fp, (err, data) => { if (err) { res.writeHead(404); res.end("Not found"); return; } res.writeHead(200, { "Content-Type": mimes[path.extname(fp)] || "application/octet-stream" }); res.end(data); }); }); server.listen(port, () => resolve(server)); server.on("error", reject); }); } // ── Fallback-only SEO injection ───────────────────────────────────────────── // Every prerendered route now mounts a <SEO> component that sets <title>, // description, OG, Twitter, canonical and JSON-LD during the Puppeteer render. // This only fills in the gaps if a page forgot to mount <SEO> (title still the // index.html placeholder "Tail Trails", or no description meta present). function injectSeo(html, title, desc) { let out = html; if (title && /<title>Tail Trails<\/title>/i.test(out)) { out = out.replace(/<title>Tail Trails<\/title>/i, `<title>${title}`); } if (desc && !/", ``); } return out; } function seoFor(route) { if (STATIC_SEO[route]) return STATIC_SEO[route]; const post = route.startsWith("/blog/") ? posts.find((p) => `/blog/${p.slug}` === route) : null; if (post) { return { title: post.title ? `${post.title} — TailTrails Blog` : "TailTrails Blog", desc: post.description || DEFAULT_DESC, }; } return { title: DEFAULT_TITLE, desc: DEFAULT_DESC }; } // ── Render one page ────────────────────────────────────────────────────────── async function prerenderRoute(browser, url, route) { const page = await browser.newPage(); try { await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 }); await page.waitForFunction( () => { const r = document.getElementById("root"); return r && r.children.length > 0; }, { timeout: 10000 } ); // let animations / images settle await new Promise((r) => setTimeout(r, 400)); let html = await page.content(); const { title, desc } = seoFor(route); html = injectSeo(html, title, desc); const outDir = route === "/" ? DIST_DIR : path.join(DIST_DIR, route); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, "index.html"), html, "utf-8"); return { route, ok: true }; } catch (err) { return { route, ok: false, error: err.message }; } finally { await page.close(); } } // ── Parallel batch renderer ────────────────────────────────────────────────── async function renderBatch(browser, routes, baseUrl, concurrency) { let done = 0; const total = routes.length; const errors = []; for (let i = 0; i < total; i += concurrency) { const batch = routes.slice(i, i + concurrency); const results = await Promise.all(batch.map((r) => prerenderRoute(browser, `${baseUrl}${r}`, r))); for (const r of results) { done++; r.ok ? console.log(` ✓ [${done}/${total}] ${r.route}`) : console.error(` ✗ [${done}/${total}] ${r.route}: ${r.error}`); if (!r.ok) errors.push(r.route); } } return errors; } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { if (!fs.existsSync(DIST_DIR)) { console.error("Error: dist/ not found. Run 'vite build' first."); process.exit(1); } console.log("🔄 Pre-rendering pages for SEO...\n"); console.log(` Discovered ${posts.length} blog post(s): ${posts.map((p) => p.slug).join(", ")}\n`); const routes = [...STATIC_ROUTES, ...BLOG_ROUTES]; const PORT = 4823; const BASE = `http://localhost:${PORT}`; const server = await startServer(PORT); console.log(` Serving dist/ on ${BASE}`); console.log(` Rendering ${routes.length} pages in Puppeteer (concurrency ${CONCURRENCY})...\n`); const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], }); const errors = await renderBatch(browser, routes, BASE, CONCURRENCY); await browser.close(); server.close(); console.log(`\n✅ Pre-rendered ${routes.length} pages.`); if (errors.length) console.log(` ⚠️ ${errors.length} route(s) failed: ${errors.join(", ")}`); // Verify key pages console.log("\n📋 Verifying key pages...\n"); const verify = ["/", "/blog", ...(BLOG_ROUTES.length ? [BLOG_ROUTES[0]] : [])]; for (const route of verify) { const fp = route === "/" ? path.join(DIST_DIR, "index.html") : path.join(DIST_DIR, route, "index.html"); if (!fs.existsSync(fp)) { console.log(` ⚠ ${route}: not found`); continue; } const html = fs.readFileSync(fp, "utf-8"); const title = html.match(/(.*?)<\/title>/)?.[1] || "MISSING"; const desc = html.match(/<meta\s+name="description"\s+content="([^"]+)"/)?.[1]?.slice(0, 70) || "MISSING"; const rootHasContent = html.includes("Every walk") || html.includes("post-content") || html.includes("Latest Articles"); console.log(` ${route}\n Title: ${title}\n Desc: ${desc}…\n Content in HTML: ${rootHasContent}\n`); } } main().catch((err) => { console.error("Pre-rendering failed:", err); process.exit(1); });