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)

OP OpenVibers committed 17 days ago · cf353a2a46 · 6 files, +215 −21

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 0db4017..a63fb8b 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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) ── */
ddiff --git a/public/js/community.js b/public/js/community.js
iindex 156417a..15a5f9b 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -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);
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
iindex d9ad053..55c351e 100644
---- a/server/render/markdown.js
++++ b/server/render/markdown.js
@@@ -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, '')
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex d86c478..04a9412 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 = 'ne
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
// 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">
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
iindex d885bc3..bbfa2f6 100644
---- a/test/markdown.test.js
++++ b/test/markdown.test.js
@@@ -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 &lt; 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();
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
iindex 31d6bee..c723a73 100644
---- a/test/ssr-community.test.js
++++ b/test/ssr-community.test.js
@@@ -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}`],