openvibers/openvibe.live Commits

Pastes: render markdown as a document, with a Raw toggle

OP OpenVibers committed 19 Aug 2026 · fbcbf4839e · 3 files, +238 −3

Markdown pastes were shown as syntax-highlighted source, so a README-style paste
read as a wall of #, * and | instead of a document.

Render markdown by default and put the source behind a Raw toggle in the paste
header; the choice is remembered, since someone reading markdown as source
usually wants that for the next one too.

The renderer is self-contained (no CDN, nothing to load) and covers what a README
actually uses: headings, bold/italic/strike, inline and fenced code, links,
images, blockquotes, ordered/unordered lists, task lists, horizontal rules and
tables with column alignment.

Paste content is arbitrary user input, so the ordering is deliberate: escape
everything FIRST, then add markup, so no HTML in the source can survive into the
output and the only tags present are ones the renderer emitted. Link and image
URLs are whitelisted to http(s)/mailto/relative — a javascript: URL renders as
plain text. Verified: an <img onerror> payload comes out escaped, and
[bad](javascript:alert(1)) does not become a link.

Styling follows the theme tokens rather than hardcoded colours, so it matches
whichever theme the viewer has selected; wide tables and long code lines scroll
inside their own box rather than widening the page.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex a5ce1dbc..a0c4fbda 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -13429,3 +13429,91 @@ body.theme-light .user-dropdown {
.controls-dock-btn i { font-size: 0.9rem; }
/* Keep the DMs FAB BEHIND the docked controls bar so it doesn't cover the buttons */
body.controls-docked .messenger-toggle { z-index: 1100; }
++
++/* ── Rendered markdown (GitHub-README style) ─────────────────────────────
++ Markdown pastes render as a document by default; the raw source sits behind
++ the Raw toggle in the paste header. Every colour comes from the theme tokens
++ so this follows whatever theme the viewer has picked. */
++.paste-md-body {
++ padding: 24px 28px;
++ line-height: 1.65;
++ color: var(--text-primary);
++ font-size: 0.95rem;
++ overflow-wrap: break-word;
++}
++.paste-md-body > *:first-child { margin-top: 0; }
++.paste-md-body > *:last-child { margin-bottom: 0; }
++
++.paste-md-body .md-h {
++ margin: 26px 0 14px;
++ line-height: 1.3;
++ font-weight: 700;
++ color: var(--text-primary);
++}
++.paste-md-body h1.md-h { font-size: 1.85rem; padding-bottom: .32em; border-bottom: 1px solid var(--border); }
++.paste-md-body h2.md-h { font-size: 1.45rem; padding-bottom: .3em; border-bottom: 1px solid var(--border); }
++.paste-md-body h3.md-h { font-size: 1.2rem; }
++.paste-md-body h4.md-h { font-size: 1.02rem; }
++.paste-md-body h5.md-h,
++.paste-md-body h6.md-h { font-size: .92rem; color: var(--text-secondary); }
++
++.paste-md-body p { margin: 0 0 14px; }
++.paste-md-body a { color: var(--accent); text-decoration: none; }
++.paste-md-body a:hover { text-decoration: underline; }
++
++.paste-md-body .md-list { margin: 0 0 14px; padding-left: 1.9em; }
++.paste-md-body .md-list li { margin: .28em 0; }
++.paste-md-body .md-tasks { list-style: none; padding-left: 1.2em; }
++.paste-md-body .md-task input { margin-right: .5em; vertical-align: middle; }
++
++.paste-md-body .md-quote {
++ margin: 0 0 14px;
++ padding: .35em 1em;
++ border-left: 4px solid var(--border-light);
++ color: var(--text-secondary);
++}
++
++.paste-md-body .md-code {
++ padding: .18em .4em;
++ border-radius: 5px;
++ background: var(--bg-input);
++ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
++ font-size: .88em;
++}
++.paste-md-body .md-pre {
++ margin: 0 0 16px;
++ padding: 14px 16px;
++ border: 1px solid var(--border);
++ border-radius: 8px;
++ background: var(--bg-input);
++ overflow-x: auto; /* long lines scroll, they never widen the page */
++}
++.paste-md-body .md-pre code {
++ padding: 0;
++ background: none;
++ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
++ font-size: .86rem;
++ line-height: 1.5;
++}
++
++.paste-md-body .md-hr { margin: 22px 0; border: 0; border-top: 1px solid var(--border); }
++.paste-md-body .md-img { max-width: 100%; border-radius: 8px; }
++
++.paste-md-body .md-table {
++ margin: 0 0 16px;
++ border-collapse: collapse;
++ display: block;
++ width: max-content;
++ max-width: 100%;
++ overflow-x: auto; /* wide tables scroll inside their own box */
++}
++.paste-md-body .md-table th,
++.paste-md-body .md-table td { padding: 7px 13px; border: 1px solid var(--border); }
++.paste-md-body .md-table th { background: var(--bg-tertiary); font-weight: 600; }
++.paste-md-body .md-table tbody tr:nth-child(2n) { background: var(--bg-secondary); }
++
++@media (max-width: 640px) {
++ .paste-md-body { padding: 16px 15px; font-size: .92rem; }
++ .paste-md-body h1.md-h { font-size: 1.5rem; }
++ .paste-md-body h2.md-h { font-size: 1.25rem; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex cb503979..2bfecb16 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -58,7 +58,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=134">
++ <link rel="stylesheet" href="/css/style.css?v=135">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@@ -5041,7 +5041,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=1" defer></script>
<script src="/js/powerchat.js?v=6" defer></script>
--<script src="/js/pastes.js?v=13" defer></script>
++<script src="/js/pastes.js?v=14" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex 59d750a1..710a8963 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -213,6 +213,139 @@ function pastesSort(sort) {
fetchPastes();
}
++
++// ── Markdown rendering ──────────────────────────────────────────────────────────
++// A small self-contained renderer. Paste content is arbitrary user input, so the
++// order here is not negotiable: escape EVERYTHING first, then add markup. That way
++// no amount of HTML in the source can survive into the output, and the only tags in
++// the result are ones this function put there itself.
++function _mdEsc(s) {
++ return String(s == null ? '' : s)
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++}
++
++// Only allow links we are happy to emit. Anything else (javascript:, data:, vbscript:)
++// renders as plain text instead of a link.
++function _mdSafeUrl(url) {
++ const u = String(url || '').trim();
++ if (/^(https?:\/\/|mailto:|\/|#)/i.test(u) && !/^javascript:/i.test(u)) return u;
++ return null;
++}
++
++function renderMarkdown(src) {
++ const text = String(src == null ? '' : src).replace(/\r\n?/g, '\n');
++
++ // Pull fenced code out first so its contents are never treated as markdown.
++ const fences = [];
++ let work = text.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_m, lang, code) => {
++ fences.push('<pre class="md-pre"><code>' + _mdEsc(code.replace(/\n$/, '')) + '</code></pre>');
++ return ' FENCE' + (fences.length - 1) + ' ';
++ });
++
++ work = _mdEsc(work); // everything below operates on escaped text
++
++ const inline = (s) => s
++ .replace(/`([^`\n]+)`/g, (_m, c) => '<code class="md-code">' + c + '</code>')
++ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => {
++ const safe = _mdSafeUrl(url.replace(/&amp;/g, '&'));
++ return safe ? '<img class="md-img" src="' + safe + '" alt="' + alt + '">' : m;
++ })
++ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
++ const safe = _mdSafeUrl(url.replace(/&amp;/g, '&'));
++ return safe ? '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + label + '</a>' : m;
++ })
++ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
++ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
++ .replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
++
++ const out = [];
++ let list = null; // 'ul' | 'ol'
++ let para = [];
++ const closeList = () => { if (list) { out.push('</' + list + '>'); list = null; } };
++ const flushPara = () => {
++ if (para.length) { out.push('<p>' + inline(para.join(' ')) + '</p>'); para = []; }
++ };
++
++ const lines = work.split('\n');
++ for (let i = 0; i < lines.length; i++) {
++ const raw = lines[i];
++ const line = raw.trimEnd();
++ if (/^FENCE\d+$/.test(line.trim())) { flushPara(); closeList(); out.push(' ' + line.trim() + ' '); continue; }
++ if (!line.trim()) { flushPara(); closeList(); continue; }
++
++ // Tables: a header row followed by a |---|---| separator. Needs lookahead, which
++ // is why this loop is indexed rather than a for..of.
++ if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length &&
++ /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
++ flushPara(); closeList();
++ const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
++ const aligns = cells(lines[i + 1]).map(c =>
++ /^:-+:$/.test(c) ? 'center' : /-+:$/.test(c) ? 'right' : 'left');
++ const head = cells(line);
++ const body = [];
++ let j = i + 2;
++ while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) { body.push(cells(lines[j])); j++; }
++ const th = head.map((c, k) => '<th style="text-align:' + (aligns[k] || 'left') + '">' + inline(c) + '</th>').join('');
++ const rows = body.map(r =>
++ '<tr>' + r.map((c, k) => '<td style="text-align:' + (aligns[k] || 'left') + '">' + inline(c) + '</td>').join('') + '</tr>'
++ ).join('');
++ out.push('<table class="md-table"><thead><tr>' + th + '</tr></thead><tbody>' + rows + '</tbody></table>');
++ i = j - 1;
++ continue;
++ }
++
++ let m;
++ if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
++ flushPara(); closeList();
++ const lvl = m[1].length;
++ out.push('<h' + lvl + ' class="md-h">' + inline(m[2]) + '</h' + lvl + '>');
++ } else if (/^(---+|\*\*\*+|___+)$/.test(line.trim())) {
++ flushPara(); closeList(); out.push('<hr class="md-hr">');
++ } else if ((m = line.match(/^\s*&gt;\s?(.*)$/))) {
++ flushPara(); closeList(); out.push('<blockquote class="md-quote">' + inline(m[1]) + '</blockquote>');
++ } else if ((m = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/))) {
++ flushPara();
++ if (list !== 'ul') { closeList(); out.push('<ul class="md-list md-tasks">'); list = 'ul'; }
++ const done = m[1].toLowerCase() === 'x';
++ out.push('<li class="md-task"><input type="checkbox" disabled' + (done ? ' checked' : '') + '> ' + inline(m[2]) + '</li>');
++ } else if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) {
++ flushPara();
++ if (list !== 'ul') { closeList(); out.push('<ul class="md-list">'); list = 'ul'; }
++ out.push('<li>' + inline(m[1]) + '</li>');
++ } else if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
++ flushPara();
++ if (list !== 'ol') { closeList(); out.push('<ol class="md-list">'); list = 'ol'; }
++ out.push('<li>' + inline(m[1]) + '</li>');
++ } else {
++ closeList();
++ para.push(line.trim());
++ }
++ }
++ flushPara(); closeList();
++
++ // Restore the code fences last, so nothing above could have altered them.
++ return out.join('\n').replace(/ FENCE(\d+) /g, (_m, i) => fences[Number(i)] || '');
++}
++
++/** Flip a markdown paste between the rendered view and the raw source. */
++function togglePasteMarkdown() {
++ const rendered = document.getElementById('paste-md-rendered');
++ const raw = document.getElementById('paste-md-raw');
++ const btn = document.getElementById('paste-md-toggle');
++ if (!rendered || !raw) return;
++ const showRaw = rendered.style.display !== 'none';
++ rendered.style.display = showRaw ? 'none' : '';
++ raw.style.display = showRaw ? '' : 'none';
++ if (btn) {
++ btn.innerHTML = showRaw
++ ? '<i class="fa-solid fa-eye"></i> Rendered'
++ : '<i class="fa-solid fa-code"></i> Raw';
++ btn.title = showRaw ? 'Show formatted markdown' : 'Show raw markdown source';
++ }
++ try { localStorage.setItem('paste-md-raw', showRaw ? '1' : '0'); } catch { /* */ }
++}
++
// ── View single paste ───────────────────────────────────────
async function loadPasteViewer(slug) {
const container = document.getElementById('paste-viewer-content');
@@@ -297,18 +430,32 @@ async function loadPasteViewer(slug) {
const lines = (p.content || '').split('\n');
const lineNums = lines.map((_, i) => `<span>${i + 1}</span>`).join('\n');
const highlighted = highlightSyntax(p.content || '', p.language || 'text');
++ // Markdown reads as a document, not as source, so render it by default and
++ // offer the raw text behind a toggle. The choice is remembered, because someone
++ // reading markdown as source usually wants that for the next one too.
++ const isMarkdown = String(p.language || '').toLowerCase() === 'markdown';
++ let mdRawPref = false;
++ try { mdRawPref = localStorage.getItem('paste-md-raw') === '1'; } catch { /* */ }
++ const mdToggle = isMarkdown ? `
++ <button class="btn btn-outline btn-sm" id="paste-md-toggle" onclick="togglePasteMarkdown()"
++ title="${mdRawPref ? 'Show formatted markdown' : 'Show raw markdown source'}">
++ <i class="fa-solid ${mdRawPref ? 'fa-eye' : 'fa-code'}"></i> ${mdRawPref ? 'Rendered' : 'Raw'}
++ </button>` : '';
contentHtml = `
<div class="paste-code-view">
<div class="paste-code-header">
<span class="paste-code-lang"><i class="fa-solid fa-code"></i> ${escapeHtml(p.language || 'text')}</span>
<span class="paste-code-lines">${lines.length} line${lines.length !== 1 ? 's' : ''}</span>
<div class="paste-code-actions">
++ ${mdToggle}
<button class="btn btn-outline btn-sm" onclick="copyPasteContent()" title="Copy"><i class="fa-solid fa-copy"></i> Copy</button>
<a class="btn btn-outline btn-sm" href="/api/pastes/${p.slug}/raw" target="_blank" title="Raw"><i class="fa-solid fa-file-lines"></i> Raw</a>
<button class="btn btn-outline btn-sm" onclick="forkPaste('${p.slug}')" title="Fork"><i class="fa-solid fa-code-fork"></i> Fork</button>
</div>
</div>
-- <div class="paste-code-body">
++ ${isMarkdown ? `
++ <div class="paste-md-body" id="paste-md-rendered" style="${mdRawPref ? 'display:none' : ''}">${renderMarkdown(p.content || '')}</div>` : ''}
++ <div class="paste-code-body" ${isMarkdown ? `id="paste-md-raw" style="${mdRawPref ? '' : 'display:none'}"` : ''}>
<div class="paste-line-numbers">${lineNums}</div>
<pre class="paste-code-pre"><code id="paste-code-content">${highlighted}</code></pre>
</div>