Files
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
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>
2026-09-24 13:00:57 -04:00

111 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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, '&amp;')
.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 };
})();