diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 7559d2a..de00230 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -310,6 +310,9 @@ export default function AsciidocArticle({ const processedContent = useMemo(() => { let content = event.content + // Normalize excessive newlines (reduce 3+ to 2) + content = content.replace(/\n\s*\n\s*\n+/g, '\n\n') + // Convert all markdown syntax to AsciiDoc syntax content = convertMarkdownToAsciidoc(content) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index f8ccd86..29753ac 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -106,41 +106,208 @@ function isYouTubeUrl(url: string): boolean { return regex.test(url) } +/** + * Parse inline markdown formatting while preserving newlines (for code blocks) + */ +function parseInlineMarkdownPreserveNewlines(text: string, keyPrefix: string): React.ReactNode[] { + const parts: React.ReactNode[] = [] + let lastIndex = 0 + const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = [] + + // Bold: **text** (double asterisk) - allow newlines within + const doubleBoldAsteriskRegex = /\*\*([\s\S]+?)\*\*/g + const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex)) + doubleBoldAsteriskMatches.forEach(match => { + if (match.index !== undefined) { + inlinePatterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'bold', + data: match[1] + }) + } + }) + + // Double underscore bold - allow newlines within + const doubleBoldUnderscoreRegex = /__([\s\S]+?)__/g + const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex)) + doubleBoldUnderscoreMatches.forEach(match => { + if (match.index !== undefined) { + const isInOther = inlinePatterns.some(p => + (p.type === 'bold') && + match.index! >= p.index && + match.index! < p.end + ) + if (!isInOther) { + inlinePatterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'bold', + data: match[1] + }) + } + } + }) + + // Italic: _text_ (single underscore, not part of __bold__) - allow newlines within + const singleItalicUnderscoreRegex = /(? { + if (match.index !== undefined) { + const isInOther = inlinePatterns.some(p => + (p.type === 'bold') && + match.index! >= p.index && + match.index! < p.end + ) + if (!isInOther) { + inlinePatterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'italic', + data: match[1] + }) + } + } + }) + + // Sort by index + inlinePatterns.sort((a, b) => a.index - b.index) + + // Remove overlaps (keep first) + const filtered: typeof inlinePatterns = [] + let lastEnd = 0 + inlinePatterns.forEach(pattern => { + if (pattern.index >= lastEnd) { + filtered.push(pattern) + lastEnd = pattern.end + } + }) + + // Build React nodes, preserving newlines + filtered.forEach((pattern, i) => { + // Add text before pattern (preserving newlines) + if (pattern.index > lastIndex) { + const textBefore = text.substring(lastIndex, pattern.index) + if (textBefore) { + // Split by newlines and render each part + const lines = textBefore.split('\n') + lines.forEach((line, lineIdx) => { + if (lineIdx > 0) { + parts.push(
) + } + if (line) { + parts.push({line}) + } + }) + } + } + + // Render pattern (preserving newlines within the pattern) + if (pattern.type === 'bold') { + const boldLines = pattern.data.split('\n') + boldLines.forEach((line, lineIdx) => { + if (lineIdx > 0) { + parts.push(
) + } + if (line) { + parts.push({line}) + } + }) + } else if (pattern.type === 'italic') { + const italicLines = pattern.data.split('\n') + italicLines.forEach((line, lineIdx) => { + if (lineIdx > 0) { + parts.push(
) + } + if (line) { + parts.push({line}) + } + }) + } + + lastIndex = pattern.end + }) + + // Add remaining text (preserving newlines) + if (lastIndex < text.length) { + const remaining = text.substring(lastIndex) + const lines = remaining.split('\n') + lines.forEach((line, lineIdx) => { + if (lineIdx > 0) { + parts.push(
) + } + if (line) { + parts.push({line}) + } + }) + } + + return parts +} + /** * CodeBlock component that renders code with syntax highlighting using highlight.js + * Also processes inline markdown formatting (bold, italic) within the code */ function CodeBlock({ id, code, language }: { id: string; code: string; language: string }) { - const codeRef = useRef(null) + const codeRef = useRef(null) + + // Check if code contains markdown formatting + const hasMarkdownFormatting = /\*\*.*?\*\*|__.*?__|_.*?_|\*.*?\*/.test(code) + + // Process inline markdown formatting (bold, italic) in code blocks while preserving newlines + const processedCode = useMemo(() => { + if (hasMarkdownFormatting) { + // Parse inline markdown while preserving newlines + return parseInlineMarkdownPreserveNewlines(code, `code-${id}`) + } + return code + }, [code, id, hasMarkdownFormatting]) useEffect(() => { - const initHighlight = async () => { - if (typeof window !== 'undefined' && codeRef.current) { - try { - const hljs = await import('highlight.js') - if (codeRef.current) { - hljs.default.highlightElement(codeRef.current) + // Only apply syntax highlighting if there's no markdown formatting + // (highlight.js would interfere with HTML formatting) + if (!hasMarkdownFormatting) { + const initHighlight = async () => { + if (typeof window !== 'undefined' && codeRef.current) { + try { + const hljs = await import('highlight.js') + const codeElement = codeRef.current.querySelector('code') + if (codeElement) { + hljs.default.highlightElement(codeElement) + } + } catch (error) { + logger.error('Error loading highlight.js:', error) } - } catch (error) { - logger.error('Error loading highlight.js:', error) } } + + // Small delay to ensure DOM is ready + const timeoutId = setTimeout(initHighlight, 0) + return () => clearTimeout(timeoutId) } - - // Small delay to ensure DOM is ready - const timeoutId = setTimeout(initHighlight, 0) - return () => clearTimeout(timeoutId) - }, [code, language]) + }, [code, language, hasMarkdownFormatting]) return (
-
-        
-          {code}
-        
+      
+        
+ {hasMarkdownFormatting ? ( + + {processedCode} + + ) : ( + + {code} + + )} +
) @@ -295,6 +462,62 @@ function normalizeBackticks(content: string): string { * Note: Only converts if the text line has at least 2 characters to avoid * creating headers from fragments like "D\n------" which would become "## D" */ +/** + * Normalize excessive newlines - reduce 3+ consecutive newlines (with optional whitespace) to exactly 2 + */ +function normalizeNewlines(content: string): string { + // Match sequences of 3 or more newlines with optional whitespace between them + // Pattern: newline, optional whitespace, newline, optional whitespace, one or more newlines + // Replace with exactly 2 newlines + return content.replace(/\n\s*\n\s*\n+/g, '\n\n') +} + +/** + * Normalize single newlines within bold/italic spans to spaces + * This allows bold/italic formatting to work across single line breaks + */ +function normalizeInlineFormattingNewlines(content: string): string { + let normalized = content + + // Match bold spans: **text** that may contain single newlines + // Replace single newlines (but not double newlines) within these spans with spaces + normalized = normalized.replace(/\*\*([^*]*?)\*\*/g, (match, innerContent) => { + // Check if this span contains double newlines (paragraph break) - if so, don't modify + if (innerContent.includes('\n\n')) { + return match // Keep original if it has paragraph breaks + } + // Replace single newlines with spaces + return '**' + innerContent.replace(/\n/g, ' ') + '**' + }) + + // Match bold spans: __text__ that may contain single newlines + normalized = normalized.replace(/__([^_]*?)__/g, (match, innerContent) => { + // Check if this span contains double newlines (paragraph break) - if so, don't modify + if (innerContent.includes('\n\n')) { + return match // Keep original if it has paragraph breaks + } + // Replace single newlines with spaces + return '__' + innerContent.replace(/\n/g, ' ') + '__' + }) + + // Match italic spans: _text_ (single underscore, not part of __bold__) + // Use a more careful pattern to avoid matching __bold__ + normalized = normalized.replace(/(? { + // Check if preceded by another underscore (would be __bold__) + if (offset > 0 && string[offset - 1] === '_') { + return match // Don't modify if part of __bold__ + } + // Check if this span contains double newlines (paragraph break) - if so, don't modify + if (innerContent.includes('\n\n')) { + return match + } + // Replace single newlines with spaces (though italic shouldn't have newlines due to [^_\n]) + return '_' + innerContent.replace(/\n/g, ' ') + '_' + }) + + return normalized +} + function normalizeSetextHeaders(content: string): string { const lines = content.split('\n') const result: string[] = [] @@ -1093,6 +1316,7 @@ function parseMarkdownContent( // This handles cases like "#orly #devstr #progressreport" on one line // Hashtags should ALWAYS be merged if they're part of text or on a line with other hashtags let shouldMergeHashtag = false + let hasHashtagsOnAdjacentLines = false if (pattern.type === 'hashtag') { // Check if line contains only hashtags and whitespace const lineWithoutHashtags = line.replace(/#[a-zA-Z0-9_]+/g, '').trim() @@ -1106,12 +1330,47 @@ function parseMarkdownContent( p.index < lineEndIndex ) + // Check if there are hashtags on adjacent lines (separated by single newlines) + // This handles cases where hashtags are on separate lines but should stay together + if (!hasOtherHashtagsOnLine) { + // Check next line for hashtags + const nextLineStart = lineEndIndex + 1 + if (nextLineStart < content.length) { + const nextLineEnd = content.indexOf('\n', nextLineStart) + const nextLineEndIndex = nextLineEnd === -1 ? content.length : nextLineEnd + const nextLine = content.substring(nextLineStart, nextLineEndIndex) + + // Check if next line has hashtags and no double newline before it + const hasHashtagOnNextLine = filteredPatterns.some((p, idx) => + idx > patternIdx && + p.type === 'hashtag' && + p.index >= nextLineStart && + p.index < nextLineEndIndex + ) + + // Also check previous line for hashtags + const prevLineStart = content.lastIndexOf('\n', lineStart - 1) + 1 + const hasHashtagOnPrevLine = prevLineStart < lineStart && filteredPatterns.some((p, idx) => + idx < patternIdx && + p.type === 'hashtag' && + p.index >= prevLineStart && + p.index < lineStart + ) + + // If there's a hashtag on next or previous line, and no double newline between them, merge + if ((hasHashtagOnNextLine || hasHashtagOnPrevLine) && !content.substring(Math.max(0, prevLineStart), nextLineEndIndex).includes('\n\n')) { + hasHashtagsOnAdjacentLines = true + } + } + } + // Merge hashtag if: // 1. Line has only hashtags (so they stay together) // 2. There are other hashtags on the same line - // 3. There's text on the same line before it (part of a sentence) - // 4. There's text before it (even on previous lines, as long as no paragraph break) - shouldMergeHashtag = lineHasOnlyHashtags || hasOtherHashtagsOnLine || hasTextOnSameLine || hasTextBefore + // 3. There are hashtags on adjacent lines (separated by single newlines) + // 4. There's text on the same line before it (part of a sentence) + // 5. There's text before it (even on previous lines, as long as no paragraph break) + shouldMergeHashtag = lineHasOnlyHashtags || hasOtherHashtagsOnLine || hasHashtagsOnAdjacentLines || hasTextOnSameLine || hasTextBefore // If none of the above, but there's text after the hashtag on the same line, also merge // This handles cases where hashtag is at start of line but followed by text @@ -1129,19 +1388,76 @@ function parseMarkdownContent( // 3. OR (for hashtags) the line contains only hashtags, so they should stay together // This ensures links and hashtags in sentences stay together with their text if (pattern.type === 'hashtag' && shouldMergeHashtag) { - // For hashtags on a line with only hashtags, merge the entire line + // For hashtags on a line with only hashtags, or hashtags on adjacent lines, merge them together if (line.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0 && line.trim().length > 0) { // Line contains only hashtags - merge the entire line - // Reconstruct text to include everything from lastIndex to the end of the line - const textBeforeLine = content.slice(lastIndex, lineStart) - const lineContent = content.substring(lineStart, lineEndIndex) - text = textBeforeLine + lineContent - textEndIndex = lineEndIndex === content.length ? content.length : lineEndIndex + 1 + // Also check if we need to merge adjacent lines with hashtags + let mergeEndIndex = lineEndIndex + let mergeStartIndex = lineStart + + // If there are hashtags on adjacent lines, extend the merge range + if (hasHashtagsOnAdjacentLines) { + // Find the start of the first hashtag line in this sequence + let checkStart = lineStart + while (checkStart > 0) { + const prevLineStart = content.lastIndexOf('\n', checkStart - 2) + 1 + if (prevLineStart >= 0 && prevLineStart < checkStart) { + const prevLineEnd = checkStart - 1 + const prevLine = content.substring(prevLineStart, prevLineEnd) + const hasHashtagOnPrevLine = filteredPatterns.some((p, idx) => + idx < patternIdx && + p.type === 'hashtag' && + p.index >= prevLineStart && + p.index < prevLineEnd + ) + if (hasHashtagOnPrevLine && prevLine.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0) { + mergeStartIndex = prevLineStart + checkStart = prevLineStart + } else { + break + } + } else { + break + } + } + + // Find the end of the last hashtag line in this sequence + let checkEnd = lineEndIndex + while (checkEnd < content.length) { + const nextLineStart = checkEnd + 1 + if (nextLineStart < content.length) { + const nextLineEnd = content.indexOf('\n', nextLineStart) + const nextLineEndIndex = nextLineEnd === -1 ? content.length : nextLineEnd + const nextLine = content.substring(nextLineStart, nextLineEndIndex) + const hasHashtagOnNextLine = filteredPatterns.some((p, idx) => + idx > patternIdx && + p.type === 'hashtag' && + p.index >= nextLineStart && + p.index < nextLineEndIndex + ) + if (hasHashtagOnNextLine && nextLine.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0) { + mergeEndIndex = nextLineEndIndex + checkEnd = nextLineEndIndex + } else { + break + } + } else { + break + } + } + } + + // Reconstruct text to include everything from lastIndex to the end of the merged range + const textBeforeMerge = content.slice(lastIndex, mergeStartIndex) + const mergedContent = content.substring(mergeStartIndex, mergeEndIndex) + // Replace single newlines with spaces in the merged content to keep hashtags together + const normalizedMergedContent = mergedContent.replace(/\n(?!\n)/g, ' ') + text = textBeforeMerge + normalizedMergedContent + textEndIndex = mergeEndIndex === content.length ? content.length : mergeEndIndex + 1 - // Mark all hashtags on this line as merged (so they don't render separately) - // Do this BEFORE processing text to ensure they're skipped in subsequent iterations + // Mark all hashtags in the merged range as merged (so they don't render separately) filteredPatterns.forEach((p, idx) => { - if (p.type === 'hashtag' && p.index >= lineStart && p.index < lineEndIndex) { + if (p.type === 'hashtag' && p.index >= mergeStartIndex && p.index < mergeEndIndex) { const tag = p.data const tagLower = tag.toLowerCase() hashtagsInContent.add(tagLower) @@ -1149,10 +1465,7 @@ function parseMarkdownContent( } }) - // Also update lastIndex immediately to prevent processing of patterns on this line - // This ensures that when we check pattern.index < lastIndex, it will be true - // Note: We still need to process the text below to render it, but lastIndex is updated - // so subsequent patterns on this line will be skipped + // Also update lastIndex immediately to prevent processing of patterns in this range lastIndex = textEndIndex } else if (hasTextOnSameLine || hasTextBefore) { // Hashtag is part of text - merge just this hashtag and text after it @@ -1238,7 +1551,7 @@ function parseMarkdownContent( if (normalizedText) { const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes) parts.push( -

+

{textContent}

) @@ -1265,7 +1578,8 @@ function parseMarkdownContent( } } } - const displayUrl = thumbnailUrl || imgUrl + // Don't use thumbnails in notes - use original URL + const displayUrl = imgUrl parts.push(
@@ -1300,7 +1614,7 @@ function parseMarkdownContent( if (normalizedText) { const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes) parts.push( -

+

{textContent}

) @@ -1321,7 +1635,7 @@ function parseMarkdownContent( const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes) // Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it) parts.push( -

+

{paraContent}

) @@ -1397,8 +1711,9 @@ function parseMarkdownContent( } } } - const displayUrl = thumbnailUrl || url - const hasThumbnail = !!thumbnailUrl + // Don't use thumbnails in notes - use original URL + const displayUrl = url + const hasThumbnail = false parts.push(
@@ -1453,7 +1768,8 @@ function parseMarkdownContent( } } } - const displayUrl = thumbnailUrl || imageUrl + // Don't use thumbnails in notes - use original URL + const displayUrl = imageUrl // Render as a block-level clickable image that links to the URL // Clicking the image should navigate to the URL (standard markdown behavior) @@ -1700,7 +2016,7 @@ function parseMarkdownContent( const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes) return ( -

+

{paragraphContent}

) @@ -1856,7 +2172,7 @@ function parseMarkdownContent( { e.stopPropagation() e.preventDefault() @@ -1931,7 +2247,7 @@ function parseMarkdownContent( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes) parts.push( -

+

{paraContent}

) @@ -1998,7 +2314,7 @@ function parseMarkdownContent( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes) parts.push( -

+

{paraContent}

) @@ -2017,7 +2333,7 @@ function parseMarkdownContent( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes) parts.push( -

+

{paraContent}

) @@ -2040,7 +2356,7 @@ function parseMarkdownContent( if (!normalizedPara) return null const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes) return ( -

+

{paraContent}

) @@ -2370,7 +2686,8 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map { if (match.index !== undefined) { @@ -2392,7 +2709,8 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map { if (match.index !== undefined) { @@ -3029,6 +3347,10 @@ export default function MarkdownArticle({ const preprocessedContent = useMemo(() => { // First unescape JSON-encoded escape sequences let processed = unescapeJsonContent(event.content) + // Normalize excessive newlines (reduce 3+ to 2) + processed = normalizeNewlines(processed) + // Normalize single newlines within bold/italic spans to spaces + processed = normalizeInlineFormattingNewlines(processed) // Normalize Setext-style headers (H1 with ===, H2 with ---) processed = normalizeSetextHeaders(processed) // Normalize backticks (inline code and code blocks) @@ -3243,8 +3565,10 @@ export default function MarkdownArticle({ } } } - const displayUrl = thumbnailUrl || media.url - const hasThumbnail = !!thumbnailUrl + // Don't use thumbnails in notes - they're too small + // Keep thumbnailUrl for fallback/OpenGraph data, but use original URL for display + const displayUrl = media.url + const hasThumbnail = false return (
@@ -3300,7 +3624,7 @@ export default function MarkdownArticle({ )} {/* Parsed content */} -
+
{parsedContent}
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 8ccc3eb..ba76747 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -235,7 +235,7 @@ export function getLongFormArticleMetadataFromEvent(event: Event) { } else if (tagName === 'image') { image = tagValue } else if (tagName === 't' && tagValue && tags.size < 6) { - tags.add(tagValue.toLocaleLowerCase()) + tags.add(tagValue.toLowerCase()) } }) @@ -263,7 +263,7 @@ export function getLiveEventMetadataFromEvent(event: Event) { } else if (tagName === 'status') { status = tagValue } else if (tagName === 't' && tagValue && tags.size < 6) { - tags.add(tagValue.toLocaleLowerCase()) + tags.add(tagValue.toLowerCase()) } }) @@ -289,7 +289,7 @@ export function getGroupMetadataFromEvent(event: Event) { } else if (tagName === 'picture') { picture = tagValue } else if (tagName === 't' && tagValue) { - tags.add(tagValue.toLocaleLowerCase()) + tags.add(tagValue.toLowerCase()) } else if (tagName === 'd') { d = tagValue }