Blog pages for Engine Finder, featuring engine problem articles, guides, and category filtering.
Blog content lives in the Supabase blog_posts table (migrated 2026-06-29), NOT in src/content/blog/ files. The 6 blog routes are SSR. To publish or update a post:
# 1. Write the post as a markdown file with frontmatter
# (title, description, pubDate, categories, heroImage?, featured?, slug?)
# Use slug: "problems/<x>" for engine-problem posts; bare slug for root posts.
# 2. Publish it (compiles MD→HTML+TOC, uploads hero to the blog-images bucket, upserts the row):
node scripts/publish-post.mjs <file.md> [--hero <image>] [--draft] [--dry]
The post is live at /blog/<slug>/ within the ~5-min CDN window — never run npm run build or a Vercel deploy just to publish content. --dry previews without writing; --draft stores it hidden. Editing a live post = edit the file + re-run the script (it upserts by slug). Full architecture + gotchas: the Storage section below.
Cross-refs: root
CLAUDE.md§ Top-of-Mind Rules (“Blog publishing = NO REBUILD”) andmemory/project_blog_migrated_to_supabase.md.
Engine Finder has no Shopify-style orchestrator skill — this list IS the orchestrator. Every step is a hard gate. Do them automatically; never ask the user whether to fact-check or generate images — the answer is always yes.
/pick-next-content) — confirm no existing post/make page already owns the primary intent./blog-fact-check on every price/code/spec claim. HARD GATE, now code-enforced. publish-post.mjs refuses a live publish unless a content-bound report exists at analysis/factcheck/<slug>.factcheck.json whose SHA-256 matches the exact body AND has 0 unresolved ✗. Long/table-heavy posts fan the check out to per-claim-cluster subagents + one whole-post consistency pass (intro↔tables↔FAQ must agree — the R4,000-vs-R5,500 miss on the manual-gearbox post). Write the report as the LAST step: node scripts/factcheck-stamp.mjs <file.md> --findings <findings.json>. A hand-typed factChecked: date no longer passes (that hole shipped a CVT post with 6 errors and the manual-gearbox post with 8)./nano-banana-text-to-image or fal) and one digit-exact cost infographic (/blog-infographic). Never ship a post that references image paths which don’t exist.target="_blank" rel="noopener noreferrer". Verify every URL resolves — never cite a page you didn’t open./blog-youtube-enrich) when a relevant on-topic video exists — score by views/subs/relevance/duration. SKIP rather than embed a weak or off-topic video. Responsive 16:9 iframe (the .embed-container pattern the problems posts use).
problems/* with N distinct fault sections): try one relevant video PER problem section, not just one for the whole post. Still hard-gated — embed only where a genuinely on-topic clip exists; skip a section rather than force a weak/tangential video. Cap ~6–8. Where the best clip is a sibling engine/model, say so in the caption (e.g. “shown on a six-cylinder; the B47 cooler fails the same way”).ytInitialData (fetch https://www.youtube.com/results?search_query=…&sp=EgIQAQ%253D%253D with a browser UA + Cookie: CONSENT=YES+1; SOCS=CAI to dodge the consent redirect; walk videoRenderer for id/title/views/length). Then confirm each chosen ID is public + embeddable via oembed — https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=<id>&format=json must return 200 (401/404 = private or embedding disabled → pick another). Proven on the BMW B47 post (2026-07-03, 6 per-problem videos)./copy-audit — HARD GATE (anti-conversion phrases, template leakage, brand contradictions, doorway/thin check).blog_posts table (see “Storage” below). Publish with node scripts/publish-post.mjs <file.md> [--hero <image>] [--draft] — it compiles markdown→HTML+TOC, uploads the hero to the blog-images bucket, and upserts the row. The post is live at /blog/<slug>/ within the CDN window (~5 min). Then flip registry status to live + interlink backfill (/contextual-internal-linking). The sitemap-blog.xml endpoint picks the post up automatically — no blogSlugs edit, no npm run build, no Vercel deploy needed for content.Gotchas baked in: slugs ending -problems get 301’d to /blog/problems/ (allow-list in src/middleware.ts); EF blog posts are SSR from Supabase — verify on dev/live, NOT dist/; EF blog links are absolute URLs.
BlogReplacementCTA) — match the post’s verticalproblems/* posts get a make-aware conversion card auto-injected by BlogWithAds.astro (a client script relocates a hidden template after the first cost <table>). The route [...slug].astro derives:
Nissan → make Nissan). If no brand category matches, the CTA is skipped.engine | gearbox) — from the slug/title/categories: anything containing gearbox/transmission → gearbox, else engine. This picks the noun, the copy, and the link target (/<make>-gearboxes-for-sale/ vs /<make>-engines-for-sale/).LESSON (shipped bug, 2026-07-06): the CTA was engine-only, so the NP200 gearbox post pitched a “Replacement Nissan engine” linking to /nissan-engines-for-sale/ — wrong vertical for a gearbox reader. Fixed by threading a product prop end-to-end (BlogReplacementCTA → BlogWithAds → [...slug].astro). Rule: any new vertical (gearbox, turbo, etc.) that reuses this CTA must pass the right product so it never sells the wrong part. When adding a gearbox problems/* post, keep a bare-make category (e.g. Nissan, NOT Nissan Gearboxes) or the make won’t derive and the CTA silently drops.
Known limitation: injection anchors on the first HTML <table>. Posts that present costs as a branded infographic image (the current standard) have no such table, so the CTA can silently fail to render (the block-index-6 fallback is unreliable — verified NOT rendering on the NP200 post). If you want the CTA on an infographic-cost post, include a small HTML cost table or extend the injection anchor. Editing BlogReplacementCTA/BlogWithAds/[...slug].astro is a code change → needs a Vercel deploy (unlike content, which publishes with no rebuild).
blog_posts (migrated 2026-06-29)Blog content moved OUT of file-based Astro content collections INTO the Supabase blog_posts table so new posts go live without rebuilding the (large, slow) site. The 6 blog routes are now prerender = false (SSR) and read via src/lib/blog/posts.ts.
slug (full path incl problems/ prefix; bare slug for the 4 root -problems allow-list posts), title, description, body_md (markdown source), body_html (pre-compiled, served via set:html), headings jsonb (TOC), hero_image, author, categories[], pub_date, updated_date, featured, draft, word_count.scripts/lib/compile-markdown.mjs — reproduces Astro’s own remark/rehype output, including a verbatim port of Astro’s heading-id algorithm (NOT rehype-slug) so TOC + hard-coded Key-Takeaways jump-links keep working.src/lib/blog/posts.ts — getPublishedPosts(), getPostBySlug(), getNonDraftPosts() (looser, for categories.astro), getPublishedSlugs(). Returns the old { slug, data: {...Date-mapped...} } envelope so card components are unchanged.scripts/migrate-blog-to-supabase.mjs (one-time; .md compiled here, the 3 .mdx guide posts captured as static HTML from prod via Playwright).src/content/blog/*.md/.mdx files + src/content/config.ts are kept as a backup/source and still feed contentInventory.ts; they are NO LONGER the rendering source. Edit a live post by editing the file and re-running publish-post.mjs, or updating the row directly.@astrojs/sitemap; src/pages/sitemap-blog.xml.ts lists them (2nd Sitemap: line in public/robots.txt).src/middleware.ts sets s-maxage=300, stale-while-revalidate=86400 on /blog/*.The cohesive blog hero look: photo background → cinematic left fade to black → short red “tick” rule → red category eyebrow (uppercase, letter-spaced) → auto-cleaned + auto-fit white headline → the real ENGINE FINDER logo PNG (public/images/engine-finder-logo-new.png), bottom-left. 1.9:1 (1520×800 @2×).
scripts/gen-branded-hero.mjs — module API buildBrandedHero({bgBuffer,eyebrow,title}) + helpers cleanHeadline() (SEO title → punchy headline, e.g. “DSG Gearbox Replacement Cost in South Africa (2026 Guide)” → “DSG Gearbox Replacement Cost”), deriveEyebrow() (Pricing Guide / Problems & Fixes / Fault Codes / Buyer’s Guide / Engine Guide), genPhoto() (gpt-image-2 low). CLI: node scripts/gen-branded-hero.mjs <slug> [--gen "<photo prompt>"] [--photo <path|url>] [--eyebrow "..."] [--apply]. Without --apply it writes a branded-hero-<slug>.png preview; with --apply it uploads to the blog-images bucket + repoints hero_image (no rebuild).scripts/audit-blog-heroes.mjs — builds labelled contact sheets (hero-audit-sheet-N.png + legend) of every hero to classify them.src/pages/blog/
├── index.astro # Main blog listing (page 1)
├── page/[page].astro # Blog pagination (pages 2+)
├── [...slug].astro # Individual blog post pages
└── category/
├── [category].astro # Category listing (page 1)
└── [category]/page/[page].astro # Category pagination (pages 2+)
| File | Purpose |
|---|---|
src/content/blog/ | Blog post markdown files |
src/lib/blog/normalizeCategory.js | Shared category normalization utility |
src/lib/constants.ts | BLOG_PAGINATION_SIZE = 12 |
src/components/PostCard.astro | Blog post card component |
src/components/CategoryFilter.astro | Category filter UI |
src/components/Paginator.astro | Pagination component |
src/components/TableOfContents.astro | Auto-generated table of contents |
Categories are normalized to consolidate brand variants (e.g., “Mercedes”, “Mercedes-Benz” → “Mercedes-Benz Engines”).
Shared utility: src/lib/blog/normalizeCategory.js
import { normalizeCategory } from '@/lib/blog/normalizeCategory';
This function is used by both [category].astro and [category]/page/[page].astro to ensure consistent category handling.
BLOG_PAGINATION_SIZE)index.astro or [category].astro)page/[page].astro)| URL | Handler |
|---|---|
/blog | index.astro |
/blog/page/2 | page/[page].astro |
/blog/category/audi-engines | category/[category].astro |
/blog/category/audi-engines/page/2 | category/[category]/page/[page].astro |
All blog routes are export const prerender = false; and read from the blog_posts table via src/lib/blog/posts.ts (NO more getCollection / getStaticPaths). Adding/editing a post does NOT require a rebuild.
import { getPublishedPosts, getPostBySlug } from '@/lib/blog/posts';
const allPosts = await getPublishedPosts(); // list/category/related routes
const post = await getPostBySlug(slugParam); // [...slug].astro
if (!post) { /* Astro.rewrite('/404') with 404 status */ }
Important: getPublishedPosts() applies the same filters the old collection did:
draft = false - Draft posts are excludedpub_date <= now - Future-dated posts are excluded(categories.astro uses the looser getNonDraftPosts() — draft = false only — preserving its original behaviour.) Unknown slug / category / out-of-range page → Astro.rewrite('/404') returned with a 404 status.
---
title: "Article Title"
description: "Article description"
pubDate: 2025-01-15
heroImage: "/images/blog/image.jpg"
categories: ["Engine Problems", "Audi Engines", "Maintenance"]
author: "Engine Finder"
featured: true
draft: false
slug: "problems/article-slug"
---
Every blog post automatically displays a Table of Contents that:
Component: src/components/TableOfContents.astro
Implementation:
const { Content, headings } = await post.render();
// ...
<TableOfContents headings={headings} />
Styling:
draft is false (not true)pubDate is not in the future[category]/page/[page].astro existsnormalizeCategory is imported from shared utilityexport const prerender = true; is setimport { normalizeCategory } from '@/lib/blog/normalizeCategory';Blog posts are configured with proper Open Graph meta tags for social media sharing (Facebook, LinkedIn, etc.).
Implementation: src/pages/blog/[...slug].astro
<Layout
title={post.data.title}
description={post.data.description}
canonical={`https://www.enginefinder.co.za/blog/${post.slug}/`}
ogImage={post.data.heroImage ? `https://www.enginefinder.co.za${post.data.heroImage}` : 'https://www.enginefinder.co.za/images/mechanic-standing-by-engine.webp'}
ogType="article"
publishedTime={post.data.pubDate.toISOString()}
modifiedTime={post.data.updatedDate ? post.data.updatedDate.toISOString() : undefined}
author="Craig Sandeman"
/>
Key Features:
heroImage) is automatically used as the Open Graph imageTesting Social Shares:
The Layout.astro component handles all Open Graph and Twitter Card meta tags:
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:type" content={ogType} />
<meta property="article:published_time" content={publishedTime} />
<meta property="article:author" content={author} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
Blog posts include comprehensive JSON-LD structured data for enhanced SEO and rich snippets in search results.
Schemas Included:
Implementation: src/pages/blog/[...slug].astro
<!-- JSON-LD Structured Data for BlogPosting -->
<script type="application/ld+json" set:html={JSON.stringify(blogPostingSchema)} />
<!-- JSON-LD Structured Data for BreadcrumbList -->
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
Key Features:
set:html directiveupdatedDate)Testing Structured Data:
Schema Properties:
BlogPosting:
headline - Post titledescription - Post descriptionimage - Featured image URLdatePublished - Publication date (ISO 8601)dateModified - Last updated date (falls back to pubDate if not set)author - Organization entity (Engine Finder)publisher - Organization with logomainEntityOfPage - Canonical URLBreadcrumbList:
Blog posts support 5 types of styled callout boxes to display data attractively within markdown content.
| Type | Color | Use For | Icon |
|---|---|---|---|
| Did You Know | Blue | Facts, statistics, reliability ratings | 💡 |
| Pro Tip | Green | Maintenance advice, diagnostic tips | ✅ |
| Warning | Orange | Safety alerts, expensive mistakes | ⚠️ |
| Forum Insight | Purple | Owner experiences, community quotes | 💬 |
| Statistic | Red | Percentage data, key numbers | Large number |
<div class="callout callout-did-you-know">
<div class="callout-icon">💡</div>
<div class="callout-content">
<strong>Did You Know?</strong>
<p>The BMW N52 has a 73% reliability rating.</p>
<cite>Source: Consumer Reports 2023</cite>
</div>
</div>
See BLOG_CALLOUTS_GUIDE.md for:
Open BLOG_CALLOUTS_EXAMPLE.html in browser to see all callouts rendered.
Styles are in src/pages/blog/[...slug].astro (lines 488-612)
src/content/config.tssrc/layouts/Layout.astroBLOG_CALLOUTS_GUIDE.md