adding prerender, updating blogs, and make images more web friendly

This commit is contained in:
itsamejms
2026-08-24 13:06:15 +01:00
parent 558888118f
commit 8be114f3d7
79 changed files with 2878 additions and 1333 deletions
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Generate one topical image per blog post with stable-diffusion.cpp
# using Juggernaut XL v9 (photoreal SDXL fine-tune).
# euler_a + 1024² + no --mmap is the stable combo on Apple Metal here
# (DPM++2M/Karras/--mmap segfault on this M4 — known large-matrix Metal issue).
# Outputs WebP into public/blog-images/<slug>.webp.
set -euo pipefail
SD=~/stable-diffusion.cpp/build/bin/sd-cli
MODEL=~/sd-models/juggernautXL_v9.safetensors
OUT=/Users/jamestwose/Coding/tt-website/public/blog-images
TMP=/tmp/sd_blog
# Juggernaut README: heavy negatives hurt more than they help — keep it minimal.
NEG="text, watermark, signature, low quality, deformed, extra limbs"
STEPS=${STEPS:-20}
mkdir -p "$OUT" "$TMP"
# slug|prompt (prompts use Juggernaut's reliable tokens: Wildlife/Landscape
# Photography, Hyperdetailed Photography, Cinematic, natural light)
POSTS=(
"best-off-leash-areas-for-dogs-in-lagos-and-nearby|Wildlife Photography, a happy dog running off-leash on a wide open sandy path through a Portuguese pine forest, dappled golden sunlight, Hyperdetailed Photography, natural light"
"how-to-choose-the-right-walk-for-your-dog-based-on-their-personality-and-needs|Wildlife Photography, a dog owner walking a calm happy dog on a quiet countryside dirt path at sunrise, soft warm light, Hyperdetailed Photography"
"how-to-give-a-reactive-dog-a-fulfilling-life-without-avoiding-the-world|Wildlife Photography, a calm dog on a quiet empty woodland trail with plenty of space, soft natural morning light, peaceful, Hyperdetailed Photography"
"off-leash-dog-rules-in-portugal-what-you-need-to-know-in-2026|Landscape Photography, a dog on a lead beside a coastal cliff path in Portugal with a blue ocean and sky, summer, Hyperdetailed Photography, Cinematic"
"the-importance-of-variety-in-your-dogs-life-and-how-to-do-it-right|Wildlife Photography, a curious dog discovering a new forest trail, sniffing the ground, vibrant green foliage, Hyperdetailed Photography, natural light"
"top-10-hikes-to-do-with-your-dog-in-lagos-and-the-algarve|Landscape Photography, a hiker with a dog on a coastal cliff trail in the Algarve Portugal, dramatic golden ocean views at sunset, Hyperdetailed Photography, Cinematic"
"walking-your-dog-in-the-algarve-hidden-dangers-every-owner-should-know|Wildlife Photography, a dog walking through a sunlit pine forest in Portugal, dappled light, natural mood, Hyperdetailed Photography"
)
i=0
for entry in "${POSTS[@]}"; do
slug="${entry%%|*}"
prompt="${entry##*|}"
i=$((i+1))
png="$TMP/$slug.png"
webp="$OUT/$slug.webp"
echo "[$i/${#POSTS[@]}] $slug (steps=$STEPS)"
"$SD" -m "$MODEL" -p "$prompt" -n "$NEG" \
-H 1024 -W 1024 --steps "$STEPS" --cfg-scale 5.0 --rng std_default \
-s $((i*1000)) -o "$png" >/tmp/sd_blog_$slug.log 2>&1 || {
echo " ✗ generation failed (see /tmp/sd_blog_$slug.log)"; exit 1; }
cwebp -quiet -resize 1280 0 -q 82 "$png" -o "$webp" 2>/dev/null
echo " -> $(ls -lh "$webp" | awk '{print $5}') $webp"
done
echo "✅ done"
+64
View File
@@ -0,0 +1,64 @@
/**
* Generate sitemap.xml for TailTrails.
* Run: node scripts/generate-sitemap.mjs
* Output: public/sitemap.xml (copied to dist/ by the subsequent vite build)
*
* Blog slugs are auto-discovered from src/blogs/posts/*.tsx so the sitemap
* can't drift from the actual posts.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SITE_URL = "https://tailtrails.club";
const POSTS_DIR = path.resolve(__dirname, "..", "src", "blogs", "posts");
const TODAY = new Date().toISOString().split("T")[0];
const staticRoutes = [
{ path: "/", priority: "1.0", changefreq: "weekly" },
{ path: "/blog", priority: "0.9", changefreq: "weekly" },
{ path: "/compare", priority: "0.9", changefreq: "weekly" },
{ path: "/compare/dog-walking-apps-compared", priority: "0.8", changefreq: "monthly" },
{ path: "/compare/tailtrails-vs-wooftrax", priority: "0.7", changefreq: "monthly" },
{ path: "/compare/tailtrails-vs-tractive", priority: "0.7", changefreq: "monthly" },
{ path: "/compare/tailtrails-vs-alltrails", priority: "0.7", changefreq: "monthly" },
{ path: "/compare/tailtrails-vs-fitbark", priority: "0.7", changefreq: "monthly" },
{ path: "/compare/tailtrails-vs-pitpat", priority: "0.7", changefreq: "monthly" },
{ path: "/privacy", priority: "0.3", changefreq: "yearly" },
{ path: "/terms", priority: "0.3", changefreq: "yearly" },
{ path: "/forget-me", priority: "0.3", changefreq: "yearly" },
{ path: "/finalize-forget-me", priority: "0.3", changefreq: "yearly" },
// /download is a redirect stub — not indexed, omitted from sitemap.
];
const blogSlugs = fs.existsSync(POSTS_DIR)
? fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith(".tsx")).map((f) => f.replace(/\.tsx$/, ""))
: [];
const blogRoutes = blogSlugs.map((slug) => ({
path: `/blog/${slug}`,
priority: "0.7",
changefreq: "monthly",
}));
const allRoutes = [...staticRoutes, ...blogRoutes];
const xmlUrls = allRoutes
.map(
(r) => ` <url>
<loc>${SITE_URL}${r.path}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${r.changefreq}</changefreq>
<priority>${r.priority}</priority>
</url>`
)
.join("\n");
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${xmlUrls}
</urlset>`;
const outputPath = path.join(__dirname, "..", "public", "sitemap.xml");
fs.writeFileSync(outputPath, sitemap, "utf8");
console.log(`✅ Sitemap generated: ${outputPath} (${allRoutes.length} URLs)`);
+211
View File
@@ -0,0 +1,211 @@
#!/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); });