From 7f8dc72dd76c91e8bcd92e2f68e203a2377e68dc Mon Sep 17 00:00:00 2001
From: Dorian
Date: Tue, 3 Mar 2026 07:30:19 +0000
Subject: [PATCH] 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
---
.../src/components/content/MagazineGrid.vue | 144 +++++-------------
.../app/src/composables/useContentPanel.ts | 128 ++++++++++------
2 files changed, 118 insertions(+), 154 deletions(-)
diff --git a/packages/app/src/components/content/MagazineGrid.vue b/packages/app/src/components/content/MagazineGrid.vue
index 32de9fe5..45b9389a 100644
--- a/packages/app/src/components/content/MagazineGrid.vue
+++ b/packages/app/src/components/content/MagazineGrid.vue
@@ -20,7 +20,7 @@
:class="isDark ? 'text-white/30' : 'text-black/40'">
In response to
-
{{ headlineText }}
@@ -191,14 +191,12 @@ const tiles = computed(() => {
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(() => {
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(() => {
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" */
diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts
index 430fa272..c743ba51 100644
--- a/packages/app/src/composables/useContentPanel.ts
+++ b/packages/app/src/composables/useContentPanel.ts
@@ -18,6 +18,8 @@ export interface MagazineSection {
author?: string
/** Optional link to open in iframe */
url?: string
+ /** Heading group this section belongs to (for banner display) */
+ group?: string
}
const panelOpen = ref(false)
@@ -120,9 +122,10 @@ function addSection(
title: string,
content: string,
seen: Set,
+ group?: string,
): void {
- const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
- const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
+ const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
+ const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
if (t.length < 2 || c.length < 15) return
const key = `${t.slice(0, 50)}`
if (seen.has(key)) return
@@ -140,77 +143,106 @@ function addSection(
url: extractUrlFromText(content),
author: extractAuthorFromText(content),
imageUrl,
+ group,
})
}
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
+function cleanMagazineContent(raw: string): string {
+ return raw
+ .replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '') // strip content tags
+ .replace(/^\s*\n---\s*\n?/g, '') // strip --- separators
+ .replace(/\n---\s*$/g, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const seen = new Set()
- const blockContents = new Set() // avoid duplicate bullets already in ## blocks
- // 1. ## or ### Heading blocks: full content until next heading or **Section**
- const headingRe = /^#{2,3}\s*[^\n]*?(?:⚡|🔥|📌|✨|📺|⭐|🔄|🎬|🎭|🎵|🎧|📖|💡|🔍|🌍|💭|🧠|🔬|📊|🏆|📝)?\s*(.+?)\n\n([\s\S]+?)(?=\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\n\n---|\n\n\*\*TL;DR|\z)/gim
+ // Strip sources/references and "For deeper" sections at the end
+ let cleanedText = text
+ .replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
+ .replace(/\n+For deeper[^:]*:[\s\S]*$/im, '')
+
+ // 1. Find all ## or ### headings and extract content between them
+ const headingPositions: { title: string; start: number; contentStart: number }[] = []
+ const headingLineRe = /^#{2,3}\s+(.+)$/gm
let m: RegExpExecArray | null
- while ((m = headingRe.exec(text)) !== null) {
- const title = m[1].trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
- const content = m[2].trim()
- if (content.length > 20) {
- blockContents.add(content)
- addSection(sections, title, content, seen)
+ while ((m = headingLineRe.exec(cleanedText)) !== null) {
+ const rawTitle = m[1].trim()
+ const title = rawTitle
+ .replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
+ .replace(/^[#*_\s-]+/, '')
+ .trim()
+ if (title.length > 1) {
+ headingPositions.push({ title, start: m.index, contentStart: m.index + m[0].length })
+ }
+ }
+
+ // For each heading, extract individual bullet points as separate sections
+ for (let i = 0; i < headingPositions.length; i++) {
+ const hp = headingPositions[i]
+ const nextStart = i + 1 < headingPositions.length ? headingPositions[i + 1].start : cleanedText.length
+ const rawContent = cleanMagazineContent(cleanedText.slice(hp.contentStart, nextStart))
+ if (rawContent.length < 20) continue
+
+ // Split bullets within this heading block
+ const bullets = rawContent
+ .split(/\n\s*[-•]\s+/)
+ .map(s => s.replace(/^[-•]\s*/, '').trim())
+ .filter(s => s.length > 10)
+
+ if (bullets.length >= 2) {
+ // Multiple bullets: each bullet becomes its own section under this heading group
+ for (const bullet of bullets) {
+ const cleaned = cleanMagazineContent(bullet)
+ if (cleaned.length < 15) continue
+ // Extract bold title from bullet if present: **Title**: content or **Title** — content
+ const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+)/s.exec(cleaned)
+ if (boldMatch) {
+ addSection(sections, boldMatch[1].trim(), boldMatch[2].trim(), seen, hp.title)
+ } else {
+ // No bold title — use first sentence as title, full text as content
+ const sentenceMatch = /^([^.!?]{10,80}[.!?])/.exec(cleaned)
+ const bulletTitle = sentenceMatch ? sentenceMatch[1] : cleaned.slice(0, 60)
+ addSection(sections, bulletTitle, cleaned, seen, hp.title)
+ }
+ }
+ } else {
+ // Single block content — add as one section
+ addSection(sections, hp.title, rawContent, seen)
}
}
// 2. **Pro/ Anti camp** blocks with their bullets
- const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
- while ((m = campRe.exec(text)) !== null) {
+ const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|$)/gim
+ while ((m = campRe.exec(cleanedText)) !== null) {
const title = m[1].trim()
- const content = m[2].trim()
+ const content = cleanMagazineContent(m[2])
if (content.length > 15) addSection(sections, title, content, seen)
}
- // 3. Bullets: - **Title**: Content (skip if already in a ## or ### block)
- const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
- while ((m = bulletRe.exec(text)) !== null) {
- const raw = m[2].trim().replace(/\n+/g, ' ')
- if (raw.length < 15) continue
- const inBlock = [...blockContents].some((b) => b.includes(raw.slice(0, 100)))
- if (!inBlock) addSection(sections, m[1].trim(), raw, seen)
- }
-
- // 4. - **Name** (Role) description — attributed quotes
- const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
- while ((m = attrRe.exec(text)) !== null) {
- const content = m[3].trim().replace(/\n+/g, ' ')
- if (content.length > 20) addSection(sections, `${m[1].trim()} (${m[2].trim()})`, content, seen)
- }
-
- // 5. Intro paragraph before first ## / ### or ---
- const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
- if (introMatch) {
- const intro = introMatch[1].trim().replace(/^[#*_\s-]+/gm, '').trim()
- if (intro.length > 60 && !seen.has('Summary')) {
+ // 3. Intro paragraph before first heading or ---
+ const firstHeadingIdx = headingPositions.length > 0 ? headingPositions[0].start : -1
+ const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf('\n---\n')
+ if (introEnd > 0) {
+ const intro = cleanedText.slice(0, introEnd).trim()
+ .replace(/^[#*_\s-]+/gm, '').trim()
+ if (intro.length > 30 && !seen.has('Summary')) {
addSection(sections, 'Summary', intro, seen)
}
}
- // 6. "This is being called..." / closing paragraph
- const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(text)
+ // 4. "This is being called..." / closing paragraph
+ const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText)
if (closingMatch && !seen.has('Key')) {
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
}
- // 7. "For deeper analysis" / podcast or further reading block
- const deeperMatch = /(?:For deeper analysis[^:]*:[\s\S]+?)(?=\n\z)/im.exec(text)
- if (deeperMatch) {
- const block = deeperMatch[1].trim()
- if (block.length > 30 && !seen.has('Further')) {
- addSection(sections, 'Further reading', block, seen)
- }
- }
-
- // Preserve document order: Summary first, then ## blocks order, then camps, then key/further
- const order = ['Summary', 'Key takeaway', 'Further reading']
+ // Preserve document order: Summary first, then heading content in order, then key
+ const order = ['Summary', 'Key takeaway']
sections.sort((a, b) => {
const ai = order.indexOf(a.title)
const bi = order.indexOf(b.title)