adding prerender, updating blogs, and make images more web friendly
This commit is contained in:
Executable
+46
@@ -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"
|
||||
@@ -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)`);
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user