From a78e6c1502e3aef6d1c70ed2ec69eac01ff33d98 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 24 Jul 2026 18:55:05 +0000 Subject: [PATCH] fix(ads): place blog units at top, mid-article, and bottom Both units were stacked at the bottom of the page, which is the lowest-value placement and looks like a footer dump. Spread them out: - Post page: one above the body, one mid-article, one after the article. - Index: one above the list, one after the 3rd card (only when the list has 6+ posts, else it's just the bottom unit again), one below. Mid-article means cutting rendered post HTML in half, and a naive cut at the halfway character lands inside a list, blockquote or code block and leaves unbalanced tags in both halves. splitHtmlForMidAd only cuts after a top-level block closes, tracking container depth as it scans. A single big block can straddle the midpoint and leave no boundary in the preferred 25-75% window; rather than drop the unit on exactly those long posts, it falls back to a wider window before giving up. Short posts and plain-text bodies get no mid unit at all. Co-Authored-By: Claude Opus 5 (1M context) --- app/(marketing)/blog/[slug]/page.tsx | 41 +++++++++- app/(marketing)/blog/layout.tsx | 22 ------ app/(marketing)/blog/page.tsx | 103 ++++++++++++++---------- lib/blog/split-html.ts | 114 +++++++++++++++++++++++++++ tests/split-html.test.ts | 85 ++++++++++++++++++++ 5 files changed, 298 insertions(+), 67 deletions(-) delete mode 100644 app/(marketing)/blog/layout.tsx create mode 100644 lib/blog/split-html.ts create mode 100644 tests/split-html.test.ts 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 */ - {`Illustration - ) : ( -
    - 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 */ + {`Illustration + ) : ( +
    + 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) => `

Paragraph ${n} ${"filler ".repeat(20)}

`; +const longArticle = Array.from({ length: 12 }, (_, i) => para(i)).join("\n"); + +describe("splitHtmlForMidAd", () => { + it("splits a long article and loses nothing", () => { + const split = splitHtmlForMidAd(longArticle); + expect(split).not.toBeNull(); + expect(split!.before + split!.after).toBe(longArticle); + expect(split!.before.trim()).not.toBe(""); + expect(split!.after.trim()).not.toBe(""); + }); + + it("cuts on a block boundary, leaving balanced

tags", () => { + const split = splitHtmlForMidAd(longArticle)!; + const opens = (s: string) => (s.match(/

/g) ?? []).length; + const closes = (s: string) => (s.match(/<\/p>/g) ?? []).length; + expect(opens(split.before)).toBe(closes(split.before)); + expect(opens(split.after)).toBe(closes(split.after)); + }); + + it("cuts near the middle, not at the edges", () => { + const split = splitHtmlForMidAd(longArticle)!; + const fraction = split.before.length / longArticle.length; + expect(fraction).toBeGreaterThanOrEqual(0.25); + expect(fraction).toBeLessThanOrEqual(0.75); + }); + + // Each of these wraps a big container around the article's midpoint, so the + // naive "cut nearest the middle" would land inside it. Leading and trailing + // paragraphs guarantee a legal boundary exists, keeping the assertions live + // rather than vacuously skipped. + const balanced = (s: string, tag: string) => + (s.match(new RegExp(`<${tag}[ >]`, "g")) ?? []).length === + (s.match(new RegExp(``, "g")) ?? []).length; + + const wrap = (middle: string) => { + const edge = Array.from({ length: 6 }, (_, i) => para(i)).join(""); + return `${edge}${middle}${edge}`; + }; + + it("never cuts inside a list", () => { + const items = Array.from( + { length: 30 }, + (_, i) => `

  • Item ${i} ${"text ".repeat(10)}
  • `, + ).join(""); + const split = splitHtmlForMidAd(wrap(`
      ${items}
    `)); + expect(split).not.toBeNull(); + expect(balanced(split!.before, "ul")).toBe(true); + expect(balanced(split!.before, "li")).toBe(true); + expect(split!.after.trimStart().startsWith(" { + const quote = `
    ${Array.from({ length: 20 }, (_, i) => para(i)).join("")}
    `; + const split = splitHtmlForMidAd(wrap(quote)); + expect(split).not.toBeNull(); + expect(balanced(split!.before, "blockquote")).toBe(true); + }); + + it("never cuts inside a code block", () => { + const code = `
    ${"const x = 1;\n".repeat(200)}
    `; + const split = splitHtmlForMidAd(wrap(code)); + expect(split).not.toBeNull(); + expect(balanced(split!.before, "pre")).toBe(true); + expect(balanced(split!.before, "code")).toBe(true); + }); + + it("returns null for short, empty, or missing bodies", () => { + expect(splitHtmlForMidAd("

    short

    ")).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 = `
      ${Array.from( + { length: 60 }, + (_, i) => `
    • Item ${i} ${"text ".repeat(10)}
    • `, + ).join("")}
    `; + expect(splitHtmlForMidAd(oneBigList)).toBeNull(); + }); +});