Files

211 lines
9.8 KiB
JavaScript

#!/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 <title> +
* <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}</title>`);
}
if (desc && !/<meta\s+name="description"\s+content="[^"]*"/i.test(out)) {
out = out.replace("<head>", `<head><meta name="description" content="${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>(.*?)<\/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); });