diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index ed0b686cec..719044a923 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -46,10 +46,12 @@ interface PostCommentsProps { canReply?: MainCommentProps['canReply']; onReplyBlocked?: MainCommentProps['onReplyBlocked']; /** - * Renders between top-level comments, every `interleaveEvery` of them. Used - * by the ad template to break a long thread up; never after the last comment, - * where whatever follows the thread already sits. Both props are required - * together, and without them the list keeps its original markup. + * Renders after the top-level comment at which the running total of + * comments — replies included, every comment counts — crosses a multiple + * of `interleaveEvery`. Used by the ad template to break a long thread up; + * never after the last top-level comment, where whatever follows the + * thread already sits. Both props are required together, and without them + * the list keeps its original markup. */ interleaveEvery?: number; renderInterleaved?: (occurrence: number) => ReactNode; @@ -134,50 +136,63 @@ export function PostComments({ } ref={container} > - {comments!.postComments.edges.map((e, index) => { - const isLast = index === comments!.postComments.edges.length - 1; - // Never after the last comment, where whatever follows the thread - // already sits. - const shouldInterleave = - !!interleaveEvery && - !!renderInterleaved && - !isLast && - (index + 1) % interleaveEvery === 0; + {(() => { + // Replies count too: the interval is over everything the reader + // scrolls past, not just top-level rows (loaded replies — collapsed + // pagination beyond the first page is not on screen and not counted), + // and the boundary can only sit after a top-level block. One prefix + // pass instead of two reductions per row. + const totals: number[] = [0]; + comments!.postComments.edges.forEach((edge, i) => { + totals.push(totals[i] + 1 + (edge.node.children?.edges?.length ?? 0)); + }); + return comments!.postComments.edges.map((e, index, edges) => { + const isLast = index === edges.length - 1; + const seen = totals[index + 1]; + const shouldInterleave = + !!interleaveEvery && + !!renderInterleaved && + !isLast && + Math.floor(seen / interleaveEvery) > + Math.floor(totals[index] / interleaveEvery); - // Always the Fragment, even rows that interleave nothing: the type at - // a given key must not flip as the boundary moves (a new comment - // landing shifts every index), or React remounts that comment's - // subtree and open reply boxes lose their state. - return ( - - } - comment={e.node} - onShare={onShare ?? noopShare} - onDelete={(comment, parentId) => - deleteComment(comment.id, parentId ?? null, post) - } - onShowUpvotes={onClickUpvote ?? noopShowUpvotes} - postAuthorId={post.author?.id ?? null} - postScoutId={post.scout?.id ?? null} - appendTooltipTo={getAppendTooltipParent} - permissionNotificationCommentId={permissionNotificationCommentId} - joinNotificationCommentId={joinNotificationCommentId} - onCommented={onCommented} - lazy={!commentHash && index >= lazyCommentThreshold} - canReply={canReply} - onReplyBlocked={onReplyBlocked} - /> - {shouldInterleave && - renderInterleaved((index + 1) / interleaveEvery)} - - ); - })} + // Always the Fragment, even rows that interleave nothing: the type at + // a given key must not flip as the boundary moves (a new comment + // landing shifts every index), or React remounts that comment's + // subtree and open reply boxes lose their state. + return ( + + } + comment={e.node} + onShare={onShare ?? noopShare} + onDelete={(comment, parentId) => + deleteComment(comment.id, parentId ?? null, post) + } + onShowUpvotes={onClickUpvote ?? noopShowUpvotes} + postAuthorId={post.author?.id ?? null} + postScoutId={post.scout?.id ?? null} + appendTooltipTo={getAppendTooltipParent} + permissionNotificationCommentId={ + permissionNotificationCommentId + } + joinNotificationCommentId={joinNotificationCommentId} + onCommented={onCommented} + lazy={!commentHash && index >= lazyCommentThreshold} + canReply={canReply} + onReplyBlocked={onReplyBlocked} + /> + {shouldInterleave && + renderInterleaved(Math.floor(seen / interleaveEvery))} + + ); + }); + })()} ); } diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index d43e49dc37..3aa08de19a 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -52,6 +52,11 @@ interface PostEngagementsProps { onCopyLinkClick?: (post?: Post) => void; /** Ad templates break a long thread up — see PostComments. */ interleaveEvery?: number; + /** + * Drops the internal AdAsComment. The programmatic template carries its own + * comment-thread units, and two ad systems in one thread double the density. + */ + hideInternalAd?: boolean; renderInterleaved?: (occurrence: number) => ReactNode; } @@ -60,6 +65,7 @@ function PostEngagements({ onCopyLinkClick, logOrigin, shouldOnboardAuthor, + hideInternalAd, interleaveEvery, renderInterleaved, }: PostEngagementsProps): ReactElement { @@ -172,7 +178,7 @@ function PostEngagements({ shouldHandleCommentQuery CommentInputOrModal={CommentInputOrModal} /> - {!isPlus && } + {!isPlus && !hideInternalAd && } & getRailAd?: (position: PostWidgetPosition) => ReactNode; /** Rendered last, below the footer links. */ trailing?: ReactNode; + /** Drops the internal sidebar ad — for templates carrying their own. */ + hideAdWidget?: boolean; }; /** @@ -100,6 +102,7 @@ export function PostWidgets({ hideToc = false, getRailAd, trailing, + hideAdWidget, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); const { source } = post; @@ -158,10 +161,12 @@ export function PostWidgets({ /> ), )} - + {!hideAdWidget && ( + + )} {withAd( PostWidgetPosition.Share, diff --git a/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx index 3d490b1808..5d1072c676 100644 --- a/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx +++ b/packages/shared/src/components/post/arbitrage/ArbitrageAdSlot.tsx @@ -28,11 +28,10 @@ export interface ArbitrageAdSlotProps { /** Marks slots wired to a declared 30-60s in-view refresh once on Ad Manager. */ refreshes?: boolean; /** - * Drops the slot below the tablet breakpoint. The Better Ads Standards cap - * mobile ad density at 30% of page height, and a scraped post carries little - * body text to dilute it — running every slot on a phone measured 56%, which - * is what gets a site's ads filtered by Chrome. The unit is hidden rather - * than skipped so it also never requests: the ad only pushes on intersection, + * Drops the slot below the tablet breakpoint — the Better Ads Standards cap + * mobile ad density at 30% of page height, and Chrome's filter for a + * violation applies to the whole domain. The unit is hidden rather than + * skipped so it also never requests: the ad only pushes on intersection, * and a display:none box never intersects. */ hideOnPhone?: boolean; @@ -49,6 +48,8 @@ export interface ArbitrageAdSlotProps { * arrived, so eager pushes ride its very first processing pass. */ eager?: boolean; + /** Per-instance extra for repeated placements — see ProgrammaticAd. */ + logExtra?: Record; } function MappedAdSlot({ @@ -58,6 +59,7 @@ function MappedAdSlot({ refreshes, hideOnPhone, eager, + logExtra, slots, surface, allowPlaceholder = false, @@ -88,6 +90,7 @@ function MappedAdSlot({ refreshes={refreshes} hideOnPhone={hideOnPhone} eager={eager} + logExtra={logExtra} /> ); } diff --git a/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx b/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx index ce1a201ea2..6e3a48bb75 100644 --- a/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx +++ b/packages/shared/src/components/post/arbitrage/ArbitragePostContent.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { isVideoPost } from '../../../graphql/posts'; @@ -19,9 +19,13 @@ import { TruncateText } from '../../utilities'; import Markdown from '../../Markdown'; import { ArbitrageAdFormat, ArbitrageAdSlot } from './ArbitrageAdSlot'; import { ArbitrageTopLeaderboard } from './ArbitrageTopLeaderboard'; +import { PostAnsweredQuestions } from '../PostAnsweredQuestions'; +import { splitContentForAds, splitTextForAds } from './splitContentForAds'; import { ARBITRAGE_SLOT, COMMENTS_PER_INTERLEAVED_AD, + CONTENT_CHARS_PER_AD, + MAX_CONTENT_ADS_PER_SECTION, TOP_LEADERBOARD_STICKY_MS, } from './slots'; import { useTimedRelease } from './useTimedRelease'; @@ -30,60 +34,35 @@ import { PostWidgets, PostWidgetPosition } from '../PostWidgets'; import PostEngagements from '../PostEngagements'; /** - * One slot per real rail widget, in render order. The rail is the page's only - * column with no article in it, so every widget there earns a unit; the two - * that are already commercial (the house ad widget and the sponsored tools - * card) have no position and so get none. - * - * Below laptop the rail stacks under the article rather than beside it, so - * an unfiltered run lands every unit on a phone too — measured at roughly 40% - * of page height against the Better Ads Standards' 30% mobile cap, and - * Chrome's ad filter for a violation applies to the whole domain, direct-sold - * inventory included. Only the first rail unit keeps its phone placement; the - * rest are desktop-only, which brings the phone run well under the cap. + * The rail carries two in-flow units between its widgets, and the closing + * sticky half page arrives separately via PostWidgets' `trailing`. None of + * them keep a phone placement: below laptop the rail stacks under the + * article, and the phone's density budget is spent on the leaderboard, the + * first in-content unit and the above-comments MPU (~17-22% of a scraped + * page against the Better Ads 30% cap). */ -const RAIL_AD: Record< - PostWidgetPosition, - { - slot: number; - format: ArbitrageAdFormat; - className?: string; - hideOnPhone?: boolean; - } +const RAIL_AD: Partial< + Record< + PostWidgetPosition, + { + slot: number; + format: ArbitrageAdFormat; + className?: string; + hideOnPhone?: boolean; + } + > > = { [PostWidgetPosition.Source]: { slot: ARBITRAGE_SLOT.railAfterSource, format: ArbitrageAdFormat.MediumRectangle, - }, - [PostWidgetPosition.Creator]: { - slot: ARBITRAGE_SLOT.railAfterCreator, - format: ArbitrageAdFormat.MediumRectangle, - hideOnPhone: true, - }, - [PostWidgetPosition.Share]: { - slot: ARBITRAGE_SLOT.railAfterShare, - format: ArbitrageAdFormat.MediumRectangle, - hideOnPhone: true, - }, - [PostWidgetPosition.Highlights]: { - slot: ARBITRAGE_SLOT.railAfterHighlights, - format: ArbitrageAdFormat.MediumRectangle, hideOnPhone: true, }, - // Between "You might like" and the discussions, and the only unit that - // stays with the visitor: it pins under the fixed chrome and rides the rest - // of the scroll. Sticky is bounded by the containing block, which must be - // the rail itself — stretched to the article column's height — for the unit - // to have the whole page to travel; FurtherReading flattens to `contents` - // around it for exactly that reason, and any wrapper that generates a box - // here would cut the travel to that box. z-1 puts it over the widgets that - // scroll underneath, and the background keeps them from showing through the - // space the creative does not fill. + // In flow, not sticky: a sticky unit mid-rail slides over the widgets + // below it, and the rail's one sticky lives at its very end (slot 19), + // where nothing follows for it to cover. [PostWidgetPosition.SimilarPosts]: { slot: ARBITRAGE_SLOT.railBetweenFurtherReading, format: ArbitrageAdFormat.MediumRectangle, - className: - 'laptop:sticky laptop:top-[calc(var(--sticky-header-offset)+1rem)] laptop:z-1 laptop:bg-background-default', hideOnPhone: true, }, }; @@ -120,6 +99,32 @@ export function ArbitragePostContent({ post, }); const leaderboardReleased = useTimedRelease(TOP_LEADERBOARD_STICKY_MS); + // Memoised: the splits re-scan the whole text, and this component + // re-renders on comment sorting, hover state and auth resolution. The TLDR + // is main content here — for a scraped article it is the only content — so + // it carries the same MPU cadence as a hosted body. + const summaryParts = useMemo( + () => + post.summary + ? splitTextForAds( + post.summary, + CONTENT_CHARS_PER_AD, + MAX_CONTENT_ADS_PER_SECTION + 1, + ) + : [], + [post.summary], + ); + const bodyChunks = useMemo( + () => + post.contentHtml + ? splitContentForAds( + post.contentHtml, + CONTENT_CHARS_PER_AD, + MAX_CONTENT_ADS_PER_SECTION + 1, + ) + : [], + [post.contentHtml], + ); return ( @@ -202,32 +207,28 @@ export function ArbitragePostContent({ /> )} - {!!post.summary && ( -
-

- {post.summary} -

-
- )} - - {/* MPU 1 beside the tags, date and cover rather than above them, so the - first ad shares the fold with real page furniture instead of - standing alone. The slot is first in the DOM because a phone stacks - the column and the brief puts the unit above the article, not below - it; from laptop `order-last` moves it to the right of the group. - - The two halves are deliberately near equal — 336 for the unit - against 385 for the article's, out of the column's 745 — so the ad - reads as the cover's counterpart rather than as a tower beside it. - items-end puts their bottom edges on the same line. */} -
- + {summaryParts.map((part, index, parts) => ( + // eslint-disable-next-line react/no-array-index-key + +
+

{part}

+
+ {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('