The project has lived only as a folder on the Mac Studio and, briefly, as a transfer tarball. This is the first commit: the Express server and its web front end, the Capacitor iOS and Android wrappers, and the release and backup scripts. The repo is the source, not a backup of the machine. Left out on purpose, by the .gitignore files that were already in the tree: node_modules, build output, mobile/www (regenerated from server/public on every build), and server/data, which holds the live checklists and belongs on the NAS rather than in git. Every credential is excluded too — server/.env, the Play upload keystore and its properties file, the App Store Connect key and ios-signing.config. The one secret that was not in an ignored file was the NAS SSH password, hard-coded in build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read it from the login keychain (service checklist-nas, account Christian), falling back to NAS_PASS in the environment, so rotating it is one keychain entry rather than a history rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 };
|
||
})();
|