Blog System

Blog pages for Engine Finder, featuring engine problem articles, guides, and category filtering.

⚡ Publishing a post — NO REBUILD (read first)

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”) and memory/project_blog_migrated_to_supabase.md.

Content Pipeline (MANDATORY — every new or rebuilt blog post)

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.

  1. Cannibalisation check (/pick-next-content) — confirm no existing post/make page already owns the primary intent.
  2. SERP-intent check — confirm the term actually ranks guides (not 100% transactional listings). If listings, strengthen a category page instead.
  3. Draft with the uniqueness gate — real SA prices / codes / failure modes / labour hours (≥60% model-specific, never template-with-name-swapped). Answer-first opening sentence, Key Takeaways box, H2/H3 structure, FAQ section.
  4. ALWAYS run /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).
  5. ALWAYS generate images — a hero (/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.
  6. ALWAYS add 2–4 authoritative external citations woven into specific factual claims (manufacturer specs, reputable SA motoring/parts sources, RCA bulletins). target="_blank" rel="noopener noreferrer". Verify every URL resolves — never cite a page you didn’t open.
  7. ALWAYS add quality-gated YouTube embed(s) (/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).
  8. Schema — Article (layout auto-generates) + inline FAQPage JSON-LD in the markdown (the layout does NOT emit FAQPage; add it inline and verify it renders on dev/live).
  9. /copy-auditHARD GATE (anti-conversion phrases, template leakage, brand contradictions, doorway/thin check).
  10. Publish plumbing — NO REBUILD. Blog content lives in the Supabase 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.

Auto-injected replacement CTA (BlogReplacementCTA) — match the post’s vertical

problems/* 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:

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 (BlogReplacementCTABlogWithAds[...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).

Storage — Supabase 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.

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×).

Directory Structure

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+)

Key Files

FilePurpose
src/content/blog/Blog post markdown files
src/lib/blog/normalizeCategory.jsShared category normalization utility
src/lib/constants.tsBLOG_PAGINATION_SIZE = 12
src/components/PostCard.astroBlog post card component
src/components/CategoryFilter.astroCategory filter UI
src/components/Paginator.astroPagination component
src/components/TableOfContents.astroAuto-generated table of contents

Category Normalization

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.

Pagination

URL Structure

URLHandler
/blogindex.astro
/blog/page/2page/[page].astro
/blog/category/audi-enginescategory/[category].astro
/blog/category/audi-engines/page/2category/[category]/page/[page].astro

Rendering (SSR from Supabase)

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.

Data-layer pattern

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:

  1. draft = false - Draft posts are excluded
  2. pub_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.

Blog Post Frontmatter

---
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"
---

Table of Contents Feature

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:

Troubleshooting

404 on Blog Post

404 on Category Pagination

ReferenceError: normalizeCategory is not defined

SEO & Social Media Sharing

Open Graph Meta Tags

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:

Testing Social Shares:

  1. Use Facebook Sharing Debugger
  2. Enter your blog post URL
  3. Click “Scrape Again” to refresh Facebook’s cache
  4. Verify the featured image, title, and description appear correctly

Layout Component

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} />

JSON-LD Structured Data

Blog posts include comprehensive JSON-LD structured data for enhanced SEO and rich snippets in search results.

Schemas Included:

  1. BlogPosting Schema - Article metadata, author, publisher, dates
  2. BreadcrumbList Schema - Navigation hierarchy (Home > Blog > Category > Post)

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:

Testing Structured Data:

  1. Use Google’s Rich Results Test
  2. Enter your blog post URL
  3. Verify BlogPosting and BreadcrumbList schemas are detected
  4. Check for validation errors or warnings

Schema Properties:

BlogPosting:

BreadcrumbList:

Callout Boxes

Blog posts support 5 types of styled callout boxes to display data attractively within markdown content.

Available Callout Types

TypeColorUse ForIcon
Did You KnowBlueFacts, statistics, reliability ratings💡
Pro TipGreenMaintenance advice, diagnostic tips
WarningOrangeSafety alerts, expensive mistakes⚠️
Forum InsightPurpleOwner experiences, community quotes💬
StatisticRedPercentage data, key numbersLarge number

Usage in Markdown

<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>

Features

Complete Documentation

See BLOG_CALLOUTS_GUIDE.md for:

Visual Examples

Open BLOG_CALLOUTS_EXAMPLE.html in browser to see all callouts rendered.

Implementation

Styles are in src/pages/blog/[...slug].astro (lines 488-612)