/* * Minimal, self-hosted Markdown renderer for task descriptions. * Safe by construction: every piece of source text is HTML-escaped BEFORE any * markup is applied, and only a fixed whitelist of tags is emitted * (h4–h6, p, br, ul/ol/li, strong, em, a[href to http/https/mailto]). * Equipment [[markers]] and auto-linked terms are intentionally LEFT as text * here — the app layers those on afterward via a safe text-node pass. * Exposes a global: window.MD.render(text) -> html string. */ (function () { 'use strict'; function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Inline formatting on already-escaped text. function inline(escaped) { var s = escaped; // links: [label](http(s)://…, mailto:…, or a site-relative /doc-files/… upload) // — label already escaped. /doc-files is the ONLY relative prefix allowed, so // the href whitelist stays as tight as before. s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+|\/doc-files\/[^\s)]+)\)/g, function (m, label, url) { var cls = url.indexOf('/doc-files/') === 0 ? ' class="doc-link"' : ''; return '' + label + ''; }); // bold+italic ***text*** (must run before the ** and * rules) s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '$1'); // bold **text** s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); // italic *text* / _text_ (require a boundary so it doesn't eat stray asterisks) s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?])/g, '$1$2'); s = s.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?])/g, '$1$2'); return s; } // CANONICAL task-list line regex. MUST stay byte-identical to server.js TASK_LINE_RE and // app.js parseTaskList — the count/index/label must agree across all three or per-location // checkmarks misalign. Group 1 = mark char ( / x / X), group 2 = label (may be empty). var TASK_RE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/; function render(src) { var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n'); var out = []; var para = []; var taskIndex = 0; // 0-based position among task-list lines, in document order (per render) function flushPara() { if (para.length) { out.push('

' + para.map(inline).join('
') + '

'); para = []; } } var i = 0; while (i < lines.length) { var raw = lines[i]; if (raw.trim() === '') { flushPara(); i++; continue; } var hm = /^(#{1,3})\s+(.*)$/.exec(raw); if (hm) { flushPara(); var lvl = hm[1].length + 3; out.push('' + inline(escapeHtml(hm[2])) + ''); i++; continue; } if (/^\s*[-*]\s+/.test(raw)) { flushPara(); var ul = []; while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { var tm = TASK_RE.exec(lines[i]); if (tm) { // A checkbox line "- [ ] label". The box is an EMPTY, non-editable span (glyph // drawn in CSS — no unicode/emoji, iPad-safe) so it adds zero text: the WYSIWYG // round-trip (htmlToMarkdown) and equipment auto-linking both stay clean. The // label runs through inline(escapeHtml(...)) IDENTICALLY to a plain bullet, so // **bold**, links and equipment terms all work inside it. data-checked is the // AUTHORED mark (round-trip fidelity); the live view overrides the visual from // per-location state, keyed by data-task-index. var on = tm[1].toLowerCase() === 'x'; ul.push('
  • ' + '' + '' + inline(escapeHtml(tm[2])) + '
  • '); } else { ul.push('
  • ' + inline(escapeHtml(lines[i].replace(/^\s*[-*]\s+/, ''))) + '
  • '); } i++; } out.push(''); continue; } if (/^\s*\d+\.\s+/.test(raw)) { flushPara(); var ol = []; while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { ol.push('
  • ' + inline(escapeHtml(lines[i].replace(/^\s*\d+\.\s+/, ''))) + '
  • '); i++; } out.push('
      ' + ol.join('') + '
    '); continue; } para.push(escapeHtml(raw)); i++; } flushPara(); return out.join(''); } window.MD = { render: render, escapeHtml: escapeHtml }; })();