openvibers/openvibe.community Commits
Markdown pastes read as documents (source a click away); the renderer gains pipe tables and nested lists; prose pastes wrap by default; links in Markdown are underlined (#147)
Changes
@@@ -210,6 +210,33 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }.md hr { border: 0; border-top: 1px solid var(--border); }++.md li > ul, .md li > ol { margin: .25em 0 .35em; }++/* Links in running text are underlined, not told apart by colour alone (axe link-in-text-block, WCAG 1.4.1). */++.md a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: .18em; }++.md a:hover { text-decoration-thickness: 2px; }++.md-table { overflow-x: auto; margin: .9em 0; border: 1px solid var(--border); border-radius: 9px; }++.md-table:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; }++.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; min-width: 7em; }++.md-table thead th { background: var(--bg-input); color: var(--text-primary); font-weight: 600; }++.md-table tbody tr:last-child td { border-bottom: 0; }++.md-table .al-c { text-align: center; }++.md-table .al-r { text-align: right; }++/* A Markdown paste read as a document */++.paste-doc { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 26px 22px; margin: 12px 0 14px; line-height: 1.65; }++.paste-doc h2, .paste-doc h3, .paste-doc h4 { line-height: 1.3; margin: 1.3em 0 .5em; }++.paste-doc h2 { font-size: 1.35rem; }++.paste-doc h3 { font-size: 1.15rem; }++.paste-doc h4 { font-size: 1rem; }++.paste-doc ul, .paste-doc ol { padding-left: 1.4em; }++/* Running text keeps a readable measure on wide screens; tables and code use the full width. */++.paste-doc > p, .paste-doc > ul, .paste-doc > ol, .paste-doc > blockquote, .paste-doc > h2, .paste-doc > h3, .paste-doc > h4 { max-width: 80ch; }++.paste-doc a { word-break: break-word; }++.paste-source { margin: 0 0 18px; }++.paste-source > summary { cursor: pointer; display: inline-flex; align-items: center; gap: 8px; padding: 6px 2px; color: var(--text-secondary); font-weight: 600; }++.paste-source > summary:hover { color: var(--text-primary); }++.paste-source[open] > summary { margin-bottom: 4px; }++.paste-source .code-wrap { margin-top: 4px; }.reply-form { margin-top: 18px; }/* Errors + footer host */@@@ -225,6 +252,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.code-wrap { grid-template-columns: 1fr; }.thread-row, .pulse-item { flex-wrap: wrap; }.thread-head { gap: 10px; }++ .paste-doc { padding: 2px 15px 16px; line-height: 1.6; }++ .paste-doc ul, .paste-doc ol { padding-left: 1.15em; }}/* ── A comment thread's own page (/c/:accessId) ── */@@@ -74,7 +74,8 @@var shot = $('.shot.nsfw');if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });var codeWrap = $('[data-code]');-- if (codeWrap) {++ // A Markdown paste shows its document; its source (inside "Markdown source") already wraps.++ if (codeWrap && !codeWrap.closest('.paste-source')) {var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);@@@ -1,23 +1,29 @@'use strict';/**-- * A small, safe Markdown renderer for forum posts.++ * A small, safe Markdown renderer for forum posts and Markdown pastes.** Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;* the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,-- * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep-- * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".-- * Fenced code blocks go through the same highlight.js wrapper pastes use (which escapes).++ * ul/ol/li, hr, pre/code, strong, em, del, a, table/thead/tbody/tr/th/td in a scrolling div).++ * Raw HTML in a post is shown as text. Links keep only http(s), mailto, same-site paths and++ * #fragments, and carry rel="nofollow ugc noopener". Fenced code blocks go through the same++ * highlight.js wrapper pastes use (which escapes).** Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so-- * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an-- * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare-- * https:// links. No images, tables, footnotes or raw HTML.++ * the page keeps one h1), > quotes, - / * / + and 1. lists (nested by indenting, MAX_DEPTH deep),++ * ---, ``` fences with an optional language, | pipe | tables | with :--: alignment, `code`,++ * **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare https:// links. No images,++ * footnotes or raw HTML.*/const { escapeHtml: esc, highlight } = require('./highlight');const MAX_SOURCE = 100_000;const MAX_QUOTE_DEPTH = 3;++// Quotes and lists nest inside each other this deep; anything deeper is shown as text.++const MAX_DEPTH = 4;++const MAX_TABLE_ROWS = 1000;++const MAX_TABLE_COLS = 64;// Every pattern here runs on untrusted text on each page view, so none may backtrack// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;@@@ -153,7 +159,55 @@ function isBlockStart(line) {return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);}--function blocks(lines, depth) {++/** Leading whitespace as columns (a tab counts 4). */++function indentOf(line) {++ let n = 0;++ for (const ch of line) {++ if (ch === ' ') n++;++ else if (ch === '\t') n += 4;++ else break;++ }++ return n;++}++++/** A table row's cells: split on pipes not escaped as \|, one optional outer pipe each side. */++function rowCells(line) {++ let t = line.trim();++ if (t.startsWith('|')) t = t.slice(1);++ if (t.endsWith('|') && !t.endsWith('\\|')) t = t.slice(0, -1);++ const cells = [];++ let cur = '';++ for (let k = 0; k < t.length; k++) {++ if (t[k] === '\\' && t[k + 1] === '|') { cur += '|'; k++; continue; }++ if (t[k] === '|') { cells.push(cur.trim()); cur = ''; continue; }++ cur += t[k];++ }++ cells.push(cur.trim());++ return cells;++}++++/** The alignments of a table's delimiter row (| --- | :-: | --: |), or null when the line is not one. */++function delimiterRow(line) {++ if (!line || !line.includes('|') || !/^[\s|:-]+$/.test(line)) return null;++ const cells = rowCells(line);++ const aligns = [];++ for (const c of cells) {++ if (!/^:?-+:?$/.test(c)) return null;++ aligns.push(c.startsWith(':') && c.endsWith(':') ? 'c' : c.endsWith(':') ? 'r' : c.startsWith(':') ? 'l' : '');++ }++ return aligns;++}++++/** A pipe table starting at lines[i]: a header row whose cells match the delimiter row under it. */++function tableAt(lines, i) {++ if (!lines[i].includes('|')) return null;++ const aligns = delimiterRow(lines[i + 1]);++ if (!aligns || aligns.length > MAX_TABLE_COLS) return null;++ const head = rowCells(lines[i]);++ return head.length === aligns.length ? { head, aligns } : null;++}++++function blocks(lines, depth, level = (n) => n + 2) {const out = [];let i = 0;while (i < lines.length) {@@@ -175,45 +229,92 @@ function blocks(lines, depth) {}if (HR.test(line)) { out.push('<hr>'); i++; continue; }m = headingMatch(line);-- if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }++ if (m) { const h = Math.min(level(m[1].length), 6); out.push(`<h${h}>${inline(m[2])}</h${h}>`); i++; continue; }++ const table = tableAt(lines, i);++ if (table) {++ const { head, aligns } = table;++ const rows = [];++ i += 2;++ while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !isBlockStart(lines[i]) && rows.length < MAX_TABLE_ROWS) rows.push(rowCells(lines[i++]));++ const cell = (tag, text, a) => `<${tag}${a ? ` class="al-${a}"` : ''}>${inline(text || '')}</${tag}>`;++ // A wide table scrolls inside its own box on a phone, so it is a labelled region keyboard users can reach.++ out.push(`<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>${head.map((h, k) => cell('th', h, aligns[k])).join('')}</tr></thead>`++ + `<tbody>${rows.map((r) => `<tr>${aligns.map((a, k) => cell('td', r[k], a)).join('')}</tr>`).join('')}</tbody></table></div>`);++ continue;++ }if (QUOTE.test(line)) {const inner = [];while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);-- out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);++ out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1, level)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);continue;}if (UL.test(line) || OL.test(line)) {const ordered = !UL.test(line);const re = ordered ? OL : UL;const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;++ const base = indentOf(line);++ // Each item: its own text, then what is indented under it from the first nested block on (a list, a fence,++ // a quote), rendered as blocks of its own.const items = [];while (i < lines.length && lines[i].trim()) {-- const mm = lines[i].match(re);-- if (mm) items.push(ordered ? mm[2] : mm[1]);-- else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;-- else break;++ const l = lines[i];++ const ind = indentOf(l);++ const mm = ind < base + 2 ? l.match(re) : null;++ if (mm) {++ const text = ordered ? mm[2] : mm[1];++ items.push({ head: [text], sub: [], at: l.length - text.length });++ } else if (ind >= base + 2 && items.length) {++ const it = items[items.length - 1];++ const under = ' '.repeat(Math.max(0, ind - it.at)) + l.trim();++ if (it.sub.length || isBlockStart(under)) it.sub.push(under);++ else it.head.push(l.trim());++ } else break;i++;}const tag = ordered ? 'ol' : 'ul';-- out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);++ const sub = (lines2) => (depth < MAX_DEPTH ? blocks(lines2, depth + 1, level) : `<p>${inline(lines2.map((x) => x.trim()).join('\n'))}</p>`);++ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it.head.join('\n'))}${it.sub.length ? sub(it.sub) : ''}</li>`).join('')}</${tag}>`);continue;}const para = [];-- while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);++ while (i < lines.length && lines[i].trim() && (!para.length || (!isBlockStart(lines[i]) && !tableAt(lines, i)))) para.push(lines[i++]);out.push(`<p>${inline(para.join('\n'))}</p>`);}return out.join('\n');}--/** Markdown source → safe HTML (see the header for what is supported). */--function renderMarkdown(source) {++/**++ * Markdown source → safe HTML (see the header for what is supported). headingShift demotes headings: 2 for a forum post++ * (# → h3, under the thread's h1 and the post's h2), 1 for a document that is the page's main content (# → h2).++ * document: the levels the source uses are numbered in order from there, so a # followed by ### reads h2 then h3++ * and the outline never skips a level (axe heading-order).++ */++function renderMarkdown(source, { headingShift = 2, document = false } = {}) {const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');-- return blocks(text.split('\n'), 0);++ const lines = text.split('\n');++ const shift = Math.max(1, Math.min(4, headingShift));++ let level = (n) => n + shift;++ if (document) {++ const used = new Set();++ let fence = null;++ for (const line of lines) {++ const f = line.trimEnd().match(FENCE_RE);++ if (f && (!fence || f[1][0] === fence[0])) { fence = fence ? null : f[1]; continue; }++ if (fence) continue;++ const m = headingMatch(line);++ if (m) used.add(m[1].length);++ }++ const rank = new Map([...used].sort((a, b) => a - b).map((n, k) => [n, k]));++ level = (n) => shift + 1 + (rank.has(n) ? rank.get(n) : n - 1);++ }++ return blocks(lines, 0, level);}/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */function markdownToText(source, max = 300) {const t = String(source == null ? '' : source).replace(/\u0000/g, '')++ .split('\n').filter((l) => !(l.includes('|') && /^[\s|:-]+$/.test(l))).join('\n') // table delimiter rows++ .replace(/^ {0,3}\|/gm, '').replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '').replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1').replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')@@@ -12,6 +12,10 @@ const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');++const { renderMarkdown } = require('./markdown');++++// Languages read as prose: their lines wrap by default (the Wrap button still toggles), so a phone shows the whole text.++const PROSE = new Set(['markdown', 'plaintext', 'text']);const NETWORK_URL = 'https://openvibe.network';@@@ -355,6 +359,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));const hl = isShot ? null : highlight(p.content, p.language);const ext = extensionFor(p.language);++ const isDoc = !!hl && hl.language === 'markdown';let bodyBlock;if (isShot) {@@@ -366,10 +371,17 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'neconst gutter = Array.from({ length: hl.lines }, (_, i) => i + 1).join('\n');// It scrolls (long lines, long pastes), so keyboard users must be able to reach and scroll it: a labelled region// in the tab order (axe scrollable-region-focusable, the browser check of 2026-10-27 on /p/icy-moon-90).-- bodyBlock = `<div class="code-wrap" data-code tabindex="0" role="region" aria-label="Paste content">++ const code = `<div class="code-wrap${PROSE.has(hl.language) ? ' wrap' : ''}" data-code tabindex="0" role="region" aria-label="${isDoc ? 'Markdown source' : 'Paste content'}"><pre class="gutter" aria-hidden="true">${gutter}</pre><pre class="code"><code class="hljs language-${esc(hl.language)}" id="paste-content">${hl.html}</code></pre></div>`;++ // A Markdown paste is a document: shown formatted (the same safe renderer as forum posts), its source a click++ // away. Copy, Raw and Download still give the source.++ bodyBlock = isDoc ? `<div class="md paste-doc">${renderMarkdown(p.content, { headingShift: 1, document: true })}</div>++ <details class="paste-source">++ <summary><i class="fa-solid fa-code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>++ ${code}++ </details>` : code;}const actions = `<div class="actions">@@@ -25,6 +25,39 @@ const { check, done } = require('./helpers/app');assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');});++ await check('pipe tables: header, alignment, inline formatting, escaped pipes; scrolling region', () => {++ const t = md('Intro line\n| Delay | Test | **Result** |\n|:---|:-:|--:|\n| queue | `code` | ok \\| fine |\n| pipe | [x](https://a.b) |');++ assert.strictEqual(t, '<p>Intro line</p>\n<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>'++ + '<th class="al-l">Delay</th><th class="al-c">Test</th><th class="al-r"><strong>Result</strong></th></tr></thead><tbody>'++ + '<tr><td class="al-l">queue</td><td class="al-c"><code>code</code></td><td class="al-r">ok | fine</td></tr>'++ + '<tr><td class="al-l">pipe</td><td class="al-c"><a href="https://a.b" rel="nofollow ugc noopener">x</a></td><td class="al-r"></td></tr>'++ + '</tbody></table></div>', 'a short row gets empty cells');++ // Without a delimiter row, or with cells that do not match it, pipes are just text.++ assert.strictEqual(md('a | b\nc | d'), '<p>a | b<br>\nc | d</p>');++ assert.strictEqual(md('| a | b |\n| --- |'), '<p>| a | b |<br>\n| --- |</p>');++ // Cells are escaped like any text.++ assert.ok(!md('| x |\n| - |\n| <script>alert(1)</script> |').includes('<script>'));++ });++++ await check('a document numbers the heading levels it uses in order; a post keeps the fixed demotion', () => {++ assert.strictEqual(md('# T\n### A\n#### B\n### C', { headingShift: 1, document: true }), '<h2>T</h2>\n<h3>A</h3>\n<h4>B</h4>\n<h3>C</h3>');++ assert.strictEqual(md('### only', { headingShift: 1, document: true }), '<h2>only</h2>');++ // A # inside a fence is code, not a heading level.++ assert.strictEqual(md('```\n# not a heading\n```\n### Real', { headingShift: 1, document: true }).endsWith('<h2>Real</h2>'), true);++ assert.strictEqual(md('# T\n### A'), '<h3>T</h3>\n<h5>A</h5>', 'forum posts unchanged');++ });++++ await check('lists nest by indenting; continuation lines stay in their item', () => {++ assert.strictEqual(md('- a\n - a1\n - a2\n- b'), '<ul><li>a<ul><li>a1</li><li>a2</li></ul></li><li>b</li></ul>');++ assert.strictEqual(md('1. one\n - x\n more x\n2. two'), '<ol><li>one<ul><li>x<br>\nmore x</li></ul></li><li>two</li></ol>');++ assert.strictEqual(md('- a\n - deep\n- b'), '<ul><li>a<ul><li>deep</li></ul></li><li>b</li></ul>');++ assert.strictEqual(md('- code:\n ```\n x < 1\n ```'), '<ul><li>code:<pre class="md-code"><code class="hljs language-plaintext">x < 1</code></pre></li></ul>');++ // Past MAX_DEPTH the rest is shown as text, never deeper markup.++ const deep = md(Array.from({ length: 12 }, (_, k) => `${' '.repeat(k)}- l${k}`).join('\n'));++ assert.ok((deep.match(/<ul>/g) || []).length <= 5, deep);++ assert.ok(deep.includes('l11'), 'nothing is dropped');++ });++await check('raw HTML is text, never markup', () => {for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {const out = md(evil);@@@ -57,6 +90,10 @@ const { check, done } = require('./helpers/app');underscore: ' __x'.repeat(10000),strike: '~~x '.repeat(10000),codeRuns,++ wideTable: `${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'|b'.repeat(20000)}|`,++ tallTable: `| a | b |\n| - | - |\n${'| x | y |\n'.repeat(20000)}`,++ pipes: `${'| '.repeat(20000)}\n${'|-'.repeat(20000)}`,++ nested: Array.from({ length: 4000 }, (_, k) => `${' '.repeat(k % 40)}- item ${k}`).join('\n'),};for (const [name, src] of Object.entries(cases)) {const started = Date.now();@@@ -93,6 +93,20 @@ const { boot, check, done } = require('./helpers/app');assert.ok(!lang.text.includes(`href="/p/${js}"`));});++ await check('a Markdown paste reads as a document, its source a click away; code pastes are unchanged', async () => {++ const doc = (await svc.createText(anon, { title: 'Notes', content: '# Notes\n\n### Part\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n- x\n - y\n\n<script>alert(1)</script>', language: 'markdown' })).slug;++ const r = await t.get(`/p/${doc}`);++ assert.strictEqual(r.status, 200);++ has(r.text, '<div class="md paste-doc"><h2>Notes</h2>\n<h3>Part</h3>');++ has(r.text, '<td class="al-r">2</td>');++ has(r.text, '<li>x<ul><li>y</li></ul></li>');++ has(r.text, '<details class="paste-source">');++ has(r.text, '<div class="code-wrap wrap" data-code tabindex="0" role="region" aria-label="Markdown source">');++ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'raw HTML stays text');++ const code = await t.get(`/p/${js}`);++ assert.ok(!code.text.includes('paste-doc') && code.text.includes('<div class="code-wrap" data-code'), 'code: no document, no default wrap');++ });++await check('a link that picked up trailing punctuation (**, a full stop, a bracket) redirects to the paste', async () => {for (const [path, to] of [[`/p/${js}**`, `/p/${js}`],
@@ -210,6 +210,33 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -210,6 +210,33 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }
.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }
.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }
.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }
.md hr { border: 0; border-top: 1px solid var(--border); }
.md hr { border: 0; border-top: 1px solid var(--border); }
+.md li > ul, .md li > ol { margin: .25em 0 .35em; }
+/* Links in running text are underlined, not told apart by colour alone (axe link-in-text-block, WCAG 1.4.1). */
+.md a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: .18em; }
+.md a:hover { text-decoration-thickness: 2px; }
+.md-table { overflow-x: auto; margin: .9em 0; border: 1px solid var(--border); border-radius: 9px; }
+.md-table:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; }
+.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; min-width: 7em; }
+.md-table thead th { background: var(--bg-input); color: var(--text-primary); font-weight: 600; }
+.md-table tbody tr:last-child td { border-bottom: 0; }
+.md-table .al-c { text-align: center; }
+.md-table .al-r { text-align: right; }
+/* A Markdown paste read as a document */
+.paste-doc { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 26px 22px; margin: 12px 0 14px; line-height: 1.65; }
+.paste-doc h2, .paste-doc h3, .paste-doc h4 { line-height: 1.3; margin: 1.3em 0 .5em; }
+.paste-doc h2 { font-size: 1.35rem; }
+.paste-doc h3 { font-size: 1.15rem; }
+.paste-doc h4 { font-size: 1rem; }
+.paste-doc ul, .paste-doc ol { padding-left: 1.4em; }
+/* Running text keeps a readable measure on wide screens; tables and code use the full width. */
+.paste-doc > p, .paste-doc > ul, .paste-doc > ol, .paste-doc > blockquote, .paste-doc > h2, .paste-doc > h3, .paste-doc > h4 { max-width: 80ch; }
+.paste-doc a { word-break: break-word; }
+.paste-source { margin: 0 0 18px; }
+.paste-source > summary { cursor: pointer; display: inline-flex; align-items: center; gap: 8px; padding: 6px 2px; color: var(--text-secondary); font-weight: 600; }
+.paste-source > summary:hover { color: var(--text-primary); }
+.paste-source[open] > summary { margin-bottom: 4px; }
+.paste-source .code-wrap { margin-top: 4px; }
.reply-form { margin-top: 18px; }
.reply-form { margin-top: 18px; }
/* Errors + footer host */
/* Errors + footer host */
@@ -225,6 +252,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -225,6 +252,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.code-wrap { grid-template-columns: 1fr; }
.code-wrap { grid-template-columns: 1fr; }
.thread-row, .pulse-item { flex-wrap: wrap; }
.thread-row, .pulse-item { flex-wrap: wrap; }
.thread-head { gap: 10px; }
.thread-head { gap: 10px; }
+ .paste-doc { padding: 2px 15px 16px; line-height: 1.6; }
+ .paste-doc ul, .paste-doc ol { padding-left: 1.15em; }
}
}
/* ── A comment thread's own page (/c/:accessId) ── */
/* ── A comment thread's own page (/c/:accessId) ── */
@@ -74,7 +74,8 @@
@@ -74,7 +74,8 @@
var shot = $('.shot.nsfw');
var shot = $('.shot.nsfw');
if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
var codeWrap = $('[data-code]');
var codeWrap = $('[data-code]');
- if (codeWrap) {
+ // A Markdown paste shows its document; its source (inside "Markdown source") already wraps.
+ if (codeWrap && !codeWrap.closest('.paste-source')) {
var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';
var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';
wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });
wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });
var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
@@ -1,23 +1,29 @@
@@ -1,23 +1,29 @@
'use strict';
'use strict';
/**
/**
- * A small, safe Markdown renderer for forum posts.
+ * A small, safe Markdown renderer for forum posts and Markdown pastes.
*
*
* Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;
* Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;
* the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,
* the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,
- * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep
- * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".
- * Fenced code blocks go through the same highlight.js wrapper pastes use (which escapes).
+ * ul/ol/li, hr, pre/code, strong, em, del, a, table/thead/tbody/tr/th/td in a scrolling div).
+ * Raw HTML in a post is shown as text. Links keep only http(s), mailto, same-site paths and
+ * #fragments, and carry rel="nofollow ugc noopener". Fenced code blocks go through the same
+ * highlight.js wrapper pastes use (which escapes).
*
*
* Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so
* Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so
- * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an
- * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare
- * https:// links. No images, tables, footnotes or raw HTML.
+ * the page keeps one h1), > quotes, - / * / + and 1. lists (nested by indenting, MAX_DEPTH deep),
+ * ---, ``` fences with an optional language, | pipe | tables | with :--: alignment, `code`,
+ * **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare https:// links. No images,
+ * footnotes or raw HTML.
*/
*/
const { escapeHtml: esc, highlight } = require('./highlight');
const { escapeHtml: esc, highlight } = require('./highlight');
const MAX_SOURCE = 100_000;
const MAX_SOURCE = 100_000;
const MAX_QUOTE_DEPTH = 3;
const MAX_QUOTE_DEPTH = 3;
+// Quotes and lists nest inside each other this deep; anything deeper is shown as text.
+const MAX_DEPTH = 4;
+const MAX_TABLE_ROWS = 1000;
+const MAX_TABLE_COLS = 64;
// Every pattern here runs on untrusted text on each page view, so none may backtrack
// Every pattern here runs on untrusted text on each page view, so none may backtrack
// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.
// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;
@@ -153,7 +159,55 @@ function isBlockStart(line) {
@@ -153,7 +159,55 @@ function isBlockStart(line) {
return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
}
}
-function blocks(lines, depth) {
+/** Leading whitespace as columns (a tab counts 4). */
+function indentOf(line) {
+ let n = 0;
+ for (const ch of line) {
+ if (ch === ' ') n++;
+ else if (ch === '\t') n += 4;
+ else break;
+ }
+ return n;
+}
+
+/** A table row's cells: split on pipes not escaped as \|, one optional outer pipe each side. */
+function rowCells(line) {
+ let t = line.trim();
+ if (t.startsWith('|')) t = t.slice(1);
+ if (t.endsWith('|') && !t.endsWith('\\|')) t = t.slice(0, -1);
+ const cells = [];
+ let cur = '';
+ for (let k = 0; k < t.length; k++) {
+ if (t[k] === '\\' && t[k + 1] === '|') { cur += '|'; k++; continue; }
+ if (t[k] === '|') { cells.push(cur.trim()); cur = ''; continue; }
+ cur += t[k];
+ }
+ cells.push(cur.trim());
+ return cells;
+}
+
+/** The alignments of a table's delimiter row (| --- | :-: | --: |), or null when the line is not one. */
+function delimiterRow(line) {
+ if (!line || !line.includes('|') || !/^[\s|:-]+$/.test(line)) return null;
+ const cells = rowCells(line);
+ const aligns = [];
+ for (const c of cells) {
+ if (!/^:?-+:?$/.test(c)) return null;
+ aligns.push(c.startsWith(':') && c.endsWith(':') ? 'c' : c.endsWith(':') ? 'r' : c.startsWith(':') ? 'l' : '');
+ }
+ return aligns;
+}
+
+/** A pipe table starting at lines[i]: a header row whose cells match the delimiter row under it. */
+function tableAt(lines, i) {
+ if (!lines[i].includes('|')) return null;
+ const aligns = delimiterRow(lines[i + 1]);
+ if (!aligns || aligns.length > MAX_TABLE_COLS) return null;
+ const head = rowCells(lines[i]);
+ return head.length === aligns.length ? { head, aligns } : null;
+}
+
+function blocks(lines, depth, level = (n) => n + 2) {
const out = [];
const out = [];
let i = 0;
let i = 0;
while (i < lines.length) {
while (i < lines.length) {
@@ -175,45 +229,92 @@ function blocks(lines, depth) {
@@ -175,45 +229,92 @@ function blocks(lines, depth) {
}
}
if (HR.test(line)) { out.push('<hr>'); i++; continue; }
if (HR.test(line)) { out.push('<hr>'); i++; continue; }
m = headingMatch(line);
m = headingMatch(line);
- if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }
+ if (m) { const h = Math.min(level(m[1].length), 6); out.push(`<h${h}>${inline(m[2])}</h${h}>`); i++; continue; }
+ const table = tableAt(lines, i);
+ if (table) {
+ const { head, aligns } = table;
+ const rows = [];
+ i += 2;
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !isBlockStart(lines[i]) && rows.length < MAX_TABLE_ROWS) rows.push(rowCells(lines[i++]));
+ const cell = (tag, text, a) => `<${tag}${a ? ` class="al-${a}"` : ''}>${inline(text || '')}</${tag}>`;
+ // A wide table scrolls inside its own box on a phone, so it is a labelled region keyboard users can reach.
+ out.push(`<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>${head.map((h, k) => cell('th', h, aligns[k])).join('')}</tr></thead>`
+ + `<tbody>${rows.map((r) => `<tr>${aligns.map((a, k) => cell('td', r[k], a)).join('')}</tr>`).join('')}</tbody></table></div>`);
+ continue;
+ }
if (QUOTE.test(line)) {
if (QUOTE.test(line)) {
const inner = [];
const inner = [];
while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
- out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);
+ out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1, level)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);
continue;
continue;
}
}
if (UL.test(line) || OL.test(line)) {
if (UL.test(line) || OL.test(line)) {
const ordered = !UL.test(line);
const ordered = !UL.test(line);
const re = ordered ? OL : UL;
const re = ordered ? OL : UL;
const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
+ const base = indentOf(line);
+ // Each item: its own text, then what is indented under it from the first nested block on (a list, a fence,
+ // a quote), rendered as blocks of its own.
const items = [];
const items = [];
while (i < lines.length && lines[i].trim()) {
while (i < lines.length && lines[i].trim()) {
- const mm = lines[i].match(re);
- if (mm) items.push(ordered ? mm[2] : mm[1]);
- else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
- else break;
+ const l = lines[i];
+ const ind = indentOf(l);
+ const mm = ind < base + 2 ? l.match(re) : null;
+ if (mm) {
+ const text = ordered ? mm[2] : mm[1];
+ items.push({ head: [text], sub: [], at: l.length - text.length });
+ } else if (ind >= base + 2 && items.length) {
+ const it = items[items.length - 1];
+ const under = ' '.repeat(Math.max(0, ind - it.at)) + l.trim();
+ if (it.sub.length || isBlockStart(under)) it.sub.push(under);
+ else it.head.push(l.trim());
+ } else break;
i++;
i++;
}
}
const tag = ordered ? 'ol' : 'ul';
const tag = ordered ? 'ol' : 'ul';
- out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);
+ const sub = (lines2) => (depth < MAX_DEPTH ? blocks(lines2, depth + 1, level) : `<p>${inline(lines2.map((x) => x.trim()).join('\n'))}</p>`);
+ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it.head.join('\n'))}${it.sub.length ? sub(it.sub) : ''}</li>`).join('')}</${tag}>`);
continue;
continue;
}
}
const para = [];
const para = [];
- while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);
+ while (i < lines.length && lines[i].trim() && (!para.length || (!isBlockStart(lines[i]) && !tableAt(lines, i)))) para.push(lines[i++]);
out.push(`<p>${inline(para.join('\n'))}</p>`);
out.push(`<p>${inline(para.join('\n'))}</p>`);
}
}
return out.join('\n');
return out.join('\n');
}
}
-/** Markdown source → safe HTML (see the header for what is supported). */
-function renderMarkdown(source) {
+/**
+ * Markdown source → safe HTML (see the header for what is supported). headingShift demotes headings: 2 for a forum post
+ * (# → h3, under the thread's h1 and the post's h2), 1 for a document that is the page's main content (# → h2).
+ * document: the levels the source uses are numbered in order from there, so a # followed by ### reads h2 then h3
+ * and the outline never skips a level (axe heading-order).
+ */
+function renderMarkdown(source, { headingShift = 2, document = false } = {}) {
const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
- return blocks(text.split('\n'), 0);
+ const lines = text.split('\n');
+ const shift = Math.max(1, Math.min(4, headingShift));
+ let level = (n) => n + shift;
+ if (document) {
+ const used = new Set();
+ let fence = null;
+ for (const line of lines) {
+ const f = line.trimEnd().match(FENCE_RE);
+ if (f && (!fence || f[1][0] === fence[0])) { fence = fence ? null : f[1]; continue; }
+ if (fence) continue;
+ const m = headingMatch(line);
+ if (m) used.add(m[1].length);
+ }
+ const rank = new Map([...used].sort((a, b) => a - b).map((n, k) => [n, k]));
+ level = (n) => shift + 1 + (rank.has(n) ? rank.get(n) : n - 1);
+ }
+ return blocks(lines, 0, level);
}
}
/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */
/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */
function markdownToText(source, max = 300) {
function markdownToText(source, max = 300) {
const t = String(source == null ? '' : source).replace(/\u0000/g, '')
const t = String(source == null ? '' : source).replace(/\u0000/g, '')
+ .split('\n').filter((l) => !(l.includes('|') && /^[\s|:-]+$/.test(l))).join('\n') // table delimiter rows
+ .replace(/^ {0,3}\|/gm, '')
.replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
.replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
.replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')
.replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')
.replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
.replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
@@ -12,6 +12,10 @@ const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
@@ -12,6 +12,10 @@ const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = require('./highlight');
+const { renderMarkdown } = require('./markdown');
+
+// Languages read as prose: their lines wrap by default (the Wrap button still toggles), so a phone shows the whole text.
+const PROSE = new Set(['markdown', 'plaintext', 'text']);
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -355,6 +359,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -355,6 +359,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
const hl = isShot ? null : highlight(p.content, p.language);
const hl = isShot ? null : highlight(p.content, p.language);
const ext = extensionFor(p.language);
const ext = extensionFor(p.language);
+ const isDoc = !!hl && hl.language === 'markdown';
let bodyBlock;
let bodyBlock;
if (isShot) {
if (isShot) {
@@ -366,10 +371,17 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -366,10 +371,17 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
const gutter = Array.from({ length: hl.lines }, (_, i) => i + 1).join('\n');
const gutter = Array.from({ length: hl.lines }, (_, i) => i + 1).join('\n');
// It scrolls (long lines, long pastes), so keyboard users must be able to reach and scroll it: a labelled region
// It scrolls (long lines, long pastes), so keyboard users must be able to reach and scroll it: a labelled region
// in the tab order (axe scrollable-region-focusable, the browser check of 2026-10-27 on /p/icy-moon-90).
// in the tab order (axe scrollable-region-focusable, the browser check of 2026-10-27 on /p/icy-moon-90).
- bodyBlock = `<div class="code-wrap" data-code tabindex="0" role="region" aria-label="Paste content">
+ const code = `<div class="code-wrap${PROSE.has(hl.language) ? ' wrap' : ''}" data-code tabindex="0" role="region" aria-label="${isDoc ? 'Markdown source' : 'Paste content'}">
<pre class="gutter" aria-hidden="true">${gutter}</pre>
<pre class="gutter" aria-hidden="true">${gutter}</pre>
<pre class="code"><code class="hljs language-${esc(hl.language)}" id="paste-content">${hl.html}</code></pre>
<pre class="code"><code class="hljs language-${esc(hl.language)}" id="paste-content">${hl.html}</code></pre>
</div>`;
</div>`;
+ // A Markdown paste is a document: shown formatted (the same safe renderer as forum posts), its source a click
+ // away. Copy, Raw and Download still give the source.
+ bodyBlock = isDoc ? `<div class="md paste-doc">${renderMarkdown(p.content, { headingShift: 1, document: true })}</div>
+ <details class="paste-source">
+ <summary><i class="fa-solid fa-code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>
+ ${code}
+ </details>` : code;
}
}
const actions = `<div class="actions">
const actions = `<div class="actions">
@@ -25,6 +25,39 @@ const { check, done } = require('./helpers/app');
@@ -25,6 +25,39 @@ const { check, done } = require('./helpers/app');
assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
});
});
+ await check('pipe tables: header, alignment, inline formatting, escaped pipes; scrolling region', () => {
+ const t = md('Intro line\n| Delay | Test | **Result** |\n|:---|:-:|--:|\n| queue | `code` | ok \\| fine |\n| pipe | [x](https://a.b) |');
+ assert.strictEqual(t, '<p>Intro line</p>\n<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>'
+ + '<th class="al-l">Delay</th><th class="al-c">Test</th><th class="al-r"><strong>Result</strong></th></tr></thead><tbody>'
+ + '<tr><td class="al-l">queue</td><td class="al-c"><code>code</code></td><td class="al-r">ok | fine</td></tr>'
+ + '<tr><td class="al-l">pipe</td><td class="al-c"><a href="https://a.b" rel="nofollow ugc noopener">x</a></td><td class="al-r"></td></tr>'
+ + '</tbody></table></div>', 'a short row gets empty cells');
+ // Without a delimiter row, or with cells that do not match it, pipes are just text.
+ assert.strictEqual(md('a | b\nc | d'), '<p>a | b<br>\nc | d</p>');
+ assert.strictEqual(md('| a | b |\n| --- |'), '<p>| a | b |<br>\n| --- |</p>');
+ // Cells are escaped like any text.
+ assert.ok(!md('| x |\n| - |\n| <script>alert(1)</script> |').includes('<script>'));
+ });
+
+ await check('a document numbers the heading levels it uses in order; a post keeps the fixed demotion', () => {
+ assert.strictEqual(md('# T\n### A\n#### B\n### C', { headingShift: 1, document: true }), '<h2>T</h2>\n<h3>A</h3>\n<h4>B</h4>\n<h3>C</h3>');
+ assert.strictEqual(md('### only', { headingShift: 1, document: true }), '<h2>only</h2>');
+ // A # inside a fence is code, not a heading level.
+ assert.strictEqual(md('```\n# not a heading\n```\n### Real', { headingShift: 1, document: true }).endsWith('<h2>Real</h2>'), true);
+ assert.strictEqual(md('# T\n### A'), '<h3>T</h3>\n<h5>A</h5>', 'forum posts unchanged');
+ });
+
+ await check('lists nest by indenting; continuation lines stay in their item', () => {
+ assert.strictEqual(md('- a\n - a1\n - a2\n- b'), '<ul><li>a<ul><li>a1</li><li>a2</li></ul></li><li>b</li></ul>');
+ assert.strictEqual(md('1. one\n - x\n more x\n2. two'), '<ol><li>one<ul><li>x<br>\nmore x</li></ul></li><li>two</li></ol>');
+ assert.strictEqual(md('- a\n - deep\n- b'), '<ul><li>a<ul><li>deep</li></ul></li><li>b</li></ul>');
+ assert.strictEqual(md('- code:\n ```\n x < 1\n ```'), '<ul><li>code:<pre class="md-code"><code class="hljs language-plaintext">x < 1</code></pre></li></ul>');
+ // Past MAX_DEPTH the rest is shown as text, never deeper markup.
+ const deep = md(Array.from({ length: 12 }, (_, k) => `${' '.repeat(k)}- l${k}`).join('\n'));
+ assert.ok((deep.match(/<ul>/g) || []).length <= 5, deep);
+ assert.ok(deep.includes('l11'), 'nothing is dropped');
+ });
+
await check('raw HTML is text, never markup', () => {
await check('raw HTML is text, never markup', () => {
for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {
for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {
const out = md(evil);
const out = md(evil);
@@ -57,6 +90,10 @@ const { check, done } = require('./helpers/app');
@@ -57,6 +90,10 @@ const { check, done } = require('./helpers/app');
underscore: ' __x'.repeat(10000),
underscore: ' __x'.repeat(10000),
strike: '~~x '.repeat(10000),
strike: '~~x '.repeat(10000),
codeRuns,
codeRuns,
+ wideTable: `${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'|b'.repeat(20000)}|`,
+ tallTable: `| a | b |\n| - | - |\n${'| x | y |\n'.repeat(20000)}`,
+ pipes: `${'| '.repeat(20000)}\n${'|-'.repeat(20000)}`,
+ nested: Array.from({ length: 4000 }, (_, k) => `${' '.repeat(k % 40)}- item ${k}`).join('\n'),
};
};
for (const [name, src] of Object.entries(cases)) {
for (const [name, src] of Object.entries(cases)) {
const started = Date.now();
const started = Date.now();
@@ -93,6 +93,20 @@ const { boot, check, done } = require('./helpers/app');
@@ -93,6 +93,20 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!lang.text.includes(`href="/p/${js}"`));
assert.ok(!lang.text.includes(`href="/p/${js}"`));
});
});
+ await check('a Markdown paste reads as a document, its source a click away; code pastes are unchanged', async () => {
+ const doc = (await svc.createText(anon, { title: 'Notes', content: '# Notes\n\n### Part\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n- x\n - y\n\n<script>alert(1)</script>', language: 'markdown' })).slug;
+ const r = await t.get(`/p/${doc}`);
+ assert.strictEqual(r.status, 200);
+ has(r.text, '<div class="md paste-doc"><h2>Notes</h2>\n<h3>Part</h3>');
+ has(r.text, '<td class="al-r">2</td>');
+ has(r.text, '<li>x<ul><li>y</li></ul></li>');
+ has(r.text, '<details class="paste-source">');
+ has(r.text, '<div class="code-wrap wrap" data-code tabindex="0" role="region" aria-label="Markdown source">');
+ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'raw HTML stays text');
+ const code = await t.get(`/p/${js}`);
+ assert.ok(!code.text.includes('paste-doc') && code.text.includes('<div class="code-wrap" data-code'), 'code: no document, no default wrap');
+ });
+
await check('a link that picked up trailing punctuation (**, a full stop, a bracket) redirects to the paste', async () => {
await check('a link that picked up trailing punctuation (**, a full stop, a bracket) redirects to the paste', async () => {
for (const [path, to] of [
for (const [path, to] of [
[`/p/${js}**`, `/p/${js}`],
[`/p/${js}**`, `/p/${js}`],