211 lines
9.8 KiB
JavaScript
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); }); |