From ffa5ae7f02c17c8d107a3a33e6bc93210f5a71d1 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 4 Nov 2025 20:56:26 -0800 Subject: [PATCH] fix: properly render links and formatting in RSS feeds Replace broken text extraction logic with renderEdraContent() which correctly handles TipTap marks including links, bold, italic, etc. Before: Links were stripped from RSS content (only plain text extracted) After: Full HTML with proper links, formatting preserved in CDATA sections Files updated: - src/routes/rss/+server.ts: Main RSS feed - src/routes/rss/universe/+server.ts: Universe RSS feed Fixes issue where content.blocks was expected but TipTap uses content.type='doc' format. --- src/routes/rss/+server.ts | 152 ++--------------------------- src/routes/rss/universe/+server.ts | 25 ++--- 2 files changed, 12 insertions(+), 165 deletions(-) diff --git a/src/routes/rss/+server.ts b/src/routes/rss/+server.ts index 902f2ed..3f4fdaa 100644 --- a/src/routes/rss/+server.ts +++ b/src/routes/rss/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types' import { prisma } from '$lib/server/database' import { logger } from '$lib/server/logger' +import { renderEdraContent } from '$lib/utils/content' // Helper function to escape XML special characters function escapeXML(str: string): string { @@ -14,159 +15,18 @@ function escapeXML(str: string): string { } // Helper function to convert content to HTML for full content +// Uses the same rendering logic as the website for consistency function convertContentToHTML(content: any): string { if (!content) return '' - + // Handle legacy content format (if it's just a string) if (typeof content === 'string') { return `

${escapeXML(content)}

` } - - // Handle TipTap/Edra format with doc root - if (content.type === 'doc' && content.content && Array.isArray(content.content)) { - return content.content - .map((node: any) => { - switch (node.type) { - case 'paragraph': - const text = extractTextFromNode(node) - return text ? `

${text}

` : '' - case 'heading': - const headingText = extractTextFromNode(node) - const level = node.attrs?.level || 2 - return headingText ? `${headingText}` : '' - case 'bulletList': - case 'orderedList': - const listItems = (node.content || []) - .map((item: any) => { - if (item.type === 'listItem') { - const itemText = extractTextFromNode(item) - return itemText ? `
  • ${itemText}
  • ` : '' - } - return '' - }) - .filter((item: string) => item) - .join('') - if (!listItems) return '' - return node.type === 'orderedList' ? `
      ${listItems}
    ` : `` - case 'blockquote': - const quoteText = extractTextFromNode(node) - return quoteText ? `
    ${quoteText}
    ` : '' - case 'codeBlock': - const code = extractTextFromNode(node) - return code ? `
    ${code}
    ` : '' - case 'image': - const src = node.attrs?.src || '' - const alt = node.attrs?.alt || '' - return src ? `
    ${escapeXML(alt)}
    ` : '' - case 'video': - const videoSrc = node.attrs?.src || '' - if (!videoSrc) return '' - // Check if it's a YouTube URL - const youtubeMatch = videoSrc.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/) - if (youtubeMatch) { - const videoId = youtubeMatch[1] - return `

    Watch on YouTube

    ` - } - // For other video sources, include a video tag - return `` - case 'urlEmbed': - const embedUrl = node.attrs?.url || '' - const embedTitle = node.attrs?.title || '' - const embedDescription = node.attrs?.description || '' - const embedImage = node.attrs?.image || '' - const embedSiteName = node.attrs?.siteName || '' - - if (!embedUrl) return '' - - // Check if it's a YouTube URL - const ytMatch = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/) - if (ytMatch) { - const videoId = ytMatch[1] - let html = '
    ' - html += `` - if (embedTitle) { - html += `

    ${escapeXML(embedTitle)}

    ` - } - if (embedDescription) { - html += `

    ${escapeXML(embedDescription)}

    ` - } - html += '
    ' - return html - } - - // Check if it's a Twitter/X URL - const twitterMatch = embedUrl.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/) - if (twitterMatch) { - // For Twitter/X, we can't embed the actual tweet in RSS, but we can provide a nice preview - let html = '
    ' - if (embedImage) { - html += `Tweet preview` - } - html += '
    ' - html += `𝕏 (Twitter)` - if (embedTitle || embedDescription) { - html += `

    ${escapeXML(embedDescription || embedTitle || '')}

    ` - } - html += `

    View on 𝕏

    ` - html += '
    ' - return html - } - - // For other URL embeds, create a rich preview - let html = '
    ' - if (embedImage) { - html += `${escapeXML(embedTitle || '')}` - } - html += '
    ' - if (embedSiteName) { - html += `${escapeXML(embedSiteName)}` - } - if (embedTitle) { - html += `

    ${escapeXML(embedTitle)}

    ` - } - if (embedDescription) { - html += `

    ${escapeXML(embedDescription)}

    ` - } - html += '
    ' - return html - case 'iframe': - const iframeSrc = node.attrs?.src || '' - const iframeWidth = node.attrs?.width || 560 - const iframeHeight = node.attrs?.height || 315 - if (!iframeSrc) return '' - return `` - default: - const defaultText = extractTextFromNode(node) - return defaultText ? `

    ${defaultText}

    ` : '' - } - }) - .filter((html: string) => html) - .join('\n') - } - - return '' -} -// Helper to extract text from TipTap nodes -function extractTextFromNode(node: any): string { - if (!node) return '' - - // Direct text content - if (node.text) return escapeXML(node.text) - - // Nested content - if (node.content && Array.isArray(node.content)) { - return node.content - .map((child: any) => { - if (child.type === 'text') { - return escapeXML(child.text || '') - } - return extractTextFromNode(child) - }) - .join('') - } - - return '' + // Use the existing renderEdraContent function which properly handles TipTap marks + // including links, bold, italic, etc. + return renderEdraContent(content) } // Helper function to extract text summary from content diff --git a/src/routes/rss/universe/+server.ts b/src/routes/rss/universe/+server.ts index 706bd47..1768b48 100644 --- a/src/routes/rss/universe/+server.ts +++ b/src/routes/rss/universe/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types' import { prisma } from '$lib/server/database' import { logger } from '$lib/server/logger' +import { renderEdraContent } from '$lib/utils/content' // Helper function to escape XML special characters function escapeXML(str: string): string { @@ -14,27 +15,13 @@ function escapeXML(str: string): string { } // Helper function to convert content to HTML for full content +// Uses the same rendering logic as the website for consistency function convertContentToHTML(content: any): string { - if (!content || !content.blocks) return '' + if (!content) return '' - return content.blocks - .map((block: any) => { - switch (block.type) { - case 'paragraph': - return `

    ${escapeXML(block.content || '')}

    ` - case 'heading': - const level = block.level || 2 - return `${escapeXML(block.content || '')}` - case 'list': - const items = (block.content || []) - .map((item: any) => `
  • ${escapeXML(item)}
  • `) - .join('') - return block.listType === 'ordered' ? `
      ${items}
    ` : `` - default: - return `

    ${escapeXML(block.content || '')}

    ` - } - }) - .join('\n') + // Use the existing renderEdraContent function which properly handles TipTap marks + // including links, bold, italic, etc. + return renderEdraContent(content) } // Helper function to extract text summary from content