From dfa2ae12e81a4c5b802b035a48f5e7613ebbacc8 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 24 Jul 2026 16:29:44 +1000 Subject: [PATCH] Add safe markdown renderer for operator-authored landing copy --- public/js/md.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 public/js/md.js diff --git a/public/js/md.js b/public/js/md.js new file mode 100644 index 0000000..1177564 --- /dev/null +++ b/public/js/md.js @@ -0,0 +1,41 @@ +/* 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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + +export function renderMarkdown(src) { + const lines = esc(src || '').split(/\r?\n/); + const out = []; + let para = [], list = null; + + const flushPara = () => { if (para.length) { out.push(`

${inline(para.join(' '))}

`); para = []; } }; + const flushList = () => { if (list) { out.push(``); 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(`${inline(h[2])}`); continue; } + + const li = line.match(/^[-*]\s+(.*)$/); + if (li) { flushPara(); (list ||= []).push(`
  • ${inline(li[1])}
  • `); continue; } + + flushList(); + para.push(line); + } + flushPara(); flushList(); + return out.join('\n'); +} + +function inline(t) { + return t + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + // links: only http(s) and site-relative, so no javascript: URLs + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, + '$1'); +}