diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx
index 1c2e5d27..bcbc5155 100644
--- a/app/(marketing)/blog/[slug]/page.tsx
+++ b/app/(marketing)/blog/[slug]/page.tsx
@@ -1,7 +1,14 @@
import { notFound } from "next/navigation";
import { findAnyPost } from "@/lib/blog/posts";
+import { splitHtmlForMidAd } from "@/lib/blog/split-html";
+import { AdUnit } from "@/components/ads/ad-unit";
import { env } from "@/lib/env";
+// The article body's typography. Hoisted out of the JSX so both halves of a
+// mid-ad split render identically.
+const PROSE =
+ "prose prose-invert max-w-none text-[var(--color-fg)] [&_a]:underline [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_p]:mt-4 [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:mt-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:mt-4 [&_blockquote]:border-l-4 [&_blockquote]:border-[var(--color-border)] [&_blockquote]:pl-4 [&_blockquote]:italic [&_img]:my-6 [&_img]:rounded-lg [&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:border [&_pre]:border-[var(--color-border)] [&_pre]:bg-[var(--color-card)] [&_pre]:p-3 [&_code]:rounded [&_code]:bg-[var(--color-card)] [&_code]:px-1.5 [&_code]:py-0.5 [&_table]:my-4 [&_th]:border [&_th]:border-[var(--color-border)] [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:border-[var(--color-border)] [&_td]:px-2 [&_td]:py-1";
+
// Force dynamic SSR. Was ISR with revalidate=60 + generateStaticParams
// over the static `posts` array, but once we emptied that array the
// empty generateStaticParams return combined with the lack of explicit
@@ -43,6 +50,10 @@ export default async function BlogPost({
const post = await findAnyPost(slug);
if (!post) notFound();
+ // Null for short posts and for plain-text (non-HTML) bodies — those render
+ // straight through with just the top and bottom units.
+ const split = splitHtmlForMidAd(post.html);
+
return (
)}
+ {/* Top unit: after the headline block, above the body. */}
+
+
{post.html ? (
-
+ split ? (
+ <>
+
+ {/* Mid unit: sits at a top-level block boundary near the middle,
+ * so it never breaks a list, quote or code block apart. */}
+
+
+ >
+ ) : (
+
+ )
) : (
{post.body}
)}
+
+ {/* Bottom unit: after the article. */}
+
);
}
diff --git a/app/(marketing)/blog/layout.tsx b/app/(marketing)/blog/layout.tsx
deleted file mode 100644
index 380c22a6..00000000
--- a/app/(marketing)/blog/layout.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { AdUnit } from "@/components/ads/ad-unit";
-
-/**
- * Ads run on /blog/** only — the editorial pages — and nowhere else on the
- * site. Scoping them to this layout keeps them off the marketing pages, the
- * app, and transactional email; the RSS route under /blog is a route handler
- * and never sees a layout, so the feed stays clean too.
- *
- * Both units leave `format` unset so /ad.js sizes them to the column: a
- * leaderboard on desktop, a mobile banner on narrow screens.
- */
-export default function BlogLayout({ children }: { children: React.ReactNode }) {
- return (
- <>
- {children}
-
-
-
-
- >
- );
-}
diff --git a/app/(marketing)/blog/page.tsx b/app/(marketing)/blog/page.tsx
index 657bdb95..8282843e 100644
--- a/app/(marketing)/blog/page.tsx
+++ b/app/(marketing)/blog/page.tsx
@@ -1,5 +1,12 @@
+import { Fragment } from "react";
import Link from "next/link";
import { loadAllPosts } from "@/lib/blog/posts";
+import { AdUnit } from "@/components/ads/ad-unit";
+
+// Drop a mid-list unit after this many posts, but only when the list is long
+// enough that it isn't effectively the bottom unit again.
+const MID_AD_AFTER = 3;
+const MID_AD_MIN_POSTS = 6;
export const metadata = {
title: "Blog",
@@ -23,57 +30,71 @@ export const revalidate = 60;
export default async function BlogIndex() {
const all = await loadAllPosts();
+ const midAdAfter = all.length >= MID_AD_MIN_POSTS ? MID_AD_AFTER : -1;
return (
Blog
Notes on AEO, AI crawlers, and how to make sites legible to LLMs.
+ {/* Top unit: above the list. */}
+
- {all.map((p) => (
-
-
- {/* Thumbnail. Falls back to a tinted "CP" placeholder so the
- * list column stays aligned even when an autoblog post lands
- * without a featured image. Alt text describes the article the
- * image illustrates so it is meaningful for screen readers and
- * image search. */}
- {p.image_url ? (
- /* eslint-disable-next-line @next/next/no-img-element */
-
- ) : (
-
- CP
-
- )}
-
-
{p.title}
-
- {p.date}
-
- {p.excerpt && (
-
- {p.excerpt}
-
+ {all.map((p, i) => (
+
+
+
+ {/* Thumbnail. Falls back to a tinted "CP" placeholder so the
+ * list column stays aligned even when an autoblog post lands
+ * without a featured image. Alt text describes the article the
+ * image illustrates so it is meaningful for screen readers and
+ * image search. */}
+ {p.image_url ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+
+ ) : (
+
+ CP
+
)}
-
-
-
+
+
{p.title}
+
+ {p.date}
+
+ {p.excerpt && (
+
+ {p.excerpt}
+
+ )}
+
+
+
+ {/* Mid unit: between cards, styled as a plain row so it doesn't
+ * masquerade as a post. */}
+ {i + 1 === midAdAfter && (
+
+
+
+ )}
+
))}
+ {/* Bottom unit: after the list. */}
+
);
}
diff --git a/lib/blog/split-html.ts b/lib/blog/split-html.ts
new file mode 100644
index 00000000..29051ba8
--- /dev/null
+++ b/lib/blog/split-html.ts
@@ -0,0 +1,114 @@
+// Finds a safe place to drop a mid-article ad into rendered post HTML.
+//
+// Post bodies are marked-rendered markdown injected with dangerouslySetInnerHTML,
+// so a naive "cut at the halfway character" would routinely land inside a list,
+// a blockquote or a code block and leave unbalanced tags in both halves. This
+// only ever cuts after a top-level block element closes.
+
+/** Block elements we're willing to cut after. */
+const CLOSERS = new Set([
+ "p",
+ "h2",
+ "h3",
+ "h4",
+ "ul",
+ "ol",
+ "blockquote",
+ "pre",
+ "table",
+ "figure",
+]);
+
+/** Elements whose interior must stay intact — never cut while inside one. */
+const CONTAINERS = new Set([
+ "blockquote",
+ "ul",
+ "ol",
+ "li",
+ "pre",
+ "table",
+ "thead",
+ "tbody",
+ "tr",
+ "td",
+ "th",
+ "figure",
+ "dl",
+ "dd",
+ "dt",
+ "div",
+ "details",
+ "section",
+]);
+
+const TAG = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(\/?)>/g;
+
+/** Below this, an article is too short to be worth interrupting. */
+const MIN_LENGTH = 1500;
+
+// Keep the break away from the very start and end of the body. A single big
+// block (a long list, a 200-line code sample) can straddle the midpoint and
+// leave no legal boundary in the preferred window — rather than drop the unit
+// entirely on exactly those long posts, fall back to a wider window before
+// giving up.
+const PREFERRED_WINDOW = [0.25, 0.75] as const;
+const FALLBACK_WINDOW = [0.12, 0.88] as const;
+
+export type SplitHtml = { before: string; after: string };
+
+/**
+ * Split `html` into two halves at the top-level block boundary nearest the
+ * midpoint, for an ad to sit between. Returns null when the article is too
+ * short or has no safe boundary — callers should then render it unsplit.
+ */
+export function splitHtmlForMidAd(html: string | null | undefined): SplitHtml | null {
+ if (!html || html.length < MIN_LENGTH) return null;
+
+ // Every top-level block boundary in the document, in order.
+ const boundaries: number[] = [];
+ let depth = 0;
+
+ TAG.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = TAG.exec(html)) !== null) {
+ const closing = m[1] === "/";
+ const name = m[2].toLowerCase();
+ const selfClosing = m[3] === "/";
+
+ if (CONTAINERS.has(name) && !selfClosing) {
+ if (closing) depth = Math.max(0, depth - 1);
+ else depth += 1;
+ }
+
+ if (closing && depth === 0 && CLOSERS.has(name)) {
+ boundaries.push(m.index + m[0].length);
+ }
+ }
+
+ const target = html.length / 2;
+ const nearestWithin = ([lo, hi]: readonly [number, number]) => {
+ const lower = html.length * lo;
+ const upper = html.length * hi;
+ let pick: number | null = null;
+ let bestDistance = Infinity;
+ for (const offset of boundaries) {
+ if (offset < lower || offset > upper) continue;
+ const distance = Math.abs(offset - target);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ pick = offset;
+ }
+ }
+ return pick;
+ };
+
+ const best = nearestWithin(PREFERRED_WINDOW) ?? nearestWithin(FALLBACK_WINDOW);
+ if (best === null) return null;
+
+ const before = html.slice(0, best);
+ const after = html.slice(best);
+ // A boundary that leaves only whitespace behind is no boundary at all.
+ if (!after.trim()) return null;
+
+ return { before, after };
+}
diff --git a/tests/split-html.test.ts b/tests/split-html.test.ts
new file mode 100644
index 00000000..8277a4d2
--- /dev/null
+++ b/tests/split-html.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { splitHtmlForMidAd } from "@/lib/blog/split-html";
+
+const para = (n: number) => `
")).toBeNull();
+ expect(splitHtmlForMidAd("")).toBeNull();
+ expect(splitHtmlForMidAd(null)).toBeNull();
+ expect(splitHtmlForMidAd(undefined)).toBeNull();
+ });
+
+ it("returns null when a long body has no top-level boundary", () => {
+ const oneBigList = `