Files

111 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

/*
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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 '<a href="' + url + '"' + cls + ' target="_blank" rel="noopener noreferrer">' + label + '</a>';
});
// bold+italic ***text*** (must run before the ** and * rules)
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
// bold **text**
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
// italic *text* / _text_ (require a boundary so it doesn't eat stray asterisks)
s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?])/g, '$1<em>$2</em>');
s = s.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?])/g, '$1<em>$2</em>');
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('<p>' + para.map(inline).join('<br>') + '</p>'); 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('<h' + lvl + '>' + inline(escapeHtml(hm[2])) + '</h' + lvl + '>'); 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('<li class="md-task' + (on ? ' is-done' : '') + '" data-task-index="' + (taskIndex++) +
'" data-checked="' + (on ? 'true' : 'false') + '">' +
'<span class="md-task-box" contenteditable="false" aria-hidden="true"></span>' +
'<span class="md-task-label">' + inline(escapeHtml(tm[2])) + '</span></li>');
} else {
ul.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*[-*]\s+/, ''))) + '</li>');
}
i++;
}
out.push('<ul>' + ul.join('') + '</ul>');
continue;
}
if (/^\s*\d+\.\s+/.test(raw)) {
flushPara();
var ol = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
ol.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*\d+\.\s+/, ''))) + '</li>');
i++;
}
out.push('<ol>' + ol.join('') + '</ol>');
continue;
}
para.push(escapeHtml(raw));
i++;
}
flushPara();
return out.join('');
}
window.MD = { render: render, escapeHtml: escapeHtml };
})();