feat(magazine): improve brief extraction with heading banners and cleaner content
- Rewrite extractMagazineSections to robustly parse ## and ### headings with any emoji (not just a hardcoded list) - Strip [[podcast:...]], [[film:...]] and other content tags from magazine text - Strip "For deeper coverage" and "Sources" sections from magazine content - Use heading titles as banner dividers instead of repeating them on every tile - Add group field to MagazineSection for heading-based grouping - Strip ** markdown from both titles and content - Enlarge "In response to" headline text to text-2xl for editorial prominence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cddfe93c5c
commit
7f8dc72dd7
@@ -20,7 +20,7 @@
|
||||
:class="isDark ? 'text-white/30' : 'text-black/40'">
|
||||
In response to
|
||||
</p>
|
||||
<p class="font-serif text-sm mt-0.5 italic"
|
||||
<p class="font-serif text-2xl mt-1 italic leading-tight"
|
||||
:class="isDark ? 'text-white/60' : 'text-black/50'">
|
||||
{{ headlineText }}
|
||||
</p>
|
||||
@@ -191,14 +191,12 @@ const tiles = computed<Tile[]>(() => {
|
||||
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
|
||||
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
|
||||
|
||||
// Layout rhythm: wide → half pair → banner → wide → half pair → ...
|
||||
// This creates the New Yorker editorial cadence
|
||||
let layoutPhase = 0 // 0=wide, 1=half-pair, 2=banner
|
||||
let lastGroup = ''
|
||||
let bannerIdx = 0
|
||||
let pairToggle = false // track half-tile pairing
|
||||
|
||||
secs.forEach((section, i) => {
|
||||
const bullets = splitIntoBullets(section.content)
|
||||
|
||||
if (i === 0) {
|
||||
if (i === 0 && !section.group) {
|
||||
// Lead section: always wide
|
||||
result.push({
|
||||
type: 'wide',
|
||||
@@ -208,44 +206,49 @@ const tiles = computed<Tile[]>(() => {
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
layoutPhase = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Insert banner to break up content
|
||||
if (layoutPhase === 2) {
|
||||
const bIdx = Math.floor(rand() * bannerIcons.length)
|
||||
// Insert a banner when entering a new heading group
|
||||
const group = section.group || ''
|
||||
if (group && group !== lastGroup) {
|
||||
// Pad any unpaired half tile before the banner
|
||||
if (pairToggle) {
|
||||
result.push({ type: 'dark', title: '', text: '' })
|
||||
pairToggle = false
|
||||
}
|
||||
result.push({
|
||||
type: 'banner',
|
||||
title: '',
|
||||
text: '',
|
||||
icon: bannerIcons[bIdx],
|
||||
label: bannerLabels[bIdx],
|
||||
icon: bannerIcons[bannerIdx % bannerIcons.length],
|
||||
label: group,
|
||||
})
|
||||
layoutPhase = 0
|
||||
bannerIdx++
|
||||
lastGroup = group
|
||||
}
|
||||
|
||||
if (bullets.length >= 3) {
|
||||
// Multi-bullet section: wide header + half tiles for bullets
|
||||
// Sections within a group get alternating half/dark tiles
|
||||
if (group) {
|
||||
const variant = pairToggle ? 'dark' : 'half'
|
||||
// If title is basically the same as content start, skip the title and just show content
|
||||
const contentClean = cleanText(section.content)
|
||||
const titleClean = cleanText(section.title)
|
||||
const titleIsContent = contentClean.toLowerCase().startsWith(titleClean.toLowerCase().slice(0, 30))
|
||||
result.push({
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
text: truncate(bullets[0], 150),
|
||||
label: section.author ? `By ${section.author}` : undefined,
|
||||
type: variant,
|
||||
title: titleIsContent ? '' : section.title,
|
||||
text: truncate(section.content, titleIsContent ? 160 : 100),
|
||||
section,
|
||||
})
|
||||
for (let b = 1; b < bullets.length; b++) {
|
||||
const variant = rand() > 0.6 ? 'dark' : 'half'
|
||||
result.push({
|
||||
type: variant,
|
||||
title: extractBulletTitle(bullets[b]) || section.title,
|
||||
text: truncate(cleanBulletTitle(bullets[b]), 100),
|
||||
section,
|
||||
})
|
||||
pairToggle = !pairToggle
|
||||
} else {
|
||||
// Non-grouped sections: use wide layout
|
||||
// Pad any unpaired half tile
|
||||
if (pairToggle) {
|
||||
result.push({ type: 'dark', title: '', text: '' })
|
||||
pairToggle = false
|
||||
}
|
||||
layoutPhase = 2
|
||||
} else if (layoutPhase === 0) {
|
||||
// Wide tile phase
|
||||
result.push({
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
@@ -253,86 +256,15 @@ const tiles = computed<Tile[]>(() => {
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
layoutPhase = 1
|
||||
} else {
|
||||
// Half-width tile phase: split content at a sentence boundary
|
||||
const cleaned = cleanText(section.content)
|
||||
|
||||
// Only split if content is long enough for two meaningful tiles
|
||||
if (cleaned.length < 120) {
|
||||
// Too short to split — use as a half tile with a decorative companion
|
||||
result.push({
|
||||
type: 'half',
|
||||
title: section.title,
|
||||
text: truncate(section.content, 120),
|
||||
section,
|
||||
})
|
||||
result.push({
|
||||
type: 'dark',
|
||||
title: '',
|
||||
text: '',
|
||||
})
|
||||
} else {
|
||||
// Find a sentence boundary (. or — or ;) after first ~40% of content
|
||||
const target = Math.floor(cleaned.length * 0.4)
|
||||
let splitAt = -1
|
||||
for (const sep of ['. ', ' — ', '; ']) {
|
||||
const idx = cleaned.indexOf(sep, target)
|
||||
if (idx > 0 && idx < cleaned.length * 0.7) {
|
||||
splitAt = idx + sep.length
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fallback: find a word boundary near the middle
|
||||
if (splitAt < 0) {
|
||||
const mid = Math.floor(cleaned.length / 2)
|
||||
const spaceIdx = cleaned.indexOf(' ', mid)
|
||||
splitAt = spaceIdx > 0 ? spaceIdx + 1 : mid
|
||||
}
|
||||
|
||||
const firstHalf = cleaned.slice(0, splitAt).trim()
|
||||
const secondHalf = cleaned.slice(splitAt).trim()
|
||||
|
||||
result.push({
|
||||
type: 'half',
|
||||
title: section.title,
|
||||
text: firstHalf.length > 110 ? firstHalf.slice(0, 107) + '\u2009...' : firstHalf,
|
||||
section,
|
||||
})
|
||||
result.push({
|
||||
type: 'dark',
|
||||
title: '',
|
||||
text: secondHalf.length > 110 ? secondHalf.slice(0, 107) + '\u2009...' : secondHalf,
|
||||
section,
|
||||
})
|
||||
}
|
||||
layoutPhase = 2
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure half tiles are paired (no orphans)
|
||||
const finalResult: Tile[] = []
|
||||
let pendingHalf = false
|
||||
for (const tile of result) {
|
||||
finalResult.push(tile)
|
||||
if (tile.type === 'half' || tile.type === 'dark') {
|
||||
pendingHalf = !pendingHalf
|
||||
} else {
|
||||
if (pendingHalf) {
|
||||
// Insert a spacer dark tile to pair the orphan
|
||||
finalResult.splice(finalResult.length - 1, 0, {
|
||||
type: 'dark', title: '', text: '', label: '',
|
||||
})
|
||||
pendingHalf = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we end with an unpaired half, pad it
|
||||
if (pendingHalf) {
|
||||
finalResult.push({ type: 'dark', title: '', text: '', label: '' })
|
||||
// Pad final unpaired half tile
|
||||
if (pairToggle) {
|
||||
result.push({ type: 'dark', title: '', text: '' })
|
||||
}
|
||||
|
||||
return finalResult
|
||||
return result
|
||||
})
|
||||
|
||||
/** Extract bold title from a bullet point like "**Title** - rest" */
|
||||
|
||||
Reference in New Issue
Block a user