Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions app/(marketing)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
<main className="mx-auto max-w-3xl px-4 sm:px-6 py-16">
<a
Expand Down Expand Up @@ -75,16 +86,38 @@ export default async function BlogPost({
className="mt-6 w-full rounded-lg border border-[var(--color-border)]"
/>
)}
{/* Top unit: after the headline block, above the body. */}
<AdUnit className="mt-8" />

{post.html ? (
<article
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"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
split ? (
<>
<article
className={`${PROSE} mt-6`}
dangerouslySetInnerHTML={{ __html: split.before }}
/>
{/* Mid unit: sits at a top-level block boundary near the middle,
* so it never breaks a list, quote or code block apart. */}
<AdUnit className="my-8" />
<article
className={PROSE}
dangerouslySetInnerHTML={{ __html: split.after }}
/>
</>
) : (
<article
className={`${PROSE} mt-6`}
dangerouslySetInnerHTML={{ __html: post.html }}
/>
)
) : (
<article className="mt-6 whitespace-pre-line text-lg text-[var(--color-fg)]">
{post.body}
</article>
)}

{/* Bottom unit: after the article. */}
<AdUnit className="mt-10" />
</main>
);
}
22 changes: 0 additions & 22 deletions app/(marketing)/blog/layout.tsx

This file was deleted.

103 changes: 62 additions & 41 deletions app/(marketing)/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 (
<main className="mx-auto max-w-3xl px-4 sm:px-6 py-16">
<h1 className="text-4xl font-extrabold">Blog</h1>
<p className="mt-2 text-[var(--color-muted)]">
Notes on AEO, AI crawlers, and how to make sites legible to LLMs.
</p>
{/* Top unit: above the list. */}
<AdUnit className="mt-8" />
<ul className="mt-10 space-y-6">
{all.map((p) => (
<li key={p.slug} className="card overflow-hidden p-0">
<Link
href={`/blog/${p.slug}`}
className="flex gap-4 p-4 sm:gap-5 sm:p-5"
>
{/* 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 */
<img
src={p.image_url}
alt={`Illustration for the blog post “${p.title}”`}
loading="lazy"
width={120}
height={120}
className="h-20 w-20 shrink-0 rounded-md object-cover sm:h-28 sm:w-28"
/>
) : (
<div
aria-hidden
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"
>
CP
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold leading-snug sm:text-xl">{p.title}</h2>
<p className="mt-1 text-xs text-[var(--color-muted)] sm:text-sm">
{p.date}
</p>
{p.excerpt && (
<p className="mt-2 line-clamp-2 text-sm text-[var(--color-muted)]">
{p.excerpt}
</p>
{all.map((p, i) => (
<Fragment key={p.slug}>
<li className="card overflow-hidden p-0">
<Link
href={`/blog/${p.slug}`}
className="flex gap-4 p-4 sm:gap-5 sm:p-5"
>
{/* 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 */
<img
src={p.image_url}
alt={`Illustration for the blog post “${p.title}”`}
loading="lazy"
width={120}
height={120}
className="h-20 w-20 shrink-0 rounded-md object-cover sm:h-28 sm:w-28"
/>
) : (
<div
aria-hidden
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"
>
CP
</div>
)}
</div>
</Link>
</li>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold leading-snug sm:text-xl">{p.title}</h2>
<p className="mt-1 text-xs text-[var(--color-muted)] sm:text-sm">
{p.date}
</p>
{p.excerpt && (
<p className="mt-2 line-clamp-2 text-sm text-[var(--color-muted)]">
{p.excerpt}
</p>
)}
</div>
</Link>
</li>
{/* Mid unit: between cards, styled as a plain row so it doesn't
* masquerade as a post. */}
{i + 1 === midAdAfter && (
<li className="list-none">
<AdUnit />
</li>
)}
</Fragment>
))}
</ul>
{/* Bottom unit: after the list. */}
<AdUnit className="mt-10" />
</main>
);
}
114 changes: 114 additions & 0 deletions lib/blog/split-html.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading