-
+ {summaryParts.map((part, index, parts) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+ {index < parts.length - 1 && (
+ // Phone density policy: only the page's first in-content unit
+ // keeps a phone placement — the 250-char cadence would stack
+ // the rest into a wall on a small screen.
+ 0}
+ logExtra={{ section: 'summary', occurrence: index + 1 }}
+ />
+ )}
+
+ ))}
+
- {!!post.contentHtml && (
-
globalThis?.document?.body}
- />
- )}
+ {/* One MPU per BODY_CHARS_PER_AD of visible text, only ever between
+ top-level blocks — splitContentForAds cannot cut a paragraph, list
+ or code block in half — and capped per section. Section and
+ occurrence ride the events for per-position analytics. */}
+ {bodyChunks.map((chunk, index, chunks) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ globalThis?.document?.body}
+ />
+ {index < chunks.length - 1 && (
+ 1 || index > 0}
+ logExtra={{ section: 'body', occurrence: index + 1 }}
+ />
+ )}
+
+ ))}
+
+ {/* Same block the post page shows: the questions that likely brought
+ an anonymous visitor here (the component self-hides for logged-in
+ users and question-less posts). */}
+
+
+
{/* The production engagement block verbatim — counts, actions, share,
sort control, composer and thread — so everything from here to the
end of the discussion matches the live post page exactly. The only
- addition is a native unit every few comments in a long thread. */}
+ addition is an MPU as a long thread grows. */}
(
+ renderInterleaved={(occurrence) => (
+ // Phone-hidden until the density precondition in slots.ts is
+ // satisfied: a repeating unit, and the phone figure was measured
+ // without it.
)}
/>
@@ -311,9 +345,14 @@ export function ArbitragePostContent({
className="!gap-2 pb-8 pt-4 tablet:border-l tablet:border-border-subtlest-tertiary"
hideSignupWidget
hideToc
+ hideAdWidget
getRailAd={(position) => {
const spec = RAIL_AD[position];
+ if (!spec) {
+ return null;
+ }
+
return (
);
}}
+ // The page's only sticky unit, closing the rail: last in the column,
+ // so pinning under the fixed chrome can never slide it over content —
+ // the overlap the mid-rail sticky produced. Compliant as a publisher
+ // sticky at exactly 300px wide, desktop only, one per viewport.
+ trailing={
+
+ }
/>
);
diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx
index ffb3c977f0..9df8dac74b 100644
--- a/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx
+++ b/packages/shared/src/components/post/arbitrage/ArbitrageTopLeaderboard.tsx
@@ -46,10 +46,23 @@ export function ArbitrageTopLeaderboard({
'z-2 laptop:sticky laptop:top-[var(--sticky-header-offset)]',
)}
>
+ {/* Two breakpoint twins of one unit: the phone requests a fixed
+ 320x100 (a responsive request can answer with expandable video —
+ half a pinned phone screen), tablet+ keeps the responsive 728x90.
+ Neither is eager: an eager push from a display:none twin would
+ initialise the visible one out of order, and both sit at the top of
+ the page where the intersection observer fires on first paint
+ anyway. A hidden ins never intersects, so exactly one requests. */}
+
diff --git a/packages/shared/src/components/post/arbitrage/slots.ts b/packages/shared/src/components/post/arbitrage/slots.ts
index 0a80901731..7c9a2196df 100644
--- a/packages/shared/src/components/post/arbitrage/slots.ts
+++ b/packages/shared/src/components/post/arbitrage/slots.ts
@@ -10,27 +10,34 @@ import type { AdsenseSlots } from '../../../features/monetization/adsense';
export const ARBITRAGE_SLOT = {
/** Leaderboard above the article. Sticks while scrolling, then releases. */
topLeaderboard: 2,
- /** Medium rectangle beside the tags, date and cover image. */
- inlineMpu1: 3,
- /** Rail unit after the author card. */
- railAfterCreator: 4,
- /** Rail unit after the share bar. */
- railAfterShare: 5,
- /** Rail unit after the highlights widget. */
- railAfterHighlights: 6,
- /** Native unit, repeated through a long comment thread. */
- commentNative: 7,
+ /** MPU repeated through a long comment thread. */
+ commentMpu: 7,
/** "MPU 1" in the brief: first rail unit, under the source card. */
railAfterSource: 11,
- /** Sticky rail unit after the further reading widget. */
+ /** Second rail unit, after the further reading widget. */
railBetweenFurtherReading: 12,
+ /** MPU repeated through the article body, one per BODY_CHARS_PER_AD. */
+ inBodyMpu: 17,
+ /** MPU directly above the comment section. */
+ aboveCommentsMpu: 18,
+ /** Half page closing the rail — the page's only sticky unit. */
+ railBottomSticky: 19,
+ /**
+ * The top leaderboard's phone twin: the same AdSense unit requested at a
+ * fixed 320x100. A responsive request can come back as expandable video,
+ * which inside the phone-sticky header block pinned half the screen; a
+ * fixed-size request can only return its exact size.
+ */
+ topLeaderboardPhone: 20,
} as const;
/*
- * Slot numbers 1, 8, 9, 10 and 13 are retired rather than reused: the sidebar
- * unit, the two closing multiplex grids, the half-page rail tower and the
- * custom floating leaderboard were all dropped, and their AdSense reporting
- * rows stay readable only while no other placement inherits the number.
+ * Slot numbers 1, 3, 4, 5, 6, 8, 9, 10 and 13 are retired rather than reused:
+ * the sidebar unit, the MPU beside the cover, the three extra rail units, the
+ * two closing multiplex grids, the half-page rail tower and the custom
+ * floating leaderboard were all dropped, and their AdSense reporting rows
+ * stay readable only while no other placement inherits the number. 15 and 16
+ * belong to the organic post page below.
*
* The bottom leaderboard is Google's Anchor format now, not a slot in this
* map: a publisher-implemented sticky is capped at 300px wide and desktop
@@ -56,10 +63,29 @@ export const ARBITRAGE_SLOT = {
export const TOP_LEADERBOARD_STICKY_MS = 10_000;
/**
- * A long thread gets a native unit after every this many comments. Short
- * threads never reach the interval, so they stay entirely ad-free.
+ * A long thread gets an MPU each time this many comments have gone by —
+ * replies included, every comment counts (product call, Aug 25; interval
+ * revised 8 → 6 the same day). Short threads stay ad-free.
*/
-export const COMMENTS_PER_INTERLEAVED_AD = 5;
+export const COMMENTS_PER_INTERLEAVED_AD = 6;
+
+/**
+ * Visible characters of content between in-content MPUs — 250, per Nick's
+ * confirmed spec (characters, not words; re-confirmed Aug 25 after the
+ * words reading shipped first). At this cadence density is carried by
+ * MAX_CONTENT_ADS_PER_SECTION below, not by the interval.
+ */
+export const CONTENT_CHARS_PER_AD = 250;
+
+/**
+ * Hard cap per section (TLDR, body): 250 characters is ~3 lines of rendered
+ * text per 282px unit, so an uncapped long body would be a wall of ads —
+ * the exact shape the Better Ads 30% mobile cap and AdSense's low-value
+ * policy act on, both of which punish the whole domain. The balanced
+ * splitters spread the capped units evenly through the section instead of
+ * front-loading them.
+ */
+export const MAX_CONTENT_ADS_PER_SECTION = 4;
/**
* The AdSense units behind each slot, keyed by slot number. Deliberately in
@@ -75,38 +101,42 @@ export const COMMENTS_PER_INTERLEAVED_AD = 5;
*/
export const READ_ADSENSE_SLOTS: AdsenseSlots = {
[ARBITRAGE_SLOT.topLeaderboard]: { id: '9942870945', type: 'display' },
- // read_s03 (9651332107) is an in-article unit, so it is fluid: it ignored
- // both the shape and an explicit 300x250 on the
and kept answering the
- // placement beside the cover with a card twice the cover's height. Pointing
- // it at a responsive Display unit is what actually binds the shape — at the
- // cost of blending its reporting with the rail unit it borrows.
- // TODO(chris): create a dedicated read_s03 Display unit and swap the id back
- // to get per-placement RPM.
- [ARBITRAGE_SLOT.inlineMpu1]: { id: '6921226982', type: 'display' },
- // TODO(chris): create the three new rail units (suggested names
- // read_s04_rail_creator, read_s05_rail_share, read_s06_rail_highlights) as
- // Display 300x250. They stay collapsed until their ids are filled in.
- [ARBITRAGE_SLOT.railAfterCreator]: { id: '', type: 'display' },
- [ARBITRAGE_SLOT.railAfterShare]: { id: '', type: 'display' },
- [ARBITRAGE_SLOT.railAfterHighlights]: { id: '', type: 'display' },
- // TODO(chris): layoutKey from the read_s07_comment_native "Get code" snippet
- // (data-ad-layout-key). The slot stays collapsed until it is filled in.
+ [ARBITRAGE_SLOT.topLeaderboardPhone]: {
+ id: '9942870945',
+ type: 'display',
+ width: 320,
+ height: 100,
+ },
+ // The three MPU placements below share existing Display units while the
+ // dedicated ones don't exist: Google's per-unit reporting blends them, but
+ // our first-party events split by slot number, so per-placement RPM stays
+ // queryable in ClickHouse.
+ // TODO(chris): create dedicated Display units (read_s17_in_body,
+ // read_s18_above_comments) and swap the ids for clean AdSense-side rows.
//
- // PRECONDITIONS on filling this in — this comment is the gate, since the
- // workflow is "ship reviewed once, switch on by editing this map":
- // 1. Ad label: DONE in code — ProgrammaticAd renders the policy-permitted
- // "Advertisements" caption above every inFeed unit, so an unlabeled
- // native between comments cannot ship by omission.
- // 2. Re-measure phone ad density on a long thread with the interval live.
- // The ~27% figure was measured with this slot inert, it is the only
- // repeating slot on the page, and Chrome's Better Ads filter applies to
- // the whole domain, direct-sold inventory included.
- [ARBITRAGE_SLOT.commentNative]: { id: '', type: 'inFeed', layoutKey: '' },
+ // Phone density, the written gate the previous map kept slot 7 behind:
+ // - The comment MPU stays hideOnPhone in the template until a long-thread
+ // phone measurement with the interval live says otherwise.
+ // - The in-content MPUs are phone-visible but capped: at the 250-char
+ // cadence the interval no longer bounds density, so
+ // MAX_CONTENT_ADS_PER_SECTION does — see its comment for the math.
+ [ARBITRAGE_SLOT.commentMpu]: { id: '6921226982', type: 'display' },
[ARBITRAGE_SLOT.railAfterSource]: { id: '5249052667', type: 'display' },
[ARBITRAGE_SLOT.railBetweenFurtherReading]: {
id: '6921226982',
type: 'display',
},
+ [ARBITRAGE_SLOT.inBodyMpu]: { id: '6921226982', type: 'display' },
+ [ARBITRAGE_SLOT.aboveCommentsMpu]: { id: '5249052667', type: 'display' },
+ // read_s10's fixed 300x600, back as the rail's closing unit. Compliant as a
+ // publisher sticky: 300px wide, desktop only, and the page's ONLY sticky —
+ // AdSense allows exactly one per viewport.
+ [ARBITRAGE_SLOT.railBottomSticky]: {
+ id: '4307400883',
+ type: 'display',
+ width: 300,
+ height: 600,
+ },
};
/**
diff --git a/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts b/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts
new file mode 100644
index 0000000000..42c6650510
--- /dev/null
+++ b/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts
@@ -0,0 +1,144 @@
+import { splitContentForAds, splitTextForAds } from './splitContentForAds';
+
+const para = (chars: number, label: string): string =>
+ `${label.repeat(Math.ceil(chars / label.length)).slice(0, chars)}
`;
+
+describe('splitContentForAds', () => {
+ it('returns short content as a single chunk', () => {
+ const html = para(100, 'a');
+ expect(splitContentForAds(html, 300)).toEqual([html]);
+ });
+
+ it('splits only at top-level block boundaries', () => {
+ const first = para(300, 'a');
+ const second = para(300, 'b');
+ const chunks = splitContentForAds(first + second, 250);
+
+ expect(chunks).toEqual([first, second]);
+ });
+
+ it('never cuts inside a nested structure', () => {
+ const list = `- ${'x'.repeat(300)}
- ${'y'.repeat(
+ 300,
+ )}
`;
+ const after = para(300, 'z');
+ const chunks = splitContentForAds(list + after, 250);
+
+ // The list crosses the threshold internally but closes as one unit.
+ expect(chunks).toEqual([list, after]);
+ });
+
+ it('treats code blocks as unsplittable units', () => {
+ const code = `${'if (x) {\n}\n'.repeat(40)}
`;
+ const after = para(300, 'a');
+ const chunks = splitContentForAds(code + after, 250);
+
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]).toBe(code);
+ });
+
+ it('does not let void elements corrupt the depth count', () => {
+ const withImages = `${'a'.repeat(150)}
${'b'.repeat(
+ 150,
+ )}
`;
+ const after = para(300, 'c');
+
+ expect(splitContentForAds(withImages + after, 250)).toEqual([
+ withImages,
+ after,
+ ]);
+ });
+
+ it('merges a trailing sliver into the previous chunk', () => {
+ const first = para(300, 'a');
+ const sliver = para(40, 'b');
+ const chunks = splitContentForAds(first + sliver, 250);
+
+ // An ad before one stray line reads as the page ending on an ad.
+ expect(chunks).toEqual([first + sliver]);
+ });
+
+ it('keeps every byte of the input across the chunks', () => {
+ const html =
+ `${para(400, 'a')}${'q'.repeat(300)}
` +
+ `Heading
${para(400, 'b')}`;
+ const chunks = splitContentForAds(html, 250);
+
+ expect(chunks.join('')).toBe(html);
+ expect(chunks.length).toBeGreaterThan(1);
+ });
+ it('ignores tags inside HTML comments when balancing depth', () => {
+ const html = `${para(300, 'a')}${para(300, 'b')}`;
+ const chunks = splitContentForAds(html, 250);
+
+ expect(chunks.join('')).toBe(html);
+ expect(chunks.length).toBe(2);
+ });
+});
+
+describe('splitTextForAds', () => {
+ it('keeps a short TLDR whole', () => {
+ expect(splitTextForAds('short summary.', 250)).toEqual(['short summary.']);
+ });
+
+ it('breaks a long TLDR at a sentence end past the threshold', () => {
+ const first = `${'a'.repeat(260)}.`;
+ const second = 'b'.repeat(300);
+ const parts = splitTextForAds(`${first} ${second}`, 250);
+
+ expect(parts).toEqual([first, second]);
+ });
+
+ it('breaks at the sentence end nearest the midpoint, not the first past a threshold', () => {
+ const sentences = [
+ `${'a'.repeat(100)}.`,
+ `${'b'.repeat(100)}.`,
+ `${'c'.repeat(100)}.`,
+ `${'d'.repeat(100)}.`,
+ ].join(' ');
+ const parts = splitTextForAds(sentences, 200);
+
+ // Two parts of two sentences each — a greedy threshold would cut 3/1.
+ expect(parts).toHaveLength(2);
+ expect(parts[0].endsWith(`${'b'.repeat(100)}.`)).toBe(true);
+ });
+
+ it('never places an ad within the cadence of the previous one', () => {
+ // Sentence ends at ~130 and ~380: nearest-to-midpoint alone would pick
+ // 130, putting an ad after half a cadence of text.
+ const text = `${'a'.repeat(130)}. ${'b'.repeat(250)}. ${'c'.repeat(300)}`;
+ const parts = splitTextForAds(text, 250);
+
+ parts.slice(0, -1).forEach((part) => {
+ expect(part.length).toBeGreaterThanOrEqual(250);
+ });
+ });
+
+ it('never ends on a sliver', () => {
+ // 720 chars rounds to a 3-part target; the only boundary near the last
+ // even point would leave a 20-char tail, which the floor rejects.
+ const text = `${'a'.repeat(300)}. ${'b'.repeat(400)}. ${'c'.repeat(20)}`;
+ const parts = splitTextForAds(text, 250);
+
+ expect(parts.length).toBeGreaterThan(1);
+ expect(parts[parts.length - 1].length).toBeGreaterThanOrEqual(125);
+ });
+
+ it('caps the part count at maxParts', () => {
+ const text = Array.from({ length: 10 }, () => `${'a'.repeat(250)}.`).join(
+ ' ',
+ );
+ expect(splitTextForAds(text, 250, 3)).toHaveLength(3);
+ });
+
+ it('falls back to word boundaries without sentence punctuation', () => {
+ const text = Array.from({ length: 120 }, () => 'word').join(' ');
+ const parts = splitTextForAds(text, 250);
+
+ expect(parts.length).toBeGreaterThan(1);
+ parts.forEach((part) => {
+ expect(part.startsWith('word')).toBe(true);
+ expect(part.endsWith('word')).toBe(true);
+ });
+ });
+});
diff --git a/packages/shared/src/components/post/arbitrage/splitContentForAds.ts b/packages/shared/src/components/post/arbitrage/splitContentForAds.ts
new file mode 100644
index 0000000000..1831e1e10b
--- /dev/null
+++ b/packages/shared/src/components/post/arbitrage/splitContentForAds.ts
@@ -0,0 +1,187 @@
+// Comments first, so a tag inside `` can never touch the
+// depth count; the alternation consumes the whole comment as one token.
+const TAG_RE =
+ /|<\/?([a-zA-Z][\w-]*)(?:[^>'"]|"[^"]*"|'[^']*')*?\/?>/g;
+
+// Elements that never take a closing tag, so an opening token must not
+// increase the nesting depth.
+const VOID_ELEMENTS = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'source',
+ 'track',
+ 'wbr',
+]);
+
+const visibleLength = (text: string): number =>
+ text
+ .replace(/&[#\w]+;/g, 'x')
+ .replace(/\s+/g, ' ')
+ .trim().length;
+
+/**
+ * Splits rendered article HTML into chunks for in-content ads, cutting only
+ * where a top-level block element closes — an ad can never land inside a
+ * paragraph, list, blockquote or code block. Like the TLDR splitter, cuts
+ * aim at even split points across the whole article (no front-loading) and
+ * carry the cadence as a hard floor: never within `minChars` of visible text
+ * of the previous cut, never with less than half a cadence after them.
+ */
+export function splitContentForAds(
+ html: string,
+ minChars: number,
+ maxParts = Infinity,
+): string[] {
+ // First pass: every depth-0 block boundary with the cumulative visible
+ // text before it.
+ const candidates: Array<{ index: number; visible: number }> = [];
+ let depth = 0;
+ let cursor = 0;
+ let visible = 0;
+
+ TAG_RE.lastIndex = 0;
+ let match = TAG_RE.exec(html);
+ while (match) {
+ const [token, rawName] = match;
+ visible += visibleLength(html.slice(cursor, match.index));
+ cursor = match.index + token.length;
+
+ if (token.startsWith('