Files

42 lines
1.5 KiB
JavaScript

/* md.js — tiny, safe markdown subset for user-authored copy.
* Supports: # headings, **bold**, *italic*, [links](url), - lists, blank-line paragraphs.
* Everything is HTML-escaped FIRST, so authored text can never inject markup.
*/
const esc = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
export function renderMarkdown(src) {
const lines = esc(src || '').split(/\r?\n/);
const out = [];
let para = [], list = null;
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
const flushList = () => { if (list) { out.push(`<ul>${list.join('')}</ul>`); list = null; } };
for (const raw of lines) {
const line = raw.trim();
if (!line) { flushPara(); flushList(); continue; }
const h = line.match(/^(#{1,3})\s+(.*)$/);
if (h) { flushPara(); flushList(); const n = h[1].length + 1; out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
const li = line.match(/^[-*]\s+(.*)$/);
if (li) { flushPara(); (list ||= []).push(`<li>${inline(li[1])}</li>`); continue; }
flushList();
para.push(line);
}
flushPara(); flushList();
return out.join('\n');
}
function inline(t) {
return t
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// links: only http(s) and site-relative, so no javascript: URLs
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}