Skip to content

Commit a4b3d0f

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@e15d3ea50167573cbe981004a8ee6f8dd678ee6c
1 parent 78d1eeb commit a4b3d0f

6 files changed

Lines changed: 347 additions & 5 deletions

File tree

bun.lock

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
accentSpanIssue,
5+
accentWordCandidates,
6+
countAccentSpans,
7+
hasAccentSpan,
8+
setAccentWord,
9+
splitAccentSpan,
10+
stripAccentSpan,
11+
} from '../ads/accent-span'
12+
13+
describe('splitAccentSpan', () => {
14+
test('splits the mockup headline around its accent', () => {
15+
expect(splitAccentSpan('Your next idea. *Live.*')).toEqual({
16+
before: 'Your next idea. ',
17+
accent: 'Live.',
18+
after: '',
19+
plain: 'Your next idea. Live.',
20+
})
21+
})
22+
23+
test('an accent in the middle keeps both sides', () => {
24+
const parts = splitAccentSpan('Ship *fast* today')
25+
expect(parts.before).toBe('Ship ')
26+
expect(parts.accent).toBe('fast')
27+
expect(parts.after).toBe(' today')
28+
})
29+
30+
test('a title with no markup is entirely plain', () => {
31+
expect(splitAccentSpan('Ship faster with Acme')).toEqual({
32+
before: 'Ship faster with Acme',
33+
accent: '',
34+
after: '',
35+
plain: 'Ship faster with Acme',
36+
})
37+
})
38+
39+
test('answers "no accent" for a title it will not vouch for', () => {
40+
// Two spans are refused at the console. A row that predates the rule must
41+
// still draw — as ordinary copy, never as markup.
42+
const parts = splitAccentSpan('*Two* accents *here*')
43+
expect(parts.accent).toBe('')
44+
expect(parts.before).toBe('Two accents here')
45+
expect(parts.plain).not.toContain('*')
46+
})
47+
48+
test('is stable across calls (no leaked regex lastIndex)', () => {
49+
const title = 'Your next idea. *Live.*'
50+
expect(splitAccentSpan(title)).toEqual(splitAccentSpan(title))
51+
expect(hasAccentSpan(title)).toBeTrue()
52+
expect(hasAccentSpan(title)).toBeTrue()
53+
})
54+
})
55+
56+
describe('stripAccentSpan', () => {
57+
test('every non-accent surface receives the plain string', () => {
58+
expect(stripAccentSpan('Your next idea. *Live.*')).toBe(
59+
'Your next idea. Live.',
60+
)
61+
expect(stripAccentSpan('*Two* accents *here*')).toBe('Two accents here')
62+
})
63+
64+
test('leaves a lone asterisk the advertiser meant to write', () => {
65+
expect(stripAccentSpan('3 * 4 faster')).toBe('3 * 4 faster')
66+
})
67+
68+
test('counts spans', () => {
69+
expect(countAccentSpans('none')).toBe(0)
70+
expect(countAccentSpans('*one*')).toBe(1)
71+
expect(countAccentSpans('*one* and *two*')).toBe(2)
72+
})
73+
})
74+
75+
describe('accentSpanIssue', () => {
76+
test('accepts none and exactly one', () => {
77+
expect(accentSpanIssue('Ship faster')).toBeNull()
78+
expect(accentSpanIssue('Your next idea. *Live.*')).toBeNull()
79+
})
80+
81+
test('rejects a title with two spans, with a message', () => {
82+
const issue = accentSpanIssue('*Two* accents *here*')
83+
expect(issue).toContain('one word only')
84+
})
85+
86+
test('rejects an unclosed span', () => {
87+
expect(accentSpanIssue('Your next idea. *Live.')).toContain(
88+
'second asterisk',
89+
)
90+
})
91+
})
92+
93+
describe('setAccentWord', () => {
94+
test('accents the first whole-word occurrence', () => {
95+
expect(setAccentWord('Live it live', 'Live')).toBe('*Live* it live')
96+
})
97+
98+
test('replaces an existing accent rather than adding a second', () => {
99+
expect(setAccentWord('Your next idea. *Live.*', 'idea.')).toBe(
100+
'Your next *idea.* Live.',
101+
)
102+
expect(countAccentSpans(setAccentWord('*a* b c', 'c'))).toBe(1)
103+
})
104+
105+
test('clearing removes the markers', () => {
106+
expect(setAccentWord('Your next idea. *Live.*', null)).toBe(
107+
'Your next idea. Live.',
108+
)
109+
})
110+
111+
test('a word the title does not contain leaves the plain title alone', () => {
112+
expect(setAccentWord('Ship faster', 'nope')).toBe('Ship faster')
113+
})
114+
115+
test('candidates keep punctuation attached, as the mockup does', () => {
116+
expect(accentWordCandidates('Your next idea. *Live.*')).toEqual([
117+
{ text: 'Your', at: 0 },
118+
{ text: 'next', at: 5 },
119+
{ text: 'idea.', at: 10 },
120+
{ text: 'Live.', at: 16 },
121+
])
122+
})
123+
})

common/src/__tests__/freebuff-placements.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,17 @@ describe('placementSlotLabel', () => {
388388
}
389389
})
390390

391+
it('names the four format slots by their format, not their position', () => {
392+
// `Single-Ad-Unit-1` is the one this exists for: the hyphen-splitter
393+
// renders it "Single Ad Unit 1", which names nothing an advertiser bought.
394+
expect(placementSlotLabel('Single-Ad-Unit-1')).toBe('CLI — Dock')
395+
expect(placementSlotLabel('Desktop-Spotlight')).toBe('Desktop — Spotlight')
396+
expect(placementSlotLabel('Desktop-Showcase')).toBe('Desktop — Showcase')
397+
expect(placementSlotLabel('Desktop-Intermission')).toBe(
398+
'Desktop — Intermission',
399+
)
400+
})
401+
391402
it('names the tracked-link grain, which no slot describes', () => {
392403
expect(placementSlotLabel(TRACKED_LINK_PLACEMENT_ID)).toBe('Tracked links')
393404
// That it is not a slot is proved by the compiler rather than asserted

common/src/ads/accent-span.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* The headline accent word.
3+
*
4+
* The sponsor-break mockups render ONE word of the headline in the accent
5+
* colour ("Your next idea. *Live.*"). That is a real difference in what the
6+
* advertiser bought — the accent is the loudest pixel on a break card — so it
7+
* has to be authored, stored and reproduced rather than guessed at render
8+
* time.
9+
*
10+
* ## Why a markup rather than a second column
11+
*
12+
* A `title_accent_word` column has to answer "which occurrence?" the first
13+
* time an advertiser writes "Live. Really live." A span written INSIDE the
14+
* title cannot be ambiguous: it names a position, not a word. The title is
15+
* stored verbatim, markers included, so the authored intent survives an edit
16+
* round-trip through the console.
17+
*
18+
* ## Why one span, and one parser
19+
*
20+
* Three surfaces read the same string and only one of them can draw an accent:
21+
* the Desktop break cards can, and the CLI dock, the inline terminal card and
22+
* the house-ad width budget cannot. Every one of those has to receive the
23+
* PLAIN string — a terminal that prints `*Live.*` has shipped our markup to a
24+
* user. So the split and the strip live here, together, and every surface
25+
* imports one of the two rather than writing its own regex.
26+
*
27+
* More than one span is REFUSED at the console rather than rendered: two
28+
* accents is not an emphasis, it is a rainbow, and quietly honouring the first
29+
* would ship copy the advertiser did not preview. The renderers still degrade
30+
* safely — {@link splitAccentSpan} answers "no accent" for a title it will not
31+
* vouch for, and {@link stripAccentSpan} still removes the markers — because a
32+
* row written before this validation existed must not print asterisks.
33+
*
34+
* ## Character budgets count the PLAIN text
35+
*
36+
* `*` is markup, not copy. A 28-character break title with an accented last
37+
* word is 28 characters on screen and 30 in the database, and charging the
38+
* advertiser two characters for a formatting mark they never see is the kind
39+
* of limit nobody can explain. Callers of the break copy limits measure
40+
* {@link stripAccentSpan} of the title.
41+
*/
42+
43+
/** One `*…*` run. Non-greedy by construction: the body may not contain `*`. */
44+
const ACCENT_SPAN = /\*([^*]+)\*/g
45+
46+
export interface AccentSpanParts {
47+
/** Text before the accent — the whole plain title when there is no accent. */
48+
before: string
49+
/** The accented run, without its markers. Empty when there is no accent. */
50+
accent: string
51+
after: string
52+
/** The whole title with every marker removed. What non-accent surfaces get. */
53+
plain: string
54+
}
55+
56+
/** How many well-formed spans a title carries. */
57+
export function countAccentSpans(title: string): number {
58+
return title.match(ACCENT_SPAN)?.length ?? 0
59+
}
60+
61+
/**
62+
* The title with the markers removed.
63+
*
64+
* Only PAIRED markers are removed. A lone `*` is left alone: an advertiser
65+
* writing "3 * 4 faster" wrote an asterisk on purpose, and silently deleting
66+
* it would corrupt copy to tidy up markup that is not there.
67+
*/
68+
export function stripAccentSpan(title: string): string {
69+
return title.replace(ACCENT_SPAN, '$1')
70+
}
71+
72+
/**
73+
* Split a title into its accent parts. TOTAL: never throws, and never returns
74+
* an accent it is not sure about.
75+
*
76+
* A title with zero spans, or with more than one, comes back as plain text in
77+
* `before` with an empty `accent`. That is the same answer a surface that
78+
* cannot draw an accent would get, which is exactly the fallback wanted: an
79+
* unvouched-for title renders as ordinary copy, never as markup.
80+
*/
81+
export function splitAccentSpan(title: string): AccentSpanParts {
82+
const plain = stripAccentSpan(title)
83+
if (countAccentSpans(title) !== 1) {
84+
return { before: plain, accent: '', after: '', plain }
85+
}
86+
// Re-run without the global flag: a `g` regex carries `lastIndex` across
87+
// calls, and a shared module-level literal would answer differently on
88+
// every other invocation.
89+
const match = /\*([^*]+)\*/.exec(title)
90+
if (!match || match.index === undefined) {
91+
return { before: plain, accent: '', after: '', plain }
92+
}
93+
return {
94+
before: title.slice(0, match.index),
95+
accent: match[1] ?? '',
96+
after: title.slice(match.index + match[0].length),
97+
plain,
98+
}
99+
}
100+
101+
/** Whether this title carries exactly one accent a surface may draw. */
102+
export function hasAccentSpan(title: string): boolean {
103+
return splitAccentSpan(title).accent.length > 0
104+
}
105+
106+
/**
107+
* Why this title's accent markup is not acceptable, in the advertiser's terms,
108+
* or null.
109+
*
110+
* Checked on write in the console, never at render: a stored row that predates
111+
* this rule still has to draw.
112+
*/
113+
export function accentSpanIssue(title: string): string | null {
114+
const spans = countAccentSpans(title)
115+
if (spans > 1) {
116+
return 'Accent one word only — remove the extra *asterisks* from this title.'
117+
}
118+
// An odd marker left over after the paired ones are consumed is a span the
119+
// advertiser started and did not close. Saying so beats rendering a stray
120+
// asterisk on a card they cannot edit from.
121+
if (stripAccentSpan(title).includes('*')) {
122+
return 'Close the accent with a second asterisk, like *this*.'
123+
}
124+
return null
125+
}
126+
127+
/**
128+
* Apply an accent to `word` inside `title`, or clear it. What the console's
129+
* "Accent a word" control writes, so nobody is asked to type asterisks.
130+
*
131+
* The FIRST occurrence, matched on a word boundary, so accenting "Live" in
132+
* "Live it live" marks the word the advertiser clicked rather than a fragment
133+
* of another one. A word the title does not contain leaves the title alone.
134+
*/
135+
export function setAccentWord(title: string, word: string | null): string {
136+
const plain = stripAccentSpan(title)
137+
if (!word) return plain
138+
const target = word.trim()
139+
if (!target) return plain
140+
const at = accentWordOffset(plain, target)
141+
if (at < 0) return plain
142+
return `${plain.slice(0, at)}*${target}*${plain.slice(at + target.length)}`
143+
}
144+
145+
/** Where a whole-word occurrence of `word` starts in `plain`, or -1. */
146+
function accentWordOffset(plain: string, word: string): number {
147+
for (const candidate of accentWordCandidates(plain)) {
148+
if (candidate.text === word) return candidate.at
149+
}
150+
return -1
151+
}
152+
153+
/**
154+
* The words of a title an advertiser may accent, in order, with their
155+
* offsets. Whitespace-separated runs — punctuation stays attached, because
156+
* the mockup's accent is "Live." with its full stop inside the colour.
157+
*/
158+
export function accentWordCandidates(
159+
title: string,
160+
): { text: string; at: number }[] {
161+
const plain = stripAccentSpan(title)
162+
const words: { text: string; at: number }[] = []
163+
const pattern = /\S+/g
164+
let match: RegExpExecArray | null
165+
while ((match = pattern.exec(plain)) !== null) {
166+
words.push({ text: match[0], at: match.index })
167+
}
168+
return words
169+
}

common/src/constants/freebuff-placements.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,24 @@ export function isInterruptingBreakPlacement(placementId: string): boolean {
358358
* copy for an inline slot and the console is where a placement choice and its
359359
* copy are seen together.
360360
*/
361+
/**
362+
* The one delivery fact a break's numbers cannot be read without.
363+
*
364+
* A break is capped at one per user per day, so its impression count is
365+
* bounded by AUDIENCE rather than by budget: a break row two orders of
366+
* magnitude under an inline row beside it is the cap working, not
367+
* under-delivery. Stated wherever a break's delivery is shown, in one place
368+
* so the builder's picker and the delivery breakdown cannot phrase the same
369+
* cap two ways.
370+
*
371+
* Deliberately not a number. The cap itself is server-side and operator-owned
372+
* (`FREEBUFF_SPONSOR_BREAK_DAILY_CAP`); printing a figure here would be a
373+
* second copy of it, free to drift, in the one place an advertiser would
374+
* quote it back to us.
375+
*/
376+
export const SPONSOR_BREAK_FREQUENCY_COPY =
377+
'Shown at most once per user per day'
378+
361379
export const SPONSOR_BREAK_CREATIVE_LIMITS = {
362380
titleMaxLength: 28,
363381
bodyMaxLength: 60,
@@ -412,8 +430,31 @@ export const TRACKED_LINK_SURFACE = 'tracked_link'
412430
* exactly as the breakdown table already rendered them, and an unknown id
413431
* degrades to a readable string rather than to `undefined`.
414432
*/
433+
/**
434+
* The four slots whose name has to say the FORMAT, not the position.
435+
*
436+
* The hyphen-splitter below is a good formatter for a slot named after where
437+
* it sits (`waiting-room-1`, `Desktop-Below-Chat`), and a poor one for a slot
438+
* named after how it draws. `Single-Ad-Unit-1` becomes "Single Ad Unit 1",
439+
* which tells an advertiser reading a delivery breakdown nothing about the
440+
* expandable terminal dock they bought; the three break ids happen to split
441+
* correctly today and are pinned here anyway, so a rename of the splitter
442+
* cannot quietly rename a format.
443+
*
444+
* The em dash matches the campaign builder's picker labels, so the same slot
445+
* reads the same way in the place it is bought and the place it is reported.
446+
*/
447+
const PLACEMENT_FORMAT_LABELS: Record<string, string> = {
448+
'Desktop-Spotlight': 'Desktop — Spotlight',
449+
'Desktop-Showcase': 'Desktop — Showcase',
450+
'Desktop-Intermission': 'Desktop — Intermission',
451+
'Single-Ad-Unit-1': 'CLI — Dock',
452+
}
453+
415454
export function placementSlotLabel(placementId: string): string {
416455
if (placementId === TRACKED_LINK_PLACEMENT_ID) return 'Tracked links'
456+
const formatted = PLACEMENT_FORMAT_LABELS[placementId]
457+
if (formatted) return formatted
417458
const [head, ...rest] = placementId.split('-')
418459
if (!head) return placementId
419460
return [head[0]!.toUpperCase() + head.slice(1), ...rest].join(' ')

0 commit comments

Comments
 (0)