/**
* 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, '"') : '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, '"')}"` : ''
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
// 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
}