openvibers/openvibe.live Commits
Pastes: render markdown as a document, with a Raw toggle
Changes
@@@ -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; }++}@@@ -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>@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')++ .replace(/"/g, '"').replace(/'/g, ''');++}++++// 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(/&/g, '&'));++ return safe ? '<img class="md-img" src="' + safe + '" alt="' + alt + '">' : m;++ })++ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {++ const safe = _mdSafeUrl(url.replace(/&/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*>\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>
@@ -13429,3 +13429,91 @@ body.theme-light .user-dropdown {
@@ -13429,3 +13429,91 @@ body.theme-light .user-dropdown {
.controls-dock-btn i { font-size: 0.9rem; }
.controls-dock-btn i { font-size: 0.9rem; }
/* Keep the DMs FAB BEHIND the docked controls bar so it doesn't cover the buttons */
/* Keep the DMs FAB BEHIND the docked controls bar so it doesn't cover the buttons */
body.controls-docked .messenger-toggle { z-index: 1100; }
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; }
+}
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- 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/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<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
@@ -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/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=1" defer></script>
<script src="/js/ai-viewers.js?v=1" defer></script>
<script src="/js/powerchat.js?v=6" 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/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
@@ -213,6 +213,139 @@ function pastesSort(sort) {
@@ -213,6 +213,139 @@ function pastesSort(sort) {
fetchPastes();
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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+// 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(/&/g, '&'));
+ return safe ? '<img class="md-img" src="' + safe + '" alt="' + alt + '">' : m;
+ })
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
+ const safe = _mdSafeUrl(url.replace(/&/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*>\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 ───────────────────────────────────────
// ── View single paste ───────────────────────────────────────
async function loadPasteViewer(slug) {
async function loadPasteViewer(slug) {
const container = document.getElementById('paste-viewer-content');
const container = document.getElementById('paste-viewer-content');
@@ -297,18 +430,32 @@ async function loadPasteViewer(slug) {
@@ -297,18 +430,32 @@ async function loadPasteViewer(slug) {
const lines = (p.content || '').split('\n');
const lines = (p.content || '').split('\n');
const lineNums = lines.map((_, i) => `<span>${i + 1}</span>`).join('\n');
const lineNums = lines.map((_, i) => `<span>${i + 1}</span>`).join('\n');
const highlighted = highlightSyntax(p.content || '', p.language || 'text');
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 = `
contentHtml = `
<div class="paste-code-view">
<div class="paste-code-view">
<div class="paste-code-header">
<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-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>
<span class="paste-code-lines">${lines.length} line${lines.length !== 1 ? 's' : ''}</span>
<div class="paste-code-actions">
<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>
<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>
<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>
<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>
</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>
<div class="paste-line-numbers">${lineNums}</div>
<pre class="paste-code-pre"><code id="paste-code-content">${highlighted}</code></pre>
<pre class="paste-code-pre"><code id="paste-code-content">${highlighted}</code></pre>
</div>
</div>