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)