111 lines
4.8 KiB
JavaScript
111 lines
4.8 KiB
JavaScript
/*
|
||||
|
|
* 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, '"')
|
|||
|
|
.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 '<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 };
|
|||
|
|
})();
|