9.6 KiB
Markdown Support in msg.html
Current State
How messages render today
In msg.html, the renderThread() function renders message content using textContent:
const content = document.createElement('div');
content.textContent = msg.content || '';
This means all message text is displayed as plain text — no links, no bold, no formatting of any kind. Even URLs are not clickable.
Existing assets in the project
marked.min.js— marked v9.0.3 is already bundled in the project. It is used bynote.htmlfor rendering long-form note content as markdown into an iframe.htmlFormatText()— A simple utility that converts URLs to<a>links and image URLs to<img>tags. Used bypost-interactions.mjsfor social feed posts.
Industry Standard for Messaging Formatting
What major messaging platforms do
| Platform | Formatting Approach |
|---|---|
| Slack | Custom markdown subset: *bold*, _italic_, ~strikethrough~, `code`, ```code blocks```, > blockquote, links, emoji shortcodes |
| Discord | Full markdown subset: **bold**, *italic*, ~~strikethrough~~, `code`, ```lang code blocks```, > blockquote, || spoiler ||, links |
| Telegram | Markdown + HTML: **bold**, __italic__, `code`, ```code blocks```, ~~strikethrough~~, || spoiler ||, links |
Limited markdown: *bold*, _italic_, ~strikethrough~, `code` |
|
| Signal | Limited markdown: *bold*, _italic_, ~~strikethrough~~, ` |
| Matrix/Element | Full markdown rendering with HTML sanitization |
| iMessage | No markdown; rich text via attributed strings |
Nostr ecosystem conventions
- NIP-01 kind 1 notes and kind 14 DMs use plain text content — no official markdown NIP exists
- In practice, most Nostr clients render:
- URLs as clickable links
- Image URLs as inline images
nostr:URIs as profile/event links- Some clients like Amethyst, Damus, and Coracle render basic markdown in notes
- NIP-23 long-form content (kind 30023) explicitly uses markdown
Recommended approach for Nostr DMs
The industry consensus for chat/messaging is a sanitized markdown subset — not full markdown. The key principles:
- Render inline formatting: bold, italic, strikethrough, inline code
- Render code blocks: fenced code blocks with syntax highlighting optional
- Auto-link URLs: make URLs clickable
- Render image URLs: show inline image previews
- Blockquotes: useful for quoting previous messages
- Sanitize HTML: prevent XSS — never allow raw HTML tags
- Do NOT render: headings, tables, horizontal rules, or complex block elements that don't make sense in chat bubbles
Implementation Plan
Architecture
flowchart TD
A[Raw message content] --> B{Contains markdown?}
B -->|Any content| C[DOMPurify sanitize raw text]
C --> D[marked.parse with chat-safe options]
D --> E[DOMPurify sanitize HTML output]
E --> F[Set innerHTML on bubble]
G[Reply input box] --> H[Get plaintext from contenteditable]
H --> I[Send as plain text over NIP-04/NIP-44/NIP-17]
style A fill:#1a1a2e,stroke:#e94560,color:#eee
style F fill:#1a1a2e,stroke:#16c79a,color:#eee
style I fill:#1a1a2e,stroke:#16c79a,color:#eee
Steps
1. Add marked.min.js script tag to msg.html
Include the existing marked.min.js in the <head> of msg.html, just like note.html does:
<script src="./js/marked.min.js"></script>
2. Add DOMPurify for XSS protection
Since messages come from untrusted third parties, HTML sanitization is critical. Add DOMPurify via CDN:
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
Alternatively, download and bundle it locally like marked.min.js.
3. Configure marked for chat-safe rendering
Create a chat-specific marked configuration that disables features inappropriate for chat bubbles:
function configureMarkedForChat() {
marked.setOptions({
breaks: true, // Convert \n to <br> - essential for chat
gfm: true, // GitHub Flavored Markdown - strikethrough, tables
pedantic: false,
silent: true // Don't throw on bad markdown
});
// Custom renderer to limit what gets rendered
const renderer = new marked.Renderer();
// Disable headings in chat - render as bold text instead
renderer.heading = function(text, level) {
return `<strong>${text}</strong><br>`;
};
// Disable horizontal rules
renderer.hr = function() {
return '<br>';
};
// Make links open in new tab
renderer.link = function(href, title, text) {
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
};
// Render images inline with max-width constraint
renderer.image = function(href, title, text) {
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${titleAttr} style="max-width:100%;border-radius:6px;">`;
};
marked.use({ renderer });
}
4. Create a renderMessageContent helper function
Add a function that safely converts message text to formatted HTML:
function renderMessageContent(text) {
if (!text) return '';
// Parse markdown to HTML
let html = '';
try {
html = marked.parse(text);
} catch (e) {
// Fallback to escaped plain text
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Sanitize to prevent XSS
if (window.DOMPurify) {
html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'b', 'i', 'em', 'strong', 'a', 'code', 'pre',
'br', 'p', 'ul', 'ol', 'li', 'blockquote',
'del', 's', 'img', 'span'
],
ALLOWED_ATTR: [
'href', 'target', 'rel', 'title', 'alt', 'src', 'style'
],
ALLOW_DATA_ATTR: false
});
}
return html;
}
5. Update renderThread to use innerHTML with sanitized markdown
Change the content rendering in renderThread() from textContent to innerHTML:
Before (line 779):
content.textContent = msg.content || '';
After:
content.innerHTML = renderMessageContent(msg.content || '');
6. Add CSS styles for markdown inside chat bubbles
Add styles scoped to .msgBubble so markdown elements look appropriate in chat context:
.msgBubble p {
margin: 0 0 0.3em 0;
}
.msgBubble p:last-child {
margin-bottom: 0;
}
.msgBubble code {
background: rgba(128,128,128,0.2);
padding: 1px 4px;
border-radius: 3px;
font-size: 85%;
}
.msgBubble pre {
background: rgba(0,0,0,0.15);
padding: 6px 8px;
border-radius: 5px;
overflow-x: auto;
margin: 4px 0;
}
.msgBubble pre code {
background: none;
padding: 0;
}
.msgBubble blockquote {
border-left: 3px solid var(--muted-color);
margin: 4px 0;
padding: 2px 8px;
color: var(--muted-color);
}
.msgBubble a {
color: var(--accent-color);
text-decoration: underline;
}
.msgBubble img {
max-width: 100%;
border-radius: 6px;
margin: 4px 0;
}
.msgBubble ul, .msgBubble ol {
margin: 4px 0;
padding-left: 20px;
}
.msgBubble del, .msgBubble s {
text-decoration: line-through;
opacity: 0.7;
}
7. Adjust white-space on msgBubble
The current msgBubble CSS has white-space: pre-wrap which will conflict with markdown-rendered HTML. Change it:
Before:
.msgBubble {
white-space: pre-wrap;
...
}
After:
.msgBubble {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
...
}
The breaks: true option in marked handles newline conversion to <br>, so pre-wrap is no longer needed.
Security Considerations
- DOMPurify is essential — messages come from untrusted Nostr users. Without sanitization, a malicious user could inject
<script>tags or event handlers via crafted markdown. - The allowlist approach in DOMPurify config above is restrictive by design — only safe formatting tags are permitted.
target="_blank"links must includerel="noopener noreferrer"to prevent tab-nabbing.- Image rendering from arbitrary URLs has privacy implications — loading an image reveals the user's IP to the image host. Consider adding a click-to-load option for images in a future iteration.
Summary of Files to Modify
| File | Change |
|---|---|
www/msg.html |
Add marked.min.js + DOMPurify script tags, add configureMarkedForChat() + renderMessageContent() functions, update renderThread() to use innerHTML, add markdown CSS styles, adjust white-space on .msgBubble |
Optional Future Enhancements
- Markdown toolbar in the reply input box with buttons for bold, italic, code, link
- Markdown preview toggle in the reply box showing formatted output before sending
- Syntax highlighting for code blocks using highlight.js or Prism
- Click-to-load images for privacy protection
- nostr: URI handling to render
nostr:npub...andnostr:note...as clickable profile/event links - Emoji shortcode support