Skip to content

Commit cae1eb1

Browse files
ralyodioclaude
andauthored
Spread blog ad units across top, mid-article, and bottom (#113)
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) <noreply@anthropic.com>
1 parent f1065ae commit cae1eb1

5 files changed

Lines changed: 298 additions & 67 deletions

File tree

‎app/(marketing)/blog/[slug]/page.tsx‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { notFound } from "next/navigation";
22
import { findAnyPost } from "@/lib/blog/posts";
3+
import { splitHtmlForMidAd } from "@/lib/blog/split-html";
4+
import { AdUnit } from "@/components/ads/ad-unit";
35
import { env } from "@/lib/env";
46

7+
// The article body's typography. Hoisted out of the JSX so both halves of a
8+
// mid-ad split render identically.
9+
const PROSE =
10+
"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";
11+
512
// Force dynamic SSR. Was ISR with revalidate=60 + generateStaticParams
613
// over the static `posts` array, but once we emptied that array the
714
// empty generateStaticParams return combined with the lack of explicit
@@ -43,6 +50,10 @@ export default async function BlogPost({
4350
const post = await findAnyPost(slug);
4451
if (!post) notFound();
4552

53+
// Null for short posts and for plain-text (non-HTML) bodies — those render
54+
// straight through with just the top and bottom units.
55+
const split = splitHtmlForMidAd(post.html);
56+
4657
return (
4758
<main className="mx-auto max-w-3xl px-4 sm:px-6 py-16">
4859
<a
@@ -75,16 +86,38 @@ export default async function BlogPost({
7586
className="mt-6 w-full rounded-lg border border-[var(--color-border)]"
7687
/>
7788
)}
89+
{/* Top unit: after the headline block, above the body. */}
90+
<AdUnit className="mt-8" />
91+
7892
{post.html ? (
79-
<article
80-
className="prose prose-invert mt-6 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"
81-
dangerouslySetInnerHTML={{ __html: post.html }}
82-
/>
93+
split ? (
94+
<>
95+
<article
96+
className={`${PROSE} mt-6`}
97+
dangerouslySetInnerHTML={{ __html: split.before }}
98+
/>
99+
{/* Mid unit: sits at a top-level block boundary near the middle,
100+
* so it never breaks a list, quote or code block apart. */}
101+
<AdUnit className="my-8" />
102+
<article
103+
className={PROSE}
104+
dangerouslySetInnerHTML={{ __html: split.after }}
105+
/>
106+
</>
107+
) : (
108+
<article
109+
className={`${PROSE} mt-6`}
110+
dangerouslySetInnerHTML={{ __html: post.html }}
111+
/>
112+
)
83113
) : (
84114
<article className="mt-6 whitespace-pre-line text-lg text-[var(--color-fg)]">
85115
{post.body}
86116
</article>
87117
)}
118+
119+
{/* Bottom unit: after the article. */}
120+
<AdUnit className="mt-10" />
88121
</main>
89122
);
90123
}

‎app/(marketing)/blog/layout.tsx‎

Lines changed: 0 additions & 22 deletions
This file was deleted.

‎app/(marketing)/blog/page.tsx‎

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { Fragment } from "react";
12
import Link from "next/link";
23
import { loadAllPosts } from "@/lib/blog/posts";
4+
import { AdUnit } from "@/components/ads/ad-unit";
5+
6+
// Drop a mid-list unit after this many posts, but only when the list is long
7+
// enough that it isn't effectively the bottom unit again.
8+
const MID_AD_AFTER = 3;
9+
const MID_AD_MIN_POSTS = 6;
310

411
export const metadata = {
512
title: "Blog",
@@ -23,57 +30,71 @@ export const revalidate = 60;
2330

2431
export default async function BlogIndex() {
2532
const all = await loadAllPosts();
33+
const midAdAfter = all.length >= MID_AD_MIN_POSTS ? MID_AD_AFTER : -1;
2634
return (
2735
<main className="mx-auto max-w-3xl px-4 sm:px-6 py-16">
2836
<h1 className="text-4xl font-extrabold">Blog</h1>
2937
<p className="mt-2 text-[var(--color-muted)]">
3038
Notes on AEO, AI crawlers, and how to make sites legible to LLMs.
3139
</p>
40+
{/* Top unit: above the list. */}
41+
<AdUnit className="mt-8" />
3242
<ul className="mt-10 space-y-6">
33-
{all.map((p) => (
34-
<li key={p.slug} className="card overflow-hidden p-0">
35-
<Link
36-
href={`/blog/${p.slug}`}
37-
className="flex gap-4 p-4 sm:gap-5 sm:p-5"
38-
>
39-
{/* Thumbnail. Falls back to a tinted "CP" placeholder so the
40-
* list column stays aligned even when an autoblog post lands
41-
* without a featured image. Alt text describes the article the
42-
* image illustrates so it is meaningful for screen readers and
43-
* image search. */}
44-
{p.image_url ? (
45-
/* eslint-disable-next-line @next/next/no-img-element */
46-
<img
47-
src={p.image_url}
48-
alt={`Illustration for the blog post “${p.title}”`}
49-
loading="lazy"
50-
width={120}
51-
height={120}
52-
className="h-20 w-20 shrink-0 rounded-md object-cover sm:h-28 sm:w-28"
53-
/>
54-
) : (
55-
<div
56-
aria-hidden
57-
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-md bg-[var(--color-card)] text-xs font-semibold uppercase tracking-wider text-[var(--color-accent)] sm:h-28 sm:w-28"
58-
>
59-
CP
60-
</div>
61-
)}
62-
<div className="min-w-0 flex-1">
63-
<h2 className="text-lg font-bold leading-snug sm:text-xl">{p.title}</h2>
64-
<p className="mt-1 text-xs text-[var(--color-muted)] sm:text-sm">
65-
{p.date}
66-
</p>
67-
{p.excerpt && (
68-
<p className="mt-2 line-clamp-2 text-sm text-[var(--color-muted)]">
69-
{p.excerpt}
70-
</p>
43+
{all.map((p, i) => (
44+
<Fragment key={p.slug}>
45+
<li className="card overflow-hidden p-0">
46+
<Link
47+
href={`/blog/${p.slug}`}
48+
className="flex gap-4 p-4 sm:gap-5 sm:p-5"
49+
>
50+
{/* Thumbnail. Falls back to a tinted "CP" placeholder so the
51+
* list column stays aligned even when an autoblog post lands
52+
* without a featured image. Alt text describes the article the
53+
* image illustrates so it is meaningful for screen readers and
54+
* image search. */}
55+
{p.image_url ? (
56+
/* eslint-disable-next-line @next/next/no-img-element */
57+
<img
58+
src={p.image_url}
59+
alt={`Illustration for the blog post “${p.title}”`}
60+
loading="lazy"
61+
width={120}
62+
height={120}
63+
className="h-20 w-20 shrink-0 rounded-md object-cover sm:h-28 sm:w-28"
64+
/>
65+
) : (
66+
<div
67+
aria-hidden
68+
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-md bg-[var(--color-card)] text-xs font-semibold uppercase tracking-wider text-[var(--color-accent)] sm:h-28 sm:w-28"
69+
>
70+
CP
71+
</div>
7172
)}
72-
</div>
73-
</Link>
74-
</li>
73+
<div className="min-w-0 flex-1">
74+
<h2 className="text-lg font-bold leading-snug sm:text-xl">{p.title}</h2>
75+
<p className="mt-1 text-xs text-[var(--color-muted)] sm:text-sm">
76+
{p.date}
77+
</p>
78+
{p.excerpt && (
79+
<p className="mt-2 line-clamp-2 text-sm text-[var(--color-muted)]">
80+
{p.excerpt}
81+
</p>
82+
)}
83+
</div>
84+
</Link>
85+
</li>
86+
{/* Mid unit: between cards, styled as a plain row so it doesn't
87+
* masquerade as a post. */}
88+
{i + 1 === midAdAfter && (
89+
<li className="list-none">
90+
<AdUnit />
91+
</li>
92+
)}
93+
</Fragment>
7594
))}
7695
</ul>
96+
{/* Bottom unit: after the list. */}
97+
<AdUnit className="mt-10" />
7798
</main>
7899
);
79100
}

‎lib/blog/split-html.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Finds a safe place to drop a mid-article ad into rendered post HTML.
2+
//
3+
// Post bodies are marked-rendered markdown injected with dangerouslySetInnerHTML,
4+
// so a naive "cut at the halfway character" would routinely land inside a list,
5+
// a blockquote or a code block and leave unbalanced tags in both halves. This
6+
// only ever cuts after a top-level block element closes.
7+
8+
/** Block elements we're willing to cut after. */
9+
const CLOSERS = new Set([
10+
"p",
11+
"h2",
12+
"h3",
13+
"h4",
14+
"ul",
15+
"ol",
16+
"blockquote",
17+
"pre",
18+
"table",
19+
"figure",
20+
]);
21+
22+
/** Elements whose interior must stay intact — never cut while inside one. */
23+
const CONTAINERS = new Set([
24+
"blockquote",
25+
"ul",
26+
"ol",
27+
"li",
28+
"pre",
29+
"table",
30+
"thead",
31+
"tbody",
32+
"tr",
33+
"td",
34+
"th",
35+
"figure",
36+
"dl",
37+
"dd",
38+
"dt",
39+
"div",
40+
"details",
41+
"section",
42+
]);
43+
44+
const TAG = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(\/?)>/g;
45+
46+
/** Below this, an article is too short to be worth interrupting. */
47+
const MIN_LENGTH = 1500;
48+
49+
// Keep the break away from the very start and end of the body. A single big
50+
// block (a long list, a 200-line code sample) can straddle the midpoint and
51+
// leave no legal boundary in the preferred window — rather than drop the unit
52+
// entirely on exactly those long posts, fall back to a wider window before
53+
// giving up.
54+
const PREFERRED_WINDOW = [0.25, 0.75] as const;
55+
const FALLBACK_WINDOW = [0.12, 0.88] as const;
56+
57+
export type SplitHtml = { before: string; after: string };
58+
59+
/**
60+
* Split `html` into two halves at the top-level block boundary nearest the
61+
* midpoint, for an ad to sit between. Returns null when the article is too
62+
* short or has no safe boundary — callers should then render it unsplit.
63+
*/
64+
export function splitHtmlForMidAd(html: string | null | undefined): SplitHtml | null {
65+
if (!html || html.length < MIN_LENGTH) return null;
66+
67+
// Every top-level block boundary in the document, in order.
68+
const boundaries: number[] = [];
69+
let depth = 0;
70+
71+
TAG.lastIndex = 0;
72+
let m: RegExpExecArray | null;
73+
while ((m = TAG.exec(html)) !== null) {
74+
const closing = m[1] === "/";
75+
const name = m[2].toLowerCase();
76+
const selfClosing = m[3] === "/";
77+
78+
if (CONTAINERS.has(name) && !selfClosing) {
79+
if (closing) depth = Math.max(0, depth - 1);
80+
else depth += 1;
81+
}
82+
83+
if (closing && depth === 0 && CLOSERS.has(name)) {
84+
boundaries.push(m.index + m[0].length);
85+
}
86+
}
87+
88+
const target = html.length / 2;
89+
const nearestWithin = ([lo, hi]: readonly [number, number]) => {
90+
const lower = html.length * lo;
91+
const upper = html.length * hi;
92+
let pick: number | null = null;
93+
let bestDistance = Infinity;
94+
for (const offset of boundaries) {
95+
if (offset < lower || offset > upper) continue;
96+
const distance = Math.abs(offset - target);
97+
if (distance < bestDistance) {
98+
bestDistance = distance;
99+
pick = offset;
100+
}
101+
}
102+
return pick;
103+
};
104+
105+
const best = nearestWithin(PREFERRED_WINDOW) ?? nearestWithin(FALLBACK_WINDOW);
106+
if (best === null) return null;
107+
108+
const before = html.slice(0, best);
109+
const after = html.slice(best);
110+
// A boundary that leaves only whitespace behind is no boundary at all.
111+
if (!after.trim()) return null;
112+
113+
return { before, after };
114+
}

0 commit comments

Comments
 (0)