OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / public / markdown.js

201 lines · javascript ref:
/**
 * Markdown rendering for Wirecore
 * Based on wiredove's markdown.js
 */

import { marked } from 'https://esm.sh/marked@12.0.0'

const renderer = new marked.Renderer()

// Custom paragraph renderer to handle hashtags
renderer.paragraph = function (text) {
  const words = text.split(' ')

  for (let i = 0; i < words.length; i++) {
    let word = words[i]

    // Handle hashtags
    if (word.startsWith('#')) {
      let punctuation = ''

      // Extract trailing punctuation
      if (['.', ',', '?', ':', '!'].some(char => word.endsWith(char))) {
        punctuation = word[word.length - 1]
        word = word.substring(0, word.length - 1)
      }

      // Create hashtag link
      const hashtag = `<a href="#?${word}" class="hashtag">${word}</a>`
      words[i] = hashtag + punctuation
    }
  }

  const paragraph = words.join(' ')
  return '<p>' + paragraph + '</p>'
}

// Custom link renderer for ANProto public keys
renderer.link = function (href, title, text) {
  // If it's a 44-char base64 string (ANProto public key), make it a profile link
  if (href.length === 44 && !href.startsWith('http')) {
    href = '#' + href
  }

  // Add target="_blank" for external links
  if (href.startsWith('http://') || href.startsWith('https://')) {
    return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
  }

  return `<a href="${href}">${text}</a>`
}

// Custom image renderer for inline images
renderer.image = function (src, title, text) {
  // Handle content hashes (44-char base64 from uploaded images)
  if (src && src.length === 44) {
    const altText = text ? text.replace(/"/g, '&quot;') : 'Post image'
    const imgId = `inline-img-${src}`

    // Schedule image loading from IndexedDB after render
    setTimeout(async () => {
      try {
        // Try to load from apds storage
        const { apds } = await import('./apds-compat.js')
        let imageData = await apds.get(`post-image:${src}`)

        const imgElement = document.getElementById(imgId)

        if (imageData) {
          console.log(`✓ Found image ${src.substring(0, 8)}... locally`)
        } else {
          console.log(`⚠ Image ${src.substring(0, 8)}... not found locally`)

          // If not found locally, try requesting from P2P peers
          if (window.trysteroTransport && window.trysteroTransport.hasActivePeers()) {
            const peerCount = window.trysteroTransport.getPeerCount()
            console.log(`Requesting image ${src.substring(0, 8)}... from ${peerCount} P2P peers`)
            try {
              imageData = await window.trysteroTransport.requestContent(src, { timeout: 5000 })
              if (imageData) {
                // Store for future use
                await apds.put(`post-image:${src}`, imageData)
                console.log(`✓ Received image ${src.substring(0, 8)}... from peers`)
              } else {
                console.log(`✗ No peers had image ${src.substring(0, 8)}...`)
              }
            } catch (err) {
              console.log(`✗ P2P request timeout for image ${src.substring(0, 8)}...`)
            }
          } else {
            console.log(`✗ No P2P peers available for image ${src.substring(0, 8)}...`)
          }
        }

        if (imgElement && imageData) {
          imgElement.src = imageData
          imgElement.style.display = 'block'
        } else if (imgElement) {
          // Image not found - show placeholder
          imgElement.alt = 'Image not available (offline)'
          imgElement.style.display = 'none'
          console.log(`✗ Image ${src.substring(0, 8)}... could not be loaded`)
        }
      } catch (err) {
        console.error('Failed to load image:', src, err)
      }
    }, 10)

    return `<img class="post-image" id="${imgId}" alt="${altText}" loading="lazy" onclick="openImageLightbox(this)" style="display:none; max-width: 100%;" />`
  }

  // Regular images - make them clickable for lightbox
  const altText = text || 'Image'
  const titleAttr = title ? `title="${title.replace(/"/g, '&quot;')}"` : ''
  return `<img class="post-image" src="${src}" alt="${altText}" ${titleAttr} loading="lazy" onclick="openImageLightbox(this)" />`
}

// Configure marked
marked.setOptions({
  renderer: renderer,
  breaks: true,  // Convert \n to <br>
  gfm: true      // GitHub Flavored Markdown
})

/**
 * Parse markdown text to HTML
 * @param {string} text - Raw text content
 * @returns {Promise<string>} HTML string
 */
export async function markdown(text) {
  if (!text) return ''

  try {
    const html = await marked.parse(text)
    return html
  } catch (err) {
    console.error('Markdown parsing error:', err)
    // Fallback: escape HTML and preserve line breaks
    return text
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>')
  }
}

/**
 * Auto-link URLs and hashtags in plain text
 * For content that isn't markdown
 * Also embeds images from URLs
 * @param {string} text - Plain text content
 * @returns {string} HTML with clickable links and embedded images
 */
export function autoLink(text) {
  if (!text) return ''

  // Image URL regex pattern (jpg, jpeg, png, gif, webp)
  const imagePattern = /(https?:\/\/[^\s<]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<]*)?)/gi

  // URL regex pattern (for non-images)
  const urlPattern = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g

  // Hashtag pattern
  const hashtagPattern = /(^|\s)(#[a-zA-Z0-9_-]+)/g

  let html = text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')

  // First, embed images
  const images = []
  html = html.replace(imagePattern, (match) => {
    const placeholder = `__IMAGE_${images.length}__`
    images.push(match)
    return placeholder
  })

  // Link remaining URLs
  html = html.replace(urlPattern, (match) => {
    // Skip if it's an image (already handled)
    if (match.match(/\.(?:jpg|jpeg|png|gif|webp)(?:\?|$)/i)) {
      return match
    }
    return `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`
  })

  // Link hashtags
  html = html.replace(hashtagPattern, '$1<a href="#?$2" class="hashtag">$2</a>')

  // Replace image placeholders with actual img tags
  images.forEach((imgUrl, index) => {
    const imgTag = `<br><img class="post-image" src="${imgUrl}" alt="Image" loading="lazy" onclick="openImageLightbox(this)" /><br>`
    html = html.replace(`__IMAGE_${index}__`, imgTag)
  })

  // Preserve line breaks
  html = html.replace(/\n/g, '<br>')

  return html
}