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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user